React integration from scratch
Tutorial 1 🎯
none - initial branch
main - final branch
none - pull request with code changes
Overview​
In this guide we will explain how to integrate Workflow Engine with React. We will create two applications:
- Backend - an ASP.NET application with Workflow Engine and Controllers.
- Frontend - a React application with Workflow Designer and simple admin panel.
We will create a process scheme that will do the following:
- Run and wait for the user to execute a command
GetWeatherForecast
to receive a weather forecast. Getting the weather forecast will be done with a custom action. - After receiving the weather forecast, the process will wait for a command
SendWeatherForecast
from the user to send the weather forecast by e-mail. - After the email is sent, the process ends and can be restarted with the
ReRun
command.
To get the weather forecast we will use Open-Meteo API.
We will also restrict the execution of commands:
- The
GetWeatherForecast
command can be executed by users with the 'User' role. - The
SendWeatherForecast
command can be executed by users with the 'Manager' role. - The
ReRun
command can be executed by users without the 'User' role.
Prerequisites​
- Docker.
- .NET 6.
- JetBrains Rider or Visual Studio.
- NodeJS.
- JetBrains IDEA or Visual Studio Code or another tool to edit JavaScript code.
- Console.
Create a database for the Workflow Engine​
First, we need a database. We will use the docker container azure-sql-edge because it works on ARM64.
Open the console and run the following commands to start the database:
docker pull mcr.microsoft.com/azure-sql-edge:latest
docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=StrongPassword#1' -p 1433:1433 --name azuresqledge -d mcr.microsoft.com/azure-sql-edge
We now have a master database with login sa and password StrongPassword#1 running on port 1433.
Now connect to the database with your favorite tool and execute SQL script .
Create a backend application​
OK. Now we have a database. Let's create a backend application. To create a backend application, we will create a .NET 6 solution. Then add two projects for this solution:
- WorkflowLib is the library that contains the Workflow Engine.
- WorkflowApi is an ASP.NET project with an API for the Workflow Engine.
It's all covered in the How to integrate guide, so let's just copy and paste with different project names:
Let's create a folder for our example and go into it:
mkdir react-example
cd react-example
Then create a Backend
folder and go into it, create an empty solution and add the WorkflowLib
library to the solution. Also add a
WorkflowApi
MVC project and add it to the solution:
mkdir Backend
cd Backend
dotnet new sln
dotnet new classlib --name WorkflowLib
dotnet sln add WorkflowLib
dotnet new mvc --name WorkflowApi
dotnet sln add WorkflowApi
dotnet add WorkflowApi reference WorkflowLib
Great, now we have two projects: WorkflowLib
and WorkflowApi
. The WorkflowApi
project references the WorkflowLib
project. Let's add
the dependencies for the nuget Workflow Engine packages to these projects:
dotnet add WorkflowLib package WorkflowEngine.NETCore-Core
dotnet add WorkflowLib package WorkflowEngine.NETCore-ProviderForMSSQL
dotnet add WorkflowApi package WorkflowEngine.NETCore-Core
dotnet add WorkflowApi package WorkflowEngine.NETCore-ProviderForMSSQL
Setting a port for the backend​
Now it's time to open the Backend
solution in your favorite IDE. We will be using JetBrains Rider.
Now we will change the port for running the backend to 5139, since the dotnet CLI can install another port during the project generation
process. Open the launchSettings.json
file in the Properties
folder of the WorkflowApi
project and enter the
value http://localhost:5139
, as in the example below:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25849",
"sslPort": 44372
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5139",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7228;http://localhost:5139",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Create a simple user model​
In our tutorial, we will use a simple user model. There will be four users: Peter, Margaret, John and Sam. And only two roles: User and
Manager. To describe a user, we'll use a simple User
class containing the user's name and a list of their roles.
Let's add the User
class to our WorkflowLib
project:
namespace WorkflowLib;
public class User
{
public string Name { get; set; }
public List<string> Roles { get; set; }
}
Next, add a Users
class containing our users:
namespace WorkflowLib;
public static class Users
{
public static readonly List<User> Data = new()
{
new User {Name = "Peter", Roles = new List<string> {"User", "Manager"}},
new User {Name = "Margaret", Roles = new List<string> {"User"}},
new User {Name = "John", Roles = new List<string> {"Manager"}},
new User {Name = "Sam", Roles = new List<string> {"Manager"}},
};
public static readonly Dictionary<string, User> UserDict = Data.ToDictionary(u => u.Name);
}
To get the user by their name, we will use the UserDict
field.
Connecting WorkflowLib to Workflow Engine​
First, remove the Class1
class from the WorkflowLib
project. We just don't need this class generated by the dotnet CLI.
Second, add WorkflowInit
class, like in How to integrate tutorial. We will use a slightly modified example of
this class now:
using System.Xml.Linq;
using OptimaJet.Workflow.Core;
using OptimaJet.Workflow.Core.Builder;
using OptimaJet.Workflow.Core.Parser;
using OptimaJet.Workflow.Core.Runtime;
using OptimaJet.Workflow.DbPersistence;
using OptimaJet.Workflow.Plugins;
namespace WorkflowLib;
public static class WorkflowInit
{
private const string ConnectionString = "Data Source=(local);Initial Catalog=master;User ID=sa;Password=StrongPassword#1";
private static readonly Lazy<WorkflowRuntime> LazyRuntime = new(InitWorkflowRuntime);
private static readonly Lazy<MSSQLProvider> LazyProvider = new(InitMssqlProvider);
public static WorkflowRuntime Runtime => LazyRuntime.Value;
public static MSSQLProvider Provider => LazyProvider.Value;
private static MSSQLProvider InitMssqlProvider()
{
return new MSSQLProvider(ConnectionString);
}
private static WorkflowRuntime InitWorkflowRuntime()
{
// TODO If you have a license key, you have to register it here
//WorkflowRuntime.RegisterLicense("your license key text");
var builder = new WorkflowBuilder<XElement>(
Provider,
new XmlWorkflowParser(),
Provider
).WithDefaultCache();
// we need BasicPlugin to send email
var basicPlugin = new BasicPlugin
{
Setting_MailserverFrom = "mail@gmail.com",
Setting_Mailserver = "smtp.gmail.com",
Setting_MailserverSsl = true,
Setting_MailserverPort = 587,
Setting_MailserverLogin = "mail@gmail.com",
Setting_MailserverPassword = "Password"
};
var runtime = new WorkflowRuntime()
.WithPlugin(basicPlugin)
.WithBuilder(builder)
.WithPersistenceProvider(Provider)
.EnableCodeActions()
.SwitchAutoUpdateSchemeBeforeGetAvailableCommandsOn()
// add custom activity
.WithCustomActivities(new List<ActivityBase> {new WeatherActivity()})
// add custom rule provider
.WithRuleProvider(new SimpleRuleProvider())
.AsSingleServer();
// events subscription
runtime.OnProcessActivityChangedAsync += (sender, args, token) => Task.CompletedTask;
runtime.OnProcessStatusChangedAsync += (sender, args, token) => Task.CompletedTask;
runtime.Start();
return runtime;
}
}
With the WorkflowInit
class, we can access the WorkflowRuntime with the WorkflowInit.Runtime
statement and
the MSSQLProvider
database provider with the WorkflowInit.Provider
statement.
We have also initialized the BasicPlugin
for sending email, fill in its properties with your settings. We also added a custom activity
WeatherActivity
and a rule provider SimpleRuleProvider
. These classes will be added later.
Adding a custom activity​
The process of adding a custom activity is described here.
We'll add a custom activity called WeatherActivity
that gets the weather forecast and stores the result in process variables.
Add the WeatherActivity
class to the WorkflowLib
project:
using System.Net;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OptimaJet.Workflow.Core;
using OptimaJet.Workflow.Core.Model;
using OptimaJet.Workflow.Core.Runtime;
namespace WorkflowLib;
public sealed class WeatherActivity : ActivityBase
{
public WeatherActivity()
{
Type = "WeatherActivity";
Title = "Weather forecast";
Description = "Get weather forecast via API";
// the file name with your form template, without extension
Template = "weatherActivity";
// the file name with your svg template, without extension
SVGTemplate = "weatherActivity";
}
public override async Task ExecutionAsync(WorkflowRuntime runtime, ProcessInstance processInstance,
Dictionary<string, string> parameters, CancellationToken token)
{
const string url = "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&daily=temperature_2m_min&timezone=GMT";
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.OK)
{
await using var stream = await response.Content.ReadAsStreamAsync(token);
using var streamReader = new StreamReader(stream);
var data = await streamReader.ReadToEndAsync();
var json = JsonConvert.DeserializeObject<JObject>(data);
var daily = json?["daily"];
var date = daily?["time"]?[0] ?? "Date is not defined";
var temperature = daily?["temperature_2m_min"]?[0] ?? "Temperature is not defined";
// store the entire response to the Weather process parameter
await processInstance.SetParameterAsync("Weather", json, ParameterPurpose.Persistence);
// store the weather date in the WeatherDate process parameter
await processInstance.SetParameterAsync("WeatherDate", date, ParameterPurpose.Persistence);
// store the temperature in the process parameter WeatherTemperature
await processInstance.SetParameterAsync("WeatherTemperature", temperature, ParameterPurpose.Persistence);
}
}
public override async Task PreExecutionAsync(WorkflowRuntime runtime, ProcessInstance processInstance,
Dictionary<string, string> parameters, CancellationToken token)
{
// do nothing
}
}
All code is in the ExecutionAsync
method. This method will be executed when the activity becomes active.
Adding RuleProvider​
To restrict the execution of transitions, we need to implement the IWorkflowRuleProvider
interface. You can read more about
Rules here.
In this tutorial, we will create a simple implementation of the IWorkflowRuleProvider
interface that will check user roles.
Add the SimpleRuleProvider
class to the WorkflowLib
project:
using OptimaJet.Workflow.Core.Model;
using OptimaJet.Workflow.Core.Runtime;
namespace WorkflowLib;
public class SimpleRuleProvider : IWorkflowRuleProvider
{
// name of our rule
private const string RuleCheckRole = "CheckRole";
public List<string> GetRules(string schemeCode, NamesSearchType namesSearchType)
{
return new List<string> {RuleCheckRole};
}
public bool Check(ProcessInstance processInstance, WorkflowRuntime runtime, string identityId, string ruleName, string parameter)
{
// we check that the identityId satisfies our rule, that is, the user has the role specified in the parameter
if (RuleCheckRole != ruleName || identityId == null || !Users.UserDict.ContainsKey(identityId)) return false;
var user = Users.UserDict[identityId];
return user.Roles.Contains(parameter);
}
public async Task<bool> CheckAsync(ProcessInstance processInstance, WorkflowRuntime runtime, string identityId, string ruleName,
string parameter,
CancellationToken token)
{
throw new NotImplementedException();
}
public IEnumerable<string> GetIdentities(ProcessInstance processInstance, WorkflowRuntime runtime, string ruleName, string parameter)
{
// return all identities (the identity is user name)
return Users.Data.Select(u => u.Name);
}
public async Task<IEnumerable<string>> GetIdentitiesAsync(ProcessInstance processInstance, WorkflowRuntime runtime, string ruleName,
string parameter,
CancellationToken token)
{
throw new NotImplementedException();
}
public bool IsCheckAsync(string ruleName, string schemeCode)
{
// use the Check method instead of CheckAsync
return false;
}
public bool IsGetIdentitiesAsync(string ruleName, string schemeCode)
{
// use the GetIdentities method instead of GetIdentitiesAsync
return false;
}
}
The SimpleRuleProvider
class is quite simple, check the comments in the code.
Connecting WorkflowApi with Designer controller​
The next thing we need is the Designer controller. This controller will respond to Workflow Designer HTTP requests. Let's copy and paste
controller code from the tutorial How to integrate into the WorkfowApi
project in the Controller
folder:
using System.Collections.Specialized;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using OptimaJet.Workflow;
using WorkflowLib;
namespace WorkflowApi.Controllers;
public class DesignerController : Controller
{
public async Task<IActionResult> Api()
{
Stream? filestream = null;
var parameters = new NameValueCollection();
//Defining the request method
var isPost = Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase);
//Parse the parameters in the query string
foreach (var q in Request.Query)
{
parameters.Add(q.Key, q.Value.First());
}
if (isPost)
{
//Parsing the parameters passed in the form
var keys = parameters.AllKeys;
foreach (var key in Request.Form.Keys)
{
if (!keys.Contains(key))
{
parameters.Add(key, Request.Form[key]);
}
}
//If a file is passed
if (Request.Form.Files.Count > 0)
{
//Save file
filestream = Request.Form.Files[0].OpenReadStream();
}
}
//Calling the Designer Api and store answer
var (result, hasError) = await WorkflowInit.Runtime.DesignerAPIAsync(parameters, filestream);
//If it returns a file, send the response in a special way
if (parameters["operation"]?.ToLower() == "downloadscheme" && !hasError)
return File(Encoding.UTF8.GetBytes(result), "text/xml");
//response
return Content(result);
}
}
Let's check that the solution works now. Run this command in a shell:
dotnet run --project WorkflowApi
And you should see something like this in your console:
Building...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5139
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/aksanaazarenka/projects/optimajet/temp/react-example/Backend/WorkflowApi/
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
Failed to determine the https port for redirect.
You could open your browser at http://localhost:5139 (in your case address may be different) and see usual ASP.NET MVC welcome page. Now stop the application (Ctrl+C).
Adding CORS​
Since we will be running our frontend application at a different address, we need to add CORS to the WorkflowApi
project. To do this, we
need to modify the Program
class by adding the highlighted code, for example:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
const string rule = "MyCorsRule";
builder.Services.AddCors(options =>
{
options.AddPolicy(rule, policy => policy.AllowAnyOrigin());
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors(rule);
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
Creating a user controller​
Let's add a controller to display all users via the HTTP API. To do this, we need to create a simple UserController
controller class.
in the WorkflowApi
project in the Controllers folder:
using Microsoft.AspNetCore.Mvc;
using WorkflowLib;
namespace WorkflowApi.Controllers;
[Route("api/user")]
public class UserController : ControllerBase
{
[HttpGet]
[Route("all")]
public async Task<IActionResult> GetUsers()
{
return Ok(Users.Data);
}
}
Testing user controller​
Now we want to make sure that users are returned via the HTTP API. Let's start our application:
dotnet run --project WorkflowApi
And open your browser at http://localhost:5139/api/user/all.
You should see something similar to this JSON:
[
{
"name": "Peter",
"roles": [
"User",
"Manager"
]
},
{
"name": "Margaret",
"roles": [
"User"
]
},
{
"name": "John",
"roles": [
"Manager"
]
},
{
"name": "Sam",
"roles": [
"Manager"
]
}
]
Now stop the application (Ctrl+C).
Create DTO classes​
Since we want to control the workflow with a React app, we need to add a controller to the backend that we can call later. In that controller, we will add methods to get schemas and process instances. We will need methods to execute commands and get the current status of the process instance. We also need DTO classes that will be sent via the HTTP API.
To describe the scheme of the process, we will use a simple DTO class WorkflowSchemeDto
with just two properties:
Code
- scheme code. This is a unique scheme code and is used when you need to start a process.Tags
- scheme tags. You can read more about tags in our documentation.
The WorkflowSchemeDto
class is a partial copy of SchemeEntity
class. Add the WorkflowSchemeDto
class to the WorkflowApi
project:
namespace WorkflowApi.Models;
public class WorkflowSchemeDto
{
public string Code { get; set; }
public string Tags { get; set; }
}
To describe process instances, we will use the WorkflowProcessDto
class with the following properties:
- Id - unique identifier of the process.
- Scheme - scheme code, the value is the same as in the
WorkflowSchemeDto.Code
property. - StateName - process state.
- ActivityName - current activity name.
- CreationDate - the date and time the process was created.
Add the WorkflowProcessDto
class to the WorkflowApi
project:
namespace WorkflowApi.Models;
public class WorkflowProcessDto
{
public string Id { get; set; }
public string Scheme { get; set; }
public string StateName { get; set; }
public string ActivityName { get; set; }
public string CreationDate { get; set; }
}
To describe the available commands for a process instance, we will use another DTO named WorkflowProcessCommandsDto
. It will contain the
following properties:
- Id - unique identifier of the process.
- Commands - list of available commands for the process instance.
Add the WorkflowProcessCommandsDto
class to the WorkflowApi
project:
namespace WorkflowApi.Models;
public class WorkflowProcessCommandsDto
{
public string Id { get; set; }
public List<string> Commands { get; set; }
}
Create workflow controller​
Now we have all the DTO classes, it's time to add a controller that will serve the data from the Workflow Engine. This controller will have several methods:
- Schemes - returns workflow schemes.
- Instances - returns process instances.
- CreateInstance - to create an instance of the process.
- Commands - returns the available commands of the process instance.
- ExecuteCommand - to execute the process instance command.
For the simplicity we will use MSSQLProvider to fetch the data from the database. This class has basic functionality, if you want to create complex database queries, for example JOIN, you'd better use something like Entity Framework.
Let's add the WorkflowController
class to the WorkflowApi
project. To understand how this class works, read the comments in the code.
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using OptimaJet.Workflow.Core.Entities;
using OptimaJet.Workflow.Core.Persistence;
using WorkflowApi.Models;
using WorkflowLib;
namespace WorkflowApi.Controllers;
[Route("api/workflow")]
public class WorkflowController : ControllerBase
{
/// <summary>
/// Returns process schemes from the database
/// </summary>
/// <returns>Process schemes</returns>
[HttpGet]
[Route("schemes")]
public async Task<IActionResult> Schemes()
{
// getting a connection to the database
await using var connection = WorkflowInit.Provider.OpenConnection();
// creating parameters for the "ORDER BY" clause
var orderParameters = new List<(string parameterName, SortDirection sortDirection)>
{
("Code", SortDirection.Asc)
};
// creating parameters for the "LIMIT" and "OFFSET" clauses
var paging = Paging.Create(0, 200);
// getting schemes from the database
var list = await WorkflowInit.Provider.WorkflowScheme
.SelectAllWorkflowSchemesWithPagingAsync(connection, orderParameters, paging);
// converting schemes to DTOs
var results = list.Select(s => new WorkflowSchemeDto
{
Code = s.Code,
Tags = s.Tags
});
return Ok(results);
}
/// <summary>
/// Returns process instances from the database
/// </summary>
/// <returns>Process instances</returns>
[HttpGet]
[Route("instances")]
public async Task<IActionResult> Instances()
{
// getting a connection to the database
await using var connection = WorkflowInit.Provider.OpenConnection();
// creating parameters for the "ORDER BY" clause
var orderParameters = new List<(string parameterName, SortDirection sortDirection)>
{
("CreationDate", SortDirection.Desc)
};
// creating parameters for the "LIMIT" and "OFFSET" clauses
var paging = Paging.Create(0, 200);
// getting process instances from the database
var processes = await WorkflowInit.Provider.WorkflowProcessInstance
.SelectAllWithPagingAsync(connection, orderParameters, paging);
// converting process instances to DTOs
var result = processes.Select(async p =>
{
// getting process scheme from another table to get SchemeCode
var schemeEntity = await WorkflowInit.Provider.WorkflowProcessScheme.SelectByKeyAsync(connection, p.SchemeId!);
// converting process instances to DTO
return ConvertToWorkflowProcessDto(p, schemeEntity.SchemeCode);
})
.Select(t => t.Result)
.ToList();
return Ok(result);
}
/// <summary>
/// Creates a process instance for the process scheme
/// </summary>
/// <param name="schemeCode">Process scheme code</param>
/// <returns>Process instance</returns>
[HttpGet]
[Route("createInstance/{schemeCode}")]
public async Task<IActionResult> CreateInstance(string schemeCode)
{
// generating a new processId
var processId = Guid.NewGuid();
// creating a new process instance
await WorkflowInit.Runtime.CreateInstanceAsync(schemeCode, processId);
// getting a connection to the database
await using var connection = WorkflowInit.Provider.OpenConnection();
// getting process instance from the database
var processInstanceEntity = await WorkflowInit.Provider.WorkflowProcessInstance
.SelectByKeyAsync(connection, processId);
// converting process instances to DTO
var workflowProcessDto = ConvertToWorkflowProcessDto(processInstanceEntity, schemeCode);
return Ok(workflowProcessDto);
}
/// <summary>
/// Returns process instance commands
/// </summary>
/// <param name="processId">Unique process identifier</param>
/// <param name="identityId">Command executor identifier</param>
/// <returns></returns>
[HttpGet]
[Route("commands/{processId:guid}/{identityId}")]
public async Task<IActionResult> Commands(Guid processId, string identityId)
{
// getting a process instance and its parameters
var process = await WorkflowInit.Runtime.GetProcessInstanceAndFillProcessParametersAsync(processId);
// getting available commands for a process instance
var commands = await WorkflowInit.Runtime.GetAvailableCommandsAsync(processId, identityId);
// convert process instance commands to a list of strings
var commandNames = commands?.Select(c => c.CommandName).ToList() ?? new List<string>();
// creating the resulting DTO
var dto = new WorkflowProcessCommandsDto
{
Id = process.ProcessId.ToString(),
Commands = commandNames
};
return Ok(dto);
}
/// <summary>
/// Executes a command on a process instance
/// </summary>
/// <param name="processId">Unique process identifier</param>
/// <param name="command">Command</param>
/// <param name="identityId">Command executor identifier</param>
/// <returns>true if the command was executed, false otherwise</returns>
[HttpGet]
[Route("executeCommand/{processId:guid}/{command}/{identityId}")]
public async Task<IActionResult> ExecuteCommand(Guid processId, string command, string identityId)
{
// getting available commands for a process instance
var commands = await WorkflowInit.Runtime.GetAvailableCommandsAsync(processId, identityId);
// search for the necessary command
var workflowCommand = commands?.First(c => c.CommandName == command)
?? throw new ArgumentException($"Command {command} not found");
// executing the command
var result = await WorkflowInit.Runtime.ExecuteCommandAsync(workflowCommand, identityId, null);
return Ok(result.WasExecuted);
}
/// <summary>
/// Converts ProcessInstanceEntity to WorkflowProcessDto
/// </summary>
/// <param name="processInstanceEntity">Process instance entity</param>
/// <param name="schemeCode">Scheme code</param>
/// <returns>WorkflowProcessDto</returns>
private static WorkflowProcessDto ConvertToWorkflowProcessDto(ProcessInstanceEntity processInstanceEntity, string schemeCode)
{
var workflowProcessDto = new WorkflowProcessDto
{
Id = processInstanceEntity.Id.ToString(),
Scheme = schemeCode,
StateName = processInstanceEntity.StateName,
ActivityName = processInstanceEntity.ActivityName,
CreationDate = processInstanceEntity.CreationDate.ToString(CultureInfo.InvariantCulture)
};
return workflowProcessDto;
}
}
Testing workflow controller​
Now let's check that the controller is working. Run this command in a shell:
dotnet run --project WorkflowApi
Then open your browser at http://localhost:5139/api/workflow/schemes. Then open your browser at http://localhost:5139/api/workflow/instances. In both pages you should see an empty JSON array in response:
[]
That's OK because we don't have any process schemes or process instances in our database. Stop the application (Ctrl+C).
Creating frontend application​
It's time to write our second application, where there will be a list of schemes, processes, and a Workflow Designer with the ability to
start a process and see its status. We will use create-react-app template to create a
simple React application. Open your console and go to the folder react-example
, then execute following commands:
npx create-react-app frontend
cd frontend
npm run start
You should see something similar in your console:
Compiled successfully!
You can now view frontend in the browser.
Local: http://localhost:3000
On Your Network: http://192.168.50.125:3000
Note that the development build is not optimized.
To create a production build, use npm run build.
webpack compiled successfully
Now open your browser at http://localhost:3000 (in your case address may be different) and you will see an example React application. Let's start hack it. First we will add a navigation bar to our application. We will use React Suite library.
Stop frontend application if its running (Ctrl+C). Open your console and execute command:
npm install rsuite
Then run frontend application again:
npm run start
Open frontend application in your favourite IDE. We will use JetBrains IDEA.
Adding settings to access backend APIs​
Add settings.js
file under frontend src
folder with the following content:
const backendUrl = 'http://localhost:5139';
const settings = {
workflowUrl: `${backendUrl}/api/workflow`,
userUrl: `${backendUrl}/api/user`,
designerUrl: `${backendUrl}/designer/API`
}
export default settings;
The settings
object contains following properties:
- workflowUrl - URL to access workflow API, see
WorkflowController
class. - userUrl - URL to access user API, see
UserController
class. - designerUrl - URL to access Workflow Designer API, see
DesignerController
class.
Adding schemes table​
We will now create a simple table to show the workflow schemes from our API. Add the Schemes.js
file to the frontend src
folder with
the following content:
import {Table} from "rsuite";
import {useEffect, useState} from "react";
import settings from "./settings";
const {Column, HeaderCell, Cell} = Table;
const Schemes = (props) => {
const [data, setData] = useState([]);
useEffect(() => {
fetch(`${settings.workflowUrl}/schemes`)
.then(response => response.json())
.then(data => setData(data))
}, []);
return <Table data={data}
height={400}
onRowClick={rowData => props.onRowClick?.(rowData)}>
<Column flexGrow={1}>
<HeaderCell>Code</HeaderCell>
<Cell dataKey="code"/>
</Column>
<Column flexGrow={1}>
<HeaderCell>Tags</HeaderCell>
<Cell dataKey="tags"/>
</Column>
</Table>
}
export default Schemes;
The React Schemes
component simply shows the data
in the Table
and retrieves the data
from the backend in the useEffect
hook. The
component calls the props.onRowClick
function, if it was passed through props, when the user clicks a table row.
Adding process instance table​
Add the Processes.js
file to the frontend src
folder with the following content:
import {Table} from "rsuite";
import {useEffect, useState} from "react";
import settings from "./settings";
const {Column, HeaderCell, Cell} = Table;
const Processes = (props) => {
const [data, setData] = useState([]);
useEffect(() => {
fetch(`${settings.workflowUrl}/instances`)
.then(response => response.json())
.then(data => setData(data))
}, []);
return <Table data={data}
height={400}
onRowClick={rowData => props.onRowClick?.(rowData)}>
<Column flexGrow={1}>
<HeaderCell>Id</HeaderCell>
<Cell dataKey="id"/>
</Column>
<Column flexGrow={1}>
<HeaderCell>Scheme</HeaderCell>
<Cell dataKey="scheme"/>
</Column>
<Column flexGrow={1}>
<HeaderCell>CreationDate</HeaderCell>
<Cell dataKey="creationDate"/>
</Column>
<Column flexGrow={1}>
<HeaderCell>StateName</HeaderCell>
<Cell dataKey="stateName"/>
</Column>
<Column flexGrow={1}>
<HeaderCell>ActivityName</HeaderCell>
<Cell dataKey="activityName"/>
</Column>
</Table>
}
export default Processes;
The React Processes
component works the same way as the Schemes
component, but for process instances. Pay attention to useState
,
useEffect
hooks and the onRowClick
property.
Adding users SelectPicker​
Now we need a component in which we can select the current user who will execute the process commands. Add the Users.js
file to the
frontend src
folder with the following content:
import {useEffect, useState} from "react";
import {SelectPicker} from "rsuite";
import settings from "./settings";
const Users = (props) => {
const [users, setUsers] = useState([]);
const onChangeUser = user => {
props.onChangeUser?.(user);
}
useEffect(() => {
fetch(`${settings.userUrl}/all`)
.then(response => response.json())
.then(data => {
setUsers(data);
onChangeUser(data[0].name)
})
}, []);
const data = users.map(u => {
const roles = u.roles.join(', ');
return ({label: `${u.name} (${roles})`, value: u.name})
});
return <SelectPicker data={data} style={{width: 224}} menuStyle={{zIndex: 1000}}
value={props.currentUser} onChange={onChangeUser}/>
}
export default Users;
The Users
component shows the users in the SelectPicker
and calls the props.onChangeUser
function if one was passed. Pay attention
to useState
, useEffect
hooks.
Adding component to create schemes and load schemes​
Since Workflow Designer does not have a built-in component for selecting the current scheme, we will create it. Add the SchemeMenu.js
file
to the frontend src
folder with the following content:
import {Button, ButtonGroup} from "rsuite";
import React from "react";
const SchemeMenu = (props) => {
const onClick = () => {
const newCode = prompt('Enter scheme name');
if (newCode) {
props.onNewScheme?.(newCode);
}
}
return <ButtonGroup>
<Button disabled={true}>Scheme name: {props.schemeCode}</Button>
<Button onClick={onClick}>Create or load scheme</Button>
<Button onClick={() => props.onCreateProcess?.()}>Create process</Button>
</ButtonGroup>
}
export default SchemeMenu;
The SchemeMenu
component shows ButtonGroup
with three buttons:
- Disabled button with current scheme name from property
props.schemeCode
. - A button for creating a new or loading an existing scheme. The Workflow Designer checks if there is a scheme with the entered name, if the scheme exists, then it will load it, otherwise it will create a new one.
- A button to create a new process instance on selected scheme. This button will call the
props.onCreateProcess
function if it was passed to properties.
The SchemeMenu
component calls the props.onNewScheme
function if it was passed to the properties when changing the scheme name.
Adding component to executing process instance commands​
We need to show the available commands for the selected user and process instance. Let's create a component for this. Add
the ProcessMenu.js
file to the frontend src
folder with the following content:
import React, {useEffect, useState} from "react";
import {Button, ButtonGroup, FlexboxGrid} from "rsuite";
import FlexboxGridItem from "rsuite/cjs/FlexboxGrid/FlexboxGridItem";
import settings from "./settings";
import Users from "./Users";
const ProcessMenu = (props) => {
const [commands, setCommands] = useState([]);
const [currentUser, setCurrentUser] = useState();
const loadCommands = (processId, user) => {
fetch(`${settings.workflowUrl}/commands/${processId}/${user}`)
.then(result => result.json())
.then(result => {
setCommands(result.commands)
})
}
const executeCommand = (command) => {
fetch(`${settings.workflowUrl}/executeCommand/${props.processId}/${command}/${currentUser}`)
.then(result => result.json())
.then(() => {
loadCommands(props.processId, currentUser);
props.afterCommandExecuted?.();
});
}
useEffect(() => {
loadCommands(props.processId, currentUser);
}, [props.processId, currentUser]);
const buttons = commands.map(c => <Button key={c} onClick={() => executeCommand(c)}>{c}</Button>)
return <FlexboxGrid>
<FlexboxGridItem colspan={4}>
<Users onChangeUser={setCurrentUser} currentUser={currentUser}/>
</FlexboxGridItem>
<FlexboxGridItem colspan={12}>
<ButtonGroup>
<Button disabled={true}>Commands:</Button>
{buttons}
</ButtonGroup>
</FlexboxGridItem>
</FlexboxGrid>
}
export default ProcessMenu;
The ProcessMenu
component loads the available commands in the useEffect
hook for props.processId
and currentUser
. Also pay attention
to the loadCommands
and executeCommand
functions.
Adding Designer component​
Now we are ready to add a Workflow Designer component. Open your console in frontend
folder and execute following command:
npm install @optimajet/workflow-designer-react --legacy-peer-deps
This will install latest npm package with a Workflow Designer for React. This npm package is wrapper around JavaScript Workflow Designer.
Add the Designer.js
file to the frontend src
folder with the following content:
import React, {useRef, useState} from "react";
import {Container} from "rsuite";
import WorkflowDesigner from "@optimajet/workflow-designer-react";
import settings from "./settings";
import SchemeMenu from "./SchemeMenu";
import ProcessMenu from "./ProcessMenu";
const Designer = (props) => {
const {schemeCode, ...otherProps} = {props}
const [code, setCode] = useState(props.schemeCode)
const [processId, setProcessId] = useState(props.processId)
const designerRef = useRef()
const designerConfig = {
renderTo: 'wfdesigner',
apiurl: settings.designerUrl,
templatefolder: '/templates/',
widthDiff: 300,
heightDiff: 100,
showSaveButton: !processId
};
const createOrLoad = (code) => {
setCode(code)
setProcessId(null)
const data = {
schemecode: code,
processid: undefined
}
const wfDesigner = designerRef.current.innerDesigner;
if (wfDesigner.exists(data)) {
wfDesigner.load(data);
} else {
wfDesigner.create(code);
}
}
const refreshDesigner = () => {
designerRef.current.loadScheme();
}
const onCreateProcess = () => {
fetch(`${settings.workflowUrl}/createInstance/${code}`)
.then(result => result.json())
.then(data => {
setProcessId(data.id)
const params = {
schemecode: code,
processid: data.id
};
designerRef.current.innerDesigner.load(params,
() => console.log('Process loaded'),
error => console.error(error));
});
}
return <Container style={{maxWidth: '80%', overflow: 'hidden'}}>
{!processId &&
<SchemeMenu {...otherProps} schemeCode={code}
onNewScheme={createOrLoad} onCreateProcess={onCreateProcess}/>
}
{!!processId && <ProcessMenu processId={processId} afterCommandExecuted={refreshDesigner}/>}
<WorkflowDesigner
schemeCode={code}
processId={processId}
designerConfig={designerConfig}
ref={designerRef}
/>
</Container>
}
export default Designer;
The Designer component does the following:
- Gets
schemeCode
andprocessId
fromprops
. - Renders the
WorkflowDesigner
component. - Renders the
SchemeMenu
component ifprocessId
is set. - Renders the
ProcessMenu
component ifprocessId
is not set.
The ProcessMenu
component is displayed when we are in "running process" mode, otherwise the SchemeMenu
is displayed.
Adding a custom activity​
Copy the entire contents of the templates folder
to the public/templates
folder in your frontend
project:
Copy the file public/templates/elements/activity.svg
to public/templates/elements/weatherActivity.svg
. The weatherActivity.svg
file is
the SVG template that will be displayed on the Canvas.
Copy the file public/templates/activity.html
to public/templates/weatherActivity.html
. The weatherActivity.html
file represents the
activity form. Open the weatherActivity.html
file and change the name of the function activity_Init
to weatherActivity_Init
:
...
function activity_Init(me) {
function weatherActivity_Init(me) {
...
You can learn more about custom activity here.
Combining all interface components together​
Now we have components to show schemes, process instances and a Workflow Designer. Let's combine these components together to show them on
web page. Add the AppView.js
file to the frontend src
folder with the following content:
import {Container, Content, Header, Nav, Navbar} from "rsuite";
import React, {useState} from "react";
import Schemes from "./Schemes";
import Processes from "./Processes";
import Designer from "./Designer";
const navigationItems = [
{name: 'Schemes', component: Schemes},
{name: 'Processes', component: Processes},
{name: 'Designer', component: Designer}
];
const AppView = () => {
const [tab, setTab] = useState(navigationItems[0].name);
const [schemeCode, setSchemeCode] = useState('Test1');
const [processId, setProcessId] = useState();
const items = navigationItems.map(
item => <Nav.Item key={item.name} active={tab === item.name} onClick={() => setTab(item.name)}>{item.name}</Nav.Item>);
const Child = navigationItems.find(item => item.name === tab)?.component
const childProps = {
onRowClick: (data) => {
if (data.code) {
setSchemeCode(data.code)
setProcessId(undefined)
setTab('Designer')
} else if (data.id) {
setSchemeCode(data.scheme);
setProcessId(data.id);
setTab('Designer')
}
},
schemeCode: schemeCode,
processId: processId
}
return <Container>
<Header>
<Navbar>
<Nav>
{items}
</Nav>
</Navbar>
</Header>
<Content>
<Child {...childProps}/>
</Content>
</Container>
}
export default AppView;
AppView
component renders three tabs:
- Schemes - shows workflow schemes.
- Processes - shows process instances.
- Designer - shows the Workflow Designer with a scheme or process instance.
When the user clicks on the navigation element, the setTab
function is executed and changes the active tab. Variable schemeCode
contains
the name of the current scheme, by default 'Test1'. The variable processId
contains the unique identifier of the current process, by
default undefined
, which means that the process is not running.
Now open your index.js
script and change its contents (changed lines are highlighted):
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import 'rsuite/dist/rsuite.min.css';
import AppView from "./AppView";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<AppView/>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
We now have a web interface that shows everything we need to display schemes, process instances, draw process schemes, and start processes.
Adding a process scheme​
Now start the backend application, execute the script from the Backend
folder (if the application was already running - restart it):
dotnet run --project WorkflowApi
Then run the frontend application, execute the script from the frontend
folder (if the application was already running - restart it):
npm run start
Now we need to load our scheme. To do this, save the following XML to the file Test1.xml
.
<Process Name="Test1" CanBeInlined="false" Tags="" LogEnabled="false">
<Designer />
<Actors>
<Actor Name="User" Rule="CheckRole" Value="User" />
<Actor Name="Manager" Rule="CheckRole" Value="Manager" />
</Actors>
<Commands>
<Command Name="GetWeatherForecast" />
<Command Name="SendWeatherForecast" />
<Command Name="ReRun" />
</Commands>
<Activities>
<Activity Name="InitialActivity" State="InitialActivity" IsInitial="true" IsFinal="false" IsForSetState="true" IsAutoSchemeUpdate="true">
<Designer X="390" Y="170" Hidden="false" />
</Activity>
<Activity Name="WeatherActivity" State="WeatherActivity" IsInitial="false" IsFinal="false" IsForSetState="true" IsAutoSchemeUpdate="true">
<Annotations>
<Annotation Name="__customtype"><![CDATA[WeatherActivity]]></Annotation>
</Annotations>
<Designer X="730" Y="170" Hidden="false" />
</Activity>
<Activity Name="SendEmail" State="SendEmail" IsInitial="false" IsFinal="false" IsForSetState="true" IsAutoSchemeUpdate="true">
<Annotations>
<Annotation Name="__customtype"><![CDATA[SendEmail]]></Annotation>
<Annotation Name="CcList"><![CDATA[[]]]></Annotation>
<Annotation Name="BccList"><![CDATA[[]]]></Annotation>
<Annotation Name="ReplyToList"><![CDATA[[]]]></Annotation>
<Annotation Name="To"><![CDATA[mail@gmail.com]]></Annotation>
<Annotation Name="Subject"><![CDATA[Weather]]></Annotation>
<Annotation Name="IsHTML"><![CDATA[true]]></Annotation>
<Annotation Name="Body"><![CDATA[WeatherDate: @WeatherDate
WeatherTemperature: @WeatherTemperature
Latitude: @Weather.latitude]]></Annotation>
</Annotations>
<Designer X="1100" Y="170" Hidden="false" />
</Activity>
</Activities>
<Transitions>
<Transition Name="InitialActivity_WeatherActivity_1" To="WeatherActivity" From="InitialActivity" Classifier="Direct" AllowConcatenationType="And" RestrictConcatenationType="And" ConditionsConcatenationType="And" DisableParentStateControl="false">
<Restrictions>
<Restriction Type="Allow" NameRef="User" />
</Restrictions>
<Triggers>
<Trigger Type="Command" NameRef="GetWeatherForecast" />
</Triggers>
<Conditions>
<Condition Type="Always" />
</Conditions>
<Designer Hidden="false" />
</Transition>
<Transition Name="WeatherActivity_SendEmail_1" To="SendEmail" From="WeatherActivity" Classifier="Direct" AllowConcatenationType="And" RestrictConcatenationType="And" ConditionsConcatenationType="And" DisableParentStateControl="false">
<Restrictions>
<Restriction Type="Allow" NameRef="Manager" />
</Restrictions>
<Triggers>
<Trigger Type="Command" NameRef="SendWeatherForecast" />
</Triggers>
<Conditions>
<Condition Type="Always" />
</Conditions>
<Designer Hidden="false" />
</Transition>
<Transition Name="SendEmail_InitialActivity_1" To="InitialActivity" From="SendEmail" Classifier="Reverse" AllowConcatenationType="And" RestrictConcatenationType="And" ConditionsConcatenationType="And" DisableParentStateControl="false">
<Restrictions>
<Restriction Type="Restrict" NameRef="User" />
</Restrictions>
<Triggers>
<Trigger Type="Command" NameRef="ReRun" />
</Triggers>
<Conditions>
<Condition Type="Always" />
</Conditions>
<Designer X="816" Y="342" Hidden="false" />
</Transition>
</Transitions>
</Process>
Open a browser at http://localhost:3000/ and click the 'Designer' tab. We will add only one process scheme, since the free license does not allow us to make more schemes. To obtain a license, you can email sales@optimajet.com.
Click on the Menu -> File -> Upload scheme
and choose Test1.xml
files. Then click on Save button to save the scheme.
Now we have a process scheme:
The last thing we need is to change the email address in the SendEmail
activity. Write your email address here:
Then click the 'Save' button and then click the 'Save' button on the toolbar to save the scheme.
Starting and executing the process​
Click the 'Create process' button:
Now the process is in InitialActivity
(highlighted in yellow) activity. And there is one 'GetWeatherForecast' command available for a user
with the 'User' role (Peter, Margaret). If you select John or Sam in select, the 'GetWeatherForecast' command will disappear because they
only have the 'Manager' role.
Now click the 'GetWeatherForecast' button to execute the command and the process will switch to the WeatherActivity
activity. And there is
one 'SendWeatherForecast' command available for a user with the 'Manager' role (Peter, John, Sam):
Click the 'Process info' button (the letter 'P' in the circle) on the toolbar and go to the 'Process Parameters' tab in the window
that opens to see the process parameters. Note that here you can see the process parameters that were saved using our custom activity class
WeatherActivity
.
Select the Sam user and click the 'SendWeatherForecast' button. Now the process is in SendEmail
activity. And there is
one 'ReRun' command available for a user without the 'User' role (John, Sam):
If all your settings have been done correctly, you should see something similar to this email in your inbox:
Weather
WeatherDate: 2022-10-07 WeatherTemperature: 5,7 Latitude: 52,52
Select the John user and click the 'Rerun' button. Now the process is in InitialActivity
activity again:
You can execute the commands for this process again, but let's go to the 'Schemes' tab (click on it):
This is a table with schemes, and it has only one row with the scheme name 'Test1'. Click on this row to open the scheme in the Workflow Designer. Click the 'Process info' button (the letter 'P' in the circle) on the toolbar and enter the tags in the 'Tags' field:
Then click the 'Save' button and then click the 'Save' button on the toolbar to save the scheme. After that, go to the 'Schemes' tab to view the data in the schemes table.
Learn more about scheme tags here. Go to the 'Processes' tab to view the table with process instances:
In this table you can see rows with the process instances. The data for this table is obtained through the WorkflowController.Instances
method in the WorkflowApi
project. You can click on a row to open a process instance in the Workflow Designer.
Conclusion​
Wow, it's been a long journey! Now you have a simple admin panel where you can see your process schemas, process instances, and Workflow
Designer. You can change our simple process - add actions, commands and more. You can also change your Backend
project and add more useful
stuff. Check out our documentation to learn more.