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;
相关推荐
GalenWu4 小时前
对象转换为 JSON 字符串(或反向解析)
前端·javascript·微信小程序·json
GUIQU.4 小时前
【Vue】微前端架构与Vue(qiankun、Micro-App)
前端·vue.js·架构
zwjapple4 小时前
“ES7+ React/Redux/React-Native snippets“常用快捷前缀
javascript·react native·react.js
数据潜水员4 小时前
插槽、生命周期
前端·javascript·vue.js
2401_837088504 小时前
CSS vertical-align
前端·html
优雅永不过时·4 小时前
实现一个漂亮的Three.js 扫光地面 圆形贴图扫光
前端·javascript·智慧城市·three.js·贴图·shader
CodeCraft Studio5 小时前
报表控件stimulsoft教程:使用 JoinType 关系参数创建仪表盘
前端·ui
春天姐姐6 小时前
vue知识点总结 依赖注入 动态组件 异步加载
前端·javascript·vue.js
互联网搬砖老肖7 小时前
Web 架构之数据读写分离
前端·架构·web
钢铁男儿8 小时前
C# 方法(值参数和引用参数)
java·前端·c#