mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-10-20 12:12:50 +00:00
Compare commits
11 Commits
ticket/scr
...
ticket-app
Author | SHA1 | Date | |
---|---|---|---|
a22cbe0239 | |||
98902bdeb8 | |||
4765d4fe28 | |||
|
30bcb85549 | ||
6d2e78ce55
|
|||
61ca700bbe | |||
|
b43aeebc3c | ||
056e2dcc5f | |||
e57d1ac696 | |||
0eff1d2e79 | |||
3928b2cc7a |
@@ -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";
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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>> =
|
||||
|
@@ -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 () => {
|
||||
|
@@ -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);
|
||||
|
@@ -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(
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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 %}
|
||||
|
@@ -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)
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
@@ -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:
|
||||
|
@@ -291,6 +291,7 @@ export interface Notification {
|
||||
relatedEntityClass: string;
|
||||
relatedEntityId: number;
|
||||
}
|
||||
|
||||
export interface Participation {
|
||||
person: Person;
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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),
|
||||
};
|
||||
}
|
||||
|
@@ -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();
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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)]
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
31
src/Bundle/ChillTicketBundle/src/Event/MotiveUpdateEvent.php
Normal file
31
src/Bundle/ChillTicketBundle/src/Event/MotiveUpdateEvent.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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,
|
||||
) {}
|
||||
}
|
22
src/Bundle/ChillTicketBundle/src/Event/TicketUpdateEvent.php
Normal file
22
src/Bundle/ChillTicketBundle/src/Event/TicketUpdateEvent.php
Normal 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,
|
||||
) {}
|
||||
}
|
@@ -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';
|
||||
}
|
69
src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php
Normal file
69
src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php
Normal 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'];
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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[]);
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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> >
|
||||
</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;
|
||||
}
|
||||
|
@@ -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];
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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) {
|
||||
|
@@ -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 {
|
||||
|
@@ -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)) ||
|
||||
|
@@ -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(" > ");
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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();
|
||||
};
|
||||
|
||||
|
@@ -97,7 +97,6 @@ import { useStore } from "vuex";
|
||||
defineProps<{
|
||||
tickets: TicketSimple[];
|
||||
hasMoreTickets: boolean;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@@ -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: () => ({
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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 %}
|
@@ -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">
|
||||
|
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
@@ -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 */
|
||||
|
@@ -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/'
|
||||
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
@@ -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..."
|
||||
|
@@ -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);
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
63
src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php
Normal file
63
src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php
Normal 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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@@ -19,6 +19,7 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"includes": [
|
||||
"./assets/**/*.ts",
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.vue"
|
||||
],
|
||||
|
@@ -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'),
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user