[EXTENSION] simpleWorkflow

Well…

Since the nextStatusListData helper function returns a list of options using the labels, why not offer a helper to display the status using the label?

If your imagination is depleted, here’s a picture to jog it:

855

imagination.png

When you define your workflow, you must set an id for each status (like you did) but you can also set a Label. The label is nothing but a user friendly name for the status. It doesn’t have to be unique or anything, and can handle internationalization.

When you use the SWHelper::nextStatusListData method, what it does is populate an array with key/value pairs, where keys are the status Id and value the status label. If not label is defined, then the Id is used (that what happened in your case).

Here is an example :




<?php	

	return array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft' ,

	  			'label' => Yii::t('draft')	// I8N status label

	  			'transition'=>'correction'),

			array('id'=>'ready',

	  			'label' => 'Ready for publication' 	// 'regular' string label

	  			'transition'=>'draft,correction,published'),

			array('id'=>'correction','transition'=>'draft,ready'),

			array('id'=>'published', 'transition'=>'ready,archived'),

			array('id'=>'archived', 	'transition'=>'ready'),

		)

	)

?>

As you can see, status draft has a ‘label’ key set to ‘Yii::t(‘draft’)’, so it uses Yii internationalization feature to display the status name to the user. The ready status has a simple string has label (‘Ready For Publication’), and eventually all other statuses don’t have any label key, so they will automatically be displayed as <workflowId>/<statusId>.

I hope this will help solve your problem.

ciao

Hi Jacmoe,

if you want to display a status label you can write something like :




echo $model->swGetStatus()->getLabel();



The swGetStatus() method returns a SWNode object. You can check the API reference here

ciao

Thanks a lot, it works! :lol:

Here’s how to use it in a zii.widgets.CDetailView:




<?php $this->widget('zii.widgets.CDetailView', array(

	'data'=>$model,

	'attributes'=>array(

		'id',

		'tracker_id',

		'project_id',

		'subject',

		'description',

		'due_date',

		'issue_category_id',

		'user_id',

		'issue_priority_id',

		'version_id',

		'assigned_to',

		'created',

		'modified',

		'start_date',

		'done_ratio',

		array(

                	'label' => 'Status',

                	'type' => 'raw',

                	'value' => $model->swGetStatus()->getLabel()

            	),

		'closed',

	),

)); ?>

I am a Yii newbie, so tell me if there’s an easier way.

But ‘Status’ is now rendered by it’s label ‘New’. Perfect.

Thanks for a excellent extension. :)

Good to hear ! … and what you wrote is just fine…

ciao

This is excellent, thank you so much Raoul.

Great extension and nicely documented.

doodle

First I have to say this extension could really save me a lot of work so thanks for this ;)

Second one thing which I find strange. If I have a Workflow like in the Blog Demo with a constraint like:




array('id'=>'ready',

	'constraint' => '!empty($this->tags)',

	'transition'=>'draft,correction,published'),



I would think that I could select those status if this state is currently is in correction even if the tags are not filled. And will get an error that the tags are not filled in the model validation.

Currently if some Author enters a text and fill only the title and the content because they are required and saves this as ‘draft’. Than the post status of this post will be set to ‘correction’ if editing is finished so the next state would be ‘ready’. But when you open the post entry for edit you will only see draft and correction as possible states because the Tags are not filled. Only when you enter some tags save and open the post again you will see the ready option available to select.

I would recommend that you will see all states which are possible from the current state in the dropdown. But would then get an error on model validate that the constraint isn’t fulfilled if he selects the state ready.

Otherwise he also wouldn’t know what fields he must fill to even see that ready state.

Hi Horizons,

if I understand well, you’d like that constraint evaluation occurs at the time the status changes (during model validation), and not when retrieving possible next status list. If that is so, then I must say that you are right ! One solution would be to add an argument so to the SWHelper::nextStatusListData method.

Something like :


SWHelper::nextStatusListData($model, $includeCurrentStatus=true, $evaluateConstraint=true);

What do you think ?

I could include it in the next release (that is, when I’m back from holidays ;) )

ciao

Something like this or and display_all setting for the helper to display all status possibilities from the current state without the constraint evaluation.

The only Problem would be that the error would just display "not a valid next status" for the status dropdown.

And wouldn’t make a hint that the tags failed (blog demo).

I guess the best thing would be that you would change the code that the validators themself could be added.

like an array




 array('id'=>'ready',

    'constraints' => array(

            array('tags', 'required'),

    ),

   'transition'=>'draft,correction,published'),

 ),



And the validation would be done as if it was done on the model itself and would display the error on the correct attribute (e.g here tags). But this validation for those attributes would only be checked on certain states.

Hmm would be similar to the ‘on’=>array(‘ready’) setting on the rules for the scenario’s.

Maybe this could be combined like that the SWActiveRecordBehavior searches for the rules with the same transition names in the ON setting (e.g. swPost/ready) an validates them when this state is set.

Or just set the model on that scenario ‘ready’ in the validation process and back to ‘correction’ if validation failed (don’t know if that is a good idea).

Yes, I’ve also been thinking about how validators and/or scenario could be used to manage constraints…

For me, validators and status constraints are 2 different things and one should take care not to move validation process, from the model to the workflow. For your example, i agree that it would make sense to postpone constraint evaluation at the time the model is validated, but this may not be appropriate in other case. Imagine that a given state is only reachable if user have sufficient permissions. It is clear that the list of reachable statuses should not contain statuses that can’t be reached because of insufficient user permission, and so constraint evaluation would have to occur before the list is built.

But tell me, from a conceptual point of view, shouldn’t the ‘tag not empty’ rule be actually a part of the model validation ? I mean I know that’s the example I used to illustrate constraints in the documentation, but maybe that"s not the best idea I’ve had, because by doing so, I’m moving a small part of validation, from the model into the workflow … which I think is not good.

Guess you are right. It was just your example which was not really the best to choose ;)

If there should be a validation for the tags to be filled you just have to add a rule for it.




public function tags_check($attribute,$params)

    {

        if($this->status=='swPost/ready'||$this->status=='swPost/published')

        {

           if(!$this->tags)

              $this->addError('tags','The Tags must filled for the selected status');

        }

    }

and to the rules   

  array('tags', 'tags_check'),



This would be then checked if the post will be set to ready or published. So if your constraint is only used for access handling you are right.

Nevertheless a validation from and to specific states would be also nice.

I will play around with the extension and reply here if i find some enhancement possibilities.

ok then … and regarding validation based on status change that will need some more thinking (maybe scenario could be the way).

I’ll be grateful for any enhancements, ideas, or anything that could contribute to make this extension better… and I’ll update the documentation as soon as possible (that is, when I’m back from holidays ;) )

thanks for your help

8)

[size="2"]simpleWorkflow RC2 is available[/size]

The main feature is workflow driven validation that allows to validate a model on a workflow transition. You can now implement validation rules like "I want this attribute to be required when the model instance goes from statusA to statusB", or even something more general like "I want this attribute to be validated when the model enters in statusC"… Ok, have a look at the documentation for more details and examples.

I hope it will be useful to others, and like always : any feedback is welcome ;)

Direct Download : simpleWorkflow_RC2.zip

Ressources :

  • Extension Page
  • Demo & website
  • API Documentation

ChangeLog :

  • fix : replace ‘split’ with ‘explode’ (got 2 doodle)
  • enh : SWActiveRecordBehavior->swValidate now returns boolean
  • enh : Workflow Driven Model Validation. It is now possible to define validators which are only executed upon specific transitions (this is done by defining specific scenario names).

Really cool extension! :D

I am a happy consumer. ;)

I’m hoping someone can help me here because my brain is about to explode!

I am using the simpleWorkflow validator in my model. It’s a great extension, thank you so much Raoul.

I have a scenario with contributors, reviewers, a master editor a publisher etc.

In order to provide unique workflows for each role I set up my swSource file with a switch/case construct - example.


$level = MyUtils::userLevel();

	switch($level) 

	{

	case User::CONTRIBUTOR_USER:

            

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft',	 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),// CONTRIBUTOR

			array('id'=>'revise', 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,1)',

                            'withdrawn'=>'MyUtils::contributorMail($this->id,2)'

                            )

                            ),// CONTRIBUTOR

			array('id'=>'withdrawn'),// CONTRIBUTOR

            array('id'=>'submitted'), // editor or contibuter

			array('id'=>'rejected'), // CONTRIBUTOR

			array('id'=>'accepted'), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')	// end of the line for all users									

		)

	);

	break;

	case User::REVIEWER_USER:

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft'), // CONTRIBUTOR

			array('id'=>'revise'),// CONTRIBUTOR

			array('id'=>'withdrawn'), // CONTRIBUTOR

			array('id'=>'submitted',

                            'transition'=>array(

                            'accepted'=>'MyUtils::reviewerMail($this->id,1)',

                            'revise'=>'MyUtils::reviewerMail($this->id,0)',

                            )), // editor or contibuter

			array('id'=>'rejected'), // CONTRIBUTOR

			array('id'=>'accepted'), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled') // end of the line for all users

		)

	);

	break;

So each user role has options based on their role, I am not sure if this is best practice but at least this part is working.

I am also able to send an email using a method in a component called ‘MyUtils’, I simply pass the document ID and the mode or function.

All is working well, except it seems sometimes a reviewer will be a contributor and sometimes a master editor will be a contributor.

Contributor should be the only one who can pass his own ‘draft’ document to ‘submitted’.

Within this workflow source document I do not seem to be able to establish what the ID of the document is. I can do a down and dirty $_GET[‘id’] because on an update I have this is the URL but then I have to check if $_GET[‘id’] is set and it’s just not working. I also tried setting an constraint but that didn’t seem to work.

I don’t quite understand how the swSource document works but it seems to be like a configuration file that is loaded when required so I don’t think that the document (record) id is available at this time.

Next I am going to try a constraint again. Using something like.


array('id'=>'draft',

'constraint'=>'MyUtils::isOwner($this->id)',	 

'transition'=>array(

                   'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),




Any help or advice would be much appreciated.

I am using the original version of this extension, should I upgrade?

doodle ???

OK this is typical after posting a cry for help I solve my own problem. Plus I went back to the documentation ::)

So now my master editor who normally doesn’t review documents can’t do very much except if they are defined as the reviewer for this document (different process)


    case User::MASTEREDITOR_USER:

 // $owner = EventAbstract::isOwner();

      

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('id'=>'revise','constraint'=>'MyUtils::abstractReviewer($this->id)',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('id'=>'withdrawn', 'constraint'=>'MyUtils::abstractOwner($this->id)', 'transition'=>'submitted'), // CONTRIBUTOR

			array('id'=>'submitted','constraint'=>'MyUtils::abstractOwner($this->id)',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('id'=>'rejected','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // CONTRIBUTOR

			array('id'=>'accepted','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );


	break;



If the master editor is the reviewer they can send back for revision, accept or reject the document.

If the master editor is the owner of the document they can submit or withdraw the document.

Since checks will be in place elsewhere to to ensure that the master editor is never their own reviewer ;) this should be no problem.

FWIW these methods are in my MyUtils component.


        public function abstractOwner($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->user_id == $userID ? true : false);

             }

	}

        public function abstractNotOwner($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->user_id == $userID ? false : true);

             }

	}


        public function abstractReviewer($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->editor_id == $userID ? true : false);

             }

	}

Hopefully all is good, still open to suggestions or comments.

doodle

Hi doodle,

glad to hear you solved your problem … and I think you did the right thing by using constraints In fact I was quite surprise when I saw this switch that returns different workflow depending on the user role. It seems to me that from a theoretical point of view, this breaks workflow uniqueness (one id, one workflow) and could lead to very complex issues.

In your case, all workflow have the exact same nodes right ? … so you need more than one workflow because depending on user role, transitions are not the same, correct ? Typically this could be solved by applying constraints to workflow nodes (I mean, that what constraints are designed for)… and that’s exactly what you did in your last example.

However, please note that I am talking again from a theoretical point of view…

Now regarding how the swSource document works, you are right : it loads a workflow from a PHP file, when it is needed. This can occur :

  • when a Model is created (new MyModel()) - this happens only if autoInsert is set to true, which means that a model is automatically inserted into a workflow
  • when a model is read from DB - the afterFind event it handled by the simpleWorkflow behavior

…and of course when you call directly swWorkflowSource component :


Yii::app()->sw->loadWorkflow('myWorkflow');

At last, you don’t have to upgrade unless you want to use the Workflow Driven Model validation feature which is the main change.

I hope that my extension will bring you more help than issues for your application (and that your brain will not explode ;) )

@raoul at first after reading your comments I thought that perhaps I had over complicated things using the switch statement and I thought a bit about whether I could or should lose the switch statement.

It seems for me that it adds considerable flexibility. For my particular project I have a few requirements that I think I can only address this way.

[list=1]

[*]The users are not experienced so I want the super-user to be able to ‘fix’ things or do things that are essentially ‘illogical’

[*]different people are emailed depending on where in the process the status changes

[/list]

I have a down and dirty page up on my website to explain this project to you. You can access it here

Also a comment about the documentation. Possibly because I am a left to right reader I found the documentation confusing regarding the introduction of the constraints.

Example: one of my workflow arrays, it works fine.


$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('id'=>'revise','constraint'=>'MyUtils::abstractReviewer($this->id)',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('id'=>'withdrawn', 'constraint'=>'MyUtils::abstractOwner($this->id)', 'transition'=>'submitted'), // CONTRIBUTOR

			array('id'=>'submitted','constraint'=>'MyUtils::abstractOwner($this->id)',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('id'=>'rejected','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // CONTRIBUTOR

			array('id'=>'accepted','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );

The same array in a slightly more understandable format (not necessarily more readable)


$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractReviewer($this->id)', 'id'=>'revise',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractOwner($this->id)','id'=>'withdrawn',  'transition'=>'submitted'), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractOwner($this->id)','id'=>'submitted',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('constraint'=>'MyUtils::abstractReviewer($this->id)','id'=>'rejected', ), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractReviewer($this->id)','id'=>'accepted' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );

So now all logic flows left to right

In order to make it to an ID you must pass THROUGH the constraint after reaching the ID the possible transitions are the next things to the right.

It helped me visualize what was happening.

Now the only issue I am facing is that notification is sent out before the save, this means that when a reviewer makes a note I don’t know how to access the most recent changes.

FWIW here is a chunk of the method that sends the mail


public function autoMail($subject,$docID,$userID=null,$fromID=null )

	{

               $doc = EventAbstract::model()->findByPk($docID);


		if(!isset(Yii::app()->params->sendAdminMail)) // don't even try to mail if this setting is null

		return;

                /*

                 * If there is no FROM id assume that it is from the admin

                 * if it is a number it is a user id otherwise it must be an email address

                 */

                if(!$fromID){

		$from=Yii::app()->params->masterEditorEmail;

                } else {

                  $from = (is_numeric($fromID) ? User::model()->findByPk($fromID)->email : $userID);

                }

                /*

                 * If there is no TO id assume that it is to the admin

                 * if it is a number it is a user id otherwise it is an email address

                 */

                if($userID){

		$to = (is_numeric($userID) ? User::model()->findByPk($userID)->email : $userID);

                } else {

                $to=Yii::app()->params->masterEditorEmail;

                }

                $headers = "MIME-Version: 1.0\r\nFrom: $from\r\nReply-To: $to\r\nContent-Type: text/html; charset=utf-8";


                $body = '<em><strong>'.$subject.'</strong></em>';.....

As you can see it pulls the data from the existing record based on an ID.

Here is an example of an action that is triggered by a status change.


case User::CONTRIBUTOR_USER:

           	$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft',	 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),.....

@raoul I think I could learn a lot by examining your extension, thanks for sharing.

doodle

Hi doodle,

like I say, if you think that this switch statement is appropriate to your project (regarding flexibility) that’s all right, you’re with no doubt better judge than me who don’t know issues you have faced (and by the way, the web page is perfect to understant, and a nice graphical representation of a workflow is better than long writings).

Regarding the order in which you choose to define a given node, I had not thought that begining with the constraint could help understand better the purpose of constraints. I’ll keep that in mind for the next doc update.

At last, for your last point : receiving save notification before the record is actually saved. Again, even if I made an effort to provide a documentation, I realize that it is missing some important things. Among them is the possibility to initialize the sW Behavior so transition tasks are executed after the record is saved. Now I don’t know if it fits your needs, but here is how to use it :




public function behaviors()

{

	return array(

		'swBehavior' => array(

			'class'   		=> 'application.extensions.simpleWorkflow.SWActiveRecordBehavior',

            'transitionBeforeSave' => false

		),			

	);

}

As you can see, the option name is transitionBeforeSave, which is by default set to TRUE. You can find more info in the SimpleWorkflow Class Reference. Here is what it says :

I hope this will help you solve your problem.

@raoul - YES! transitionBeforeSave this works!

Now everything is working exactly as I had envisioned.

Great work Raoul!

:D

doodle