diff --git a/.gitignore b/.gitignore index 1cd717b..7ac8b99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,4 @@ vendor/ -node_modules/ - -# Laravel 4 specific -bootstrap/compiled.php -app/storage/ - -# Laravel 5 & Lumen specific -bootstrap/cache/ -.env.*.php -.env.php -.env - -# Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer -.rocketeer/ +straight.png +composer.lock +.idea \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1d0b152 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php + +php: + - 7.0 + - 7.1 + - 7.2 +before_install: + - sudo apt-get update -q + - sudo apt-get autoremove graphviz -y + - sudo apt-get install graphviz -y + +before_script: + - composer self-update + - composer install --no-interaction + +script: + - vendor/bin/phpunit diff --git a/LICENSE b/LICENSE index beca08b..b36f86c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 PICR +Copyright (c) 2016 BREXIS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a8a3098..c152a9c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,21 @@ -# Laravel workflow +# Laravel workflow [![Build Status](https://travis-ci.org/brexis/laravel-workflow.svg?branch=1.1.2)](https://travis-ci.org/brexis/laravel-workflow) Use the Symfony Workflow component in Laravel -### Composer Configuration - -Include the laravel-workflow package as a dependency in your `composer.json`: - - "picr/laravel-workflow": "dev-master" - ### Installation -Run `composer install` to download the dependencies. + composer require brexis/laravel-workflow -#### Laravel 5 +#### For laravel <= 5.4 Add a ServiceProvider to your providers array in `config/app.php`: ```php -'providers' => [ + [ + ... + Brexis\LaravelWorkflow\WorkflowServiceProvider::class, ] ``` @@ -27,5 +23,202 @@ Add a ServiceProvider to your providers array in `config/app.php`: Add the `Workflow` facade to your facades array: ```php - 'Workflow' => 'Picr\LaravelWorkflow\Facades\WorkflowFacade', + Brexis\LaravelWorkflow\Facades\WorkflowFacade::class, +``` + +### Configuration + +Publish the config file + +``` + php artisan vendor:publish --provider="Brexis\LaravelWorkflow\WorkflowServiceProvider" +``` + +Configure your workflow in `config/workflow.php` + +```php + [ + 'type' => 'workflow', // or 'state_machine' + 'marking_store' => [ + 'type' => 'multiple_state', + 'arguments' => ['currentPlace'] + ], + 'supports' => ['App\BlogPost'], + 'places' => ['draft', 'review', 'rejected', 'published'], + 'transitions' => [ + 'to_review' => [ + 'from' => 'draft', + 'to' => 'review' + ], + 'publish' => [ + 'from' => 'review', + 'to' => 'published' + ], + 'reject' => [ + 'from' => 'review', + 'to' => 'rejected' + ] + ], + ] +]; +``` + +Use the `WorkflowTrait` inside supported classes + +```php +can($post, 'publish'); // False +$workflow->can($post, 'to_review'); // True +$transitions = $workflow->getEnabledTransitions($post); + +// Apply a transition +$workflow->apply($post, 'to_review'); +$post->save(); // Don't forget to persist the state + +// Using the WorkflowTrait +$post->workflow_can('publish'); // True +$post->workflow_can('to_review'); // False + +// Get the post transitions +foreach ($post->workflow_transitions() as $transition) { + echo $transition->getName(); +} + +// Apply a transition +$post->workflow_apply('publish'); +$post->save(); +``` + +### Use the events +This package provides a list of events fired during a transition + +```php + Brexis\LaravelWorkflow\Events\Guard + Brexis\LaravelWorkflow\Events\Leave + Brexis\LaravelWorkflow\Events\Transition + Brexis\LaravelWorkflow\Events\Enter + Brexis\LaravelWorkflow\Events\Entered +``` + +You can subscribe to an event + +```php +getOriginalEvent(); + + /** @var App\BlogPost $post */ + $post = $originalEvent->getSubject(); + $title = $post->title; + + if (empty($title)) { + // Posts with no title should not be allowed + $originalEvent->setBlocked(true); + } + } + + /** + * Handle workflow leave event. + */ + public function onLeave($event) {} + + /** + * Handle workflow transition event. + */ + public function onTransition($event) {} + + /** + * Handle workflow enter event. + */ + public function onEnter($event) {} + + /** + * Handle workflow entered event. + */ + public function onEntered($event) {} + + /** + * Register the listeners for the subscriber. + * + * @param Illuminate\Events\Dispatcher $events + */ + public function subscribe($events) + { + $events->listen( + 'Brexis\LaravelWorkflow\Events\GuardEvent', + 'App\Listeners\BlogPostWorkflowSubscriber@onGuard' + ); + + $events->listen( + 'Brexis\LaravelWorkflow\Events\LeaveEvent', + 'App\Listeners\BlogPostWorkflowSubscriber@onLeave' + ); + + $events->listen( + 'Brexis\LaravelWorkflow\Events\TransitionEvent', + 'App\Listeners\BlogPostWorkflowSubscriber@onTransition' + ); + + $events->listen( + 'Brexis\LaravelWorkflow\Events\EnterEvent', + 'App\Listeners\BlogPostWorkflowSubscriber@onEnter' + ); + + $events->listen( + 'Brexis\LaravelWorkflow\Events\EnteredEvent', + 'App\Listeners\BlogPostWorkflowSubscriber@onEntered' + ); + } + +} +``` + +### Dump Workflows +Symfony workflow uses GraphvizDumper to create the workflow image. You may need to install the `dot` command of [Graphviz](http://www.graphviz.org/) + + php artisan workflow:dump workflow_name --class App\\BlogPost + +You can change the image format with the `--format` option. By default the format is png. + + php artisan workflow:dump workflow_name --format=jpg diff --git a/composer.json b/composer.json index efac4d2..cfda9b1 100644 --- a/composer.json +++ b/composer.json @@ -1,22 +1,41 @@ { - "name": "picr/laravel-workflow", + "name": "brexis/laravel-workflow", "description": "Integerate Symfony Workflow component into Laravel.", - "keywords": ["workflow", "petri net", "laravel", "laravel5"], + "keywords": ["workflow", "symfony", "laravel", "laravel5"], "license": "MIT", "require": { - "php": ">=5.5.0", - "symfony/workflow": "^3.2@dev", - "symfony/property-access": "^3.2@dev" + "php": ">=5.5.9", + "symfony/workflow": "^3.3 || ^4.0", + "symfony/process": "^3.3 || ^4.0", + "symfony/event-dispatcher": "^3.3 || ^4.0", + "illuminate/console": "5.3.* || 5.4.* || 5.5.* || 5.6.* || 5.7.* || 5.8.*", + "illuminate/support": "5.3.* || 5.4.* || 5.5.* || 5.6.* || 5.7.* || 5.8.*" }, "autoload": { - "psr-0": { - "Picr\\LaravelWorkflow": "src/" + "psr-4": { + "Brexis\\LaravelWorkflow\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" } }, "extra": { "branch-alias": { "dev-master": "1.0-dev" + }, + "laravel": { + "providers": [ + "Brexis\\LaravelWorkflow\\WorkflowServiceProvider" + ], + "aliases": { + "Workflow": "Brexis\\LaravelWorkflow\\Facades\\WorkflowFacade" + } } }, - "minimum-stability": "dev" + "require-dev": { + "mockery/mockery": "^0.9.8", + "phpunit/phpunit": "^6.0 || ~7.0" + } } diff --git a/config/config.php b/config/config.php deleted file mode 100644 index 53d8135..0000000 --- a/config/config.php +++ /dev/null @@ -1,208 +0,0 @@ - [ - 'marking_store' => [ - 'type' => 'property_accessor', - ], - 'supports' => [ - 0 => 'stdClass', - ], - 'places' => [ - 0 => 'a', - 1 => 'b', - 2 => 'c', - 3 => 'd', - ], - 'transitions' => [ - 't1' => [ - 'from' => 'a', - 'to' => 'b', - ], - 't2' => [ - 'from' => 'b', - 'to' => 'c', - ], - 't3' => [ - 'from' => 'c', - 'to' => 'd', - ], - ], - ], - 'round_trip' => [ - 'marking_store' => [ - 'type' => 'property_accessor', - ], - 'supports' => [ - 0 => 'stdClass', - ], - 'places' => [ - 0 => 'a', - 1 => 'b', - 2 => 'c', - ], - 'transitions' => [ - 't1' => [ - 'from' => 'a', - 'to' => 'b', - ], - 't2' => [ - 'from' => 'b', - 'to' => 'c', - ], - 't3' => [ - 'from' => 'c', - 'to' => 'a', - ], - ], - ], - 'or' => [ - 'marking_store' => [ - 'type' => 'property_accessor', - ], - 'supports' => [ - 0 => 'stdClass', - ], - 'places' => [ - 0 => 'a', - 1 => 'b', - 2 => 'c', - 3 => 'd', - ], - 'transitions' => [ - 't1' => [ - 'from' => 'a', - 'to' => 'b', - ], - 't2' => [ - 'from' => 'a', - 'to' => 'c', - ], - 't3' => [ - 'from' => 'b', - 'to' => 'd', - ], - 't4' => [ - 'from' => 'c', - 'to' => 'd', - ], - ], - ], - 'and' => [ - 'marking_store' => [ - 'type' => 'property_accessor', - ], - 'supports' => [ - 0 => 'stdClass', - ], - 'places' => [ - 0 => 'a', - 1 => 'b', - 2 => 'c', - 3 => 'd', - 4 => 'e', - 5 => 'f', - ], - 'transitions' => [ - 't1' => [ - 'from' => 'a', - 'to' => [ - 0 => 'b', - 1 => 'c', - ], - ], - 't2' => [ - 'from' => 'b', - 'to' => 'd', - ], - 't3' => [ - 'from' => 'c', - 'to' => 'e', - ], - 't4' => [ - 'from' => [ - 0 => 'd', - 1 => 'e', - ], - 'to' => 'f', - ], - ], - ], - 'wtf' => [ - 'marking_store' => [ - 'type' => 'property_accessor', - ], - 'supports' => 'stdClass', - 'places' => [ - 0 => 'a', - 1 => 'b', - 2 => 'c', - 3 => 'd', - 4 => 'e', - 5 => 'f', - 6 => 'g', - 7 => 'h', - 8 => 'i', - 9 => 'j', - 10 => 'k', - ], - 'transitions' => [ - 't1' => [ - 'from' => 'a', - 'to' => 'b', - ], - 't2' => [ - 'from' => 'b', - 'to' => 'c', - ], - 't3' => [ - 'from' => 'c', - 'to' => 'd', - ], - 't4' => [ - 'from' => 'b', - 'to' => 'e', - ], - 't5' => [ - 'from' => 'b', - 'to' => 'f', - ], - 't6' => [ - 'from' => [ - 0 => 'c', - 1 => 'd', - ], - 'to' => [ - 0 => 'f', - 1 => 'g', - ], - ], - 't7' => [ - 'from' => 'e', - 'to' => 'h', - ], - 't8' => [ - 'from' => [ - 0 => 'e', - 1 => 'g', - 2 => 'i', - ], - 'to' => 'h', - ], - 't9' => [ - 'from' => [ - 0 => 'f', - 1 => 'g', - ], - 'to' => [ - 0 => 'i', - 1 => 'j', - ], - ], - 't10' => [ - 'from' => 'h', - 'to' => 'k', - ], - ], - ], -]; \ No newline at end of file diff --git a/config/workflow.php b/config/workflow.php new file mode 100644 index 0000000..7c6fd39 --- /dev/null +++ b/config/workflow.php @@ -0,0 +1,22 @@ + [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'single_state', + ], + 'supports' => ['stdClass'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + 't1' => [ + 'from' => 'a', + 'to' => 'b', + ], + 't2' => [ + 'from' => 'b', + 'to' => 'c', + ] + ], + ] +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..da28f90 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests + + + diff --git a/src/Commands/WorkflowDumpCommand.php b/src/Commands/WorkflowDumpCommand.php new file mode 100644 index 0000000..fa008e1 --- /dev/null +++ b/src/Commands/WorkflowDumpCommand.php @@ -0,0 +1,69 @@ + + */ +class WorkflowDumpCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'workflow:dump + {workflow : name of workflow from configuration} + {--class= : the support class name} + {--format=png : the image format}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'GraphvizDumper dumps a workflow as a graphviz file. + You can convert the generated dot file with the dot utility (http://www.graphviz.org/):'; + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $workflowName = $this->argument('workflow'); + $format = $this->option('format'); + $class = $this->option('class'); + $config = Config::get('workflow'); + + if (!isset($config[$workflowName])) { + throw new Exception("Workflow $workflowName is not configured."); + } + + if (false === array_search($class, $config[$workflowName]['supports'])) { + throw new Exception("Workflow $workflowName has no support for class $class.". + ' Please specify a valid support class with the --class option.'); + } + + $subject = new $class; + $workflow = Workflow::get($subject, $workflowName); + $definition = $workflow->getDefinition(); + + $dumper = new GraphvizDumper(); + + $dotCommand = "dot -T$format -o $workflowName.$format"; + + $process = new Process($dotCommand); + $process->setInput($dumper->dump($definition)); + $process->mustRun(); + } +} diff --git a/src/Events/BaseEvent.php b/src/Events/BaseEvent.php new file mode 100644 index 0000000..d126f31 --- /dev/null +++ b/src/Events/BaseEvent.php @@ -0,0 +1,30 @@ + + */ +abstract class BaseEvent +{ + /** + * @var Event + */ + protected $originalEvent; + + public function __construct(Event $event) + { + $this->originalEvent = $event; + } + + /** + * Return the original event + * @return Event + */ + public function getOriginalEvent() + { + return $this->originalEvent; + } +} diff --git a/src/Events/CompletedEvent.php b/src/Events/CompletedEvent.php new file mode 100644 index 0000000..2c03b33 --- /dev/null +++ b/src/Events/CompletedEvent.php @@ -0,0 +1,11 @@ + + */ +class CompletedEvent extends BaseEvent +{ + // +} diff --git a/src/Events/EnterEvent.php b/src/Events/EnterEvent.php new file mode 100644 index 0000000..7b46338 --- /dev/null +++ b/src/Events/EnterEvent.php @@ -0,0 +1,11 @@ + + */ +class EnterEvent extends BaseEvent +{ + // +} diff --git a/src/Events/EnteredEvent.php b/src/Events/EnteredEvent.php new file mode 100644 index 0000000..d191f5e --- /dev/null +++ b/src/Events/EnteredEvent.php @@ -0,0 +1,11 @@ + + */ +class EnteredEvent extends BaseEvent +{ + // +} diff --git a/src/Events/GuardEvent.php b/src/Events/GuardEvent.php new file mode 100644 index 0000000..0b1b625 --- /dev/null +++ b/src/Events/GuardEvent.php @@ -0,0 +1,16 @@ + + */ +class GuardEvent extends BaseEvent +{ + public function __construct(SymfonyGuardEvent $event) + { + $this->originalEvent = $event; + } +} diff --git a/src/Events/LeaveEvent.php b/src/Events/LeaveEvent.php new file mode 100644 index 0000000..ed0803f --- /dev/null +++ b/src/Events/LeaveEvent.php @@ -0,0 +1,11 @@ + + */ +class LeaveEvent extends BaseEvent +{ + // +} diff --git a/src/Events/TransitionEvent.php b/src/Events/TransitionEvent.php new file mode 100644 index 0000000..b44b591 --- /dev/null +++ b/src/Events/TransitionEvent.php @@ -0,0 +1,11 @@ + + */ +class TransitionEvent extends BaseEvent +{ + // +} diff --git a/src/Events/WorkflowSubscriber.php b/src/Events/WorkflowSubscriber.php new file mode 100644 index 0000000..75e8fb7 --- /dev/null +++ b/src/Events/WorkflowSubscriber.php @@ -0,0 +1,100 @@ + + */ +class WorkflowSubscriber implements EventSubscriberInterface +{ + public function guardEvent(SymfonyGuardEvent $event) + { + $workflowName = $event->getWorkflowName(); + $transitionName = $event->getTransition()->getName(); + + event(new GuardEvent($event)); + event('workflow.guard', $event); + event(sprintf('workflow.%s.guard', $workflowName), $event); + event(sprintf('workflow.%s.guard.%s', $workflowName, $transitionName), $event); + } + + public function leaveEvent(Event $event) + { + $places = $event->getTransition()->getFroms(); + $workflowName = $event->getWorkflowName(); + + event(new LeaveEvent($event)); + event('workflow.leave', $event); + event(sprintf('workflow.%s.leave', $workflowName), $event); + + foreach ($places as $place) { + event(sprintf('workflow.%s.leave.%s', $workflowName, $place), $event); + } + } + + public function transitionEvent(Event $event) + { + $workflowName = $event->getWorkflowName(); + $transitionName = $event->getTransition()->getName(); + + event(new TransitionEvent($event)); + event('workflow.transition', $event); + event(sprintf('workflow.%s.transition', $workflowName), $event); + event(sprintf('workflow.%s.transition.%s', $workflowName, $transitionName), $event); + } + + public function enterEvent(Event $event) + { + $places = $event->getTransition()->getTos(); + $workflowName = $event->getWorkflowName(); + + event(new EnterEvent($event)); + event('workflow.enter', $event); + event(sprintf('workflow.%s.enter', $workflowName), $event); + + foreach ($places as $place) { + event(sprintf('workflow.%s.enter.%s', $workflowName, $place), $event); + } + } + + public function enteredEvent(Event $event) + { + $places = $event->getTransition()->getTos(); + $workflowName = $event->getWorkflowName(); + + event(new EnteredEvent($event)); + event('workflow.entered', $event); + event(sprintf('workflow.%s.entered', $workflowName), $event); + + foreach ($places as $place) { + event(sprintf('workflow.%s.entered.%s', $workflowName, $place), $event); + } + } + + public function completedEvent(Event $event) + { + $workflowName = $event->getWorkflowName(); + $transitionName = $event->getTransition()->getName(); + + event(new CompletedEvent($event)); + event('workflow.completed', $event); + event(sprintf('workflow.%s.completed', $workflowName), $event); + event(sprintf('workflow.%s.completed.%s', $workflowName, $transitionName), $event); + } + + public static function getSubscribedEvents() + { + return [ + 'workflow.guard' => ['guardEvent'], + 'workflow.leave' => ['leaveEvent'], + 'workflow.transition' => ['transitionEvent'], + 'workflow.enter' => ['enterEvent'], + 'workflow.entered' => ['enteredEvent'], + 'workflow.completed' => ['completedEvent'], + ]; + } +} diff --git a/src/Picr/LaravelWorkflow/Facades/WorkflowFacade.php b/src/Facades/WorkflowFacade.php similarity index 64% rename from src/Picr/LaravelWorkflow/Facades/WorkflowFacade.php rename to src/Facades/WorkflowFacade.php index 8091213..49b5480 100644 --- a/src/Picr/LaravelWorkflow/Facades/WorkflowFacade.php +++ b/src/Facades/WorkflowFacade.php @@ -1,13 +1,16 @@ + */ class WorkflowFacade extends Facade { protected static function getFacadeAccessor() { return 'workflow'; } -} \ No newline at end of file +} diff --git a/src/Picr/LaravelWorkflow/Commands/WorkflowGraphvizDumpCommand.php b/src/Picr/LaravelWorkflow/Commands/WorkflowGraphvizDumpCommand.php deleted file mode 100644 index a8e2d8b..0000000 --- a/src/Picr/LaravelWorkflow/Commands/WorkflowGraphvizDumpCommand.php +++ /dev/null @@ -1,67 +0,0 @@ -argument('workflow'); - $config = Config::get('workflow'); - if (!isset($config[$workflowName])) { - throw new Exception("There is not a workflow called $workflowName configured."); - } - $className = $config[$workflowName]['supports'][0]; // todo: add option to select single class? - - $workflow = Workflow::get(new $className, $workflowName); - - $property = new ReflectionProperty($workflow, 'definition'); - $property->setAccessible(true); - $definition = $property->getValue($workflow); - - $dumper = new GraphvizDumper(); - - if (! $outputType = $this->option('format')) { - $this->output->writeln($dumper->dump($definition)); - - return; - } - - $process = new Process('dot -T' . $outputType); - $process->setInput($dumper->dump($definition)); - $process->mustRun(); - $output = $process->getOutput(); - file_put_contents($workflowName . '.' . $outputType, $output); - - } -} \ No newline at end of file diff --git a/src/Picr/LaravelWorkflow/Events/EventDispatcher.php b/src/Picr/LaravelWorkflow/Events/EventDispatcher.php deleted file mode 100644 index a95dbeb..0000000 --- a/src/Picr/LaravelWorkflow/Events/EventDispatcher.php +++ /dev/null @@ -1,47 +0,0 @@ -publishes([$configPath => config_path('laravel-workflow.php')], 'config'); - } - - /** - * Register the application services. - * - * @return void - */ - public function register() - { - $this->commands($this->commands); - $this->registerWorkflow(); - } - - /** - * Register the Workflow - */ - public function registerWorkflow() - { - $this->app->singleton('workflow', function ($app) { - $registry = new Registry(); - foreach ($app['config']['workflow'] as $name => $workflowData) { - $definition = new Definition($workflowData['places']); - foreach ($workflowData['transitions'] as $transitionName => $transition) { - $definition->addTransition(new Transition($transitionName, $transition['from'], $transition['to'])); - } - - if (isset($workflowData['marking_store']['type'])) { - switch ($workflowData['marking_store']['type']) { - case 'property_accessor': - $markingStore = new PropertyAccessorMarkingStore(); - break; - case 'scalar': - $markingStore = new ScalarMarkingStore(); - break; - default: - throw new Exception("There needs to be a marking store"); - } - } - $workflow = new Workflow($definition, $markingStore, new EventDispatcher(), $name); - - foreach ($workflowData['supports'] as $supportedClass) { - $registry->add($workflow, $supportedClass); - } - } - - return $registry; - }); - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() - { - return ['workflow']; - } -} \ No newline at end of file diff --git a/src/Traits/WorkflowTrait.php b/src/Traits/WorkflowTrait.php new file mode 100644 index 0000000..6a47a19 --- /dev/null +++ b/src/Traits/WorkflowTrait.php @@ -0,0 +1,26 @@ + + */ +trait WorkflowTrait +{ + public function workflow_apply($transition, $workflow = null) + { + return Workflow::get($this, $workflow)->apply($this, $transition); + } + + public function workflow_can($transition, $workflow = null) + { + return Workflow::get($this, $workflow)->can($this, $transition); + } + + public function workflow_transitions() + { + return Workflow::get($this)->getEnabledTransitions($this); + } +} diff --git a/src/WorkflowRegistry.php b/src/WorkflowRegistry.php new file mode 100644 index 0000000..0e87d76 --- /dev/null +++ b/src/WorkflowRegistry.php @@ -0,0 +1,161 @@ + + */ +class WorkflowRegistry +{ + /** + * @var Registry + */ + protected $registry; + + /** + * @var array + */ + protected $config; + + /** + * @var EventDispatcher + */ + protected $dispatcher; + + /** + * WorkflowRegistry constructor + * + * @param array $config + * @throws \ReflectionException + */ + public function __construct(array $config) + { + $this->registry = new Registry(); + $this->config = $config; + $this->dispatcher = new EventDispatcher(); + + $subscriber = new WorkflowSubscriber(); + $this->dispatcher->addSubscriber($subscriber); + + foreach ($this->config as $name => $workflowData) { + $this->addFromArray($name, $workflowData); + } + } + + /** + * Return the $subject workflow + * + * @param object $subject + * @param string $workflowName + * @return Workflow + */ + public function get($subject, $workflowName = null) + { + return $this->registry->get($subject, $workflowName); + } + + /** + * Add a workflow to the subject + * + * @param Workflow $workflow + * @param string $supportStrategy + */ + public function add(Workflow $workflow, $supportStrategy) + { + $this->registry->add($workflow, new ClassInstanceSupportStrategy($supportStrategy)); + } + + /** + * Add a workflow to the registry from array + * + * @param string $name + * @param array $workflowData + * @throws \ReflectionException + */ + public function addFromArray($name, array $workflowData) + { + $builder = new DefinitionBuilder($workflowData['places']); + + foreach ($workflowData['transitions'] as $transitionName => $transition) { + if (!is_string($transitionName)) { + $transitionName = $transition['name']; + } + + foreach ((array)$transition['from'] as $form) { + $builder->addTransition(new Transition($transitionName, $form, $transition['to'])); + } + } + + $definition = $builder->build(); + $markingStore = $this->getMarkingStoreInstance($workflowData); + $workflow = $this->getWorkflowInstance($name, $workflowData, $definition, $markingStore); + + foreach ($workflowData['supports'] as $supportedClass) { + $this->add($workflow, $supportedClass); + } + } + + /** + * Return the workflow instance + * + * @param String $name + * @param array $workflowData + * @param Definition $definition + * @param MarkingStoreInterface $markingStore + * @return Workflow + */ + protected function getWorkflowInstance( + $name, + array $workflowData, + Definition $definition, + MarkingStoreInterface $markingStore + ) { + if (isset($workflowData['class'])) { + $className = $workflowData['class']; + } elseif (isset($workflowData['type']) && $workflowData['type'] === 'state_machine') { + $className = StateMachine::class; + } else { + $className = Workflow::class; + } + + return new $className($definition, $markingStore, $this->dispatcher, $name); + } + + /** + * Return the making store instance + * + * @param array $workflowData + * @return MarkingStoreInterface + * @throws \ReflectionException + */ + protected function getMarkingStoreInstance(array $workflowData) + { + $markingStoreData = isset($workflowData['marking_store']) ? $workflowData['marking_store'] : []; + $arguments = isset($markingStoreData['arguments']) ? $markingStoreData['arguments'] : []; + + if (isset($markingStoreData['class'])) { + $className = $markingStoreData['class']; + } elseif (isset($markingStoreData['type']) && $markingStoreData['type'] === 'multiple_state') { + $className = MultipleStateMarkingStore::class; + } else { + $className = SingleStateMarkingStore::class; + } + + $class = new \ReflectionClass($className); + + return $class->newInstanceArgs($arguments); + } +} diff --git a/src/WorkflowServiceProvider.php b/src/WorkflowServiceProvider.php new file mode 100644 index 0000000..51a7e52 --- /dev/null +++ b/src/WorkflowServiceProvider.php @@ -0,0 +1,65 @@ + + */ +class WorkflowServiceProvider extends ServiceProvider +{ + protected $commands = [ + 'Brexis\LaravelWorkflow\Commands\WorkflowDumpCommand', + ]; + + /** + * Bootstrap the application services... + * + * @return void + */ + public function boot() + { + $configPath = $this->configPath(); + + $this->publishes([ + $configPath => config_path('workflow.php') + ], 'config'); + } + + /** + * Register the application services. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom( + $this->configPath(), + 'workflow' + ); + + $this->commands($this->commands); + + $this->app->singleton( + 'workflow', function ($app) { + return new WorkflowRegistry($app['config']->get('workflow')); + } + ); + } + + protected function configPath() + { + return __DIR__ . '/../config/workflow.php'; + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return ['workflow']; + } +} diff --git a/tests/Fixtures/TestObject.php b/tests/Fixtures/TestObject.php new file mode 100644 index 0000000..50301de --- /dev/null +++ b/tests/Fixtures/TestObject.php @@ -0,0 +1,7 @@ +makePartial() + ->shouldReceive('argument') + ->with('workflow') + ->andReturn('fake') + ->shouldReceive('option') + ->with('format') + ->andReturn('png') + ->shouldReceive('option') + ->with('class') + ->andReturn('Tests\Fixtures\TestObject') + ->getMock(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Workflow fake is not configured.'); + $command->handle(); + } + + public function testShouldThrowExceptionForUndefinedClass() + { + $command = Mockery::mock(WorkflowDumpCommand::class) + ->makePartial() + ->shouldReceive('argument') + ->with('workflow') + ->andReturn('straight') + ->shouldReceive('option') + ->with('format') + ->andReturn('png') + ->shouldReceive('option') + ->with('class') + ->andReturn('Tests\Fixtures\FakeObject') + ->getMock(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Workflow straight has no support for'. + ' class Tests\Fixtures\FakeObject. Please specify a valid support'. + ' class with the --class option.'); + $command->handle(); + } + + public function testWorkflowCommand() + { + if (file_exists('straight.png')) { + unlink('straight.png'); + } + + $command = Mockery::mock(WorkflowDumpCommand::class) + ->makePartial() + ->shouldReceive('argument') + ->with('workflow') + ->andReturn('straight') + ->shouldReceive('option') + ->with('format') + ->andReturn('png') + ->shouldReceive('option') + ->with('class') + ->andReturn('Tests\Fixtures\TestObject') + ->getMock(); + + $command->handle(); + + $this->assertTrue(file_exists('straight.png')); + } + } +} + +namespace { + use Brexis\LaravelWorkflow\WorkflowRegistry; + + $config = [ + 'straight' => [ + 'supports' => ['Tests\Fixtures\TestObject'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + 't1' => [ + 'from' => 'a', + 'to' => 'b', + ], + 't2' => [ + 'from' => 'b', + 'to' => 'c', + ] + ], + ] + ]; + + class Workflow + { + public static function get($object, $name) + { + global $config; + + $workflowRegistry = new WorkflowRegistry($config); + + return $workflowRegistry->get($object, $name); + } + } + + class Config + { + public static function get($name) + { + global $config; + + return $config; + } + } +} diff --git a/tests/WorkflowRegistryTest.php b/tests/WorkflowRegistryTest.php new file mode 100644 index 0000000..6a43c11 --- /dev/null +++ b/tests/WorkflowRegistryTest.php @@ -0,0 +1,168 @@ + [ + 'supports' => ['Tests\Fixtures\TestObject'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + 't1' => [ + 'from' => 'a', + 'to' => 'b', + ], + 't2' => [ + 'from' => 'b', + 'to' => 'c', + ] + ], + ] + ]; + + $registry = new WorkflowRegistry($config); + $subject = new TestObject; + $workflow = $registry->get($subject); + + $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); + $markingStoreProp->setAccessible(true); + + $markingStore = $markingStoreProp->getValue($workflow); + + $this->assertTrue($workflow instanceof Workflow); + $this->assertTrue($markingStore instanceof SingleStateMarkingStore); + } + + public function testIfStateMachineIsRegistered() + { + $config = [ + 'straight' => [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'multiple_state', + ], + 'supports' => ['Tests\Fixtures\TestObject'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + 't1' => [ + 'from' => 'a', + 'to' => 'b', + ], + 't2' => [ + 'from' => 'b', + 'to' => 'c', + ] + ], + ] + ]; + + $registry = new WorkflowRegistry($config); + $subject = new TestObject; + $workflow = $registry->get($subject); + + $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); + $markingStoreProp->setAccessible(true); + + $markingStore = $markingStoreProp->getValue($workflow); + + $this->assertTrue($workflow instanceof StateMachine); + $this->assertTrue($markingStore instanceof MultipleStateMarkingStore); + } + + public function testIfTransitionsWithSameNameCanBothBeUsed() + { + $config = [ + 'straight' => [ + 'type' => 'state_machine', + 'supports' => ['Tests\Fixtures\TestObject'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + [ + 'name' => 't1', + 'from' => 'a', + 'to' => 'b', + ], + [ + 'name' => 't1', + 'from' => 'c', + 'to' => 'b', + ], + [ + 'name' => 't2', + 'from' => 'b', + 'to' => 'c', + ] + ], + ] + ]; + + $registry = new WorkflowRegistry($config); + $subject = new TestObject; + $workflow = $registry->get($subject); + + $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); + $markingStoreProp->setAccessible(true); + + $markingStore = $markingStoreProp->getValue($workflow); + + $this->assertTrue($workflow instanceof StateMachine); + $this->assertTrue($markingStore instanceof SingleStateMarkingStore); + $this->assertTrue($workflow->can($subject, 't1')); + + $workflow->apply($subject, 't1'); + $workflow->apply($subject, 't2'); + + $this->assertTrue($workflow->can($subject, 't1')); + } + + public function testWhenMultipleFromIsUsed() + { + $config = [ + 'straight' => [ + 'type' => 'state_machine', + 'supports' => ['Tests\Fixtures\TestObject'], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + [ + 'name' => 't1', + 'from' => 'a', + 'to' => 'b', + ], + [ + 'name' => 't2', + 'from' => [ + 'a', + 'b' + ], + 'to' => 'c', + ], + ], + ], + ]; + + $registry = new WorkflowRegistry($config); + $subject = new TestObject; + $workflow = $registry->get($subject); + + $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); + $markingStoreProp->setAccessible(true); + + $markingStore = $markingStoreProp->getValue($workflow); + + $this->assertTrue($workflow instanceof StateMachine); + $this->assertTrue($markingStore instanceof SingleStateMarkingStore); + $this->assertTrue($workflow->can($subject, 't1')); + $this->assertTrue($workflow->can($subject, 't2')); + } +} diff --git a/tests/WorkflowSubscriberTest.php b/tests/WorkflowSubscriberTest.php new file mode 100644 index 0000000..9ee94cb --- /dev/null +++ b/tests/WorkflowSubscriberTest.php @@ -0,0 +1,96 @@ + [ + 'supports' => [TestObject::class], + 'places' => ['a', 'b', 'c'], + 'transitions' => [ + 't1' => [ + 'from' => 'a', + 'to' => 'b', + ], + 't2' => [ + 'from' => 'b', + 'to' => 'c', + ], + ], + ], + ]; + + $registry = new WorkflowRegistry($config); + $object = new TestObject; + $workflow = $registry->get($object); + + $workflow->apply($object, 't1'); + + $this->assertCount(28, $events); + + $this->assertInstanceOf(GuardEvent::class, $events[0]); + $this->assertEquals('workflow.guard', $events[1]); + $this->assertEquals('workflow.straight.guard', $events[2]); + $this->assertEquals('workflow.straight.guard.t1', $events[3]); + + $this->assertInstanceOf(LeaveEvent::class, $events[4]); + $this->assertEquals('workflow.leave', $events[5]); + $this->assertEquals('workflow.straight.leave', $events[6]); + $this->assertEquals('workflow.straight.leave.a', $events[7]); + + $this->assertInstanceOf(TransitionEvent::class, $events[8]); + $this->assertEquals('workflow.transition', $events[9]); + $this->assertEquals('workflow.straight.transition', $events[10]); + $this->assertEquals('workflow.straight.transition.t1', $events[11]); + + $this->assertInstanceOf(EnterEvent::class, $events[12]); + $this->assertEquals('workflow.enter', $events[13]); + $this->assertEquals('workflow.straight.enter', $events[14]); + $this->assertEquals('workflow.straight.enter.b', $events[15]); + + $this->assertInstanceOf(EnteredEvent::class, $events[16]); + $this->assertEquals('workflow.entered', $events[17]); + $this->assertEquals('workflow.straight.entered', $events[18]); + $this->assertEquals('workflow.straight.entered.b', $events[19]); + + $this->assertInstanceOf(CompletedEvent::class, $events[20]); + $this->assertEquals('workflow.completed', $events[21]); + $this->assertEquals('workflow.straight.completed', $events[22]); + $this->assertEquals('workflow.straight.completed.t1', $events[23]); + + $this->assertInstanceOf(GuardEvent::class, $events[24]); + $this->assertEquals('workflow.guard', $events[25]); + $this->assertEquals('workflow.straight.guard', $events[26]); + $this->assertEquals('workflow.straight.guard.t2', $events[27]); + } + } +} + +namespace { + + $events = null; + + function event($ev) + { + global $events; + $events[] = $ev; + } +}