如何开发designable内置组件缺少的步骤器(FormStep)

1,673 阅读8分钟

如何开发designable内置组件缺少的步骤器(FormStep)

[TOC]

开篇先点赞一下formily,在使用期间越来越觉得这是一个终极版的通用的表单解决方案。衍生出来的designable也是做到了熟悉formily的开发者就能二次改造👍。designable内置了一些组件,这些都是由formily/antd包导出的。

Space,FormGrid,FormLayout,FormTab,FormCollapse,ArrayTable,ArrayCards,FormItem,DatePicker,Checkbox,Cascader,Editable,Input,Text,NumberPicker,Switch,Password,PreviewText,Radio,Reset,Select,Submit,TimePicker,Transfer,TreeSelect,Upload,Card,Slider,Rate

分为 layouts, inputs, displays, arrays 四大类。

可以看出内置的组件涵盖了大部分的场景,但是业务中常用的 步骤器 并没有包含。

我是基于formily@2.0.0-rc4版本进行该找开发的,最新版本编辑器的一些API的写法发生了改变,不过依然有参考价值。

designable playground

先介绍一下designable playground 的页面结构, 左侧A区域是组件的分类和列表,B区域是展示区或者预览区,右侧C区域是选中组件后的组件的熟悉。 image

A区域:新增组件分类和icon

  1. 通过GlobalDragSource.setSourcesByGroup注册组件

    DragSourceWidget 这个组件的有两个属性, title 是分类的在左侧显示的名称。 可以直接传中文名称,也可以传source.xxx, 然后进行中英文切换。name 就是用来匹配我们自定义的组件分类。和setSourcesByGroup搭配使用,setSourcesByGroup会在单例对象 GlobalDragSource上面注册我们的分类,DragSourceWidget通过name 去匹配我们注册的分类组,然后就会展示对应的分类下的组件。

    这里最新版本已经弃用就不展开了。

  2. 这里designable的API的ts类型是不支持直接传入组件的icon的,因为有这么一段声明把sourceIcon设置为字符串类型。

    export interface IDesignerProps {
        sourceIcon?: string;
    }
    export declare type IDesignerPropsMap = Record<string, IDesignerProps>;
    export declare type IDesignerControllerProps = IDesignerProps | ((node: TreeNode) => IDesignerProps);
    

    但是IconWidget的源码是这样的,所以可以在sourceIcon传入React.Element并且断言为 any 逃过ts校验,同时达到自定义icon的目的。

    if (isStr(infer)) {
      const finded = registry.getDesignerIcon(infer)
      if (finded) {
        return takeIcon(finded)
      }
      return <img src={infer} height={height} width={width} />
    } else if (React.isValidElement(infer)) {
      ...
      return infer
    }
    
  3. ITreeNodeGlobalDragSource 操作组件的对象的最小单元,通俗来讲他就是我们自定义的组件的一个宏观上的描述.

    componentName 需要写死DesignableField是因为源码里面判断是否是组件的时候会用到这个常量。

    DesignableField 就是可以展示在画布<ViewPanel type="DESIGNABLE">上组件的名称,只有名称为DesignableField的组件才能展示在画布上,这也是为什么createDesignableField/registryName要写死。这里虽然可以传入多个,但是PreviewWidget里面的核心函数transformToSchema 的入参数只有一个,所以这里必须写死并且保持一致,不然会出现可以拖动到画布上展示,但是无法预览的问题

      const { form: formProps, schema } = transformToSchema(props.tree, {
        designableFormName: 'Root',
        designableFieldName: 'DesignableField',
      });
    
    export interface ITreeNode {
        componentName?: string; // 写死 DesignableField 
        sourceName?: string; // 组件的名称, 不填的话会自动生成一串md5来标识这个组件,需要唯一
        operation?: Operation;
        designerProps?: IDesignerControllerProps;
        designerLocales?: LocaleMessages;
        hidden?: boolean; // 在当前组件分类中是否展示
        props?: Record<string | number | symbol, any>; // 组件的props, decorator 属性,代表字段的 UI 装饰器,通常我们都会指定为 FormItem, component 属性,代表我们这个组件所需要展示UI的使用用的组件,在设置容器组件的时候,需要设置 type = void
        children?: ITreeNode[];
    }
    
    export declare type IDesignerControllerProps = IDesignerProps | ((node: TreeNode) => IDesignerProps);
    
    export interface IDesignerProps {
        path?: string; // 当前组件所在的路径,根目录的相对路径,一版在 node_module 或者自己创建的文件夹中
        title?: string; // 组件的名称, 就是显示在坐标分类中的组件的名称
        description?: string; // 组件的描述
        sourceIcon?: string;// sourceIcon 目前好像只是支持内置的图标类名,所以可以不要
        droppable?: boolean; // 是否可放置
        draggable?: boolean; // 是否可拖拽
        deletable?: boolean; // 是否可删除
        cloneable?: boolean; 
        propsSchema?: ISchema; // 组件内部的属性,组件的微观描述
        allowAppend?: (target: TreeNode, sources?: TreeNode[]) => boolean; // 是否允许往里面加组件
        [key: string]: any; // 是否允许放置
    }
    
    // 比如 allowDrop 在以下代码中表示,
    // 具有当前这个属性的组件,只能在 componentName === 'Card' 的组件中添加
    // component with designerProps like this can only drop into components named Card
    {
      allowDrop(parent) {
        return parent.componentName === 'Card'
      },
    },
    
  4. 代码贴一下

import React from 'react';
import { GlobalDragSource } from '@designable/core';

export function SourceIcon() {
  return (
      <svg>....</svg>
  );
}

export const MyFormStepTreeNode = {
  componentName: 'DesignableField',
  designerProps: {
    title: '步骤器',
    sourceIcon: SourceIcon as any,
  },
  props: {
    type: 'void',
    'x-component': 'MyFormStep',
  },
};

export const registerTreeIntoGroups = () => {
  GlobalDragSource.setSourcesByGroup('myGroup', [
    MyFormStepTreeNode,
  ]);
};

 <DragSourceWidget title="我的组件" name="myGroup" defaultExpand={false} />

C区域: 新增组件的属性

这部分其实就是很普通的formily的json-schema,用过formily的朋友都能写。

这些属性就是组件编辑区组件的props

这里我也有一个疑问,就是C区域的属性间是不可以联动的,我试了没成功。有经验的可以分享一下解决方法。

 <Steps
   {...props}
   current={current}
   size={props.type === 'navifation' ? 'small' : 'default'}
   className={props.type === 'navigation' && steps.length > 5 ? 'mysteps-container' : ''}
 >
export const MyFormStepEditor: ISchema & { StepPane?: ISchema } = {
  type: 'object',
  properties: {
    type: {
      type: 'string',
      'x-decorator': 'FormItem',
      'x-component': 'Select',
      'x-component-props': {
        defaultValue: 'default',
      },
      enum: [
        { label: 'default', value: 'default' },
        { label: 'navigation', value: 'navigation' },
      ],
    },
  },
};

MyFormStepEditor.StepPane = {
  type: 'object',
  properties: {
    step: {
      type: 'string',
      'x-decorator': 'FormItem',
      'x-component': 'Input',
    },
    title: {
      type: 'string',
      title: '步骤名称',
      'x-decorator': 'FormItem',
      'x-component': 'Input',
    },
  },
};

B区域:编辑区和预览区

这部分是核心

问题

设计一个步骤器组件在编辑画布上需要考虑的问题

  • 可以拖动步骤器组件进入页面,使得这个组件成为一个容器组件。

  • 没有组件的时候展示empty的背景和一个dropable的容器组件。

  • 拖动其他组件放入步骤组件,自动生成一个步骤。

  • 如何生成步骤和删除步骤。

  • 点击下一步,打开新的步骤页面子容器。

  • 点击上一步,打开之前创建的的子容器。

  • 最后一步点击done,收集和提交容器内部的表单数据。

  • 点击每一步的步骤器组件容器,可以设置每一步的步骤名称

  • 可以给步骤器组件设置初始的步骤

  • 每一个步骤都是一个容器,新拖入的组件完全维持和拖入根组件一样的交互。

设计一个步骤器组件在预览画布上需要考虑的问题

  • 如何使用组件的属性,从特定步骤开始

  • 如何渲染组件

  • 怎么切换步骤

  • 切换步骤前怎么收集每一步的信息,校验每一步的表单输入是否完整和正确

  • 一进入页面即开启校验而不是 onInputonBlur才进行校验

  • 每一个步骤器的每一步,都是一个容器。每一步都会是非常复杂的数据,怎么将每一步容器从画布 => 预览的变化。在编辑画布上,容器里面的数据都是拖动进来的,在预览器上,容器的数据都是编辑画布上生成的shema.

可以看见一个组件在编辑画布和在预览画布上,是有很多不同的。

  1. 数据交互,编辑画布上是没有数据交互的,预览画布才有
  2. 数据校验,编辑画布上是不需要做数据交互的,只要保证能正常切换上一步下一步,然后每一步都是容器组件就好,但是预览画布上需要对数据进行校验,在切换步骤前每一步的信息,校验每一步的表单输入是否完整和正确。如果不正确就提示错误不给进行下一步。
  3. 数据的来源,编辑画布上属性配置区域配置的信息,对于预览画布来说就是props中的一个属性。
  4. 渲染组件,编辑画布上的步骤组件的每一步的容器,都可以往里面添加拖动组件,他的结构来自于操作人的操作。对于预览画布来说,每一步的内容都是已经生成好的数据。是不可以动态添加步骤的。

简要解决方式

步骤器组件在编辑画布的问题

  • 使用antd/Steps来展示样式,根据props选择的是default 还是 navigation 来展示是普通的步骤还是导航式的步骤

  • Steps/Step 就根据点击添加步骤和删除步骤控制

    其中增加步骤的例子可以参考自增组件的源码,删除的部分是我自己实现的。

    直接取到treeNode 的 第current节点,remove即可,

    但是如果删除的步骤是最后一个步骤,那么需要提前将步骤设置为前一步,这样删除后,仍然保持在最后一步的样式。而且这样避免了remove所在步骤的节点,造成了“自己删除自己”的错误导致页面崩掉。

      const handleRemove = () => {
        if (node?.children.length <= 1) {
          return;
        }
        const _current = current;
        if (current >= node?.children.length - 1) {
          setCurrent(node?.children.length - 2);
        }
    
        setTimeout(() => {
          const target = node.children[_current];
          target.remove();
        }, 70);
      };      
    <LoadTemplate
      actions={[
        {
          title: '添加步骤',
          onClick: () => {
            const stepPane = new TreeNode({
              componentName: 'DesignableField',
              props: {
                type: 'void',
                'x-component': 'MyFormStep.StepPane',
                'x-component-props': {
                  title: `步骤名称`,
                },
              },
            });
            node.append(stepPane);
          },
        },
        {
          title: '删除步骤',
          onClick: () => {
            handleRemove();
          },
        },
      ]}
      />
    
  • 点击步骤切换,设置Steps的步骤current属性即可。

  • 点击每一步的步骤器组件容器,可以设置每一步的步骤名称,

    MyFormStepEditor.StepPane = {
      type: 'object',
      properties: {
        step: {
          type: 'string',
          'x-decorator': 'FormItem',
          'x-component': 'Input',
        },
        title: {
          type: 'string',
          title: '步骤名称',
          'x-decorator': 'FormItem',
          'x-component': 'Input',
        },
      },
    };
    
    <Step key={props.title} title={props.title}>
    </Step>
    

步骤器组件在预览画布的问题

步骤器里面大多时候都是一个表单

  • 表单的validate是 onInputonBlur才进行校验,如果我们想一开始就校验,该怎么做呢
    useFormEffects(() => {
      onFieldInit(`${field.address}.*(${stepsName.join(',')}).*`, (form: any) => {
        form.validate(form.value).then(res => {
          // console.log(' ... ', res); 这里触发validate方法,一些required的表单没填写就直接error报红
        });
      });

    });
  • 我们在编辑画布编辑的属性或者插入的表单这些都是一个schema,获得整个步骤器的schema并且解析schema
    • 获得步骤器Steps的全部shcema
    • 解析出StepPane 也就是每一个步骤的schema
    • 获得步骤器一共几步,每一步的名称,每一步的内部的schema
    • 渲染步骤器本身
    • 渲染每一步
    const schema = useFieldSchema();
    const steps = parseSteps(schema);
    const stepsName = steps.map(s => s.name);

const parseSteps = (schema: Schema) => {
  const steps: SchemaStep[] = [];
  schema.mapProperties((schema, name) => {
    if (schema['x-component']?.indexOf('StepPane') > -1) {
      steps.push({
        name,
        props: schema['x-component-props'],
        schema,
      });
    }
  });
  return steps;
};

{steps.map(({ name, schema }, key) => {
  if (key !== current) return;
  return <RecursionField key={key} name={name} schema={schema} />;
})}