Compare commits

..

4 Commits

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

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

See merge request Chill-Projet/chill-bundles!891
2025-10-15 11:06:04 +00:00
Boris Waaub
30bcb85549 Créer la page et la liste des tickets dans le dossier d'usager 2025-10-15 11:06:02 +00:00
51 changed files with 914 additions and 738 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Admin interface for Motive entity
time: 2025-10-07T15:59:45.597029709+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Add an admin interface for Motive entity
time: 2025-10-22T11:15:52.13937955+02:00
custom:
Issue: ""
SchemaChange: Add columns or tables

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
<?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\Controller\Admin;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\MotiveDTO;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class MotiveController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
/* @var QueryBuilder $query */
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function createFormFor(string $action, mixed $entity, ?string $formClass = null, array $formOptions = []): FormInterface
{
if (in_array($action, ['new', 'edit'], true) && $entity instanceof Motive) {
$dto = MotiveDTO::fromMotive($entity);
return parent::createFormFor($action, $dto, $formClass, $formOptions);
}
return parent::createFormFor($action, $entity, $formClass, $formOptions);
}
protected function onFormValid(string $action, object $entity, FormInterface $form, Request $request): void
{
if (in_array($action, ['new', 'edit'], true) && $entity instanceof Motive) {
$dto = $form->getData();
if ($dto instanceof MotiveDTO) {
$dto->applyToMotive($entity);
}
}
parent::onFormValid($action, $entity, $form, $request);
}
}

View File

@@ -1,31 +0,0 @@
<?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\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class AdminController
* Controller for the ticket configuration section (in admin section).
*/
class AdminController extends AbstractController
{
/**
* Ticket admin.
*/
#[Route(path: '/{_locale}/admin/ticket', name: 'chill_ticket_admin_index')]
public function indexAdminAction()
{
return $this->render('@ChillTicket/Admin/index.html.twig');
}
}

View File

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

View File

@@ -46,7 +46,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
continue;
}
$labels = explode(' > ', $data[0]);
$labels = explode(' > ', (string) $data[0]);
$parent = null;
while (count($labels) > 0) {

View File

@@ -11,10 +11,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\DependencyInjection;
use Chill\TicketBundle\Controller\Admin\MotiveController;
use Chill\TicketBundle\Controller\MotiveApiController;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Form\MotiveType;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -38,7 +36,6 @@ class ChillTicketExtension extends Extension implements PrependExtensionInterfac
public function prepend(ContainerBuilder $container)
{
$this->prependApi($container);
$this->prependCruds($container);
}
private function prependApi(ContainerBuilder $container): void
@@ -69,37 +66,4 @@ class ChillTicketExtension extends Extension implements PrependExtensionInterfac
],
]);
}
protected function prependCruds(ContainerBuilder $container): void
{
$container->prependExtensionConfig('chill_main', [
'cruds' => [
[
'class' => Motive::class,
'name' => 'motive',
'base_path' => '/admin/ticket/motive',
'form_class' => MotiveType::class,
'controller' => MotiveController::class,
'actions' => [
'index' => [
'template' => '@ChillTicket/Admin/Motive/index.html.twig',
'role' => 'ROLE_ADMIN',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/new.html.twig',
],
'view' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/view.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/edit.html.twig',
],
],
],
],
]);
}
}

View File

@@ -51,15 +51,13 @@ class Motive
#[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')]
private ?Motive $parent = null;
/**
* @var Collection<int, Motive>&Selectable<int, Motive>
*/
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: Motive::class)]
#[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'parent')]
private Collection&Selectable $children;
#[ORM\Column(name: 'ordering', type: \Doctrine\DBAL\Types\Types::FLOAT, nullable: true, options: ['default' => '0.0'])]
private float $ordering = 0;
public function __construct()
{
$this->storedObjects = new ArrayCollection();
@@ -220,14 +218,4 @@ class Motive
return $collection;
}
public function getOrdering(): float
{
return $this->ordering;
}
public function setOrdering(float $ordering): void
{
$this->ordering = $ordering;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
<?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\Form;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\MotiveDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MotiveType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TranslatableStringFormType::class, [
'label' => 'Label',
'required' => true,
])
->add('active', CheckboxType::class, [
'label' => 'Active',
'required' => false,
])
->add('makeTicketEmergency', EnumType::class, [
'class' => EmergencyStatusEnum::class,
'label' => 'emergency?',
'required' => false,
'placeholder' => 'Choose an option...',
])
->add('supplementaryComments', ChillCollectionType::class, [
'entry_type' => TextareaType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => true,
'label' => 'Supplementary comments',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => MotiveDTO::class,
]);
}
public function getBlockPrefix(): string
{
return 'chill_ticketbundle_motive';
}
}

View File

@@ -1,45 +0,0 @@
<?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\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class AdminMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(protected AuthorizationCheckerInterface $authorizationChecker) {}
public function buildMenu($menuId, MenuItem $menu, array $parameters): void
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
return;
}
$menu->addChild('Tickets', [
'route' => 'chill_ticket_admin_index',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 7500,
]);
$menu->addChild('admin.ticket.motive.menu', [
'route' => 'chill_crud_motive_index',
])->setExtras(['order' => 7510]);
}
public static function getMenuIds(): array
{
return ['admin_section', 'admin_ticket'];
}
}

View File

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

View File

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

View File

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

View File

@@ -1,64 +0,0 @@
<?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;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraints as Assert;
class MotiveDTO
{
public function __construct(
#[Assert\NotBlank()]
public array $label = [],
public bool $active = true,
public Collection $supplementaryComments = new ArrayCollection(),
public ?EmergencyStatusEnum $makeTicketEmergency = null,
) {
if ($this->supplementaryComments->isEmpty()) {
$this->supplementaryComments = new ArrayCollection();
}
}
public static function fromMotive(Motive $motive): self
{
$supplementaryCommentsCollection = new ArrayCollection();
foreach ($motive->getSupplementaryComments() as $comment) {
$supplementaryCommentsCollection->add($comment['label']);
}
return new self(
label: $motive->getLabel(),
active: $motive->isActive(),
supplementaryComments: $supplementaryCommentsCollection,
makeTicketEmergency: $motive->getMakeTicketEmergency(),
);
}
public function applyToMotive(Motive $motive): void
{
$motive->setLabel($this->label);
$motive->setActive($this->active);
$motive->setMakeTicketEmergency($this->makeTicketEmergency);
$supplementaryCommentsArray = [];
foreach ($this->supplementaryComments as $supplementaryComment) {
$supplementaryCommentsArray[] = ['label' => $supplementaryComment];
}
$motive->setSupplementaryComment($supplementaryCommentsArray);
}
}

View File

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

View File

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

View File

@@ -177,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;
@@ -198,7 +202,7 @@ export interface TicketFilterParams {
byCreatedBefore?: string;
byResponseTimeExceeded?: string;
byAddresseeToMe?: boolean;
byTicketId?: number;
byTicketId?: number | null;
}
export interface TicketInitForm {

View File

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

View File

@@ -16,6 +16,7 @@
v-model="motive"
class="form-control"
@remove="(value: Motive) => $emit('remove', value)"
:disabled="disabled"
>
<template
#option="{
@@ -95,6 +96,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const store = useStore();

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,13 @@
trans(CHILL_TICKET_LIST_FILTER_PERSONS_CONCERNED)
}}</label>
<persons-selector
v-model="selectedPersons"
v-model="filters.byPerson"
:suggested="availablePersons"
:multiple="true"
:types="['person']"
id="personSelector"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_PERSON)"
:disabled="ticketFilterParams?.byPerson ? true : false"
/>
</div>
@@ -28,12 +29,13 @@
trans(CHILL_TICKET_LIST_FILTER_CREATORS)
}}</label>
<persons-selector
v-model="selectedCreator"
v-model="filters.byCreator"
:suggested="[]"
:multiple="true"
:types="['user']"
id="userSelector"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_CREATOR)"
:disabled="ticketFilterParams?.byCreator ? true : false"
/>
</div>
@@ -42,10 +44,11 @@
trans(CHILL_TICKET_LIST_FILTER_ADDRESSEES)
}}</label>
<addressee-selector-component
v-model="selectedAddressees"
v-model="filters.byAddressee"
:suggested="[]"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES)"
id="addresseeSelector"
:disabled="ticketFilterParams?.byAddressee ? true : false"
/>
</div>
<div class="col-md-6 mb-3">
@@ -60,12 +63,13 @@
: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"
>
@@ -75,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>
@@ -96,6 +101,7 @@
}"
@update:model-value="handleStateToggle"
id="currentState"
:disabled="ticketFilterParams?.byCurrentState ? true : false"
/>
</div>
@@ -114,6 +120,9 @@
}"
@update:model-value="handleEmergencyToggle"
id="emergency"
:disabled="
ticketFilterParams?.byCurrentStateEmergency ? true : false
"
/>
</div>
</div>
@@ -129,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) }}
@@ -142,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) }}
@@ -157,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>
@@ -170,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"
/>
@@ -183,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"
/>
@@ -227,7 +254,6 @@ import {
type TicketFilterParams,
type TicketFilters,
} from "../../../types";
import { User, UserGroupOrUser } from "ChillMainAssets/types";
// Translation
import {
@@ -269,6 +295,7 @@ const props = defineProps<{
availablePersons?: Person[];
availableMotives: Motive[];
resultCount: number;
ticketFilterParams: TicketFilters | null;
}>();
// Emits
@@ -276,74 +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 || []);
// 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 | null>();
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);
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"];
@@ -379,12 +398,9 @@ 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;
}
@@ -447,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 = [];
filters.value = { ...filtersInitValues };
selectedMotive.value = null;
isClosedToggled.value = false;
isEmergencyToggled.value = false;
byCreatedAfterTime.value = "00:00";
byCreatedBeforeTime.value = "23:59";
applyFilters();
};

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block js %}
{{ parent() }}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_save_and_view %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -1,64 +0,0 @@
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block title %}{{ 'admin.motive.list.title'|trans }}{% endblock title %}
{% block admin_content %}
<h1>{{ 'admin.motive.list.title'|trans }}</h1>
<table class="records_list table table-bordered border-dark">
<thead>
<tr>
<th>{{ 'Label'|trans }}</th>
<th>{{ 'Active'|trans }}</th>
<th>{{ 'emergency?'|trans }}</th>
<th>{{ 'Supplementary comments'|trans }}</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr>
<td>{{ entity.label|localize_translatable_string }}</td>
<td style="text-align:center;">
{%- if entity.isActive -%}
<i class="fa fa-check-square-o"></i>
{%- else -%}
<i class="fa fa-square-o"></i>
{%- endif -%}
</td>
<td style="text-align:center;">
{%- if entity.makeTicketEmergency -%}
{{ entity.makeTicketEmergency.value|trans }}
{%- else -%}
-
{%- endif -%}
</td>
<td style="text-align:center;">
{{ entity.supplementaryComments|length }}
</td>
<td>
<ul class="record_actions">
<li>
<a href="{{ path('chill_crud_motive_view', { 'id': entity.id }) }}" class="btn btn-show" title="{{ 'show'|trans }}"></a>
</li>
<li>
<a href="{{ path('chill_crud_motive_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'edit'|trans }}"></a>
</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ chill_pagination(paginator) }}
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_motive_new') }}" class="btn btn-create">
{{ 'admin.motive.new.title'|trans }}
</a>
</li>
</ul>
{% endblock %}

View File

@@ -1,15 +0,0 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block js %}
{{ parent() }}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -1,68 +0,0 @@
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block title %}{{ 'admin.motive.view.title'|trans }}{% endblock title %}
{% block admin_content %}
<h1>{{ 'admin.motive.view.title'|trans }}</h1>
<table class="record_properties table table-bordered">
<tbody>
<tr>
<th>{{ 'Id'|trans }}</th>
<td>{{ entity.id }}</td>
</tr>
<tr>
<th>{{ 'Label'|trans }}</th>
<td>{{ entity.label|localize_translatable_string }}</td>
</tr>
<tr>
<th>{{ 'Active'|trans }}</th>
<td style="text-align:center;">
{%- if entity.isActive -%}
<i class="fa fa-check-square-o"></i> {{ 'Yes'|trans }}
{%- else -%}
<i class="fa fa-square-o"></i> {{ 'No'|trans }}
{%- endif -%}
</td>
</tr>
<tr>
<th>{{ 'emergency?'|trans }}</th>
<td>
{%- if entity.makeTicketEmergency -%}
{{ entity.makeTicketEmergency.value|trans }}
{%- else -%}
-
{%- endif -%}
</td>
</tr>
</tbody>
</table>
{% if entity.supplementaryComments is not empty %}
<h2>{{ 'Supplementary comments'|trans }}</h2>
<div class="supplementary-comments">
{% for comment in entity.supplementaryComments %}
<div class="card mb-3">
<div class="card-body">
<div class="comment-content">
{{ comment.label|raw }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<h2>{{ 'Supplementary comments'|trans }}</h2>
<p class="text-muted">{{ 'No supplementary comments'|trans }}</p>
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_crud_motive_index') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a>
</li>
<li>
<a href="{{ path('chill_crud_motive_edit', { 'id': entity.id }) }}" class="btn btn-edit">{{ 'Edit'|trans }}</a>
</li>
</ul>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %}
{% block vertical_menu_content %}
{{ chill_menu('admin_ticket', {
'layout': '@ChillMain/Admin/menu_admin_section.html.twig',
}) }}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}<!-- block content empty -->
<h1>{{ 'Tickets configuration' |trans }}</h1>
{% endblock %}
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
<?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 Version20251022081554 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ordering property on motive entity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive ADD ordering DOUBLE PRECISION DEFAULT \'0.0\'');
$this->addSql('UPDATE chill_ticket.motive SET ordering = id * 100');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive DROP ordering');
}
}

View File

@@ -1,7 +1,9 @@
restore: Restaurer
chill_ticket:
list:
title: Tickets
title: "Tickets"
title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}"
title_menu: "Tickets de l'usager"
title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}"
no_tickets: "Aucun ticket"
loading_ticket: "Chargement des tickets..."
@@ -148,35 +150,3 @@ chill_ticket:
open_new_tab: "Ouvrir dans un nouvel onglet"
iframe_not_supported: "Votre navigateur ne supporte pas les iframes."
click_to_open_pdf: "Cliquez ici pour ouvrir le PDF"
admin:
ticket:
motive:
menu: Motifs
motive:
list:
title: Liste des motifs
view:
title: Le motif
new:
title: Créer un motif
crud:
motive:
title_edit: Modifier le motif
new:
"Create a new motive": "Créer un nouveau motif"
"Label": "Libellé"
"Active": "Actif"
"emergency?": "Urgent ?"
"Supplementary comments": "Commentaires supplémentaires"
"edit": "modifier"
"show": "voir"
"Yes": "Oui"
"No": "Non"
"Id": "ID"
"Date": "Date"
"User": "Utilisateur"
"No supplementary comments": "Aucun commentaire supplémentaire"
"Back to the list": "Retour à la liste"
"Edit": "Modifier"

View File

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

View File

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

View File

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

View File

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

View File

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