您现在的位置是:首页 >技术杂谈 >基于antd edit table二次改造支持所有单元格编辑及校验网站首页技术杂谈

基于antd edit table二次改造支持所有单元格编辑及校验

吴迪98 2024-06-26 14:23:21
简介基于antd edit table二次改造支持所有单元格编辑及校验

前言

一、需求描述

  1. 表格分为俩种模式:view模式和edit模式
  2. 在view模式下可点击edit按钮进入编辑模式
  3. 在edit模式下点击cancel按钮回到view模式且数据还原
  4. 在edit模式下点击generate保存数据暴露给外层组件
  5. 在edit模式下所有的单元格但是可以编辑的
  6. 在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);
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。