react-sortablejs 实现自定义表单设计
一、业务需求描述1. 能够拖动表单组件(不限制样式)到指定区域后,渲染成指定的组件2. 能支持自定义标签名和属性,以及默认值3. 能实现可支持预览、可排序、可编辑等功能4. 能通过JSON数据格式前后端相互传递二、业务前期准备1. 在网上搜索了蛮多素材,但都是基于VUE的成品(http://www.maoyl.top/Demo/elformdesigndemo/index.html),与实际基于r
一、业务需求描述
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); // 当前选中组件的Key
const [curItemName, setCurItemName] = useState(String); // 当前选中组件的Name
const [curItemType, setCurItemType] = useState(String); // 当前选中组件的Type
const [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;
// 新路径 为根节点时直接使用index
const newPath = parentPath ? `${parentPath}-${newIndex}` : newIndex;
console.log('nameOrIndex:'+nameOrIndex,'parentPath:'+parentPath,'newIndex:'+newIndex,'newPath:'+newPath)
// 判断是否为路径 路径执行移动,非路径为新增
if (isPath(nameOrIndex)) {
// 旧的路径index
const 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 = nameOrIndex
const 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');
// 父元素 根节点时直接调用data
let parent = parentPath ? getItem(parentPath, itemData) : itemData;
// 当前拖拽元素
const dragItem = parent[oldIndex];
// 更新后的父节点
parent = update(parent, {
$splice: [[oldIndex, 1], [newIndex, 0, dragItem]],
});
// 最新的数据 根节点时直接调用data
const 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}>
<Sortable
key={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 (
<div
data-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>
<Sortable
options = {{
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>
<Sortable
className='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 &&
<Modal
title='表单预览'
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.children
return 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.children
return 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 = param
return treeData
}
/**
*
* @param {*} pathIndex
*/
const isPath = pathIndex => {
let result = true
indexToArray(pathIndex).forEach(item => {
if (isNaN(item)) {
result = false
return 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 => (
<a
onClick={() => 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>
<Table
components={components}
bordered
dataSource={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)。它可以更改数据副本而不更改原始源。
更多推荐
所有评论(0)