业务背景
想必做 PC 后台管理的时候经常遇到产品要一个可编辑表格的 “表单控件” 吧,类似如下图:
市场上又很多类似的 “组件” 已经有类似的功能,比如 @alipay/techu-ui 的可编辑表格,同时具备校验防抖等功能,但是本文只是封装行为,毕竟业务定制化很多时候不一样,我们也疲于看文档。
useOperate
reducer 部分
const reducer = (state, action) => {
switch (action.type) {
case 'add': {
const _dataSource = [...state.dataSource, { id: uuidv4(), ...action.payload }];
return { ...state, dataSource: _dataSource };
}
case 'delete': {
const _dataSource = state.dataSource.filter(v => v.id !== action.payload);
return { ...state, dataSource: _dataSource };
}
case 'update': {
const _dataSource = state.dataSource.map((v) => {
if (v.id === action.payload.id) {
// const newValues = merge(cloneDeep(v), action.payload.values);
// console.log(newValues, 'newValues');
const newValues = action.payload.values;
return newValues;
}
return v;
});
return {
...state,
dataSource: _dataSource,
};
}
case 'copy': {
const _dataSource = [...state.dataSource];
const values = _dataSource.find(v => v.id === action.payload.id);
const index = _dataSource.findIndex(v => v.id === action.payload.id);
// 使用 slice 方法创建数组副本并插入新的元素
const newDataSource = _dataSource.slice();
newDataSource.splice(index + 1, 0, { ...values, id: uuidv4() });
return {
...state,
dataSource: newDataSource,
};
}
case 'UPDATE_DATA_SOURCE': {
const _dataSource = [...action.payload];
return {
...state, dataSource: _dataSource
};
}
default:
throw new Error(`Unknown action type: ${action.type}`);
}
};
init 函数
主要是为了保证操作行数据的唯一性
这里用的唯一值可以参考 import { v4 as uuidv4 } from 'uuid';
const init = initialState => ({
dataSource: initialState?.dataSource?.map(v => ({ ...v, id: v?.id || uuidv4() }))
});
useOperate 部分
function useOperate(value = []) {
const [state, dispatch] = useReducer(reducer, { dataSource: value }, init);
const onAdd = useMemo(() => (defaultValues) => {
dispatch({ type: 'add', payload: defaultValues });
}, []);
const onDelete = useMemo(() => (id) => {
dispatch({ type: 'delete', payload: id });
}, []);
const onUpdate = useMemo(() => (id, updateValues) => {
dispatch({ type: 'update', payload: { id, values: updateValues } });
}, []);
const onCopy = useMemo(() => (id) => {
dispatch({ type: 'copy', payload: { id } });
}, []);
const getItem = useMemo(() => id => state.dataSource.find(v => v.id === id), [state.dataSource]);
const setData = (data) => {
dispatch({ type: 'UPDATE_DATA_SOURCE', payload: data });
};
return {
dataSource: state.dataSource,
onAdd,
onDelete,
onUpdate,
onCopy,
getItem,
setData
};
}
如何实践,请参考以下业务代码
这里我写了一个组件为 ConfigTable,其中点击配置后有一个弹窗,这时候弹窗内容也为一个可编辑表格,同时可编辑表格每一行都可以展开
如下图
其中 IP 批次的数量和展开的内容的数字输入框数量一样
import React, { useState } from 'react';
import {
Table, Modal, InputNumber, Space, Select
} from 'antd';
import { useBoolean, useDeepCompareEffect } from 'ahooks';
import { useOperate } from '@/core/hooks';
const rangeOptions = [
{
label: '所有', value: 'ALL'
},
{
label: '核心', value: 'L1'
},
{
label: '非核心', value: 'L2'
},
];
/**
* "buAppConfigs [bu应用的批次配置]": [
{
"buNo [bu编码]": "string",
"range [部署应用范围 所有:ALL, 核心:L1 非核心:L2]": "string",
"excludeList": [
"string"
],
"batch0 [非核心应用]": [
{
"batchNum [批次号]": "integer[int32]",
"ratio [灰度比例]": "integer[int32]",
"publishStatus [发布单状态]": "integer[int32]",
"batchList [ip批次配置]": [
"#/definitions/Batch"
]
}
],
"batch1 [核心应用]": [
{
"batchNum [批次号]": "integer[int32]",
"ratio [灰度比例]": "integer[int32]",
"publishStatus [发布单状态]": "integer[int32]",
"batchList [ip批次配置]": [
"#/definitions/Batch"
]
}
]
}
]
*/
function ConfigTable(props) {
const { value = [], onChange, defaultBus } = props;
const [currId, setCurrId] = useState();
const {
onDelete, onAdd, onUpdate, onCopy, dataSource, getItem
} = useOperate(value?.length > 0 ? value : defaultBus);
const {
onDelete: onSubDel,
onAdd: onSubAdd,
onCopy: onSubCopy, dataSource: subData, onUpdate: onSubUpdate, getItem: getSubItem, setData: setSubData
} = useOperate([{
batchNum: 1, ratio: 10, batch: 1, id: 1
}]);
const [visible, { setTrue, setFalse }] = useBoolean();
const handleCopy = (id) => {
onCopy(id);
};
const handleDelete = (id) => {
onDelete(id);
};
const handleAdd = (id) => {
onAdd(id);
};
const handleOpen = (id) => {
const currItem = getItem(id);
setCurrId(id);
setTrue();
// 手动更新子组件状态
const _sData = currItem?.batches?.length > 0 ? currItem?.batches : [{
batchNum: 1, ratio: 10, batch: 1, id: 1
}];
setSubData(_sData);
};
const handleUpdate = (id, key, val) => {
const item = getItem(id);
const newItem = { ...item, [key]: val };
onUpdate(id, newItem);
};
const handleSubCopy = (id) => {
onSubCopy(id);
};
const handleSubDelete = (id) => {
onSubDel(id);
};
const handleSubAdd = (id) => {
onSubAdd(id);
};
const handleSubUpdate = (id, val, key) => {
const subItem = getSubItem(id);
const newItem = { ...subItem, [key]: val };
onSubUpdate(id, newItem);
};
const handleSubIpUpdate = (id, batch, val) => {
// batchList
const subItem = getSubItem(id);
const newItem = { ...subItem, batchList: subItem?.batchList || [] };
newItem.batchList[batch] = {
batchNum: batch + 1,
ratio: val,
};
onSubUpdate(id, newItem);
};
const onOk = () => {
const currItem = getItem(currId);
// 需要更新外部的 batches;
onUpdate(currId, { ...currItem, batches: subData });
setFalse();
};
const onCancel = () => {
setFalse();
};
const subDataSource = subData;
const tableProps = {
columns: [
{
dataIndex: 'buCode',
title: 'BU',
render: (v, { id }) => (
<Select
value={v}
mode="multiple"
options={defaultBus}
placeholder="请选择"
onChange={val => handleUpdate(id, 'buCode', val)}
/>
)
},
{
title: '是否核心',
dataIndex: 'range',
width: 130,
render: (v, { id }) => (
<Select
onChange={val => handleUpdate(id, 'range', val)}
value={v}
options={rangeOptions}
placeholder="请选择"
/>
)
},
{
title: '批次配置',
dataIndex: 'batchConfig',
width: 90,
render: (_, { id }) => <a onClick={() => handleOpen(id)}>配置</a>
},
{
title: '操作',
width: 150,
render: (_, { id }, index) => {
const isLast = index === dataSource?.length - 1;
return (
<Space>
<a onClick={() => handleCopy(id)}>复制</a>
<a onClick={() => handleDelete(id)}>删除</a>
{
isLast && <a onClick={() => handleAdd(id)}>添加</a>
}
</Space>
);
}
}
],
pagination: {
hideOnSinglePage: true
},
rowKey: 'id',
dataSource,
};
const mtableProps = {
columns: [
{
title: '应用批次', dataIndex: 'batchNum', render: (v, r, i) => `第 ${i + 1} 批`
},
{
title: '应用比例',
dataIndex: 'ratio',
render: (v, { id }) => (
<InputNumber
min={1}
onChange={val => handleSubUpdate(id, val, 'ratio')}
value={v}
placeholder="请输入"
formatter={item => `${item}%`}
/>
)
},
{
title: '应用下 IP 批次',
dataIndex: 'batch',
render: (v, { id }) => (
<InputNumber
min={1}
onChange={val => handleSubUpdate(id, val, 'batch')}
value={v}
placeholder="请输入"
formatter={item => `${item}批`}
/>
)
},
{
title: '操作',
width: 150,
render: (_, { id }, index) => {
const isLast = index === subData?.length - 1;
return (
<Space>
<a onClick={() => handleSubCopy(id)}>复制</a>
<a onClick={() => handleSubDelete(id)}>删除</a>
{
isLast && <a onClick={() => handleSubAdd(id)}>添加</a>
}
</Space>
);
}
}
],
dataSource: subDataSource,
pagination: {
hideOnSinglePage: true
},
rowKey: 'id',
expandable: {
expandIcon: () => false,
expandedRowRender: ({ batch, id, batchList }) => {
const currBatch = batchList || [];
const ipbatchList = Array(batch).fill().map((_, i) => (
<div style={{ width: 106 }}>
<div>第 {i + 1} 批 IP 比例</div>
<InputNumber
value={currBatch[i]?.ratio || null}
onChange={val => handleSubIpUpdate(id, i, val)}
min={1}
formatter={v => `${v}%`}
/>
</div>
));
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{ipbatchList}
</div>
);
},
expandedRowKeys: subData?.map(v => v.id)
},
};
const modalProps = {
visible,
onOk,
onCancel,
title: '应用批次配置',
width: 720,
};
useDeepCompareEffect(() => {
onChange(dataSource);
}, [dataSource]);
return (
<div>
<Table {...tableProps} />
<Modal {...modalProps}>
<Table {...mtableProps} />
</Modal>
</div>
);
}
export default ConfigTable;