Skip to main content

Dynamic Workflow Model

In most parts of this documentation and our example, the model is static. At development time, we define the model, the steps, and the properties of each step. However, in some cases, we need to create a dynamic model. For example, we may have a separate editor outside the workflow designer that allows the user to define steps, etc. This issue also applies to simpler scenarios, such as dynamic choices in a dropdown. This article explains how to handle these scenarios.

Dynamic Choices

The static choice was described in the Choice Value Model article. According to the article, for predefined choices, we can simply use the createChoiceValueModel function to define the value model and pass the choices in the configuration.

interface CreateArticleStepModel extends StepModel {
type: 'createArticle';
properties: {
category: string;
};
}

const createArticleStepModel = createStepModel<ChoiceStepModel>('createArticle', 'task', step => {
// ...
builder.property('category').value(createChoiceValueModel({
choices: ['red', 'blue', 'green'],
}));
});

But what if we need to load the choices dynamically? For example, if we have a REST API that returns a list of categories, we’ll need to adjust the code slightly. Let’s start by fetching the categories:

function fetchCategories(): Promise<string[]> {
return (await fetch('https://api/categories')).json();
}

Next, we need to adjust the model creation:

import { createStepModel, createChoiceValueModel } from 'sequential-workflow-editor-model'

function createCreateArticleStepModel(categories: string[]) {
return createStepModel<ChoiceStepModel>('createArticle', 'task', step => {
// ...
builder.property('category').value(createChoiceValueModel({
choices: categories,
}));
});
}

This change affects how we create the entire model. Now, we can't add the step model directly to the definition model. Instead, we need to wrap the definition model creation in a function similar to what we did for the createArticle step.

import { createDefinitionModel } from 'sequential-workflow-editor-model'

function createMyDefinitionModel(configuration: { categories: string[] }) {
return createDefinitionModel(model => {
// ...
model.steps([
createCreateArticleStepModel(configuration.categories)
]);
});
}

In conclusion, to create a model with dynamic choices, we need to fetch the choices and pass them to the createMyDefinitionModel function.

const categories = await fetchCategories();
const definitionModel = createMyDefinitionModel({ categories });

This structure of the code requires loading all the necessary data before creating the model. The general flow in the frontend is:

load data -> create model -> render designer & editor

Dynamic Steps

To handle dynamic steps, we essentially need to follow the approach described in the previous section. First, we need to fetch the configuration data, and then create the model.

interface UserStep {
label: string;
type: string;
// ...
}

function fetchUserSteps(): Promise<UserStep[]> {
return (await fetch('https://api/user-steps')).json();
}

To create dynamic steps, we need to modify the model creation process:

import { Step, createStepModel, createDefinitionModel } from 'sequential-workflow-editor-model'

function createUserStepModel(userStep: UserStep) {
return createStepModel<Step>(userStep.type, 'task', step => {
step.label(userStep.label);
// ...
});
}

function createMyDefinitionModel(configuration: { userSteps: UserStep[] }) {
return createDefinitionModel(model => {
// ...
model.steps([
...userSteps.map(createUserStepModel)
]);
});
}

Within the createUserStepModel function, you need to map your configuration format to the calls that build the step model. This logic strictly depends on your needs.

Validation of Dynamic Model

The main goal of the sequential workflow editor is to introduce an easily validated model that can be validated in both the frontend and backend. However, as you may notice in this article, we have quite a bit of code that reads data from the server. We should not fetch data in the backend in the same way we do in the frontend. So, what should we do? The answer is quite simple: we need to create an abstraction for data fetching and develop two different implementations: one for the frontend and one for the backend. If we combine this approach with the idea from the Sharing Types Between Frontend and Backend article, we should be able to create an abstract interface in the model package and two implementations in the frontend and backend packages.

// @myapp/model
interface ModelConfiguration {
// ...
}
interface ModelConfigurationProvider {
getConfiguration(): Promise<ModelConfiguration>;
}

// @myapp/frontend
class FrontendModelConfigurationProvider implements ModelConfigurationProvider {
// ...
public async getConfiguration(): Promise<ModelConfiguration> {
return (await fetch('https://api/configuration')).json();
}
}

// @myapp/backend
class BackendModelConfigurationProvider implements ModelConfigurationProvider {
// ...
public async getConfiguration(): Promise<ModelConfiguration> {
return await dbStorage.getConfiguration();
}
}

Then the code responsible for creating the model should look like this:

const provider = // FrontendModelConfigurationProvider or BackendModelConfigurationProvider
const configuration = await provider.getConfiguration();
const definitionModel = createMyDefinitionModel(configuration);