【Formily】实现antd designable平台的简易版本

1 背景

designable设计器平台,是阿里开发的一个表单设计器,基于formily表单引擎、schema json实现的。designable源码在这里

这里的功能,其实也类似于第代码平台,可以查看低代码引擎
非官方文档:designable工程结构

现在,由于业务需求,我们需要实现designable平台的一个简易版的表单设计器,包括组件拖拽功能,对表单字段的schema配置,即右侧的【属性配置】,还需要支持reaction字段的联动。

在这里插入图片描述

拖动组件
放置到放置区域
获取当前组件的类别和schema json
在放置区域渲染当前组件

功能列表:

  • 拖拽区域
    • 渲染组件列表,包括组件的名称和icon
    • 组件可以拖动
  • 放置区域
    • 拖动后的组件可以放置,并且拖拽区域的组件依旧存在
    • 放置区域的组件可以正确渲染相应样式,使用formily的schema json渲染
    • 放置区域的组件可以上下移动排序,同时可以进行删除和编辑操作
  • 字段配置
    • 支持常规的key,title,default、required等字段
    • 对于某些用户可视化的字段,比如title,需要支持多语言(自定义输入和json输入)
    • 支持展示配置后的schema json,也支持修改schema json后,表单组件同步更新渲染
  • 联动逻辑
    • 代码编辑框
    • 代码提示
    • 字段的组装

2 技术栈

antd:react web组件库
formily:表单引擎,可以根据schema json直接渲染表单
react-beautiful-dnd:常用于列表的拖拽,支持排序
react-dnd:拖拽和放置功能,比如上面截图的组件拖拽

3 组件拖拽和放置

3.1 类型定义

右侧组件类型:id唯一标识,scheme存放渲染表单的json文件

export interface ComponentConfig {
  id?: string; // 唯一标识,随机生成,且不可更改
  key: string; // 表单字段key,用户可以更改
  title: string; // 拖拽区域的文案,不可更改
  component_type: ComponentType; // RN侧的组件标识,不可更改
  schema: ISchema;
}

export enum ComponentType {
  TextInputRow = 'TextInputRow', // 文本输入框
  DateInputRow = 'DateInputRow', // 时间选择器
  CheckBox = 'CheckBox',
}

右侧组件列表:

export const ComponentConfigs: ComponentConfig[] = [
  {
    key: ComponentType.TextInputRow,
    schema: {
      type: 'string',
      'x-component': 'Input',
      'x-decorator': 'FormItem',
    },
  },
  {
    key: ComponentType.DateInputRow,
    schema: {
      type: 'string',
      'x-component': 'DatePicker',
      'x-decorator': 'FormItem',
    },
  },
  {
    key: ComponentType.CheckBox,
    schema: {
      type: 'string',
      'x-component': 'Checkbox',
      'x-decorator': 'FormItem',
    },
  },
].map((i) => ({ ...i, title: i.key, component_type: i.key }));

3.2 拖拽

useDrag:让DOM实现拖拽能力的构子

  • 请求参数:
    • type: 指定元素的类型,只有 类型相同的元素 才能进行drop操作
    • item: 元素在拖拽过程中,描述该对象的数据。可以在useDrop中的drop接收到该数据
    • collect: 返回一个描述状态的普通对象,然后返回以注入到组件中。它接收两个参数,一个DragTargetMonitor实例和拖拽元素描述信息item
  • 返回参数:
    • 第一个返回值:是一个对象 表示关联在拖拽过程中的变量,需要在传入useDrag的规范方法的collect属性中进行映射绑定, 比如:isDraging, canDrag
    • 第二个返回值: 代表拖拽元素的ref
    • 第三个返回值: 代表拖拽元素拖拽后实际操作到的dom
// 用于包裹每一个可以拖拽的组件
export const WrapComponent = (props: DndComponentDndItem) => {
  const [, drag] = useDrag(() => ({
    type: ItemTypes.CARD,
    item: props.config,
    // collect中可以监控drag状态变更,并把状态暴露给组件
    collect: (monitor) => ({ isDragging: !!monitor.isDragging() }),
  }));
  return (
    <div
      style={{
        width: 100,
        cursor: 'move',
        height: 50,
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: 'white',
        borderRadius: 4,
      }}
      ref={drag}
    >
      {props.children}
    </div>
  );
};

3.3 放置

useDrop:让拖拽物放置的构子

  • 请求参数:
    • type: 指定元素的类型,只有 类型相同的元素 才能进行drop操作
    • item: 元素在拖拽过程中,描述该对象的数据。可以在useDrop中的drop接收到该数据
    • collect: 返回一个描述状态的普通对象,然后返回以注入到组件中。它接收两个参数,一个DragTargetMonitor实例和拖拽元素描述信息item
  • 返回参数:
    • 第一个返回值:是一个对象 表示关联在拖拽过程中的变量,需要在传入useDrag的规范方法的collect属性中进行映射绑定, 比如:isDraging, canDrag
    • 第二个返回值: 代表拖拽元素的ref
    • 第三个返回值: 代表拖拽元素拖拽后实际操作到的dom
export const DropContainer = observer((props: DndComponentDndItem) => {
  const [droppedItems, setDroppedItems] = useState<FieldConfigItem[]>([]);
  const field = useField() as ObjectField;
  const form = useForm();

  const [{ isOver, canDrop }, drop] = useDrop(() => ({
    accept: ItemTypes.CARD,
    drop: (item: ComponentConfig) => {
      // 放置成功后的回调,需要将当前组件加入到列表中
      // 添加拖拽的组件到列表
      const newKey = `${item.key}-${Math.random()}`;
      // 对于新增的组件,必须加上component和index
      const newRnSchema = {
        title: {},
        component: item.component_type,
        index: Object.keys(formFieldsSchemaRef.current)?.length || 0,
      } as unknown as RNSchemaItem;

      const newItem = {
        ...item,
        // 新加的组件key暂时不用回填到弹窗上面
        key: '',
        // key: newKey,
        id: newKey,
        // 新增加的组件,同步rn_schema字段
        rn_schema: newRnSchema,
        // 回填到弹窗的字段
        ...item.web_schema,
        ...newRnSchema,
      } as FieldConfigItem;
      setDroppedItems((i) => [...i, newItem]); // 回调函数里面无法取到最新的state,所以直接通过这种函数的方式获取最新的state
    },
    collect: (monitor) => ({
      // 是否放置在目标上
      isOver: monitor.isOver(),
      // 是否开始拖拽
      canDrop: monitor.canDrop(),
    }),
  }));

  const handleFieldChange = (value: FieldConfigItem[]) => {
    const newSchema: Record<string, any> = {};
    value?.forEach(({ key, web_schema, rn_schema }, index) => {
      newSchema[key] = { web_schema, rn_schema: { ...rn_schema, index } };
    });
    // 更新表单的值
    setDroppedItems(value);
  };

  // 获取错误信息,需要提示给用户
  const takeMessage = () => {
    if (field?.selfErrors?.length) {
      return field.selfErrors;
    }
    if (field?.selfWarnings?.length) {
      return field.selfWarnings;
    }
    return [];
  };
  const feedbackText = takeMessage().join(',');

  return (
    <>
      <div
        style={{ height: 200, width: '100%', backgroundColor: '#f2f2f2', borderRadius: 4, overflowY: 'auto' }}
        ref={drop} // here
      >
        <DragFormItem items={droppedItems} onChange={handleFieldChange} />
      </div>
      {/* 展示错误信息 */}
      <span style={{ color: '#ff4d4f' }}>{feedbackText}</span>
    </>
  );
});

3.4 表单项列表的拖拽和排序

技术栈:react-beautiful-dnd,可以对列表项(即这里的表单组件)进行拖拽排序
核心概念:

  • DragDropContext:上下文,需要绑定onDragEnd事件,拖拽结束后,需要更新列表顺序
  • DroppabledroppableId必须跟Draggable保持一致,因为我们的列表都是可以放置的区域,所以直接包裹整个列表就好了
  • DraggabledroppableId必须跟Droppable保持一致,直接包裹列表项,表示可以被拖拽
  • DragHandleContext.Provider:上下文
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import { MenuOutlined, DeleteOutlined } from '@ant-design/icons';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Button, Input, Row, Space } from 'antd';
import { createForm } from '@formily/core';
import { uniqueId } from 'lodash';
import { ArrayItems, Checkbox, DatePicker, FormItem, FormLayout, NumberPicker, Select } from '@formily/antd';
import { createSchemaField, FormProvider, ISchema, Schema } from '@formily/react';
import { FieldConfigItem } from './interface';
import FieldConfigAction from '../FieldConfigAction';
import EmptyRow from '../EmptyRow';
import { getFieldSchema } from '../FieldConfigAction/utils';
import PhotoInput from '../PhotoInput';

interface Props {
  items: FieldConfigItem[];
  onChange: (value: FieldConfigItem[]) => void;
}

const DragHandleContext = createContext<any>(null);

const SchemaField = createSchemaField({
  components: {
    FormItem,
    FormLayout,
    Input,
    ArrayItems,
    Space,
    Select,
    NumberPicker,
    DatePicker,
    Checkbox,
    PhotoInput,
    EmptyRow,
  },
});

const DragIcon = () => {
  const dragHandleProps = useContext<any>(DragHandleContext);
  return <MenuOutlined {...dragHandleProps} style={{ cursor: 'grab', marginRight: 5 }} />;
};

const ListItem = ({
  item,
  provided,
  onDelete,
  onEdit,
}: {
  item: FieldConfigItem;
  provided: any;
  onDelete: (item: FieldConfigItem) => void;
  onEdit: (item: FieldConfigItem) => void;
}) => {
  const { web_schema, id, key } = item;

  // item.web_schema发生改变的时候,需要重新创建表单实例
  const form = useMemo(() => {
    return createForm();
  }, [item]);

  return (
    // 下面这两行要放在DragHandleContext.Provider外面
    <div ref={provided.innerRef} {...provided.draggableProps}>
      <DragHandleContext.Provider value={provided.dragHandleProps}>
        <Row
          justify="start"
          align="middle"
        >
          <DragIcon />
          <FormProvider form={form}>
            {/* 这样子的话,当编辑字段的配置项的时候,web_schema发生变化,并重新渲染表单 */}
            {/* 同时,也不会存在一个默认值的问题,因为这里的表单项只是起渲染作用的,我们并不关表单的值 */}
            {/* 包括进行save操作的时候,也不会感知到这个表单的validator、required等验证 */}
            <SchemaField
              schema={
                new Schema({
                  type: 'object',
                  properties: {
                    [key || id]: web_schema,
                  },
                } as ISchema)
              }
            />
          </FormProvider>
          <Row style={{ marginLeft: 'auto' }}>
            <FieldConfigAction initItem={item} onChange={onEdit} /> // 编辑表单项的字段,配置弹窗
            <Button
              shape="circle"
              icon={<DeleteOutlined />}
              size="small"
              style={{ backgroundColor: 'transparent', border: 'none', boxShadow: 'none' }}
              onClick={() => onDelete(item)}
            />
          </Row>
        </Row>
      </DragHandleContext.Provider>
    </div>
  );
};

export default (props: Props) => {
  const { items, onChange } = props;
  const [data, setData] = useState<FieldConfigItem[]>([]);

  useEffect(() => {
    items && setData(items);
  }, [items]);

  // 拖拽后的回调事件
  const onDragEnd = (result: any) => {
    const { source, destination } = result;
    if (source && destination) {
      const newData = Array.from(data);
      const [removed] = newData.splice(result.source.index, 1);
      newData.splice(result.destination.index, 0, removed);
      // 更新拖拽后的列表顺序
      setData(newData);

      // 回调
      onChange(newData);
    }
  };

  const handleDelete = (item: FieldConfigItem) => {
    const newData = data.filter((i) => i.id !== item.id);
    setData(newData);
    // 回调
    onChange(newData);
  };

  const handleEdit = (item: FieldConfigItem) => {
    const newData = Array.from(data);
    const editIndex = newData.findIndex((i) => i.id === item.id);
    newData.splice(editIndex, 1, {
      ...item,
      ...getFieldSchema(item),
    });
    setData(newData);
    // 回调
    onChange(newData);
  };

  return (
    <DragDropContext onDragEnd={onDragEnd}>
      <Droppable droppableId="droppable">
        {(provided) => (
          <div {...provided.droppableProps} ref={provided.innerRef} style={{ padding: 5 }}>
            {data?.map((item, index) => (
              // todo: 拖拽的时候,位置存在抖动
              <div style={{ marginTop: 5 }}>
                <Draggable draggableId={index.toString()} index={index} key={uniqueId()}>
                  {(provided, snapshot) => (
                    <ListItem provided={provided} item={item} onDelete={handleDelete} onEdit={handleEdit} />
                  )}
                </Draggable>
              </div>
            ))}
            {provided.placeholder}
          </div>
        )}
      </Droppable>
    </DragDropContext>
  );
};

4 字段配置

字段主要分为两类,一类通用字段,一类业务需要的个性化字段:

  • 通用字段:比如title、default、required、disabled等
  • 组件特有字段:比如PhotoInput组件会有sample_image等个性化字段

字段配置弹窗,用户点击编辑按钮之后,会弹出该弹窗,也是使用schema json渲染的,这里我们需要新建一个SchemaField。

当初始化的时候,根据value赋初始值;

当点击保存的时候,获取弹窗的form.values,手动为当前表单项设置field.setValue。

5 Reaction联动

字段的响应配置弹窗。

一共分为三部分:

  • Dependencies:依赖项,当前字段受哪些字段的影响

    • Source:字段的key
    • Property:字段的属性值,比如valuecomponent_props.*
    • Variable Name:别名,在代码中可以通过$deps.xxx引用
    • 对应dependencies
    • 在这里插入图片描述
  • State:影响当前字段的哪些属性,比如titlehiden

    • 对应fulfill.state
    • 在这里插入图片描述
  • Run:可以进行简单的代码编写

    • 对应fulfill.run
    • 在这里插入图片描述

schemareactions参考如下

在这里插入图片描述

这里的实现可以直接参考 ReactionsSetter 实现逻辑。

6 组件

6.1 PolyInput

思路:【React】实现输入框切换

代码:PolyInput

6.2 LanguageTextRule

多语言输入框,支持两种方式:

  • 添加多个语言组,类似ArrayTabs,支持自增
    在这里插入图片描述

  • 只支持添加一个语言组,popover展示

在这里插入图片描述

校验规则:只要填了一个多语言字段,则其他字段是必填的。即 都填 & 都不填

const getProperties = (
  languages: string[],
  componentType: LanguageComponentType,
  schemaProps?: Record<string, any>,
) => {
  const properties: Record<string, any> = {};
  languages?.forEach((language) => {
    properties[language] = {
      type: 'string',
      title: language,
      'x-decorator': 'FormItem',
      'x-decorator-props': {
        labelWidth: 100,
      },
      'x-component': LanguageComponentMap[componentType],
      'x-validator': (value: any, rule: any, ctx: { field: Field; form: Form }) => {
        let count = 0;
        languages.forEach((lan) => {
          const curField = ctx.field.query(`.${lan}`).take() as Field;
          if (curField.value) {
            count++;
          }
        });
        // 多语言字段,只要填了其中一个字段,其他字段则为必填
        // 即两种选择:都填 & 都不填
        if (count > 0 && count < languages.length) {
          return value ? true : { type: 'error', message: `The ${language} value is required` };
        } else {
          return true;
        }
      },
      ...schemaProps,
    };
  });

  return properties;
};

所有的多语言数据格式如下:

{
"title": {
  "default": {
    "en": "Full Address",
    "id": "Alamat Lengkap"
  },

  // 下面这个可以自定义
  "spp": {
    "en": "Full Address",
    "id": "Alamat Lengkap"
  }
}
}

6.3 ArrayCards

自定义卡片,并且可以编辑卡片的title。
在这里插入图片描述

基于formily的 array-cards 来实现,修改点如下:

  • 根据ArrayCards.Title来判断是否是title组件
  • 获取title的schema json
  • 渲染title的schema json
    在这里插入图片描述
const isTitleComponent = (schema: ISchema) => {
  return schema['x-component']?.indexOf?.('Title') > -1;
};

schema json例子:

// 拖拽放置区域中,自增card组件的shcema
const schema: ISchema = {
  type: 'object',
  properties: {
    array: {
      type: 'array',
      'x-component': 'MyArrayCards',
      'x-decorator': 'FormItem',
      'x-component-props': {
        title: 'Group',
        size: 'small',
      },
      default: [{ card_title: 'basic' }],
      items: {
        type: 'object',
        properties: {
          index: {
            type: 'void',
            'x-component': 'MyArrayCards.Index',
          },
          dispaly_card_title: {
            type: 'void',
            'x-component': 'MyArrayCards.Title',
            properties: {
              card_title: {
                type: 'string',
                default: 'bottom',
                'x-component': 'Input',
                'x-component-props': {
                  onKeyDown: '{{(e) => { e.stopPropagation()}}}',
                },
                'x-decorator': 'Editable',
                'x-decorator-props': {
                  showEditIcon: true,
                  style: {
                    margin: '5px',
                    padding: '0',
                    maxWidth: '416px',
                    alignSelf: 'flex-start',
                  },
                  disabled: '{{$self.value==="basic"}}', // 是否可以编辑
                  value: '{{$self.value}}',
                  patternProps: '{{$self.value==="basic" ? "readOnly" : "editable"}}', // 非editable模式下,不展示编辑icon
                },
              },
            },
          },
          content: {
            type: 'object',
            'x-component': 'DropContainer',
            'x-component-props': {
              key: uniqueId(),
            },
            'x-decorator': 'FormLayout',
            'x-decorator-props': {
              labelWidth: 200,
              wrapperWidth: 300,
              className: 'drop-container-from-layout',
            },
            'x-validator': (value: Record<string, FieldConfigItem>, rule: any, ctx: { field: Field; form: Form }) => {
              if (!value || ctx.field.unmounted) {
                return true;
              }
              let count = 0;
              Object.entries(value).forEach(([fieldKey, fieldConfig]) => {
                const rnSchemaFieldArr = Object.keys(fieldConfig.rn_schema);

                if (
                  !['name', 'title', 'index', 'component', 'decorator'].every(
                    (f) => rnSchemaFieldArr.includes(f) && !checkEmpty(fieldConfig.rn_schema[f]),
                  )
                ) {
                  // 必填字段不存在
                  count = count + 1;
                }
              });

              if (count !== 0) {
                return { type: 'error', message: `There have ${count} item not configured.` };
              }
              return true;
            },
          },
          remove: {
            type: 'void',
            'x-component': 'MyArrayCards.Remove',
          },
        },
      },
      properties: {
        addition: {
          type: 'void',
          title: 'Add',
          'x-component': 'MyArrayCards.Addition',
        },
      },
    },
  },
};

6.4 MonacoInput

代码编辑器,使用@monaco-editor/react实现。主要用在配置响应字段时候使用。

在这里插入图片描述

主要功能:

  • 支持不同的编辑器语言,比如js、ts、json等
  • 支持帮助文档code
  • 支持语法提示
  • 支持简单语法校验

上面响应式弹窗的配置里面的代码输入框,就可以参考 MonacoInput

JSONEditor:

import Editor from '@monaco-editor/react';
import { debounce } from 'lodash';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { useRef } from 'react';

interface Props {
  height?: number | string;
  defaultValue?: Record<string, any> | Record<string, any>[];
  onChange: (value: Record<string, any> | Record<string, any>[]) => void;
}

const JsonEditor = ({ height = 100, defaultValue = {}, onChange }: Props) => {
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();

  const handleEditorChange = debounce((value: string | undefined) => {
    value && onChange(JSON.parse(value));
  }, 500);

  const handleEditorMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
    // 存储editor实例
    editorRef.current = editor;
  };

  // const handleParseValue = () => {
  //   // 触发右键格式化
  //   editorRef.current?.trigger('anyString', 'editor.action.formatDocument', {});
  // };

  return (
    <Editor
      height={height}
      defaultLanguage="json"
      defaultValue={JSON.stringify(defaultValue, null, 2)}
      onChange={handleEditorChange}
      options={{
        automaticLayout: true, // 自动调整其布局以适应容器的大小变化
        minimap: { enabled: false }, // 小地图
        scrollBeyondLastLine: false,
        formatOnPaste: true,
        formatOnType: true,
      }}
      onMount={handleEditorMount}
    />
  );
};

export default JsonEditor;

在这里插入图片描述

7 Schema JSON一键配置

功能:

  • 支持直接粘贴已有的scheme json,这里的schema json指上面提到的rn_schema(即接口数据)
  • 支持对该接口数据进行转换,转换成web_schema和rn_schema,可以同步在Edit Tab查看已有配置是否合理(如何转换,查看4.4数据转换)
  • 对节点配置的保存,只有在Edit Tab点击Save按钮才会生效,避免配置的json可能有问题,所以需要先查看一下我们配置的是否合理,然后再保存
  • 支持防抖

在这里插入图片描述

类似于designable平台的这里:
在这里插入图片描述

7.1 问题一

问题:比如我修改able_to_pop_backable_to_pop_back111 > 切换到Edit Tab > 切换到Schema JSON Tab > 可以看到下图截图所示的现象(able_to_pop_back1able_to_pop_back11able_to_pop_back111),当然这是因为没有防抖的问题,但是我们预期的应该只会展示able_to_pop_back111即可,除了防抖,还有一个原因是,setValues默认的合并策略是merge,但这并不是我们希望的,我们希望是直接覆盖原来的数据。

在这里插入图片描述

解决: form.setValues存在合并策略。我们直接设置成form.setValues(cloneDeep(newValues), 'overwrite');即可。

interface setValues {
  (values: object, strategy: IFormMergeStrategy = 'merge'): void
}
type IFormMergeStrategy = 'overwrite' | 'merge' | 'deepMerge' | 'shallowMerge'

进一步完善: 上面的写法虽然可以解决覆盖问题,但是,有时候,首次粘贴的时候,然后切换回Edit Tab,我们拿到的新数据是更新的,但是formily的数据并不是我们需要的,所以这里直接form.values = cloneDeep(newValues);处理。

在这里插入图片描述

这里有坑,还需要直接调用两次setValues,否则,首次粘贴的时候,values并没有更新。

7.2 问题二

修改schema json有以下几种情况:

  • 全局粘贴
    • 这时候,form_config都是初始数据,会重新渲染DropContainer组件
    • 也就是说,useMount里面的initDroppedItems方法是可以执行的,即可以获取表单列表项,并渲染
  • 修改FE Config、BE Config
    • formily会直接触发渲染
  • 修改Form Config
    • 根据上面说的,field.value触发更新后,我们还需要通过initDroppedItems函数来渲染表单列表项
    • 监听field.value变化 —> 触发列表更新并渲染

先来看一下我们的表单放置区域的schema结构:

在这里插入图片描述

每一个分组表单项是一个数组元素,content就是我们的表单内容区域,就类似{ic_type:{web_schema:xx, rn_schema:xxx}}这样子的结构。

我们希望能够监听到字段值的变化,好巧不巧,最开始使用onFieldValueChange来监听字段值的变化,但是出现一个问题,只能监听到数组元素的最后一个,也就是说有多个分组表单的话,只有最后一个分组表单可以被监听到,如果我们改了第一个分组表单的数据,那么也会同步更改到最后一个分组表单的数据。

之后研究了一下,发现是因为onFieldValueChange只能监听数组长度变化,并不能监听数组元素的变化,最后改用了onFieldReact

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值