一、业务需求描述

1. 能够拖动表单组件(不限制样式)到指定区域后,渲染成指定的组件
2. 能支持自定义标签名和属性,以及默认值
3. 能实现可支持预览、可排序、可编辑等功能
4. 能通过JSON数据格式前后端相互传递

二、业务前期准备

1. 在网上搜索了蛮多素材,但都是基于VUE的成品(http://www.maoyl.top/Demo/elformdesigndemo/index.html),与实际基于react实现不太符合,虽然自定义表单设计可以自己实现,但需一定时间,所以更偏向于使用已有的素材。
2. 综上,最后选择了react-sortablejs 工具(http://www.sortablejs.com/)来实现

三、业务正式开发

1. 效果预览图如下

2. 代码附上(不提供样式)

①CustomForm/index.js


import React, { useEffect, useState } from 'react';
import { Tag, Layout, Button, Modal, Divider, Form, Input, Select } from 'antd';
import Sortable from 'react-sortablejs';
import _ from 'lodash';
import uniqueId from 'lodash/uniqueId';
import update from 'immutability-helper';
import './index.less';
import { indexToArray, getItem, setInfo, isPath, getCloneItem, itemRemove, itemAdd } from './utils';
import { formItemData, GlobalComponent } from './config';
import EditableTable from '../../components/EditableTable';const { Header, Sider, Content, Footer } = Layout;
const { Option } = Select;
const sortableOption = {animation: 150,fallbackOnBody: true,swapThreshold: 0.65,group: {name: 'formItem',pull: true,put: true,},
};const CustomForm = () => {const [itemData, setItemData] = useState(Array);                  // 组件数据const [isShowModal, setIsShowModal] = useState(false);            // 弹框是否显示const [curItemKey, setCurItemKey] = useState(String);             // 当前选中组件的Keyconst [curItemName, setCurItemName] = useState(String);           // 当前选中组件的Nameconst [curItemType, setCurItemType] = useState(String);           // 当前选中组件的Typeconst [isChoose, setIsChoose] = useState(false);                  // 组件是否处于选中状态useEffect(() => {}, []);const handleSubmit = () => {};const handlePreview = () => {console.log('itemData:'+JSON.stringify(itemData))setIsChoose(false)setIsShowModal(true)};const handleLabelChange = (e) => {const val = e.target.value;setCurItemName(val);itemData[curItemKey].label = val;setItemData(...[itemData]);}const handleDel = () => {let newTreeData = itemRemove(curItemKey, itemData);setCurItemKey('');setCurItemName('');setCurItemType('');setItemData([...newTreeData])}const sortableChoose = (e) => {console.log(e)setIsChoose(true);const curKey = e.item.getAttribute('data-id');const curName = e.item.firstChild.innerText;const curType = e.item.getAttribute('type');setCurItemKey(curKey);setCurItemName(curName);setCurItemType(curType)};// 拖拽的添加方法const sortableAdd = e => {// 组件名或路径const nameOrIndex = e.clone.getAttribute('data-id');// 父节点路径const parentPath = e.path[1].getAttribute('data-id');// 拖拽元素的目标路径const { newIndex } = e;// 新路径 为根节点时直接使用indexconst newPath = parentPath ? `${parentPath}-${newIndex}` : newIndex;console.log('nameOrIndex:'+nameOrIndex,'parentPath:'+parentPath,'newIndex:'+newIndex,'newPath:'+newPath)// 判断是否为路径 路径执行移动,非路径为新增if (isPath(nameOrIndex)) {// 旧的路径indexconst oldIndex = nameOrIndex;// 克隆要移动的元素const dragItem = getCloneItem(oldIndex, itemData)// 比较路径的上下位置 先执行靠下的数据 再执行靠上数据if (indexToArray(oldIndex) > indexToArray(newPath)) {// 删除元素 获得新数据let newTreeData = itemRemove(oldIndex, itemData);// 添加拖拽元素newTreeData = itemAdd(newPath, newTreeData, dragItem)// 更新视图setItemData([...newTreeData])return}// 添加拖拽元素let newData = itemAdd(newPath, itemData, dragItem)// 删除元素 获得新数据newData = itemRemove(oldIndex, newData);setItemData([...newData])return}// 新增流程 创建元素 => 插入元素 => 更新视图const id = nameOrIndexconst newItem = _.cloneDeep(formItemData.find(item => (item.name === id)))// 为容器或者弹框时增加子元素if ( newItem.name === 'Containers') {const ComponentsInfo = _.cloneDeep(GlobalComponent[newItem.name])// 判断是否包含默认数据newItem.children = [ComponentsInfo]}let Data = itemAdd(newPath, itemData, newItem)setItemData([...Data])};// 拖拽的排序方法const sortableUpdate = e => {// 交换数组const { newIndex, oldIndex } = e;// 父节点路径const parentPath = e.path[1].getAttribute('data-id');// 父元素 根节点时直接调用datalet parent = parentPath ? getItem(parentPath, itemData) : itemData;// 当前拖拽元素const dragItem = parent[oldIndex];// 更新后的父节点parent = update(parent, {$splice: [[oldIndex, 1], [newIndex, 0, dragItem]],});// 最新的数据 根节点时直接调用dataconst Data = parentPath ? setInfo(parentPath, itemData, parent) : parent// 调用父组件更新方法setItemData([...Data])};// 递归函数const loop = (arr, index) => {return (arr.map((item, i) => {const indexs = index === '' ? String(i) : `${index}-${i}`;if (item) {if (item.children) {return (<div {...item.attr} data-id={indexs} key={indexs}><Sortablekey={uniqueId()}style={{ minHeight: 100, margin: 10 }}ref={c => c && c.sortable}options={{...sortableOption,onUpdate: e => sortableUpdate(e),onAdd: e => sortableAdd(e),onChoose: e => sortableChoose(e),onSort: e => setIsChoose(false),}}>{ loop(item.children, indexs) }</Sortable></div>)}const ComponentInfo = GlobalComponent[item.name]return (<divdata-id={indexs} key={indexs}type={item.name}className='formItemStyle'style={(isChoose && indexs === curItemKey) ? {border: '1px solid #FF3333'} : {}}>{item.name !== 'Divider' && <div className='formItemLabel'>{ isChoose ? (indexs === curItemKey ? curItemName : item.label) : item.label}</div>}{ renderDiffComponents(item, indexs, ComponentInfo)}   </div>)} else {return null}}))};const renderDiffComponents = (item, indexs, ComponentInfo) => {switch (item.name) {case 'Divider':return <ComponentInfo key={indexs} {...item.attr}></ComponentInfo>case 'Select':return (<ComponentInfo key={indexs} defaultValue={item.attr.defaultValue}>{item.attr.options.map(subItem => <Option key={subItem.key} value={subItem.value + ''}>{ subItem.label }</Option>)}</ComponentInfo>)default:return <ComponentInfo key={indexs} {...item.attr} />}}const getDataSource = (options) => {itemData[curItemKey].attr.options = [...options];setItemData([...itemData])}return (<div className='formMain'><Layout className='firstLayout'><Sider style={{ padding: 10 }}><h3 className='textHead'>组件列表</h3><Sortableoptions = {{group:{name: 'formItem',pull: 'clone',put: false,},sort: false,}}>{formItemData.map(item => (<div data-id={item.name} key={item.name} style={{ marginTop: 10 }}><Tag>{item.label + '-' + item.name}</Tag></div>))}</Sortable></Sider><Layout className='secondLayout'><Header><div className='headerWrapper'><h3 className='textHead' style={{ float: 'left' }}>表单设计</h3><Button className='formBtn' type='primary' onClick={handleSubmit}>保存</Button><Button className='formBtn' onClick={handlePreview}>预览</Button></div><Divider /></Header><Content style={{ marginTop: 15 }}><Layout className='thirdLayout'><Content><SortableclassName='formContent'ref={c => c && c.sortable}options={{...sortableOption,onUpdate: e => sortableUpdate(e),onAdd: e => sortableAdd(e),onChoose: e => sortableChoose(e),onSort: e => setIsChoose(false),}}key={uniqueId()}>{ loop(itemData, '')}</Sortable></Content><Sider className='itemInfo'><Header><h3 className='textHead'>字段设置</h3></Header><Content><Form className='itemForm'><Form.Item label="组件Key"><Input value={curItemKey} disabled /></Form.Item><Form.Item label="标签名"><Input value={curItemName} disabled={!isChoose} onChange={handleLabelChange} /></Form.Item>{['CheckboxGroup', 'RadioGroup', 'Select'].includes(curItemType) &&<EditableTable getDataSource={getDataSource}curItemKey={curItemKey} options={itemData[curItemKey].attr.options}disabled={!isChoose}/>}</Form></Content><Footer style={{ border: 'none' }}><Button className='delBtn' onClick={handleDel} disabled={!isChoose}>删除</Button></Footer></Sider></Layout></Content></Layout></Layout>{isShowModal &&<Modaltitle='表单预览'visible={true}onCancel={() => setIsShowModal(false)}onOk={() => setIsShowModal(false)}>{ loop(itemData, '') }</Modal>}</div>);
}export default CustomForm;

② CustomForm/utils.js


import _ from 'lodash';/*** 将下标数组转为数组* @param {String|Number} pathStr 字符串类型的树路径 例:2-3-4* return {Array}  数组类型*/
const indexToArray = pathStr => `${pathStr}`.split('-').map(n => +n);/*** * @param {String}  index  下标路径* @param {Array}  cards  treeData* @return {object}  返回详情对象*/
const getCloneItem = (index, cards) => {const arr = indexToArray(index);let result = {};arr.forEach(n => {result = cards[n];cards = result.children;});return _.cloneDeep(result);
}
/*** 根据下标获取父节点* @param {String}   index  下标路径* @param {Array}    cards  treeData* @return {object}  返回详情对象*/
const getItem = (pathIndex, cards) => {const arr = indexToArray(pathIndex)// 嵌套节点删除let parent;if (arr.length === 0) {return cards}arr.forEach((item, index) => {if (index === 0) {parent = cards[item]} else {parent = parent.children[item]}})if (parent.children) return parent.childrenreturn parent
}const getParent = (pathIndex, cards) => {const arr = indexToArray(pathIndex)// 嵌套节点删除let parent;arr.pop()if (arr.length === 0) {return cards}arr.forEach((item, index) => {if (index === 0) {parent = cards[item]} else {parent = parent.children[item]}})if (parent.children) return parent.childrenreturn parent
}
/*** 根据路径删除数据* @param {*} index * @param {*} cards * @return {*} */
const itemRemove = (index, cards) => {let parent = getParent(index, cards);let arr = indexToArray(index)let getIndex = arr.pop()if (parent.children) {parent.children.splice(getIndex, 1)return cards}parent.splice(getIndex, 1)return cards
}
/*** * @param {*} index * @param {*} cards * @param {*} item */
const itemAdd = (index, cards, item) => {let parent = getParent(index, cards);let arr = indexToArray(index)let getIndex = arr.pop()if (parent.children) {parent.children.splice(getIndex, 0, item)return cards}parent.splice(getIndex, 0, item)return cards
}
/*** 根据index设置排序* @param {Array}  arr   节点路径的数组格式 * @param {Array}  treeData  树节点数据* @param {object} param   要替换的数据*/
const setInfo = (arrPath, treeData, param) => {const arr = indexToArray(arrPath)treeData = _.cloneDeep(treeData);let parent;arr.forEach((item, index) => {if (index == 0) {parent = treeData[item]} else {parent = parent.children[item]}})parent.children = paramreturn treeData
}/*** * @param {*} pathIndex */
const isPath = pathIndex => {let result = trueindexToArray(pathIndex).forEach(item => {if (isNaN(item)) {result = falsereturn false}})return result
}
/*** 判断hover的路径是否为自己的子元素* @param {String} dragIndex * @param {String} hoverIndex */
const isChildrenPath = (dragIndex, hoverIndex) => {let dragIndexArr = String(dragIndex).split('-')let hoverIndexArr = String(hoverIndex).split('-')if (hoverIndexArr > dragIndexArr) {let sliceArr = hoverIndexArr.slice(0, dragIndexArr.length)if (sliceArr.join('-') === dragIndexArr.join('-')) {return true}}return false
}
/**
* 根据数组路径 生成所有父级别的路径
* @param {String} index
*/
const generatePathArr = index => {let arr = []let indexArr = String(index).split('-');let data = Array.from(indexArr)indexArr.forEach((item, i) => {data.pop()arr.push(Array.from(data).join('-'))})arr.pop()return arr
}export { indexToArray, getParent, setInfo, isChildrenPath, generatePathArr, isPath, getCloneItem, getItem, itemRemove, itemAdd }

③Custom/config.js


import { Rate, Input, Divider, DatePicker, InputNumber, Switch, Slider, Checkbox, Radio, Select } from 'antd';const { MonthPicker, RangePicker, WeekPicker } = DatePicker;
const { TextArea } = Input;
const CheckboxGroup = Checkbox.Group;
const RadioGroup = Radio.Group;
const options = [];
for (let i = 0; i < 3; i++) {options.push({key: i.toString(),label: `选项 ${i}`,value: i.toString()});
}
const GlobalComponent = {Divider,DatePicker,RangePicker,MonthPicker,WeekPicker,Input,TextArea,InputNumber,Switch,Slider,CheckboxGroup,RadioGroup,Select,Rate,
};const formItemData = [{name: 'Containers',attr: {style: {border: '1px solid #40a9ff'}},label: '容器'},{name: 'RangePicker',attr: {style: {width: '100%'},defaultValue: undefined},label: '区间选择框'},{name: 'DatePicker',attr: {style: {width: '100%'},defaultValue: undefined},label: '日选择框'},{name: 'MonthPicker',attr: {style: {width: '100%'},defaultValue: undefined,placeholder: '请选择月份'},label: '月选择框'},{name: 'WeekPicker',attr: {style: {width: '100%',},defaultValue: undefined,placeholder: '请选择周期'},label: '周选择框'},{name: 'Input',attr: {defaultValue: '',placeholder: '请输入'},label: '文本框'},{name: 'TextArea',attr: {defaultValue: '',placeholder: '请输入'},label: '文本域'},{name: 'InputNumber',attr: {defaultValue: undefined,},label: '数字框'},{name: 'Switch',attr: {style: {width: 44,},defaultValue: false,},label: '开关'},{name: 'Slider',attr: {style: {width: '100%',padding: '0'},defaultValue: 10},label: '滑动条'},{name: 'Rate',attr: {style: { width: '100%',color: '#47FECF' },defaultValue: 0},label: '评分'},{name: 'Divider',attr: {},label: '分割线'},{name: 'CheckboxGroup',attr: {options: options,defaultValue: []},label: '多选框'},{name: 'RadioGroup',attr: {options: options,defaultValue: options[0].value},label: '单选框'},{name: 'Select',attr: {options: options,defaultValue: options[0].value},label: '下拉框'},
];export { formItemData, GlobalComponent, options }

④组件EditableTable/index.js

import React from 'react';
import PropTypes from 'prop-types';
import { Table, Input, InputNumber, Form, Button } from 'antd';
import './index.less';const EditableContext = React.createContext();class EditableCell extends React.Component {getInput = () => {if (this.props.inputType === 'number') {return <InputNumber />;}return <Input />;};renderCell = ({ getFieldDecorator }) => {const {editing,dataIndex,title,inputType,record,index,children,...restProps} = this.props;return (<td {...restProps}>{editing ? (<Form.Item style={{ margin: 0 }}>{getFieldDecorator(dataIndex, {rules: [{required: true,message: `请输入 ${title}!`,},],initialValue: record[dataIndex],})(this.getInput())}</Form.Item>) : (children)}</td>);};render() {return <EditableContext.Consumer>{this.renderCell}</EditableContext.Consumer>;}
}class EditableTable extends React.Component {constructor(props) {super(props);const { options } = this.props;this.state = { data: options, editingKey: '', count: options.length };this.columns = [{title: '显示值',dataIndex: 'label',width: '30%',editable: true,},{title: '传递值',dataIndex: 'value',width: '20%',editable: true,},{title: '操作',dataIndex: 'operation',render: (text, record) => {const { editingKey } = this.state;const editable = this.isEditing(record);return editable ? (<span><EditableContext.Consumer>{form => (<aonClick={() => this.save(form, record.key)}style={{ marginRight: 15 }}>保存</a>)}</EditableContext.Consumer><a onClick={() => this.cancel(record.key)}>取消</a></span>) : (<span><a disabled={editingKey !== ''} onClick={() => this.edit(record.key)}>编辑</a><a style={{ marginLeft: 15, color: '#FF3333' }} onClick={() => this.delete(record.key)}>删除</a></span>);},},];}componentDidUpdate({ curItemKey }) {if (curItemKey !== this.props.curItemKey) {this.setState({ data: this.props.options })}}isEditing = record => record.key === this.state.editingKey;cancel = () => {this.setState({ editingKey: '' });};save(form, key) {form.validateFields((error, row) => {if (error) {return;}const newData = [...this.state.data];const index = newData.findIndex(item => key === item.key);if (index > -1) {const item = newData[index];newData.splice(index, 1, {...item,...row,});} else {newData.push(row);}this.props.getDataSource(newData)this.setState({ data: newData, editingKey: '' });});}edit(key) {this.setState({ editingKey: key });}delete = key => {const data = [...this.state.data];this.props.getDataSource(data.filter(item => item.key !== key))this.setState({ data: data.filter(item => item.key !== key) });};add = () => {const { count, data } = this.state;const newData = {key: count,label: `选项 ${count}`,value: count};this.props.getDataSource([...data, newData])this.setState({data: [...data, newData],count: count + 1,});};render() {const components = {body: {cell: EditableCell,},};const columns = this.columns.map(col => {if (!col.editable) {return col;}return {...col,onCell: record => ({record,inputType: col.dataIndex === 'value' ? 'number' : 'text',dataIndex: col.dataIndex,title: col.title,editing: this.isEditing(record),}),};});return (<EditableContext.Provider value={this.props.form}><Button disabled={this.props.disabled} onClick={this.add} type="primary" style={{ marginBottom: 16 }}>添加选项</Button><Tablecomponents={components}bordereddataSource={this.state.data}columns={columns}rowClassName="editable-row"pagination={false}/></EditableContext.Provider>);}
}const EditableFormTable = Form.create()(EditableTable);EditableCell.propTypes = {editing: PropTypes.bool,dataIndex: PropTypes.string,title: PropTypes.string,inputType: PropTypes.string,record: PropTypes.object,index: PropTypes.string,children: PropTypes.array.isRequired,restProps: PropTypes.object,
}EditableTable.propTypes = {form: PropTypes.object.isRequired,getDataSource: PropTypes.func.isRequired,curItemKey: PropTypes.string.isRequired,options: PropTypes.array,disabled: PropTypes.bool.isRequired
}export default EditableFormTable

3. 其他工具

①lodash(https://www.lodashjs.com/)。是一个一致性、模块化、高性能的 JavaScript 实用工具库,它可以通过降低 array、number、objects、string 等等的使用难度从而让 JavaScript 变得更简单。

②immutability-helper(https://github.com/kolodny/immutability-helper)。它可以更改数据副本而不更改原始源。

react-sortablejs 实现自定义表单设计相关推荐

  1. 品高工作流 - 基于InfoPath的自定义表单设计教程

    一.        摘要 InfoPath是企业级搜集信息和制作表单的工具,将很多的界面控件集成在该工具中,为企业开发表单提供了极大的方便.InfoPath文件的后缀名是.XML,可见InfoPath ...

  2. 【自定义表单】自定义表单设计

    1.后端设计1 diy_field_pool 字段池(我们定义好的字段类型) diy_form 表单表(记录用户自定义的表单) diy_form_field 表单字段表(记录某张表单中有哪些字段) d ...

  3. Jeecg_3.6新版本功能专题讲解 - 公开课(自定义表单、数据权限)

    Jeecg_3.6新版本功能专题讲解 - 公开课 (自定义表单.数据权限) 听课时间:12月22日/23日/28日/29日 晚上20:30-22:30 听课地点:http://ke.qq.com/co ...

  4. Web自定义表单工具和协同办公系统之集成(1)

    提起"协同办公",随便在百度或者Google搜索一下,就能让你看到眼花缭乱的信息,国内的各大协同办公软件厂商都在鼓吹着自己对协同的理解和自己的协同办公软件产品如何能实现协同办公管理 ...

  5. 浅谈eform自定义表单工具和协同办公系统

    浅谈eform自定义表单工具和协同办公系统 提起"协同办公",随便在百度或者Google搜索一下,就能让你看到眼花缭乱的信息,国内的各大协同办公软件厂商都在鼓吹着自己对协同的理解和 ...

  6. 符合自己业务场景的自定义表单自定义报表及自定义图表

    随着公司业务发展,目前要应对几十家医院的文书.报表.BI,定制开发导致研发每天加班,现场需求也是不停的在改动,可能刚改好的东西还没部署就又出现了变化,应现场实施要求,需要一款智能化的工具帮助实施.就是 ...

  7. 工作流Flowable实战 (五)自定义表单

    文章目录 前言 一.Flowable自定义表单 二.自己实现的自定义表单 三.工作流中使用自定义表单 前言 Flowable中默认带了自定义表单,但无法满足项目需求,于是打算自己开发自定义表单 一.F ...

  8. java 自定义表单 动态表单 表单设计器 工作流引擎 flowable

    自定义表单设计模块都有哪些? 1 定义模版:拖拽左侧表单元素到右侧区域,编辑表单元素,保存表单模版 2 表单模版:编辑维护表单模版,复制表单模版,修改模版类型,预览表单模版 3. 我的表单:选择表单模 ...

  9. yii2表单数据检查怎么自定义输出错误_B端产品日记——表单设计

    编辑导语:表单在很多工作和项目中都会用到,在一个项目中,会涉及到大量的数据.信息等等,这时候用表单进行记录是很重要的:本文作者详细的介绍了在B端产品设计的工程中运用到的表单设计,我们一起来看一下. 人 ...

最新文章

  1. OOP 面向对象 七大原则 (一)
  2. asp.net实现ftp上传代码(解决大文件上传问题)
  3. 软考高项之质量管理-攻坚记忆
  4. Miner3D Enterprise 企业版
  5. mysql proxy 悲观锁_使用MySQL悲观锁解决电商扣库存并发问题
  6. 使用WebCrypto API的电子签名
  7. mysql 查询此时日期_mysql 查询日期
  8. opencv学习笔记3
  9. Markdown语法--整理
  10. xp系统如何使两台计算机共享,xp系统共享文件,两部电脑共享文件方法
  11. 聊一聊2D地图的迷雾效果
  12. VISIO画立体图——VISIO画图技巧
  13. Hadoop组件搭建-Hadoop全分布式
  14. 仿制美团购物的网站源码
  15. PR中视频材料声音大小不一样?1招快速统一音量
  16. 虚拟机安装linux的\/root,pt深海湛蓝爆屏图 -官网
  17. 我的世界rpg服务器背包位置,我的世界查看玩家背包方法 如何查看玩家背包
  18. 汉诺塔系列问题: 汉诺塔II、汉诺塔III、汉诺塔IV、汉诺塔V、汉诺塔VI
  19. MOS管与三极管比较及应用
  20. 3D打印技术新进展,正带来哪些产业新机会?

热门文章

  1. weblogic卸载 for linux
  2. 互联网跟移动互联网_互联网如何变坏
  3. ASEMI整流桥KBJ610,KBJ610浪涌电流,KBJ610反向电流
  4. spring 动态数据源切换实例
  5. 第2章-系统控制原理 -> 经典控制理论
  6. 41.Android之图片放大缩小学习
  7. PLC通过伯努利方程近似计算水箱流量(FC)
  8. 咸鱼带你学Java—关键字与标识符
  9. Kubernetes 实战——部署基于 Redis 和 Docker 的留言簿
  10. 做人要低调,绝对经典的低调