Compare commits

...

11 Commits

Author SHA1 Message Date
a22cbe0239 Merge branch 'ticket/add-events-on-change' into 'ticket-app-master'
Add Events when a ticket is updated, and trigger asynchronously post update events

See merge request Chill-Projet/chill-bundles!902
2025-10-16 12:34:12 +00:00
98902bdeb8 Add Events when a ticket is updated, and trigger asynchronously post update events 2025-10-16 12:34:12 +00:00
4765d4fe28 Merge branch '1677-create-ticket-list-for-user-file' into 'ticket-app-master'
Créer la page et la liste des tickets dans le dossier d'usager

See merge request Chill-Projet/chill-bundles!891
2025-10-15 11:06:04 +00:00
Boris Waaub
30bcb85549 Créer la page et la liste des tickets dans le dossier d'usager 2025-10-15 11:06:02 +00:00
6d2e78ce55 Fix parameter handling in MenuComposer and MenuTwig
- Corrected `routeParameters` assignment in `MenuComposer` for proper parameter usage.
- Adjusted `menus` and `routes` assignment order in `MenuTwig` for consistent handling.
2025-10-03 12:00:51 +02:00
61ca700bbe Merge branch '1682-1683-1684-fix-bug-mr-884' into 'ticket-app-master'
FIX des bugs du merge request 884

See merge request Chill-Projet/chill-bundles!885
2025-09-30 13:49:04 +00:00
Boris Waaub
b43aeebc3c FIX des bugs du merge request 884 2025-09-30 13:49:04 +00:00
056e2dcc5f Merge branch 'ticket/WP1617-motifs-hierarchiques' into 'ticket-app-master'
Support for parent/children motives

See merge request Chill-Projet/chill-bundles!886
2025-09-30 13:12:06 +00:00
e57d1ac696 Support for parent/children motives 2025-09-30 13:12:06 +00:00
0eff1d2e79 Merge branch 'ticket/improve-local-menu-builder' into 'ticket-app-master'
Refactor `MenuComposer` to improve type safety and simplify local menu builder integration

See merge request Chill-Projet/chill-bundles!890
2025-09-29 15:03:05 +00:00
3928b2cc7a Refactor MenuComposer to improve type safety and simplify local menu builder integration 2025-09-29 15:03:05 +00:00
77 changed files with 1898 additions and 493 deletions

View File

@@ -1,7 +1,12 @@
import { trans, setLocale, setLocaleFallbacks } from "./ux-translator";
import {
trans,
setLocale,
getLocale,
setLocaleFallbacks,
} from "./ux-translator";
setLocaleFallbacks({"en": "fr", "nl": "fr", "fr": "en"});
setLocale('fr');
setLocaleFallbacks({ en: "fr", nl: "fr", fr: "en" });
setLocale("fr");
export { trans };
export * from '../var/translations';
export { trans, getLocale };
export * from "../var/translations";

View File

@@ -66,6 +66,7 @@ framework:
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
'Chill\TicketBundle\Messenger\PostTicketUpdateMessage': async
# end of routes added by chill-bundles recipes
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -14,7 +14,6 @@ namespace Chill\MainBundle;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
@@ -70,7 +69,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new MenuCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ACLFlagsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new CRUDControllerCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
}

View File

@@ -1,4 +1,5 @@
import { TranslatableString } from "ChillMainAssets/types";
import { DateTime, TranslatableString } from "ChillMainAssets/types";
import { getLocale } from "translator";
/**
* Localizes a translatable string object based on the current locale.
@@ -17,11 +18,10 @@ import { TranslatableString } from "ChillMainAssets/types";
* @returns The localized URL
*/
export function localizedUrl(url: string): string {
const lang =
document.documentElement.lang || navigator.language.split("-")[0] || "fr";
const locale = getLocale();
// Ensure url starts with a slash and does not already start with /{lang}/
const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
const langPrefix = `/${lang}`;
const langPrefix = `/${locale}`;
if (normalizedUrl.startsWith(langPrefix + "/")) {
return normalizedUrl;
}
@@ -36,7 +36,7 @@ export function localizeString(
return "";
}
const currentLocale = locale || navigator.language.split("-")[0] || "fr";
const currentLocale = locale || getLocale();
if (translatableString[currentLocale]) {
return translatableString[currentLocale];
@@ -59,3 +59,47 @@ export function localizeString(
return "";
}
const datetimeFormats: Record<
string,
Record<string, Intl.DateTimeFormatOptions>
> = {
fr: {
short: {
year: "numeric",
month: "numeric",
day: "numeric",
},
text: {
year: "numeric",
month: "long",
day: "numeric",
},
long: {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: false,
},
hoursOnly: {
hour: "numeric",
minute: "numeric",
hour12: false,
},
},
};
export function localizeDateTimeFormat(
dateTime: DateTime,
format: keyof typeof datetimeFormats.fr = "short",
): string {
const locale = getLocale();
const options =
datetimeFormats[locale]?.[format] || datetimeFormats.fr[format];
return new Intl.DateTimeFormat(locale, options).format(
new Date(dateTime.datetime),
);
}
export default datetimeFormats;

View File

@@ -122,7 +122,6 @@ const tabDefinitions: TabDefinition[] = [
];
const displayedTabs = computed(() => {
// Always show MyCustoms first if present
const tabs = [] as TabDefinition[];
for (const tabEnum of homepageConfig.value.displayTabs) {
const def = tabDefinitions.find(
@@ -137,10 +136,7 @@ const activeTab = ref(Number(HomepageTabs[homepageConfig.value.defaultTab]));
const loading = computed(() => store.state.loading);
function selectTab(tab: HomepageTabs) {
if (tab !== HomepageTabs.MyCustoms) {
store.dispatch("getByTab", { tab: tab });
}
async function selectTab(tab: HomepageTabs) {
activeTab.value = tab;
}

View File

@@ -2,7 +2,9 @@
<li>
<h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text")
props.item?.startDate
? localizeDateTimeFormat(props.item?.startDate, "text")
: ""
}}</time>
<div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div>
@@ -26,7 +28,9 @@
<template #body>
<p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text")
props.item?.startDate
? localizeDateTimeFormat(props.item?.startDate, "text")
: ""
}}</time>
</p>
<div v-html="convertMarkdownToHtml(item.content)"></div>
@@ -42,7 +46,7 @@ import DOMPurify from "dompurify";
import { NewsItemType } from "../../../types";
import type { PropType } from "vue";
import { ref } from "vue";
import { ISOToDatetime } from "../../../chill/js/date";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const props = defineProps({
item: {
@@ -133,7 +137,7 @@ const preprocess = (markdown: string): string => {
};
const postprocess = (html: string): string => {
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
@@ -159,10 +163,6 @@ const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent);
};
const newsItemStartDate = (): null | Date => {
return ISOToDatetime(props.item?.startDate.datetime);
};
</script>
<style scoped>

View File

@@ -21,7 +21,7 @@
</template>
<template #tbody>
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`">
<td>{{ $d(new Date(c.openingDate.datetime), "short") }}</td>
<td>{{ localizeDateTimeFormat(c.openingDate, "short") }}</td>
<td>
<span
v-for="(issue, index) in c.socialIssues"
@@ -82,6 +82,8 @@ import {
CONFIDENTIAL,
trans,
} from "translator";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore();
const accompanyingCourses: ComputedRef<PaginationResponse<AccompanyingCourse>> =

View File

@@ -1,62 +1,59 @@
<template>
<span v-if="noResults" class="chill-no-data-statement">
{{ trans(NO_DASHBOARD) }}
</span>
<div v-else id="dashboards" class="container g-3">
<div id="dashboards" class="container g-3">
<div class="row">
<div class="mbloc col-xs-12 col-sm-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="(counter.value?.notifications || 0) > 0">
<li v-if="counter.notifications > 0">
<span :class="counterClass">
{{
trans(COUNTER_UNREAD_NOTIFICATIONS, {
n: counter.value?.notifications || 0,
n: counter.notifications,
})
}}
</span>
</li>
<li v-if="(counter.value?.accompanyingCourses || 0) > 0">
<li v-if="counter.accompanyingCourses > 0">
<span :class="counterClass">
{{
trans(COUNTER_ASSIGNATED_COURSES, {
n: counter.value?.accompanyingCourses || 0,
n: counter.accompanyingCourses,
})
}}
</span>
</li>
<li v-if="(counter.value?.works || 0) > 0">
<li v-if="counter.works > 0">
<span :class="counterClass">
{{
trans(COUNTER_ASSIGNATED_ACTIONS, {
n: counter.value?.works || 0,
n: counter.works,
})
}}
</span>
</li>
<li v-if="(counter.value?.evaluations || 0) > 0">
<li v-if="counter.evaluations > 0">
<span :class="counterClass">
{{
trans(COUNTER_ASSIGNATED_EVALUATIONS, {
n: counter.value?.evaluations || 0,
n: counter.evaluations,
})
}}
</span>
</li>
<li v-if="(counter.value?.tasksAlert || 0) > 0">
<li v-if="counter.tasksAlert > 0">
<span :class="counterClass">
{{
trans(COUNTER_ALERT_TASKS, {
n: counter.value?.tasksAlert || 0,
n: counter.tasksAlert,
})
}}
</span>
</li>
<li v-if="(counter.value?.tasksWarning || 0) > 0">
<li v-if="counter.tasksWarning > 0">
<span :class="counterClass">
{{
trans(COUNTER_WARNING_TASKS, {
n: counter.value?.tasksWarning || 0,
n: counter.tasksWarning,
})
}}
</span>
@@ -85,7 +82,6 @@ import { useStore } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import News from "./DashboardWidgets/News.vue";
import {
NO_DASHBOARD,
COUNTER_UNREAD_NOTIFICATIONS,
COUNTER_ASSIGNATED_COURSES,
COUNTER_ASSIGNATED_ACTIONS,
@@ -105,14 +101,19 @@ interface MyCustom {
const store = useStore();
const counter = computed(() => store.getters.counter);
const counter = computed(() => ({
notifications: store.state.homepage.notifications?.count ?? 0,
accompanyingCourses: store.state.homepage.accompanyingCourses?.count ?? 0,
works: store.state.homepage.works?.count ?? 0,
evaluations: store.state.homepage.evaluations?.count ?? 0,
tasksAlert: store.state.homepage.tasksAlert?.count ?? 0,
tasksWarning: store.state.homepage.tasksWarning?.count ?? 0,
}));
const counterClass = { counter: true };
const dashboardItems = ref<MyCustom[]>([]);
const noResults = computed(() => false);
const hasDashboardItems = computed(() => dashboardItems.value.length > 0);
onMounted(async () => {

View File

@@ -22,11 +22,7 @@
<template #tbody>
<tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`">
<td>
{{
e.maxDate?.datetime
? $d(new Date(e.maxDate.datetime), "short")
: ""
}}
{{ e.maxDate ? localizeDateTimeFormat(e.maxDate, "short") : "" }}
</td>
<td>
{{ localizeString(e.evaluation?.title ?? null) }}
@@ -115,6 +111,8 @@ import {
NO_DATA,
trans,
} from "translator";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const evaluations: ComputedRef<
PaginationResponse<AccompanyingPeriodWorkEvaluation>
> = computed(() => store.state.homepage.evaluations);

View File

@@ -20,7 +20,7 @@
</template>
<template #tbody>
<tr v-for="(n, i) in notifications.results" :key="`notify-${i}`">
<td>{{ $d(new Date(n.date.datetime), "long") }}</td>
<td>{{ localizeDateTimeFormat(n.date, "long") }}</td>
<td>
<span class="unread">
<i class="fa fa-envelope-o" />
@@ -65,6 +65,8 @@ import {
trans,
} from "translator";
import { PaginationResponse } from "ChillMainAssets/lib/api/apiMethods";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore();
const notifications: ComputedRef<PaginationResponse<Notification>> = computed(

View File

@@ -21,12 +21,12 @@
<template #tbody>
<tr v-for="(t, i) in tasks.alert.results" :key="`task-alert-${i}`">
<td v-if="t.warningDate !== null">
{{ $d(new Date(t.warningDate.datetime), "short") }}
{{ localizeDateTimeFormat(t.warningDate, "short") }}
</td>
<td v-else />
<td>
<span class="outdated">{{
$d(new Date(t.endDate.datetime), "short")
localizeDateTimeFormat(t.endDate, "short")
}}</span>
</td>
<td>{{ t.title }}</td>
@@ -62,10 +62,10 @@
<tr v-for="(t, i) in tasks.warning.results" :key="`task-warning-${i}`">
<td>
<span class="outdated">{{
$d(new Date(t.warningDate.datetime), "short")
localizeDateTimeFormat(t.warningDate, "short")
}}</span>
</td>
<td>{{ $d(new Date(t.endDate.datetime), "short") }}</td>
<td>{{ localizeDateTimeFormat(t.endDate, "short") }}</td>
<td>{{ t.title }}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(t)">
@@ -94,6 +94,7 @@ import {
} from "translator";
import { TasksState } from "./store/modules/homepage";
import { Alert, Warning } from "ChillPersonAssets/types";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore();

View File

@@ -21,7 +21,7 @@
</template>
<template #tbody>
<tr v-for="(w, i) in works.value.results" :key="`works-${i}`">
<td>{{ $d(w.startDate.datetime, "short") }}</td>
<td>{{ localizeDateTimeFormat(w.startDate.datetime, "short") }}</td>
<td>
<span class="chill-entity entity-social-issue">
<span class="badge bg-chill-l-gray text-dark">
@@ -90,6 +90,7 @@ import {
trans,
} from "translator";
import { Workflow } from "ChillPersonAssets/types";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore();

View File

@@ -11,6 +11,7 @@ import {
Warning,
Workflow,
WorflowCc,
Notification,
} from "ChillPersonAssets/types";
import { RootState } from "..";
import { HomepageTabs } from "ChillMainAssets/types";
@@ -191,6 +192,7 @@ export const moduleHomepage: Module<State, RootState> = {
if (!getters.isNotificationsLoaded) {
commit("setLoading", true);
const url = `/api/1.0/main/notification/my/unread${"?" + param}`;
makeFetch("GET", url)
.then((response) => {
commit("addNotifications", response);

View File

@@ -1,34 +1,16 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<li class="nav-item dropdown btn btn-primary nav-section">
<a id="menu-section"
class="nav-link dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-haspopup="true"
<a id="menu-section"
class="nav-link dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
{{ 'Sections'|trans }}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section">
{% for menu in menus %}
<a class="dropdown-item list-group-item bg-dark text-white"
<a class="dropdown-item list-group-item bg-dark text-white"
href="{{ menu.uri }}">
{{ menu.label }}
{% apply spaceless %}

View File

@@ -13,39 +13,37 @@ namespace Chill\MainBundle\Routing;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\Routing\RouteCollection;
use Knp\Menu\MenuItem;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This class permit to build menu from the routing information
* stored in each bundle.
*
* how to must come here FIXME
*/
class MenuComposer
final readonly class MenuComposer
{
private array $localMenuBuilders = [];
public function __construct(
private RouterInterface $router,
private FactoryInterface $menuFactory,
private TranslatorInterface $translator,
/**
* @var iterable<LocalMenuBuilderInterface>
*/
private iterable $localMenuBuilders,
) {}
private RouteCollection $routeCollection;
public function __construct(private readonly RouterInterface $router, private readonly FactoryInterface $menuFactory, private readonly TranslatorInterface $translator) {}
public function addLocalMenuBuilder(LocalMenuBuilderInterface $menuBuilder, $menuId)
public function getMenuFor($menuId, array $parameters = []): ItemInterface
{
$this->localMenuBuilders[$menuId][] = $menuBuilder;
}
public function getMenuFor($menuId, array $parameters = [])
{
$routes = $this->getRoutesFor($menuId, $parameters);
$routes = $this->getRoutesForInternal($menuId, $parameters);
/** @var MenuItem $menu */
$menu = $this->menuFactory->createItem($menuId);
// build menu from routes
foreach ($routes as $order => $route) {
$menu->addChild($this->translator->trans($route['label']), [
'route' => $route['key'],
'routeParameters' => $parameters['args'],
'routeParameters' => $parameters,
'order' => $order,
])
->setExtras([
@@ -55,10 +53,9 @@ class MenuComposer
]);
}
if ($this->hasLocalMenuBuilder($menuId)) {
foreach ($this->localMenuBuilders[$menuId] as $builder) {
/* @var $builder LocalMenuBuilderInterface */
$builder->buildMenu($menuId, $menu, $parameters['args']);
foreach ($this->localMenuBuilders as $builder) {
if (in_array($menuId, $builder::getMenuIds(), true)) {
$builder->buildMenu($menuId, $menu, $parameters);
}
}
@@ -71,12 +68,16 @@ class MenuComposer
* Return an array of routes added to $menuId,
* The array is aimed to build route with MenuTwig.
*
* @param string $menuId
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
* @deprecated
*
* @return array
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
*/
public function getRoutesFor($menuId, array $parameters = [])
public function getRoutesFor(string $menuId, array $parameters = []): array
{
return $this->getRoutesForInternal($menuId, $parameters);
}
private function getRoutesForInternal(string $menuId, array $parameters = []): array
{
$routes = [];
$routeCollection = $this->router->getRouteCollection();
@@ -108,22 +109,17 @@ class MenuComposer
* should be used, or `getRouteFor`. The method `getMenuFor` should be used
* if the result is true (it **does** exists at least one menu builder.
*
* @param string $menuId
* @deprecated
*/
public function hasLocalMenuBuilder($menuId): bool
public function hasLocalMenuBuilder(string $menuId): bool
{
return \array_key_exists($menuId, $this->localMenuBuilders);
}
foreach ($this->localMenuBuilders as $localMenuBuilder) {
if (in_array($menuId, $localMenuBuilder::getMenuIds(), true)) {
return true;
}
}
/**
* Set the route Collection
* This function is needed for testing purpose: routeCollection is not
* available as a service (RouterInterface is provided as a service and
* added to this class as paramater in __construct).
*/
public function setRouteCollection(RouteCollection $routeCollection)
{
$this->routeCollection = $routeCollection;
return false;
}
private function reorderMenu(ItemInterface $menu)

View File

@@ -18,7 +18,7 @@ use Twig\TwigFunction;
/**
* Add the filter 'chill_menu'.
*/
class MenuTwig extends AbstractExtension
final class MenuTwig extends AbstractExtension
{
/**
* the default parameters for chillMenu.
@@ -43,22 +43,16 @@ class MenuTwig extends AbstractExtension
*
* @deprecated link: see https://redmine.champs-libres.coop/issues/179 for more informations
*
* @param string $menuId
* @param mixed[] $params
* @param array{layout?: string, activeRouteKey?: string|null, args?: array<array-key, mixed>} $params
*/
public function chillMenu(Environment $env, $menuId, array $params = [])
public function chillMenu(Environment $env, string $menuId, array $params = []): string
{
$resolvedParams = array_merge($this->defaultParams, $params);
$layout = $resolvedParams['layout'];
unset($resolvedParams['layout']);
if (false === $this->menuComposer->hasLocalMenuBuilder($menuId)) {
$resolvedParams['routes'] = $this->menuComposer->getRoutesFor($menuId, $resolvedParams);
return $env->render($layout, $resolvedParams);
}
$resolvedParams['menus'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams);
$resolvedParams['routes'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams['args']);
$resolvedParams['menus'] = $resolvedParams['routes'];
return $env->render($layout, $resolvedParams);
}

View File

@@ -11,44 +11,107 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Services;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Routing\MenuComposer;
use Knp\Menu\MenuFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This class provide functional test for MenuComposer.
* Tests for MenuComposer methods.
*
* We only verify that items provided by local menu builders are present
* when getRoutesFor() yields no routes, and that hasLocalMenuBuilder behaves
* as expected with the configured builders.
*
* @internal
*
* @coversNothing
*/
final class MenuComposerTest extends KernelTestCase
final class MenuComposerTest extends TestCase
{
/**
* @var \Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader;
*/
private $loader;
use ProphecyTrait;
/**
* @var \Chill\MainBundle\DependencyInjection\Services\MenuComposer;
*/
private $menuComposer;
protected function setUp(): void
private function buildMenuComposerWithDefaultBuilder(): array
{
self::bootKernel(['environment' => 'test']);
$this->menuComposer = self::getContainer()
->get('chill.main.menu_composer');
// Router: returns an empty RouteCollection so getRoutesFor() yields []
$routerProphecy = $this->prophesize(RouterInterface::class);
$routerProphecy->getRouteCollection()->willReturn(new RouteCollection());
$router = $routerProphecy->reveal();
// Menu factory from Knp\Menu
$menuFactory = new MenuFactory();
// Translator: identity translator
$translator = new class () implements TranslatorInterface {
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return $id;
}
public function getLocale(): string
{
return 'en';
}
};
// Local builder that adds two items to the requested menu
$builder = new class () implements LocalMenuBuilderInterface {
public static function getMenuIds(): array
{
return ['main'];
}
public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters)
{
// Ensure we can use parameters passed to getMenuFor
$suffix = $parameters['suffix'] ?? '';
$menu->addChild('local_item_one', [
'label' => 'Local Item One'.$suffix,
])->setExtras(['order' => 1]);
$menu->addChild('local_item_two', [
'label' => 'Local Item Two'.$suffix,
])->setExtras(['order' => 2]);
}
};
$composer = new MenuComposer(
$router,
$menuFactory,
$translator,
[$builder]
);
return [$composer, $builder];
}
/**
* @covers \Chill\MainBundle\Routing\MenuComposer
*/
public function testMenuComposer()
public function testGetMenuForReturnsItemsFromLocalBuildersOnly(): void
{
$collection = new RouteCollection();
[$composer] = $this->buildMenuComposerWithDefaultBuilder();
$routes = $this->menuComposer->getRoutesFor('dummy0');
$menu = $composer->getMenuFor('main', []);
$this->assertIsArray($routes);
// No routes were added, only local builder items should be present
$children = $menu->getChildren();
self::assertCount(2, $children, 'Menu should contain exactly the items provided by local builders');
// Assert the two expected items exist with their names
self::assertNotNull($menu->getChild('local_item_one'));
self::assertNotNull($menu->getChild('local_item_two'));
// And that their labels include the parameter suffix
self::assertSame('Local Item One', $menu->getChild('local_item_one')->getLabel());
self::assertSame('Local Item Two', $menu->getChild('local_item_two')->getLabel());
}
public function testHasLocalMenuBuilder(): void
{
[$composer] = $this->buildMenuComposerWithDefaultBuilder();
self::assertTrue($composer->hasLocalMenuBuilder('main'));
self::assertFalse($composer->hasLocalMenuBuilder('secondary'));
}
}

View File

@@ -6,9 +6,8 @@ services:
chill.main.menu_composer:
class: Chill\MainBundle\Routing\MenuComposer
arguments:
- '@Symfony\Component\Routing\RouterInterface'
- '@Knp\Menu\FactoryInterface'
- '@Symfony\Contracts\Translation\TranslatorInterface'
$localMenuBuilders: !tagged_iterator 'chill.menu_builder'
Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer'
chill.main.routes_loader:

View File

@@ -291,6 +291,7 @@ export interface Notification {
relatedEntityClass: string;
relatedEntityId: number;
}
export interface Participation {
person: Person;
}

View File

@@ -112,7 +112,7 @@ paths:
- no
- name: byMotives
in: query
description: the motives of the ticket
description: the motives of the ticket. All the descendants of the motive are taken into account.
required: false
style: form
explode: false

View File

@@ -14,14 +14,20 @@ namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\EmergencyStatusUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Handler for changing the emergency status of a ticket.
*/
class ChangeEmergencyStateCommandHandler
{
public function __construct(private readonly ClockInterface $clock) {}
public function __construct(
private readonly ClockInterface $clock,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
public function __invoke(Ticket $ticket, ChangeEmergencyStateCommand $command): Ticket
{
@@ -30,6 +36,8 @@ class ChangeEmergencyStateCommandHandler
return $ticket;
}
$previous = $ticket->getEmergencyStatus();
// End the current emergency status history (if any)
foreach ($ticket->getEmergencyStatusHistories() as $emergencyStatusHistory) {
if (null === $emergencyStatusHistory->getEndDate()) {
@@ -44,6 +52,12 @@ class ChangeEmergencyStateCommandHandler
$this->clock->now(),
);
// Dispatch event about the toggle
if (null !== $previous) {
$event = new EmergencyStatusUpdateEvent($ticket, $previous, $command->newEmergencyStatus);
$this->eventDispatcher->dispatch($event, TicketUpdateEvent::class);
}
return $ticket;
}
}

View File

@@ -15,8 +15,11 @@ use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\MotiveUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class ReplaceMotiveCommandHandler
{
@@ -24,6 +27,7 @@ class ReplaceMotiveCommandHandler
private readonly ClockInterface $clock,
private readonly EntityManagerInterface $entityManager,
private readonly ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void
@@ -32,6 +36,8 @@ class ReplaceMotiveCommandHandler
throw new \InvalidArgumentException('The new motive cannot be null');
}
$event = new MotiveUpdateEvent($ticket);
// will add if there are no existing motive
$readyToAdd = 0 === count($ticket->getMotiveHistories());
@@ -45,6 +51,8 @@ class ReplaceMotiveCommandHandler
continue;
}
// collect previous active motives before closing
$event->previousMotive = $history->getMotive();
$history->setEndDate($this->clock->now());
$readyToAdd = true;
}
@@ -52,6 +60,7 @@ class ReplaceMotiveCommandHandler
if ($readyToAdd) {
$history = new MotiveHistory($command->motive, $ticket, $this->clock->now());
$this->entityManager->persist($history);
$event->newMotive = $command->motive;
// Check if the motive has makeTicketEmergency set and update the ticket's emergency status if needed
if ($command->motive->isMakeTicketEmergency()) {
@@ -59,5 +68,9 @@ class ReplaceMotiveCommandHandler
($this->changeEmergencyStateCommandHandler)($ticket, $changeEmergencyCommand);
}
}
if ($event->hasChanges()) {
$this->eventDispatcher->dispatch($event, TicketUpdateEvent::class);
}
}
}

View File

@@ -15,9 +15,12 @@ use Chill\MainBundle\Entity\User;
use Chill\TicketBundle\Action\Ticket\SetPersonsCommand;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\PersonsUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final readonly class SetPersonsCommandHandler
{
@@ -25,10 +28,13 @@ final readonly class SetPersonsCommandHandler
private ClockInterface $clock,
private EntityManagerInterface $entityManager,
private Security $security,
private EventDispatcherInterface $eventDispatcher,
) {}
public function handle(Ticket $ticket, SetPersonsCommand $command): void
{
$event = new PersonsUpdateEvent($ticket);
// remove existing addresses which are not in the new addresses
foreach ($ticket->getPersonHistories() as $personHistory) {
if (null !== $personHistory->getEndDate()) {
@@ -40,6 +46,7 @@ final readonly class SetPersonsCommandHandler
if (($user = $this->security->getUser()) instanceof User) {
$personHistory->setRemovedBy($user);
}
$event->personsRemoved[] = $personHistory->getPerson();
}
}
@@ -51,6 +58,11 @@ final readonly class SetPersonsCommandHandler
$history = new PersonHistory($person, $ticket, $this->clock->now());
$this->entityManager->persist($history);
$event->personsAdded[] = $person;
}
if ($event->hasChanges()) {
$this->eventDispatcher->dispatch($event, TicketUpdateEvent::class);
}
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Controller;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
@@ -21,13 +22,13 @@ final class MotiveApiController extends ApiController
protected function customizeQuery(string $action, Request $request, $query): void
{
/* @var $query QueryBuilder */
$query->andWhere('e.active = TRUE');
$query->andWhere('e.active = TRUE')->andWhere('e.parent IS NULL');
}
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
return match ($request->getMethod()) {
Request::METHOD_GET => ['groups' => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]],
Request::METHOD_GET => ['groups' => ['read', 'read:extended', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY, MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]],
default => parent::getContextForSerialization($action, $request, $_format, $entity),
};
}

View File

@@ -11,38 +11,22 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Controller;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Repository\MotiveRepository;
use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class TicketListController
{
public function __construct(
private Security $security,
private TicketRepositoryInterface $ticketRepository,
private Environment $twig,
private FilterOrderHelperFactory $filterOrderHelperFactory,
// private MotiveRepository $motiveRepository,
// private TranslatableStringHelperInterface $translatableStringHelper,
) {}
/**
* @throws RuntimeError
* @throws SyntaxError
* @throws LoaderError
*/
#[Route('/{_locale}/ticket/ticket/list', name: 'chill_ticket_ticket_list')]
public function __invoke(Request $request): Response
{
@@ -50,35 +34,22 @@ final readonly class TicketListController
throw new AccessDeniedHttpException('only user can access this page');
}
$filter = $this->buildFilter();
$tickets = $this->ticketRepository->findAllOrdered();
return new Response(
$this->twig->render('@ChillTicket/Ticket/list.html.twig', [
'tickets' => $tickets,
'filter' => $filter,
])
$this->twig->render('@ChillTicket/Ticket/list.html.twig')
);
}
private function buildFilter(): FilterOrderHelper
#[Route('/{_locale}/ticket/by-person/{id}/list', name: 'chill_person_ticket_list')]
public function listByPerson(Request $request, Person $person): Response
{
// $motives = $this->motiveRepository->findAll();
if (!$this->security->isGranted(PersonVoter::SEE, $person)) {
throw new AccessDeniedHttpException('you are not allowed to see this person');
}
return $this->filterOrderHelperFactory
->create(self::class)
->addSingleCheckbox('to_me', 'chill_ticket.list.filter.to_me')
->addSingleCheckbox('in_alert', 'chill_ticket.list.filter.in_alert')
->addDateRange('created_between', 'chill_ticket.list.filter.created_between')
/*
->addEntityChoice('by_motive', 'chill_ticket.list.filter.by_motive', Motive::class, $motives, [
'choice_label' => fn (Motive $motive) => $this->translatableStringHelper->localize($motive->getLabel()),
'expanded' => true,
'multiple' => true,
'attr' => ['class' => 'select2'],
return new Response(
$this->twig->render('@ChillTicket/Person/list.html.twig', [
'person' => $person,
])
*/
->build();
);
}
}

View File

@@ -13,7 +13,6 @@ namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
@@ -35,6 +34,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'],
['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'],
];
$motivesByLabel = [];
foreach (explode("\n", self::MOTIVES) as $row) {
if ('' === trim($row)) {
@@ -46,50 +46,65 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
continue;
}
$motive = new Motive();
$motive->setLabel(['fr' => trim((string) $data[0])]);
$motive->setMakeTicketEmergency(match ($data[1]) {
'true' => EmergencyStatusEnum::YES,
'false' => EmergencyStatusEnum::NO,
default => throw new \UnexpectedValueException('Unexpected value'),
});
$labels = explode(' > ', (string) $data[0]);
$parent = null;
$numberOfDocs = (int) $data[2];
for ($i = 1; $i <= $numberOfDocs; ++$i) {
$doc = $docs[$i - 1];
$storedObject = new StoredObject();
$storedObject->setTitle($doc['label']);
while (count($labels) > 0) {
$label = array_shift($labels);
dump($labels);
if (isset($motivesByLabel[$label])) {
$motive = $motivesByLabel[$label];
} else {
$motive = new Motive();
$motive->setLabel(['fr' => $label]);
$motivesByLabel[$label] = $motive;
}
$content = file_get_contents($doc['path']);
$contentType = match (substr($doc['path'], -3, 3)) {
'pdf' => 'application/pdf',
'png' => 'image/png',
default => throw new \UnexpectedValueException('Not supported content type here'),
};
$this->storedObjectManager->write($storedObject, $content, $contentType);
if (null !== $parent) {
$motive->setParent($parent);
}
$motive->addStoredObject($storedObject);
$manager->persist($storedObject);
}
$manager->persist($motive);
$parent = $motive;
if (0 === count($labels)) {
// this is the last one, we add data
$numberOfDocs = (int) $data[2];
for ($i = 1; $i <= $numberOfDocs; ++$i) {
$doc = $docs[$i - 1];
$storedObject = new StoredObject();
$storedObject->setTitle($doc['label']);
$content = file_get_contents($doc['path']);
$contentType = match (substr($doc['path'], -3, 3)) {
'pdf' => 'application/pdf',
'png' => 'image/png',
default => throw new \UnexpectedValueException('Not supported content type here'),
};
$this->storedObjectManager->write($storedObject, $content, $contentType);
$motive->addStoredObject($storedObject);
$manager->persist($storedObject);
}
foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
}
}
}
}
$manager->persist($motive);
}
$manager->flush();
}
private const MOTIVES = <<<'CSV'
"Coordonnées",false,"3","Nouvelles coordonnées",
"Horaire de passage",false,"0",
"Retard de livraison",false,"0",
"Erreur de livraison",false,"0",
"Colis incomplet",false,"0",
"Motif administratif > Coordonnées",false,"3","Nouvelles coordonnées",
"Organisation > Horaire de passage",false,"0",
"Organisation > Livraison > Retard de livraison",false,"0",
"Organisation > Livraison > Erreur de livraison",false,"0",
"Organisation > Livraison > Colis incomplet",false,"0",
"MATLOC",false,"0",
"Retard DASRI",false,"1",
"Planning d'astreintes",false,"0",
@@ -116,7 +131,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
"Mauvaise adresse",false,"0",
"Patient absent",false,"0",
"Annulation",false,"0",
"Colis perdu",false,"0",
"Organisation > Livraison > Colis perdu",false,"0",
"Changement de rendez-vous",false,"0",
"Coordination interservices",false,"0",
"Problème de substitution produits",true,"0",

View File

@@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
@@ -26,19 +27,15 @@ class Motive
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[Serializer\Groups(['read'])]
private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)]
#[Serializer\Groups(['read'])]
private ?EmergencyStatusEnum $makeTicketEmergency = null;
/**
@@ -49,12 +46,22 @@ class Motive
private Collection $storedObjects;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $supplementaryComments = [];
#[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')]
private ?Motive $parent = null;
/**
* @var Collection<int, Motive>&Selectable<int, Motive>
*/
#[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'parent')]
private Collection&Selectable $children;
public function __construct()
{
$this->storedObjects = new ArrayCollection();
$this->children = new ArrayCollection();
}
public function addStoredObject(StoredObject $storedObject): void
@@ -69,7 +76,6 @@ class Motive
$this->storedObjects->removeElement($storedObject);
}
#[Serializer\Groups(['read'])]
public function getStoredObjects(): ReadableCollection
{
return $this->storedObjects;
@@ -142,4 +148,74 @@ class Motive
$this->supplementaryComments[$key] = $supplementaryComment;
}
}
public function isParent(): bool
{
return $this->children->count() > 0;
}
public function isChild(): bool
{
return null !== $this->parent;
}
public function setParent(?Motive $parent): void
{
if (null !== $parent) {
$parent->addChild($this);
} else {
$this->parent->removeChild($this);
}
$this->parent = $parent;
}
/**
* @internal use @see{setParent} instead
*/
public function addChild(Motive $child): void
{
if (!$this->children->contains($child)) {
$this->children->add($child);
}
}
/**
* @internal use @see{setParent} with null as argument instead
*/
public function removeChild(Motive $child): void
{
$this->children->removeElement($child);
}
public function getChildren(): ReadableCollection&Selectable
{
return $this->children;
}
public function getParent(): ?Motive
{
return $this->parent;
}
/**
* Get the descendants of the current entity.
*
* This method collects all descendant entities recursively, starting from the current entity
* and including all of its children and their descendants.
*
* @return ReadableCollection&Selectable A collection containing the current entity and all its descendants
*/
public function getDescendants(): ReadableCollection&Selectable
{
$collection = new ArrayCollection([$this]);
foreach ($this->getChildren() as $child) {
foreach ($child->getDescendants() as $descendant) {
$collection->add($descendant);
}
}
return $collection;
}
}

View File

@@ -42,7 +42,7 @@ class MotiveHistory implements TrackCreationInterface
public function __construct(
#[ORM\ManyToOne(targetEntity: Motive::class)]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
#[Serializer\Groups(['read', 'read:children-to-parent'])]
private Motive $motive,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Ticket;
final class EmergencyStatusUpdateEvent extends TicketUpdateEvent
{
public function __construct(
Ticket $ticket,
public EmergencyStatusEnum $previousEmergencyStatus,
public EmergencyStatusEnum $newEmergencyStatus,
) {
parent::__construct(TicketUpdateKindEnum::TOGGLE_EMERGENCY, $ticket);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event\EventSubscriber;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Chill\TicketBundle\Messenger\PostTicketUpdateMessage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Subscribe to TicketUpdateEvents and dispatch a message for each one when the kernel terminates.
*/
final class GeneratePostUpdateTicketEventSubscriber implements EventSubscriberInterface
{
/**
* @var list<PostTicketUpdateMessage>
*/
private array $toDispatch = [];
public function __construct(private readonly MessageBusInterface $messageBus) {}
public static function getSubscribedEvents(): array
{
return [
TicketUpdateEvent::class => ['onTicketUpdate', 0],
KernelEvents::TERMINATE => ['onKernelTerminate', 8096],
];
}
public function onTicketUpdate(TicketUpdateEvent $event): void
{
$this->toDispatch[] = new PostTicketUpdateMessage($event->ticket, $event->updateKind);
}
public function onKernelTerminate(TerminateEvent $event): void
{
foreach ($this->toDispatch as $message) {
$this->messageBus->dispatch($message);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\Ticket;
final class MotiveUpdateEvent extends TicketUpdateEvent
{
public function __construct(
Ticket $ticket,
public ?Motive $previousMotive = null,
public ?Motive $newMotive = null,
) {
parent::__construct(TicketUpdateKindEnum::UPDATE_MOTIVE, $ticket);
}
public function hasChanges(): bool
{
return null !== $this->newMotive || null !== $this->previousMotive;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Ticket;
class PersonsUpdateEvent extends TicketUpdateEvent
{
public function __construct(Ticket $ticket)
{
parent::__construct(TicketUpdateKindEnum::UPDATE_PERSONS, $ticket);
}
/**
* @var list<Person>
*/
public $personsAdded = [];
/**
* @var list<Person>
*/
public $personsRemoved = [];
public function hasChanges(): bool
{
return count($this->personsAdded) > 0 || count($this->personsRemoved) > 0;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
use Chill\TicketBundle\Entity\Ticket;
/**
* Event triggered asynchronously after a ticket has been updated.
*
* This event is trigged by PostTicketUpdateMessageHandler, using Messenger component.
*
* To use a synchronous event, see @see{TicketUpdateEvent}
*/
class PostTicketUpdateEvent
{
public function __construct(
public readonly TicketUpdateKindEnum $updateKind,
public readonly Ticket $ticket,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
use Chill\TicketBundle\Entity\Ticket;
abstract class TicketUpdateEvent
{
public function __construct(
public readonly TicketUpdateKindEnum $updateKind,
public readonly Ticket $ticket,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Event;
enum TicketUpdateKindEnum: string
{
case UPDATE_ADDRESSEE = 'UPDATE_ADDRESSEE';
case ADD_COMMENT = 'ADD_COMMENT';
case TOGGLE_EMERGENCY = 'TOGGLE_EMERGENCY';
case TOGGLE_STATE = 'TOGGLE_STATE';
case UPDATE_MOTIVE = 'UPDATE_MOTIVE';
case UPDATE_CALLER = 'UPDATE_CALLER';
case UPDATE_PERSONS = 'UPDATE_PERSONS';
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Menu;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Add menu entrie to person menu.
*
* Menu entries added :
*
* - person details ;
* - accompanying period (if `visible`)
*
* @implements LocalMenuBuilderInterface<array{person: Person}>
*/
class PersonMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(
private readonly AuthorizationCheckerInterface $authorizationChecker,
private readonly TranslatorInterface $translator,
private readonly TicketRepositoryInterface $ticketRepository,
) {}
/**
* @param array{person: Person} $parameters
*
* @return void
*/
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
/** @var Person $person */
$person = $parameters['person'];
if ($this->authorizationChecker->isGranted(PersonVoter::SEE, $person)) {
$menu->addChild($this->translator->trans('chill_ticket.list.title_menu'), [
'route' => 'chill_person_ticket_list',
'routeParameters' => [
'id' => $person->getId(),
],
])
->setExtras([
'order' => 150,
'counter' => 0 < ($nbTickets = $this->ticketRepository->countOpenedByPerson($person))
? $nbTickets : null,
]);
}
}
public static function getMenuIds(): array
{
return ['person'];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Messenger\Handler;
use Chill\TicketBundle\Event\PostTicketUpdateEvent;
use Chill\TicketBundle\Messenger\PostTicketUpdateMessage;
use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final readonly class PostTicketUpdateMessageHandler
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private TicketRepositoryInterface $ticketRepository,
) {}
public function __invoke(PostTicketUpdateMessage $event): void
{
$ticket = $this->ticketRepository->find($event->ticketId);
if (null === $ticket) {
throw new UnrecoverableMessageHandlingException('Ticket not found');
}
$this->eventDispatcher->dispatch(new PostTicketUpdateEvent($event->updateKind, $ticket));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Messenger;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\TicketUpdateKindEnum;
final readonly class PostTicketUpdateMessage
{
public readonly int $ticketId;
public function __construct(
Ticket $ticket,
public TicketUpdateKindEnum $updateKind,
) {
$this->ticketId = $ticket->getId();
}
}

View File

@@ -113,10 +113,11 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor
if (array_key_exists('byMotives', $params)) {
$byMotives = $qb->expr()->orX();
foreach ($params['byMotives'] as $motive) {
$motivesWithDescendants = $motive->getDescendants()->toArray();
$byMotives->add(
$qb->expr()->exists(sprintf(
'SELECT 1 FROM %s tp_motive_%d WHERE tp_motive_%d.ticket = t
AND tp_motive_%d.motive = :motive_%d AND tp_motive_%d.endDate IS NULL
AND tp_motive_%d.motive IN (:motives_%d) AND tp_motive_%d.endDate IS NULL
',
MotiveHistory::class,
++$i,
@@ -126,7 +127,7 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor
$i,
))
);
$qb->setParameter(sprintf('motive_%d', $i), $motive);
$qb->setParameter(sprintf('motives_%d', $i), $motivesWithDescendants);
}
$qb->andWhere($byMotives);
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Repository;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectRepository;
@@ -61,4 +62,19 @@ final readonly class TicketRepository implements TicketRepositoryInterface
{
return $this->repository->findOneBy(['externalRef' => $extId]);
}
/**
* Count tickets associated with a person where endDate is null.
*/
public function countOpenedByPerson(Person $person): int
{
return (int) $this->objectManager->createQuery(
'SELECT COUNT(DISTINCT t.id) FROM '.$this->getClassName().' t
JOIN t.personHistories ph
WHERE ph.person = :person
AND ph.endDate IS NULL'
)
->setParameter('person', $person)
->getSingleScalarResult();
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Repository;
use Chill\TicketBundle\Entity\Ticket;
use Chill\PersonBundle\Entity\Person;
use Doctrine\Persistence\ObjectRepository;
/**
@@ -25,4 +26,6 @@ interface TicketRepositoryInterface extends ObjectRepository
* @return list<Ticket>
*/
public function findAllOrdered(): array;
public function countOpenedByPerson(Person $person): int;
}

View File

@@ -8,14 +8,32 @@ import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types";
import { StoredObject } from "ChillDocStoreAssets/types";
export interface Motive {
interface MotiveBase {
type: "ticket_motive";
id: number;
active: boolean;
label: TranslatableString;
}
/**
* Represent a motive with basic information and parent motive.
*
* Match the "read" and "read:children-to-parent" serializer groups.
*/
export interface MotiveWithParent extends MotiveBase {
parent: MotiveWithParent | null;
}
/**
* Represents a motive for a ticket, including details like emergency status, stored objects, and supplementary comments.
*
* Match the "read:extended" serializer group in MotiveNormalizer.
*/
export interface Motive extends MotiveBase {
makeTicketEmergency: TicketEmergencyState;
storedObjects: StoredObject[];
supplementaryComments: { label: string }[];
children: Motive[];
}
export type TicketState = "open" | "closed" | "close";
@@ -45,7 +63,7 @@ export interface MotiveHistory {
id: number;
startDate: null;
endDate: null | DateTime;
motive: Motive;
motive: MotiveWithParent;
createdBy: User | null;
createdAt: DateTime | null;
}
@@ -140,7 +158,7 @@ interface BaseTicket<
createdAt: DateTime | null;
currentAddressees: UserGroupOrUser[];
currentPersons: Person[];
currentMotive: null | Motive;
currentMotive: MotiveWithParent | null;
currentState: TicketState | null;
emergency: TicketEmergencyState | null;
caller: Person | Thirdparty | null;
@@ -159,8 +177,12 @@ export interface Ticket extends BaseTicket<"ticket_ticket:extended"> {
}
export interface TicketFilters {
byPerson: Person[];
byCreator: User[];
byAddressee: UserGroupOrUser[];
byCurrentState: TicketState[];
byCurrentStateEmergency: TicketEmergencyState[];
byMotives: Motive[];
byCreatedAfter: string;
byCreatedBefore: string;
byResponseTimeExceeded: boolean;
@@ -180,12 +202,12 @@ export interface TicketFilterParams {
byCreatedBefore?: string;
byResponseTimeExceeded?: string;
byAddresseeToMe?: boolean;
byTicketId?: number;
byTicketId?: number | null;
}
export interface TicketInitForm {
content: string;
motive?: Motive;
motive?: MotiveWithParent | null;
addressees: UserGroupOrUser[];
persons: Person[];
caller: Person | null;

View File

@@ -179,7 +179,7 @@ import {
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
import { Comment, Motive, Ticket } from "../../../types";
import { Comment, Motive, MotiveWithParent, Ticket } from "../../../types";
import { Person } from "ChillPersonAssets/types";
const store = useStore();
@@ -252,7 +252,7 @@ const returnPath = computed((): string => {
return returnPath;
});
const motive = ref(ticket.value.currentMotive as Motive);
const motive = ref(ticket.value.currentMotive as MotiveWithParent | null);
const content = ref("" as Comment["content"]);
const addressees = ref(ticket.value.currentAddressees as UserGroupOrUser[]);
const persons = ref(ticket.value.currentPersons as Person[]);

View File

@@ -7,7 +7,7 @@
class="badge-user-group"
:style="`background-color: ${addressee.backgroundColor}; color: ${addressee.foregroundColor};`"
>
{{ addressee.label.fr }}
{{ localizeString(addressee.label) }}
</span>
<span v-else-if="addressee.type === 'user'" class="badge-user">
<user-render-box-badge :user="addressee"
@@ -24,6 +24,9 @@ import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRen
// Types
import { UserGroupOrUser } from "../../../../../../../../ChillMainBundle/Resources/public/types";
// Utils
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
defineProps<{ addressees: UserGroupOrUser[] }>();
</script>

View File

@@ -1,16 +1,23 @@
<template>
<pick-entity
uniqid="ticket-addressee-selector"
:types="['user', 'user_group']"
:picked="selectedEntities"
:suggested="suggestedValues"
:multiple="true"
:removable-if-set="true"
:display-picked="true"
:label="label"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
<div
:class="{
'opacity-50': disabled,
}"
:style="disabled ? 'pointer-events: none;' : ''"
>
<pick-entity
uniqid="ticket-addressee-selector"
:types="['user', 'user_group']"
:picked="selectedEntities"
:suggested="suggestedValues"
:multiple="true"
:removable-if-set="true"
:display-picked="true"
:label="label"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
</div>
</template>
<script lang="ts" setup>
@@ -33,9 +40,11 @@ const props = withDefaults(
modelValue: Entities[];
suggested: Entities[];
label?: string;
disabled?: boolean;
}>(),
{
label: trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_USER_LABEL),
disabled: false,
},
);

View File

@@ -6,7 +6,9 @@
<h1>
{{ getTicketTitle(ticket) }}
<peloton-component
:stored-objects="ticket.currentMotive?.storedObjects ?? null"
:stored-objects="
currentMotive ? currentMotive.storedObjects : null
"
/>
</h1>
@@ -91,7 +93,7 @@
</style>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ComputedRef, ref } from "vue";
import { useToast } from "vue-toast-notification";
// Components
@@ -102,7 +104,7 @@ import StateToggleComponent from "./State/StateToggleComponent.vue";
import PersonComponent from "./Person/PersonComponent.vue";
// Types
import { Ticket } from "../../../types";
import { Motive, Ticket } from "../../../types";
import { Person } from "ChillPersonAssets/types";
import {
@@ -127,7 +129,7 @@ import { useStore } from "vuex";
// Utils
import { getTicketTitle } from "../utils/utils";
defineProps<{
const props = defineProps<{
ticket: Ticket;
}>();
@@ -135,6 +137,10 @@ const store = useStore();
const toast = useToast();
const today = ref(new Date());
const currentMotive: ComputedRef<Motive | null> = computed(() =>
store.getters.getMotiveById(props.ticket.currentMotive?.id),
);
setInterval(() => {
today.value = new Date();
}, 5000);

View File

@@ -26,22 +26,24 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch, computed } from "vue";
import { reactive, ref, watch, computed, ComputedRef } from "vue";
// Components
import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue";
// Types
import { Motive } from "../../../../types";
import { Motive, MotiveWithParent } from "../../../../types";
// Utils
import { StoredObject } from "ChillDocStoreAssets/types";
import { useStore } from "vuex";
const props = defineProps<{
modelValue?: string;
motive?: Motive;
motive?: MotiveWithParent | null;
}>();
const store = useStore();
const supplementaryCommentsInput = reactive<string[]>([]);
const emit = defineEmits<{
@@ -50,11 +52,14 @@ const emit = defineEmits<{
}>();
const content = ref(props.modelValue);
const motive: ComputedRef<Motive | null> = computed(() =>
store.getters.getMotiveById(props.motive?.id),
);
const aggregateSupplementaryComments = computed(() => {
function aggregateSupplementaryComments() {
let supplementaryText = " \n\n ";
if (props.motive && props.motive.supplementaryComments) {
props.motive.supplementaryComments.forEach(
if (props.motive && motive.value && motive.value.supplementaryComments) {
motive.value.supplementaryComments.forEach(
(item: { label: string }, index: number) => {
if (supplementaryCommentsInput[index]) {
supplementaryText +=
@@ -65,18 +70,18 @@ const aggregateSupplementaryComments = computed(() => {
);
}
return (content.value || "") + supplementaryText;
});
}
watch(
supplementaryCommentsInput,
() => {
emit("update:modelValue", aggregateSupplementaryComments.value);
emit("update:modelValue", aggregateSupplementaryComments());
},
{ deep: true },
);
watch(content, () => {
emit("update:modelValue", aggregateSupplementaryComments.value);
emit("update:modelValue", aggregateSupplementaryComments());
});
</script>

View File

@@ -1,24 +1,32 @@
<template>
<div class="col-12 fw-bolder">
{{ localizeTranslatableString(motiveHistory.motive.label) }}
{{ motiveLabelRecursive(props.motiveHistory.motive) }}
<peloton-component
:stored-objects="motiveHistory.motive.storedObjects ?? null"
:stored-objects="motive ? motive.storedObjects : null"
pelotonBtnClass="float-end"
/>
</div>
</template>
<script lang="ts" setup>
import { useStore } from "vuex";
// Types
import { MotiveHistory } from "../../../../types";
import {Motive, MotiveHistory, MotiveWithParent} from "../../../../types";
//Utils
import { localizeTranslatableString } from "../../utils/utils";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
//Components
import PelotonComponent from "../PelotonComponent.vue";
import { computed, ComputedRef } from "vue";
import {motiveLabelRecursive} from "../../utils/utils";
defineProps<{ motiveHistory: MotiveHistory }>();
const props = defineProps<{ motiveHistory: MotiveHistory }>();
const store = useStore();
const motive: ComputedRef<Motive | null> = computed(() =>
store.getters.getMotiveById(props.motiveHistory.motive.id),
);
</script>
<style lang="scss" scoped></style>

View File

@@ -5,22 +5,51 @@
<vue-multiselect
name="selectMotive"
id="selectMotive"
label="label"
:custom-label="customLabel"
label="displayLabel"
:custom-label="(value: Motive) => localizeString(value.label)"
track-by="id"
:open-direction="openDirection"
:multiple="false"
:searchable="true"
:placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)"
:select-label="trans(MULTISELECT_SELECT_LABEL)"
:deselect-label="trans(MULTISELECT_DESELECT_LABEL)"
:selected-label="trans(MULTISELECT_SELECTED_LABEL)"
:options="motives"
:options="flattenedMotives"
v-model="motive"
class="form-control"
/>
@remove="(value: Motive) => $emit('remove', value)"
:disabled="disabled"
>
<template
#option="{
option,
}: {
option: Motive & {
isChild?: boolean;
isParent?: boolean;
level?: number;
breadcrumb: string[];
};
}"
>
<span
:data-select="trans(MULTISELECT_SELECT_LABEL)"
:data-selected="trans(MULTISELECT_SELECTED_LABEL)"
:data-deselect="trans(MULTISELECT_DESELECT_LABEL)"
>
<span v-for="(crumb, idx) in option.breadcrumb" :key="idx">
<template v-if="idx < option.breadcrumb.length - 1">
<i>{{ crumb }}</i> &gt;
</template>
<template v-else>
{{ crumb }}
</template>
</span>
</span>
</template>
</vue-multiselect>
<div class="input-group-append">
<peloton-component :stored-objects="motive?.storedObjects ?? null" />
<peloton-component
:stored-objects="motive ? motive.storedObjects : null"
/>
</div>
</div>
</div>
@@ -28,11 +57,11 @@
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed } from "vue";
import VueMultiselect from "vue-multiselect";
// Types
import { Motive } from "../../../../types";
import { Motive, MotiveWithParent } from "../../../../types";
// Translations
import {
@@ -46,10 +75,14 @@ import {
// Component
import PelotonComponent from "../PelotonComponent.vue";
// Utils
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import { useStore } from "vuex";
const props = defineProps({
modelValue: {
type: Object as () => Motive | undefined,
default: undefined,
type: Object as () => MotiveWithParent | Motive | null,
default: null,
},
motives: {
type: Array as () => Motive[],
@@ -59,34 +92,77 @@ const props = defineProps({
type: String,
default: "bottom",
},
});
const emit =
defineEmits<(e: "update:modelValue", value: Motive | undefined) => void>();
const motive = ref(props.modelValue);
watch(motive, (val) => {
emit("update:modelValue", val);
});
watch(
() => props.modelValue,
(val) => {
motive.value = val;
allowParentSelection: {
type: Boolean,
default: false,
},
);
disabled: {
type: Boolean,
default: false,
},
});
const store = useStore();
function customLabel(motive: Motive) {
return motive?.label?.fr;
}
const emit = defineEmits<{
(e: "update:modelValue", value: Motive | null): void;
(e: "remove", value: Motive): void;
}>();
const motive = computed<Motive | null>({
get() {
return store.getters.getMotiveById(props.modelValue?.id);
},
set(value: Motive | null) {
emit("update:modelValue", value);
},
});
const flattenedMotives = computed(() => {
const result: (Motive & {
isChild?: boolean;
isParent?: boolean;
level?: number;
displayLabel: string;
breadcrumb: string[];
})[] = [];
const processMotiveRecursively = (
motive: Motive,
isChild = false,
level = 0,
parentBreadcrumb: string[] = [],
) => {
const hasChildren = motive.children && motive.children.length > 0;
const displayLabel = localizeString(motive.label);
const breadcrumb = [...parentBreadcrumb, displayLabel];
if (props.allowParentSelection || !hasChildren) {
result.push({
...motive,
isChild,
isParent: hasChildren,
level,
breadcrumb,
displayLabel,
});
}
if (hasChildren) {
motive.children.forEach((childMotive) => {
processMotiveRecursively(childMotive, true, level + 1, breadcrumb);
});
}
};
props.motives.forEach((parentMotive) => {
processMotiveRecursively(parentMotive, false, 0, []);
});
return result;
});
</script>
<style lang="scss" scoped>
#selectMotive {
margin-bottom: 1.5em;
}
// Supprime le padding de .form-control pour ce composant
.form-control {
padding: 0 !important;
}

View File

@@ -1,16 +1,23 @@
<template>
<pick-entity
uniqid="ticket-person-selector"
:types="types"
:picked="pickedEntities"
:suggested="suggestedValues"
:multiple="multiple"
:removable-if-set="true"
:display-picked="true"
:label="label"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
<div
:class="{
'opacity-50': disabled,
}"
:style="disabled ? 'pointer-events: none;' : ''"
>
<pick-entity
uniqid="ticket-person-selector"
:types="types"
:picked="pickedEntities"
:suggested="suggestedValues"
:multiple="multiple"
:removable-if-set="true"
:display-picked="true"
:label="label"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
</div>
</template>
<script setup lang="ts">
@@ -22,13 +29,19 @@ import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
// Types
import { Entities, EntitiesOrMe, EntityType } from "ChillPersonAssets/types";
const props = defineProps<{
modelValue: EntitiesOrMe[] | EntitiesOrMe | null;
suggested: Entities[];
multiple: boolean;
types: EntityType[];
label: string;
}>();
const props = withDefaults(
defineProps<{
modelValue: EntitiesOrMe[] | EntitiesOrMe | null;
suggested: Entities[];
multiple: boolean;
types: EntityType[];
label: string;
disabled?: boolean;
}>(),
{
disabled: false,
},
);
const emit = defineEmits<{
"update:modelValue": [value: EntitiesOrMe[] | EntitiesOrMe | null];

View File

@@ -5,6 +5,7 @@
type="number"
class="form-control"
:placeholder="trans(CHILL_TICKET_LIST_FILTER_TICKET_ID)"
:disabled="disabled"
@input="
ticketId = isNaN(Number(($event.target as HTMLInputElement).value))
? null
@@ -26,9 +27,15 @@
import { ref, watch } from "vue";
// Translation
import { trans, CHILL_TICKET_LIST_FILTER_TICKET_ID } from "translator";
const props = defineProps<{
modelValue: number | null;
}>();
const props = withDefaults(
defineProps<{
modelValue: number | null;
disabled?: boolean;
}>(),
{
disabled: false,
},
);
const ticketId = ref<number | null>(props.modelValue);

View File

@@ -2,6 +2,13 @@
<div class="card mb-4">
<div class="card-body">
<form @submit.prevent="submitForm">
<div class="mb-3">
<label class="form-label pe-2" for="emergency">
{{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }}
</label>
<emergency-toggle-component v-model="isEmergency" class="float-end" />
</div>
<!-- Sélection du motif -->
<div class="mb-3">
<label class="form-label">{{
@@ -12,16 +19,7 @@
:motives="motives"
/>
</div>
<!-- Attribution des tickets -->
<div class="mb-3">
<label class="form-label">
{{ trans(CHILL_TICKET_TICKET_ADD_COMMENT_TITLE) }}
</label>
<addressee-selector-component
v-model="ticketForm.addressees"
:suggested="userGroups"
/>
</div>
<!-- Sélection des personnes -->
<div class="row mb-3">
<div class="col-md-6">
@@ -57,26 +55,20 @@
}}</label>
<comment-editor-component
v-model="ticketForm.content"
:motive="ticketForm.motive"
/>
</div>
<div class="mb-3">
<label class="form-label pe-2" for="emergency">
{{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }}
</label>
<toggle-component
v-model="isEmergency"
:on-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:classColor="{
on: 'bg-warning',
off: 'bg-secondary',
}"
id="emergency"
class="float-end"
:motive="ticketForm.motive ? ticketForm.motive : null"
/>
</div>
<!-- Attribution des tickets -->
<div class="mb-3">
<label class="form-label">
{{ trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE) }}
</label>
<addressee-selector-component
v-model="ticketForm.addressees"
:suggested="userGroups"
/>
</div>
<!-- Boutons d'action -->
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-secondary" @click="resetForm">
@@ -100,11 +92,11 @@ import MotiveSelectorComponent from "./Motive/MotiveSelectorComponent.vue";
import CommentEditorComponent from "./Comment/CommentEditorComponent.vue";
import PersonsSelectorComponent from "./Person/PersonsSelectorComponent.vue";
import AddresseeSelectorComponent from "./Addressee/AddresseeSelectorComponent.vue";
import ToggleComponent from "../../TicketList/components/ToggleComponent.vue";
import EmergencyToggleComponent from "./Emergency/EmergencyToggleComponent.vue";
// Types
import {
Motive,
MotiveWithParent,
Ticket,
TicketEmergencyState,
TicketInitForm,
@@ -123,6 +115,7 @@ import {
CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL,
CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL,
CHILL_TICKET_LIST_FILTER_EMERGENCY,
CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE,
} from "translator";
import { UserGroup, UserGroupOrUser } from "ChillMainAssets/types";
@@ -142,7 +135,7 @@ const store = useStore();
const ticketForm = reactive({
content: "",
addressees: props.ticket.currentAddressees as UserGroupOrUser[],
motive: props.ticket.currentMotive as Motive | null,
motive: props.ticket.currentMotive as MotiveWithParent | null,
persons: props.ticket.currentPersons as Person[],
caller: props.ticket.caller as Person | null,
emergency: props.ticket.emergency as TicketEmergencyState,
@@ -174,7 +167,7 @@ function submitForm() {
}
function resetForm() {
ticketForm.content = "";
ticketForm.motive = undefined;
ticketForm.motive = null;
ticketForm.persons = [];
ticketForm.caller = null;
ticketForm.emergency = props.ticket.emergency as TicketEmergencyState;

View File

@@ -21,6 +21,19 @@ export const moduleMotive: Module<State, RootState> = {
getMotives(state) {
return state.motives;
},
getMotiveById: (state) => (motiveId: number) => {
const findInChildren = (motives: Motive[]): Motive | null => {
for (const motive of motives) {
if (motive.id === motiveId) return motive;
const found = motive.children?.length
? findInChildren(motive.children)
: null;
if (found) return found;
}
return null;
};
return findInChildren(state.motives);
},
},
mutations: {
setMotives(state, motives) {

View File

@@ -3,7 +3,7 @@ import { Person } from "../../../../../../../../ChillPersonBundle/Resources/publ
import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types";
import { Module } from "vuex";
import { RootState } from "..";
import { Ticket } from "../../../../types";
import { Ticket } from ".././../../../types";
import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types";
export interface State {

View File

@@ -1,6 +1,5 @@
import { Module } from "vuex";
import { RootState } from "..";
import { TicketFilterParams, TicketSimple } from "../../../../types";
import {
makeFetch,
@@ -76,7 +75,7 @@ export const moduleTicketList: Module<State, RootState> = {
const filteredParams = Object.fromEntries(
Object.entries(ticketFilterParams).filter(
([, value]) =>
value !== undefined &&
value !== null &&
value !== null &&
(value === true ||
(typeof value === "number" && !isNaN(value)) ||

View File

@@ -8,7 +8,8 @@ import {
CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
} from "translator";
import { Ticket, TicketSimple } from "../../../types";
import {MotiveWithParent, Ticket, TicketSimple} from "../../../types";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
/**
* Calcule et formate le temps écoulé depuis une date de création
@@ -65,17 +66,6 @@ export function getSinceCreated(createdAt: string, currentTime: Date): string {
return parts[0];
}
export function localizeTranslatableString(
translatableString: Record<string, string> | string,
): string {
// This would be implemented based on your localization logic
if (typeof translatableString === "string") {
return translatableString;
}
// Assuming it's an object with locale keys
return translatableString?.fr || translatableString?.en || "Unknown";
}
export function formatDateTime(
dateTime: string,
dateStyle: string,
@@ -88,7 +78,23 @@ export function formatDateTime(
}
export function getTicketTitle(ticket: Ticket | TicketSimple): string {
if (ticket.currentMotive) {
return `#${ticket.id} ${localizeTranslatableString(ticket.currentMotive.label)}`;
return `#${ticket.id} ${localizeString(ticket.currentMotive.label)}`;
}
return `#${ticket.id} ${trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE)}`;
}
export function motiveLabelRecursive(motive: MotiveWithParent|null): string {
console.log('test', motive);
if (null === motive) {
return "";
}
const str = [];
let m: MotiveWithParent|null = motive;
do {
str.push(localizeString(m.label));
m = m.parent;
} while (m !== null);
return str.reverse().join(" > ");
}

View File

@@ -1,14 +1,12 @@
<template>
<div class="container-fluid">
<h1 class="text-primary">
{{ title }}
</h1>
<div class="row">
<div class="col-12 mb-4">
<ticket-filter-list-component
:resultCount="resultCount"
:available-persons="availablePersons"
:available-motives="availableMotives"
:ticket-filter-params="ticketFilterParams"
@filters-changed="handleFiltersChanged"
/>
</div>
@@ -35,7 +33,6 @@
<ticket-list-component
v-else
:tickets="ticketList"
:title="title"
:hasMoreTickets="pagination.next !== null"
@fetchNextPage="fetchNextPage"
/>
@@ -61,8 +58,10 @@ import { Pagination } from "ChillMainAssets/lib/api/apiMethods";
import { trans, CHILL_TICKET_LIST_LOADING_TICKET } from "translator";
const store = useStore();
const ticketFilterParams = window.ticketFilterParams
? window.ticketFilterParams
: null;
const title = window.title;
const isLoading = ref(false);
const ticketList = computed(
() => store.getters.getTicketList as TicketSimple[],
@@ -90,12 +89,27 @@ const fetchNextPage = async () => {
onMounted(async () => {
isLoading.value = true;
const filters: TicketFilterParams = {
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: "",
byAddresseeToMe: false,
byPerson: ticketFilterParams?.byPerson
? ticketFilterParams.byPerson.map((person) => person.id)
: [],
byCreator: ticketFilterParams?.byCreator
? ticketFilterParams.byCreator.map((creator) => creator.id)
: [],
byAddressee: ticketFilterParams?.byAddressee
? ticketFilterParams.byAddressee.map((addressee) => addressee.id)
: [],
byCurrentState: ticketFilterParams?.byCurrentState ?? ["open"],
byCurrentStateEmergency: ticketFilterParams?.byCurrentStateEmergency ?? [],
byMotives: ticketFilterParams?.byMotives
? ticketFilterParams.byMotives.map((motive) => motive.id)
: [],
byCreatedAfter: ticketFilterParams?.byCreatedAfter ?? "",
byCreatedBefore: ticketFilterParams?.byCreatedBefore ?? "",
byResponseTimeExceeded: ticketFilterParams?.byResponseTimeExceeded
? "true"
: "",
byAddresseeToMe: ticketFilterParams?.byAddresseeToMe ?? false,
byTicketId: ticketFilterParams?.byTicketId ?? null,
};
try {
await store.dispatch("fetchTicketList", filters);

View File

@@ -14,12 +14,13 @@
trans(CHILL_TICKET_LIST_FILTER_PERSONS_CONCERNED)
}}</label>
<persons-selector
v-model="selectedPersons"
v-model="filters.byPerson"
:suggested="availablePersons"
:multiple="true"
:types="['person']"
id="personSelector"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_PERSON)"
:disabled="ticketFilterParams?.byPerson ? true : false"
/>
</div>
@@ -28,12 +29,13 @@
trans(CHILL_TICKET_LIST_FILTER_CREATORS)
}}</label>
<persons-selector
v-model="selectedCreator"
v-model="filters.byCreator"
:suggested="[]"
:multiple="true"
:types="['user']"
id="userSelector"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_CREATOR)"
:disabled="ticketFilterParams?.byCreator ? true : false"
/>
</div>
@@ -42,10 +44,11 @@
trans(CHILL_TICKET_LIST_FILTER_ADDRESSEES)
}}</label>
<addressee-selector-component
v-model="selectedAddressees"
v-model="filters.byAddressee"
:suggested="[]"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES)"
id="addresseeSelector"
:disabled="ticketFilterParams?.byAddressee ? true : false"
/>
</div>
<div class="col-md-6 mb-3">
@@ -57,13 +60,16 @@
<motive-selector
v-model="selectedMotive"
:motives="availableMotives"
:allow-parent-selection="true"
@remove="(motive) => removeMotive(motive)"
id="motiveSelector"
:disabled="ticketFilterParams?.byMotives ? true : false"
/>
<div class="mb-2" style="min-height: 2em">
<div class="d-flex flex-wrap gap-2">
<span
v-for="motive in selectedMotives"
v-for="motive in filters.byMotives"
:key="motive.id"
class="badge bg-secondary d-flex align-items-center gap-1"
>
@@ -73,6 +79,7 @@
class="btn-close btn-close-white"
:aria-label="trans(CHILL_TICKET_LIST_FILTER_REMOVE)"
@click="removeMotive(motive)"
:disabled="ticketFilterParams?.byMotives ? true : false"
></button>
</span>
</div>
@@ -94,6 +101,7 @@
}"
@update:model-value="handleStateToggle"
id="currentState"
:disabled="ticketFilterParams?.byCurrentState ? true : false"
/>
</div>
@@ -112,6 +120,9 @@
}"
@update:model-value="handleEmergencyToggle"
id="emergency"
:disabled="
ticketFilterParams?.byCurrentStateEmergency ? true : false
"
/>
</div>
</div>
@@ -127,6 +138,7 @@
class="form-check-input"
type="checkbox"
id="stateMe"
:disabled="ticketFilterParams?.byAddresseeToMe ? true : false"
/>
<label class="form-check-label" for="stateMe">
{{ trans(CHILL_TICKET_LIST_FILTER_TO_ME) }}
@@ -140,6 +152,9 @@
v-model="filters.byResponseTimeExceeded"
@change="handleResponseTimeExceededChange"
id="responseTimeExceeded"
:disabled="
ticketFilterParams?.byResponseTimeExceeded ? true : false
"
/>
<label class="form-check-label" for="responseTimeExceeded">
{{ trans(CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_EXCEEDED) }}
@@ -155,7 +170,11 @@
<label class="form-label pe-2" for="ticketSelector">
{{ trans(CHILL_TICKET_LIST_FILTER_BY_TICKET_ID) }}
</label>
<ticket-selector v-model="filters.byTicketId" id="ticketSelector" />
<ticket-selector
v-model="filters.byTicketId"
id="ticketSelector"
:disabled="ticketFilterParams?.byTicketId ? true : false"
/>
</div>
</div>
@@ -168,7 +187,12 @@
default-value-time="00:00"
:model-value-date="filters.byCreatedAfter"
:model-value-time="byCreatedAfterTime"
:disabled="filters.byResponseTimeExceeded"
:disabled="
filters.byResponseTimeExceeded ||
ticketFilterParams?.byCreatedAfter
? true
: false
"
@update:modelValueDate="filters.byCreatedAfter = $event"
@update:modelValueTime="byCreatedAfterTime = $event"
/>
@@ -181,7 +205,12 @@
default-value-time="23:59"
:model-value-date="filters.byCreatedBefore"
:model-value-time="byCreatedBeforeTime"
:disabled="filters.byResponseTimeExceeded"
:disabled="
filters.byResponseTimeExceeded ||
ticketFilterParams?.byCreatedBefore
? true
: false
"
@update:modelValueDate="filters.byCreatedBefore = $event"
@update:modelValueTime="byCreatedBeforeTime = $event"
/>
@@ -225,7 +254,6 @@ import {
type TicketFilterParams,
type TicketFilters,
} from "../../../types";
import { User, UserGroupOrUser } from "ChillMainAssets/types";
// Translation
import {
@@ -267,6 +295,7 @@ const props = defineProps<{
availablePersons?: Person[];
availableMotives: Motive[];
resultCount: number;
ticketFilterParams: TicketFilters | null;
}>();
// Emits
@@ -274,75 +303,66 @@ const emit = defineEmits<{
"filters-changed": [filters: TicketFilterParams];
}>();
const filtersInitValues: TicketFilters = {
byPerson: props.ticketFilterParams?.byPerson ?? [],
byCreator: props.ticketFilterParams?.byCreator ?? [],
byAddressee: props.ticketFilterParams?.byAddressee ?? [],
byCurrentState: props.ticketFilterParams?.byCurrentState ?? ["open"],
byCurrentStateEmergency:
props.ticketFilterParams?.byCurrentStateEmergency ?? [],
byMotives: props.ticketFilterParams?.byMotives ?? [],
byCreatedAfter: props.ticketFilterParams?.byCreatedAfter ?? "",
byCreatedBefore: props.ticketFilterParams?.byCreatedBefore ?? "",
byResponseTimeExceeded:
props.ticketFilterParams?.byResponseTimeExceeded ?? false,
byAddresseeToMe: props.ticketFilterParams?.byAddresseeToMe ?? false,
byTicketId: props.ticketFilterParams?.byTicketId ?? null,
};
// État réactif
const filters = ref<TicketFilters>({
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: false,
byAddresseeToMe: false,
byTicketId: null,
});
const filters = ref<TicketFilters>({ ...filtersInitValues });
const byCreatedAfterTime = ref("00:00");
const byCreatedBeforeTime = ref("23:59");
const isClosedToggled = ref(false);
const isEmergencyToggled = ref(false);
// Sélection des personnes
const selectedPersons = ref<Person[]>([]);
const availablePersons = ref<Person[]>(props.availablePersons || []);
const selectedMotive = ref<Motive | null>();
// Sélection des utilisateur assigné
const selectedAddressees = ref<UserGroupOrUser[]>([]);
// Séléction des créateurs
const selectedCreator = ref<User[]>([]);
// Sélection des motifs
const selectedMotive = ref<Motive | undefined>();
const selectedMotives = ref<Motive[]>([]);
// Watchers pour les sélecteurs
watch(selectedMotive, (newMotive) => {
if (newMotive && !selectedMotives.value.find((m) => m.id === newMotive.id)) {
selectedMotives.value.push(newMotive);
selectedMotive.value = undefined; // Reset pour permettre une nouvelle sélection
if (
newMotive &&
!filters.value.byMotives.find((m) => m.id === newMotive.id)
) {
filters.value.byMotives = [...filters.value.byMotives, newMotive];
}
});
// Computed pour les IDs des personnes sélectionnées
const selectedPersonIds = computed(() =>
selectedPersons.value.map((person) => person.id),
filters.value.byPerson.map((person) => person.id),
);
// Computed pour les IDs des utilisateur ou groupes sélectionnées
const selectedUserAddresseesIds = computed(() =>
selectedAddressees.value
filters.value.byAddressee
.filter((addressee) => addressee.type === "user")
.map((addressee) => addressee.id),
);
const selectedGroupAddresseesIds = computed(() =>
selectedAddressees.value
filters.value.byAddressee
.filter((addressee) => addressee.type === "user_group")
.map((addressee) => addressee.id),
);
// Computed pour les IDs des créateurs
const selectedCreatorIds = computed(() =>
selectedCreator.value.map((creator) => creator.id),
filters.value.byCreator.map((creator) => creator.id),
);
// Computed pour les IDs des motifs sélectionnés
const selectedMotiveIds = computed(() =>
selectedMotives.value.map((motive) => motive.id),
filters.value.byMotives.map((motive) => motive.id),
);
// Nouveaux états pour les toggles
const isClosedToggled = ref(false);
const isEmergencyToggled = ref(false);
// Méthodes pour gérer les toggles
const handleStateToggle = (value: boolean) => {
if (value) {
filters.value.byCurrentState = ["closed"];
@@ -378,11 +398,11 @@ const getMotiveDisplayName = (motive: Motive): string => {
};
const removeMotive = (motiveToRemove: Motive): void => {
const index = selectedMotives.value.findIndex(
(m) => m.id === motiveToRemove.id,
filters.value.byMotives = filters.value.byMotives.filter(
(m) => m.id !== motiveToRemove.id,
);
if (index !== -1) {
selectedMotives.value.splice(index, 1);
if (selectedMotive.value && motiveToRemove.id == selectedMotive.value.id) {
selectedMotive.value = null;
}
};
@@ -443,22 +463,12 @@ const applyFilters = (): void => {
};
const resetFilters = (): void => {
filters.value = {
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: false,
byAddresseeToMe: false,
byTicketId: null,
};
selectedPersons.value = [];
selectedCreator.value = [];
selectedAddressees.value = [];
selectedMotives.value = [];
selectedMotive.value = undefined;
filters.value = { ...filtersInitValues };
selectedMotive.value = null;
isClosedToggled.value = false;
isEmergencyToggled.value = false;
byCreatedAfterTime.value = "00:00";
byCreatedBeforeTime.value = "23:59";
applyFilters();
};

View File

@@ -97,7 +97,6 @@ import { useStore } from "vuex";
defineProps<{
tickets: TicketSimple[];
hasMoreTickets: boolean;
title: string;
}>();
const emit = defineEmits<{

View File

@@ -17,11 +17,7 @@
: classColor?.off || 'bg-danger',
]"
:style="{
backgroundColor: classColor
? undefined
: !modelValue
? colorOff
: colorOn,
backgroundColor: classColor ? '' : !modelValue ? colorOff : colorOn,
height: '28px',
width: toggleWidth + 'px',
}"
@@ -72,7 +68,7 @@ const props = withDefaults(defineProps<Props>(), {
onLabel: "ON",
offLabel: "OFF",
disabled: false,
id: undefined,
id: "",
colorOn: "#4caf50",
colorOff: "#ccc",
classColor: () => ({

View File

@@ -3,10 +3,12 @@ import { createApp } from "vue";
import { store } from "../TicketApp/store";
import VueToast from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
import { TicketFilters } from "../../types";
declare global {
interface Window {
title: string;
ticketFilterParams: TicketFilters;
}
}

View File

@@ -0,0 +1,32 @@
{% extends "@ChillPerson/Person/layout.html.twig" %}
{% set ticketTitle = 'chill_ticket.list.title_with_name'|trans({'%name%': person|chill_entity_render_string }) %}
{% set activeRouteKey = 'chill_person_ticket_list' %}
{% set ticketFilterParams = {
'byPerson': [person]
} %}
{% block title %}{{ ticketTitle }}{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('vue_ticket_list') }}
{% endblock %}
{% block js %}
{{ parent() }}
<script type="text/javascript">
window.ticketFilterParams = {{ ticketFilterParams|serialize|raw }};
</script>
{{ encore_entry_script_tags('vue_ticket_list') }}
{% endblock %}
{% block content %}
<h1>{{ ticketTitle }}</h1>
<div id="ticketList"></div>
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ chill_path_add_return_path('chill_ticket_createticket__invoke') }}" class="btn btn-create">{{ 'Create'|trans }}</a>
</li>
</ul>
{% endblock %}

View File

@@ -1,4 +1,6 @@
{% extends '@ChillMain/layout.html.twig' %}
{% set ticketTitle = 'chill_ticket.list.title'|trans %}
{% block title %}{{ ticketTitle }}{% endblock %}
{% block css %}
{{ parent() }}
@@ -7,13 +9,11 @@
{% block js %}
{{ parent() }}
<script type="text/javascript">
window.title = "{{ 'chill_ticket.list.title'|trans|escape('js') }}";
</script>
{{ encore_entry_script_tags('vue_ticket_list') }}
{% endblock %}
{% block content %}
<h1>{{ ticketTitle }}</h1>
<div id="ticketList"></div>
<ul class="record_actions sticky-form-buttons">

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\TicketBundle\Entity\Motive;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Normalizes a Motive object into an array format, supporting different serialization groups
* to customize the output depending on the context.
*
* There are several serialization groups available:
* - 'read': Basic information about the motive.
* - 'read:extended': Includes additional details like stored objects and supplementary comments.
* - 'read:parent-to-children': Normalizes children recursively without exposing parent to avoid cycles.
* - 'read:children-to-parent': Normalizes parent recursively without exposing children to avoid cycles.
*/
final class MotiveNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public const GROUP_PARENT_TO_CHILDREN = 'read:parent-to-children';
public const GROUP_CHILDREN_TO_PARENT = 'read:children-to-parent';
public function normalize($object, ?string $format = null, array $context = []): array
{
if (!$object instanceof Motive) {
throw new UnexpectedValueException('Expected instance of '.Motive::class);
}
$groups = $context[AbstractNormalizer::GROUPS] ?? [];
if (is_string($groups)) {
$groups = [$groups];
}
$data = [];
if (in_array('read', $groups, true) || in_array('read:extended', $groups, true)) {
// Build base representation
$data = [
'type' => 'ticket_motive',
'id' => $object->getId(),
'label' => $object->getLabel(),
'active' => $object->isActive(),
];
}
if (in_array('read:extended', $groups, true)) {
$data['makeTicketEmergency'] = $object->getMakeTicketEmergency();
$data['supplementaryComments'] = $object->getSupplementaryComments();
// Normalize stored objects (delegated to their own normalizer when present)
$storedObjects = [];
foreach ($object->getStoredObjects() as $storedObject) {
$storedObjects[] = $this->normalizer->normalize($storedObject, $format, $context);
}
$data['storedObjects'] = $storedObjects;
}
if (in_array(self::GROUP_PARENT_TO_CHILDREN, $groups, true)) {
// Normalize children recursively (but we do not expose parent to avoid cycles)
$children = [];
foreach ($object->getChildren() as $child) {
$children[] = $this->normalizer->normalize($child, $format, $context);
}
$data['children'] = $children;
} elseif (in_array(self::GROUP_CHILDREN_TO_PARENT, $groups, true)) {
$data['parent'] = $this->normalizer->normalize($object->getParent(), $format, $context);
}
return $data;
}
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
return $data instanceof Motive;
}
/**
* Optimization hint for the Serializer (available since Symfony 5.3+).
*
* @return array<class-string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [
Motive::class => true,
];
}
}

View File

@@ -51,7 +51,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
]),
'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']),
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]),
'currentState' => $object->getState()?->value ?? 'open',
'emergency' => $object->getEmergencyStatus()?->value ?? 'no',
'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']),
@@ -167,12 +167,20 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'event_type' => $data['event_type'],
'at' => $this->normalizer->normalize($data['at'], $format, $context),
'by' => $this->normalizer->normalize($data['by'], $format, $context),
'data' => $this->normalizer->normalize($data['data'], $format, $context),
'data' => $this->normalizer->normalize($data['data'], $format, $this->contextByEventType($data['event_type'], $context)),
],
$events
);
}
private function contextByEventType(string $eventType, array $context): array
{
return match($eventType) {
'set_motive' => array_merge($context, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]),
default => $context,
};
}
private function addresseesStates(Ticket $ticket): array
{
/** @var array{string, array{added: list<AddresseeHistory>, removed: list<AddresseeHistory>}} $changes */

View File

@@ -17,6 +17,9 @@ services:
tags:
- controller.service_arguments
Chill\TicketBundle\Event\EventSubscriber\:
resource: '../Event/EventSubscriber/'
Chill\TicketBundle\Repository\:
resource: '../Repository/'
@@ -35,6 +38,9 @@ services:
Chill\TicketBundle\Menu\:
resource: '../Menu/'
Chill\TicketBundle\Messenger\Handler\:
resource: '../Messenger/Handler'
Chill\TicketBundle\Validation\:
resource: '../Validation/'

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250924124214 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add parent to motive';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive ADD parent_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_ticket.motive ADD CONSTRAINT FK_DE298BF8727ACA70 FOREIGN KEY (parent_id) REFERENCES chill_ticket.motive (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_DE298BF8727ACA70 ON chill_ticket.motive (parent_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive DROP CONSTRAINT FK_DE298BF8727ACA70');
$this->addSql('DROP INDEX chill_ticket.IDX_DE298BF8727ACA70');
$this->addSql('ALTER TABLE chill_ticket.motive DROP parent_id');
}
}

View File

@@ -1,7 +1,9 @@
restore: Restaurer
chill_ticket:
list:
title: Tickets
title: "Tickets"
title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}"
title_menu: "Tickets de l'usager"
title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}"
no_tickets: "Aucun ticket"
loading_ticket: "Chargement des tickets..."

View File

@@ -16,9 +16,13 @@ use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\EmergencyStatusUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal
@@ -36,7 +40,10 @@ final class ChangeEmergencyStateCommandHandlerTest extends TestCase
// Create a YES emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::type(EmergencyStatusUpdateEvent::class), TicketUpdateEvent::class)->shouldNotBeCalled();
$handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES);
$result = $handler->__invoke($ticket, $command);
@@ -57,7 +64,17 @@ final class ChangeEmergencyStateCommandHandlerTest extends TestCase
// Create a YES emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(
fn ($e) => $e instanceof EmergencyStatusUpdateEvent
&& EmergencyStatusEnum::YES === $e->previousEmergencyStatus
&& EmergencyStatusEnum::NO === $e->newEmergencyStatus
),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO);
$result = $handler->__invoke($ticket, $command);
@@ -90,7 +107,17 @@ final class ChangeEmergencyStateCommandHandlerTest extends TestCase
// Create a NO emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(
fn ($e) => $e instanceof EmergencyStatusUpdateEvent
&& EmergencyStatusEnum::NO === $e->previousEmergencyStatus
&& EmergencyStatusEnum::YES === $e->newEmergencyStatus
),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES);
$result = $handler->__invoke($ticket, $command);

View File

@@ -19,16 +19,19 @@ use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\MotiveUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal
*
* @coversNothing
* @covers \Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler
*/
final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
{
@@ -37,14 +40,18 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
private function buildHandler(
EntityManagerInterface $entityManager,
?ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler = null,
?EventDispatcherInterface $eventDispatcher = null,
): ReplaceMotiveCommandHandler {
$clock = new MockClock();
if (null === $changeEmergencyStateCommandHandler) {
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class)->reveal();
}
if (null === $eventDispatcher) {
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class)->reveal();
}
return new ReplaceMotiveCommandHandler($clock, $entityManager, $changeEmergencyStateCommandHandler);
return new ReplaceMotiveCommandHandler($clock, $entityManager, $changeEmergencyStateCommandHandler, $eventDispatcher);
}
public function testHandleOnTicketWithoutMotive(): void
@@ -61,7 +68,16 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(fn ($event) => $event instanceof MotiveUpdateEvent
&& $event->newMotive === $motive
&& null === $event->previousMotive
&& $event->hasChanges()),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
@@ -83,7 +99,17 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$previous = $history->getMotive();
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(fn ($event) => $event instanceof MotiveUpdateEvent
&& $event->newMotive === $motive
&& $previous === $event->previousMotive
&& $event->hasChanges()),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
@@ -106,7 +132,10 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
return $arg->getMotive() === $motive;
}))->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::any(), TicketUpdateEvent::class)->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
@@ -134,10 +163,15 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus)
)->shouldBeCalled();
// Expect event dispatch for motive update
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::type(MotiveUpdateEvent::class), TicketUpdateEvent::class)->shouldBeCalled();
// Create the handler with our mocks
$handler = $this->buildHandler(
$entityManager->reveal(),
$changeEmergencyStateCommandHandler->reveal()
$changeEmergencyStateCommandHandler->reveal(),
$eventDispatcher->reveal()
);
// Handle the command
@@ -166,10 +200,15 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
Argument::cetera()
)->shouldNotBeCalled();
// Expect event dispatch for motive update
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::type(MotiveUpdateEvent::class), TicketUpdateEvent::class)->shouldBeCalled();
// Create the handler with our mocks
$handler = $this->buildHandler(
$entityManager->reveal(),
$changeEmergencyStateCommandHandler->reveal()
$changeEmergencyStateCommandHandler->reveal(),
$eventDispatcher->reveal()
);
// Handle the command

View File

@@ -17,12 +17,15 @@ use Chill\TicketBundle\Action\Ticket\Handler\SetPersonsCommandHandler;
use Chill\TicketBundle\Action\Ticket\SetPersonsCommand;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\PersonsUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal
@@ -42,7 +45,16 @@ final class SetPersonsCommandHandlerTest extends TestCase
$entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person1))->shouldBeCalledOnce();
$entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $group1))->shouldBeCalledOnce();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(fn ($arg) => $arg instanceof PersonsUpdateEvent
&& in_array($person1, $arg->personsAdded, true)
&& in_array($group1, $arg->personsAdded, true)
&& [] === $arg->personsRemoved),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal());
$handler->handle($ticket, $command);
@@ -59,7 +71,15 @@ final class SetPersonsCommandHandlerTest extends TestCase
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(
fn ($arg) => $arg instanceof PersonsUpdateEvent
),
TicketUpdateEvent::class
)->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal());
$handler->handle($ticket, $command);
@@ -78,7 +98,17 @@ final class SetPersonsCommandHandlerTest extends TestCase
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person2))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(
fn ($arg) => $arg instanceof PersonsUpdateEvent
&& in_array($person, $arg->personsRemoved, true) && 1 === count($arg->personsRemoved)
&& in_array($person2, $arg->personsAdded, true) && 1 === count($arg->personsAdded)
),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal());
$handler->handle($ticket, $command);
@@ -95,18 +125,28 @@ final class SetPersonsCommandHandlerTest extends TestCase
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldBeCalledOnce();
$handler = $this->buildHandler($entityManager->reveal());
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(
Argument::that(
fn ($arg) => $arg instanceof PersonsUpdateEvent
&& in_array($person, $arg->personsAdded, true) && 1 === count($arg->personsAdded)
&& [] === $arg->personsRemoved
),
TicketUpdateEvent::class
)->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal());
$handler->handle($ticket, $command);
self::assertCount(1, $ticket->getPersons());
}
private function buildHandler(EntityManagerInterface $entityManager): SetPersonsCommandHandler
private function buildHandler(EntityManagerInterface $entityManager, EventDispatcherInterface $eventDispatcher): SetPersonsCommandHandler
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(new User());
return new SetPersonsCommandHandler(new MockClock(), $entityManager, $security->reveal());
return new SetPersonsCommandHandler(new MockClock(), $entityManager, $security->reveal(), $eventDispatcher);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Entity;
use Chill\TicketBundle\Entity\Motive;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @covers \Chill\TicketBundle\Entity\Motive::getWithDescendants
*/
final class MotiveTest extends TestCase
{
public function testGetDescendantsOnLeafReturnsSelfOnly(): void
{
$leaf = new Motive();
$leaf->setLabel(['fr' => 'Feuille']);
$collection = $leaf->getDescendants();
self::assertCount(1, $collection);
self::assertSame($leaf, $collection->first());
self::assertContains($leaf, $collection->toArray());
}
public function testGetWithDescendantsReturnsSelfAndAllDescendants(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$childA = new Motive();
$childA->setLabel(['fr' => 'Enfant A']);
$childA->setParent($parent);
$childB = new Motive();
$childB->setLabel(['fr' => 'Enfant B']);
$childB->setParent($parent);
$grandChildA1 = new Motive();
$grandChildA1->setLabel(['fr' => 'Petit-enfant A1']);
$grandChildA1->setParent($childA);
$descendants = $parent->getDescendants();
$asArray = $descendants->toArray();
// It should contain the parent itself, both children and the grand child
self::assertCount(4, $descendants, 'Expected parent + 2 children + 1 grandchild');
self::assertContains($parent, $asArray);
self::assertContains($childA, $asArray);
self::assertContains($childB, $asArray);
self::assertContains($grandChildA1, $asArray);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\tests\Event\EventSubscriber;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\EventSubscriber\GeneratePostUpdateTicketEventSubscriber;
use Chill\TicketBundle\Event\TicketUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateKindEnum;
use Chill\TicketBundle\Messenger\PostTicketUpdateMessage;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @covers \Chill\TicketBundle\Event\EventSubscriber\GeneratePostUpdateTicketEventSubscriber
*
* @internal
*/
class GeneratePostUpdateTicketEventSubscriberTest extends TestCase
{
use ProphecyTrait;
public function testOnTicketUpdate(): void
{
$ticket = new Ticket();
$reflection = new \ReflectionClass(Ticket::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($ticket, 1);
$event = new class (TicketUpdateKindEnum::UPDATE_MOTIVE, $ticket) extends TicketUpdateEvent {};
$messageBus = $this->prophesize(MessageBusInterface::class);
$messageBus->dispatch(Argument::that(fn ($arg) => $arg instanceof PostTicketUpdateMessage && TicketUpdateKindEnum::UPDATE_MOTIVE === $arg->updateKind && 1 === $arg->ticketId))
->will(fn ($args) => new Envelope($args[0]))
->shouldBeCalled();
$eventSubscriber = new GeneratePostUpdateTicketEventSubscriber($messageBus->reveal());
$eventSubscriber->onTicketUpdate($event);
$kernel = $this->prophesize(KernelInterface::class);
$terminate = new TerminateEvent($kernel->reveal(), new Request(), new Response());
$eventSubscriber->onKernelTerminate($terminate);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\tests\Messenger\Handler;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Event\PostTicketUpdateEvent;
use Chill\TicketBundle\Event\TicketUpdateKindEnum;
use Chill\TicketBundle\Messenger\PostTicketUpdateMessage;
use Chill\TicketBundle\Messenger\Handler\PostTicketUpdateMessageHandler;
use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @covers \Chill\TicketBundle\Messenger\Handler\PostTicketUpdateMessageHandler
*
* @internal
*/
class PostTicketUpdateMessageHandlerTest extends TestCase
{
use ProphecyTrait;
public function testDispatchesEventWhenTicketExists(): void
{
// Arrange: a Ticket with an ID
$ticket = new Ticket();
$reflection = new \ReflectionClass(Ticket::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($ticket, 123);
$message = new PostTicketUpdateMessage($ticket, TicketUpdateKindEnum::UPDATE_MOTIVE);
// Mock repository to return the Ticket when searching by id
$ticketRepository = $this->prophesize(TicketRepositoryInterface::class);
$ticketRepository->find(123)->willReturn($ticket)->shouldBeCalledOnce();
// Expect the dispatcher to dispatch a PostTicketUpdateEvent with correct data
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher
->dispatch(Argument::that(fn ($event) => $event instanceof PostTicketUpdateEvent
&& TicketUpdateKindEnum::UPDATE_MOTIVE === $event->updateKind
&& $event->ticket === $ticket))
->will(fn ($args) => $args[0])
->shouldBeCalledOnce();
$handler = new PostTicketUpdateMessageHandler($eventDispatcher->reveal(), $ticketRepository->reveal());
// Act
$handler($message);
// Assert: expectations asserted by Prophecy
self::assertTrue(true);
}
public function testThrowsWhenTicketNotFound(): void
{
// Arrange: a Ticket with an ID for the message, but repository will return null
$ticket = new Ticket();
$reflection = new \ReflectionClass(Ticket::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($ticket, 999);
$message = new PostTicketUpdateMessage($ticket, TicketUpdateKindEnum::UPDATE_MOTIVE);
$ticketRepository = $this->prophesize(TicketRepositoryInterface::class);
$ticketRepository->find(999)->willReturn(null)->shouldBeCalledOnce();
$eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled();
$handler = new PostTicketUpdateMessageHandler($eventDispatcher->reveal(), $ticketRepository->reveal());
// Assert: exception is thrown
$this->expectException(UnrecoverableMessageHandlingException::class);
$this->expectExceptionMessage('Ticket not found');
// Act
$handler($message);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @covers \Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer
*/
final class MotiveNormalizerTest extends TestCase
{
public function testNormalizeReadBasic(): void
{
$motive = new Motive();
$motive->setLabel(['fr' => 'Logement', 'en' => 'Housing']);
// active is true by default
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($motive, 'json', ['groups' => 'read']);
self::assertSame('ticket_motive', $actual['type']);
self::assertNull($actual['id']);
self::assertSame(['fr' => 'Logement', 'en' => 'Housing'], $actual['label']);
self::assertTrue($actual['active']);
// no extended fields here
self::assertArrayNotHasKey('makeTicketEmergency', $actual);
self::assertArrayNotHasKey('supplementaryComments', $actual);
self::assertArrayNotHasKey('storedObjects', $actual);
self::assertArrayNotHasKey('children', $actual);
}
public function testNormalizeExtended(): void
{
$motive = new Motive();
$motive->setLabel(['fr' => 'Financier']);
$motive->setMakeTicketEmergency(EmergencyStatusEnum::YES);
$motive->addSupplementaryComment(['label' => 'Justifier le revenu']);
$motive->addStoredObject(new StoredObject('pending'));
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($motive, 'json', ['groups' => ['read', 'read:extended']]);
self::assertSame('ticket_motive', $actual['type']);
self::assertSame(['fr' => 'Financier'], $actual['label']);
self::assertSame(EmergencyStatusEnum::YES, $actual['makeTicketEmergency']);
self::assertSame([
['label' => 'Justifier le revenu'],
], $actual['supplementaryComments']);
self::assertSame([
['stored_object'],
], $actual['storedObjects']);
}
public function testNormalizeParentToChildren(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$child1 = new Motive();
$child1->setLabel(['fr' => 'Enfant 1']);
$child2 = new Motive();
$child2->setLabel(['fr' => 'Enfant 2']);
// build relation
$child1->setParent($parent);
$child2->setParent($parent);
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($parent, 'json', ['groups' => [MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]]);
// children must be normalized by the injected normalizer and parent not exposed
self::assertArrayHasKey('children', $actual);
self::assertSame([
['motive' => 'normalized'],
['motive' => 'normalized'],
], $actual['children']);
self::assertArrayNotHasKey('parent', $actual);
}
public function testNormalizeChildrenToParent(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$child = new Motive();
$child->setLabel(['fr' => 'Enfant']);
$child->setParent($parent);
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($child, 'json', ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]);
// parent must be normalized by the injected normalizer and children not exposed
self::assertArrayHasKey('parent', $actual);
self::assertSame(['motive' => 'normalized'], $actual['parent']);
self::assertArrayNotHasKey('children', $actual);
}
public function testSupportsAndSupportedTypes(): void
{
$motive = new Motive();
$normalizer = new MotiveNormalizer();
self::assertTrue($normalizer->supportsNormalization($motive, 'json'));
self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json'));
$supported = $normalizer->getSupportedTypes('json');
self::assertArrayHasKey(Motive::class, $supported);
self::assertTrue($supported[Motive::class]);
}
private function buildDummyNormalizer(): NormalizerInterface
{
return new class () implements NormalizerInterface {
public function normalize($object, ?string $format = null, array $context = []): array
{
if ($object instanceof StoredObject) {
return ['stored_object'];
}
if ($object instanceof Motive) {
return ['motive' => 'normalized'];
}
return ['normalized'];
}
public function supportsNormalization($data, ?string $format = null): bool
{
return true;
}
};
}
}

View File

@@ -19,6 +19,7 @@
"sourceMap": true
},
"includes": [
"./assets/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue"
],

View File

@@ -104,7 +104,7 @@ module.exports = (async () => {
await populateConfig(Encore, chillEntries);
Encore.addAliases({
translator: resolve(__dirname, './assets/translator'),
translator: resolve(__dirname, 'assets/translator.ts'),
"@symfony/ux-translator": resolve(__dirname, './vendor/symfony/ux-translator/assets'),
});