Compare commits

..

6 Commits

Author SHA1 Message Date
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
45 changed files with 986 additions and 320 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"}); setLocaleFallbacks({ en: "fr", nl: "fr", fr: "en" });
setLocale('fr'); setLocale("fr");
export { trans }; export { trans, getLocale };
export * from '../var/translations'; export * from "../var/translations";

View File

@@ -14,7 +14,6 @@ namespace Chill\MainBundle;
use Chill\MainBundle\Cron\CronJobInterface; use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass; use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; 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 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 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 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 ACLFlagsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new CRUDControllerCompilerPass(), \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. * Localizes a translatable string object based on the current locale.
@@ -17,11 +18,10 @@ import { TranslatableString } from "ChillMainAssets/types";
* @returns The localized URL * @returns The localized URL
*/ */
export function localizedUrl(url: string): string { export function localizedUrl(url: string): string {
const lang = const locale = getLocale();
document.documentElement.lang || navigator.language.split("-")[0] || "fr";
// Ensure url starts with a slash and does not already start with /{lang}/ // Ensure url starts with a slash and does not already start with /{lang}/
const normalizedUrl = url.startsWith("/") ? url : `/${url}`; const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
const langPrefix = `/${lang}`; const langPrefix = `/${locale}`;
if (normalizedUrl.startsWith(langPrefix + "/")) { if (normalizedUrl.startsWith(langPrefix + "/")) {
return normalizedUrl; return normalizedUrl;
} }
@@ -36,7 +36,7 @@ export function localizeString(
return ""; return "";
} }
const currentLocale = locale || navigator.language.split("-")[0] || "fr"; const currentLocale = locale || getLocale();
if (translatableString[currentLocale]) { if (translatableString[currentLocale]) {
return translatableString[currentLocale]; return translatableString[currentLocale];
@@ -59,3 +59,47 @@ export function localizeString(
return ""; 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(() => { const displayedTabs = computed(() => {
// Always show MyCustoms first if present
const tabs = [] as TabDefinition[]; const tabs = [] as TabDefinition[];
for (const tabEnum of homepageConfig.value.displayTabs) { for (const tabEnum of homepageConfig.value.displayTabs) {
const def = tabDefinitions.find( const def = tabDefinitions.find(
@@ -137,10 +136,7 @@ const activeTab = ref(Number(HomepageTabs[homepageConfig.value.defaultTab]));
const loading = computed(() => store.state.loading); const loading = computed(() => store.state.loading);
function selectTab(tab: HomepageTabs) { async function selectTab(tab: HomepageTabs) {
if (tab !== HomepageTabs.MyCustoms) {
store.dispatch("getByTab", { tab: tab });
}
activeTab.value = tab; activeTab.value = tab;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
Warning, Warning,
Workflow, Workflow,
WorflowCc, WorflowCc,
Notification,
} from "ChillPersonAssets/types"; } from "ChillPersonAssets/types";
import { RootState } from ".."; import { RootState } from "..";
import { HomepageTabs } from "ChillMainAssets/types"; import { HomepageTabs } from "ChillMainAssets/types";
@@ -191,6 +192,7 @@ export const moduleHomepage: Module<State, RootState> = {
if (!getters.isNotificationsLoaded) { if (!getters.isNotificationsLoaded) {
commit("setLoading", true); commit("setLoading", true);
const url = `/api/1.0/main/notification/my/unread${"?" + param}`; const url = `/api/1.0/main/notification/my/unread${"?" + param}`;
makeFetch("GET", url) makeFetch("GET", url)
.then((response) => { .then((response) => {
commit("addNotifications", 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"> <li class="nav-item dropdown btn btn-primary nav-section">
<a id="menu-section" <a id="menu-section"
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
type="button" type="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false"> aria-expanded="false">
{{ 'Sections'|trans }} {{ 'Sections'|trans }}
</a> </a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section"> <div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section">
{% for menu in menus %} {% 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 }}"> href="{{ menu.uri }}">
{{ menu.label }} {{ menu.label }}
{% apply spaceless %} {% apply spaceless %}

View File

@@ -13,32 +13,30 @@ namespace Chill\MainBundle\Routing;
use Knp\Menu\FactoryInterface; use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface; use Knp\Menu\ItemInterface;
use Symfony\Component\Routing\RouteCollection; use Knp\Menu\MenuItem;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* This class permit to build menu from the routing information * This class permit to build menu from the routing information
* stored in each bundle. * 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 getMenuFor($menuId, array $parameters = []): ItemInterface
public function __construct(private readonly RouterInterface $router, private readonly FactoryInterface $menuFactory, private readonly TranslatorInterface $translator) {}
public function addLocalMenuBuilder(LocalMenuBuilderInterface $menuBuilder, $menuId)
{ {
$this->localMenuBuilders[$menuId][] = $menuBuilder; $routes = $this->getRoutesForInternal($menuId, $parameters);
} /** @var MenuItem $menu */
public function getMenuFor($menuId, array $parameters = [])
{
$routes = $this->getRoutesFor($menuId, $parameters);
$menu = $this->menuFactory->createItem($menuId); $menu = $this->menuFactory->createItem($menuId);
// build menu from routes // build menu from routes
@@ -55,10 +53,9 @@ class MenuComposer
]); ]);
} }
if ($this->hasLocalMenuBuilder($menuId)) { foreach ($this->localMenuBuilders as $builder) {
foreach ($this->localMenuBuilders[$menuId] as $builder) { if (in_array($menuId, $builder::getMenuIds(), true)) {
/* @var $builder LocalMenuBuilderInterface */ $builder->buildMenu($menuId, $menu, $parameters);
$builder->buildMenu($menuId, $menu, $parameters['args']);
} }
} }
@@ -71,12 +68,16 @@ class MenuComposer
* Return an array of routes added to $menuId, * Return an array of routes added to $menuId,
* The array is aimed to build route with MenuTwig. * The array is aimed to build route with MenuTwig.
* *
* @param string $menuId * @deprecated
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
* *
* @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 = []; $routes = [];
$routeCollection = $this->router->getRouteCollection(); $routeCollection = $this->router->getRouteCollection();
@@ -108,22 +109,17 @@ class MenuComposer
* should be used, or `getRouteFor`. The method `getMenuFor` should be used * should be used, or `getRouteFor`. The method `getMenuFor` should be used
* if the result is true (it **does** exists at least one menu builder. * 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;
}
}
/** return false;
* 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;
} }
private function reorderMenu(ItemInterface $menu) private function reorderMenu(ItemInterface $menu)

View File

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

View File

@@ -11,44 +11,107 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Services; 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\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 * @internal
* *
* @coversNothing * @coversNothing
*/ */
final class MenuComposerTest extends KernelTestCase final class MenuComposerTest extends TestCase
{ {
/** use ProphecyTrait;
* @var \Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader;
*/
private $loader;
/** private function buildMenuComposerWithDefaultBuilder(): array
* @var \Chill\MainBundle\DependencyInjection\Services\MenuComposer;
*/
private $menuComposer;
protected function setUp(): void
{ {
self::bootKernel(['environment' => 'test']); // Router: returns an empty RouteCollection so getRoutesFor() yields []
$this->menuComposer = self::getContainer() $routerProphecy = $this->prophesize(RouterInterface::class);
->get('chill.main.menu_composer'); $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];
} }
/** public function testGetMenuForReturnsItemsFromLocalBuildersOnly(): void
* @covers \Chill\MainBundle\Routing\MenuComposer
*/
public function testMenuComposer()
{ {
$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: chill.main.menu_composer:
class: Chill\MainBundle\Routing\MenuComposer class: Chill\MainBundle\Routing\MenuComposer
arguments: arguments:
- '@Symfony\Component\Routing\RouterInterface' $localMenuBuilders: !tagged_iterator 'chill.menu_builder'
- '@Knp\Menu\FactoryInterface'
- '@Symfony\Contracts\Translation\TranslatorInterface'
Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer' Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer'
chill.main.routes_loader: chill.main.routes_loader:

View File

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

View File

@@ -112,7 +112,7 @@ paths:
- no - no
- name: byMotives - name: byMotives
in: query 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 required: false
style: form style: form
explode: false explode: false

View File

@@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Controller;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\MainBundle\CRUD\Controller\ApiController; use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -21,13 +22,13 @@ final class MotiveApiController extends ApiController
protected function customizeQuery(string $action, Request $request, $query): void protected function customizeQuery(string $action, Request $request, $query): void
{ {
/* @var $query QueryBuilder */ /* @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 protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{ {
return match ($request->getMethod()) { 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), default => parent::getContextForSerialization($action, $request, $_format, $entity),
}; };
} }

View File

@@ -13,7 +13,6 @@ namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; 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' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'],
['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'], ['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'],
]; ];
$motivesByLabel = [];
foreach (explode("\n", self::MOTIVES) as $row) { foreach (explode("\n", self::MOTIVES) as $row) {
if ('' === trim($row)) { if ('' === trim($row)) {
@@ -46,50 +46,65 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
continue; continue;
} }
$motive = new Motive(); $labels = explode(' > ', $data[0]);
$motive->setLabel(['fr' => trim((string) $data[0])]); $parent = null;
$motive->setMakeTicketEmergency(match ($data[1]) {
'true' => EmergencyStatusEnum::YES,
'false' => EmergencyStatusEnum::NO,
default => throw new \UnexpectedValueException('Unexpected value'),
});
$numberOfDocs = (int) $data[2]; while (count($labels) > 0) {
for ($i = 1; $i <= $numberOfDocs; ++$i) { $label = array_shift($labels);
$doc = $docs[$i - 1]; dump($labels);
$storedObject = new StoredObject(); if (isset($motivesByLabel[$label])) {
$storedObject->setTitle($doc['label']); $motive = $motivesByLabel[$label];
} else {
$motive = new Motive();
$motive->setLabel(['fr' => $label]);
$motivesByLabel[$label] = $motive;
}
$content = file_get_contents($doc['path']); if (null !== $parent) {
$contentType = match (substr($doc['path'], -3, 3)) { $motive->setParent($parent);
'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($motive);
$manager->persist($storedObject); $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) { foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) { if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
}
}
} }
} }
$manager->persist($motive);
} }
$manager->flush(); $manager->flush();
} }
private const MOTIVES = <<<'CSV' private const MOTIVES = <<<'CSV'
"Coordonnées",false,"3","Nouvelles coordonnées", "Motif administratif > Coordonnées",false,"3","Nouvelles coordonnées",
"Horaire de passage",false,"0", "Organisation > Horaire de passage",false,"0",
"Retard de livraison",false,"0", "Organisation > Livraison > Retard de livraison",false,"0",
"Erreur de livraison",false,"0", "Organisation > Livraison > Erreur de livraison",false,"0",
"Colis incomplet",false,"0", "Organisation > Livraison > Colis incomplet",false,"0",
"MATLOC",false,"0", "MATLOC",false,"0",
"Retard DASRI",false,"1", "Retard DASRI",false,"1",
"Planning d'astreintes",false,"0", "Planning d'astreintes",false,"0",
@@ -116,7 +131,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
"Mauvaise adresse",false,"0", "Mauvaise adresse",false,"0",
"Patient absent",false,"0", "Patient absent",false,"0",
"Annulation",false,"0", "Annulation",false,"0",
"Colis perdu",false,"0", "Organisation > Livraison > Colis perdu",false,"0",
"Changement de rendez-vous",false,"0", "Changement de rendez-vous",false,"0",
"Coordination interservices",false,"0", "Coordination interservices",false,"0",
"Problème de substitution produits",true,"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\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection; use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
@@ -26,19 +27,15 @@ class Motive
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = []; private array $label = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[Serializer\Groups(['read'])]
private bool $active = true; private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)]
#[Serializer\Groups(['read'])]
private ?EmergencyStatusEnum $makeTicketEmergency = null; private ?EmergencyStatusEnum $makeTicketEmergency = null;
/** /**
@@ -49,12 +46,22 @@ class Motive
private Collection $storedObjects; private Collection $storedObjects;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $supplementaryComments = []; 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() public function __construct()
{ {
$this->storedObjects = new ArrayCollection(); $this->storedObjects = new ArrayCollection();
$this->children = new ArrayCollection();
} }
public function addStoredObject(StoredObject $storedObject): void public function addStoredObject(StoredObject $storedObject): void
@@ -69,7 +76,6 @@ class Motive
$this->storedObjects->removeElement($storedObject); $this->storedObjects->removeElement($storedObject);
} }
#[Serializer\Groups(['read'])]
public function getStoredObjects(): ReadableCollection public function getStoredObjects(): ReadableCollection
{ {
return $this->storedObjects; return $this->storedObjects;
@@ -142,4 +148,74 @@ class Motive
$this->supplementaryComments[$key] = $supplementaryComment; $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( public function __construct(
#[ORM\ManyToOne(targetEntity: Motive::class)] #[ORM\ManyToOne(targetEntity: Motive::class)]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read', 'read:children-to-parent'])]
private Motive $motive, private Motive $motive,
#[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]

View File

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

View File

@@ -8,14 +8,32 @@ import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types"; import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types";
import { StoredObject } from "ChillDocStoreAssets/types"; import { StoredObject } from "ChillDocStoreAssets/types";
export interface Motive { interface MotiveBase {
type: "ticket_motive"; type: "ticket_motive";
id: number; id: number;
active: boolean; active: boolean;
label: TranslatableString; 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; makeTicketEmergency: TicketEmergencyState;
storedObjects: StoredObject[]; storedObjects: StoredObject[];
supplementaryComments: { label: string }[]; supplementaryComments: { label: string }[];
children: Motive[];
} }
export type TicketState = "open" | "closed" | "close"; export type TicketState = "open" | "closed" | "close";
@@ -45,7 +63,7 @@ export interface MotiveHistory {
id: number; id: number;
startDate: null; startDate: null;
endDate: null | DateTime; endDate: null | DateTime;
motive: Motive; motive: MotiveWithParent;
createdBy: User | null; createdBy: User | null;
createdAt: DateTime | null; createdAt: DateTime | null;
} }
@@ -140,7 +158,7 @@ interface BaseTicket<
createdAt: DateTime | null; createdAt: DateTime | null;
currentAddressees: UserGroupOrUser[]; currentAddressees: UserGroupOrUser[];
currentPersons: Person[]; currentPersons: Person[];
currentMotive: null | Motive; currentMotive: MotiveWithParent | null;
currentState: TicketState | null; currentState: TicketState | null;
emergency: TicketEmergencyState | null; emergency: TicketEmergencyState | null;
caller: Person | Thirdparty | null; caller: Person | Thirdparty | null;
@@ -185,7 +203,7 @@ export interface TicketFilterParams {
export interface TicketInitForm { export interface TicketInitForm {
content: string; content: string;
motive?: Motive; motive?: MotiveWithParent | null;
addressees: UserGroupOrUser[]; addressees: UserGroupOrUser[];
persons: Person[]; persons: Person[];
caller: Person | null; caller: Person | null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,22 +5,50 @@
<vue-multiselect <vue-multiselect
name="selectMotive" name="selectMotive"
id="selectMotive" id="selectMotive"
label="label" label="displayLabel"
:custom-label="customLabel" :custom-label="(value: Motive) => localizeString(value.label)"
track-by="id" track-by="id"
:open-direction="openDirection" :open-direction="openDirection"
:multiple="false" :multiple="false"
:searchable="true" :searchable="true"
:placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)" :placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)"
:select-label="trans(MULTISELECT_SELECT_LABEL)" :options="flattenedMotives"
:deselect-label="trans(MULTISELECT_DESELECT_LABEL)"
:selected-label="trans(MULTISELECT_SELECTED_LABEL)"
:options="motives"
v-model="motive" v-model="motive"
class="form-control" class="form-control"
/> @remove="(value: Motive) => $emit('remove', value)"
>
<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"> <div class="input-group-append">
<peloton-component :stored-objects="motive?.storedObjects ?? null" /> <peloton-component
:stored-objects="motive ? motive.storedObjects : null"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -28,11 +56,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { computed } from "vue";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
// Types // Types
import { Motive } from "../../../../types"; import { Motive, MotiveWithParent } from "../../../../types";
// Translations // Translations
import { import {
@@ -46,10 +74,14 @@ import {
// Component // Component
import PelotonComponent from "../PelotonComponent.vue"; import PelotonComponent from "../PelotonComponent.vue";
// Utils
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import { useStore } from "vuex";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object as () => Motive | undefined, type: Object as () => MotiveWithParent | Motive | null,
default: undefined, default: null,
}, },
motives: { motives: {
type: Array as () => Motive[], type: Array as () => Motive[],
@@ -59,34 +91,73 @@ const props = defineProps({
type: String, type: String,
default: "bottom", default: "bottom",
}, },
}); allowParentSelection: {
type: Boolean,
const emit = default: false,
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;
}, },
); });
const store = useStore();
function customLabel(motive: Motive) { const emit = defineEmits<{
return motive?.label?.fr; (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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
#selectMotive {
margin-bottom: 1.5em;
}
// Supprime le padding de .form-control pour ce composant
.form-control { .form-control {
padding: 0 !important; padding: 0 !important;
} }

View File

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

View File

@@ -21,6 +21,19 @@ export const moduleMotive: Module<State, RootState> = {
getMotives(state) { getMotives(state) {
return state.motives; 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: { mutations: {
setMotives(state, motives) { setMotives(state, motives) {

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ import {
CHILL_TICKET_TICKET_BANNER_AND, CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE, CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
} from "translator"; } 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 * 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]; 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( export function formatDateTime(
dateTime: string, dateTime: string,
dateStyle: string, dateStyle: string,
@@ -88,7 +78,23 @@ export function formatDateTime(
} }
export function getTicketTitle(ticket: Ticket | TicketSimple): string { export function getTicketTitle(ticket: Ticket | TicketSimple): string {
if (ticket.currentMotive) { 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)}`; 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

@@ -57,6 +57,8 @@
<motive-selector <motive-selector
v-model="selectedMotive" v-model="selectedMotive"
:motives="availableMotives" :motives="availableMotives"
:allow-parent-selection="true"
@remove="(motive) => removeMotive(motive)"
id="motiveSelector" id="motiveSelector"
/> />
@@ -299,14 +301,13 @@ const selectedAddressees = ref<UserGroupOrUser[]>([]);
const selectedCreator = ref<User[]>([]); const selectedCreator = ref<User[]>([]);
// Sélection des motifs // Sélection des motifs
const selectedMotive = ref<Motive | undefined>(); const selectedMotive = ref<Motive | null>();
const selectedMotives = ref<Motive[]>([]); const selectedMotives = ref<Motive[]>([]);
// Watchers pour les sélecteurs // Watchers pour les sélecteurs
watch(selectedMotive, (newMotive) => { watch(selectedMotive, (newMotive) => {
if (newMotive && !selectedMotives.value.find((m) => m.id === newMotive.id)) { if (newMotive && !selectedMotives.value.find((m) => m.id === newMotive.id)) {
selectedMotives.value.push(newMotive); selectedMotives.value.push(newMotive);
selectedMotive.value = undefined; // Reset pour permettre une nouvelle sélection
} }
}); });
@@ -384,6 +385,9 @@ const removeMotive = (motiveToRemove: Motive): void => {
if (index !== -1) { if (index !== -1) {
selectedMotives.value.splice(index, 1); selectedMotives.value.splice(index, 1);
} }
if (selectedMotive.value && motiveToRemove.id == selectedMotive.value.id) {
selectedMotive.value = null;
}
}; };
const applyFilters = (): void => { const applyFilters = (): void => {
@@ -456,7 +460,7 @@ const resetFilters = (): void => {
selectedCreator.value = []; selectedCreator.value = [];
selectedAddressees.value = []; selectedAddressees.value = [];
selectedMotives.value = []; selectedMotives.value = [];
selectedMotive.value = undefined; selectedMotive.value = null;
isClosedToggled.value = false; isClosedToggled.value = false;
isEmergencyToggled.value = false; isEmergencyToggled.value = false;
applyFilters(); applyFilters();

View File

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

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']), 'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']),
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $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', 'currentState' => $object->getState()?->value ?? 'open',
'emergency' => $object->getEmergencyStatus()?->value ?? 'no', 'emergency' => $object->getEmergencyStatus()?->value ?? 'no',
'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']), 'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']),
@@ -167,12 +167,20 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'event_type' => $data['event_type'], 'event_type' => $data['event_type'],
'at' => $this->normalizer->normalize($data['at'], $format, $context), 'at' => $this->normalizer->normalize($data['at'], $format, $context),
'by' => $this->normalizer->normalize($data['by'], $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 $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 private function addresseesStates(Ticket $ticket): array
{ {
/** @var array{string, array{added: list<AddresseeHistory>, removed: list<AddresseeHistory>}} $changes */ /** @var array{string, array{added: list<AddresseeHistory>, removed: list<AddresseeHistory>}} $changes */

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

@@ -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,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 "sourceMap": true
}, },
"includes": [ "includes": [
"./assets/**/*.ts",
"./src/**/*.ts", "./src/**/*.ts",
"./src/**/*.vue" "./src/**/*.vue"
], ],

View File

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