diff --git a/app/DataFixtures/AppFixtures.php b/app/DataFixtures/AppFixtures.php index ced24d83..cccbd367 100644 --- a/app/DataFixtures/AppFixtures.php +++ b/app/DataFixtures/AppFixtures.php @@ -13,6 +13,7 @@ namespace App\DataFixtures; +use App\Story\DefaultApplicationsStory; use App\Story\DefaultBooksStory; use App\Story\DefaultConferencesStory; use App\Story\DefaultSpeakersStory; @@ -32,5 +33,6 @@ public function load(ObjectManager $manager): void DefaultSyliusCon2024TalksStory::load(); DefaultTalksStory::load(); DefaultUsersStory::load(); + DefaultApplicationsStory::load(); } } diff --git a/app/Entity/Application.php b/app/Entity/Application.php new file mode 100644 index 00000000..1edd3d23 --- /dev/null +++ b/app/Entity/Application.php @@ -0,0 +1,111 @@ +id; + } + + public function getSubmittedAt(): ?\DateTimeImmutable + { + return $this->submittedAt; + } + + public function setSubmittedAt(\DateTimeImmutable $submittedAt): static + { + $this->submittedAt = $submittedAt; + + return $this; + } + + public function getTalk(): ?Talk + { + return $this->talk; + } + + public function setTalk(Talk $talk): static + { + $this->talk = $talk; + + return $this; + } + + public function getStatus(): ?ApplicationStatus + { + return $this->status; + } + + public function setStatus(ApplicationStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } +} diff --git a/app/Entity/Speaker.php b/app/Entity/Speaker.php index f6c19025..adbf72e6 100644 --- a/app/Entity/Speaker.php +++ b/app/Entity/Speaker.php @@ -125,4 +125,9 @@ public function getTalks(): Collection { return $this->talks; } + + public function __toString(): string + { + return $this->getFullName(); + } } diff --git a/app/Entity/Talk.php b/app/Entity/Talk.php index 601e8ffa..300dc050 100644 --- a/app/Entity/Talk.php +++ b/app/Entity/Talk.php @@ -82,6 +82,9 @@ class Talk implements ResourceInterface #[ORM\ManyToMany(targetEntity: Speaker::class, inversedBy: 'talks')] private Collection $speakers; + #[ORM\OneToOne(mappedBy: 'talk', cascade: ['persist', 'remove'])] + private ?Application $application = null; + public function __construct() { $this->speakers = new ArrayCollection(); @@ -181,4 +184,21 @@ public function removeSpeaker(Speaker $speaker): void { $this->speakers->removeElement($speaker); } + + public function getApplication(): ?Application + { + return $this->application; + } + + public function setApplication(Application $application): static + { + // set the owning side of the relation if necessary + if ($application->getTalk() !== $this) { + $application->setTalk($this); + } + + $this->application = $application; + + return $this; + } } diff --git a/app/Enum/ApplicationStateMachineTransition.php b/app/Enum/ApplicationStateMachineTransition.php new file mode 100644 index 00000000..8c27ef6b --- /dev/null +++ b/app/Enum/ApplicationStateMachineTransition.php @@ -0,0 +1,23 @@ + + */ +final class ApplicationFactory extends PersistentObjectFactory +{ + + #[\Override] + public static function class(): string + { + return Application::class; + } + + public function withSubmittedAt(\DateTimeImmutable $submittedAt): self + { + return $this->with(['submittedAt' => $submittedAt]); + } + + #[\Override] + protected function defaults(): array|callable + { + return [ + 'status' => self::faker()->randomElement(ApplicationStatus::cases()), + 'submittedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'talk' => TalkFactory::new(), + 'updatedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + ]; + } +} diff --git a/app/Grid/ApplicationGrid.php b/app/Grid/ApplicationGrid.php new file mode 100644 index 00000000..26113836 --- /dev/null +++ b/app/Grid/ApplicationGrid.php @@ -0,0 +1,89 @@ +withFilters( + EntityFilter::create(name: 'conference', resourceClass: Conference::class, fields: ['talk.conference']) + ->setLabel('app.ui.conference') + ->addFormOption('choice_label', 'name'), + Filter::create(name: 'speaker', type: SpeakerFilter::class) + ->setLabel('app.ui.speaker') + ->setOptions(['fields' => ['talk.speakers.id']]), + StringFilter::create('search', ['talk.title']) + ->setLabel('sylius.ui.search'), + EnumFilter::create(name: 'status', enumClass: ApplicationStatus::class, field: 'status') + ->addFormOption('choice_value', fn (?ApplicationStatus $enum) => $enum?->value) + ->addFormOption('choice_label', fn (ApplicationStatus $choice) => ucfirst($choice->value)) + ->setLabel('app.ui.status'), + DateFilter::create('submittedAt') + ->setLabel('app.ui.submitted_at'), + ) + ->withFields( + StringField::create('talk.title') + ->setLabel('app.ui.talk'), + TwigField::create(name: 'talk.speakers', template: 'talk/grid/field/speakers.html.twig') + ->setLabel('app.ui.speakers') + ->setSortable(true), + EnumField::create('status') + ->setLabel('app.ui.status') + ->setSortable(true), + DateTimeField::create('submittedAt') + ->setLabel('app.ui.submitted_at') + ->setSortable(true), + DateTimeField::create('updatedAt') + ->setLabel('app.ui.updated_at') + ->setSortable(true), + ) + ->withItemActions( + ApplyTransitionAction::create(name: 'accept', route: 'app_admin_application_accept') + ->setIcon('ep:success-filled') + ->setLabel('app.ui.accept'), + ApplyTransitionAction::create(name: 'reject', route: 'app_admin_application_reject') + ->setIcon('flat-color-icons:cancel') + ->setLabel('app.ui.reject'), + ApplyTransitionAction::create(name: 'hold', route: 'app_admin_application_hold') + ->setIcon('iconmind:wait-approval-outline-thin') + ->setLabel('app.ui.hold'), + ApplyTransitionAction::create(name: 'cancel', route: 'app_admin_application_cancel') + ->setIcon('streamline-stickies-color:cancel-2') + ->setLabel('app.ui.cancel'), + ); + } +} diff --git a/app/Menu/AdminMenuBuilder.php b/app/Menu/AdminMenuBuilder.php index 050efc53..0e9c91ee 100644 --- a/app/Menu/AdminMenuBuilder.php +++ b/app/Menu/AdminMenuBuilder.php @@ -76,5 +76,9 @@ private function addConfigurationSubMenu(ItemInterface $menu): void $configuration->addChild('speakers', ['route' => 'app_admin_speaker_index']) ->setLabel('app.ui.speakers') ; + + $configuration->addChild('applications', ['route' => 'app_admin_application_index']) + ->setLabel('app.ui.applications') + ; } } diff --git a/app/Repository/ApplicationRepository.php b/app/Repository/ApplicationRepository.php new file mode 100644 index 00000000..d6983988 --- /dev/null +++ b/app/Repository/ApplicationRepository.php @@ -0,0 +1,43 @@ + + */ +class ApplicationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Application::class); + } + + // /** + // * @return Application[] Returns an array of Application objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Application + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/Story/DefaultApplicationsStory.php b/app/Story/DefaultApplicationsStory.php new file mode 100644 index 00000000..837f919c --- /dev/null +++ b/app/Story/DefaultApplicationsStory.php @@ -0,0 +1,25 @@ +extension('framework', [ + 'workflows' => [ + 'care_task' => [ + 'type' => 'state_machine', + 'initial_marking' => ApplicationStatus::DRAFT->value, + 'marking_store' => [ + 'property' => 'status', + ], + 'supports' => [ + Application::class, + ], + 'audit_trail' => [ + 'enabled' => true, + ], + 'places' => [ + ApplicationStatus::SUBMITTED->value, + ApplicationStatus::ACCEPTED->value, + ApplicationStatus::REJECTED->value, + ApplicationStatus::WAITING_LIST->value, + ApplicationStatus::CANCELLED->value, + ], + 'transitions' => [ + 'submit' => [ + 'from' => ApplicationStatus::DRAFT->value, + 'to' => ApplicationStatus::SUBMITTED->value, + ], + 'accept' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::ACCEPTED->value, + ], + 'reject' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::REJECTED->value, + ], + 'hold' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::WAITING_LIST->value, + ], + 'cancel' => [ + 'from' => [ApplicationStatus::ACCEPTED->value, ApplicationStatus::WAITING_LIST->value], + 'to' => ApplicationStatus::CANCELLED->value, + ], + ], + ], + ], + ]); +}; diff --git a/docs/.gitbook/assets/state_machine_actions.png b/docs/.gitbook/assets/state_machine_actions.png new file mode 100644 index 00000000..aff80765 Binary files /dev/null and b/docs/.gitbook/assets/state_machine_actions.png differ diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b69a604a..214fb1b2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ * [Customizing the metatags](cookbook/admin_panel/metatags.md) * [Using autocompletes](cookbook/admin_panel/using-autocompletes.md) * [Exporting grid data](cookbook/admin_panel/grid_export.md) + * [State machine transitions](cookbook/admin_panel/state_machine.md) * [How to use in a DDD architecture](cookbook/ddd_architecture.md) * [Architecture overview](cookbook/ddd_architecture/overview.md) * [Resource configuration](cookbook/ddd_architecture/resource_configuration.md) diff --git a/docs/cookbook/admin_panel/state_machine.md b/docs/cookbook/admin_panel/state_machine.md new file mode 100644 index 00000000..163420df --- /dev/null +++ b/docs/cookbook/admin_panel/state_machine.md @@ -0,0 +1,307 @@ +# State machine transitions cookbook + +The Sylius Stack lets you leverage [Symfony Workflow](https://symfony.com/doc/current/workflow.html) to apply **state machine transitions** +directly from your grids or Twig templates using `actions`. If you're not familiar with workflows and state +machines in Symfony, read [this article](https://symfony.com/doc/current/workflow/workflow-and-state-machine.html). + +## Install Symfony Workflow + +You need to install [Symfony Workflow](https://symfony.com/doc/current/workflow.html) + +````bash +composer require symfony/workflow +```` + +## Example workflow configuration + +Then, you need to set up your own workflow. In this example, we will assume a speaker can submit an application for a +talk at a conference (the famous "Call for Papers" !). + +An application for a talk starts a `draft`, then it can be `submitted` by the speaker. + +In this admin interface, we will "allow" admin users to either : + +- `accept` an application +- `reject` an application +- put an application on a` waiting list` +- `cancel` an application (this action could possibly be done by the speaker themselves too if they couldn't make it anymore to the conference event) + +### Symfony workflow config + +We therefore need to register our config. This is done in `config/packages/workflow.yaml`. + +For the purpose of this example, we've placed the config in a separate PHP file, which we then imported in +`config/packages/workflow.yaml` this way : + +{% code title="config/packages/workflow.yaml" lineNumbers=true %} +```yaml +imports: + - { resource: '../workflows/**/*.php' } + +framework: + workflows: null +``` +{% endcode %} + +{% code title="config/workflows/application.php" lineNumbers=true %} +```php +extension('framework', [ + 'workflows' => [ + 'care_task' => [ + 'type' => 'state_machine', + 'initial_marking' => ApplicationStatus::DRAFT->value, + 'marking_store' => [ + 'property' => 'status', + ], + 'supports' => [ + Application::class, + ], + 'audit_trail' => [ + 'enabled' => true, + ], + 'places' => [ + ApplicationStatus::SUBMITTED->value, + ApplicationStatus::ACCEPTED->value, + ApplicationStatus::REJECTED->value, + ApplicationStatus::WAITING_LIST->value, + ApplicationStatus::CANCELLED->value, + ], + 'transitions' => [ + 'submit' => [ + 'from' => ApplicationStatus::DRAFT->value, + 'to' => ApplicationStatus::SUBMITTED->value, + ], + 'accept' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::ACCEPTED->value, + ], + 'reject' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::REJECTED->value, + ], + 'hold' => [ + 'from' => ApplicationStatus::SUBMITTED->value, + 'to' => ApplicationStatus::WAITING_LIST->value, + ], + 'cancel' => [ + 'from' => [ApplicationStatus::ACCEPTED->value, ApplicationStatus::WAITING_LIST->value], + 'to' => ApplicationStatus::CANCELLED->value, + ], + ], + ], + ], + ]); +}; +``` +{% endcode %} + +### Create a Resource and Grid + +Now, we need to create a Sylius resource and a Sylius grid to manage our `Applications`. + +First, let's create an `Application` resource : + +{% code title="src/Entity/Application.php" lineNumbers="true" %} +```php +id; + } + + public function getSubmittedAt(): ?\DateTimeImmutable + { + return $this->submittedAt; + } + + public function setSubmittedAt(\DateTimeImmutable $submittedAt): static + { + $this->submittedAt = $submittedAt; + + return $this; + } + + public function getTalk(): ?Talk + { + return $this->talk; + } + + public function setTalk(Talk $talk): static + { + $this->talk = $talk; + + return $this; + } + + public function getStatus(): ?ApplicationStatus + { + return $this->status; + } + + public function setStatus(ApplicationStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } +} +``` +{% endcode %} + +The resource is linked it up to a standard Sylius Grid : + +{% code title="src/Grid/ApplicationGrid.php" lineNumbers="true" %} +```php +withFilters( + EntityFilter::create(name: 'conference', resourceClass: Conference::class, fields: ['talk.conference']) + ->setLabel('app.ui.conference') + ->addFormOption('choice_label', 'name'), + Filter::create(name: 'speaker', type: SpeakerFilter::class) + ->setLabel('app.ui.speaker') + ->setOptions(['fields' => ['talk.speakers.id']]), + StringFilter::create('search', ['talk.title']) + ->setLabel('sylius.ui.search'), + EnumFilter::create(name: 'status', enumClass: ApplicationStatus::class, field: 'status') + ->addFormOption('choice_value', fn (?ApplicationStatus $enum) => $enum?->value) + ->addFormOption('choice_label', fn (ApplicationStatus $choice) => ucfirst($choice->value)) + ->setLabel('app.ui.status'), + DateFilter::create('submittedAt') + ->setLabel('app.ui.submitted_at'), + ) + ->withFields( + StringField::create('talk.title') + ->setLabel('app.ui.talk'), + TwigField::create(name: 'talk.speakers', template: 'talk/grid/field/speakers.html.twig') + ->setLabel('app.ui.speakers') + ->setSortable(true), + EnumField::create('status') + ->setLabel('app.ui.status') + ->setSortable(true), + DateTimeField::create('submittedAt') + ->setLabel('app.ui.submitted_at') + ->setSortable(true), + DateTimeField::create('updatedAt') + ->setLabel('app.ui.updated_at') + ->setSortable(true), + ) + } +} +``` +{% endcode %} + + +### State machine transition operations + +ressource +### State machine transtion actions +grille + + +The Sylius Stack will automatically enable your state machine transition action if the action can be executed according to +the workflow configuration you set up. + +
Applications grid with state machine actions
diff --git a/symfony.lock b/symfony.lock index 283feadd..0532efb9 100644 --- a/symfony.lock +++ b/symfony.lock @@ -324,6 +324,18 @@ "config/routes/web_profiler.yaml" ] }, + "symfony/workflow": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.3", + "ref": "3b2f8ca32a07fcb00f899649053943fa3d8bbfb6" + }, + "files": [ + "config/packages/workflow.yaml" + ] + }, "vich/uploader-bundle": { "version": "2.4", "recipe": { diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 8b27f63f..b6278ec4 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -1,5 +1,7 @@ app: ui: + application: Anmeldung + applications: Anmeldungen archival: Archivierung author: Autor author_name: Name des Autors diff --git a/translations/messages.de_AT.yaml b/translations/messages.de_AT.yaml index 8b27f63f..b6278ec4 100644 --- a/translations/messages.de_AT.yaml +++ b/translations/messages.de_AT.yaml @@ -1,5 +1,7 @@ app: ui: + application: Anmeldung + applications: Anmeldungen archival: Archivierung author: Autor author_name: Name des Autors diff --git a/translations/messages.de_CH.yaml b/translations/messages.de_CH.yaml index 8b27f63f..b6278ec4 100644 --- a/translations/messages.de_CH.yaml +++ b/translations/messages.de_CH.yaml @@ -1,5 +1,7 @@ app: ui: + application: Anmeldung + applications: Anmeldungen archival: Archivierung author: Autor author_name: Name des Autors diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 618d2749..f79abfb0 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,5 +1,8 @@ app: ui: + accept: Accept + application: Application + applications: Applications archival: Archival author: Author author_name: Author name @@ -8,6 +11,7 @@ app: book: Book books: Books browsing_speakers: Browsing speakers + cancel: Cancel category: Category company_name: Company name conference: Conference @@ -17,6 +21,7 @@ app: ends_at: Ends at export: Export first_name: First name + hold: Place on waiting list last_name: Last name library: Library managing_your_conferences: Managing your conferences @@ -26,11 +31,14 @@ app: new_speakers: New speakers new_talks: New talks past_event: Past event + reject: Reject show_all: Show all show_talks: Show talks speaker: Speaker speakers: Speakers starts_at: Starts at + status: Status + submitted_at: Submitted at statistics: day: Day lifetime: Lifetime @@ -44,3 +52,4 @@ app: tech_two: Tech#2 title: Title track: Track + updated_at: Updated at diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml index 45fb2e5d..2035d40b 100644 --- a/translations/messages.fr.yaml +++ b/translations/messages.fr.yaml @@ -1,5 +1,8 @@ app: ui: + accept: Accepter + application: Candidature + applications: Candidatures archival: Archive author: Auteur author_name: Auteur @@ -8,6 +11,7 @@ app: book: Livre books: Livres browsing_speakers: Parcourir les Conférencier-e-s + cancel: Annuler category: Catégorie company_name: Société conference: Conférence @@ -17,6 +21,7 @@ app: ends_at: Termine à export: Export first_name: Prénom + hold: Mettre sur liste d'attente last_name: Nom de famille library: Bibliothèque managing_your_conferences: Gérer vos conférences @@ -24,13 +29,17 @@ app: name: Nom new_conference: Nouvelle conférence past_event: Evènement passé + reject: Rejeter show_talks: Affiche les talks speaker: Conférencier speakers: Conférencier–e–s starts_at: Commence à + status: Statut + submitted_at: Envoyée à talk: Talk talks: Talks tech_one: Tech#1 tech_two: Tech#2 title: Titre track: Track + updated_at: Mise à jour à