diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php index 7363d5e73..026ab377b 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php @@ -49,7 +49,6 @@ final readonly class ReplaceMotiveCommandHandler if ($readyToAdd) { $history = new MotiveHistory($command->motive, $ticket, $this->clock->now()); - $ticket->addMotiveHistory($history); $this->entityManager->persist($history); } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php index 6f8ab2a2b..0e8fcd7e5 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php @@ -19,7 +19,7 @@ use Twig\Environment; class EditTicketController { public function __construct( - private Environment $templating, + private Environment $templating ) {} #[Route('/{_locale}/ticket/ticket/{id}/edit', name: 'chill_ticket_ticket_edit')] @@ -29,6 +29,9 @@ class EditTicketController return new Response( $this->templating->render( '@ChillTicket/Ticket/edit.html.twig', + [ + 'ticket' => $ticket, + ] ) ); } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php index c96c2d6f4..1922459e8 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php @@ -14,9 +14,11 @@ namespace Chill\TicketBundle\Entity; use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'motives_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive_history' => MotiveHistory::class])] class MotiveHistory implements TrackCreationInterface { use TrackCreationTrait; @@ -24,21 +26,27 @@ class MotiveHistory implements TrackCreationInterface #[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::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] private ?\DateTimeImmutable $endDate = null; public function __construct( #[ORM\ManyToOne(targetEntity: Motive::class)] #[ORM\JoinColumn(nullable: false)] + #[Serializer\Groups(['read'])] private Motive $motive, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] private Ticket $ticket, #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] private \DateTimeImmutable $startDate = new \DateTimeImmutable('now') - ) {} + ) { + $ticket->addMotiveHistory($this); + } public function getEndDate(): ?\DateTimeImmutable { diff --git a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php index 9914f96a1..57a9809e2 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php @@ -16,9 +16,11 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Entity\Person; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'person_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_person_history' => PersonHistory::class])] class PersonHistory implements TrackCreationInterface { use TrackCreationTrait; @@ -26,22 +28,27 @@ class PersonHistory implements TrackCreationInterface #[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::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] private ?\DateTimeImmutable $endDate = null; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] private ?User $removedBy = null; public function __construct( #[ORM\ManyToOne(targetEntity: Person::class, fetch: 'EAGER')] + #[Serializer\Groups(['read'])] private Person $person, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] private Ticket $ticket, #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] private \DateTimeImmutable $startDate, ) { // keep ticket instance in sync with this diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index 3551aa93d..de369379b 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -176,4 +176,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface { return $this->motiveHistories; } + + /** + * @return ReadableCollection + */ + public function getPersonHistories(): ReadableCollection + { + return $this->personHistories; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index e7eed8575..b0c6a37df 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -1,11 +1,52 @@ -import {TranslatableString} from "../../../../ChillMainBundle/Resources/public/types"; - -export interface Ticket { - id: number -} +import {DateTime, TranslatableString, User} from "../../../../ChillMainBundle/Resources/public/types"; +import {Person} from "../../../../ChillPersonBundle/Resources/public/types"; export interface Motive { + type: "ticket_motive" id: number, active: boolean, label: TranslatableString } + +interface TicketHistory { + event_type: T, + at: DateTime, + by: User, + data: D +} + +interface PersonHistory { + type: "ticket_person_history", + id: number, + startDate: DateTime, + endDate: null|DateTime, + person: Person, + removedBy: null, + createdBy: User|null, + createdAt: DateTime|null +} + +interface MotiveHistory { + type: "ticket_motive_history", + id: number, + startDate: null, + endDate: null|DateTime, + motive: Motive, + createdBy: User|null, + createdAt: DateTime|null, +} + +interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; +interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {}; + +type TicketHistoryLine = AddPersonEvent | SetMotiveEvent; + +export interface Ticket { + type: "ticket_ticket" + id: number + externalRef: string + currentPersons: Person[] + currentMotive: null|Motive + history: TicketHistoryLine[], +} + diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts index bad5fb9ac..c72576af5 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts @@ -4,9 +4,30 @@ import VueToast from 'vue-toast-notification'; import 'vue-toast-notification/dist/theme-sugar.css'; import {messages} from "./i18n/messages"; import App from './App.vue'; +import {Ticket} from "../../types"; + +declare global { + interface Window { + initialTicket: string + } +} const i18n = _createI18n(messages) +// the initial ticket is serialized there: +console.log(window.initialTicket); +// to have js object +const ticket = JSON.parse(window.initialTicket) as Ticket; +console.log("the ticket for this app (at page loading)", ticket); + +for (const eh of ticket.history) { + if (eh.event_type === 'add_person') { + console.log("add_person", eh.data.person); + } else if (eh.event_type === 'set_motive') { + console.log("set_motive", eh.data.motive); + } +} + const _app = createApp({ template: '', }) diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig index a8e38d86a..2b067304e 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig @@ -7,6 +7,9 @@ {% block js %} {{ parent() }} + {{ encore_entry_script_tags('vue_ticket_app') }} {% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php new file mode 100644 index 000000000..c69ac5a7f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -0,0 +1,91 @@ + 'ticket_ticket', + 'id' => $object->getId(), + 'externalRef' => $object->getExternalRef(), + 'currentPersons' => $this->normalizer->normalize($object->getPersons(), $format, [ + 'groups' => 'read', + ]), + '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']), + 'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])), + ]; + } + + public function supportsNormalization($data, ?string $format = null) + { + return 'json' === $format && $data instanceof Ticket; + } + + private function serializeHistory(Ticket $ticket, string $format, array $context): array + { + $events = [ + ...array_map( + fn (MotiveHistory $motiveHistory) => [ + 'event_type' => 'set_motive', + 'at' => $motiveHistory->getStartDate(), + 'by' => $motiveHistory->getCreatedBy(), + 'data' => $motiveHistory, + ], + $ticket->getMotiveHistories()->toArray() + ), + ...array_map( + fn (PersonHistory $personHistory) => [ + 'event_type' => 'add_person', + 'at' => $personHistory->getStartDate(), + 'by' => $personHistory->getCreatedBy(), + 'data' => $personHistory, + ], + $ticket->getPersonHistories()->toArray(), + ), + ]; + + usort( + $events, + static function (array $a, array $b): int { + return $a['at'] <=> $b['at']; + } + ); + + return array_map( + fn ($data) => [ + '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), + ], + $events + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 0ccd26525..490e215c4 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -11,6 +11,8 @@ services: Chill\TicketBundle\Action\Ticket\Handler\: resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Serializer\: + resource: '../Serializer/' Chill\TicketBundle\DataFixtures\: resource: '../DataFixtures/' diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php index ef86dad17..b0f49dd61 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php @@ -64,7 +64,7 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase { $motive = new Motive(); $ticket = new Ticket(); - $ticket->addMotiveHistory(new MotiveHistory(new Motive(), $ticket)); + $history = new MotiveHistory(new Motive(), $ticket); $entityManager = $this->prophesize(EntityManagerInterface::class); $entityManager->persist(Argument::that(static function ($arg) use ($motive): bool { @@ -87,7 +87,7 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase { $motive = new Motive(); $ticket = new Ticket(); - $ticket->addMotiveHistory(new MotiveHistory($motive, $ticket)); + $history = new MotiveHistory($motive, $ticket); $entityManager = $this->prophesize(EntityManagerInterface::class); $entityManager->persist(Argument::that(static function ($arg) use ($motive): bool { diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index f8d7ad270..6f710f1aa 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -11,8 +11,10 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Entity; +use Chill\PersonBundle\Entity\Person; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\Ticket; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -31,7 +33,6 @@ class TicketTest extends KernelTestCase self::assertNull($ticket->getMotive()); $history = new MotiveHistory($motive, $ticket); - $ticket->addMotiveHistory($history); self::assertSame($motive, $ticket->getMotive()); self::assertCount(1, $ticket->getMotiveHistories()); @@ -39,9 +40,22 @@ class TicketTest extends KernelTestCase // replace motive $motive2 = new Motive(); $history->setEndDate(new \DateTimeImmutable()); - $ticket->addMotiveHistory(new MotiveHistory($motive2, $ticket)); + $history2 = new MotiveHistory($motive2, $ticket); self::assertCount(2, $ticket->getMotiveHistories()); self::assertSame($motive2, $ticket->getMotive()); } + + public function testGetPerson(): void + { + $ticket = new Ticket(); + $person = new Person(); + + self::assertEquals([], $ticket->getPersons()); + + $history = new PersonHistory($person, $ticket, new \DateTimeImmutable('now')); + + self::assertCount(1, $ticket->getPersons()); + self::assertSame($person, $ticket->getPersons()[0]); + } } diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php new file mode 100644 index 000000000..e876341cd --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -0,0 +1,144 @@ +buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read']); + + self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual)); + + foreach (array_keys($expected) as $k) { + if ('history' === $k) { + continue; + } + self::assertEqualsCanonicalizing($expected[$k], $actual[$k], sprintf("assert the content of the '%s' key", $k)); + } + + self::assertArrayHasKey('history', $actual); + self::assertIsArray($actual['history']); + + foreach ($actual['history'] as $k => $eventType) { + self::assertEquals($expected['history'][$k]['event_type'], $eventType['event_type']); + } + } + + private function buildNormalizer(): TicketNormalizer + { + $normalizer = $this->prophesize(NormalizerInterface::class); + + // empty array + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 === count($arg)), + 'json', + Argument::type('array') + )->willReturn([]); + + // array of mixed objects + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0])), + 'json', + Argument::type('array') + )->will(function ($args) { + return array_fill(0, count($args[0]), 'embedded'); + }); + + // array of event type + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0]) && array_key_exists('event_type', $arg[0])), + 'json', + Argument::type('array') + )->will(function ($args): array { + $events = []; + + foreach ($args[0] as $event) { + $events[] = $event['event_type']; + } + + return $events; + }); + + // datetime + $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) + ->will(function ($args) { return $args[0]->getTimestamp(); }); + $normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]); + $normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array')) + ->willReturn(['personHistory']); + $normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array')) + ->willReturn(['motiveHistory']); + $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); + + $ticketNormalizer = new TicketNormalizer(); + $ticketNormalizer->setNormalizer($normalizer->reveal()); + + return $ticketNormalizer; + } + + public static function provideTickets(): iterable + { + yield [ + new Ticket(), + [ + 'type' => 'ticket_ticket', + 'id' => null, + 'externalRef' => '', + 'currentPersons' => [], + 'currentAddressees' => [], + 'currentInputs' => [], + 'currentMotive' => null, + 'history' => [], + ], + ]; + + $ticket = new Ticket(); + $ticket->setExternalRef('2134'); + $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + $ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); + + yield [ + $ticket, + [ + 'type' => 'ticket_ticket', + 'id' => null, + 'externalRef' => '2134', + 'currentPersons' => ['embedded'], + 'currentAddressees' => [], + 'currentInputs' => [], + 'currentMotive' => ['type' => 'motive', 'id' => 0], + 'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive']], + ], + ]; + } +}