Part 7 of Building Workflow Driven .NET Applications with Elsa 2
Putting it all together
In the previous part, we implemented a small number of custom activities that we will put to good use here.
In this part, we will be putting everything together by creating three workflows, one for each type of document:
- Change Request Workflow
- Leave Request Workflow
- Identity Verification Workflow
The main purpose of setting up these workflows is to demonstrate how to deal with things like:
- Passing information into the workflow
- Passing information between activities
And as a bonus we will also see how to:
- Add custom JavaScript functions
- Add custom JavaScript Type definitions for intellisense
- Handle the HTTP response when a user clicks an expired signal URL
Let’s get started!
Change Request Workflow
The Change Request workflow will be a simple, sequential workflow that looks like this:
This workflow will:
- Load the document and file from the database.
- Compress the file.
- Send it as an attachment via email.
- Mark the document as Archived.
Workflow Settings
The first thing we do is configure the Tag property of the workflow and associate it with the Change Request document type ID:
Also be sure to give the workflow a proper name:
Get Document
The first activity to add is the Get Document activity. When doing so, you get to provide the ID of the document to get.
As you may recall, we have a handler in our application called StartDocumentWorkflows
which will execute any & all workflows that are tagged with the same document type ID as the created document .
When the matching workflows are invoked, we pass in the document ID as the correlation ID.
This means that we can simply provide this correlation ID as the value for the document ID to get using a simple JavaScript expression:
correlationId
Later in the workflow, we will need to access the loaded document + file, so make sure to give this activity a name as well:
Giving an activity a name allows us to access its output from other activities, as we will see next.
Zip File
Connect the Zip File activity and configure its Stream and File Name properties.
The Stream property should get the following JavaScript expression:
activities.GetDocument1.Output().FileStream
And the File Name property should get the following JavaScript expression:
activities.GetDocument1.Output().Document.FileName
As you type in the above expressions, notice that there is intellisense support to help you discover the activities that are available as well as their properties:
Except for the Output
property, which is of type DocumentFile
. Intellisense doesn’t seem to pick up on that, showing any
as its type instead.
To make intellisense become aware of custom .NET types, we need to register them explicitly. Let’s do that right away.
Ceate a new folder called JavaScript
in the Scripting
folder of the DocumentManagement.Workflows
project and create the following file:
Then register this class with the service container from the ServiceCollectionExtensions
class in the same project:
// Register custom type definition provider for JS intellisense.
services.AddJavaScriptTypeDefinitionProvider<CustomTypeDefinitionProvider>();
Restart your application and refresh the designer. When you now start typing, intellisense will recognize the return type of the Output
property:
Before moving onto the next activity, be sure to give the Zip File activity a name of ZipFile1
.
Send Email
The Zip File activity produces a zipped stream as its output, which we can attach to an email and send it over.
Connect a new Send Email activity and provide the following settings:
- To:
policy@acme.com
- Subject:
Please review this change request
- Body:
Please review attached change request.
For the Attachments field, we need to return one or more EmailAttachment
objects. In this example, we will return just one attachment by entering the following JavaScript code:
const fileName = `${activities.GetDocument1.Output().Document.FileName}.zip`;const zipStream = activities.ZipFile1.Output();const contentType = contentTypeFromFileName(fileName);const attachment = new EmailAttachment(zipStream, fileName, contentType);return attachment;
In the above code snippet, we access output from both the Get Document and the Zip File activity.
We then instantiate a new EmailAttachment
object that receives the zipped stream, the file name and the content type.
Archive Document
We end the workflow by connecting a new Archive Document activity with the following settings:
- Document (JS):
activities.GetDocument1.Output().Document
This activity will mark the document as archived and update it in the database.
Testing the Workflow
Make sure to publish your changes, then upload a new document with the Change Request selected as the document type.
After a few seconds, you should receive an email with a zip file containing the uploaded document:
That’s it for this workflow! The next one will be a long-running workflow where the user needs to click Approve or Reject before the workflow continues.
Leave Request Workflow
This workflow will look like this:
From Elsa Studio, create a new workflow named LeaveRequestWorkflow
.
Get Document
Like the previous workflow, this one too shall start with the Get Document activity configured as:
- Document ID (JS):
correlationId
- Name:
GetDocument1
Send Email
Connect a new Send Email activity with the following settings:
- To:
hr@acme.com
- Subject:
Please review this leave request
For the Attachments field, enter the following JavaScript code:
This looks similar to the Send Email’s Attachment script used in the previous workflow, except for a couple of differences:
- Here, we will send the file itself, not a zipped version of it.
- We determine the content type based on the file name using a function called
contentTypeFromFileName
.
This contentTypeFromFileName
function however does not exist within Elsa. So let’s create it ourselves!
Within the JavaScript
folder of the Scripting
folder of the DocumentManagement.Workflows
project, add the following class:
This is similar to what we did when we configured the Liquid templating engine. Only this time, we handle the EvaluatingJavaScriptExpression
event so that we can register a custom function.
The handler depends on IContentTypeProvider
, which is provided by the Microsoft.AspNetCore.StaticFiles
packages, so let’s add it as a reference:
dotnet add DocumentManagement/src/DocumentManagement.Workflows/DocumentManagement.Workflows.csproj package Microsoft.AspNetCore.StaticFiles
Then update ServiceCollectionExtensions
to register the service with an actual implementation:
services.AddSingleton<IContentTypeProvider, FileExtensionContentTypeProvider>();
Now we should be able to use the function, which conveniently returns the content type based on a given file name.
However, when we try it out in the expression editor, we don’t see the function suggested by intellisense:
Although we can still use the function and type it in ourselves, let’s register the function so that it is easier to use next time.
To do this, we need to handle the RenderingTypeScriptDefinitions
event, which gives us an opportunity to enrich the typescript definiton being generated.
Since we are providing the function in the `ConfigureJavaScriptEngineWithCustomFunctions` class, let’s have it also handle the RenderingTypeScriptDefinitions
event:
public Task Handle(RenderingTypeScriptDefinitions notification, CancellationToken cancellationToken)
{
var output = notification.Output;
output.AppendLine("declare function contentTypeFromFileName(fileName: string): string");
return Task.CompletedTask;
}
The complete code should look like this:
This time around, we will see our custom function being available from intellisense:
Sweet!
Let’s move on to the Body field next.
Enter the following Liquid markup:
The Body field provides the recipient with two hyperlinks: an Approve and a Reject link. We generate the URLs for these links using the signal_url
Liquid filter, which takes as an input the name of a signal and renders a fully-qualified URL that the user can invoke through their browser.
Invoking a signal URL will trigger the workflow that is blocked on the Signal Received activity that matches the same signal name.
We need our workflow to receive either an Approve or Reject signal from the user whom was sent an email with the leave request. A great activity to await user input is the Signal Received activity. However, we need two of them: one to listen for the Approve signal and another for the Reject signal.
If we were to put them one after another, then only one signal would be handled. Which would then cause the workflow to move on to the next Signal Received activity, putting the workflow in a suspended state again until the other signal is received.
In other words, that won’t work. How do we solve for that?
Fork
Meet the Fork activity.
The Fork activity can split workflow execution into zero or more branches. In our case, we want to split into an Approved and a Rejected branch.
Go ahead and connect a new Fork activity and configure it with the following branch names:
- Branches:
[Approved, Rejected]
When the Fork activity executes, it will schedule all activities that are connected to all of its branches. Which is exactly what we need.
Approved Branch
Connect a new Signal Received activity to the Approved outcome of the Fork activity and configure it as follows:
- Signal:
Approve
Next, connect a new Write Line activity with the following settings:
- Text:
Inform the requester that the leave request was approved.
- Display Name:
Approve
In real world scenarios, you will likely replace this activity with some form of communication to the person requesting their leave, or perhaps by requesting another review from a colleague. But for our purposes, it’s enough to do a simple write to console out.
Join
We end the Approved branch with a Join activity, which will merge execution back into a single branch.
The Join activity has a Mode
property that can be one of the following values:
- Wait All
- Wait Any
The Wait All mode will cause the workflow to remain at the Join activity until after ALL inbound branches have executed the Join activity.
The Wait Any mode on the other hand will continue as soon as ANY inbound branch executes the Join activity.
For our workflow, we need the user to either select Approve or Reject, and not both. Therefore, configure the Join activity as follows:
- Mode:
WaitAny
Before we continue with the activities that come after the Join, let’s return to the Rejected branch first.
Rejected Branch
This branch is similar to the Accepted branch in terms of structure. The only differences are the signal to listen for and the text to write to the console.
Connect a new Signal Received activity to the Approved outcome of the Fork activity and configure it as follows:
- Signal:
Reject
Next, connect a new Write Line activity with the following settings:
- Text:
Inform the requester that the leave request was denied.
- Display Name:
Reject
Now connect the Write Line activity to the Join activity we added earlier:
Get Document
Regardless of the user’s input (Accept or Reject), we want to archive the document.
However, since the workflow went out of memory when it was suspended (due to the Signal Received activities), the loaded document + file stream are no longer in memory. If you recall, we created the Get Document activity’s Output property to use Transient storage.
For this reason, we will add another Get Document activity to re-load the document into memory.
Configure it as follows:
- Document ID:
correlationId
Archive Document
Finally, connect a new Archive Document with the following settings:
- Document (JS):
input.Document
Notice that instead of referencing the previous Get Document activity directly, this time we take advantage of a JS variable called input
.
In this context, input
is a reference to the output provided by the previously executed activity. Which means that it references the DocumentFile
object provided by the Get Document activity.
The only real difference is that intellisense doesn’t know at design time what the input might be, since it’s possible for multiple inbound activities to provide output.
Testing the Workflow
To test this workflow, upload another document but this time using the Leave Request option.
After a few seconds, you should receive an email with the uploaded document attached:
Looking at the Workflow Instances screen, we indeed see a new workflow instance:
When you click the workflow instance, you will be taken to the workflow instance viewer screen. The workflow instance viewer displays a journal representing the activities that have executed. In addition, the workflow designer displays a green border around each activity that has executed.
As you can see, the workflow is currently blocked on two Signal Received activities: one awaiting the Approve signal, the other awaiting the Reject signal.
To send any of these signals, simply click one of the links received via the email.
When you click Approve, the console window of the application should display the following text:
Inform the requester that the leave request was approved.
When you look at the workflow instance again, you will notice that it now has the Finished status.
Improvements
One important area of improvement is the HTTP response we received when clicking the Approve or Reject link from the email. Right now, we get something like this:
This won’t do when we go to production of course. What we want to display the user instead is some kind of confirmation page.
Let’s fix this right away.
First, create a new Razor page in the web project named LeaveRequestApproved.cshtml
and add the following content:
Create another Razor page named LeaveRequestDenied.cshtml
and add the following content to it:
Now that we have these two pages in place, let’s consider how we can display these in response to the user clicking one of the two hyperlinks from the email.
The hyperlinks are generated using the signal_url Liquid filter, which points to an API controller exposed by Elsa. This controller is responsible for decoding the signal token and then invoking the appropriate workflow.
What it also does is publish an event. Which is very convenient, because we can handle this event and write a custom HTTP response. In our case, we can simply send an HTTP 302 — Redirect response that redirects the browser to the appropriate Razor page.
To do this, create a new folder called Handlers
in the web project and add the following class:
To get this handler registered with DI, add the following code to Startup
:
// Register all Mediatr event handlers from this assembly.
services.AddNotificationHandlersFrom<Startup>();
When we now start the application again, upload a new document and click on Approve, we get the following result:
That’s it for this workflow!
Identity Verification Workflow
The Identity Verification workflow will look like this:
Since this workflow is almost identical to the previous workflow, I will not go over the entire workflow in details. But I will point out two things:
- Make sure to tag this workflow with the
IdentityVerification
document type ID. - Configure the Update Blockchain activity as follows:
- File:
input.FileStream
Remember: the previously executed activity (Get Document) will have its output be provided as input into the next activity (Update Blockchain) automatically, which means it’s available via the input
JS variable.
By convention, if an activity output property is named
Output
, it will be available to the next activity via theinput
JS variable and theInput
liquid variable.
Summary
This concludes the blog series about building workflow-driven ASP.NET applications with Elsa 2.
I hope you found it useful and that you see how easy and fun it is to power your applications with workflows.
Having workflow-driven apps is a powerful feat. As your set of activities, JS and Liquid building blocks grow, adding and updating business rules or even entire processes becomes so much quicker and easier.
And you always have the choice of whether these workflows are provided from the database, allowing changes to be made in production systems, or to have them provided as part of your application’s code base in the form of JSON files.
And that’s what Elsa is really all about: to offer a wide range of options to the developer when it comes to enabling workflows in their applications.
Here, we’ve seen how to empower an ASP.NET application with workflow capabilities. Another approach might have been to have a separate ASP.NET application acting as the workflow server. The Razor app’s backend would then communicate with the workflow server via Elsa API endpoints and possibly with custom API endpoints as well, which internally leverage the Elsa library. Although not discussed in this series, Elsa also supports conductor-style workflows using generic Task and Event activities. The Task activity represents a task to be executed by the client app (e.g. the Razor app), while the Event activity represents an event raised by the client app, causing the workflow on the workflow server to resume execution.
This setup works very wel in a microservice architecture.
Anyway. I could go on and on about all the great things you can do with Elsa Workflows, but I will save all that for future posts.
Stay awesome!