diff --git a/README.md b/README.md index dfb2207..246b1e8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ XState is a state machine library to play with any complex behavior of your PHP ### Installation -The recommended way to install Xstate is through +The recommended way to install Xstate is through [Composer](https://getcomposer.org/). ```bash @@ -58,6 +58,21 @@ The `Transition` class expect 3 required params: - **From**: Expect a string for a single / or array for multiple initial allowed states - **To**: Expect string which is the next target state *(should match one of the defined allowed states)* +#### Guards (optional) + +You can either define a guard callback for a specific transition using `guard` method, which must return a bool. If a guard returns false, the transition cannot be performed. + +```php +use \Mouadziani\XState\Transition; +$video->transitions([ + (new Transition('PLAY', ['stopped', 'paused'], 'playing')) + ->guard(function ($from, $to) { + return true; + }) +]); +``` + + ### 💡 You can define the whole workflow with a single statement: ```php @@ -122,8 +137,8 @@ $video->addTransition(new Transition('TURN_OFF', 'playing', 'stopped')); ## Upcoming features +- [x] Add the ability to define guard for a specific transition - [ ] Define/handle hooks before/after triggering transition -- [ ] Add the ability to define gates for a specific transition ## Testing diff --git a/composer.json b/composer.json index 74a79f2..fd31b7b 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "pestphp/pest": "^1.20", - "spatie/ray": "^1.28" + "pestphp/pest": "^1.20" }, "autoload": { "psr-4": { diff --git a/src/StateMachine.php b/src/StateMachine.php index 2d31e2f..ea25901 100755 --- a/src/StateMachine.php +++ b/src/StateMachine.php @@ -2,7 +2,6 @@ namespace Mouadziani\XState; -use Closure; use Mouadziani\XState\Exceptions\TransitionNotAllowedException; use Mouadziani\XState\Exceptions\TransitionNotDefinedException; @@ -16,10 +15,6 @@ class StateMachine private ?string $currentState = null; - private ?Closure $beforeEachTransition = null; - - private ?Closure $afterEachTransition = null; - public static function make(): self { return new static(); @@ -61,20 +56,6 @@ public function addTransition(Transition $transition): self return $this; } - public function beforeEachTransition(Closure $beforeEachTransition): self - { - $this->beforeEachTransition = $beforeEachTransition; - - return $this; - } - - public function afterEachTransition(Closure $afterTransition): self - { - $this->afterTransition = $afterTransition; - - return $this; - } - public function currentState(): string { return $this->currentState; @@ -92,7 +73,7 @@ public function transitionTo(string $trigger): self throw new TransitionNotAllowedException('Transition not allowed'); } - $transition->handle($this->currentState, $this->beforeEachTransition, $this->afterEachTransition); + $transition->handle($this->currentState); return $this; } @@ -101,16 +82,16 @@ public function canTransisteTo(string $trigger): bool { $transition = $this->findTransition($trigger); - return $transition && in_array($trigger, $this->allowedTransitions()); + return $transition->allowed(); + + return $transition && $transition->allowed() && in_array($trigger, $this->allowedTransitions()); } public function allowedTransitions(): array { - $allowedTransitions = array_filter( - $this->transitions, - fn ($transition) => - in_array($this->currentState(), is_array($transition->from) ? $transition->from : [$transition->from]) - ); + $allowedTransitions = array_filter($this->transitions, function ($transition) { + return in_array($this->currentState(), is_array($transition->from) ? $transition->from : [$transition->from]); + }); return array_map(fn ($transition) => $transition->trigger, array_values($allowedTransitions)); } diff --git a/src/Transition.php b/src/Transition.php index e3f200b..06acb0b 100644 --- a/src/Transition.php +++ b/src/Transition.php @@ -3,32 +3,65 @@ namespace Mouadziani\XState; use Closure; +use Mouadziani\XState\Exceptions\TransitionNotAllowedException; class Transition { public string $trigger; + public string|array $from; + public string $to; - public ?Closure $beforeHook; - public ?Closure $afterHook; - public function __construct(string $trigger, string|array $from, string $to, ?Closure $beforeHook = null, ?Closure $afterHook = null) + private ?Closure $guard = null; + + private ?Closure $before = null; + + private ?Closure $after = null; + + public function __construct(string $trigger, string|array $from, string $to) { $this->trigger = $trigger; $this->from = $from; $this->to = $to; - $this->beforeHook = $beforeHook; - $this->afterHook = $afterHook; } - public function handle(string &$currentState, ?Closure $beforeTransition = null, ?Closure $afterTransition = null): void + public function guard(Closure $guard) { - $beforeTransition && $beforeTransition($this->from, $this->to); - $this->beforeHook && call_user_func($this->beforeHook, $this->from, $this->to); + $this->guard = $guard; + + return $this; + } + + public function before(Closure $before) + { + $this->before = $before; + + return $this; + } + + public function after(Closure $before) + { + $this->before = $before; + + return $this; + } + + public function allowed(): bool + { + return ! $this->guard || call_user_func($this->guard, $this->from, $this->to); + } + + public function handle(string &$currentState): void + { + if (! $this->allowed()) { + throw new TransitionNotAllowedException(); + } + + $this->before && call_user_func($this->before, $this->from, $this->to); $currentState = $this->to; - $afterTransition && $afterTransition($this->from, $this->to); - $this->afterHook && call_user_func($this->afterHook, $this->from, $this->to); + $this->after && call_user_func($this->after, $this->from, $this->to); } } diff --git a/tests/StateMachineTest.php b/tests/StateMachineTest.php index df0ba91..f99ce79 100644 --- a/tests/StateMachineTest.php +++ b/tests/StateMachineTest.php @@ -133,3 +133,22 @@ $video->transitionTo('TURN_OFF'); })->throws(TransitionNotDefinedException::class); + +it('Uses guard to check if the transition is allowed', function () { + $video = StateMachine::make() + ->defaultState('stopped') + ->states(['playing', 'stopped', 'paused']) + ->transitions([ + (new Transition('PLAY', ['stopped', 'paused'], 'playing')) + ->guard(fn ($from, $to) => false), + + (new Transition('TURN_ON', ['stopped', 'paused'], 'playing')) + ->guard(fn ($from, $to) => true), + ]); + + expect(false)->toBe($video->canTransisteTo('PLAY')); + expect(true)->toBe($video->canTransisteTo('TURN_ON')); + + $video->transitionTo('TURN_ON'); + expect($video->currentState())->toBe('playing'); +});