ag-grid 自定义组件开发详解

128 阅读4分钟

需求背景

业务中存在需要自定义select,并实现打开弹框新增数据,如图 image.png

技术背景

  1. react 18
  2. ag 版本 31.1.1 (32版本提供了onValueChange方法,无需getValue,可自行了解)
  3. 组件 antd
  4. 开启了stopEditingWhenCellsLoseFocus(防止用户点击保存按钮,ag还在编辑态,拿不到最新的数据问题,设置失焦自动完成编辑)

核心点

  1. 设置自定义组件cell 为 popup
  2. 点击select Options、modal框时 阻止 mousedown事件(ag失焦判断监听的是 mousedown事件),否则下拉框、弹框会自动关闭

自定义组件需要注意的点

  1. function组件,必须使用 forward 抛出 ICellEditor 方法,其中getValue是必须的
  2. class组件 需实现 ICellEditorComp(建议使用 function 组件,更简单清晰)
  3. stopEdit是同步方法,而setValue是异步方法(回调更新),导致getValue拿不到最新的值,解决方案,使用useRef包裹一下
  4. 下拉框属于popup类型,ag默认有cell尺寸限制,这里根据你的需求选择
  5. react中如果是popup类型,则 col define时必须添加 cellEditorPopup: true,

ICellEditor 介绍

方法名时机用途(举例说明官方注释
isCancelBeforeStart()编辑器创建前(init() 后立即调用)根据初始条件决定是否 取消启动编辑(如按下非法键、字段只读等)“If you return true, the editor will not be used and the grid will continue editing.”
isCancelAfterEnd()编辑器关闭前(getValue() 调用之后)决定是否丢弃编辑结果,例如用户输入不合法、不满足业务逻辑时取消编辑“If you return true, then the new value will not be used.”
getValue()编辑完成时返回最终要写入单元格的值;比如返回 <Select /> 的选中值“Return the final value - called by the grid once after editing is complete”
afterGuiAttached()编辑器 DOM 渲染后用于设置焦点等 DOM 操作;比如自动聚焦 <input />“Useful for any logic that requires attachment before executing”
focusIn()在整行编辑时,编辑器获得焦点时执行一些进入焦点时的动作(例如高亮)“If doing full line edit, then gets called when focus should be put into the editor”
focusOut()在整行编辑时,编辑器失去焦点时用于处理失焦逻辑(如验证、清理)“If doing full line edit, then gets called when focus is leaving the editor”
refresh(params)当 cellEditor 被重复使用,并接收新的参数时更新内部状态(比如新 row data、新默认值)“Gets called with the latest cell editor params every time they update”
isPopup()编辑器启动时调用一次返回 true 则编辑器以 popup 模式展示,不受 cell 尺寸限制“If you return true, the editor will appear in a popup”
getPopupPosition()仅当 isPopup() 返回 true 时调用返回 "over"(覆盖 cell)或 "under"(浮在 cell 下方)“Return 'over' if the popup should cover the cell, or 'under'…”

代码示例

import React, {
  useState,
  useImperativeHandle,
  forwardRef,
  useEffect,
  useRef,
} from "react";
import { Select, Divider, Button, Modal, Input } from "antd";
import { useMemoizedFn } from "ahooks";
import { CustomCellEditorProps } from "ag-grid-react";
import { ICellEditor } from "ag-grid-enterprise";

const { Option } = Select;

function AntdSelectPopupEditor(
  params: CustomCellEditorProps,
  ref: React.Ref<ICellEditor>,
) {
  // 初始值为ag传入的value
  const [value, setV] = useState(params.value || "");
  // 解决stopEdit时,getValue 拿到的不是最新值的问题
  const valueRef = useRef(value);
  const options = ["Apple", "Banana", "Orange"];
  const containerRef = useRef<HTMLDivElement>(null);
  // modal框
  const [open, setOpen] = useState(false);
  const modalRef = useRef<HTMLDivElement>(null);
  const setValue = useMemoizedFn((value: any) => {
    setV(value);
    valueRef.current = value;
  });

  useImperativeHandle(ref, () => ({
    // 重要:自定义组件必须有
    getValue: () => {
      return valueRef.current;
    },
    // 重要: 告诉 AG Grid:这个编辑器是 popup,不要随便失焦
    isPopup: () => true, 
    afterGuiAttached: () => {
      // 我这边设置select 默认open,不需要这个了
      // setTimeout(() => {
      //   containerRef.current?.querySelector(".ant-select-selector")?.focus?.();
      // });
    },
  }));

  const handleAddOption = () => {
    setOpen(true);
  };

  // 👇关键点:防止点击 dropdown 触发失焦
  useEffect(() => {
    const stopMouseDown = (e: any) => {
      // 如果点击在 dropdown、editor或者modal框内部就阻止默认冒泡
      if (
        (containerRef.current && containerRef.current.contains(e.target)) ||
        e.target.closest(".ant-select-popup-editor-modal")
      ) {
        e.stopPropagation();
      }
    };
    document.addEventListener("mousedown", stopMouseDown, true);
    return () => {
      document.removeEventListener("mousedown", stopMouseDown, true);
    };
  }, []);

  return (
    <div ref={containerRef} style={{ padding: 8, minWidth: 200 }}>
      <Select
        value={value}
        onChange={(v) => {
          setValue(v);
          params.stopEditing();
        }}
        dropdownRender={(menu: any) => (
          <>
            {menu}
            <Divider style={{ margin: "8px 0" }} />
            <div style={{ padding: "8px", textAlign: "center" }}>
              <Button
                type="link"
                onClick={handleAddOption}
              >
                添加新选项
              </Button>
            </div>
          </>
        )}
        getPopupContainer={() => containerRef.current}
        style={{ width: "100%" }}
        // 默认打开,ag编辑组件时编辑时加载,这里直接设置为open即可
        open 
      >
        {options.map((opt) => (
          <Option key={opt} value={opt}>
            {opt}
          </Option>
        ))}
      </Select>
      <Modal
        open={open}
        title="规格选择"
        className="ant-select-popup-editor-modal"
        ref={modalRef}
        onOk={() => {
          params.stopEditing();
          setOpen(false);
        }}
        onCancel={() => setOpen(false)}
      >
        <Input value={value} onChange={(e) => setValue(e.target.value)} />
      </Modal>
    </div>
  );
}

export default forwardRef(AntdSelectPopupEditor);

使用示例

import { ColDef } from "ag-grid-enterprise";
import { AgGridReact } from "ag-grid-react";
import { useEffect, useState } from "react";
import AntdSelectPopupEditor from "./AntdSelectPopupEditor";
// Row Data Interface
interface IRow {
  make: string;
  model: string;
  price: number;
  electric: boolean;
  size: string;
}

// Create new GridExample component
const GridExample = () => {
  // Row Data: The data to be displayed.
  const [rowData, setRowData] = useState<IRow[]>([
    {
      make: "Tesla",
      model: "Model Y",
      price: 64950,
      electric: true,
      size: "1",
    },
    {
      make: "Ford",
      model: "F-Series",
      price: 33850,
      electric: false,
      size: "2",
    },
    {
      make: "Toyota",
      model: "Corolla",
      price: 29600,
      electric: false,
      size: "3",
    },
    { make: "Mercedes", model: "EQA", price: 48890, electric: true, size: "4" },
    { make: "Fiat", model: "500", price: 15774, electric: false, size: "4" },
    { make: "Nissan", model: "Juke", price: 20675, electric: false, size: "5" },
    { make: "Fiat", model: "500", price: 15774, electric: false, size: "6" },
  ]);

  // Column Definitions: Defines & controls grid columns.
  const [colDefs, setColDefs] = useState<ColDef<IRow>[]>([
    { field: "make" },
    { field: "model" },
    { field: "price", editable: true },
    { field: "electric", editable: true },
    {
      field: "size",
      cellEditor: AntdSelectPopupEditor,
      editable: true,
      // 重要
      cellEditorPopup: true,
      cellEditorPopupPosition: "over",
    },
  ]);

  return (
    <div
      className={"ag-theme-quartz"}
      style={{ width: "100%", height: "500px" }}
    >
      <AgGridReact
        rowData={rowData}
        columnDefs={colDefs}
        stopEditingWhenCellsLoseFocus={true}
      />
    </div>
  );
};
export default GridExample;

效果展示

output.gif

封装 useStopPropagation 方便复用

type ClassName = string;
type Container = React.RefObject<any> | ClassName;

function useStopPropagation(contains: Container[]) {
  const stopMouseDown = (e: any) => {
    // 如果点击在 dropdown 或 editor 里,就阻止默认行为
    const isContains = contains.some((container) => {
      if (typeof container === "string") {
        return e.target.closest(
          container.startsWith(".") ? container : `.${container}`,
        );
      }
      return container.current?.contains(e.target);
    });

    if (isContains) {
      e.stopPropagation();
    }
  };

  document.addEventListener("mousedown", stopMouseDown, true);
  return () => {
    document.removeEventListener("mousedown", stopMouseDown, true);
  };
}

export default useStopPropagation;