Skip to main content

Introducing Formengine - The New Formbuilder, try for FREE formengine.io.

How to Connect Forms to Workflow Engine

In this section, we’ll give a high-level overview of how to connect forms that run alongside workflow processes to your application. Before you begin, we strongly recommend reviewing the sample project we provide and reading the detailed description of all form settings in the following sections.

In short, here’s the sequence of steps:

  1. Connect Workflow Engine to your application.
  2. Connect the Forms Plugin to the Workflow Runtime.
  3. Implement the Forms Controller.
  4. If you don’t yet have a controller for the designer API, implement one.
  5. Add the Forms Viewer component to your frontend.
  6. Add the Forms Manager component to your frontend.

Backend

Connect the Forms Plugin to the Workflow Runtime

var formsPluginSettings = new FormsPluginSettings
{
FormsManagerUrl = configuration.Workflow.FormsManagerUrl,
};

var formsPlugin = new FormsPlugin(formsPluginSettings);
WorkflowRuntime runtime = new WorkflowRuntime()
.WithPlugin(formsPlugin)
...

You only need to specify one required parameter, FormsManagerUrl. It has a single purpose: to let the process-scheme designer open the form designer at the right moment. This will be explained in detail in later sections and doesn’t significantly affect the plugin’s behavior.

Implement the Forms Controller

You’ll need to implement the Forms Controller methods yourself because frontend-to-backend communication differs across projects. The required methods are straightforward, and we plan to provide a standard implementation soon in the Workflow Engine Web API project. You can try them out in the sample project discussed in the following sections.

You need one method that retrieves a form by its name and version number. It’s straightforward—it just returns the form without tying it to any process.

[HttpGet]
[Route("form")]
public async Task<ActionResult<Form>> GetForm([FromQuery] string formName, [FromQuery] int? formVersion)
{
GetFormResult formResponse = await _runtime.GetFormsRuntimeApi().GetFormAsync(new GetFormParameters
{
FormKey = new FormKey { FormName = formName, FormVersion = formVersion }
});

return formResponse.Match<ActionResult>(
ok => Ok(ok.Form),
error => Problem(error.Message, statusCode: 500)
);
}

Next, you need a method that returns the list of forms available to a specific user within a specific process. Note that you don’t have to read the user ID from the request; most likely you’ll get it from the application context. This code is copied from the sample project, where, for simplicity, there is no authorization or authentication, but there is a user ID that can be switched.

[HttpGet]
[Route("get")]
public async Task<ActionResult<ExecutableForm[]>> GetForms([FromQuery] Guid processId, [FromQuery] string user)
{
FormsRuntimeApi formsPluginRuntimeApi = _runtime.GetFormsRuntimeApi();
GetExecutableFormsResult executableFormsResponse = await formsPluginRuntimeApi
.GetExecutableFormsAsync(new GetExecutableFormsParameters { ProcessId = processId, IdentityId = user, ConditionCheck = true });

return executableFormsResponse.Match<ActionResult>(
ok => Ok(ok.Forms.Select(f => f with { FormData = f.FormData.ToCamelCase() }).ToArray()),
error => Problem(error.Message, statusCode: 500)
);
}

Now we need a method that saves the form without advancing it through the workflow.

public record SaveFormApiRequest(FormKey FormKey, Guid ProcessId, string User, Dictionary<string, object?> Data);

[HttpPost]
[Route("save")]
public async Task<ActionResult<object>> SaveForm([FromBody] SaveFormApiRequest request)
{
Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();

SaveFormResult response = await _runtime.GetFormsRuntimeApi().SaveFormAsync(new()
{
FormKey = request.FormKey, ProcessId = request.ProcessId, IdentityId = request.User, FormData = pascalCaseData
});

return response.Match<ActionResult>(
ok => Ok(ok.Data.ToCamelCase()),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: 500)
);
}

Finally, you need a method that executes a command together with the form.

public record ExecuteFormApiRequest(FormKey FormKey, string CommandName, Guid ProcessId, string User, Dictionary<string, object?> Data);

public record ExecuteFormApiResponse(bool WasExecuted);

[HttpPost]
[Route("execute")]
public async Task<ActionResult<ExecuteFormApiResponse>> ExecuteForm([FromBody] ExecuteFormApiRequest request)
{
Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();

ExecuteFormResult response = await _runtime.GetFormsRuntimeApi()
.ExecuteFormAsync(
new ExecuteFormParameters
{
FormKey = request.FormKey,
CommandName = request.CommandName,
ProcessId = request.ProcessId,
IdentityId = request.User,
FormData = pascalCaseData
});

return response.Match<ActionResult>(
ok => Ok(new ExecuteFormApiResponse(ok.WasExecuted)),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: 500)
);
}

In general, those are all the essential methods for form interaction in the user-facing application. Keep in mind that we continually convert data from PascalCase to camelCase. You don’t have to do this, but remember that the Form Engine binds data by component keys on the form; if everything coming from your server is in PascalCase, you’ll need to name the keys with an initial capital letter.

Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();
...
return response.Match<ActionResult>(
ok => Ok(ok.Data.ToCamelCase()),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: 500)
);

Implement the Designer Controller

If you already know how to connect the process-scheme designer, then you’ve already implemented this controller, and all the methods for saving forms are in place. If not, read this section.

Frontend

Connect the Forms Viewer component

On the client side, in the end-user application, you need to implement a component that renders the form to the user. Important points:

  • showError — a function in your app for displaying errors; you probably already have one.
  • id — the workflow process ID (in this example it matches the document ID and is taken from the URL).
  • selectedUser — in the sample code this value comes from a dropdown of usernames. As mentioned earlier, you might not need the user ID at all if you retrieve it from the backend context.
  • apiUrl — the base URL of your backend application.
import {Form, FormKey, FormsViewer} from '@optimajet/workflow-forms-viewer'

...

export function UserForm({apiUrl}: AppProps) {

const {id} = useParams()
const {selectedUser} = useAppState()
const showError = useShowError()

const getForm = useCallback(async (formKey: FormKey) => {
...
}, [apiUrl])

const getForms = useCallback(async () => {
...
}, [apiUrl, id, selectedUser])

const saveForm = useCallback(async (processId: string, formKey: FormKey, data: Record<string, unknown>) => {
...
}, [apiUrl, selectedUser])

const executeForm = useCallback(async (processId: string, formKey: FormKey, commandName: string, data: Record<string, unknown>) => {
...
}, [apiUrl, selectedUser])

return <FormsViewer getForm={getForm} getForms={getForms} onError={showError} onSuccess={showSuccess} saveForm={saveForm}
executeForm={executeForm}
/>

}

In short, you need to implement four functions:

  • getForm — calls the controller’s GetForm method described above.
  • getForms — calls the controller’s GetForms method described above.
  • saveForm — calls the controller’s SaveForm method described above.
  • executeForm — calls the controller’s ExecuteForm method described above.

Four functions on the client, four on the server—nothing too complicated. Next, pass these functions to the FormsViewer component from the @optimajet/workflow-forms-viewer library, which you can install by running the command.

npm install @optimajet/workflow-forms-viewer

A sample implementation of the functions is as follows.

Function getForm.

 const getForm = useCallback(async (formKey: FormKey) => {
const formVersion = typeof formKey.formVersion === 'number' ? `&formVersion=${formKey.formVersion}` : ''
const response = await fetch(`${apiUrl}/reports/forms/form?formName=${formKey.formName}${formVersion}`)
if (response.ok) return ((await response.json()) as Form).formCode
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl])

Function getForms.

  const getForms = useCallback(async () => {
const response = await fetch(`${apiUrl}/reports/forms/get?processId=${id}&user=${selectedUser}`)
if (response.ok) return await response.json() as Form[]
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, id, selectedUser])

Function saveForm.

Note that validation errors are returned with an HTTP 400 (Bad Request) status code. You don’t have to handle them this way in every case—you can adjust the implementation as needed.

const saveForm = useCallback(async (processId: string, formKey: FormKey, data: Record<string, unknown>) => {
const postData = {
formKey: formKey,
processId: processId,
user: selectedUser,
data: data
}
const response = await fetch(`${apiUrl}/reports/forms/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(postData)
})
if (response.ok) return {formData: await response.json()}
if (response.status === 400) return {formErrors: await response.json()}
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, selectedUser])

Function executeForm.

Note that validation errors are returned with an HTTP 400 (Bad Request) status code. You don’t have to handle them this way in every case—you can adjust the implementation as needed.

const executeForm = useCallback(async (processId: string, formKey: FormKey, commandName: string, data: Record<string, unknown>) => {
const postData = {
formKey: formKey,
processId: processId,
commandName: commandName,
user: selectedUser,
data: data
}
const response = await fetch(`${apiUrl}/reports/forms/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(postData)
})
if (response.ok) return {wasExecuted: (await response.json()).wasExecuted}
if (response.status === 400) return {formErrors: await response.json()}
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, selectedUser])

Connect the Forms Manager component

On the client side, in the application available to administrators or power users, implement a component that renders the form editor. Important points:

  • showError — a function in your app used to display errors (you likely already have one).
  • licenseKey — the license key for Form Engine (yes, it’s a separate product and requires its own license).
  • apiUrl — the base URL of your backend application.
import {FormsManager} from '@optimajet/workflow-forms-manager'

export function FormsManagerPage({apiUrl, licenseKey}: AppProps) {
const showError = useShowError()
return <FormsManager apiUrl={`${apiUrl}/designer`} licenseKey={licenseKey} onError={showError}>
</FormsManager>
}

It’s straightforward, but keep in mind that the apiUrl of the FormsManager component must ultimately point to the controller that exposes the workflow-scheme designer API. The FormsManager component lives in the @optimajet/workflow-forms-manager library, which you can install by running the command.

npm install @optimajet/workflow-forms-manager

Once integration is complete, the next section will explore the capabilities of the Forms Plugin.