需求背景
业务中存在需要自定义select,并实现打开弹框新增数据,如图
技术背景
- react 18
- ag 版本 31.1.1 (32版本提供了onValueChange方法,无需getValue,可自行了解)
- 组件 antd
- 开启了stopEditingWhenCellsLoseFocus(防止用户点击保存按钮,ag还在编辑态,拿不到最新的数据问题,设置失焦自动完成编辑)
核心点
- 设置自定义组件cell 为 popup
- 点击select Options、modal框时 阻止 mousedown事件(ag失焦判断监听的是 mousedown事件),否则下拉框、弹框会自动关闭
自定义组件需要注意的点
- function组件,必须使用 forward 抛出 ICellEditor 方法,其中getValue是必须的
- class组件 需实现 ICellEditorComp(建议使用 function 组件,更简单清晰)
- stopEdit是同步方法,而setValue是异步方法(回调更新),导致getValue拿不到最新的值,解决方案,使用useRef包裹一下
- 下拉框属于popup类型,ag默认有cell尺寸限制,这里根据你的需求选择
- 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;