Orchard Core Workflows Walkthrough: Content Approval

Demo: Content Approval Workflow

To get a good feel for the new Workflows module, we'll create a workflow to try out some of the new activities. Let's create a content approval workflow that models the following process:

  • As an author, I can submit new articles to some HTTP endpoint.
  • The site moderator receives an email that a new article has been submitted for review.
  • When moderator approves the article, an actual Article content item is created and published. The author receives an email notification with a link to the published content item.
  • When the moderator rejects the changes with the "needs work" action, the moderator receives an email notification that includes a new URL to which to submit an updated version of her work.
    • The author has to submit a revised version of her work before a timeout expires.
    • When the author submits a revised version, the moderator again has the option to approve or reject the article, allowing the workflow to repeat itself indefinitely.

The complete workflow looks like this:

Let's build this workflow step-by-step and see it in action.

Step 1: Enable the Workflows & Email features

Go to Configuration -> Modules and enable the following 3 features:

  • HTTP Workflow Activities
  • Timer Workflow Activities
  • Email

Step 2: Create a new Workflow Type

A new admin menu item appears that reads "Workflows". Expand this item to reveal the "Definitions" child menu item. Click this one and click the "Create Workflow Definition" button. Give the workflow the name "Content Approval Workflow", and make sure that the Enabled checkbox is ticked. The other two checkbox can be left unticked, but feel free to try them out.

Step 3: Add the Incoming HTTP Request Event

We want a workflow that allows an author to POST content to our application, so we'll start our workflow with exposing an HTTP endpoint that will kick things off. Click the Add Event button to launch the activity picker.

From the activity picker, look for the HTTP Request Event and click the Add button.

In the activity editor, select POST as the HTTP method and then click the Generate button. Clicking this button will generate a workflow URL and a SAS token. This token must be provided when making HTTP requests to the generated URL, otherwise the workflow won't execute. The purpose of this token is to prevent unauthorized parties to trigger your workflow. It is therefore important that you don't distribute this URL to just anyone; only give the workflow URL to parties that you trust, because anyone who has this URL can invoke your workflow.

Click the Save button.

Step 4: Set the Start Activity

Back on the workflow editor canvas, click the added HTTP Request activity. When you do, you'll notice a small toolbar appears just below the activity. Click the left-most button with the "power on/off' icon to mark this activity as a start activity. Only activities marked as a start activity can cause a workflow to execute.

Step 5: Add the Set Property Activity

When the author makes an HTTP POST request, we expect her to send JSON formatted content. In order to work with this data, we'll store it as a workflow property. Click the Add Task button and look for the Set Property activity and click Add. Enter "Article" in the Name field and the following JavaScript expression in the Value field:

JSON.parse(readBody())

The readBody function reads the HTTP content from the request as a string, and the JSON.parse function parses this string into an actual object. The Set Property activity stores this object as a property named "Article". This allows us to access this object throughout the rest of the workflow by simply asking for this property. We'll see how that works in thhe next step. For your reference, I intend the JSON string to look like this:

{
"author": {
"name": "Max Whitaker",
"email": "max@whitaker.me"
},
"article":{
"title": "A short story",
"body": "The article content goes here"
}
}

Before going to the next step, make sure to connect the HTTP Request activity with the Set Property activity. Your workflow should now look something like this:

Step 6: Add the Send Email Activity

When we receive an article submission from an author, we'll want to notify the site's moderator. Let's do this by sending out an email. Add the Send Email activity with the following values:

Sender: workflow@acme.io

Recipients: moderator@acme.io

Subject: New article submission received: {{Article.article.title}}

Body:

<p>Hi! A new article was just submitted by <strong>{{Article.author.name}}</strong>.</p>
<p><em>{{Article.article.body}}</em></p>
<p>you have the following available actions:</p>
<p>
<a href="{{"Approve" | signal_url | absolute_url}}">Approve</a> | <a href="{{"NeedsWork" | signal_url | absolute_url}}">Needs Work</a>
</p>

Notice that we can use Liquid syntax in both the Subject and Body fields. A really neat feature of Orchard Core Workflows is that it will make available all workflow properties to the Liquid template context. Since we added a "Article" property in the previous step, we can now access that property directly when writing Liquid statements. And since we parsed the JSON content into an actual object, we can simply use dot notation to access any of its fields.

As part of the email message, we want to provide the moderator with two actions: Approve and Needs Work. We'll implement this by generating a workflow URL using the signal_url Liquid filter. The way this filter works is that it will take its input (the preceding string, e.g. "Approve" or "NeedsWork") and use it as a signal name. That signal name is then encoded into a SAS token and appended as a querystring parameter to a URL. Invoking this complete URL will trigger the Signal event. Any workflow that starts with a Signal event that is configured with a corresponding signal name will then execute. And any workflow that is blocked by a Signal event with a corresponding signal name will be resumed. This is exactly what we need for our workflow.

Make sure to connect the Set Article Property activity with the Send Email activity.

Step 7: Add the Fork Activity

The Fork activity will split the workflow into multiple parallel execution paths. The reason we want to do this here is that we are going to use two Signal events and wait on either of them to be triggered. The Signal event will cause workflow execution to halt until it is triggered. We could have taken a different approach and simply add another HTTP Request event and for example expect a request with a query string parameter that contains wether the moderator approved or rejected the article, and simply test for this value. Although that would work nicely, the key difference between the Signal event and the HTTP Request event is that the Signal event allows you to include a signal name as part of the SAS token, which effectively prevents anyone from tampering with the signal name. This is not the case when using a query string appended to the URL, since anyone could just tweak that value and potentially hacks into your workflow by controlling input variables in this manner.

When you add the Fork activity, enter the following two outcomes: "Await Approval,  Await Needs Work" and hit Save. On the workflow editor canvas, make sure to connect the Done outcome of the Send Email activity to the newly added Fork activity.

Step 8: Add two Signal Activities

Open the Event activity picker and add the Signal event. Configure this one with "Approve" as the signal name. Add another Signal event, this time for the "NeedsWork" signal. When both activities are added, connect the appropriate outcomes from the Fork activity to the Signal activities.

Step 9: Add the Join Activity

At this point, two signal events can continue the workflow. If the "Approve" signal is triggered, execution will continue from that point on, and if the "NeedsWork" signal is triggered, execution will continue from that point on. However, what we don't want is that if either one of the signals are triggered, the other one can still be triggered at a later time. Instead, we want to "join" the two branches into a single path of execution when either one signal is triggered. To achieve that, add the Join activity and set its Mode to WaitAny.

Step 10: Add the If/Else Activity

Although execution has been merged, we do need to know which signal was triggered so that we can take appropriate action. If the "Approve" signal was triggered, we want to go ahead and create a content item. But if the "NeedsWork" signal was triggered, we want to take a different path. Although we could have set a property on the workflow in response to a Signal event, there's an easier way: whenever a signal event is triggered, the signal name is provided as a workflow input. Let's go ahead and add the If/Else activity and set its condition to:

input("Signal") === "NeedsWork"

That JavaScript expression evaluates to true if the received signal was "NeedsWork", and to false in case of any other signal. Since we only support two signals at this point in the workflow, this logic works fine. If you have a workflow that awaits more than two signals, you should use the Script activity, which lets you define multiple outcomes.

There are two possible outcomes: either True or False. We'll first do the False outcome (which means the signal was not "NeedsWork", ergo "Approved"), and then circle back to this point in the workflow and continue with the True path ("article needs work").

Step 11: Create the Article Content Type

Before we can actually have the workflow create an Article content item, we first need to define the Article content type. To do so, go to Content Definition -> Content Types and click the Create new type button. Enter "Article" for both the Display Name and Technical Name fields. Next, add the following parts:

  • Autoroute
  • Body
  • Title

Also add a Text Field and give it a display name of "Author Name"  and a technical name of "AuthorName". Your content definition should look like this:

Now that we have the Article content type in place, we can go back to the workflow editor and continue with the next step.

Step 12: Add the Create Content Activity

If the submitted article (the JSON payload) was approved by the moderator, we need the workflow to actually create an Article content item and copy in the provided content. To do so, we'll take advantage of the new Create Content activity. Go ahead and add it from the activity picker, and provide the following values:

Content Type: Article

Publish: yes

Content Properties:

{
"TitlePart": {
"Title": "{{ Article.article.title }}",
},
"BodyPart": {
"Body": "{{ Article.article.body }}"
},
"Article": {
"AuthorName": {
"Text": "{{ Article.author.name }}"
}
}
}

The value of the Content Properties field needs to contain a JSON structure that is the exact same structure used for the Content property of a ContentItem object. There are a number of ways to figure out the structure of a content item. One way is to actually create a content item via the admin and then go into the database and look at its JSON structure. Another way is to use the Visual Studio debugger and set a breakpoint in the AdminController of the Contents module in the Display action method, and then writing out the contentItem's Content property using the immediate window. I used the latter approach as I happened to be in the debugger anyway.

Notice that I'm using Liquid syntax to insert fields from the Article workflow property.

Step 13: Add the Send Email Activity

When the moderator approves the submitted article, it would be nice to let the author know about this. Let's do that via email. Add another Send Email activity and provide the following values:

Sender: workflow@acme.io 

Recipients: {{ Article.author.email }}

Subject: Your article has been approved and published!

Body:

<p>Dear {{ Article.author.name }},</p>
<p>Your article &quot;<strong>{{ Article.article.title }}</strong>&quot; has been published!</p>
<p>You can check it out online via the following link:</p>
<p><a href="{{ LastResult | display_url | absolute_url}}">{{LastResult | display_text }}</a></p>

This is the last activity we need for the "Approve" branch. Once this activity has executed, the workflow completes.

Step 14: Add another Send Email Activity

Go back to the If/Else activity where we branched off the "Approved" flow. When the moderator triggered the "NeedsWork" signal, this activity's condition will evaluate to true, meaning that we need to let the author know that his work wasn't good enough. To do so, add another Send Email activity with the following fields:

Sender: workflow@acme.io

Recipients: {{Article.author.email}}

Subject: Your article needs work

Body:

<p>Dear {{Article.author.name}},</p>
<p>We received your submission, thank you!</p>
<p>However, your article needs a little bit of work.</p>
<p>When you're ready, please submit (HTTP PUT) an updated draft to the following URL:</p>
<p><strong>{{ "/Workflows/Invoke?token=CfDJ8J14khzBx5ZDmlhyDVsxlZ4hZHnM7Qfw4IqnSFuIMmcp1uwL90Gcf9uokBJSe_F5fgmCODds-YblHBCH1dnJ8UnwkL8TaYR3BZsDUXH6bBRD0_ek_Vgcj9aHIcd9YhRI6UkV15g6hu-ui8_TDQGPjlhGwziTzerc0Yee3CFAs_6YOktpZH4dR1K4GjFs5XTzXprY22L1V2ddNOvRhkePmHXPMunZ2XE_3Ioi1w0FU_00" | absolute_url }}</strong></p>
<p></p>
<p><strong>NOTE:</strong> Please re-submit your work within the next hour, or else your submission will expire!</p>

(IMPORTANT: replace the URL in the above code snippet with the URL generated for the HTTP Request event in Step 17 below!).

Step 15: Add the Fork Activity

We now want to await the author's revised work, so we should add another HTTP Request event. However, we also want to add some sort of "time out" that expires the workflow if the author doesn't send us an update within a specific amount of time. To implement this logic, we'll await one of two events: the HTTP Request event and the Timer event. Since we want to await both of these events simultaneously, we should fork our workflow once again, so go ahead and add a new Fork activity and specify the following two outcomes: "Await Revision, Begin Timer"

Step 16: Add the Timer Activity

To implement the timeout, add a Timer event to the workflow. The Timer actvity uses a CRON tab expression to control when it should trigger. Let's do 5 minutes, which translates to the following CRON tab expression: */5 * * * *.

Step 17: Add the HTTP Request Activity

We expect the author to submit a revised article by sending an HTTP PUT request, so let's add another HTTP Request event. Make sure to set the HTTP Method to Put. Which HTTP method you use doesn't really matter, just as long as the HTTP request being made by the author matches whatever method is configured. Make sure to generate a URL for this activity, and use it in the email body described in Step 14.

Step 18: Add the Join Activity

For the same reason as described in Step 9, we need to add a Join activity to join the forked flow back into a single execution path, since we only want to continue execution when either the timer triggers, or an HTTP request is received, but never both. Without the Join activity, the timer would still ltrigger after 5 minutes even if we received an HTTP request before then. So let's go ahead and add the Join activity using the WaitAny mode. And as always, make sure to connect the dots.

Step 19: Add the If/Else Activity

Although we joined back two flows into a single one, we need to know which event was triggered: either the HTTP request, or the timer. Fortunately, this is easy to do because when the Timer event is triggered, it sets the workflow's LastResult property to a string literal of "TimerEvent". So all we need to do is add a If/Else activity and enter a JavaScript expression checking for this astResult property. The expression reads as follows: 

lastResult() === "TimerEvent"

If that condition evaluates to true, it means the timer has been triggered and the author's time is up. When this happens, we should send him an email. If, on the other hand, the author submitted an updated article, we should repeat the workflow and send an email to the moderator, which closes the loop. 

Step 20: Add the Send Email Activity

First, add another Send Email activity with the following values:

Sender: workflow@acme.io

Recipients: {{Article.author.email}}

Subject: Sorry, you're revision took too long and we cancelled your submission

Body: 

<p>Dear {{Article.author.name}},</p>
<p>Unfortunately, your revision time window has expired and we had to cancel your submission.</p>
<p>Please do not be discouraged. You can re-start the submission process at any time by submitting a new article.</p>
<p>See you soon!</p>

Step 21: Close the Loop

The final step is closing the loop: when the author submitted an updated article, we need to repeat the entire workflow. All we need to do is connect the False outcome of the If/Else activity in Step 19 to the Set Article Property activity created in Step 5.

Note: When I tried to connect the If/Else activity with the Set Property activity all the way to the top, I was struggeling with the fact that the editor doesn't automatically scroll up. To work around this issue, I zoomed out the browser window. Alternatively, you can temporarily drag the source activity close enough to the destination activity, connect the two, and then drag the source activity back to where you want it to be.

Demo: Try it out!

It's time to try out the workflow!

To trigger our workflow, we need to make an HTTP POST request that carries the article information in JSON format. An easy way to do so is using a tool such as Postman, which is what I'm using for this demo. So, create a new request in Postman using the following values:

URL: https://localhost:44300/Workflows/Invoke?token=CfDJ8J14khzBx5ZDmlhyDVsxlZ6JIYCB0pHweMHXe7qHC5jazD-8WdrqnV5SzxXcN3MZSazeglEAu6zJZ_VahyBRkHTU6IJQSuwFXapu7y1Zg5g_Zkm8e62ywJ4hneNAAlhC1siSVNP6Ypsjsyz6d_Aoz55ZrcBQXwiiWXSDNcdEGBZpVbMs9ufB6iP4gFEQWnoRPO6c7X9rHWsJyjhDF8TBeNb0jQaZQrKIylYBzLTC5WpW (make sure to use the actual URL you generated for the start activity)

Method: POST

Body: 

{
"author": {
"name": "Max Whitaker",
"email": "max@whitaker.me"
},
"article":{
"title": "A short story",
"body": "Andy Parkes had always loved cosy Sidney with its thirsty, tricky trees. It was a place where he felt calm. He was an energetic, callous, port drinker with pointy feet and curvaceous eyelashes. His friends saw him as a greasy, grated god. Once, he had even helped an adorable baby bird cross the road. That's the sort of man he was. Andy walked over to the window and reflected on his pretty surroundings. The sun shone like shouting mice. Then he saw something in the distance, or rather someone. It was the figure of Zoe Kowalski. Zoe was a lovable juggler with handsome feet and scrawny eyelashes. Andy gulped. He was not prepared for Zoe. As Andy stepped outside and Zoe came closer, he could see the troubled glint in her eye. Zoe glared with all the wrath of 6162 arrogant tight tortoises. She said, in hushed tones, 'I hate you and I want a wifi code.' Andy looked back, even more confident and still fingering the minuscule newspaper. 'Zoe, I don't have the money,' he replied. They looked at each other with sparkly feelings, like two bitter, brawny badgers skipping at a very callous dinner party, which had indie music playing in the background and two noble uncles bouncing to the beat. Andy regarded Zoe's handsome feet and scrawny eyelashes. 'I feel the same way!' revealed Andy with a delighted grin. Zoe looked concerned, her emotions blushing like a raspy, rabblesnatching rock. Then Zoe came inside for a nice glass of port. THE END"
}
}

Content Type: application/json

We'll also need to configure an SMTP server so that we can have the workflow actually send emails. But instead of using a real SMTP server, I prefer to use Smtp4Dev, which is an SMTP server that doesn't actually send out emails; instead, it shows emails being sent in a list so that we can inspect the sent messages. This way we can send emails to fake addresses and still inspect the sent messages. I'll be using Smtp4Dev for this demo.

If you haven't done so already, make sure to go to Configuration -> Settings -> Smtp and provide the SMTP details. In my case, I configured Smtp4Dev to listen on port 2525, so my settings look like this:

When you submit the HTTP request, you should receive an HTTP 200 Accepted status code. We should also see a workflow instance for our workflow:

Click on the workflow instance ID to go to the workflow instance details view, which should look something like this:

Notice the following:

  1. The workflow is in the Halted state (1).
  2. The workflow is blocked by two Signal activities (2)(3)

While we're on this page, let's also have a look at the State view of the workflow instance:

As you can see, the submitted JSON is stored in a custom property called "Article".

Now let's open the Smtp4Dev web interface to inspect the sent email:

So that worked beautifully. Now let's click the "Needs Work" link from the email. When you do, you should see a new email coming in immediately:

Brilliant! Let's also have a look at the current state of the workflow:

As you can see, the workflow is now halted on an HTTP Request event (1) and a Timer event (2). If we wait long enough (around 5 minutes), we will receive an email that our time is up. (waiting 5 minutes ... ) and sure enough:

The timer triggered before we sent an updated article, and caused the workflow to end.

To start the workflow again, we need to send a new HTTP POST request, then either approve or request a revision, etc. You get the idea. Try it out!

Conclusion

I hope I've given you a good idea of what the new workflows module is capable of and how it works. Although the module already enables powerful workflows in its current shape, there are more features to come, such as:

  • More activities, such as User, Role, Content Deployment, Forms, Workflow Activity and many others
  • Workflow tracing
  • Editor & Activity UX improvements
  • Versioning

You can follow the Workflows project on GitHub here: https://github.com/OrchardCMS/OrchardCore/projects/4

If you have a suggestion for a great new feature, please let me know! You can find me on Gitter using my handle @sfmskywalker, shoot me an email or simply create an issue on GitHub.

Leave a comment