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..3b73dd4f
--- /dev/null
+++ b/app/Entity/Application.php
@@ -0,0 +1,129 @@
+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;
+ }
+
+ /**
+ * String-typed accessor used by Symfony Workflow's MethodMarkingStore.
+ *
+ * Symfony < 7 does not support BackedEnum properties in the default
+ * marking store and attempts to cast the enum to string, which throws.
+ * Exposing the backed value keeps the domain API enum-typed while
+ * remaining compatible with Symfony 6.4.
+ */
+ public function getStatusValue(): ?string
+ {
+ return $this->status?->value;
+ }
+
+ public function setStatusValue(string $value): void
+ {
+ $this->status = ApplicationStatus::from($value);
+ }
+
+ 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..2bfbf019
--- /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]);
+ }
+
+ public function withTalk(Talk $talk): self
+ {
+ return $this->with(['talk' => $talk]);
+ }
+
+ public function withStatus(ApplicationStatus $status): self
+ {
+ return $this->with(['status' => $status]);
+ }
+
+ #[\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..790d94f7
--- /dev/null
+++ b/app/Grid/ApplicationGrid.php
@@ -0,0 +1,90 @@
+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')
+ ->setPath('talk.title')
+ ->setLabel('app.ui.talk'),
+ TwigField::create(name: 'speakers', template: 'talk/grid/field/speakers.html.twig')
+ ->setPath('talk.speakers')
+ ->setLabel('app.ui.speakers'),
+ StringField::create('status')
+ ->setPath('status.value')
+ ->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..bea0f7df
--- /dev/null
+++ b/app/Repository/ApplicationRepository.php
@@ -0,0 +1,54 @@
+
+ */
+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' => [
+ 'application' => [
+ 'type' => 'state_machine',
+ 'initial_marking' => ApplicationStatus::DRAFT->value,
+ 'marking_store' => [
+ // Point at statusValue (string) rather than status (BackedEnum):
+ // Symfony < 7's MethodMarkingStore casts the property to string
+ // and does not support BackedEnum properties.
+ 'property' => 'statusValue',
+ ],
+ 'supports' => [
+ Application::class,
+ ],
+ 'audit_trail' => [
+ 'enabled' => true,
+ ],
+ 'places' => [
+ ApplicationStatus::DRAFT->value,
+ 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::DRAFT->value, ApplicationStatus::SUBMITTED->value, ApplicationStatus::ACCEPTED->value, ApplicationStatus::WAITING_LIST->value],
+ 'to' => ApplicationStatus::CANCELLED->value,
+ ],
+ ],
+ ],
+ ],
+ ]);
+};
diff --git a/docs/.gitbook/assets/debug_router_state_machine.png b/docs/.gitbook/assets/debug_router_state_machine.png
new file mode 100644
index 00000000..f455eb21
Binary files /dev/null and b/docs/.gitbook/assets/debug_router_state_machine.png differ
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..46e617c7
--- /dev/null
+++ b/docs/cookbook/admin_panel/state_machine.md
@@ -0,0 +1,445 @@
+# 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' => [
+ 'application' => [
+ '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 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
+
+To enable your state machine transitions, you need to add an `ApplyStateMachineTransition` operation for each of
+the operations you want to enable on your Resource.
+
+{% code title="src/Entity/Application.php" lineNumbers="true" %}
+```php
+
+
+
+### State machine transition actions
+
+#### Actions in Grids
+
+Once the operation has been added to your Resource, you can simply add an `ApplyTransitionAction` button on your grid.
+
+{% code title="src/Grid/ApplicationGrid.php" lineNumbers=true %}
+```php
+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'),
+ );
+ }
+}
+```
+{% endcode %}
+
+
+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.
+
+