bpmn.js属性面板的自定义

3,674 阅读5分钟

一、背景

项目开发时用到 bpmn.js 来绘制工作流,但开源 bpmn.js 右侧的属性面板功能有限,很多业务场景无法满足,比如:

最终实现效果如下:

image.png

二、目标

在bpmn.js的属性面板上拓展/改造以下内容

三、实现思路

3.1 重写整个属性面板

参考 React Properties Panel for bpmn-js 重写整个属性面板。

3.2 局部自定义属性面板

参考 properties-panel-extension 局部改写属性面板

四、具体实现

  1. 隐藏掉需要定制的面板元素
[data-entry-id='assignmentDefinitionAssignee'], 
[data-entry-id='assignmentDefinitionCandidateGroups'], 
[data-entry-id='assignmentDefinitionCandidateUsers'], 
[data-entry-id='taskScheduleDueDate'], 
[data-entry-id='taskScheduleFollowUpDate'], 
[data-entry-id='name'], 
[data-entry-id='formType'], 
[data-entry-id='formConfiguration'], [data-entry-id='sla'] { 
    display: none; 
}
  1. 监听 canvas 画布上的元素变化,并在指定元素中插入定制节点的内容
/** 绑定 element 的监听事件 */
this.modeler.on('element.changed', (event: any) => {
  if (event.element.id === this.viewState.selectedElement?.id) {
    this.handleSelectedElementChange(event.element);
  }
});

/** 更新选中的 Element 节点 */
handleSelectedElementChange = (element: Element | ModdleElement) => {
  runInAction(() => {
    this.viewState.selectedElement = element;

    if (!element) return;

    this.handleAddCustomProperties(element);
  });
};

/** 处理不同节点类型需要自定义的面板内容 */
handleAddCustomProperties = (element: Element | ModdleElement) => {
    this.addName(element);

    if (is(element, 'bpmn:UserTask')) {
        this.addSla(element);
        this.addAssignee(element);
        this.addCamundaForm(element);
    }
};

/** 添加SLA */
addSla = (element: Element | ModdleElement) => {
    const wrapperSelector = '[data-entry-id="sla"]';
    const value = this.getSlaDefinition(element);
    const renderNode = () => <Sla sla={value} onChange={this.handleSlaUpdate} />;

    this.renderPanelContent({ wrapperSelector, renderNode, destroyCb: this.addDestroyCallbacks });
};

/** 渲染自定义 panel */
renderPanelContent = ({ wrapperSelector, renderNode, destroyCb }: PanelContentProps) => {
    // 保证 properties-panel 已经渲染完成
    setTimeout(() => {
        // 找到插入的元素
        const wrapperElement = document.querySelector(wrapperSelector)?.parentElement;

        // 渲染自定义panel
        if (wrapperElement) {
            ReactDOM.render(<ConfigProvider value={DESIGN_CONFIG}>{renderNode()}</ConfigProvider>, wrapperElement);
            destroyCb(() => this.destroyElement(wrapperElement));
        }
    });
};
  1. 定制内容变化时更新xml
/** 更新SLA的XML */
handleSlaUpdate = (sla: string) => {
  const element = this.viewState.selectedElement;

  if (!element) return;

  let extensionElements = element?.businessObject?.extensionElements;

  // 如果没有 extensionElements 则创建
  if (!extensionElements) {
    extensionElements = this.bpmnFactory.create('bpmn:ExtensionElements', {});
    this.modeling.updateProperties(element, {
      businessObject: Object.assign(element.businessObject, { extensionElements })
    });
  }

  // 找到 extensionElements 中 taskHeaders 字段
  let taskHeaders = extensionElements?.values?.find?.((item: any) => is(item, 'zeebe:TaskHeaders'));

  // 如果没有 taskHeaders 则创建
  if (!taskHeaders) {
    taskHeaders = this.bpmnFactory.create('zeebe:TaskHeaders', {});
    extensionElements.values.push(taskHeaders);
  }

  // 找到 taskHeaders 中 sla 字段
  const slaIndex = taskHeaders?.values?.findIndex?.((item: any) => item.key === 'sla');

  // 如果找到则更新,否则创建后更新
  if (slaIndex > -1) {
    taskHeaders.values[slaIndex].value = sla;
  } else {
    const slaDefinition = this.bpmnFactory.create('zeebe:Header', { key: 'sla', value: sla });
    taskHeaders.values = taskHeaders.values || [];
    taskHeaders.values.push(slaDefinition);
  }

  this.modeling.updateModdleProperties(element, extensionElements, { values: extensionElements.values });
};
  1. 在 bpmn-js-bpmnlint 中编写相应的校验函数
/** 验证用户任务的SLA输入是否合法 */
const slaValidate = () => {
  const check = (element: Element | ModdleElement, reporter: Reporter) => {
    if (is(element, 'bpmn:UserTask')) {
      const taskHeaders = getTaskHeaders(element)?.[0];
      const sla = taskHeaders?.values?.find?.((item: any) => item.key === 'sla')?.value;
      const [slaNumber, slaUnit] = [sla?.slice?.(0, -1), sla?.slice?.(-1)];

      if (sla && (!isInteger(slaNumber) || ![SlaUnit.Day, SlaUnit.Hour, SlaUnit.Minute].includes(slaUnit as SlaUnit))) {
        reporter.report(element.id, 'SLA必须是大于1的整数');
      }
    }
  };

  return { check };
};

五、遇到的问题

1. bpmn.js的面板支持它自带的一套 entries 组件,它不支持换成我们自己的组件,导致表单工具箱等业务场景无法实现。

发现 xml 的解析和更新可以通过 Modeling 来做,根据 React Properties Panel for bpmn-js 了解到Modeling可以通过modeler获取到。考虑写个假的把原生的内容替换掉,然后我们自己通过 Modeling 来控制 xml 的更新~

2. zeeSlaDefination 字段是我们后端引擎拓展的,我们这边并不支持解析和处理,如何拓展自定义xml元素呢?

看 template-json 翻阅源码时,发现 bpmnjs 里面 moddle 的定义似乎是对应了 xml 的自定义拓展元素,尝试把 zeebe 的 moddle 文件copy出来修改,发现可行~

3. camunda form 由 formType 和 formConfiguration 两块控制,它的xml结构如下图所示,这里解析和操作xml相对麻烦容易出错,那官方是如何处理的了?

阅读源码发现源码中有一段专门针对 form xml操作的工具类,但是官方库没有导出这个工具类,于是copy出来自己补了类型定义~

4. 后端给的 template-json 如何解析?

根据 template 关键字翻阅源码后发现官方提供了一个 element-template-chooser 插件, 尝试后发现该插件可行~

5. 怎么在canvas画布上展示面板上的报错信息?

根据 validate / lint 等关键字翻阅源码,找到 bpmn-js-bpmnlint,尝试后发现可行,但是校验函数需要自己写。

6. 怎么在canvas画布上展示 template-json 的报错信息?
  • template-json 为接口请求回来动态解析的内容,提前不知道校验规则,查阅 bpmn-js-bpmnlint 的校验规则后发现并没有与template-json相关的内容
  • 根据 template/lint/validate等关键字查阅源码,未发现相关内容。
  • 希望后端给出 template-json 文件的校验规则,尝试自己解析 template-json 生成校验函数给到 bpmn-js-bpmnlint 的插件
  • 后端反馈 camunda 云平台可以展示出 template-json 的报错信息,于是开始在 camunda 源码中查找 template/lint/validate 等关键字,发现 @camunda/linting 插件
  • @camunda/linting 插件可以解析出 template-json 的报错信息,但是canvas上无法展示出来,该Bug持续一年都没解决
  • 阅读 @camunda/linting 源码,发现解析 template-json 报错的核心代码是由插件 CloudElementTemplatesLinterPlugin 完成的,查阅该插件源码发现它实际生成了一套 bpmn-js-bpmnlint 的配置,但是该配置需要通过 @camunda/linting 二次解析才能使用,所以考虑把配置里面核心的校验函数取出来,组合丢进 bpmn-js-bpmnlint 里,尝试后发现可行~
7. template-json 上面内容的定制

与后端约定不要修改模板的id值等信息,但由于 template-json 的元素ID是根据 custom-entry-<group_id>-<group_index> 生成的,不具备全局唯一性(出现表单字段联动时可能会重复),所以原有信息的覆盖需要在js里做插入时做屏敝。