Introduction | how to implement a process designer that meets business requirements

Posted by billybathgate on Sun, 02 Jan 2022 06:54:34 +0100

background

In low code scenarios, process is an essential capability. The process capability is to give users the ability to trigger an asynchronous task flowing at different time nodes through a form. The most common is leave application. Users submit a leave application form. After submitting it successfully, the process starts running. The process status will not move to the next step until the next node is processed by the corresponding personnel. So how to put the process designer we conceived into practice? Today I'll teach you hand in hand!

process design

First, process design is required. For the front end, the process designer needs to meet the following points:

  1. Users create a process based on a form
  2. Nodes that can define processes
  3. You can define the configuration corresponding to the process node

The node defining the process is to define each link of asynchronous task and the relationship between them. The process node configuration is a form for implementation. This form can configure the capabilities required for the current step, such as the form fields triggered by the process, approvers, etc.

Therefore, we can determine the basic data model of the process. The first intuition may consider using a directed acyclic graph to represent the whole process. The nodes in the graph correspond to the nodes of the process, and the lines between nodes represent the pre relationship between processes. However, the processing of the graph is too complex. Here, we consider using array description. The elements of the array include nodes and edges. Each node has a node name and id, and the edges between nodes contain the node id information.

Data interface definition

The following are the data model interfaces defined using typescript:

export interface Node<T = any> {
  id: ElementId;
  position: XYPosition;
  type?: string;
  __rf?: any;
  data?: T;
  style?: CSSProperties;
  className?: string;
  targetPosition?: Position;
  sourcePosition?: Position;
  isHidden?: boolean;
  draggable?: boolean;
  selectable?: boolean;
  connectable?: boolean;
  dragHandle?: string;
}
export interface Edge<T = any> {
  id: ElementId;
  type?: string;
  source: ElementId;
  target: ElementId;
  sourceHandle?: ElementId | null;
  targetHandle?: ElementId | null;
  label?: string | ReactNode;
  labelStyle?: CSSProperties;
  labelShowBg?: boolean;
  labelBgStyle?: CSSProperties;
  labelBgPadding?: [number, number];
  labelBgBorderRadius?: number;
  style?: CSSProperties;
  animated?: boolean;
  arrowHeadType?: ArrowHeadType;
  isHidden?: boolean;
  data?: T;
  className?: string;
}
export type FlowElement<T = any> = Node<T> | Edge<T>;
export interface Data {
  nodeData: Record<string, any>;
  businessData: Record<string, any>;
}
export interface WorkFlow {
  version: string;
  shapes: FlowElement<Data>[];
}

The definition of the whole data model is very clear. The overall structure is a WorkFlow, in which the version is the version, and the shapes are the array collection of edges and nodes. The FlowElement can be either a Node or an Edge (Edge). For a Node, there will be data in it, which can be accessed through the data attribute of the Node. And the data is divided into two parts. One part is the metadata of the Node, which we call nodeData, and the other part is the business data corresponding to the Node, which we call businessData. Metadata is mainly used when drawing flow charts, and business data is mainly used for process engines Used during execution.

Process implementation

For the implementation of the process designer, we use the open source library react flow renderer, which has the following advantages:

  1. Easily implement custom nodes
  2. Custom edge
  3. Preset graphic controls such as mini map

The react flow renderer only needs to pass in the shapes array to render the whole flow chart. Before passing in, it needs to perform layout processing on the elements. For the layout, we will use the dagre graphic layout library. The following is the layout implementation.

Flow chart layout

import store from './store';

import useObservable from '@lib/hooks/observable';

function App() {
  const { elements } = useObservable(store);
  const [dagreGraph, setDagreGraph] = useState(() => new dagre.graphlib.Graph());
  dagreGraph.setDefaultEdgeLabel(() => ({}));
  dagreGraph.setGraph({ rankdir: 'TB', ranksep: 90 });

  elements?.forEach((el) => {
    if (isNode(el)) {
      return dagreGraph.setNode(el.id, {
        width: el.data?.nodeData.width,
        height: el.data?.nodeData.height,
      });
    }
    dagreGraph.setEdge(el.source, el.target);
  });
  dagre.layout(dagreGraph);
  const layoutedElements = elements?.map((ele) => {
    const el = deepClone(ele);
    if (isNode(el)) {
      const nodeWithPosition = dagreGraph.node(el.id);
      el.targetPosition = Position.Top;
      el.sourcePosition = Position.Bottom;
      el.position = {
        x: nodeWithPosition.x - ((el.data?.nodeData.width || 0) / 2),
        y: nodeWithPosition.y,
      };
    }
    return el;
  });

  return (
    <>
      <ReactFlow
        className="cursor-move"
        elements={layoutedElements}
      />
    </>
  )
}

When using dagre, you first need to call dagre graphlib. Graph generates an instance. setDefaultEdgeLabel is used to set the label. Since it is not required here, it is set to null. setGraph is used to configure graph attributes. By specifying rankdir as TB, it means that the layout is from top to bottom, that is, from top to bottom. ranksep represents the distance between nodes. Next, we only need the loop element to set the node through setNode, set it to dagre through setEdge, and then call dagre.. Just layout. When setting a node, you need to specify the node id and the width and height of the node. When setting an edge, you only need to specify the id of the start and end points of the edge. Finally, we need to fine tune the position of the nodes, because the default layout of dagre is vertically aligned to the left. We need to align it vertically in the middle, so we need to subtract half of its width.

Custom node

For the node part, since the default node type cannot meet the requirements, we need to customize the node. Here, take the end node as an example:

import React from 'react';
import { Handle, Position } from 'react-flow-renderer';

import Icon from '@c/icon';

import type { Data } from '../type';

function EndNodeComponent({ data }: { data: Data }): JSX.Element {
  return (
    <div
      className="shadow-flow-header rounded-tl-8 rounded-tr-8 rounded-br-0 rounded-bl-8
        bg-white w-100 h-28 flex items-center cursor-default"
    >
      <section className="flex items-center p-4 w-full h-full justify-center">
        <Icon name="stop_circle" className="mr-4 text-red-600" />
        <span className="text-caption-no-color-weight font-medium text-gray-600">
          {data.nodeData.name}
        </span>
      </section>
    </div>
  );
}

function End(props: any): JSX.Element {
  return (
    <>
      <Handle
        type="target"
        position={Position.Top}
        isConnectable={false}
      />
      <EndNodeComponent {...props} />
    </>
  );
}

export const nodeTypes = { end: EndNode };

function App() {
  // Omit some contents
  return (
    <>
      <ReactFlow
        className="cursor-move"
        elements={layoutedElements}
        nodeTypes={nodeTypes}
      />
    </>
  )
}

The user-defined node is specified by nodeTypes. Here we specify the user-defined end node. The specific implementation of the node lies in the function component EndNode. When creating a node, we only need to specify the node type as end to render using EndNode. The logic of creating a node is as follows:

function nodeBuilder(id: string, type: string, name: string, options: Record<string, any>) {
  return {
    id,
    type,
    data: {
      nodeData: { name },
      businessData: getNodeInitialData(type)
    }
  }
}
const endNode = nodeBuilder(endID, 'end', 'end', {
  width: 100,
  height: 28,
  parentID: [startID],
  childrenID: [],
})
elements.push(endNode);

Custom edge

The custom edge is similar to the custom node. It is completed by specifying edgeTypes. The following is an example code for implementing the custom edge:

import React, { DragEvent, useState, MouseEvent } from 'react';
import { getSmoothStepPath, getMarkerEnd, EdgeText, getEdgeCenter } from 'react-flow-renderer';
import cs from 'classnames';

import ToolTip from '@c/tooltip/tip';

import type { EdgeProps, FormDataData } from '../type';

import './style.scss';

export default function CustomEdge({
  id,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
  style = {},
  label,
  arrowHeadType,
  markerEndId,
  source,
  target,
}: EdgeProps): JSX.Element {
  const edgePath = getSmoothStepPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
    borderRadius: 0,
  });
  const markerEnd = getMarkerEnd(arrowHeadType, markerEndId);
  const [centerX, centerY] = getEdgeCenter({
    sourceX,
    sourceY,
    targetX,
    targetY,
    sourcePosition,
    targetPosition,
  });

  const formDataElement = elements.find(({ type }) => type === 'formData');
  const hasForm = !!(formDataElement?.data?.businessData as FormDataData)?.form.name;
  const cursorClassName = cs({ 'cursor-not-allowed': !hasForm });

  return (
    <>
      <g
        className={cs(cursorClassName, { 'opacity-50': !hasForm })}
      >
        <path
          id={id}
          style={{ ...style, borderRadius: '50%' }}
          className={cs('react-flow__edge-path cursor-pointer pointer-events-none', cursorClassName)}
          d={edgePath}
          markerEnd={markerEnd}
        />
        {status === 'DISABLE' && (
          <EdgeText
            className={cursorClassName}
            style={{
              filter: 'drop-shadow(0px 8px 24px rgba(55, 95, 243, 1))',
              pointerEvents: 'all',
            }}
            x={centerX}
            y={centerY}
            label={label}
          />
        )}
      </g>
      {!hasForm && (
        <foreignObject
          className="overflow-visible workflow-node--tooltip"
          x={centerX + 20}
          y={centerY - 10}
          width="220"
          height="20"
        >
          <ToolTip
            label="Please select a worksheet for the start node"
            style={{
              transform: 'none',
              backgroundColor: 'transparent',
              alignItems: 'center',
            }}
            labelClassName="whitespace-nowrap text-12 bg-gray-700 rounded-8 text-white pl-5"
          >
            <span></span>
          </ToolTip>
        </foreignObject>
      )}
    </>
  );
}

export const edgeTypes = {
  plus: CustomEdge,
};

function App() {
  // Omit some contents
  return (
    <>
      <ReactFlow
        className="cursor-move"
        elements={layoutedElements}
        nodeTypes={nodeTypes}
        edgeTypes={edgeTypes}
      />
    </>
  )
}

The specific implementation of the edge lies in the CustomEdge function component. Here, we display a plus sign in the middle of the edge by using the EdgeText component provided by react flow renderer, so that the processing events when clicking the plus sign can be added later. EdgeText needs to pass in a label as the displayed content, and then specify the coordinates of the content display. Here, let the text be displayed in the middle of the edge. The middle position of the edge is calculated by calling getEdgeCenter. During calculation, the coordinates of the starting point and the ending point need to be passed in.

There are four kinds of anchor points for the starting point: Left, Top, Right and Bottom, which respectively represent the middle positions of the Left, Top, Right and lower edges of a node. The starting positions of the edges between nodes are connected with the anchor point. In addition, it is also necessary to determine whether the element with type formData is configured with a form. If not, set cursor not allow to disable user interaction and prompt the user to select a worksheet to continue. This is because our process needs to specify a trigger form to be meaningful.

Next, we only need to specify the type of edge as plus when creating an edge to render with CustomEdge. The logic of creating an edge is as follows:

export function edgeBuilder(
  startID?: string,
  endID?: string,
  type = 'plus',
  label = '+',
): Edge {
  return {
    id: `e${startID}-${endID}`,
    type,
    source: startID as string,
    target: endID as string,
    label,
    arrowHeadType: ArrowHeadType.ArrowClosed,
  };
}

const edge = edgeBuilder('startNodeId', 'end');
elements.push(edge);

The above creates an edge of type plus, where startNodeId and end are the start node and end node of the edge respectively. If no type is specified, it defaults to plus type, and label displays a plus sign by default.

Add node

After you have custom nodes and custom edges, you can add new nodes. There are many ways to add new nodes. You can drag or click. Here, you can use more intuitive dragging.

First, add click event processing to the edge. After the user clicks the plus sign, the available nodes are displayed for the user to drag.

import store, { updateStore } from './store';

export type CurrentConnection = {
  source?: string;
  target?: string;
  position?: XYPosition;
}
export default function CustomEdge(props: EdgeProps): JSX.Element {
  function switcher(currentConnection: CurrentConnection) {
    updateStore((s) => ({ ...s, currentConnection, nodeIdForDrawerForm: 'components' }));
  }

  function onShowComponentSelector(e: MouseEvent<SVGElement>): void {
    e.stopPropagation();
    if (!hasForm) {
      return;
    }
    switcher({ source, target, position: { x: centerX, y: centerY } });
  }

  return (
    <EdgeText
      // ...  Omit some contents
      onClick={onShowComponentSelector}
      // ...  Omit some contents
    />
  )
}

After the user clicks the plus sign, we first prevent the event from bubbling and trigger the default event processing mechanism of react flow renderer. Then judge whether the user has configured the worksheet. If not, do nothing; Otherwise, the id of the start node and end node corresponding to the current edge and the midpoint position of the edge need to be recorded in the state store. When changing the status, specify nodeIdForDrawerForm as components, and the display of node selection sidebar will be triggered at the consumer end of the status. The code of node selector is as follows:

import React from 'react';

import useObservable from '@lib/hooks/use-observable';
import Drawer from '@c/drawer';

import store, { toggleNodeForm } from '../store';

function DragNode({
  text, type, width, height, iconName, iconClassName
}: RenderProps): JSX.Element {
  function onDragStart(event: DragEvent, nodeType: string, width: number, height: number): void {
    event.dataTransfer.setData('application/reactflow', JSON.stringify({
      nodeType,
      nodeName: text,
      width,
      height,
    }));
    event.dataTransfer.effectAllowed = 'move';
  }

  return (
    <div
      className="bg-gray-100 rounded-8 cursor-move flex items-center overflow-hidden
       border-dashed hover:border-blue-600 border transition"
      draggable
      onDragStart={(e) => onDragStart(e, type, width, height)}
    >
      <Icon name={iconName} size={40} className={cs('mr-4 text-white', iconClassName)} />
      <span className="ml-16 text-body2">{text}</span>
    </div>
  );
}

const nodeLists = [{
  text: 'Custom node 1',
  type: 'type1',
  iconName: 'icon1',
  iconClassName: 'bg-teal-500',
}, {
  text: 'Custom node 2',
  type: 'type2',
  iconName: 'icon2',
  iconClassName: 'bg-teal-1000',
}];

export default function Components(): JSX.Element {
  const { nodeIdForDrawerForm } = useObservable(store);

  return (
    <>
      {nodeIdForDrawerForm === 'components' && (
        <Drawer
          title={(
            <div>
              <span className="text-h5 mr-16">Select a component</span>
              <span className="text-caption text-underline">💡 Understanding components</span>
            </div>
          )}
          distanceTop={0}
          onCancel={() => toggleNodeForm('')}
          className="flow-editor-drawer"
        >
          <div>
            <div className="text-caption-no-color text-gray-400 my-12">Manual processing</div>
            <div className="grid grid-cols-2 gap-16">
              {nodeLists.map((node) => (
                <DragNode
                  {...node}
                  key={node.text}
                  width={200}
                  height={72}
                />
              ))}
            </div>
          </div>
        </Drawer>
      )}
    </>
  );
}

function App() {
  // Omit some contents

  return (
    <>
      {/* Omit some contents */}
      <Components />
    </>
  )
}

Here, the Components component is introduced into the App, and the node selector is implemented by the Components component. The implementation process is also very simple. Each DragNode is rendered circularly through the Drawer component, and the DragNode renders each node information according to nodeLists. Of course, whether Components render depends on whether nodeidfordrawerform = = 'Components' is established. Finally, when each node starts dragging, store the node name, node type and node width and height through dataTransfer through onDragStart.

When the user drags the node to the middle of the connection, we can do the following:

import { nanoid } from 'nanoid';
import { XYPosition } from 'react-flow-renderer';

import { updateStore } from './store';
import { nodeBuilder } from './utils';

function setElements(eles: Elements): void {
  updateStore((s) => ({ ...s, elements: eles }));
}

function getCenterPosition(position: XYPosition, width: number, height: number): XYPosition {
  return { x: position.x - (width / 2), y: position.y - (height / 2) };
}

function onDrop(e: DragEvent): Promise<void> {
  e.preventDefault();
  if (!e?.dataTransfer) {
    return;
  }
  const { nodeType, width, height, nodeName } = JSON.parse(
    e.dataTransfer.getData('application/reactflow'),
  );
  const { source, target, position } = currentConnection;
  if (!source || !target || !position) {
    return;
  }
  addNewNode({ nodeType, width, height, nodeName, source, target, position });
  updateStore((s) => ({ ...s, currentConnection: {} }));
}

function App() {
  const { elements } = useObservable(store);
  // Omit some contents

  function addNewNode({ nodeType, width, height, nodeName, source, target, position }) {
    const id = nodeType + nanoid();
    const newNode = nodeBuilder(id, nodeType, nodeName, {
      width,
      height,
      parentID: [source],
      childrenID: [target],
      position: getCenterPosition(position, width, height),
    });
    let newElements = elements.concat([newNode, edgeBuilder(source, id), edgeBuilder(id, target)];
    newElements = removeEdge(newElements, source, target);
    setElements(newElements);
  }

  return (
    <>
      {/* Omit some contents */}
      <ReactFlow
        onDrop={onDrop}
      />
    </>
  )
}

When dragging and dropping, execute onDrop, call preventDefault to prevent triggering the default behavior of react flow renderer, and then judge whether there is dataTransfer, otherwise return. Next, get the node type, node width, node height and node name through nodeType, width, height and nodeName respectively, and then call addNewNode to add a new node.

After completion, we need to reset the currentConnection to the initial value. The process of addNewNode generates a id for nodes, and uses nodeType as a prefix, and then calls nodeBuilder to generate new nodes. Note that we record the source and target in the childrenID and parentID for subsequent node operations. getCenterPosition is used to obtain the position of the node. Since the node needs to be placed at the midpoint of the line, the starting point of the node should be the original starting point minus half of the width and height of the node.

After the required node is ready, you need to generate two edges for the new node and delete the connection between the previous start and end nodes. Here, it is implemented through edgeBuilder and removeEdge respectively. removeEdge is provided by react flow renderer, which receives three parameters: the first is an array of elements; The second is the id of the starting node; The third is the id of the end node. The final return is a new array of elements that deletes the connection between the start node and the end node. Finally, update the new element array to the store state through setElements to update the canvas.

Delete node

With the ability to add nodes, the next step is to delete nodes. First, you need to render a delete button in the upper right corner of the node. When the user clicks the delete button, the current node and the edges associated with the current node will be deleted together. The following is the implementation of deleting node components:

import { FlowElement, removeElements } from 'react-flow-renderer';

import Icon from '@c/icon';
import useObservable from '@lib/util/observable';

import store, { updateStore } from './store';

function onRemoveNode(nodeID: string, elements: FlowElement<Data>[]): FlowElement<Data>[] {
  const elementToRemove = elements.find((element) => element.id === nodeID);
  const { parentID, childrenID } = elementToRemove?.data?.nodeData || {};
  const edge = edgeBuilder(parentID, childrenID);
  const newElements = removeElements([elementToRemove], elements);
  return newElements.concat(edge);
}

interface Props {
  id: string;
}

export function NodeRemover({ id }: Props) {
  const { elements } = useObservable(store);

  function onRemove() {
    const newElements = onRemoveNode(id, elements);
    updateStore((s) => ({ ...s, elements: newElements }));
  }

  return (
    <Icon
      name="close"
      onClick={onRemove}
    />
  )
}

function CustomNode({ id }: Props) {
  // Omit some contents
  return (
    <>
      <NodeRemover id={id}>
    </>
  )
}

After clicking the delete button, the current node element is found through the id of the current node, and then the react-flow-renderer node's removeElements method is invoked to delete the current node from elements. After that, you need to add a connection between the front node and the subsequent nodes to ensure that the graph is connected. Finally, you only need to introduce the NodeRemover component into the custom node.

Node configuration

The logic of node configuration is similar to adding nodes, but here the sidebar component does not render the list of nodes for dragging, but the configuration form of the current corresponding node. First, click the current node to display the configuration form. The following is the implementation of node click event processing:

function CustomNode({ id }: Props) {
  // Omit some contents
  function handleConfig() {
    updateStore((s) => ({
      ...s,
      nodeIdForDrawerForm: id,
    }));
  }

  return (
    <>
      <div onClick={handleConfig}>
        <NodeRemover id={id}>
      </div>
    </>
  )
}

After clicking the node, the nodeIdForDrawerForm, that is, the id of the current node, is recorded in the status. Next, implement the ConfigForm form:

import store, { updateStore } from './store';

import Form from './form';

function ConfigForm() {
  const { nodeIdForDrawerForm, id, name, elements } = useObservable(store);
  const currentNodeElement = elements.find(({ id }) => id === nodeIdForDrawerForm);

  const defaultValue = currentNodeElement?.data?.businessData;

  function onSubmit(data: BusinessData): void {
    const newElements = elements.map(element => {
      if (element.id === nodeIdForDrawerForm) {
        element.data.businessData = data;
      }
      return element;
    })
    updateStore((s) => ({ ...s, elements }))
  }

  function closePanel() {
    updateStore((s) => ({
      ...s,
      nodeIdForDrawerForm: '',
    }));
  }

  return (
    <Form
      defaultValue={defaultValue}
      onSubmit={onSubmit}
      onCancel={closePanel}
    />>
  )
}

Here, get the current node element according to nodeIdForDrawerForm, pass its businessData as the default value to the form, and then write the value back to the element state when the form is submitted. Finally, if you need to close the sidebar form when you click Cancel, just reset nodeIdForDrawerForm to empty.

Next, introduce the ConfigForm component into the App to realize the function of clicking to display the configuration form and not rendering the form when closing the sidebar:

import store from './store';

function App() {
  // Omit some contents
  const { nodeIdForDrawerForm } = useObservable(store);

  return (
    <>
      {/* Omit some contents */}
      <Components />
      {nodeIdForDrawerForm && (
        <ConfigForm />
      )}
    </>
  )
}

Here, determine whether to render the form by judging whether nodeIdForDrawerForm exists. When nodeIdForDrawerForm is reset to empty, the form will not be rendered.

Problems and difficulties

There are two known problems:

  1. The connection will be covered by the node, so that the connection cannot be seen.
  2. There will be a cross between lines.

For the first case, react flow renderer itself does not provide a solution, and can only implement the path find algorithm by itself. The second case is difficult to solve. When the graphics are very complex, it is difficult to avoid the generation of line crossing.

summary

In this paper, from the requirements of the process designer to the data interface definition of the designer, we finally realize a process designer that meets the basic requirements. However, there are still many scenarios that need to be analyzed according to different business needs, which need to be further refined in the actual business.

Open source library

react-flow-renderer:
https://github.com/prahladina...

Official account: full cloud low code
GitHub: https://github.com/quanxiang-...

This article is composed of blog one article multi posting platform OpenWrite release!

Topics: JSON