├── tests ├── codeception │ ├── unit │ │ ├── fixtures │ │ │ ├── .gitkeep │ │ │ ├── data │ │ │ │ ├── .gitkeep │ │ │ │ └── item04.php │ │ │ └── ItemFixture04.php │ │ ├── templates │ │ │ └── fixtures │ │ │ │ └── .gitkeep │ │ ├── _bootstrap.php │ │ ├── models │ │ │ ├── Component01.php │ │ │ ├── Item00.php │ │ │ ├── LoginFormTest.php │ │ │ ├── Item08Workflow2.php │ │ │ ├── Workflow1.php │ │ │ ├── ExternalStatusBehavior.php │ │ │ ├── Item01.php │ │ │ ├── Item04.php │ │ │ ├── Item06.php │ │ │ ├── Item07.php │ │ │ ├── Item07Workflow.php │ │ │ ├── Item05Workflow.php │ │ │ ├── Item06Workflow.php │ │ │ ├── Item04Workflow.php │ │ │ ├── Item08Workflow1.php │ │ │ ├── Item03.php │ │ │ ├── EventTrackerBehavior.php │ │ │ ├── MyStatus.php │ │ │ ├── workflow-00.graphml │ │ │ ├── Item08.php │ │ │ ├── Item05.php │ │ │ ├── StatusAccessor07.php │ │ │ ├── ExternalStatusAccessor.php │ │ │ ├── Item06Behavior.php │ │ │ ├── workflow-03.graphml │ │ │ └── workflow-02.graphml │ │ └── workflow │ │ │ ├── behavior │ │ │ ├── DiscoverWorkflowTest.php │ │ │ ├── AfterFindTest.php │ │ │ ├── StatusEqualsTest.php │ │ │ ├── ChangeStatusTest.php │ │ │ ├── EnterWorkflowTest.php │ │ │ ├── AttachBehaviorTest.php │ │ │ ├── MultiWorkflowTest.php │ │ │ ├── InitStatusTest.php │ │ │ └── StatusIdConvertionTest.php │ │ │ ├── source │ │ │ └── file │ │ │ │ ├── LoadWorkflowTest.php │ │ │ │ ├── GraphmlLoaderTest.php │ │ │ │ ├── ClassMapTest.php │ │ │ │ ├── TransitionTest.php │ │ │ │ ├── WorkflowFileSourceTest.php │ │ │ │ ├── MinimalArrayParserTest.php │ │ │ │ └── StatusTest.php │ │ │ ├── events │ │ │ ├── EnterWorkflowReducedEventTest.php │ │ │ ├── InvalidEventTest.php │ │ │ ├── ChangeStatusReducedEventTest.php │ │ │ ├── ChangeStatusExtendedEventTest.php │ │ │ └── StopOnFirstInvalidEventTest.php │ │ │ ├── helpers │ │ │ └── WorkflowHelperTest.php │ │ │ └── base │ │ │ ├── TransitionObjectTest.php │ │ │ ├── WorkflowObjectTest.php │ │ │ └── StatusIdConverterTest.php │ ├── unit.suite.yml │ ├── .gitignore │ ├── config │ │ ├── db.php │ │ ├── unit.php │ │ └── console.php │ ├── bin │ │ ├── yii.bat │ │ ├── _bootstrap.php │ │ └── yii │ ├── migrations │ │ └── m150203_201448_init.php │ └── _bootstrap.php ├── codeception.yml └── README.md ├── guide ├── docs │ ├── images │ │ ├── sw-3.png │ │ ├── workflow1.png │ │ ├── yed-view.png │ │ ├── post-workflow-2.png │ │ ├── post-workflow.png │ │ └── yii2-workflow-uml.jpg │ ├── class-ref.md │ ├── index.md │ ├── concept-overview.md │ ├── upgrade.md │ └── concept-source.md ├── mkdocs.yml └── README.md ├── .travis.yml ├── src ├── source │ ├── file │ │ ├── IWorkflowDefinitionProvider.php │ │ ├── PhpArrayLoader.php │ │ ├── WorkflowDefinitionLoader.php │ │ ├── PhpClassLoader.php │ │ ├── WorkflowArrayParser.php │ │ └── MinimalArrayParser.php │ └── IWorkflowSource.php ├── base │ ├── WorkflowException.php │ ├── WorkflowValidationException.php │ ├── TransitionInterface.php │ ├── IStatusIdConverter.php │ ├── IStatusAccessor.php │ ├── WorkflowInterface.php │ ├── StatusInterface.php │ ├── Workflow.php │ ├── Transition.php │ ├── Status.php │ ├── WorkflowBaseObject.php │ └── StatusIdConverter.php ├── events │ ├── IEventSequence.php │ ├── ReducedEventSequence.php │ └── BasicEventSequence.php ├── validation │ ├── WorkflowValidator.php │ └── WorkflowScenario.php └── actions │ └── ChangeStatusAction.php ├── .gitignore ├── composer.json └── LICENSE.md /tests/codeception/unit/fixtures/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/codeception/unit/fixtures/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/codeception/unit/templates/fixtures/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/codeception/unit/_bootstrap.php: -------------------------------------------------------------------------------- 1 | 'yii\db\Connection', 7 | 'dsn' => 'mysql:host=127.0.0.1;dbname=yii2_workflow_test', 8 | 'username' => 'root', 9 | 'password' => '', 10 | 'charset' => 'utf8', 11 | ]; 12 | -------------------------------------------------------------------------------- /tests/codeception/unit/fixtures/ItemFixture04.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'id' => 1, 5 | 'name' => 'name1', 6 | 'status' => 'Item04Workflow/B' 7 | ], 8 | 'item2' => [ 9 | 'id' => 2, 10 | 'name' => 'name2', 11 | 'status' => 'Item04Workflow/NOT_FOUND' 12 | ], 13 | 'item3' => [ 14 | 'id' => 3, 15 | 'name' => 'name3', 16 | 'status' => 'Item04Workflow/B' 17 | ], 18 | 'item4' => [ 19 | 'id' => 4, 20 | 'name' => 'name4', 21 | 'status' => 'Item04Workflow/D' 22 | ] 23 | ]; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | # composer vendor dir 16 | /vendor 17 | 18 | # composer itself is not needed 19 | composer.phar 20 | 21 | # Mac DS_Store Files 22 | .DS_Store 23 | 24 | # phpunit itself is not needed 25 | phpunit.phar 26 | 27 | # doc build 28 | /guide/site 29 | /guide/api 30 | apigen.phar 31 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/LoginFormTest.php: -------------------------------------------------------------------------------- 1 | specify('dummy test always succeeds', function () { 22 | expect('true is true', true)->true(); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/codeception/bin/yii.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | rem ------------------------------------------------------------- 4 | rem Yii command line bootstrap script for Windows. 5 | rem 6 | rem @author Qiang Xue 7 | rem @link http://www.yiiframework.com/ 8 | rem @copyright Copyright (c) 2008 Yii Software LLC 9 | rem @license http://www.yiiframework.com/license/ 10 | rem ------------------------------------------------------------- 11 | 12 | @setlocal 13 | 14 | set YII_PATH=%~dp0 15 | 16 | if "%PHP_COMMAND%" == "" set PHP_COMMAND=php.exe 17 | 18 | "%PHP_COMMAND%" "%YII_PATH%yii" %* 19 | 20 | @endlocal 21 | -------------------------------------------------------------------------------- /src/base/WorkflowValidationException.php: -------------------------------------------------------------------------------- 1 | createTable('item', [ 11 | 'id' => Schema::TYPE_PK, 12 | 'name' => Schema::TYPE_STRING . ' DEFAULT NULL', 13 | 'status' => Schema::TYPE_STRING . ' DEFAULT NULL', 14 | 'status_ex' => Schema::TYPE_STRING . ' DEFAULT NULL' 15 | ], 16 | 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB' 17 | ); 18 | } 19 | 20 | public function down() 21 | { 22 | $this->dropTable('item'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item08Workflow2.php: -------------------------------------------------------------------------------- 1 | 'success', 12 | 'status' => [ 13 | 'success' => [ 14 | 'transition' => ['onHold'] 15 | ], 16 | 'onHold' => [ 17 | 'transition' => ['success'] 18 | ], 19 | ] 20 | ]; 21 | } 22 | } -------------------------------------------------------------------------------- /tests/codeception/unit/models/Workflow1.php: -------------------------------------------------------------------------------- 1 | 'A', 14 | 'status' => [ 15 | 'A' => [ 16 | 'label' => 'Entry', 17 | 'transition' => ['B','A'] 18 | ], 19 | 'B' => [ 20 | 'label' => 'Published', 21 | 'transition' => ['A','C'] 22 | ], 23 | 'C' => [ 24 | 'label' => 'node C', 25 | 'transition' => ['A','D'] 26 | ], 27 | 'D' 28 | ] 29 | ]; 30 | } 31 | } -------------------------------------------------------------------------------- /tests/codeception/unit/models/ExternalStatusBehavior.php: -------------------------------------------------------------------------------- 1 | 'afterFindHandler', 16 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsertHandler', 17 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdateHandler', 18 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdateHandler', 19 | ActiveRecord::EVENT_AFTER_INSERT => 'afterInsertHandler', 20 | ]; 21 | } 22 | } -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item01.php: -------------------------------------------------------------------------------- 1 | [ 29 | 'class' => SimpleWorkflowBehavior::className() 30 | ] 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item04.php: -------------------------------------------------------------------------------- 1 | [ 28 | 'class' => SimpleWorkflowBehavior::className(), 29 | ] 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /guide/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: yii2-workflow User Guide 2 | repo_url: https://github.com/raoul2000/yii2-workflow 3 | site_description: A simple Yii2 extension to manage your workflows 4 | site_author: raoul2000 5 | pages: 6 | - 'Introduction': 'index.md' 7 | - 'Overview': 'overview.md' 8 | - Concept: 9 | - 'Basics': 'concept-overview.md' 10 | - 'Events': 'concept-events.md' 11 | - 'Source': 'concept-source.md' 12 | - 'Validation': 'concept-validation.md' 13 | - Specials: 14 | - 'Defining a workflow' : workflow-creation.md 15 | - 'The source file compoent' : source-file.md 16 | - 'Upgrade from 1.x' : upgrade.md 17 | - Cookbook: 'special-cookbook.md' 18 | - 'Class Reference': 'class-ref.md' 19 | theme: readthedocs 20 | -------------------------------------------------------------------------------- /tests/codeception/config/unit.php: -------------------------------------------------------------------------------- 1 | 'basic', 9 | 'basePath' => realpath(__DIR__ . '/../../../'), 10 | 'bootstrap' => ['log'], 11 | 'components' => [ 12 | 13 | 'cache' => [ 14 | 'class' => 'yii\caching\FileCache', 15 | ], 16 | 'log' => [ 17 | 'traceLevel' => YII_DEBUG ? 3 : 0, 18 | 'targets' => [ 19 | [ 20 | 'class' => 'yii\log\FileTarget', 21 | 'levels' => ['error', 'warning'], 22 | 'logFile' => __DIR__ . '/../_output/yii.log' 23 | ], 24 | ], 25 | ], 26 | 'db' => $db 27 | ] 28 | ]; 29 | -------------------------------------------------------------------------------- /tests/codeception/bin/yii: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | [ 17 | 'migrate' => [ 18 | 'class' => 'yii\console\controllers\MigrateController', 19 | 'migrationPath' => __DIR__ . '/../migrations/' 20 | ], 21 | ], 22 | ] 23 | ); 24 | 25 | $application = new yii\console\Application($config); 26 | $exitCode = $application->run(); 27 | exit($exitCode); 28 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item06.php: -------------------------------------------------------------------------------- 1 | [ 27 | 'class' => SimpleWorkflowBehavior::className() 28 | ], 29 | 'activeWorkflow' => [ 30 | 'class' => Item06Behavior::className() 31 | ] 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/codeception/config/console.php: -------------------------------------------------------------------------------- 1 | 'basic-console', 8 | 'basePath' => YII_APP_BASE_PATH, 9 | 'controllerNamespace' => 'app\commands', 10 | 'extensions' => require(YII_APP_BASE_PATH . '/vendor/yiisoft/extensions.php'), 11 | 'components' => [ 12 | 'cache' => [ 13 | 'class' => 'yii\caching\FileCache', 14 | ], 15 | 'log' => [ 16 | 'targets' => [ 17 | [ 18 | 'class' => 'yii\log\FileTarget', 19 | 'levels' => ['error', 'warning'], 20 | 'logFile' => '@tests/codeception/_output/.tests.log' 21 | ], 22 | ], 23 | ], 24 | 'db' => $db, 25 | ] 26 | ]; 27 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item07.php: -------------------------------------------------------------------------------- 1 | [ 29 | 'class' => SimpleWorkflowBehavior::className(), 30 | 'statusAttribute' => 'statusAlias', 31 | 'statusAccessor' => 'status_accessor' 32 | ] 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item07Workflow.php: -------------------------------------------------------------------------------- 1 | 'A', 13 | 'status' => [ 14 | 'A' => [ 15 | 'label' => 'Entry', 16 | 'transition' => [ 17 | 'B' => [], 18 | 'A' => [] 19 | ] 20 | ], 21 | 'B' => [ 22 | 'label' => 'Published', 23 | 'transition' => [ 24 | 'A' => [], 25 | 'C' => [] 26 | ] 27 | ], 28 | 'C' => [ 29 | 'label' => 'node C', 30 | 'transition' => [ 31 | 'A' => [], 32 | 'D' => [] 33 | ] 34 | ], 35 | 'D' => [ 36 | 'label' => 'node D', 37 | 'transition' => [] 38 | ] 39 | ] 40 | ]; 41 | } 42 | } -------------------------------------------------------------------------------- /src/base/TransitionInterface.php: -------------------------------------------------------------------------------- 1 | 'new', 13 | 'status' => [ 14 | 'new' => [ 15 | 'label' => 'New Item', 16 | 'transition' => [ 17 | 'correction' => [], 18 | 'published' => [] 19 | ] 20 | ], 21 | 'correction' => [ 22 | 'label' => 'In Correction', 23 | 'transition' => [ 24 | 'published' => [] 25 | ] 26 | ], 27 | 'published' => [ 28 | 'label' => 'Published', 29 | 'transition' => [ 30 | 'correction' => [], 31 | 'archive' => [] 32 | ] 33 | ], 34 | 'archive' => [ 35 | 'label' => 'Archived', 36 | 'transition' => [] 37 | ] 38 | ] 39 | ]; 40 | } 41 | } -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item06Workflow.php: -------------------------------------------------------------------------------- 1 | 'new', 13 | 'status' => [ 14 | 'new' => [ 15 | 'label' => 'New Item', 16 | 'transition' => [ 17 | 'correction' => [], 18 | 'published' => [] 19 | ] 20 | ], 21 | 'correction' => [ 22 | 'label' => 'In Correction', 23 | 'transition' => [ 24 | 'published' => [] 25 | ] 26 | ], 27 | 'published' => [ 28 | 'label' => 'Published', 29 | 'transition' => [ 30 | 'correction' => [], 31 | 'archive' => [] 32 | ] 33 | ], 34 | 'archive' => [ 35 | 'label' => 'Archived', 36 | 'transition' => [] 37 | ] 38 | ] 39 | ]; 40 | } 41 | } -------------------------------------------------------------------------------- /src/base/IStatusIdConverter.php: -------------------------------------------------------------------------------- 1 | 'A', 13 | 'status' => [ 14 | 'A' => [ 15 | 'label' => 'Entry', 16 | 'transition' => [ 17 | 'B' => [], 18 | 'A' => [] 19 | ], 20 | 'metadata' => [ 21 | 'color' => '#FF545669', 22 | 'priority' => 1 23 | ] 24 | ], 25 | 'B' => [ 26 | 'label' => 'Published', 27 | 'transition' => [ 28 | 'A' => [], 29 | 'C' => [] 30 | ] 31 | ], 32 | 'C' => [ 33 | 'label' => 'node C', 34 | 'transition' => [ 35 | 'A' => [], 36 | 'D' => [] 37 | ] 38 | ], 39 | 'D' => [ 40 | 'label' => 'node D', 41 | 'transition' => [] 42 | ] 43 | ] 44 | ]; 45 | } 46 | } -------------------------------------------------------------------------------- /src/base/IStatusAccessor.php: -------------------------------------------------------------------------------- 1 | 'draft', 12 | 'status' => [ 13 | 'draft' => [ 14 | 'transition' => ['correction'] 15 | ], 16 | 'correction' => [ 17 | 'transition' => ['draft','ready'] 18 | ], 19 | 'ready' => [ 20 | 'transition' => ['draft', 'correction', 'published'] 21 | ], 22 | 'published' => [ 23 | 'transition' => ['ready', 'archived'] 24 | ], 25 | 'archived' => [ 26 | 'transition' => ['ready'] 27 | ] 28 | ] 29 | ]; 30 | } 31 | } -------------------------------------------------------------------------------- /src/base/WorkflowInterface.php: -------------------------------------------------------------------------------- 1 | [ 28 | 'class' => SimpleWorkflowBehavior::className() 29 | ] 30 | ]; 31 | } 32 | public function getDefinition() 33 | { 34 | return [ 35 | 'initialStatusId' => 'A', 36 | 'status' => [ 37 | 'A' => [ 38 | 'label' => 'Entry', 39 | 'transition' => ['B','A'] 40 | ], 41 | 'B' => [ 42 | 'label' => 'Published', 43 | 'transition' => ['A','C'] 44 | ], 45 | 'C' => [ 46 | 'label' => 'node C', 47 | 'transition' => ['A','D'] 48 | ] 49 | ] 50 | ]; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/DiscoverWorkflowTest.php: -------------------------------------------------------------------------------- 1 | specify('a workflow Id is created if not provided', function () { 18 | $model = new Item01(); 19 | expect('model should have workflow id set to "Item01"', $model->getDefaultWorkflowId() == 'Item01Workflow' )->true(); 20 | }); 21 | } 22 | public function testConfiguredWorkflowId() 23 | { 24 | $this->specify('use the configured workflow Id', function () { 25 | $model = new Item01(); 26 | $model->attachBehavior('workflow', [ 27 | 'class' => SimpleWorkflowBehavior::className(), 28 | 'defaultWorkflowId' => 'myWorkflow' 29 | ]); 30 | expect('model should have workflow id set to "myWorkflow"', $model->getDefaultWorkflowId() == 'myWorkflow' )->true(); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "raoul2000/yii2-workflow", 3 | "description" : "A simple workflow engine for Yii2", 4 | "keywords" : [ 5 | "yii2", 6 | "workflow" 7 | ], 8 | "homepage" : "http://raoul2000.github.io/yii2-workflow/guide-README.html", 9 | "type" : "yii2-extension", 10 | "license" : "BSD-3-Clause", 11 | "support" : { 12 | "issues" : "https://github.com/raoul2000/yii2-workflow/issues", 13 | "source" : "https://github.com/raoul2000/yii2-workflow", 14 | "email" : "raoul.boulard@gmail.com" 15 | }, 16 | "minimum-stability" : "stable", 17 | "require" : { 18 | "php" : ">=5.4.0", 19 | "yiisoft/yii2" : "~2.0.13" 20 | }, 21 | "require-dev" : { 22 | "codeception/codeception" : ">= 2.0.9", 23 | "myclabs/deep-copy" : ">= 1.3.1", 24 | "codeception/specify" : "*", 25 | "codeception/verify" : "*", 26 | "yiisoft/yii2-codeception" : "*", 27 | "codeception/assert-throws": "^1.0" 28 | }, 29 | "config" : { 30 | "process-timeout" : 1800 31 | }, 32 | "autoload" : { 33 | "psr-4" : { 34 | "raoul2000\\workflow\\" : "src/" 35 | } 36 | }, 37 | "authors" : [{ 38 | "name" : "Raoul", 39 | "email" : "raoul.boulard@gmail.com", 40 | "homepage" : "https://github.com/raoul2000/yii2-workflow" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/EventTrackerBehavior.php: -------------------------------------------------------------------------------- 1 | 'afterFindHandler', 21 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsertHandler', 22 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeUpdateHandler', 23 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdateHandler', 24 | ActiveRecord::EVENT_AFTER_INSERT => 'afterInsertHandler', 25 | ]; 26 | } 27 | public function afterFindHandler() 28 | { 29 | $this->afterFind ++; 30 | } 31 | public function beforeInsertHandler() 32 | { 33 | $this->beforeInsert ++; 34 | } 35 | public function beforeUpdateHandler() 36 | { 37 | $this->beforeUpdate ++; 38 | } 39 | public function afterUpdateHandler() 40 | { 41 | $this->afterUpdate ++; 42 | } 43 | public function afterInsertHandler() 44 | { 45 | $this->afterInsert ++; 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /tests/codeception/unit/models/MyStatus.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Raoul 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item08.php: -------------------------------------------------------------------------------- 1 | [ 34 | 'class' => \raoul2000\workflow\base\SimpleWorkflowBehavior::className(), 35 | 'defaultWorkflowId' => 'Item08Workflow1' 36 | ], 37 | 'w2' => [ 38 | 'class' => \raoul2000\workflow\base\SimpleWorkflowBehavior::className(), 39 | 'statusAttribute' => 'status_ex', 40 | 'defaultWorkflowId' => 'Item08Workflow2' 41 | ] 42 | ]; 43 | } 44 | /** 45 | * @inheritdoc 46 | */ 47 | public function attributeLabels() 48 | { 49 | return [ 50 | 'id' => 'ID', 51 | 'name' => 'Name', 52 | 'status' => 'Status', 53 | 'status_ex' => 'Status Ex.', 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/base/StatusInterface.php: -------------------------------------------------------------------------------- 1 | WorkflowScenario::changeStatus('Item05Workflow/new', 'Item05Workflow/correction') ], 32 | 33 | ['category', 'required', 34 | 'on' => WorkflowScenario::enterWorkflow('Item05Workflow')], 35 | 36 | ['category', 'compare', 'compareValue' => 'done', 37 | 'on' => WorkflowScenario::leaveWorkflow()], 38 | 39 | ['tags', 'required', 40 | 'on' => WorkflowScenario::leaveStatus('Item05Workflow/correction')], 41 | 42 | ['author', 'required' , 43 | 'on' => WorkflowScenario::enterStatus('Item05Workflow/published')] 44 | ]; 45 | 46 | } 47 | 48 | public function behaviors() 49 | { 50 | return [ 51 | 'workflow' => [ 52 | 'class' => SimpleWorkflowBehavior::className() 53 | ] 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /guide/README.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Prerequisite 4 | 5 | The User Guide is designed to be built by **[mkDocs](http://www.mkdocs.org/)** based on a [Bootswatch theme](https://github.com/mkdocs/mkdocs-bootswatch) 6 | 7 | ``` 8 | pip install mkdocs 9 | pip install mkdocs-bootswatch 10 | ``` 11 | 12 | More info about **mkDocs** installation [here](http://www.mkdocs.org/#installation) 13 | 14 | ## Building the Guide 15 | 16 | During dev, the guide can be served from a local server in charge of refreshing the page on each 17 | change. To start the local server, enter : 18 | 19 | ``` 20 | cd guide 21 | mkdocs serve 22 | ``` 23 | 24 | When the guide is ready to be published, build it into the folder `guide/site` with : 25 | 26 | ``` 27 | mkdocs build --clean 28 | ``` 29 | 30 | # Class Reference 31 | 32 | ## Prerequisite 33 | 34 | The class reference documentation is built using [apiGen](http://www.apigen.org/) using the *bootstrap* built-in theme. 35 | 36 | To install *apiGen*, [download the apigen.phar](http://apigen.org/apigen.phar) file into the `guide` folder. 37 | 38 | ## Building The Class Reference Doc 39 | 40 | From the `guide` folder : 41 | 42 | ``` 43 | php apigen.phar generate -s ..\src -d site\class-ref\api --template-theme bootstrap --no-source-code --title "yii2-workflow Class Reference" 44 | ``` 45 | 46 | The documentation is built into the folder `guide/site/class-ref/api`. 47 | 48 | 49 | # Github Pages 50 | 51 | To push it to the **gh-pages** branch : 52 | 53 | ``` 54 | cd guide 55 | mkdocs gh-deploy 56 | ``` 57 | 58 | [read more](http://www.mkdocs.org/user-guide/deploying-your-docs/) 59 | -------------------------------------------------------------------------------- /src/source/file/PhpArrayLoader.php: -------------------------------------------------------------------------------- 1 | createFilename($workflowId)); 35 | return $this->parse($workflowId, $wd, $source); 36 | } 37 | /** 38 | * Creates and returns the absolute filename of the PHP file that contains 39 | * the workflow definition to load. 40 | * 41 | * @param string $workflowId 42 | * @return string the absolute file path of the workflow definition file 43 | */ 44 | public function createFilename($workflowId) 45 | { 46 | return Yii::getAlias($this->path) . '/' . $workflowId . '.php'; 47 | } 48 | } -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Running Unit tests 2 | 3 | - Navigate to the **yii2-workflow** installation folder 4 | - Install composer dependencies 5 | 6 | ``` 7 | composer self-update 8 | composer global require "fxp/composer-asset-plugin:1.0.0-beta4" 9 | composer install --prefer-dist --dev 10 | ``` 11 | - Create Database `yii2_workflow_test` 12 | - Apply DB migrations 13 | 14 | ``` 15 | cd tests 16 | php ./codeception/bin/yii migrate/up --interactive=0 17 | ``` 18 | 19 | - Build and start Codeception tests 20 | 21 | ``` 22 | ../vendor/bin/codecept build 23 | ../vendor/bin/codecept run unit 24 | ``` 25 | 26 | To produce the code coverage report, run : 27 | 28 | ``` 29 | ../vendor/bin/codecept run unit --coverage-html 30 | ``` 31 | 32 | To run a single test : 33 | ``` 34 | ../vendor/bin/codecept run codeception/unit/workflow/helpers/WorkflowHelperTest.php:testGetNextStatus 35 | ../vendor/bin/codecept run codeception/unit/workflow/helpers/WorkflowHelperTest.php 36 | ``` 37 | 38 | 39 | ## Memento 40 | 41 | ### Output to Console during Tests 42 | 43 | To output to console from a codeception test use : 44 | ``` 45 | \Codeception\Util\Debug::debug($someVariable); 46 | ``` 47 | 48 | With debug mode enabled : 49 | ``` 50 | ../vendor/bin/codecept run --debug codeception/unit/workflow/helpers/WorkflowHelperTest.php 51 | ``` 52 | 53 | ### Enable XDebug With Codeception 54 | 55 | Check XDebug is installed : 56 | - run `php -i > info.txt` 57 | - copy/paste `info.txt` into [this form](https://xdebug.org/wizard.php) and check the result 58 | - if needed, follow *Installation instructions*. 59 | 60 | ### More ... 61 | 62 | Change PATH to use PHP v7.2.5 : 63 | ``` 64 | PATH=$(echo $PATH | sed 's/php7.0.10/php7.2.5/g') 65 | ``` 66 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/StatusAccessor07.php: -------------------------------------------------------------------------------- 1 | callGetStatusCount = 0; 32 | $this->callCommitStatusCount = 0; 33 | $this->callSetStatusCount = 0; 34 | $this->callSetStatusLastArg = []; 35 | } 36 | /** 37 | * (non-PHPdoc) 38 | * @see \raoul2000\workflow\IStatusAccessor::getStatus() 39 | */ 40 | public function readStatus(BaseActiveRecord $model) { 41 | $this->callGetStatusCount++; 42 | return $this->statusToReturnOnGet; 43 | } 44 | 45 | /** 46 | * (non-PHPdoc) 47 | * @see \raoul2000\workflow\IStatusAccessor::commitStatus() 48 | */ 49 | public function commitStatus($model) 50 | { 51 | $this->callCommitStatusCount++; 52 | 53 | } 54 | /** 55 | * (non-PHPdoc) 56 | * @see \raoul2000\workflow\IStatusAccessor::setStatus() 57 | */ 58 | public function updateStatus(BaseActiveRecord $model, Status $status = null) { 59 | $this->callSetStatusCount++; 60 | $this->callSetStatusLastArg = [$model, $status]; 61 | } 62 | } -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/AfterFindTest.php: -------------------------------------------------------------------------------- 1 | ItemFixture04::className(), 22 | ]; 23 | } 24 | protected function setup() 25 | { 26 | parent::setUp(); 27 | Yii::$app->set('workflowSource',[ 28 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 29 | 'definitionLoader' => [ 30 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 31 | 'namespace' => 'tests\codeception\unit\models' 32 | ] 33 | ]); 34 | } 35 | 36 | protected function tearDown() 37 | { 38 | parent::tearDown(); 39 | } 40 | 41 | public function testInitStatusOnAfterFind() 42 | { 43 | $this->specify('item1 can be read from db', function() { 44 | $item = $this->items('item1'); 45 | verify('current status is set', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/B'); 46 | }); 47 | 48 | $this->specify('item2 cannot be read from db (invalid status)', function() { 49 | 50 | $this->assertThrowsWithMessage( 51 | 'raoul2000\workflow\base\WorkflowException' , 52 | "Not a valid status id : incorrect status local id format in 'Item04Workflow/NOT_FOUND'", 53 | function() { 54 | $this->items('item2'); 55 | } 56 | ); 57 | }); 58 | 59 | $this->specify('item3 can be read from db : short name', function() { 60 | $this->items('item3'); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /guide/docs/index.md: -------------------------------------------------------------------------------- 1 | # What is yii2-workflow ? 2 | 3 | **yii2-workflow** is an extension of the [Yii2 Framework](http://www.yiiframework.com/), designed to help you manage workflow in your app. It is the successor of *[simpleWorkflow](http://s172418307.onlinehome.fr/project/sandbox/www/index.php?r=simpleWorkflow/page&view=home)* which was developed some years ago for the 1.x version of Yii. Both extensions try to keep thing simple and easy to use. They rely as much as possible on standard Yii2 features like [Events](http://www.yiiframework.com/doc-2.0/guide-concept-events.html), [components](http://www.yiiframework.com/doc-2.0/guide-concept-components.html), [behaviors](http://www.yiiframework.com/doc-2.0/guide-concept-behaviors.html), etc. 4 | 5 | Before going any further in your reading it is important to understand what yii2-workflow is *not* : 6 | 7 | - it is not a complete and complex workflow engine 8 | - it does not provide any UI components (basically it's a *behavior* that you add to your *ActiveRecord* models) 9 | - it is not a solution to all your problems (I whish it would though) 10 | 11 | # Requirements 12 | 13 | Well, the only requirement here is to have installed the latest version of the [Yii2 Framework](http://www.yiiframework.com/) (or at least a version greater or equal to 2.0.3). 14 | 15 | # How to install 16 | 17 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 18 | 19 | Either run 20 | 21 | ``` 22 | php composer.phar require --prefer-dist raoul2000/yii2-workflow "*" 23 | ``` 24 | 25 | or add 26 | 27 | ``` 28 | "raoul2000/yii2-workflow": "*" 29 | ``` 30 | 31 | to the require section of your `composer.json` file. 32 | 33 | # What's next ? 34 | 35 | If you have been using the previous implementation of this extension it could be a good thing to start by reading the [Upgrade From 1.x](upgrade.md) chapter. 36 | 37 | If you want to know more about workflow in general in the features provided by **yii2-workflow** in particular, check the [overview](overview.md) chapter. 38 | 39 | If you feel like an advanced workflow Expert (more or less), the dive into the [Concept](concept-overview.md) chapter. 40 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/ExternalStatusAccessor.php: -------------------------------------------------------------------------------- 1 | isNewRecord == false) { 21 | echo 'loading status for item '.$model->id; 22 | $post = $this->loadStatusRow($model->id); 23 | $result = $post['value']; 24 | echo ' status = '.$result.'
'; 25 | return $result; 26 | } else { 27 | return null; 28 | } 29 | } 30 | 31 | public function commitStatus($model) 32 | { 33 | 34 | echo 'saving model id = '.$model->id,' status = '.$this->_status.'
'; 35 | Yii::$app->db->createCommand()->insert('status', [ 36 | 'item_id' => $model->id, 37 | 'value' => $this->_status, 38 | 'created_at' => time() 39 | ])->execute(); 40 | 41 | } 42 | private function loadStatusRow($id) 43 | { 44 | $command = Yii::$app->db->createCommand('SELECT value FROM status WHERE item_id=:ITEM_ID and ' 45 | .' id in ( SELECT MAX(id) FROM status )'); 46 | $command->bindValue(':ITEM_ID', $id); 47 | return $command->queryOne(); 48 | } 49 | /* (non-PHPdoc) 50 | * @see \raoul2000\workflow\IStatusAccessor::setStatus() 51 | */ 52 | public function setStatus(BaseActiveRecord $model, Status $status = null) { 53 | echo 'setStatus model
'; 54 | $this->_status = $status != null ? $status->getId() : null; 55 | } 56 | /* (non-PHPdoc) 57 | * @see \raoul2000\workflow\base\IStatusAccessor::readStatus() 58 | */ 59 | public function readStatus(BaseActiveRecord $model) { 60 | // TODO: Auto-generated method stub 61 | 62 | } 63 | 64 | /* (non-PHPdoc) 65 | * @see \raoul2000\workflow\base\IStatusAccessor::updateStatus() 66 | */ 67 | public function updateStatus(BaseActiveRecord $model, Status $status = null) { 68 | // TODO: Auto-generated method stub 69 | 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/LoadWorkflowTest.php: -------------------------------------------------------------------------------- 1 | src = new WorkflowFileSource(); 26 | } 27 | 28 | 29 | public function testLoadWorkflowSuccess1() 30 | { 31 | $src = new WorkflowFileSource(); 32 | $src->addWorkflowDefinition('wid', [ 33 | 'initialStatusId' => 'A', 34 | 'status' => [ 35 | 'A' => [ 36 | 'label' => 'Entry', 37 | 'transition' => ['B','A'] 38 | ], 39 | 'B' => [ 40 | 'label' => 'Published', 41 | 'transition' => ['A','C'] 42 | ], 43 | 'C' => [ 44 | 'label' => 'node C', 45 | 'transition' => ['A','D'] 46 | ],'D' 47 | ] 48 | ]); 49 | 50 | verify($src->getStatus('wid/A'))->notNull(); 51 | verify($src->getStatus('wid/B'))->notNull(); 52 | verify($src->getStatus('wid/C'))->notNull(); 53 | verify($src->getStatus('wid/D'))->notNull(); 54 | 55 | verify(count($src->getTransitions('wid/A')))->equals(2); 56 | } 57 | 58 | public function testLoadWorkflowSuccess2() 59 | { 60 | $src = new WorkflowFileSource(); 61 | $src->addWorkflowDefinition('wid', [ 62 | 'initialStatusId' => 'A', 63 | 'status' => [ 64 | 'A' => [ 65 | 'label' => 'Entry', 66 | 'transition' => 'A,B' 67 | ], 68 | 'B' => [ 69 | 'label' => 'Published', 70 | 'transition' => ' A , B ' 71 | ], 72 | ] 73 | ]); 74 | 75 | verify($src->getStatus('wid/A'))->notNull(); 76 | verify($src->getStatus('wid/B'))->notNull(); 77 | 78 | verify(count($src->getTransitions('wid/A')))->equals(2); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/StatusEqualsTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 21 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 22 | 'definitionLoader' => [ 23 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 24 | 'namespace' => 'tests\codeception\unit\models' 25 | ] 26 | ]); 27 | } 28 | 29 | public function testStatusEqualsSuccess() 30 | { 31 | $item = new Item04(); 32 | 33 | expect_that($item->statusEquals()); 34 | expect_that($item->statusEquals(null)); 35 | expect_that($item->statusEquals('')); 36 | expect_that($item->statusEquals([])); 37 | expect_that($item->statusEquals(0)); 38 | 39 | $item->sendToStatus('A'); 40 | expect_that($item->statusEquals('A')); 41 | expect_that($item->statusEquals('Item04Workflow/A')); 42 | 43 | $itself= $item->getWorkflowStatus(); 44 | 45 | expect_that($item->statusEquals($itself)); 46 | } 47 | 48 | 49 | public function testStatusEqualsFails() 50 | { 51 | $item = new Item04(); 52 | $item->sendToStatus('A'); 53 | 54 | expect_not($item->statusEquals('B')); 55 | expect_not($item->statusEquals('Item04Workflow/B')); 56 | expect_not($item->statusEquals('NOTFOUND')); 57 | expect_not($item->statusEquals('Item04Workflow/NOTFOUND')); 58 | expect_not($item->statusEquals('NOTFOUND/NOTFOUND')); 59 | expect_not($item->statusEquals('invalid name')); 60 | expect_not($item->statusEquals('')); 61 | expect_not($item->statusEquals(null)); 62 | 63 | $statusA = $item->getWorkflowStatus(); 64 | $item->sendToStatus('B'); 65 | 66 | verify($item->statusEquals('B')); 67 | 68 | expect_not($item->statusEquals($statusA)); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/events/IEventSequence.php: -------------------------------------------------------------------------------- 1 | event sequence is an array of workflow events that occurs on three occasions : 8 | * 13 | * 14 | * For each one of these methods, the implementation must returns with 2 keys : **before** and **after**. For each 15 | * key, the value is an array of `\raoul2000\workflow\events\WorkflowEvent` representing the sequence of event 16 | * to fire *before* or *after* the workflow event occurs. 17 | * 18 | * Two event sequences implementations are provided : 19 | * 20 | * - {@link \raoul2000\workflow\events\BasicEventSequence} 21 | * - {@link \raoul2000\workflow\events\ExtendedEventSequence} 22 | */ 23 | interface IEventSequence 24 | { 25 | /** 26 | * Creates and returns the sequence of events that occurs when a model enters into a workflow. 27 | * 28 | * @param \raoul2000\workflow\base\StatusInterface $initalStatus the status used to enter into the workflow (the initial status) 29 | * @param Object $sender 30 | * @return array 31 | */ 32 | public function createEnterWorkflowSequence($initalStatus, $sender); 33 | /** 34 | * Creates and returns the sequence of events that occurs when a model leaves a workflow. 35 | * 36 | * @param \raoul2000\workflow\base\StatusInterface $finalStatus the status that the model last visited in the workflow it is leaving 37 | * (the final status) 38 | * @param Object $sender 39 | * @return array 40 | */ 41 | public function createLeaveWorkflowSequence($finalStatus, $sender); 42 | /** 43 | * Creates and returns the sequence of events that occurs when a model changes 44 | * from an existing status to another existing status. 45 | * 46 | * @param \raoul2000\workflow\base\TransitionInterface $transition the transition representing the status 47 | * change 48 | * @param Object $sender 49 | * @return array 50 | */ 51 | public function createChangeStatusSequence($transition, $sender); 52 | } 53 | -------------------------------------------------------------------------------- /src/base/Workflow.php: -------------------------------------------------------------------------------- 1 | _id = $config['id']; 29 | unset($config['id']); 30 | } else { 31 | throw new InvalidConfigException('missing workflow id '); 32 | } 33 | 34 | if ( ! empty($config[self::PARAM_INITIAL_STATUS_ID])) { 35 | $this->_initialStatusId = $config[self::PARAM_INITIAL_STATUS_ID]; 36 | unset($config[self::PARAM_INITIAL_STATUS_ID]); 37 | } else { 38 | throw new InvalidConfigException('missing initial status id'); 39 | } 40 | parent::__construct($config); 41 | } 42 | /** 43 | * @see \raoul2000\workflow\base\WorkflowBaseObject::getId() 44 | */ 45 | public function getId() 46 | { 47 | return $this->_id; 48 | } 49 | /** 50 | * @see \raoul2000\workflow\base\WorkflowInterface::getInitialStatusId() 51 | */ 52 | public function getInitialStatusId() 53 | { 54 | return $this->_initialStatusId; 55 | } 56 | 57 | /** 58 | * @see \raoul2000\workflow\base\WorkflowInterface::getInitialStatus() 59 | */ 60 | public function getInitialStatus() { 61 | if ( $this->getSource() === null) { 62 | throw new WorkflowException('no workflow source component available'); 63 | } 64 | return $this->getSource()->getStatus($this->getInitialStatusId()); 65 | } 66 | 67 | /** 68 | * @see \raoul2000\workflow\base\WorkflowInterface::getAllStatuses() 69 | */ 70 | public function getAllStatuses() { 71 | if ( $this->getSource() === null) { 72 | throw new WorkflowException('no workflow source component available'); 73 | } 74 | return $this->getSource()->getAllStatuses($this->getId()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/source/file/WorkflowDefinitionLoader.php: -------------------------------------------------------------------------------- 1 | parser === null ) { 39 | $this->_p = Yii::createObject([ 40 | 'class' => DefaultArrayParser::className() 41 | ]); 42 | } elseif( $this->parser === false) { 43 | $this->_p = null; 44 | } elseif( is_array($this->parser)) { 45 | $this->_p = Yii::createObject($this->parser); 46 | } elseif( is_string($this->parser)) { 47 | $this->_p = Yii::$app->get($this->parser); 48 | } elseif( is_object($this->parser)) { 49 | $this->_p = $this->parser; 50 | } else { 51 | throw new InvalidConfigException('invalid "parser" attribute : string or array expected'); 52 | } 53 | 54 | if( $this->_p !== null && ! $this->_p instanceof WorkflowArrayParser ) { 55 | throw new InvalidConfigException('the parser component must implement the WorkflowArrayParser interface'); 56 | } 57 | } 58 | /** 59 | * Returns the instance of the array parser used. 60 | * 61 | * @returnWorkflowArrayParser the parser component used by this instance or NULL if no parser has been configured 62 | */ 63 | public function getParser() 64 | { 65 | return $this->_p; 66 | } 67 | /** 68 | * 69 | * @param string $workflowId 70 | * @param array $wd 71 | * @param WorkflowFileSource $source 72 | * @return array The workflow definition 73 | */ 74 | public function parse($workflowId, $wd, $source) 75 | { 76 | if( $this->_p !== null ) { 77 | return $this->_p->parse($workflowId, $wd, $source); 78 | } else { 79 | return $wd; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /guide/docs/concept-overview.md: -------------------------------------------------------------------------------- 1 | ## Generalities 2 | 3 | *yii2-workflow* is a set on objects dedicated to help managing the life-cycle of an ActiveRecord model within a *workflow*. 4 | 5 | It includes : 6 | 7 | - a behavior (*SimpleWorkflowBehavior*) 8 | - a Workflow Source Component (*WorkflowFileSource*) 9 | - a Validator (*WorkflowValidator*) 10 | - three event sequence models 11 | - various helpers 12 | - a set of interfaces 13 | 14 | *yii2-workflow* can be configure to fit your requirements and if that's not enough, you can extend all classes so to implement your own features. 15 | 16 | ## Identifiers 17 | 18 | The *yii2-workflow* refers to workflows and statuses using identifiers. The way these identifiers are formatted, depends on the *WorkflowSource* components used. For instance if you're working with the default source component (the *workflowFileSource* ), status identifiers will look like this : `workflowId/StatusId` ([read more](workflow-creation/#identifiers)) 19 | 20 | ## Initial Status 21 | 22 | The initial status is the first status assigned to a model, that's the *one and only entry point* into a workflow. Each workflow must have exactly one initial status. 23 | 24 | For example, in a workflow dedicated to manage posts, the initial status could be called 'draft' : it usually describes the first state of the post. 25 | 26 | ## Transition 27 | 28 | A transition is a *directed* link between two statuses : the *start* status and the *end* status (the words 'source' and 'target' may also be used). 29 | 30 | For example, if we define a transition between the status 'draft' and 'published', a post with status 'draft' (the start status) is able to reach status 'published' (the end status), but not the opposite. 31 | 32 | ## Workflow Source 33 | 34 | The *Workflow Source* is a component responsible for providing workflow, status and transitions objects based on a formatted workflow definition. 35 | 36 | A *Workflow Source* component can ready virtually any kind of source. The first release includes the `WorkflowFileSource` component : by default this source reads a workflow definition from a PHP array wrapped in a class. 37 | 38 | [Read more about Workflow Source](concept-source.md) 39 | 40 | ## Events 41 | 42 | The *SimpleWorkflow* is making use of [Yii2 events](http://www.yiiframework.com/doc-2.0/guide-concept-events.html) to allow customization of model behavior. You can attach handlers to these events in order to implement a specific behavior to your model during its life cycle inside the workflow. 43 | 44 | [Read more about events](concept-events.md) 45 | -------------------------------------------------------------------------------- /src/source/IWorkflowSource.php: -------------------------------------------------------------------------------- 1 | ItemFixture04::className(), 20 | ]; 21 | } 22 | protected function setup() 23 | { 24 | parent::setUp(); 25 | Yii::$app->set('workflowSource',[ 26 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 27 | 'definitionLoader' => [ 28 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 29 | 'namespace' => 'tests\codeception\unit\models' 30 | ] 31 | ]); 32 | } 33 | 34 | protected function tearDown() 35 | { 36 | parent::tearDown(); 37 | } 38 | 39 | public function testChangeStatusOnSaveFailed() 40 | { 41 | $item = $this->items('item1'); 42 | $this->assertTrue($item->workflowStatus->getId() == 'Item04Workflow/B'); 43 | 44 | $this->expectException( 45 | 'raoul2000\workflow\base\WorkflowException' 46 | ); 47 | $this->expectExceptionMessage( 48 | 'No status found with id Item04Workflow/Z' 49 | ); 50 | 51 | $item->status = 'Item04Workflow/Z'; 52 | $item->save(false); 53 | } 54 | 55 | public function testChangeStatusByMethodFailed() 56 | { 57 | $item = $this->items('item1'); 58 | $this->assertTrue($item->workflowStatus->getId() == 'Item04Workflow/B'); 59 | 60 | $this->expectException( 61 | 'raoul2000\workflow\base\WorkflowException' 62 | ); 63 | $this->expectExceptionMessage( 64 | 'No status found with id Item04Workflow/Z' 65 | ); 66 | 67 | $item->sendToStatus('Item04Workflow/Z'); 68 | } 69 | 70 | public function testChangeStatusOnSaveSuccess() 71 | { 72 | $item = $this->items('item1'); 73 | $this->specify('success saving model and perform transition',function() use ($item) { 74 | 75 | $item->status = 'Item04Workflow/C'; 76 | verify('current status is ok',$item->workflowStatus->getId())->equals('Item04Workflow/B'); 77 | expect('save returns true',$item->save(false))->equals(true); 78 | verify('model status attribute has not been modified',$item->status)->equals('Item04Workflow/C'); 79 | verify('model current status has not been modified',$item->getWorkflowStatus()->getId())->equals('Item04Workflow/C'); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/GraphmlLoaderTest.php: -------------------------------------------------------------------------------- 1 | specify('convertion fails when no custom property "initialStatusId" is defined',function (){ 25 | 26 | $this->expectException( 27 | 'raoul2000\workflow\base\WorkflowException' 28 | ); 29 | $this->expectExceptionMessage( 30 | "Missing custom workflow property : 'initialStatusId'" 31 | ); 32 | 33 | $l = new GraphmlLoader(); 34 | $filename = Yii::getAlias('@tests/codeception/unit/models/workflow-01.graphml'); 35 | $l->convert($filename); 36 | }); 37 | } 38 | 39 | public function testParseFail2() 40 | { 41 | $this->specify('convertion fails when no node is defined',function (){ 42 | 43 | $this->expectException( 44 | 'raoul2000\workflow\base\WorkflowException' 45 | ); 46 | $this->expectExceptionMessage( 47 | "no node could be found in this workflow" 48 | ); 49 | 50 | $l = new GraphmlLoader(); 51 | $filename = Yii::getAlias('@tests/codeception/unit/models/workflow-00.graphml'); 52 | $l->convert($filename); 53 | }); 54 | } 55 | 56 | public function testParseFail3() 57 | { 58 | $this->specify('convertion fails when no edge is defined',function (){ 59 | 60 | $this->expectException( 61 | 'raoul2000\workflow\base\WorkflowException' 62 | ); 63 | $this->expectExceptionMessage( 64 | "no edge could be found in this workflow" 65 | ); 66 | 67 | $l = new GraphmlLoader(); 68 | $filename = Yii::getAlias('@tests/codeception/unit/models/workflow-03.graphml'); 69 | $l->convert($filename); 70 | }); 71 | } 72 | 73 | public function testParseFail4() 74 | { 75 | $this->specify('convertion fails when more then one workflow (graph) is defined',function (){ 76 | 77 | $this->expectException( 78 | 'raoul2000\workflow\base\WorkflowException' 79 | ); 80 | $this->expectExceptionMessage( 81 | "more than one workflow found" 82 | ); 83 | 84 | $l = new GraphmlLoader(); 85 | $filename = Yii::getAlias('@tests/codeception/unit/models/workflow-04.graphml'); 86 | $l->convert($filename); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/base/Transition.php: -------------------------------------------------------------------------------- 1 | _startStatus = $config['start']; 37 | unset($config['start']); 38 | if ( ! $this->_startStatus instanceof StatusInterface ) { 39 | throw new WorkflowException('Start status object must implement raoul2000\workflow\base\StatusInterface'); 40 | } 41 | } else { 42 | throw new InvalidConfigException('missing start status'); 43 | } 44 | 45 | if ( ! empty($config['end'])) { 46 | $this->_endStatus = $config['end']; 47 | unset($config['end']); 48 | if ( ! $this->_endStatus instanceof StatusInterface) { 49 | throw new WorkflowException('End status object must implement raoul2000\workflow\base\StatusInterface'); 50 | } 51 | 52 | } else { 53 | throw new InvalidConfigException('missing end status'); 54 | } 55 | parent::__construct($config); 56 | $this->_id = $this->_startStatus->getId().'-'.$this->_endStatus->getId(); 57 | } 58 | /** 59 | * Returns the id of this transition. 60 | * 61 | * The id is built by concatenating the start and the end status Ids, separated with character '-'. For instance, a transition 62 | * between status A and B has an idea equals to "A-B". 63 | * 64 | * @return string the transition Id 65 | * @see \raoul2000\workflow\base\WorkflowBaseObject::getId() 66 | */ 67 | public function getId() 68 | { 69 | return $this->_id; 70 | } 71 | 72 | /** 73 | * @see \raoul2000\workflow\base\TransitionInterface::getEndStatus() 74 | */ 75 | public function getEndStatus() 76 | { 77 | return $this->_endStatus; 78 | } 79 | /** 80 | * @see \raoul2000\workflow\base\TransitionInterface::getStartStatus() 81 | */ 82 | public function getStartStatus() 83 | { 84 | return $this->_startStatus; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/source/file/PhpClassLoader.php: -------------------------------------------------------------------------------- 1 | getClassname($workflowId); 35 | $defProvider = null; 36 | try { 37 | $defProvider = Yii::createObject(['class' => $wfClassname]); 38 | } catch ( \ReflectionException $e) { 39 | throw new WorkflowException('failed to load workflow definition : '.$e->getMessage()); 40 | } 41 | if( ! $defProvider instanceof IWorkflowDefinitionProvider ) { 42 | throw new WorkflowException('Invalid workflow provider : class '.$wfClassname 43 | .' doesn\'t implement \raoul2000\workflow\source\file\IWorkflowDefinitionProvider'); 44 | } 45 | 46 | return $this->parse($workflowId, $defProvider->getDefinition(), $source); 47 | } 48 | 49 | /** 50 | * Returns the complete name for the Workflow Provider class used to retrieve the definition of workflow $workflowId. 51 | * The class name is built by appending the workflow id to the namespace parameter set for this source component. 52 | * 53 | * @param string $workflowId a workflow id 54 | * @return string the full qualified class name used to provide definition for the workflow 55 | */ 56 | public function getClassname($workflowId) 57 | { 58 | return $this->getNameSpace() . '\\' . $workflowId; 59 | } 60 | 61 | /** 62 | * Returns the namespace value used to load the workflow definition provider class. 63 | * If the alias with name self::NAMESPACE_ALIAS_NAME is found, it takes precedence over the configured *namespace* 64 | * attribute. 65 | * @return string the namespace value 66 | */ 67 | public function getNameSpace() 68 | { 69 | $nsAlias = Yii::getAlias(self::NAMESPACE_ALIAS_NAME,false); 70 | 71 | return $nsAlias === false ? $this->namespace : $nsAlias; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/events/ReducedEventSequence.php: -------------------------------------------------------------------------------- 1 | $initalStatus, 27 | 'sender' => $sender 28 | ]; 29 | return [ 30 | 'before' => [ 31 | new WorkflowEvent( 32 | WorkflowEvent::beforeEnterWorkflow($initalStatus->getWorkflowId()), 33 | $config 34 | ), 35 | ], 36 | 'after' => [ 37 | new WorkflowEvent( 38 | WorkflowEvent::afterEnterWorkflow($initalStatus->getWorkflowId()), 39 | $config 40 | ), 41 | ] 42 | ]; 43 | } 44 | 45 | /** 46 | * Produces the following sequence when a model leaves a workflow : 47 | * 48 | * - beforeLeaveWorkflow(WID) 49 | * - afterLeaveWorkflow(WID) 50 | * 51 | * @see \raoul2000\workflow\events\IEventSequence::createLeaveWorkflowSequence() 52 | */ 53 | public function createLeaveWorkflowSequence($finalStatus, $sender) 54 | { 55 | $config = [ 56 | 'start' => $finalStatus, 57 | 'sender' => $sender 58 | ]; 59 | return [ 60 | 'before' => [ 61 | new WorkflowEvent( 62 | WorkflowEvent::beforeLeaveWorkflow($finalStatus->getWorkflowId()), 63 | $config 64 | ) 65 | ], 66 | 'after' => [ 67 | new WorkflowEvent( 68 | WorkflowEvent::afterLeaveWorkflow($finalStatus->getWorkflowId()), 69 | $config 70 | ) 71 | ] 72 | ]; 73 | } 74 | 75 | /** 76 | * Produces the following sequence when a model changes from status A to status B: 77 | * 78 | * - beforeChangeStatus(A,B) 79 | * - afterChangeStatus(A,B) 80 | * 81 | * @see \raoul2000\workflow\events\IEventSequence::createChangeStatusSequence() 82 | */ 83 | public function createChangeStatusSequence($transition, $sender) 84 | { 85 | $config = [ 86 | 'start' => $transition->getStartStatus(), 87 | 'end' => $transition->getEndStatus(), 88 | 'transition' => $transition, 89 | 'sender' => $sender 90 | ]; 91 | return [ 92 | 'before' => [ 93 | new WorkflowEvent( 94 | WorkflowEvent::beforeChangeStatus($transition->getStartStatus()->getId(), $transition->getEndStatus()->getId()), 95 | $config 96 | ) 97 | ], 98 | 'after' => [ 99 | new WorkflowEvent( 100 | WorkflowEvent::afterChangeStatus($transition->getStartStatus()->getId(), $transition->getEndStatus()->getId()), 101 | $config 102 | ) 103 | ] 104 | ]; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/ClassMapTest.php: -------------------------------------------------------------------------------- 1 | specify('Workflow source construct fails if classMap is not an array',function (){ 23 | 24 | $this->expectException( 25 | 'yii\base\InvalidConfigException' 26 | ); 27 | $this->expectExceptionMessage( 28 | 'Invalid property type : \'classMap\' must be a non-empty array' 29 | ); 30 | 31 | new WorkflowFileSource([ 32 | 'classMap' => null 33 | ]); 34 | }); 35 | } 36 | 37 | public function testConstructFails2() 38 | { 39 | $this->specify('Workflow source construct fails if classMap is an empty array',function (){ 40 | 41 | $this->expectException( 42 | 'yii\base\InvalidConfigException' 43 | ); 44 | $this->expectExceptionMessage( 45 | 'Invalid property type : \'classMap\' must be a non-empty array' 46 | ); 47 | 48 | new WorkflowFileSource([ 49 | 'classMap' => null 50 | ]); 51 | }); 52 | } 53 | public function testConstructFails3() 54 | { 55 | $this->specify('Workflow source construct fails if a class entry is missing',function (){ 56 | 57 | $this->expectException( 58 | 'yii\base\InvalidConfigException' 59 | ); 60 | $this->expectExceptionMessage( 61 | 'Invalid class map value : missing class for type workflow' 62 | ); 63 | 64 | new WorkflowFileSource([ 65 | 'classMap' => [ 66 | 'workflow' => null, 67 | 'status' => 'raoul2000\workflow\base\Status', 68 | 'transition' => 'raoul2000\workflow\base\Transition' 69 | ] 70 | ]); 71 | 72 | 73 | }); 74 | } 75 | 76 | public function testClassMapStatus() 77 | { 78 | $this->specify('Replace default status class with custom one',function (){ 79 | $src = new WorkflowFileSource([ 80 | 'definitionLoader' => [ 81 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 82 | 'namespace' => 'tests\codeception\unit\models' 83 | ], 84 | 'classMap' => [ 85 | WorkflowFileSource::TYPE_STATUS => 'tests\codeception\unit\models\MyStatus', 86 | ] 87 | ]); 88 | 89 | verify($src->getClassMapByType(WorkflowFileSource::TYPE_WORKFLOW))->equals( 'raoul2000\workflow\base\Workflow' ); 90 | verify($src->getClassMapByType(WorkflowFileSource::TYPE_STATUS))->equals( 'tests\codeception\unit\models\MyStatus' ); 91 | verify($src->getClassMapByType(WorkflowFileSource::TYPE_TRANSITION))->equals('raoul2000\workflow\base\Transition'); 92 | 93 | $status = $src->getStatus('Item04Workflow/A'); 94 | 95 | expect(get_class($status))->equals('tests\codeception\unit\models\MyStatus'); 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/TransitionTest.php: -------------------------------------------------------------------------------- 1 | src = new WorkflowFileSource(); 26 | } 27 | 28 | /** 29 | * 30 | */ 31 | public function testTransitionNotFound() 32 | { 33 | $this->src->addWorkflowDefinition('wid', [ 34 | 'initialStatusId' => 'A', 35 | 'status' => [ 36 | 'A' => [] 37 | ] 38 | ]); 39 | 40 | $this->specify('empty transition set', function () { 41 | $tr = $this->src->getTransitions('wid/A'); 42 | verify('empty transition set is returned', count($tr) )->equals(0); 43 | }); 44 | } 45 | 46 | public function testTransitionSuccess() 47 | { 48 | $this->src->addWorkflowDefinition('wid', [ 49 | 'initialStatusId' => 'A', 50 | 'status' => [ 51 | 'A' => [ 52 | 'transition' => ['B' => []] 53 | ], 54 | 'B' => [] 55 | ] 56 | ]); 57 | 58 | $this->specify('end and start status can be obtained',function() { 59 | $tr = $this->src->getTransitions('wid/A'); 60 | 61 | verify('empty transition set is returned', count($tr) )->equals(1); 62 | 63 | reset($tr); 64 | //$startId = key($tr); 65 | $transition = current($tr); 66 | 67 | verify('transition is a Transition', get_class($transition))->equals('raoul2000\workflow\base\Transition'); 68 | 69 | verify('start status is a Status instance',get_class($transition->getStartStatus()) )->equals('raoul2000\workflow\base\Status'); 70 | verify('start status is A', $transition->getStartStatus()->getId())->equals('wid/A'); 71 | 72 | verify('end status is a Status instance',get_class($transition->getStartStatus()) )->equals('raoul2000\workflow\base\Status'); 73 | verify('end status is B', $transition->getEndStatus()->getId())->equals('wid/B'); 74 | }); 75 | } 76 | public function testTransitionCached() 77 | { 78 | $this->src->addWorkflowDefinition('wid', [ 79 | 'initialStatusId' => 'A', 80 | 'status' => [ 81 | 'A' => [ 82 | 'transition' => ['B' => []] 83 | ], 84 | 'B' => [] 85 | ] 86 | ]); 87 | $tr = $this->src->getTransitions('wid/A'); 88 | reset($tr); 89 | //$startId = key($tr); 90 | $transition1 = current($tr); 91 | 92 | $tr=null; 93 | $tr = $this->src->getTransitions('wid/A'); 94 | reset($tr); 95 | //$startId = key($tr); 96 | $transition2 = current($tr); 97 | 98 | $this->assertTrue(spl_object_hash($transition1) == spl_object_hash($transition2)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/validation/WorkflowValidator.php: -------------------------------------------------------------------------------- 1 | getScenarioSequence($object->$attribute); 49 | } catch (WorkflowException $e) { 50 | $object->addError($attribute, 'Workflow validation failed : '.$e->getMessage()); 51 | $scenarioList = []; 52 | } 53 | 54 | if ( count($scenarioList) != 0 ) { 55 | foreach ($object->getValidators() as $validator) { 56 | foreach ($scenarioList as $scenario) { 57 | if ($this->_isActiveValidator($validator, $scenario)) { 58 | $validator->validateAttributes($object); 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Checks if a validator is active for the workflow event passed as argument. 67 | * A Validator is active if it is configured for a scenario that matches the 68 | * current scenario. 69 | * 70 | * @param yii\validators\Validator $validator The validator instance to test 71 | * @param WorklflowEvent $event The workflow event for which the validator is tested 72 | * @return boolean TRUE if the validtor is active, FALSE otherwise. 73 | */ 74 | private function _isActiveValidator($validator, $currentScenario) 75 | { 76 | foreach ($validator->on as $scenario) { 77 | if ( WorkflowScenario::match($scenario, $currentScenario)) { 78 | return true; 79 | } 80 | } 81 | return false; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/source/file/WorkflowArrayParser.php: -------------------------------------------------------------------------------- 1 | 21 | * [ 22 | * 'initialStatusId' => 'WID/A' 23 | * 'status' => [ 24 | * 'WID/A' => [ 25 | * 'transition' => [ 26 | * 'WID/B' => [] 27 | * 'WID/C' => [] 28 | * ] 29 | * ] 30 | * 'WID/B' => null 31 | * 'WID/C' => null 32 | * ] 33 | * ] 34 | * 35 | */ 36 | abstract class WorkflowArrayParser extends BaseObject { 37 | /** 38 | * @var boolean when TRUE, the parse method performs some validations 39 | */ 40 | public $validate = true; 41 | /** 42 | * Parse a workflow defined as a PHP Array. 43 | * 44 | * The workflow definition passed as argument is turned into an array that can be 45 | * used by the WorkflowFileSource components. 46 | * 47 | * @param string $wId 48 | * @param array $definition 49 | * @param raoul2000\workflow\source\file\WorkflowFileSource $source 50 | * @return array The parse workflow array definition 51 | * @throws WorkflowValidationException 52 | */ 53 | abstract public function parse($wId, $definition, $source); 54 | 55 | /** 56 | * Validates an array that contains a workflow definition. 57 | * 58 | * @param string $wId 59 | * @param IWorkflowSource $source 60 | * @param string $initialStatusId 61 | * @param array $startStatusIdIndex 62 | * @param array $endStatusIdIndex 63 | * @throws WorkflowValidationException 64 | */ 65 | public function validate($wId, $source, $initialStatusId, $startStatusIdIndex, $endStatusIdIndex ) 66 | { 67 | if ($this->validate === true) { 68 | if (! \in_array($initialStatusId, $startStatusIdIndex)) { 69 | throw new WorkflowValidationException("Initial status not defined : $initialStatusId"); 70 | } 71 | 72 | // detect not defined statuses 73 | 74 | $missingStatusIdSuspects = \array_diff($endStatusIdIndex, $startStatusIdIndex); 75 | if (count($missingStatusIdSuspects) != 0) { 76 | $missingStatusId = []; 77 | foreach ($missingStatusIdSuspects as $id) { 78 | list ($thisWid, $thisSid) = $source->parseStatusId($id, $wId); 79 | if ($thisWid == $wId) { 80 | $missingStatusId[] = $id; // refering to the same workflow, this Id is not defined 81 | } 82 | } 83 | if (count($missingStatusId) != 0) { 84 | throw new WorkflowValidationException("One or more end status are not defined : " . VarDumper::dumpAsString($missingStatusId)); 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/EnterWorkflowTest.php: -------------------------------------------------------------------------------- 1 | ItemFixture04::className(), 21 | ]; 22 | } 23 | protected function setup() 24 | { 25 | parent::setUp(); 26 | Yii::$app->set('workflowSource',[ 27 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 28 | 'definitionLoader' => [ 29 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 30 | 'namespace' => 'tests\codeception\unit\models' 31 | ] 32 | ]); 33 | } 34 | 35 | protected function tearDown() 36 | { 37 | parent::tearDown(); 38 | } 39 | 40 | public function testEnterWorkflowSuccess() 41 | { 42 | $item = new Item04(); 43 | 44 | $this->specify('model is inserted in the default workflow',function() use ($item) { 45 | 46 | verify('current status is not set',$item->hasWorkflowStatus())->false(); 47 | 48 | $item->enterWorkflow(); 49 | verify('current status is set',$item->hasWorkflowStatus())->true(); 50 | 51 | verify('current status is ok',$item->workflowStatus->getId())->equals('Item04Workflow/A'); 52 | //verify('current status is the initial status for the current workflow', $item->engine->getInitialStatus($item->getWorkflowId())->getId() )->equals($item->currentStatus->id); 53 | 54 | verify('item can be saved',$item->save())->true(); 55 | 56 | $newitem = Item04::findOne(['id' => $item->id]); 57 | verify('current status is set',$newitem->hasWorkflowStatus())->true(); 58 | verify('current status is ok',$newitem->workflowStatus->getId())->equals('Item04Workflow/A'); 59 | 60 | }); 61 | } 62 | 63 | public function testEnterWorkflowFails1() 64 | { 65 | $item = new Item04(); 66 | $this->specify('enterWorkflow fails if the model is already in a workflow',function() use ($item) { 67 | 68 | verify('current status is not set',$item->hasWorkflowStatus())->false(); 69 | $item->sendToStatus('Item04Workflow/A'); 70 | verify('current status is set',$item->hasWorkflowStatus())->true(); 71 | $this->expectException('raoul2000\workflow\base\WorkflowException'); 72 | $this->expectExceptionMessage('Model already in a workflow'); 73 | $item->enterWorkflow(); 74 | }); 75 | } 76 | 77 | public function testEnterWorkflowFails2() 78 | { 79 | $item = new Item04(); 80 | $this->specify('enterWorkflow fails if workflow not found for ID',function() use($item) { 81 | 82 | $this->expectException( 83 | 'raoul2000\workflow\base\WorkflowException' 84 | ); 85 | $this->expectExceptionMessage( 86 | 'failed to load workflow definition : Class tests\codeception\unit\models\INVALIDID does not exist' 87 | ); 88 | 89 | $item->enterWorkflow('INVALIDID'); 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/events/EnterWorkflowReducedEventTest.php: -------------------------------------------------------------------------------- 1 | eventsBefore = []; 22 | $this->eventsAfter = []; 23 | 24 | Yii::$app->set('workflowSource',[ 25 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 26 | 'definitionLoader' => [ 27 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 28 | 'namespace' => 'tests\codeception\unit\models' 29 | ] 30 | ]); 31 | Yii::$app->set('eventSequence',[ 32 | 'class'=> 'raoul2000\workflow\events\ReducedEventSequence', 33 | ]); 34 | 35 | $this->model = new Item04(); 36 | $this->model->attachBehavior('workflow', [ 37 | 'class' => SimpleWorkflowBehavior::className() 38 | ]); 39 | } 40 | 41 | protected function tearDown() 42 | { 43 | $this->model->delete(); 44 | parent::tearDown(); 45 | } 46 | 47 | public function testOnEnterWorkflowSuccess() 48 | { 49 | $this->model->on( 50 | WorkflowEvent::beforeEnterWorkflow('Item04Workflow'), 51 | function($event) { 52 | $this->eventsBefore[] = $event; 53 | } 54 | ); 55 | $this->model->on( 56 | WorkflowEvent::afterEnterWorkflow('Item04Workflow'), 57 | function($event) { 58 | $this->eventsAfter[] = $event; 59 | } 60 | ); 61 | 62 | verify('event handler handlers have been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 63 | 64 | $this->model->enterWorkflow(); 65 | 66 | verify('current status is set',$this->model->hasWorkflowStatus())->true(); 67 | 68 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(1); 69 | expect('afterChangeStatus handler has been called',count($this->eventsAfter))->equals(1); 70 | } 71 | 72 | public function testOnEnterWorkflowError() 73 | { 74 | $this->model->on( 75 | WorkflowEvent::beforeEnterWorkflow('Item04Workflow'), 76 | function($event) { 77 | $this->eventsBefore[] = $event; 78 | $event->isValid = false; 79 | } 80 | ); 81 | $this->model->on( 82 | WorkflowEvent::afterEnterWorkflow('Item04Workflow'), 83 | function($event) { 84 | $this->eventsAfter[] = $event; 85 | } 86 | ); 87 | 88 | verify('event handler handlers have been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 89 | 90 | $this->model->enterWorkflow(); 91 | 92 | verify('current status is not set',$this->model->hasWorkflowStatus())->false(); 93 | 94 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(1); 95 | expect('afterChangeStatus handler has not been called',count($this->eventsAfter))->equals(0); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/AttachBehaviorTest.php: -------------------------------------------------------------------------------- 1 | specify('behavior can be attached to ActiveRecord', function () use ($model) { 22 | $behaviors = $model->behaviors(); 23 | expect('model should have the "workflow" behavior attached', isset($behaviors['workflow']) )->true(); 24 | expect('model has a SimpleWorkflowBehavior attached', SimpleWorkflowBehavior::isAttachedTo($model) )->true(); 25 | }); 26 | } 27 | 28 | public function testAttachSuccess2() 29 | { 30 | $this->specify('behavior can be attached to a Component with the "status" property', function () { 31 | $model = Yii::createObject('\tests\codeception\unit\models\Component01',['status'=>'']); 32 | $model->attachBehavior('workflow', SimpleWorkflowBehavior::className()); 33 | }); 34 | } 35 | 36 | 37 | public function testAttachFails1() 38 | { 39 | $this->specify('behavior cannot be attached if the owner has no suitable attribute or property to store the status', function () { 40 | $this->assertThrowsWithMessage( 41 | 'yii\base\InvalidConfigException' , 42 | "Property not found for owner model : 'status'", 43 | function() { 44 | $model = Yii::createObject("yii\base\Component",[]); 45 | $model->attachBehavior('workflow', SimpleWorkflowBehavior::className()); 46 | } 47 | ); 48 | }); 49 | } 50 | 51 | public function testAttachFails2() 52 | { 53 | $this->specify('the status attribute cannot be empty', function () { 54 | $this->assertThrowsWithMessage( 55 | 'yii\base\InvalidConfigException' , 56 | 'The "statusAttribute" configuration for the Behavior is required.', 57 | function() { 58 | $model = new Item01(); 59 | expect('model has a SimpleWorkflowBehavior attached', SimpleWorkflowBehavior::isAttachedTo($model) )->true(); 60 | $model->detachBehavior('workflow'); 61 | expect('model has a NO SimpleWorkflowBehavior attached', SimpleWorkflowBehavior::isAttachedTo($model) )->false(); 62 | $model->attachBehavior('workflow', [ 'class' => SimpleWorkflowBehavior::className(), 'statusAttribute' => '' ]); 63 | } 64 | ); 65 | }); 66 | } 67 | 68 | public function testAttachFails3() 69 | { 70 | $this->specify('the status attribute must exist in the owner model', function () { 71 | 72 | $this->assertThrowsWithMessage( 73 | 'yii\base\InvalidConfigException' , 74 | "Attribute or property not found for owner model : 'not_found'", 75 | function() { 76 | $model = new Item01(); 77 | $model->detachBehavior('workflow'); 78 | $model->attachBehavior('workflow', [ 'class' => SimpleWorkflowBehavior::className(), 'statusAttribute' => 'not_found' ]); 79 | } 80 | ); 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/WorkflowFileSourceTest.php: -------------------------------------------------------------------------------- 1 | specify('Workflow source construct fails if classMap is not an array',function (){ 24 | 25 | $this->expectException( 26 | 'yii\base\InvalidConfigException' 27 | ); 28 | $this->expectExceptionMessage( 29 | 'Invalid property type : \'classMap\' must be a non-empty array' 30 | ); 31 | 32 | new WorkflowFileSource([ 33 | 'namespace' =>'a\b\c', 34 | 'classMap' => null 35 | ]); 36 | }); 37 | } 38 | 39 | public function testConstructSuccess() 40 | { 41 | $this->specify('Workflow source construct default',function (){ 42 | 43 | $src = new WorkflowFileSource(); 44 | 45 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_WORKFLOW))->equals( 'raoul2000\workflow\base\Workflow' ); 46 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_STATUS))->equals( 'raoul2000\workflow\base\Status' ); 47 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_TRANSITION))->equals('raoul2000\workflow\base\Transition' ); 48 | 49 | expect($src->getDefinitionCache())->equals(null); 50 | expect($src->getDefinitionLoader())->notNull(); 51 | }); 52 | 53 | 54 | 55 | $this->specify('Workflow source construct with class map',function (){ 56 | 57 | $src = new WorkflowFileSource([ 58 | 'classMap' => [ 59 | WorkflowFileSource::TYPE_WORKFLOW => 'my\namespace\Workflow', 60 | WorkflowFileSource::TYPE_STATUS => 'my\namespace\Status', 61 | WorkflowFileSource::TYPE_TRANSITION => 'my\namespace\Transition' 62 | ] 63 | ]); 64 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_WORKFLOW))->equals( 'my\namespace\Workflow' ); 65 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_STATUS))->equals( 'my\namespace\Status' ); 66 | expect($src->getClassMapByType(WorkflowFileSource::TYPE_TRANSITION))->equals('my\namespace\Transition' ); 67 | }); 68 | 69 | 70 | 71 | $this->specify('Workflow source construct with cache',function (){ 72 | // initialized by array 73 | $src = new WorkflowFileSource([ 74 | 'definitionCache' => ['class' => 'yii\caching\FileCache'] 75 | ]); 76 | expect_that($src->getDefinitionCache() instanceof yii\caching\FileCache); 77 | 78 | 79 | // initialized by component ID 80 | Yii::$app->set('myCache',['class' => 'yii\caching\FileCache']); 81 | $src = new WorkflowFileSource([ 82 | 'definitionCache' => 'myCache' 83 | ]); 84 | expect_that($src->getDefinitionCache() instanceof yii\caching\FileCache); 85 | 86 | // initialized by object 87 | $cache = Yii::$app->get('myCache'); 88 | Yii::$app->set('myCache',['class' => 'yii\caching\FileCache']); 89 | $src = new WorkflowFileSource([ 90 | 'definitionCache' => $cache 91 | ]); 92 | expect_that($src->getDefinitionCache() instanceof yii\caching\FileCache); 93 | 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/events/InvalidEventTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 26 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 27 | 'definitionLoader' => [ 28 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 29 | 'namespace' => 'tests\codeception\unit\models' 30 | ] 31 | ]); 32 | } 33 | 34 | protected function tearDown() 35 | { 36 | parent::tearDown(); 37 | } 38 | 39 | public function invalidateEvent($event) { 40 | $event->invalidate('err_message_1'); 41 | 42 | } 43 | 44 | public function testPropagateErrorToModel() 45 | { 46 | // prepare item instance 47 | 48 | $item = new Item00(); 49 | $item->attachBehavior('workflow', [ 50 | 'class' => SimpleWorkflowBehavior::className(), 51 | 'defaultWorkflowId' => 'Item04Workflow', 52 | 'propagateErrorsToModel' => true 53 | ]); 54 | 55 | $item->on(WorkflowEvent::beforeEnterStatus('Item04Workflow/B'),[$this, 'invalidateEvent']); 56 | 57 | $item->sendToStatus('Item04Workflow/A'); 58 | 59 | verify('item is in status A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 60 | verify('item has no error', $item->hasErrors())->false(); 61 | 62 | // send to B 63 | 64 | $item->sendToStatus('B'); 65 | expect('status is still A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 66 | expect('item has error', $item->hasErrors())->true(); 67 | expect('error message is set for attribute "status"', count($item->getErrors('status')) )->equals(1); 68 | expect('error message is "err_message_1" ', $item->getFirstError('status') )->equals("err_message_1"); 69 | 70 | } 71 | 72 | public function testNoPropagateErrorToModel() 73 | { 74 | // prepare item instance 75 | 76 | $item = new Item00(); 77 | $item->attachBehavior('workflow', [ 78 | 'class' => SimpleWorkflowBehavior::className(), 79 | 'defaultWorkflowId' => 'Item04Workflow', 80 | 'propagateErrorsToModel' => false 81 | ]); 82 | 83 | $item->on(WorkflowEvent::beforeEnterStatus('Item04Workflow/B'),[$this, 'invalidateEvent']); 84 | 85 | $item->sendToStatus('Item04Workflow/A'); 86 | 87 | verify('item is in status A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 88 | verify('item has no error', $item->hasErrors())->false(); 89 | 90 | // send to B 91 | 92 | $item->sendToStatus('B'); 93 | 94 | expect('status is still A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 95 | expect('item has no error', $item->hasErrors())->false(); 96 | 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/Item06Behavior.php: -------------------------------------------------------------------------------- 1 | "beforeNew", 30 | WorkflowEvent::afterEnterStatus('Item06Workflow/new') => "afterNew", 31 | WorkflowEvent::afterEnterStatus('Item06Workflow/correction') => "postToCorrect", 32 | WorkflowEvent::beforeLeaveStatus('Item06Workflow/correction') => "postCorrected", 33 | WorkflowEvent::beforeEnterStatus('Item06Workflow/published') => "checkCanBePublished", 34 | WorkflowEvent::beforeChangeStatus('Item06Workflow/published', 'Item06Workflow/archive') => "canBeArchived", 35 | WorkflowEvent::beforeLeaveWorkflow('Item06Workflow') => 'beforeLeaveWorkflow', 36 | WorkflowEvent::afterLeaveWorkflow('Item06Workflow') => 'afterLeaveWorkflow', 37 | 38 | WorkflowEvent::beforeLeaveStatus('Item06Workflow/new') => 'beforeLeaveNew', 39 | WorkflowEvent::afterLeaveStatus('Item06Workflow/new') => 'afterLeaveNew', 40 | ]; 41 | } 42 | public function beforeLeaveNew($event) 43 | { 44 | self::$countBeforeLeaveNew++; 45 | } 46 | public function afterLeaveNew($event) 47 | { 48 | self::$countAfterLeaveNew++; 49 | } 50 | 51 | public function afterLeaveWorkflow($event) 52 | { 53 | self::$countLeaveWorkflow++; 54 | } 55 | public function beforeLeaveWorkflow($event) 56 | { 57 | if( $this->canLeaveWorkflow == false) { 58 | $event->invalidate('item cannot be deleted'); 59 | return false; 60 | } else { 61 | return true; 62 | } 63 | } 64 | 65 | 66 | public function beforeNew($event) 67 | { 68 | if(self::$countPost >= self::$maxPostCount) { 69 | $event->isValid = false; 70 | } 71 | } 72 | public function afterNew($event) 73 | { 74 | self::$countPost++; 75 | } 76 | public function postToCorrect($event) 77 | { 78 | self::$countPostToCorrect++; 79 | } 80 | public function postCorrected($event) 81 | { 82 | if( ! $this->corrected) { 83 | $event->isValid = false; 84 | } else { 85 | $this->corrected = true; 86 | self::$countPostToCorrect--; 87 | self::$countPostCorrected++; 88 | } 89 | } 90 | public function checkCanBePublished($event) 91 | { 92 | if( ! $this->corrected) { 93 | $event->isValid = false; 94 | } 95 | } 96 | public function canBeArchived($event) 97 | { 98 | $event->isValid = ( $this->canBeArchived == true ); 99 | } 100 | 101 | 102 | ////////////////////////////////////////////////////////////////// 103 | 104 | public function markAsCorrected() 105 | { 106 | $this->corrected = true; 107 | } 108 | public function markAsCandidateForArchive() 109 | { 110 | $this->canBeArchived = true; 111 | } 112 | public function canLeaveWorkflow($bool) 113 | { 114 | $this->canLeaveWorkflow = $bool; 115 | } 116 | } -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/helpers/WorkflowHelperTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 23 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 24 | 'definitionLoader' => [ 25 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 26 | 'namespace' => 'tests\codeception\unit\models' 27 | ] 28 | ]); 29 | } 30 | 31 | protected function tearDown() 32 | { 33 | parent::tearDown(); 34 | } 35 | 36 | public function testGetNextStatus() 37 | { 38 | $model = new Item04(); 39 | $model->enterWorkflow(); 40 | 41 | $ar = WorkflowHelper::getNextStatus($model); 42 | $this->assertEquals( 'Item04Workflow/B', $ar); 43 | 44 | } 45 | 46 | public function testGetAllStatusListData() 47 | { 48 | $ar = WorkflowHelper::getAllStatusListData('Item04Workflow', Yii::$app->workflowSource); 49 | 50 | $expected = [ 51 | 'Item04Workflow/A' => 'Entry', 52 | 'Item04Workflow/B' => 'Published', 53 | 'Item04Workflow/C' => 'node C', 54 | 'Item04Workflow/D' => 'node D', 55 | ]; 56 | 57 | $this->assertEquals(4, count(array_intersect_assoc($expected,$ar))); 58 | } 59 | 60 | public function testGetNextStatusListData() 61 | { 62 | $model = new Item04(); 63 | $model->enterWorkflow(); 64 | 65 | $ar = WorkflowHelper::getNextStatusListData($model); 66 | 67 | $expected = [ 68 | 'Item04Workflow/A' => 'Entry', 69 | 'Item04Workflow/B' => 'Published', 70 | ]; 71 | 72 | $this->assertEquals( 2, count($ar)); 73 | $this->assertEquals(2, count(array_intersect_assoc($expected,$ar))); 74 | 75 | $model->sendTostatus('B'); 76 | $ar = WorkflowHelper::getNextStatusListData($model,false,false,true); 77 | $this->assertEquals( 3, count($ar)); 78 | 79 | $this->assertEquals(3, count(array_intersect_assoc([ 80 | 'Item04Workflow/A' => 'Entry', 81 | 'Item04Workflow/B' => 'Published', 82 | 'Item04Workflow/C' => 'node C', 83 | ],$ar))); 84 | } 85 | 86 | public function testGetStatusDropDownData() 87 | { 88 | $model = new Item04(); 89 | $model->enterWorkflow(); 90 | 91 | $ar = WorkflowHelper::GetStatusDropDownData($model); 92 | $listData = WorkflowHelper::getAllStatusListData($model->getWorkflow()->getId(), $model->getWorkflowSource()); 93 | codecept_debug($ar); 94 | $expected = [ 95 | 'Item04Workflow/A' => 'Entry', 96 | 'Item04Workflow/B' => 'Published', 97 | ]; 98 | 99 | $this->assertTrue(is_array($ar)); 100 | $this->assertTrue(isset($ar['items']) && is_array($ar['items'])); 101 | $this->assertTrue(isset($ar['options']) && is_array($ar['options'])); 102 | $this->assertEquals( 2, count($ar)); 103 | 104 | foreach ($listData as $status => $label) { 105 | $this->assertTrue( array_key_exists($status, $ar['items'])); 106 | } 107 | $this->assertTrue( $ar['options']['Item04Workflow/C']['disabled']); 108 | $this->assertTrue( $ar['options']['Item04Workflow/D']['disabled']); 109 | 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/actions/ChangeStatusAction.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Carlos (neverabe) Llamosas 15 | * @author Alejandro (seether69) Marquez 16 | */ 17 | class ChangeStatusAction extends Action 18 | { 19 | /** 20 | * @var callable used to find the model to be updated. 21 | * 22 | * Must have signature 23 | * ```php 24 | * function ($id); 25 | * ``` 26 | * 27 | * Where 28 | * - id: mixed the param to be searched as model. 29 | * 30 | * and returns an ActiveRecord instance. 31 | * or throw `yii\web\HttpException` 32 | */ 33 | public $findModel; 34 | 35 | /** 36 | * @var callable method to handle the response for the user. 37 | * 38 | * Must have signature 39 | * 40 | * ```php 41 | * function ($changedStatus, $model) 42 | * ``` 43 | * 44 | * Where 45 | * - $changedStatus: boolean if the status were changed correctly 46 | * - $model: ActiveRecord the model which was updated. 47 | * 48 | * With a mixed return depending on the controller. 49 | */ 50 | public $response; 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | public function init() 56 | { 57 | parent::init(); 58 | if (!is_callable($this->findModel)) { 59 | throw new InvalidConfigException( 60 | '`findModel` must be a callable property.' 61 | ); 62 | } 63 | if (!is_callable($this->response)) { 64 | throw new InvalidConfigException( 65 | '`response` must be a callable property.' 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * Runs this action with the specified parameters. 72 | * This method is mainly invoked by the controller 73 | * @param integer $id Id of model to find 74 | * @param string $status status will change 75 | * @return closure object Preconfigured response 76 | */ 77 | public function run($id, $status) 78 | { 79 | $model = $this->findModel($id); 80 | $model->load(Yii::$app->request->post()); 81 | $changedStatus = $model->sendToStatus($status); 82 | $model->save(); 83 | 84 | return $this->response( 85 | $changedStatus, 86 | $model 87 | ); 88 | } 89 | 90 | /** 91 | * Finds the model based on its primary key value. 92 | * If the model is not found, a 404 HTTP exception will be thrown. 93 | * @param integer $id 94 | * @return The loaded model 95 | * @throws NotFoundHttpException if the model cannot be found 96 | */ 97 | protected function findModel($id) 98 | { 99 | return call_user_func($this->findModel, $id); 100 | } 101 | 102 | /** 103 | * Function preconfigured response 104 | * @param boolean $changedStatus if status changed 105 | * @param object $model Model in the response 106 | * @return closure object preconfigured response 107 | */ 108 | protected function response($changedStatus, $model) 109 | { 110 | return call_user_func( 111 | $this->response, 112 | $changedStatus, 113 | $model 114 | ); 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /src/base/Status.php: -------------------------------------------------------------------------------- 1 | _workflow_id = $config['workflowId']; 51 | unset($config['workflowId']); 52 | } else { 53 | throw new InvalidConfigException('missing workflow id'); 54 | } 55 | 56 | if ( ! empty($config['id'])) { 57 | $this->_id = $config['id']; 58 | unset($config['id']); 59 | } else { 60 | throw new InvalidConfigException('missing status id'); 61 | } 62 | 63 | if ( ! empty($config['label'])) { 64 | $this->_label = $config['label']; 65 | unset($config['label']); 66 | } 67 | parent::__construct($config); 68 | } 69 | /** 70 | * Returns the id of this status. 71 | * 72 | * Note that the status id returned must be unique inside the workflow it belongs to, but it 73 | * doesn't have to be unique among all workflows. 74 | * 75 | * @return string the id for this status 76 | */ 77 | public function getId() 78 | { 79 | return $this->_id; 80 | } 81 | /** 82 | * Returns the label for this status. 83 | * 84 | * @return string the label for this status. . 85 | */ 86 | public function getLabel() 87 | { 88 | return $this->_label; 89 | } 90 | /** 91 | * @return string the id of the workflow this status belongs to. 92 | */ 93 | public function getWorkflowId() 94 | { 95 | return $this->_workflow_id; 96 | } 97 | /** 98 | * 99 | * @see \raoul2000\workflow\base\StatusInterface::getTransitions() 100 | */ 101 | public function getTransitions() 102 | { 103 | if( $this->getSource() === null) { 104 | throw new WorkflowException('no workflow source component available'); 105 | } 106 | return $this->getSource()->getTransitions($this->getId()); 107 | } 108 | /** 109 | * @see \raoul2000\workflow\base\StatusInterface::getWorkflow() 110 | */ 111 | public function getWorkflow() 112 | { 113 | if( $this->getSource() === null) { 114 | throw new WorkflowException('no workflow source component available'); 115 | } 116 | return $this->getSource()->getWorkflow($this->getWorkflowId()); 117 | } 118 | /** 119 | * @see \raoul2000\workflow\base\StatusInterface::isInitialStatus() 120 | */ 121 | public function isInitialStatus() 122 | { 123 | if( $this->getSource() === null) { 124 | throw new WorkflowException('no workflow source component available'); 125 | } 126 | return $this->getWorkflow()->getInitialStatusId() == $this->getId(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/validation/WorkflowScenario.php: -------------------------------------------------------------------------------- 1 | _metadata = $config['metadata']; 35 | unset($config['metadata']); 36 | } 37 | 38 | if( array_key_exists('source', $config) ) { 39 | $this->_source = $config['source']; 40 | if( ! $this->_source instanceof IWorkflowSource){ 41 | throw new InvalidConfigException('The "source" property must implement interface raoul2000\workflow\source\IWorkflowSource'); 42 | } 43 | unset($config['source']); 44 | } 45 | parent::__construct($config); 46 | } 47 | 48 | /** 49 | * 50 | */ 51 | public function __get($name) 52 | { 53 | if ( $this->canGetProperty($name)) { 54 | return parent::__get($name); 55 | } elseif ( $this->hasMetadata($name)) { 56 | return $this->_metadata[$name]; 57 | } else { 58 | throw new WorkflowException("No metadata found is the name '$name'"); 59 | } 60 | } 61 | /** 62 | * @return string the object identifier 63 | */ 64 | abstract public function getId(); 65 | /** 66 | * Returns the value of the metadata with namer `$paramName`. 67 | * If no `$paramName`is provided, this method returns an array containing all metadata parameters. 68 | * 69 | * @param string $paramName when null the method returns the complet metadata array, otherwise it returns the 70 | * value of the correponding metadata. 71 | * @param mixed $defaultValue 72 | * @throws \yii\base\InvalidConfigException 73 | * @return mixed 74 | */ 75 | public function getMetadata($paramName = null, $defaultValue = null) 76 | { 77 | if ( $paramName === null) { 78 | return $this->_metadata; 79 | } elseif( $this->hasMetadata($paramName) ) { 80 | return $this->_metadata[$paramName]; 81 | } else { 82 | return $defaultValue; 83 | } 84 | } 85 | /** 86 | * Test if a metadata parameter is defined. 87 | * 88 | * @param string $paramName the metadata parameter name 89 | * @throws \raoul2000\workflow\base\WorkflowException 90 | * @return boolean TRUE if the metadata parameter exists, FALSE otherwise 91 | */ 92 | public function hasMetadata($paramName) 93 | { 94 | if ( ! is_string($paramName) || empty($paramName)) { 95 | throw new WorkflowException("Invalid metadata name : non empty string expected"); 96 | } 97 | return array_key_exists($paramName, $this->_metadata); 98 | } 99 | /** 100 | * Returns the source workflow component used to create this instance. 101 | * 102 | * @return \raoul2000\workflow\source\IWorkflowSource the source instance or null if no 103 | * source was been provided 104 | */ 105 | public function getSource() 106 | { 107 | return $this->_source; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /guide/docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrading From *SimpleWorkflow* 1.x 2 | 3 | If you have been using the [previous version of *SimpleWorkflow*](http://s172418307.onlinehome.fr/project/sandbox/www/index.php?r=simpleWorkflow/page&view=home) together with Yii 1.x and now want to migrate to Yii2, this chapter is for you. We will focus on the main differences between the previous version of *SimpleWorkflow* (v1.x) and this one (v2.x). 4 | 5 | Main improvements in v2.x aimed to clearly separate the workflow definition in terms of status and transition, from the business logic that implements the way a model is going to behave inside a workflow. By doing so, the same workflow can be easily used by models of different types, each one having its own behavior. 6 | 7 | Another goal was to provide maximum flexibility in the way the developer is going to setup its app architecture. For instance the workflow definition can be embedded in any PHP class that implements the appropriate interface (`raoul2000\workflow\source\file\IWorkflowDefinitionProvider`). Moreover almost all classes can be overloaded and new ones created to implements specific needs not covered by the current version. 8 | 9 | 10 | ## Principles 11 | 12 | Not that much to say here, as there is no change in the way the *SimpleWorkflowBehavior* detects status changes : 13 | 14 | - On one side an ActiveRecord's attribute which can be viewed as the future status. 15 | - On the other side, the actual status managed internally by the behavior. 16 | 17 | A transition is a directed link between to statuses : the *start* and the *end* status. 18 | 19 | ## Definition 20 | The workflow definition required by the [[raoul2000\workflow\source\file\WorkflowFileSource|WorkflowFileSource]] component differs from previous version : 21 | 22 | - key `initial` is replaced by `initialStatusId` 23 | - key `node` is replaced by `status` 24 | - status ids are stored as keys of the *status* array (and not as value of the `id` key anymore) 25 | 26 | For more information please refer to [Workflow File Source Component](source-file.md) documentation. 27 | 28 | ## Workflow Tasks 29 | 30 | ### Declaration 31 | 32 | Workflow Tasks are not declared anymore in the workflow definition itself. This could create a dependency between the workflow and the model, lie for instance when the workflow tasks was using *$this*. 33 | 34 | ### Implementation 35 | 36 | Workflow task used to be a piece of PHP code associated with a workflow transition and evaluated when this transition was performed. With this new version, **Workflow Tasks should be implemented as event handler attached to a *after* event type**. 37 | 38 | Please refer to the [Workflow Event](concept-events.md) documentation for more. 39 | 40 | ## Status Constraints 41 | ### Declaration 42 | Status Constraints are not declared anymore in the workflow definition itself, for the same reason as above. 43 | 44 | ### Implementation 45 | Status Constraints used to be a piece of PHP code associated with a status and evaluated as a logical expression *before* a model enters into this status. If the evaluation returned TRUE, the model can access the status, otherwise the transition is blocked. 46 | 47 | The same principles still applies but in v2.x **status constraints should be implemented as event handler attached to a *before* event type.** 48 | 49 | Please refer to the [Workflow Event](concept-events.md) documentation for more. 50 | 51 | ## Workflow Driven Validation 52 | 53 | It is still possible to validate models attributes based on the transition that is done by the model. The principle remains the same : Workflow driven validation is based on dynamic scenario names and their associated validation rules declared in the model. 54 | 55 | Please refer to the [Workflow Driven Attribute Validation](concept-validation.md) documentation for more. 56 | 57 | ## Events 58 | 59 | There are no major changes in the way workflow events are managed although the event model has been enhanced to provide more control through event handlers. 60 | 61 | Please refer to the [Workflow Event](concept-events.md) documentation for more. 62 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/events/ChangeStatusReducedEventTest.php: -------------------------------------------------------------------------------- 1 | eventsBefore = []; 26 | $this->eventsAfter = []; 27 | 28 | Yii::$app->set('workflowSource',[ 29 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 30 | 'definitionLoader' => [ 31 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 32 | 'namespace' => 'tests\codeception\unit\models' 33 | ] 34 | ]); 35 | 36 | Yii::$app->set('eventSequence',[ 37 | 'class'=> 'raoul2000\workflow\events\ReducedEventSequence', 38 | ]); 39 | 40 | $this->model = new Item04(); 41 | $this->model->attachBehavior('workflow', [ 42 | 'class' => SimpleWorkflowBehavior::className() 43 | ]); 44 | } 45 | 46 | protected function tearDown() 47 | { 48 | $this->model->delete(); 49 | parent::tearDown(); 50 | } 51 | 52 | public function testChangeStatusEventOnSaveSuccess() 53 | { 54 | $this->model->on( 55 | WorkflowEvent::beforeChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 56 | function($event) { 57 | $this->eventsBefore[] = $event; 58 | } 59 | ); 60 | $this->model->on( 61 | WorkflowEvent::afterChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 62 | function($event) { 63 | $this->eventsAfter[] = $event; 64 | } 65 | ); 66 | 67 | $this->model->enterWorkflow(); 68 | verify('current status is set',$this->model->hasWorkflowStatus())->true(); 69 | verify('event handler handlers have been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 70 | 71 | $this->model->status = 'Item04Workflow/B'; 72 | verify('save succeeds',$this->model->save())->true(); 73 | 74 | expect('model has changed to status B',$this->model->getWorkflowStatus()->getId())->equals('Item04Workflow/B'); 75 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(1); 76 | expect('afterChangeStatus handler has been called',count($this->eventsAfter))->equals(1); 77 | } 78 | 79 | public function testChangeStatusEventOnSaveFails() 80 | { 81 | $this->model->on( 82 | WorkflowEvent::beforeChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 83 | function($event) { 84 | $this->eventsBefore[] = $event; 85 | $event->isValid = false; 86 | } 87 | ); 88 | $this->model->on( 89 | WorkflowEvent::afterChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 90 | function($event) { 91 | $this->eventsAfter[] = $event; 92 | } 93 | ); 94 | $this->model->enterWorkflow(); 95 | verify('current status is set',$this->model->hasWorkflowStatus())->true(); 96 | verify('event handlers have never been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 97 | 98 | $this->model->status = 'Item04Workflow/B'; 99 | verify('save fails',$this->model->save())->false(); 100 | 101 | expect('model has not changed status',$this->model->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 102 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(1); 103 | expect('afterChangeStatus handler has not been called',count($this->eventsAfter))->equals(0); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/MultiWorkflowTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 20 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 21 | 'definitionLoader' => [ 22 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 23 | 'namespace' => 'tests\codeception\unit\models' 24 | ] 25 | ]); 26 | } 27 | 28 | public function testSetStatusAssignedSuccess() 29 | { 30 | $o = new Item08(); 31 | 32 | $o->status = 'draft'; 33 | $o->status_ex = 'success'; 34 | expect_that($o->save()); 35 | verify_that( $o->status == 'Item08Workflow1/draft'); 36 | verify_that( $o->status_ex == 'Item08Workflow2/success'); 37 | 38 | $o = new Item08(); 39 | $o->status = 'draft'; 40 | expect_that($o->save()); 41 | verify_that( $o->status == 'Item08Workflow1/draft'); 42 | verify_that( $o->status_ex == null); 43 | 44 | $o = new Item08(); 45 | $o->status_ex = 'success'; 46 | expect_that($o->save()); 47 | verify_that( $o->status == null); 48 | verify_that( $o->status_ex == 'Item08Workflow2/success'); 49 | } 50 | 51 | /** 52 | * @expectedException raoul2000\workflow\base\WorkflowException 53 | * @expectedExceptionMessageRegExp #No status found with id Item08Workflow2/DUMMY# 54 | */ 55 | public function testSetStatusAssignedFails1() 56 | { 57 | $o = new Item08(); 58 | 59 | $o->status = 'draft'; 60 | $o->status_ex = 'DUMMY'; 61 | $o->save(); 62 | } 63 | 64 | /** 65 | * @expectedException raoul2000\workflow\base\WorkflowException 66 | * @expectedExceptionMessageRegExp #No status found with id Item08Workflow1/DUMMY# 67 | */ 68 | public function testSetStatusAssignedFails2() 69 | { 70 | $o = new Item08(); 71 | 72 | $o->status = 'DUMMY'; 73 | $o->status_ex = 'succcess'; 74 | $o->save(); 75 | } 76 | 77 | public function testSetStatusBehaviorSuccess() 78 | { 79 | $o = new Item08(); 80 | 81 | $o->getBehavior('w1')->sendToStatus('draft'); 82 | $o->getBehavior('w2')->sendToStatus('success'); 83 | 84 | verify_that( $o->getBehavior('w1')->getWorkflowStatus()->getId() == 'Item08Workflow1/draft'); 85 | verify_that( $o->getBehavior('w2')->getWorkflowStatus()->getId() == 'Item08Workflow2/success'); 86 | 87 | $o->getBehavior('w1')->sendToStatus('correction'); 88 | $o->getBehavior('w2')->sendToStatus('onHold'); 89 | 90 | verify_that( $o->getBehavior('w1')->getWorkflowStatus()->getId() == 'Item08Workflow1/correction'); 91 | verify_that( $o->getBehavior('w2')->getWorkflowStatus()->getId() == 'Item08Workflow2/onHold'); 92 | } 93 | 94 | /** 95 | * @expectedException raoul2000\workflow\base\WorkflowException 96 | * @expectedExceptionMessageRegExp #No status found with id Item08Workflow1/DUMMY# 97 | */ 98 | public function testSetStatusBehaviorFails1() 99 | { 100 | $o = new Item08(); 101 | 102 | $o->getBehavior('w1')->sendToStatus('DUMMY'); 103 | } 104 | 105 | /** 106 | * @expectedException raoul2000\workflow\base\WorkflowException 107 | * @expectedExceptionMessageRegExp #No status found with id Item08Workflow2/DUMMY# 108 | */ 109 | public function testSetStatusBehaviorFails2() 110 | { 111 | $o = new Item08(); 112 | 113 | $o->getBehavior('w2')->sendToStatus('DUMMY'); 114 | } 115 | 116 | public function testEnterWorkflowSuccess() 117 | { 118 | $o = new Item08(); 119 | 120 | $o->getBehavior('w1')->enterWorkflow(); 121 | $o->getBehavior('w2')->enterWorkflow(); 122 | 123 | verify_that( $o->getBehavior('w1')->getWorkflowStatus()->getId() == 'Item08Workflow1/draft'); 124 | verify_that( $o->getBehavior('w2')->getWorkflowStatus()->getId() == 'Item08Workflow2/success'); 125 | } 126 | } -------------------------------------------------------------------------------- /src/base/StatusIdConverter.php: -------------------------------------------------------------------------------- 1 | 23 | * $map = [ 24 | * 'post/new' => 12, 25 | * 'post/corrected' => 25, 26 | * 'post/published' => 1, 27 | * 'post/archived' => 6, 28 | * StatusIdConverter::VALUE_NULL => 'some value', 29 | * 'workflow/Status' => StatusIdConverter::VALUE_NULL 30 | * ] 31 | * 32 | * 33 | * Note that if the NULL value must be part of the conversion, you should use the VALUE_NULL 34 | * constant instead of the actual 'null' value.
35 | * For example in the conversion table below, the fact for the owner model to be outside a workflow, 36 | * would mean that the actual status column would be set to 25. In the same way, any model with a 37 | * status column equals to NULL, is considered as being in status 'post/toDelete' : 38 | * 39 | *
 40 |  * $map = [
 41 |  *      StatusIdConverter::VALUE_NULL => 25,
 42 |  *     'post/toDelete' => StatusIdConverter::VALUE_NULL
 43 |  * ];
 44 |  * 
45 | * 46 | * @see IStatusIdConverter 47 | */ 48 | class StatusIdConverter extends BaseObject implements IStatusIdConverter 49 | { 50 | const VALUE_NULL = 'null'; 51 | private $_map = []; 52 | 53 | /** 54 | * Contruct an instance of the StatusIdConverter. 55 | * The parameter `map` must be defined in the configuration array passed as argument. It contains the 56 | * associative array used to convert statuses. 57 | * 58 | * @param array $config 59 | * @throws InvalidConfigException 60 | */ 61 | public function __construct($config = []) 62 | { 63 | if ( ! empty($config['map'])) { 64 | if ( ! is_array($config['map'])) { 65 | throw new InvalidConfigException('The map must be an array'); 66 | } 67 | $this->_map = $config['map']; 68 | unset($config['map']); 69 | } else { 70 | throw new InvalidConfigException('missing map'); 71 | } 72 | parent::__construct($config); 73 | } 74 | /** 75 | * @return array the convertion map used by this converter 76 | */ 77 | public function getMap() 78 | { 79 | return $this->_map; 80 | } 81 | /** 82 | * Replace the convertion map initialized in constructor by the one passed as argument. 83 | * 84 | * @param array $map 85 | * @throws InvalidCallException 86 | */ 87 | public function setMap($map) 88 | { 89 | if ( ! is_array($map)) { 90 | throw new InvalidCallException('The map argument must be an array'); 91 | } 92 | $this->_map = $map; 93 | } 94 | /** 95 | * (non-PHPdoc) 96 | * @see IStatusIdConverter::toSimpleWorkflow() 97 | */ 98 | public function toSimpleWorkflow($id) 99 | { 100 | if ($id === null) { 101 | $id = self::VALUE_NULL; 102 | } 103 | $statusId = array_search($id, $this->_map); 104 | if ($statusId === false) { 105 | throw new Exception('Conversion to SimpleWorkflow failed : no value found for id = '.$id); 106 | } 107 | return ($statusId == self::VALUE_NULL ? null : $statusId); 108 | } 109 | 110 | /** 111 | * (non-PHPdoc) 112 | * @see IStatusIdConverter::toModelAttribute() 113 | */ 114 | public function toModelAttribute($id) 115 | { 116 | if ($id === null) { 117 | $id = self::VALUE_NULL; 118 | } 119 | 120 | if (! array_key_exists($id, $this->_map) ) { 121 | throw new Exception('Conversion from SimpleWorkflow failed : no key found for id = '.$id); 122 | } 123 | $value = $this->_map[$id]; 124 | return ($value === self::VALUE_NULL ? null : $value); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/base/TransitionObjectTest.php: -------------------------------------------------------------------------------- 1 | specify('create a transition instance with success', function () 20 | { 21 | $start = new Status([ 22 | 'id' => 'draft', 23 | 'workflowId' => 'workflow1' 24 | ]); 25 | 26 | $end = new Status([ 27 | 'id' => 'published', 28 | 'workflowId' => 'workflow1' 29 | ]); 30 | 31 | $tr = new Transition([ 32 | 'start' => $start, 33 | 'end' => $end 34 | ]); 35 | 36 | verify("start status id is 'draft'", $tr->getStartStatus()->getId())->equals('draft'); 37 | verify("end status id is 'published'", $tr->getEndStatus()->getId())->equals('published'); 38 | }); 39 | } 40 | 41 | public function testEmptyStartStatusFails() 42 | { 43 | $this->specify('create transition with NULL start status fails', function () 44 | { 45 | $this->expectException('yii\base\InvalidConfigException'); 46 | $this->expectExceptionMessage('missing start status'); 47 | new Transition([ 48 | 'start' => null, 49 | 'end' => new Status([ 50 | 'id' => 'published', 51 | 'workflowId' => 'workflow1' 52 | ]) 53 | ]); 54 | }); 55 | } 56 | public function testMissingStartStatusFails() 57 | { 58 | 59 | $this->specify('create transition with no start status provided fails ', function () 60 | { 61 | $this->expectException( 62 | 'yii\base\InvalidConfigException' 63 | ); 64 | $this->expectExceptionMessage( 65 | 'missing start status' 66 | ); 67 | new Transition([ 68 | 'end' => new Status([ 69 | 'id' => 'published', 70 | 'workflowId' => 'workflow1' 71 | ]) 72 | ]); 73 | }); 74 | } 75 | public function testNotStatusStartStatusFails() 76 | { 77 | 78 | $this->specify('create transition with start status not Status instance fails ', function () 79 | { 80 | $this->expectException( 81 | 'raoul2000\workflow\base\WorkflowException' 82 | ); 83 | $this->expectExceptionMessage( 84 | 'Start status object must implement raoul2000\workflow\base\StatusInterface' 85 | ); 86 | new Transition([ 87 | 'start' => new \stdClass() 88 | ]); 89 | }); 90 | } 91 | public function testMissingEndStatusFails() 92 | { 93 | $this->specify('create transition with no end status provided fails', function () 94 | { 95 | $this->expectException( 96 | 'yii\base\InvalidConfigException' 97 | ); 98 | $this->expectExceptionMessage( 99 | 'missing end status' 100 | ); 101 | new Transition([ 102 | 'start' => new Status([ 103 | 'id' => 'published', 104 | 'workflowId' => 'workflow1' 105 | ]) 106 | ]); 107 | }); 108 | } 109 | 110 | public function testEmptyEndStatusFails() 111 | { 112 | $this->specify('create transition with empty end status fails', function () 113 | { 114 | $this->expectException( 115 | 'yii\base\InvalidConfigException' 116 | ); 117 | $this->expectExceptionMessage( 118 | 'missing end status' 119 | ); 120 | new Transition([ 121 | 'start' => new Status([ 122 | 'id' => 'published', 123 | 'workflowId' => 'workflow1' 124 | ]), 125 | 'end' => null 126 | ]); 127 | }); 128 | } 129 | public function testNotStatusEndStatusFails() 130 | { 131 | 132 | $this->specify('create transition with end status not Status instance fails ', function () 133 | { 134 | $this->expectException( 135 | 'raoul2000\workflow\base\WorkflowException' 136 | ); 137 | $this->expectExceptionMessage( 138 | 'End status object must implement raoul2000\workflow\base\StatusInterface' 139 | ); 140 | new Transition([ 141 | 'start' => new Status([ 142 | 'id' => 'published', 143 | 'workflowId' => 'workflow1' 144 | ]), 145 | 'end' => new \stdClass() 146 | ]); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/events/BasicEventSequence.php: -------------------------------------------------------------------------------- 1 | $initalStatus, 29 | 'sender' => $sender 30 | ]; 31 | return [ 32 | 'before' => [ 33 | new WorkflowEvent( 34 | WorkflowEvent::beforeEnterWorkflow($initalStatus->getWorkflowId()), 35 | $config 36 | ), 37 | new WorkflowEvent( 38 | WorkflowEvent::beforeEnterStatus($initalStatus->getId()), 39 | $config 40 | ) 41 | ], 42 | 'after' => [ 43 | new WorkflowEvent( 44 | WorkflowEvent::afterEnterWorkflow($initalStatus->getWorkflowId()), 45 | $config 46 | ), 47 | new WorkflowEvent( 48 | WorkflowEvent::afterEnterStatus($initalStatus->getId()), 49 | $config 50 | ) 51 | ] 52 | ]; 53 | } 54 | 55 | /** 56 | * Produces the following sequence when a model leaves a workflow : 57 | * 58 | * - beforeLeaveStatus(statusID) 59 | * - beforeLeaveWorkflow(workflowID) 60 | * 61 | * - afterLeaveStatus(statusID) 62 | * - afterLeaveWorkflow(workflowID) 63 | * 64 | * @see IEventSequence::createLeaveWorkflowSequence() 65 | */ 66 | public function createLeaveWorkflowSequence($finalStatus, $sender) 67 | { 68 | $config = [ 69 | 'start' => $finalStatus, 70 | 'sender' => $sender 71 | ]; 72 | 73 | return [ 74 | 'before' => [ 75 | new WorkflowEvent( 76 | WorkflowEvent::beforeLeaveStatus($finalStatus->getId()), 77 | $config 78 | ), 79 | new WorkflowEvent( 80 | WorkflowEvent::beforeLeaveWorkflow($finalStatus->getWorkflowId()), 81 | $config 82 | ) 83 | ], 84 | 'after' => [ 85 | new WorkflowEvent( 86 | WorkflowEvent::afterLeaveStatus($finalStatus->getId()), 87 | $config 88 | ), 89 | new WorkflowEvent( 90 | WorkflowEvent::afterLeaveWorkflow($finalStatus->getWorkflowId()), 91 | $config 92 | ) 93 | ] 94 | ]; 95 | } 96 | 97 | /** 98 | * Produces the following sequence when a model changes from status A to status B: 99 | * 100 | * - beforeLeaveStatus(A) 101 | * - beforeChangeStatus(A,B) 102 | * - beforeEnterStatus(B) 103 | * 104 | * - afterLeaveStatus(A) 105 | * - afterChangeStatus(A,B) 106 | * - afterEnterStatus(B) 107 | * 108 | * @see IEventSequence::createChangeStatusSequence() 109 | */ 110 | public function createChangeStatusSequence($transition, $sender) 111 | { 112 | $config = [ 113 | 'start' => $transition->getStartStatus(), 114 | 'end' => $transition->getEndStatus(), 115 | 'transition' => $transition, 116 | 'sender' => $sender 117 | ]; 118 | return [ 119 | 'before' => [ 120 | new WorkflowEvent( 121 | WorkflowEvent::beforeLeaveStatus($transition->getStartStatus()->getId()), 122 | $config 123 | ), 124 | new WorkflowEvent( 125 | WorkflowEvent::beforeChangeStatus($transition->getStartStatus()->getId(), $transition->getEndStatus()->getId()), 126 | $config 127 | ), 128 | new WorkflowEvent( 129 | WorkflowEvent::beforeEnterStatus($transition->getEndStatus()->getId()), 130 | $config 131 | ) 132 | ], 133 | 'after' => [ 134 | new WorkflowEvent( 135 | WorkflowEvent::afterLeaveStatus($transition->getStartStatus()->getId()), 136 | $config 137 | ), 138 | new WorkflowEvent( 139 | WorkflowEvent::afterChangeStatus($transition->getStartStatus()->getId(), $transition->getEndStatus()->getId()), 140 | $config 141 | ), 142 | new WorkflowEvent( 143 | WorkflowEvent::afterEnterStatus($transition->getEndStatus()->getId()), 144 | $config 145 | ) 146 | ] 147 | ]; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/events/ChangeStatusExtendedEventTest.php: -------------------------------------------------------------------------------- 1 | eventsBefore = []; 26 | $this->eventsAfter = []; 27 | 28 | Yii::$app->set('workflowSource',[ 29 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 30 | 'definitionLoader' => [ 31 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 32 | 'namespace' => 'tests\codeception\unit\models' 33 | ] 34 | ]); 35 | 36 | Yii::$app->set('eventSequence',[ 37 | 'class'=> 'raoul2000\workflow\events\ExtendedEventSequence', 38 | ]); 39 | 40 | $this->model = new Item04(); 41 | $this->model->attachBehavior('workflow', [ 42 | 'class' => SimpleWorkflowBehavior::className() 43 | ]); 44 | } 45 | 46 | protected function tearDown() 47 | { 48 | $this->model->delete(); 49 | parent::tearDown(); 50 | } 51 | 52 | public function testChangeStatusEventOnSaveSuccess() 53 | { 54 | $this->model->on( 55 | WorkflowEvent::beforeEnterStatus(), 56 | function($event) { 57 | $this->eventsBefore[] = $event; 58 | } 59 | ); 60 | $this->model->on( 61 | WorkflowEvent::afterEnterStatus(), 62 | function($event) { 63 | $this->eventsAfter[] = $event; 64 | } 65 | ); 66 | verify('event handler handlers have been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 67 | 68 | $this->model->enterWorkflow(); 69 | verify('current status is set',$this->model->hasWorkflowStatus())->true(); 70 | 71 | // NOTE: Since Yii v2.0.14 it is possible to specify event name as a wildcard pattern 72 | // This causes handler to be called twice because the ExtendedEventSequence includes events name 73 | // that contains the '*' character : beforeEnterWorkflow{*}, beforeEnterStatus{*} 74 | // 75 | expect('event handler handlers have been called', count($this->eventsBefore) == 2 && count($this->eventsAfter) == 2)->true(); 76 | 77 | $this->model->status = 'Item04Workflow/B'; 78 | verify('save succeeds',$this->model->save())->true(); 79 | 80 | expect('model has changed to status B',$this->model->getWorkflowStatus()->getId())->equals('Item04Workflow/B'); 81 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(4); 82 | expect('afterChangeStatus handler has been called',count($this->eventsAfter))->equals(4); 83 | } 84 | 85 | public function testChangeStatusEventOnSaveFails() 86 | { 87 | $this->model->on( 88 | WorkflowEvent::beforeChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 89 | function($event) { 90 | $this->eventsBefore[] = $event; 91 | $event->isValid = false; 92 | } 93 | ); 94 | $this->model->on( 95 | WorkflowEvent::afterChangeStatus('Item04Workflow/A', 'Item04Workflow/B'), 96 | function($event) { 97 | $this->eventsAfter[] = $event; 98 | } 99 | ); 100 | $this->model->enterWorkflow(); 101 | verify('current status is set',$this->model->hasWorkflowStatus())->true(); 102 | verify('event handlers have never been called', count($this->eventsBefore) == 0 && count($this->eventsAfter) == 0)->true(); 103 | 104 | $this->model->status = 'Item04Workflow/B'; 105 | verify('save fails',$this->model->save())->false(); 106 | 107 | expect('model has not changed status',$this->model->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 108 | expect('beforeChangeStatus handler has been called',count($this->eventsBefore))->equals(1); 109 | expect('afterChangeStatus handler has not been called',count($this->eventsAfter))->equals(0); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/events/StopOnFirstInvalidEventTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 26 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 27 | 'definitionLoader' => [ 28 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 29 | 'namespace' => 'tests\codeception\unit\models' 30 | ] 31 | ]); 32 | } 33 | 34 | protected function tearDown() 35 | { 36 | parent::tearDown(); 37 | } 38 | 39 | public function invalidateEvent1($event) { 40 | $event->invalidate('err_message_1'); 41 | } 42 | public function invalidateEvent2($event) { 43 | $event->invalidate('err_message_2'); 44 | } 45 | 46 | public function testStopOnFirstInvalidEventTrue() 47 | { 48 | // prepare item instance 49 | 50 | $item = new Item00(); 51 | $item->attachBehavior('workflowBehavior', [ 52 | 'class' => SimpleWorkflowBehavior::className(), 53 | 'defaultWorkflowId' => 'Item04Workflow', 54 | 'propagateErrorsToModel' => true, 55 | 'stopOnFirstInvalidEvent' => true 56 | ]); 57 | 58 | $item->on(WorkflowEvent::beforeLeaveStatus('Item04Workflow/A'),[$this, 'invalidateEvent1']); 59 | $item->on(WorkflowEvent::beforeEnterStatus('Item04Workflow/B'),[$this, 'invalidateEvent2']); 60 | 61 | verify('stopOnFirstInvalidEvent is true', $item->stopOnFirstInvalidEvent)->true(); 62 | 63 | $item->sendToStatus('Item04Workflow/A'); 64 | 65 | verify('item is in status A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 66 | verify('item has no error', $item->hasErrors())->false(); 67 | 68 | // send to B 69 | 70 | $item->sendToStatus('B'); 71 | 72 | expect('status is still A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 73 | expect('item has error', $item->hasErrors())->true(); 74 | expect('1 error message is set for attribute "status"', count($item->getErrors('status')) )->equals(1); 75 | 76 | $errorMessages = $item->getErrors('status'); 77 | 78 | expect('First error message is "err_message_1" ',$errorMessages[0] )->equals("err_message_1"); 79 | } 80 | 81 | public function testStopOnFirstInvalidEventFalse() 82 | { 83 | // prepare item instance 84 | 85 | $item = new Item00(); 86 | $item->attachBehavior('workflowBehavior', [ 87 | 'class' => SimpleWorkflowBehavior::className(), 88 | 'defaultWorkflowId' => 'Item04Workflow', 89 | 'propagateErrorsToModel' => true, 90 | 'stopOnFirstInvalidEvent' => false 91 | ]); 92 | 93 | $item->on(WorkflowEvent::beforeLeaveStatus('Item04Workflow/A'),[$this, 'invalidateEvent1']); 94 | $item->on(WorkflowEvent::beforeEnterStatus('Item04Workflow/B'),[$this, 'invalidateEvent2']); 95 | 96 | verify('stopOnFirstInvalidEvent is true', $item->stopOnFirstInvalidEvent)->false(); 97 | 98 | $item->sendToStatus('Item04Workflow/A'); 99 | 100 | verify('item is in status A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 101 | verify('item has no error', $item->hasErrors())->false(); 102 | 103 | // send to B 104 | 105 | $item->sendToStatus('B'); 106 | 107 | expect('status is still A', $item->getWorkflowStatus()->getId())->equals('Item04Workflow/A'); 108 | expect('item has error', $item->hasErrors())->true(); 109 | expect('1 error message is set for attribute "status"', count($item->getErrors('status')) )->equals(2); 110 | 111 | $errorMessages = $item->getErrors('status'); 112 | 113 | expect('First error message is "err_message_1" ',$errorMessages[0] )->equals("err_message_1"); 114 | expect('Second error message is "err_message_2" ',$errorMessages[1] )->equals("err_message_2"); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/workflow-03.graphml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 1 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 2 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 3 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /guide/docs/concept-source.md: -------------------------------------------------------------------------------- 1 | # Workflow Source 2 | 3 | The *Workflow Source* is a Yii2 component dedicated to read the persistent representation of a workflow and provide on demand to the *SimpleWorkflowBehavior*, its memory representation in terms of PHP objects. 4 | 5 | The main Workflow Source component included in the *SimpleWorkflow* package is `raoul2000\workflow\source\file\WorkflowFileSource`. It is designed to process workflow definition stored in a file as a regular PHP Array, a PHP class definition or a Graphml file ( for a detail description, refer to [Workflow File Source Component](source-file.md)). 6 | 7 | Note that it is possible that in the future, other workflow source component are provided like for instance a *WorkflowDbSource* that would read from a database. 8 | 9 | ## About Workflow Objects 10 | 11 | The *SimpleWorkflow* manipulates objects to manage workflows. There are 3 basic types of objects that you will meet sooner or later. They are all part of the `raoul2000\workflow\base` namespace: 12 | 13 | - `Status` : implements a status in a workflow 14 | - `Transition` : implemented a directed transition between 2 statuses 15 | - `Workflow` : implement a collection of statuses and transitions 16 | 17 | The main purpose of a Workflow Source component is to turn a workflow definition into a set of Status, Transition and Workflow objects. 18 | 19 | 20 | ## Component registration 21 | 22 | When the *SimpleWorkflowBehavior* is initialized, it tries to get a reference to the *Workflow Source Component* to use. By default this component is assumed to have the id **workflowSource**. If no such component is available, the *SimpleWorkflowBehavior* will **create one**, with the type `raoul2000\workflow\source\file\WorkflowFileSource` (default) and registers it in the Yii2 application, so to make it available to other instances of *SimpleWorkflowBehavior*. 23 | 24 | This implies that, unless specified otherwise, by default, all *SimpleWorkflowBehavior* are sharing **the same Workflow Source component**. 25 | 26 | If you're not familiar with "application Component", please refer to the "[Definitive Guide to Yii2](http://www.yiiframework.com/doc-2.0/guide-structure-application-components.html)" 27 | 28 | To summarize : 29 | 30 | - **workflowSource** : default Id of the workflow source component used by the *SimpleWorkflowBehavior* 31 | - **\raoul2000\workflow\source\file\WorkflowFileSource** : default workflow source component type 32 | 33 | If for instance you want to use another Workflow Source Component instead of the default one, you must configure it like you would do for any other Yii2 component and use the expected default Id. 34 | 35 | ```php 36 | $config = [ 37 | // .... 38 | 'components' => [ 39 | 'workflowSource' => [ 40 | 'class' => '\my\own\component\SuperCoolWorkflowSource', 41 | ] 42 | // ... 43 | ``` 44 | With this configuration, all *SimpleWorkflowBehavior* are going to use your *SuperCoolWorkflowSource* to get Status, Transition and Workflow objects. 45 | 46 | Another option is to mix Workflow Source Components and for instance use the default one with all models except for a particular one. To achieve this, simply configure your custom Workflow Source Component under a custom Id. Let's see that on an example: 47 | 48 | > Let's assume that you have developed a super cool workflow source component, able to read workflow definition from a satellite data stream, live 49 | from deep outer space (if you did so, pull requests are welcome !!). You want to use this source only for the *SpaceShip* model in your app, leaving all other models 50 | with the default source (PHP class). 51 | 52 | To do so, first declare your workflow source as a Yii2 component : 53 | 54 | ```php 55 | $config = [ 56 | // .... 57 | 'components' => [ 58 | // declare your source component under a custom id 59 | 'mySpaceSource' => [ 60 | 'class' => '\my\own\component\AlienWorkflowSource', 61 | ] 62 | // ... 63 | ``` 64 | 65 | And then use the component *mySpaceSource* as Workflow source for the *SpaceShip* model only : 66 | 67 | ```php 68 | namespace app\models; 69 | class SpaceShip extends \yii\db\ActiveRecord 70 | { 71 | public function behaviors() 72 | { 73 | return [ 74 | [ 75 | 'class' => \raoul2000\workflow\base\SimpleWorkflowBehavior::className(), 76 | // SpaceShip will use a specific Workflow Source Component 77 | // All other models are using the default one 78 | 'source' => 'mySpaceSource' 79 | ] 80 | ]; 81 | } 82 | } 83 | ``` 84 | 85 | 86 | ## Implementing Your Own Workflow source 87 | 88 | You can create your own Workflow Source Component by implementing the `\raoul2000\workflow\source\IWorkflowSource` interface. 89 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/InitStatusTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 20 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 21 | 'definitionLoader' => [ 22 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 23 | 'namespace' => 'tests\codeception\unit\models' 24 | ] 25 | ]); 26 | 27 | } 28 | 29 | protected function tearDown() 30 | { 31 | parent::tearDown(); 32 | } 33 | 34 | public function testInitStatusOnAttachSuccess() 35 | { 36 | $this->specify('current status initialization is ok', function() { 37 | 38 | $model = new Item01(); 39 | $model->status = 'Workflow1/A'; 40 | $model->attachBehavior('workflow', [ 41 | 'class' => SimpleWorkflowBehavior::className(), 42 | 'defaultWorkflowId' => 'Workflow1' 43 | ]); 44 | 45 | verify('current status is set', $model->getWorkflowStatus() != null)->true(); 46 | verify('current status is set (use attribute notation)', $model->workflowStatus != null)->true(); 47 | verify('current status is Status instance', get_class($model->getWorkflowStatus()))->equals('raoul2000\workflow\base\Status'); 48 | }); 49 | } 50 | 51 | public function testInitStatusOnAttachFails() 52 | { 53 | $this->specify('status initialisation fails when status not found', function(){ 54 | $model = new Item01(); 55 | $model->status = 'Workflow1/X'; 56 | $this->expectException( 57 | 'raoul2000\workflow\base\WorkflowException' 58 | ); 59 | $this->expectExceptionMessage( 60 | 'No status found with id Workflow1/X' 61 | ); 62 | $model->attachBehavior('workflow', [ 63 | 'class' => SimpleWorkflowBehavior::className(), 64 | 'defaultWorkflowId' => 'Workflow1' 65 | ]); 66 | }); 67 | } 68 | 69 | public function testSaveModelNoChangeSuccess() 70 | { 71 | $this->specify('a model can be saved with status not set', function() { 72 | 73 | $model = new Item01(); 74 | $model->attachBehavior('workflow', [ 75 | 'class' => SimpleWorkflowBehavior::className(), 76 | 'defaultWorkflowId' => 'Workflow1' 77 | ]); 78 | expect('model is saved', $model->save())->true(); 79 | }); 80 | } 81 | 82 | public function testInitStatusAfterFindSuccess() 83 | { 84 | $this->specify('status initialisation when reading model from db (after find)', function(){ 85 | 86 | $model = new Item01(); 87 | $model->detachBehavior('workflow'); 88 | $model->id = 1; 89 | $model->name = 'name'; 90 | $model->status = 'Workflow1/B'; 91 | $model->save(false); 92 | 93 | $model = Item01::findOne(1); 94 | 95 | $model->attachBehavior('workflow', [ 96 | 'class' => SimpleWorkflowBehavior::className(), 97 | 'defaultWorkflowId' => 'Workflow1' 98 | ]); 99 | 100 | verify('current model status is "B"',$model->getWorkflowStatus()->getId())->equals('Workflow1/B'); 101 | }); 102 | } 103 | 104 | public function testInitStatusAfterFindFails() 105 | { 106 | $this->specify('status initialisation success when saving model', function(){ 107 | 108 | $model = new Item01(); 109 | $model->detachBehavior('workflow'); 110 | $model->id = 1; 111 | $model->name = 'name'; 112 | $model->status = 'Workflow1/X'; 113 | $model->save(false); 114 | 115 | $this->expectException( 116 | 'raoul2000\workflow\base\WorkflowException' 117 | ); 118 | $this->expectExceptionMessage( 119 | 'No status found with id Workflow1/X' 120 | ); 121 | 122 | $model = Item01::findOne(1); 123 | 124 | }); 125 | } 126 | 127 | // public function testAutoInsertSuccess() 128 | // { 129 | // $this->specify('autoInsert feature works ok', function() { 130 | 131 | // $model = new Item01(); 132 | // $model->attachBehavior('workflow', [ 133 | // 'class' => SimpleWorkflowBehavior::className(), 134 | // 'defaultWorkflowId' => 'Workflow1', 135 | // 'autoInsert' => true 136 | // ]); 137 | 138 | // expect('', $model->hasWorkflowStatus())->false(); 139 | // expect_that(' status attribute is not null', $model->status != null); 140 | // }); 141 | // } 142 | } 143 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/MinimalArrayParserTest.php: -------------------------------------------------------------------------------- 1 | set('parser',[ 23 | 'class' => MinimalArrayParser::className(), 24 | ]); 25 | 26 | $this->src = new WorkflowFileSource(); 27 | } 28 | 29 | /** 30 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 31 | * @expectedExceptionMessage Workflow definition must be provided as an array 32 | */ 33 | public function testParseInvalidType() 34 | { 35 | Yii::$app->parser->parse('WID',null,$this->src); 36 | } 37 | /** 38 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 39 | * @expectedExceptionMessage Missing argument : workflow Id 40 | */ 41 | public function testMissingWorkflowId() 42 | { 43 | Yii::$app->parser->parse('',null,$this->src); 44 | } 45 | /** 46 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 47 | * @expectedExceptionMessage Workflow definition must be provided as associative array 48 | */ 49 | public function testNonAssociativeArray1() 50 | { 51 | Yii::$app->parser->parse('WID',['a'],$this->src); 52 | } 53 | /** 54 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 55 | * @expectedExceptionMessage Workflow definition must be provided as associative array 56 | */ 57 | public function testNonAssociativeArray2() 58 | { 59 | Yii::$app->parser->parse('WID',['a'=> [], 'b'],$this->src); 60 | } 61 | /** 62 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 63 | * @expectedExceptionMessage Status must belong to workflow : EXT/a 64 | */ 65 | public function testExternalStatusError() 66 | { 67 | Yii::$app->parser->parse('WID',[ 68 | 'EXT/a' => [], 69 | 'b' => [] 70 | ],$this->src); 71 | } 72 | 73 | /** 74 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 75 | * @expectedExceptionMessage Associative array not supported (status : WID/a) 76 | */ 77 | public function testEndStatusAssociativeError() 78 | { 79 | Yii::$app->parser->parse('WID',[ 80 | 'a' => ['b' => 'value'], 81 | 'b' => [] 82 | ],$this->src); 83 | } 84 | /** 85 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 86 | * @expectedExceptionMessage End status list must be an array for status : WID/a 87 | */ 88 | public function testEndStatusTypeNotSupported() 89 | { 90 | Yii::$app->parser->parse('WID',[ 91 | 'a' => 4, 92 | 'b' => [] 93 | ],$this->src); 94 | } 95 | 96 | public function testParseArraySuccess() 97 | { 98 | $workflow = Yii::$app->parser->parse('WID',[ 99 | 'a' => ['b','c'], 100 | 'b' => ['a'], 101 | 'c' => [] 102 | ],$this->src); 103 | 104 | verify('status "a" is set ', array_key_exists('WID/a',($workflow['status'])) )->true(); 105 | verify('status "b" is set ', array_key_exists('WID/b',($workflow['status'])) )->true(); 106 | verify('status "c" is set ', array_key_exists('WID/c',($workflow['status'])) )->true(); 107 | 108 | verify('status transitions from "a" are set ', $workflow['status']['WID/a']['transition'])->equals(['WID/b'=>[],'WID/c'=>[]]); 109 | verify('status transitions from "b" are set ', $workflow['status']['WID/b']['transition'])->equals(['WID/a'=>[]]); 110 | verify('status transitions from "a" are set ', $workflow['status']['WID/c'])->equals(null); 111 | } 112 | 113 | public function testParseStringSuccess() 114 | { 115 | $workflow = Yii::$app->parser->parse('WID',[ 116 | 'a' => 'b,c', 117 | 'b' => 'a', 118 | 'c' => [] 119 | ],$this->src); 120 | 121 | verify('status "a" is set ', array_key_exists('WID/a',($workflow['status'])) )->true(); 122 | verify('status "b" is set ', array_key_exists('WID/b',($workflow['status'])) )->true(); 123 | verify('status "c" is set ', array_key_exists('WID/c',($workflow['status'])) )->true(); 124 | 125 | verify('status transitions from "a" are set ', $workflow['status']['WID/a']['transition'])->equals(['WID/b'=>[],'WID/c'=>[]]); 126 | verify('status transitions from "b" are set ', $workflow['status']['WID/b']['transition'])->equals(['WID/a'=>[]]); 127 | verify('status transitions from "a" are set ', $workflow['status']['WID/c'])->equals(null); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/source/file/MinimalArrayParser.php: -------------------------------------------------------------------------------- 1 | 18 | * [ 19 | * 'draft' => ['ready', 'delivered'], 20 | * 'ready' => ['draft', 'delivered'], 21 | * 'delivered' => ['payed', 'archived'], 22 | * 'payed' => ['archived'], 23 | * 'archived' => [] 24 | * ] 25 | * 26 | * 27 | * You can also use a comma separated list of status for the end status list instead of an array. 28 | * For example : 29 | *
 30 |  * [
 31 |  *	'draft'     => 'ready, delivered',
 32 |  *	'ready'     => 'draft, delivered',
 33 |  *	'delivered' => 'payed, archived',
 34 |  *	'payed'     => 'archived',
 35 |  *	'archived'  => []
 36 |  * ]
 37 |  * 
38 | */ 39 | class MinimalArrayParser extends WorkflowArrayParser { 40 | 41 | 42 | 43 | /** 44 | * Parse a workflow defined as a PHP Array. 45 | * 46 | * The workflow definition passed as argument is turned into an array that can be 47 | * used by the WorkflowFileSource components. 48 | * 49 | * @param string $wId 50 | * @param array $definition 51 | * @param raoul2000\workflow\source\file\WorkflowFileSource $source 52 | * @return array The parse workflow array definition 53 | * @throws WorkflowValidationException 54 | */ 55 | public function parse($wId, $definition, $source) { 56 | 57 | if ( empty($wId)) { 58 | throw new WorkflowValidationException("Missing argument : workflow Id"); 59 | } 60 | if ( ! \is_array($definition)) { 61 | throw new WorkflowValidationException("Workflow definition must be provided as an array"); 62 | } 63 | 64 | if ( ! ArrayHelper::isAssociative($definition)) { 65 | throw new WorkflowValidationException("Workflow definition must be provided as associative array"); 66 | } 67 | 68 | $initialStatusId = null; 69 | $normalized = []; 70 | $startStatusIdIndex = []; 71 | $endStatusIdIndex = []; 72 | 73 | foreach($definition as $id => $targetStatusList) { 74 | list($workflowId, $statusId) = $source->parseStatusId($id, $wId); 75 | $absoluteStatusId = $workflowId . WorkflowFileSource::SEPARATOR_STATUS_NAME .$statusId; 76 | if ( $workflowId != $wId) { 77 | throw new WorkflowValidationException('Status must belong to workflow : ' . $absoluteStatusId); 78 | } 79 | if (count($normalized) == 0) { 80 | $initialStatusId = $absoluteStatusId; 81 | $normalized['initialStatusId'] = $initialStatusId; 82 | $normalized[WorkflowFileSource::KEY_NODES] = []; 83 | } 84 | $startStatusIdIndex[] = $absoluteStatusId; 85 | $endStatusIds = []; 86 | if ( \is_string($targetStatusList)) { 87 | $ids = array_map('trim', explode(',', $targetStatusList)); 88 | $endStatusIds = $this->normalizeStatusIds($ids, $wId, $source); 89 | }elseif ( \is_array($targetStatusList)) { 90 | if( ArrayHelper::isAssociative($targetStatusList,false) ){ 91 | throw new WorkflowValidationException("Associative array not supported (status : $absoluteStatusId)"); 92 | } 93 | $endStatusIds = $this->normalizeStatusIds($targetStatusList, $wId, $source); 94 | }elseif ( $targetStatusList === null ) { 95 | $endStatusIds = []; 96 | }else { 97 | throw new WorkflowValidationException('End status list must be an array for status : ' . $absoluteStatusId); 98 | } 99 | 100 | if ( count($endStatusIds)) { 101 | $normalized[WorkflowFileSource::KEY_NODES][$absoluteStatusId] = ['transition' => array_fill_keys($endStatusIds,[])]; 102 | $endStatusIdIndex = \array_merge($endStatusIdIndex, $endStatusIds); 103 | } else { 104 | $normalized[WorkflowFileSource::KEY_NODES][$absoluteStatusId] = null; 105 | } 106 | } 107 | 108 | $this->validate($wId, $source, $initialStatusId, $startStatusIdIndex, $endStatusIdIndex); 109 | 110 | return $normalized; 111 | } 112 | 113 | 114 | /** 115 | * 116 | * @param array $ids 117 | * @param string $workflowId 118 | */ 119 | private function normalizeStatusIds($ids, $workflowId, $source) 120 | { 121 | $normalizedIds = []; 122 | foreach ($ids as $id) { 123 | $pieces = $source->parseStatusId($id, $workflowId); 124 | $normalizedIds[] = \implode(WorkflowFileSource::SEPARATOR_STATUS_NAME, $pieces); 125 | } 126 | return $normalizedIds; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/behavior/StatusIdConvertionTest.php: -------------------------------------------------------------------------------- 1 | set('workflowSource',[ 24 | 'class'=> 'raoul2000\workflow\source\file\WorkflowFileSource', 25 | 'definitionLoader' => [ 26 | 'class' => 'raoul2000\workflow\source\file\PhpClassLoader', 27 | 'namespace' => 'tests\codeception\unit\models' 28 | ] 29 | ]); 30 | 31 | Yii::$app->set('converter',[ 32 | 'class'=> 'raoul2000\workflow\base\StatusIdConverter', 33 | 'map' => [ 34 | 'Item04Workflow/A' => '1', 35 | 'Item04Workflow/C' => '2', 36 | StatusIdConverter::VALUE_NULL => '55', 37 | 'Item04Workflow/B' => StatusIdConverter::VALUE_NULL 38 | ] 39 | ]); 40 | } 41 | 42 | public function testConvertionOnAttachSuccess() 43 | { 44 | $item = new Item04(); 45 | $item->attachBehavior('workflow',[ 46 | 'class' => SimpleWorkflowBehavior::className(), 47 | 'statusConverter' => 'converter' 48 | ]); 49 | $this->specify('on attach, initialize status and convert NULL to status ID', function() use ($item) { 50 | $this->assertEquals('Item04Workflow/B', $item->getWorkflowStatus()->getId()); 51 | $this->assertTrue($item->getWorkflow()->getId() == 'Item04Workflow'); 52 | $this->assertEquals(null, $item->status); 53 | }); 54 | } 55 | 56 | public function testConvertionOnAttachSuccess2() 57 | { 58 | $converter = new StatusIdConverter([ 59 | 'map' => [ 60 | 'Item04Workflow/A' => '1', 61 | 'Item04Workflow/C' => '2', 62 | StatusIdConverter::VALUE_NULL => '55', 63 | 'Item04Workflow/B' => StatusIdConverter::VALUE_NULL 64 | ] 65 | ]); 66 | 67 | $item = new Item04(); 68 | $item->attachBehavior('workflow',[ 69 | 'class' => SimpleWorkflowBehavior::className(), 70 | 'statusConverter' => $converter 71 | ]); 72 | $this->specify('on attach, initialize status and convert NULL to status ID', function() use ($item) { 73 | $this->assertEquals('Item04Workflow/B', $item->getWorkflowStatus()->getId()); 74 | $this->assertTrue($item->getWorkflow()->getId() == 'Item04Workflow'); 75 | $this->assertEquals(null, $item->status); 76 | }); 77 | } 78 | 79 | public function testConvertionOnAttachFails() 80 | { 81 | $item = new Item04(); 82 | $this->expectException( 83 | 'yii\base\InvalidConfigException' 84 | ); 85 | $this->expectExceptionMessage( 86 | 'Unknown component ID: not_found_component' 87 | ); 88 | $item->attachBehavior('workflow',[ 89 | 'class' => SimpleWorkflowBehavior::className(), 90 | 'statusConverter' => 'not_found_component' 91 | ]); 92 | } 93 | public function testConvertionOnChangeStatus() 94 | { 95 | $item = new Item04(); 96 | $item->attachBehavior('workflow',[ 97 | 'class' => SimpleWorkflowBehavior::className(), 98 | 'statusConverter' => 'converter' 99 | ]); 100 | 101 | $this->specify('convertion is done on change status when setting the model attribute', function() use ($item) { 102 | $item->status = 1; 103 | verify($item->save())->true(); 104 | $this->assertEquals('Item04Workflow/A', $item->getWorkflowStatus()->getId()); 105 | }); 106 | 107 | $this->specify('convertion is done on change status when using SendToStatus()', function() use ($item) { 108 | $item->sendToStatus('Item04Workflow/B'); 109 | 110 | $this->assertEquals('Item04Workflow/B', $item->getWorkflowStatus()->getId()); 111 | $this->assertEquals(null, $item->status); 112 | }); 113 | } 114 | 115 | public function testConvertionOnLeaveWorkflow() 116 | { 117 | $item = new Item04(); 118 | $item->attachBehavior('workflow',[ 119 | 'class' => SimpleWorkflowBehavior::className(), 120 | 'statusConverter' => 'converter' 121 | ]); 122 | 123 | $this->assertEquals(null, $item->status); 124 | $this->assertEquals('Item04Workflow/B', $item->getWorkflowStatus()->getId()); 125 | 126 | $this->specify('convertion is done when leaving workflow', function() use ($item) { 127 | $item->sendToStatus(null); 128 | expect('item to not be in a workflow',$item->getWorkflow())->equals(null); 129 | expect('item to not have status',$item->hasWorkflowStatus())->false(); 130 | expect('status attribut to be converted into 55', $item->status)->equals(55); 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/codeception/unit/models/workflow-02.graphml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | start 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ready 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | orphan 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/source/file/StatusTest.php: -------------------------------------------------------------------------------- 1 | src = new WorkflowFileSource(); 28 | } 29 | 30 | /** 31 | * @expectedException raoul2000\workflow\base\WorkflowValidationException 32 | * @expectedExceptionMessageRegExp #No status definition found# 33 | */ 34 | public function testStatusNotFoundSuccess() 35 | { 36 | $src = new WorkflowFileSource(); 37 | $src->addWorkflowDefinition('wid', [ 38 | 'initialStatusId' => 'A', 39 | 'status' => null 40 | ]); 41 | 42 | $this->specify('status is not found', function () use ($src) { 43 | $status = $src->getStatus('wid/A'); 44 | verify('a Workflow instance is returned', $status )->equals(null); 45 | }); 46 | } 47 | 48 | public function testLoadStatusSuccess() 49 | { 50 | $this->src->addWorkflowDefinition('wid', [ 51 | 'initialStatusId' => 'A', 52 | 'status' => [ 53 | 'A' => [ 54 | 'label' => 'label A' 55 | ], 56 | 'B' => [] 57 | ] 58 | ]); 59 | $this->specify('status can be obtained',function() { 60 | $w = $this->src->getWorkflow('wid'); 61 | verify('non null workflow instance is returned', $w != null)->true(); 62 | 63 | verify('workflow contains status A', $this->src->getStatus('wid/A') != null)->true(); 64 | 65 | verify('initial status is A ', $w->getInitialStatusId())->equals('wid/A'); 66 | 67 | 68 | verify('status A has correct id', $this->src->getStatus('wid/A')->getId() )->equals('wid/A'); 69 | verify('status A has correct label', $this->src->getStatus('wid/A')->getLabel() )->equals('label A'); 70 | 71 | verify('workflow contains status B', $this->src->getStatus('wid/B') != null)->true(); 72 | verify('status B has correct id', $this->src->getStatus('wid/B')->getId() )->equals('wid/B'); 73 | verify('status B has default label', $this->src->getStatus('wid/B')->getLabel() )->equals('B'); 74 | 75 | //verify('workflow does not contains status C', $this->src->getStatus('wid/C') == null)->true(); 76 | }); 77 | } 78 | 79 | public function testAccessRelatedWorkflowObjects() 80 | { 81 | $this->src->addWorkflowDefinition('wid', [ 82 | 'initialStatusId' => 'A', 83 | 'status' => [ 84 | 'A' => [ 85 | 'transition' => 'B', 86 | 'label' => 'label A' 87 | ], 88 | 'B' => [] 89 | ] 90 | ]); 91 | 92 | $this->specify('isInitialStatus is ok',function() { 93 | 94 | $a = $this->src->getStatus('wid/A'); 95 | expect($a->isInitialStatus())->true(); 96 | 97 | $a = $this->src->getStatus('wid/B'); 98 | expect($a->isInitialStatus())->false(); 99 | }); 100 | 101 | $this->specify('parent workflow can be obtained',function() { 102 | $a = $this->src->getStatus('wid/A'); 103 | expect($a->getWorkflow()->getId())->equals('wid'); 104 | $a = $this->src->getStatus('wid/B'); 105 | expect($a->getWorkflow()->getId())->equals('wid'); 106 | }); 107 | 108 | $this->specify('transitions can be obtained',function() { 109 | $a = $this->src->getStatus('wid/A'); 110 | expect(count($a->getTransitions()))->equals(1); 111 | }); 112 | 113 | } 114 | 115 | public function testLoadStatusSuccess2() 116 | { 117 | $this->src->addWorkflowDefinition('wid', [ 118 | 'initialStatusId' => 'A', 119 | 'status' => [ 120 | 'A' => null 121 | ] 122 | ]); 123 | $this->specify('a null status definition is not allowed',function() { 124 | $w = $this->src->getWorkflow('wid'); 125 | verify('non null workflow instance is returned', $w != null)->true(); 126 | verify('status A cannot be loaded', $this->src->getStatus('wid/A') !== null)->true(); 127 | }); 128 | } 129 | public function testStatusCached() 130 | { 131 | $this->src->addWorkflowDefinition('wid', [ 132 | 'initialStatusId' => 'A', 133 | 'status' => [ 134 | 'A' => [] 135 | ] 136 | ]); 137 | 138 | $this->specify('status are loaded once',function() { 139 | $this->src->getWorkflow('wid'); 140 | verify('status instances are the same', spl_object_hash($this->src->getStatus('wid/A')) )->equals(spl_object_hash($this->src->getStatus('wid/A'))); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/base/WorkflowObjectTest.php: -------------------------------------------------------------------------------- 1 | specify('create a workflow instance', function () { 22 | $w = new Workflow([ 23 | 'id' => 'workflow1', 24 | 'initialStatusId' => 'draft' 25 | ]); 26 | expect("workflow id should be 'workflow1'", $w->getId() == 'workflow1' )->true(); 27 | expect("initial status id should be 'draft'", $w->getInitialStatusId() == 'draft' )->true(); 28 | }); 29 | } 30 | 31 | public function testMissingIdFails() 32 | { 33 | $this->specify('create a workflow instance with no id', function () { 34 | $this->expectException( 35 | 'yii\base\InvalidConfigException' 36 | ); 37 | $this->expectExceptionMessage( 38 | 'missing workflow id' 39 | ); 40 | new Workflow([ 41 | 'initialStatusId' => 'draft' 42 | ]); 43 | }); 44 | } 45 | 46 | public function testEmptyIdFails() 47 | { 48 | $this->specify('create a workflow instance with invalid id', function () { 49 | $this->expectException( 50 | 'yii\base\InvalidConfigException' 51 | ); 52 | $this->expectExceptionMessage( 53 | 'missing workflow id' 54 | ); 55 | new Workflow([ 56 | 'id' => null, 57 | 'initialStatusId' => 'draft' 58 | ]); 59 | }); 60 | } 61 | 62 | public function testMissingInitialStatusIdFails() 63 | { 64 | $this->specify('create a workflow instance with no initial status id', function () { 65 | $this->expectException( 66 | 'yii\base\InvalidConfigException' 67 | ); 68 | $this->expectExceptionMessage( 69 | 'missing initial status id' 70 | ); 71 | new Workflow([ 72 | 'id' => 'workflow1' 73 | ]); 74 | }); 75 | } 76 | public function testEmptyInitialStatusIdFails() 77 | { 78 | $this->specify('create a workflow instance with empty initial status id', function () { 79 | $this->expectException( 80 | 'yii\base\InvalidConfigException' 81 | ); 82 | $this->expectExceptionMessage( 83 | 'missing initial status id' 84 | ); 85 | new Workflow([ 86 | 'id' => 'workflow1', 87 | 'initialStatusId' => null 88 | ]); 89 | }); 90 | } 91 | 92 | public function testAccessorFails() 93 | { 94 | // creating a Workflow with 'new' will not allow to use some accessors 95 | 96 | $w = new Workflow([ 97 | 'id' => 'wid', 98 | 'initialStatusId' => 'A' 99 | ]); 100 | 101 | $this->specify('fails to get initial status if no source component is available', function () use ($w) { 102 | $this->expectException( 103 | 'raoul2000\workflow\base\WorkflowException' 104 | ); 105 | $this->expectExceptionMessage( 106 | 'no workflow source component available' 107 | ); 108 | $w->getInitialStatus(); 109 | }); 110 | 111 | $this->specify('fails to get all statues if no source component is available', function () use ($w) { 112 | $this->expectException( 113 | 'raoul2000\workflow\base\WorkflowException' 114 | ); 115 | $this->expectExceptionMessage( 116 | 'no workflow source component available' 117 | ); 118 | $w->getAllStatuses(); 119 | }); 120 | } 121 | 122 | public function testWorkflowAccessorSuccess() 123 | { 124 | $src = new WorkflowFileSource(); 125 | $src->addWorkflowDefinition('wid', [ 126 | 'initialStatusId' => 'A', 127 | 'status' => [ 128 | 'A' => [ 129 | 'label' => 'label A', 130 | 'transition' => ['B','C'] 131 | ], 132 | 'B' => [], 133 | 'C' => [] 134 | ] 135 | ]); 136 | $w = $src->getWorkflow('wid'); 137 | verify_that($w != null); 138 | 139 | $this->specify('initial status can be obtained through workflow',function() use($w) { 140 | 141 | expect_that($w->getInitialStatus() instanceof StatusInterface); 142 | expect_that($w->getInitialStatus()->getId() == $w->getInitialStatusId()); 143 | 144 | }); 145 | 146 | $this->specify('all statuses can be obtained through workflow',function() use($w) { 147 | 148 | $statuses = $w->getAllStatuses(); 149 | 150 | expect_that(is_array($statuses) && count($statuses) == 3); 151 | expect_that($statuses['wid/A'] instanceof StatusInterface ); 152 | expect_that($statuses['wid/B'] instanceof StatusInterface ); 153 | expect_that($statuses['wid/C'] instanceof StatusInterface ); 154 | }); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/codeception/unit/workflow/base/StatusIdConverterTest.php: -------------------------------------------------------------------------------- 1 | specify('a map parameter must be provided', function(){ 22 | $this->assertThrowsWithMessage( 'yii\base\InvalidConfigException' ,'missing map', function() { 23 | Yii::createObject(['class'=> 'raoul2000\workflow\base\StatusIdConverter']); 24 | }); 25 | }); 26 | 27 | $this->specify(' the map parameter must be an array', function() { 28 | $this->assertThrowsWithMessage( 'yii\base\InvalidConfigException', 'The map must be an array', function() { 29 | Yii::createObject(['class'=> 'raoul2000\workflow\base\StatusIdConverter', 'map' => 'string']); 30 | }); 31 | }); 32 | 33 | $this->specify(' the map parameter must be a non empty array', function() { 34 | $this->assertThrowsWithMessage('yii\base\InvalidConfigException', 'missing map', function() { 35 | Yii::createObject(['class'=> 'raoul2000\workflow\base\StatusIdConverter', 'map' => [] ]); 36 | }); 37 | }); 38 | } 39 | 40 | public function testCreateSuccess() 41 | { 42 | $this->specify('a status converter is created successfully', function(){ 43 | Yii::createObject([ 44 | 'class'=> 'raoul2000\workflow\base\StatusIdConverter', 45 | 'map' => [ 46 | 'Post/ready' => '1', 47 | 'Post/draft' => '2', 48 | 'Post/deleted' => '3', 49 | StatusIdConverter::VALUE_NULL => '0' 50 | ] 51 | ]); 52 | }); 53 | } 54 | 55 | public function testConvertionSuccess() 56 | { 57 | $c = Yii::createObject([ 58 | 'class'=> 'raoul2000\workflow\base\StatusIdConverter', 59 | 'map' => [ 60 | 'Post/ready' => '1', 61 | 'Post/draft' => '2', 62 | 'Post/deleted' => '3', 63 | StatusIdConverter::VALUE_NULL => '0', 64 | 'Post/new' => StatusIdConverter::VALUE_NULL 65 | ] 66 | ]); 67 | 68 | $this->assertEquals('1', $c->toModelAttribute('Post/ready')); 69 | $this->assertEquals('2', $c->toModelAttribute('Post/draft')); 70 | $this->assertEquals('3', $c->toModelAttribute('Post/deleted')); 71 | $this->assertEquals(null, $c->toModelAttribute('Post/new')); 72 | $this->assertEquals('0', $c->toModelAttribute(null)); 73 | 74 | $this->assertEquals('Post/ready', $c->toSimpleWorkflow(1)); 75 | $this->assertEquals('Post/draft', $c->toSimpleWorkflow(2)); 76 | $this->assertEquals('Post/deleted', $c->toSimpleWorkflow(3)); 77 | $this->assertEquals(null, $c->toSimpleWorkflow(0)); 78 | $this->assertEquals('Post/new', $c->toSimpleWorkflow(null)); 79 | } 80 | 81 | public function testConvertionRuntimeMapAssignement() 82 | { 83 | $c = Yii::createObject([ 84 | 'class'=> 'raoul2000\workflow\base\StatusIdConverter', 85 | 'map' => [ 86 | 'Post/ready' => '1', 87 | 'Post/draft' => '2', 88 | 'Post/deleted' => '3', 89 | StatusIdConverter::VALUE_NULL => '0', 90 | 'Post/new' => StatusIdConverter::VALUE_NULL 91 | ] 92 | ]); 93 | 94 | $this->assertEquals('1', $c->toModelAttribute('Post/ready')); 95 | $this->assertEquals('2', $c->toModelAttribute('Post/draft')); 96 | $this->assertEquals('3', $c->toModelAttribute('Post/deleted')); 97 | 98 | $c->setMap([ 99 | 'Post/ready' => '11', 100 | 'Post/draft' => '22', 101 | 'Post/deleted' => '33', 102 | StatusIdConverter::VALUE_NULL => '0', 103 | 'Post/new' => StatusIdConverter::VALUE_NULL 104 | ]); 105 | $this->assertEquals('11', $c->toModelAttribute('Post/ready')); 106 | $this->assertEquals('22', $c->toModelAttribute('Post/draft')); 107 | $this->assertEquals('33', $c->toModelAttribute('Post/deleted')); 108 | $this->assertEquals(null, $c->toSimpleWorkflow(0)); 109 | $this->assertEquals('Post/new', $c->toSimpleWorkflow(null)); 110 | 111 | } 112 | 113 | public function testConvertionFails() 114 | { 115 | 116 | $c = Yii::createObject([ 117 | 'class'=> 'raoul2000\workflow\base\StatusIdConverter', 118 | 'map' => [ 119 | 'Post/ready' => '1', 120 | ] 121 | ]); 122 | 123 | 124 | $this->specify(' an exception is thrown if value is not found', function() use ($c) { 125 | $this->assertThrowsWithMessage( 126 | 'yii\base\Exception' , 127 | 'Conversion to SimpleWorkflow failed : no value found for id = not found', 128 | function() use ($c) { 129 | $c->toSimpleWorkflow('not found'); 130 | } 131 | ); 132 | }); 133 | 134 | 135 | $this->specify(' an exception is thrown if value is not found', function() use ($c) { 136 | $this->assertThrowsWithMessage( 137 | 'yii\base\Exception' , 138 | 'Conversion from SimpleWorkflow failed : no key found for id = not found', 139 | function() use ($c) { 140 | $c->toModelAttribute('not found'); 141 | } 142 | ); 143 | }); 144 | } 145 | } 146 | --------------------------------------------------------------------------------