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

需求背景

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

技术背景

  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'..."

代码示例

typescript 复制代码
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);

使用示例

typescript 复制代码
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;

效果展示

封装 useStopPropagation 方便服用

typescript 复制代码
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;
相关推荐
小飞侠是个胖子7 分钟前
在 WebGL 中构建高性能 3D 沉浸式系统的三套高阶方案
前端·3d
wh_xia_jun9 分钟前
Vue3 + Vitest 浏览器测试 从零开发指南
前端·javascript·vue.js
FlyWIHTSKY11 分钟前
区块链前端技术栈介绍
前端·区块链
唐青枫12 分钟前
别再让 key 写成字符串:TypeScript keyof 从入门到实战
前端·javascript·typescript
一点一木8 小时前
深度体验TRAE SOLO移动端7天:作为独立开发者,我把工作流揣进了兜里
前端·人工智能·trae
天外飞雨道沧桑8 小时前
TypeScript 中 omit 和 record 用法
前端·javascript·typescript
Lee川9 小时前
mini-cursor 揭秘:从 Tool 定义到 Agent 循环的完整实现
前端·人工智能·后端
canonical_entropy9 小时前
从 Spec-Driven Development 到 Attractor-Guided Engineering
前端·aigc·ai编程
研☆香9 小时前
聊聊前端页面的三种长度单位
前端
给钱,谢谢!10 小时前
React + PixiJS 实现果园成长页:从状态机到浇水动画
前端·react.js·前端框架