refactor(admin): EditableCell 跨单元格协调状态迁入 zustand,替代模块级单例并补单测
This commit is contained in:
@@ -3,6 +3,7 @@ import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import equal from 'fast-deep-equal';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useEditableCellStore } from '../../store/editableCell/editableCellStore';
|
||||
import { message } from '../../ui/app-message';
|
||||
import './style.css';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
@@ -39,9 +40,6 @@ export interface EditableCellProps<Value = unknown> {
|
||||
onSave: (value: Value) => Promise<void>;
|
||||
}
|
||||
|
||||
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
|
||||
let replayingOutsideAction = false;
|
||||
|
||||
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
|
||||
if (editor === 'date') return value ? dayjs(value as string) : null;
|
||||
if (editor === 'date-range')
|
||||
@@ -125,7 +123,7 @@ const EditableCell = <Value,>({
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||
setEditing(false);
|
||||
}, [editor, formatValue, value]);
|
||||
|
||||
@@ -138,7 +136,7 @@ const EditableCell = <Value,>({
|
||||
return false;
|
||||
}
|
||||
if (editableValuesEqual(serialized, original)) {
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||
setEditing(false);
|
||||
return true;
|
||||
}
|
||||
@@ -146,7 +144,7 @@ const EditableCell = <Value,>({
|
||||
const previousValue = original;
|
||||
try {
|
||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||
if (activeCell?.id === idRef.current) activeCell = null;
|
||||
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||
setEditing(false);
|
||||
// 提供 6 秒内的撤销入口(把旧值再保存一次)
|
||||
setUndoMeta({ serializedPrevious: previousValue });
|
||||
@@ -167,16 +165,14 @@ const EditableCell = <Value,>({
|
||||
|
||||
useEffect(() => {
|
||||
const cellId = idRef.current;
|
||||
if (editing && activeCell?.id === cellId) activeCell.save = save;
|
||||
return () => {
|
||||
if (activeCell?.id === cellId) activeCell = null;
|
||||
};
|
||||
if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
|
||||
return () => useEditableCellStore.getState().clearIfActive(cellId);
|
||||
}, [editing, save]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (replayingOutsideAction) return;
|
||||
if (useEditableCellStore.getState().replayingOutsideAction) return;
|
||||
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
|
||||
const actionTarget =
|
||||
event.target instanceof Element
|
||||
@@ -192,10 +188,10 @@ const EditableCell = <Value,>({
|
||||
event.stopPropagation();
|
||||
void save().then((saved) => {
|
||||
if (!saved) return;
|
||||
replayingOutsideAction = true;
|
||||
useEditableCellStore.getState().setReplayingOutsideAction(true);
|
||||
actionTarget.click();
|
||||
queueMicrotask(() => {
|
||||
replayingOutsideAction = false;
|
||||
useEditableCellStore.getState().setReplayingOutsideAction(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -207,11 +203,12 @@ const EditableCell = <Value,>({
|
||||
|
||||
const beginEdit = async () => {
|
||||
if (!enabled || saving) return;
|
||||
const { activeCell } = useEditableCellStore.getState();
|
||||
if (activeCell && activeCell.id !== idRef.current) {
|
||||
const saved = await activeCell.save();
|
||||
if (!saved) return;
|
||||
}
|
||||
activeCell = { id: idRef.current, save };
|
||||
useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
|
||||
// 重新进入编辑时清掉上一次的撤销入口
|
||||
window.clearTimeout(undoTimerRef.current);
|
||||
setUndoMeta(null);
|
||||
|
||||
49
apps/admin/src/store/editableCell/editableCellStore.test.ts
Normal file
49
apps/admin/src/store/editableCell/editableCellStore.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useEditableCellStore } from './editableCellStore';
|
||||
|
||||
const { getState } = useEditableCellStore;
|
||||
|
||||
describe('editableCellStore', () => {
|
||||
beforeEach(() => getState().resetEditableCell());
|
||||
|
||||
it('setActiveCell / clearIfActive 只清理匹配 id 的激活单元格', () => {
|
||||
const save = async () => true;
|
||||
getState().setActiveCell({ id: 'a', save });
|
||||
expect(getState().activeCell?.id).toBe('a');
|
||||
|
||||
getState().clearIfActive('b');
|
||||
expect(getState().activeCell?.id).toBe('a');
|
||||
|
||||
getState().clearIfActive('a');
|
||||
expect(getState().activeCell).toBeNull();
|
||||
});
|
||||
|
||||
it('updateActiveSave 只更新最新激活单元格的 save 闭包', () => {
|
||||
const s1 = async () => true;
|
||||
const s2 = async () => false;
|
||||
getState().setActiveCell({ id: 'a', save: s1 });
|
||||
|
||||
getState().updateActiveSave('a', s2);
|
||||
expect(getState().activeCell?.save).toBe(s2);
|
||||
|
||||
// 非激活 id 的更新被忽略
|
||||
getState().updateActiveSave('b', s1);
|
||||
expect(getState().activeCell?.save).toBe(s2);
|
||||
});
|
||||
|
||||
it('replayingOutsideAction 可置位与复位', () => {
|
||||
expect(getState().replayingOutsideAction).toBe(false);
|
||||
getState().setReplayingOutsideAction(true);
|
||||
expect(getState().replayingOutsideAction).toBe(true);
|
||||
getState().setReplayingOutsideAction(false);
|
||||
expect(getState().replayingOutsideAction).toBe(false);
|
||||
});
|
||||
|
||||
it('resetEditableCell 清空全部协调状态', () => {
|
||||
getState().setActiveCell({ id: 'a', save: async () => true });
|
||||
getState().setReplayingOutsideAction(true);
|
||||
getState().resetEditableCell();
|
||||
expect(getState().activeCell).toBeNull();
|
||||
expect(getState().replayingOutsideAction).toBe(false);
|
||||
});
|
||||
});
|
||||
57
apps/admin/src/store/editableCell/editableCellStore.ts
Normal file
57
apps/admin/src/store/editableCell/editableCellStore.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* EditableCell 跨单元格协调状态。
|
||||
*
|
||||
* 原实现为模块级单例(activeCell / replayingOutsideAction),HMR、路由
|
||||
* 卸载与测试之间不会重置;迁入 zustand 后行为一致且可重置、可测试。
|
||||
* 所有读写都通过 getState() 命令式完成,不订阅渲染,避免无谓重渲染。
|
||||
*/
|
||||
export interface EditableCellSession {
|
||||
id: string;
|
||||
save: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface EditableCellStore {
|
||||
/** 当前处于编辑态的单元格(全局唯一) */
|
||||
activeCell: EditableCellSession | null;
|
||||
/** 外部点击回放期间置位,抑制 document 级 pointerdown 的二次保存 */
|
||||
replayingOutsideAction: boolean;
|
||||
setActiveCell: (session: EditableCellSession | null) => void;
|
||||
/** 编辑中更新 save 闭包(仅当仍是最新激活单元格时生效) */
|
||||
updateActiveSave: (id: string, save: () => Promise<boolean>) => void;
|
||||
/** 若指定 id 仍是最新激活单元格则清空 */
|
||||
clearIfActive: (id: string) => void;
|
||||
setReplayingOutsideAction: (value: boolean) => void;
|
||||
resetEditableCell: () => void;
|
||||
}
|
||||
|
||||
export const useEditableCellStore = create<EditableCellStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
activeCell: null,
|
||||
replayingOutsideAction: false,
|
||||
setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'),
|
||||
updateActiveSave: (id, save) => {
|
||||
const { activeCell } = get();
|
||||
if (activeCell?.id === id) {
|
||||
set({ activeCell: { ...activeCell, save } }, false, 'editableCell/updateActiveSave');
|
||||
}
|
||||
},
|
||||
clearIfActive: (id) => {
|
||||
const { activeCell } = get();
|
||||
if (activeCell?.id === id) set({ activeCell: null }, false, 'editableCell/clearIfActive');
|
||||
},
|
||||
setReplayingOutsideAction: (value) =>
|
||||
set({ replayingOutsideAction: value }, false, 'editableCell/setReplayingOutsideAction'),
|
||||
resetEditableCell: () =>
|
||||
set(
|
||||
{ activeCell: null, replayingOutsideAction: false },
|
||||
false,
|
||||
'editableCell/reset',
|
||||
),
|
||||
}),
|
||||
{ name: 'editable-cell-store', enabled: import.meta.env.DEV },
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user