如何开发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区域是选中组件后的组件的熟悉。
A区域:新增组件分类和icon
-
通过
GlobalDragSource.setSourcesByGroup
注册组件DragSourceWidget
这个组件的有两个属性,title
是分类的在左侧显示的名称。 可以直接传中文名称,也可以传source.xxx
, 然后进行中英文切换。name
就是用来匹配我们自定义的组件分类。和setSourcesByGroup
搭配使用,setSourcesByGroup
会在单例对象GlobalDragSource
上面注册我们的分类,DragSourceWidget
通过name
去匹配我们注册的分类组,然后就会展示对应的分类下的组件。这里最新版本已经弃用就不展开了。
-
这里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 }
-
ITreeNode
是GlobalDragSource
操作组件的对象的最小单元,通俗来讲他就是我们自定义的组件的一个宏观上的描述.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' }, },
-
代码贴一下
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,收集和提交容器内部的表单数据。
-
点击每一步的步骤器组件容器,可以设置每一步的步骤名称
-
可以给步骤器组件设置初始的步骤
-
每一个步骤都是一个容器,新拖入的组件完全维持和拖入根组件一样的交互。
设计一个步骤器组件在预览画布上需要考虑的问题
-
如何使用组件的属性,从特定步骤开始
-
如何渲染组件
-
怎么切换步骤
-
切换步骤前怎么收集每一步的信息,校验每一步的表单输入是否完整和正确
-
一进入页面即开启校验而不是
onInput
和onBlur
才进行校验 -
每一个步骤器的每一步,都是一个容器。每一步都会是非常复杂的数据,怎么将每一步容器从画布 => 预览的变化。在编辑画布上,容器里面的数据都是拖动进来的,在预览器上,容器的数据都是编辑画布上生成的
shema
.
可以看见一个组件在编辑画布和在预览画布上,是有很多不同的。
- 数据交互,编辑画布上是没有数据交互的,预览画布才有
- 数据校验,编辑画布上是不需要做数据交互的,只要保证能正常切换上一步下一步,然后每一步都是容器组件就好,但是预览画布上需要对数据进行校验,在切换步骤前每一步的信息,校验每一步的表单输入是否完整和正确。如果不正确就提示错误不给进行下一步。
- 数据的来源,编辑画布上属性配置区域配置的信息,对于预览画布来说就是
props
中的一个属性。 - 渲染组件,编辑画布上的步骤组件的每一步的容器,都可以往里面添加拖动组件,他的结构来自于操作人的操作。对于预览画布来说,每一步的内容都是已经生成好的数据。是不可以动态添加步骤的。
简要解决方式
步骤器组件在编辑画布的问题
-
使用
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是
onInput
和onBlur
才进行校验,如果我们想一开始就校验,该怎么做呢
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} />;
})}