您现在的位置是:首页 >技术杂谈 >基于antd edit table二次改造支持所有单元格编辑及校验网站首页技术杂谈
基于antd edit table二次改造支持所有单元格编辑及校验
简介基于antd edit table二次改造支持所有单元格编辑及校验
文章目录
前言
一、需求描述
- 表格分为俩种模式:view模式和edit模式
- 在view模式下可点击edit按钮进入编辑模式
- 在edit模式下点击cancel按钮回到view模式且数据还原
- 在edit模式下点击generate保存数据暴露给外层组件
- 在edit模式下所有的单元格但是可以编辑的
- 在edit模式下点击generate会对所有单元格进行validate校验
二、组件代码
1.EditorTable/index.jsx
import React, { useMemo, useEffect, useState, useCallback, forwardRef } from 'react';
import { Table, Form } from 'antd';
import { Button, FloatingActionButton } from 'appkit-react';
import { v4 as uuidv4 } from 'uuid';
import { EditableCell, EditableRow } from './components';
import addIcon from '@assets/imgs/add.png';
import deleteIcon from '@assets/imgs/cancel.png';
import _ from 'lodash';
import { handleEditTableData, returnPureData } from './dataProcessors';
import './index.scss';
export const EditableContext = React.createContext(null);
/**
* @name 可编辑表格组件
* @author 吴迪 di.c.wu@pwc.com
* @link https://4x.ant.design/components/table-cn/#API
* @todo 暂时已知问题皆以解决,如有问题请联系作者或最后编辑的同学
*
* @desc 传入columns及dataSource即可轻松实现可编辑表格
* @param tableProps 表格的属性(同antd-table)
* 特殊说明:disabled+required 支持传入function,可以拿到当前行的数据
* @param defaultColumns 默认的表头配置(同antd-table的表头配置)
* @param defaultDataSource 默认表格数据
* @param needDelete 是否需要删除icon,默认true
* @param disabledActions 是否禁用所有的操作(如:新增、删除、编辑)
* @param afterAddRowFunc 增加行之后进行的操作-比如默认值之类的.在这个方法里可以拿到新增的那一行数据进行处理
* @param beforeEditRowFunc 编辑行成功之前进行的操作-比如清空值之类的.在这个方法里可以拿到新增的那一行数据进行处理
* @param afterDeleteRowFunc 删除行之后的回调-这个方法可以拿到删除后的剩余行数据进行处理
* @param dataChange 增、删、改数据之后的回调
* @param saveData 点击Generate保存数据之后的回调,可以拿到整个table的数据
*
* @version 0.1.1
* @createdTime 2023-4-18
* @lastModifiedTime 2023-4-28
* @lastModifiedUser 吴迪
*/
const Index = ({
tableProps = {},
tableColumns = [],
defaultDataSource = [],
needDelete = true,
disabledActions = false,
afterAddRowFunc = (newItem) => {},
afterDeleteRowFunc = (newItem) => {},
beforeEditRowFunc = (newItem) => {},
dataChange = (newData) => {},
saveData = (resData) => {},
toggleEdit= () => {},
}, editableRowRef) => {
const [form] = Form.useForm();
// 控制view模式与edit模式的,true:edit模式,false:view模式
const [isEditMode, setIsEditMode] = useState(false);
// 初始数据
const initData = defaultDataSource.map((item) => {
const newItem = { ...item };
Object.keys(item).forEach(key => {
if (key === 'id') {
newItem.id = item.id;
} else {
newItem[`${key}_${item.id}`] = item[key];
}
});
return newItem;
});
const [tableDataSource, setTableDataSource] = useState(initData);
// 增删改都功过该方法改数据,改当前组件使用的data以及暴露给父组件的data
const changeData = (data) => {
const newData = returnPureData(data);
dataChange(newData);
// 这里使用者可能需要改这个data,所以改完之后我再进行一次数据处理
setTableDataSource(handleEditTableData({
formVal: form.getFieldsValue(),
tableData: newData,
}));
};
// 添加每一项
const handleAdd = async () => {
let newData = { id: uuidv4() };
afterAddRowFunc(newData);
changeData(handleEditTableData({
formVal: form.getFieldsValue(),
tableData: [...tableDataSource, newData],
}));
};
// 删除
const handleDelete = async (id) => {
const newData = tableDataSource.filter((item) => item.id !== id);
afterDeleteRowFunc(newData);
changeData(handleEditTableData({
formVal: form.getFieldsValue(),
tableData: newData,
}));
};
// 保存数据 - 失焦即调
const handleSave = useCallback(
async (row) => {
const newData = [...tableDataSource];
const index = newData.findIndex((item) => row.id === item.id);
const curItem = newData[index];
console.log('保存curItem:', curItem);
if (_.isEqual(row, curItem)) return console.log('没有变化');
newData.splice(index, 1, {
...curItem,
...row,
});
const resData = handleEditTableData({
formVal: form.getFieldsValue(),
tableData: newData,
});
beforeEditRowFunc(resData[index]);
changeData(resData);
},
[tableDataSource],
);
// 获取禁用的类名 - 如果不是禁用的就返回空字符串
const getDisabledClassName = useMemo(() => {
return disabledActions ? 'disabled_style' : '';
}, [disabledActions]);
useEffect(() => {
toggleEdit(isEditMode);
}, [isEditMode, toggleEdit]);
// 判断是否可操作
const actionWrapper = useCallback(
(action) => {
if (disabledActions) return;
action && action();
},
[disabledActions],
);
// 处理表头数据-追加 序号列/删除列 且处理可编辑逻辑
const columns = useMemo(() => {
const newColumns = [...tableColumns];
if (needDelete && isEditMode) {
newColumns.push({
title: '',
dataIndex: 'operation',
width: 40,
render: (_, record) =>
tableDataSource.length >= 1 ? (
// <Popconfirm title="Sure to delete?" onConfirm={() => handleDelete(record.id)}>
<div className={`${getDisabledClassName} delete_wrapper`}>
<img
onClick={() => actionWrapper(() => handleDelete(record.id))}
src={deleteIcon}
className={`${getDisabledClassName} delete_icon`}
/>
</div>
) : // </Popconfirm>
null,
});
}
return newColumns.map((col) => {
// console.log('col: ', col);
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record) => ({
record,
type: col.type,
compProps: col.compProps,
required: col.required,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
alwaysEditMode: isEditMode,
placeholder: col.placeholder,
disabled: disabledActions,
handleSave,
}),
};
});
}, [isEditMode, tableColumns, needDelete, disabledActions, tableDataSource]);
const editableRow = ({ record, index, ...restProps }) => (
<EditableRow
{...restProps}
/>
);
const components = {
body: {
row: editableRow,
cell: EditableCell,
},
};
const renderBtnGroups = () => (
isEditMode ? (
<div className="btn-group">
<FloatingActionButton
className="cancel-btn"
style={{ marginRight: 6 }}
onClick={() => {
// 恢复没编辑之前的值
setTableDataSource(initData);
console.log('defaultDataSource: ', defaultDataSource, initData);
// 恢复view模式
setIsEditMode(false);
}}
>
Cancel
</FloatingActionButton>
<FloatingActionButton
className="generate-btn"
style={{ height: 24 }}
onClick={async () => {
await form?.validateFields();
const newArr = handleEditTableData({
formVal: form.getFieldsValue(),
tableData: tableDataSource,
});
// 在这里把数据暴露出去方便各自调接口
saveData(returnPureData(newArr));
changeData(newArr);
setIsEditMode(false);
}}
>
Generate
</FloatingActionButton>
</div>
) : (
<Button
className="btn role-control"
style={{ height: 26 }}
onClick={() => setIsEditMode(true)}
>
Edit
</Button>
)
)
return (
<EditableContext.Provider value={form}>
<div className="pwc_editor_table_wrapper">
<div className="pwc_editor_table_header">
<span>Table</span>
{renderBtnGroups()}
</div>
<Form ref={editableRowRef} form={form} component={false} autoComplete="off">
<Table
components={components}
rowClassName={() => 'editable-row'}
bordered
dataSource={tableDataSource}
columns={columns}
{...tableProps}
/>
</Form>
{/* 目前快速点击add new 是有问题的,包括export之类的数据都会有些问题 */}
{
isEditMode && (
<div
className={`${getDisabledClassName} add_btn`}
onClick={() => actionWrapper(handleAdd)}
>
<img src={addIcon} alt="add_icon" />
<span className="add-benchmark-color">Add Row</span>
</div>
)
}
</div>
</EditableContext.Provider>
);
};
export default forwardRef(Index);
2.EditorTable/components/EditableCell.jsx
import React, { memo, useEffect, useState, useContext, useRef, useMemo } from 'react';
import moment from 'moment';
import {
PWCInput,
PWCSelect,
PWCDatePicker,
} from '@components/Form/components';
import { EditableContext } from '../index';
import { EDIT_TABLE_COLUMNS_TYPE } from '@utils/enums';
import { isObject } from '@utils/common';
import { isFunction } from 'lodash';
import { handleMomentData } from '../dataProcessors';
const EditableCell = ({
title,
editable,
children,
dataIndex: sourceDataIndex,
required, // 是否是必填的
type, // 标记是什么类型,如:Input、Select、RichEditor
compProps = {}, // 每一项可编辑内容的props
alwaysEditMode = false, // 一直是编辑模式
disabled = false, // 是否禁用
placeholder, // 没有值的时候默认显示的文案,与输入框选择框的placeholder不是一个性质
record,
handleSave,
...restProps
}) => {
const dataIndex = `${sourceDataIndex}_${record?.id}`;
// 日期类型
const isDatePicker = type === EDIT_TABLE_COLUMNS_TYPE.DATE_PICKER;
// console.log('record: ', record);
// console.log('title: ', title);
// console.log('dataIndex: ', dataIndex);
// console.log('restProps: ', restProps);
// console.log('type: ', type);
// console.log('compProps: ', compProps);
const [editing, setEditing] = useState(false);
const inputRef = useRef(null);
const form = useContext(EditableContext);
useEffect(() => {
if (editing && inputRef?.current) {
inputRef.current.focus();
}
}, [editing, inputRef]);
const handleFormData = () => {
if (record) {
form.setFieldsValue({
[dataIndex]: isDatePicker && record[dataIndex] ? moment(record[dataIndex]) : record[dataIndex],
[sourceDataIndex]: isDatePicker && record[dataIndex] ? moment(record[dataIndex]) : record[dataIndex],
});
}
}
// 进入编辑模式时初始化form数据
useEffect(() => {
handleFormData();
}, [alwaysEditMode, record]);
// 切换编辑模式
const toggleEdit = () => {
if (disabled) return;
setEditing(!editing);
handleFormData();
};
// 失焦、选择调用
const save = async () => {
const values = await form.getFieldsValue();
if (!alwaysEditMode) {
toggleEdit();
}
// 单独处理日期的值
Object.keys(values).map(key => {
handleMomentData(values[key]);
})
const resValue = {
...record,
...values,
};
handleSave(resValue);
};
// 处理不是 react node 的 children
if (Array.isArray(children) && isObject(children[1])) {
children = children.join('');
}
let childNode = children;
// 组件的参数
const formItemInfo = useMemo(() => {
// 处理rules-如果required为true那么自动追加一个required判断条件
const rules = compProps.rules || [];
if (isFunction(required) ? required(record) : required) {
rules.push({
required: true,
message: `${title} is required.`,
});
}
// 处理rules-END
const formItem = {
name: dataIndex,
rules,
style: { margin: 0 },
formItemStyle: { margin: 0 },
compProps: {
...compProps,
disabled: isFunction(compProps?.disabled) ? compProps.disabled(record) : disabled,
ref: inputRef,
// onChange: save, // 目前的设计千万别监听change事件,否则输入框那里输入一下就会失焦一下,因为更改了数据
onBlur: save,
},
};
// console.log('最终的dataIndex: ', dataIndex);
// console.log('aaaaaa: ', formItem.name);
if ([EDIT_TABLE_COLUMNS_TYPE.SELECT].includes(type)) {
formItem.compProps.onSelect = save;
}
return formItem;
}, []);
if (editable) {
// 默认显示的默认值
const renderDefaultConteng = placeholder;
childNode =
editing || alwaysEditMode ? (
{
[EDIT_TABLE_COLUMNS_TYPE.INPUT]: <PWCInput {...formItemInfo} />,
[EDIT_TABLE_COLUMNS_TYPE.SELECT]: <PWCSelect {...formItemInfo} />,
[EDIT_TABLE_COLUMNS_TYPE.DATE_PICKER]: <PWCDatePicker {...formItemInfo} />,
}[type]
) : (
<div
className="editable-cell-value-wrap"
// onClick={toggleEdit}
dangerouslySetInnerHTML={{
__html: children || renderDefaultConteng,
}}
/>
);
}
return <td {...restProps}>{childNode}</td>;
};
export default memo(EditableCell);
3.EditorTable/components/EditableRow.jsx
import React from 'react';
const EditableRow = ({ index, ...props }) => {
return (
<tr {...props} />
);
};
export default EditableRow;
4.EditorTable/components/index.js
import EditableCell from './EditableCell';
import EditableRow from './EditableRow';
export { EditableCell, EditableRow };
5.EditorTable/dataProcessors.js
import moment from 'moment';
// 判断如果是moment的话进行额外的format
export const handleMomentData = (data) => {
if (moment.isMoment(data)) {
data = data.format('MM/DD/YYYY');
}
return data;
}
// 处理可编辑表格数据
export const handleEditTableData = ({
formVal = {},
tableData = [],
}) => {
const newObj = {};
Object.keys(formVal).forEach(key => {
const [dataIndex, id] = key.split('_');
if (newObj[id]) {
newObj[id][dataIndex] = handleMomentData(formVal[key]);
} else {
newObj[id] = { [dataIndex]: handleMomentData(formVal[key]) };
}
});
const newArr = tableData.map((item) => {
const newItem = { ...item, ...newObj[item.id], rowIndex: item.id };
Object.keys(item).forEach(key => {
if (
!newItem[`${key}_${item.id}`] &&
key !== 'rowIndex' &&
key !== 'id' &&
!key.split('_')?.[1]
) {
newItem[`${key}_${item.id}`] = item[key];
}
});
return newItem;
});
return newArr;
};
// 返回纯净的数据 - 去除掉 『_id』
export const returnPureData = (data) => {
return data.map(item => {
const newItem = {};
Object.keys(item).forEach(key => {
if (!key.split('_')[1]) {
newItem[key] = item[key];
}
});
return newItem;
});
}
6.EditorTable/index.less
@import '@style/variables.scss';
.pwc_editor_table_wrapper {
// 表格外部头部拓展区域
.pwc_editor_table_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
span {
// color: #DBDBDB;
font-size: 20px;
}
// 按钮组
.btn-group {
display: flex;
// 取消按钮的样式
.cancel-btn {
height: 24px !important;
background-color: transparent !important;
border: 1px solid $activeColor;
padding: 12.5px 10px;
display: flex;
column-gap: 7.5px;
border-radius: 2px !important;
width: 80px !important;
color: #E45C2B;
}
.generate-btn {
height: 24px !important;
border: 1px solid $activeColor;
padding: 12.5px 10px;
display: flex;
column-gap: 7.5px;
border-radius: 2px !important;
width: 80px !important;
}
}
}
// 重置表格样式
.ant-table-wrapper {
border: 1px solid #474747;
border-bottom: none;
background: #242424;
// 表头
.ant-table-thead {
background: #242424;
.ant-table-cell {
border-right: none!important;
border-bottom: 1px solid #474747;
}
}
// 表体
.ant-table-tbody {
.ant-table-cell {
border-right: none!important;
position: relative;
// 表格表格静态展示时候的样式
.editable-cell-value-wrap {
min-height: 32px;
cursor: pointer;
// 处理文本框为空会高一块的问题 - 有bug,会导致富文本区域变成滚动
// position: absolute;
// box-sizing: border-box;
// top: 0;
// left: 16px;
// right: 0;
// bottom: 0;
// margin: 16px auto;
// END-处理文本框为空会高一块的问题
}
// 每一行的删除icon
.delete_wrapper {
background: rgb(71, 71, 71);
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.delete_icon {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
}
}
// 添加按钮
.add_btn {
display: flex;
align-items: center;
height: 43px;
padding-left: 18px;
border: 1px solid #474747;
background-color: #242424;
cursor: pointer;
img {
width: 16px;
height: 16px;
margin-right: 10px;
}
}
// 禁用样式
.disabled_style {
cursor: not-allowed!important;
opacity: 0.4;
}
}
三、使用组件
import React, { memo, useMemo, useState, useRef } from 'react';
import { EditTable } from '@components';
import { EDIT_TABLE_COLUMNS_TYPE } from '@utils/enums';
const Index = ({ projectInfo }) => {
// demo
const editTableRef = useRef(null);
const [sourceData, setSourceData] = useState([{
id: 1,
Category: 'People',
CompanyA: 'Aaa',
CompanyB: 'Baa',
date: '05/15/2023',
}, {
id: 2,
Category: 'Application',
CompanyA: 'ccc',
CompanyB: 'ddd',
date: '05/18/2023',
}]);
// 可编辑表格的参数
const editTableParams = useMemo(
() => ({
tableProps: {
pagination: false,
rowKey: 'id',
// scroll: { y: `${window.screen.height - 455}px` }, // 根据自己不同要求控制高度
// loading: tableLoading, // 获取数据和保存的时候控制表格loading
},
tableColumns: [
{
title: 'Category',
dataIndex: 'Category',
required: true,
editable: true,
type: EDIT_TABLE_COLUMNS_TYPE.SELECT,
compProps: {
options: [
{ label: 'People', value: 'People' },
{ label: 'Applications', value: 'Applications' },
{ label: 'Legal Entities', value: 'LegalEntities' },
{ label: 'Contracts', value: 'Contracts' },
],
},
},
{
title: 'Company A',
dataIndex: 'CompanyA',
required: (record) => record.Category !== 'People',
editable: true,
type: EDIT_TABLE_COLUMNS_TYPE.INPUT,
compProps: {
disabled: (record) => record.Category === 'People',
}
},
{
title: 'Company B',
dataIndex: 'CompanyB',
required: true,
editable: true,
type: EDIT_TABLE_COLUMNS_TYPE.INPUT,
compProps: {
disabled: (record) => {
return record.Category === 'People';
},
}
},
{
title: 'Test Date',
dataIndex: 'date',
required: true,
editable: true,
type: EDIT_TABLE_COLUMNS_TYPE.DATE_PICKER,
},
], // 推荐单独写一个index.data.ts文件存放该枚举
defaultDataSource: sourceData,
afterAddRowFunc: (newItem) => {newItem.Category = 888}, // 添加行增加默认值的demo
beforeEditRowFunc: (newItem) => {
if (newItem.Category === 'People') {
newItem.CompanyA = '';
}
},
afterDeleteRowFunc: (newData) => {
newData.forEach(item => item.CompanyB = '');
},
dataChange: (newData) => console.log('dataChange ::::', newData),
saveData: (resData) => console.log('saveData ::::', JSON.stringify(resData)),
}),
[
projectInfo,
// tableLoading,
],
);
return (
<EditTable ref={editTableRef} {...editTableParams} />
);
};
export default memo(Index);
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。