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 dayjs, { type Dayjs } from 'dayjs';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { useEditableCellStore } from '../../store/editableCell/editableCellStore';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
@@ -39,9 +40,6 @@ export interface EditableCellProps<Value = unknown> {
|
|||||||
onSave: (value: Value) => Promise<void>;
|
onSave: (value: Value) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
|
|
||||||
let replayingOutsideAction = false;
|
|
||||||
|
|
||||||
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
|
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
|
||||||
if (editor === 'date') return value ? dayjs(value as string) : null;
|
if (editor === 'date') return value ? dayjs(value as string) : null;
|
||||||
if (editor === 'date-range')
|
if (editor === 'date-range')
|
||||||
@@ -125,7 +123,7 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [editor, formatValue, value]);
|
}, [editor, formatValue, value]);
|
||||||
|
|
||||||
@@ -138,7 +136,7 @@ const EditableCell = <Value,>({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (editableValuesEqual(serialized, original)) {
|
if (editableValuesEqual(serialized, original)) {
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -146,7 +144,7 @@ const EditableCell = <Value,>({
|
|||||||
const previousValue = original;
|
const previousValue = original;
|
||||||
try {
|
try {
|
||||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
// 提供 6 秒内的撤销入口(把旧值再保存一次)
|
// 提供 6 秒内的撤销入口(把旧值再保存一次)
|
||||||
setUndoMeta({ serializedPrevious: previousValue });
|
setUndoMeta({ serializedPrevious: previousValue });
|
||||||
@@ -167,16 +165,14 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cellId = idRef.current;
|
const cellId = idRef.current;
|
||||||
if (editing && activeCell?.id === cellId) activeCell.save = save;
|
if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
|
||||||
return () => {
|
return () => useEditableCellStore.getState().clearIfActive(cellId);
|
||||||
if (activeCell?.id === cellId) activeCell = null;
|
|
||||||
};
|
|
||||||
}, [editing, save]);
|
}, [editing, save]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
if (replayingOutsideAction) return;
|
if (useEditableCellStore.getState().replayingOutsideAction) return;
|
||||||
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
|
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
|
||||||
const actionTarget =
|
const actionTarget =
|
||||||
event.target instanceof Element
|
event.target instanceof Element
|
||||||
@@ -192,10 +188,10 @@ const EditableCell = <Value,>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void save().then((saved) => {
|
void save().then((saved) => {
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
replayingOutsideAction = true;
|
useEditableCellStore.getState().setReplayingOutsideAction(true);
|
||||||
actionTarget.click();
|
actionTarget.click();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
replayingOutsideAction = false;
|
useEditableCellStore.getState().setReplayingOutsideAction(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -207,11 +203,12 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
const beginEdit = async () => {
|
const beginEdit = async () => {
|
||||||
if (!enabled || saving) return;
|
if (!enabled || saving) return;
|
||||||
|
const { activeCell } = useEditableCellStore.getState();
|
||||||
if (activeCell && activeCell.id !== idRef.current) {
|
if (activeCell && activeCell.id !== idRef.current) {
|
||||||
const saved = await activeCell.save();
|
const saved = await activeCell.save();
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
}
|
}
|
||||||
activeCell = { id: idRef.current, save };
|
useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
|
||||||
// 重新进入编辑时清掉上一次的撤销入口
|
// 重新进入编辑时清掉上一次的撤销入口
|
||||||
window.clearTimeout(undoTimerRef.current);
|
window.clearTimeout(undoTimerRef.current);
|
||||||
setUndoMeta(null);
|
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