From d144e1bc08167395cd59d669880e309c8fc2c3f2 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 31 Aug 2026 16:58:32 +0800 Subject: [PATCH] fix(grid): decouple where and order by undo histories --- .../grid/DataGridConditionEditor.vue | 50 ++++++++++++++++++- .../__tests__/DataGridConditionEditor.spec.ts | 40 ++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue index f0b4c6d520..2532a70cea 100644 --- a/apps/desktop/src/components/grid/DataGridConditionEditor.vue +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -55,6 +55,9 @@ const suggestionPosition = ref({ left: 0, top: 0, width: 180 }); const historyPreview = ref<{ value: string; left: number; top: number; maxWidth: number; arrowTop: number; side: "left" | "right" } | null>(null); const pointerMovedSuggestionIndex = ref(-1); const editorFocused = ref(false); +const conditionUndoStack = ref([]); +const conditionRedoStack = ref([]); +let conditionLastValue = modelValue.value; let collapseTimer: ReturnType | undefined; let resizeObserver: ResizeObserver | undefined; let expandAfterComposition = false; @@ -370,6 +373,43 @@ function onInput(event: Event) { scheduleCaretIntoView(); } +function isConditionUndoRedoShortcut(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + return ((event.metaKey || event.ctrlKey) && !event.altKey && key === "z") || (event.ctrlKey && !event.metaKey && !event.altKey && key === "y"); +} + +function isConditionRedoShortcut(event: KeyboardEvent) { + return ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === "z") || (event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === "y"); +} + +function applyConditionHistoryValue(value: string) { + conditionLastValue = value; + modelValue.value = value; + void nextTick(() => { + const target = activeEditor.value; + if (!target) return; + target.focus({ preventScroll: true }); + target.setSelectionRange(value.length, value.length); + syncSelection(target); + }); +} + +function handleConditionUndoRedo(event: KeyboardEvent) { + if (!isConditionUndoRedoShortcut(event)) return false; + + event.preventDefault(); + event.stopPropagation(); + const source = isConditionRedoShortcut(event) ? conditionRedoStack.value : conditionUndoStack.value; + const targetValue = source.pop(); + if (targetValue === undefined) return true; + + const currentValue = modelValue.value; + const destination = isConditionRedoShortcut(event) ? conditionUndoStack.value : conditionRedoStack.value; + destination.push(currentValue); + applyConditionHistoryValue(targetValue); + return true; +} + async function applyCondition() { editor.dismiss(); const applied = props.apply ? await props.apply(modelValue.value) : emit("apply", modelValue.value); @@ -385,6 +425,7 @@ async function clearCondition() { } function onKeydown(event: KeyboardEvent) { + if (handleConditionUndoRedo(event)) return; if (completeQuote(event)) return; const action = editor.handleKeydown(event); if (action === "apply") void applyCondition(); @@ -471,7 +512,14 @@ function hideHistoryPreview() { historyPreview.value = null; } -watch(modelValue, () => resizeEditor()); +watch(modelValue, (value) => { + if (value !== conditionLastValue) { + conditionUndoStack.value.push(conditionLastValue); + conditionRedoStack.value = []; + conditionLastValue = value; + } + resizeEditor(); +}); watch(suggestionPreferredWidth, () => { if (editor.dropdownOpen.value) updateSuggestionPosition(); }); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts index 8594bd1381..b2a65cd11e 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts @@ -30,7 +30,7 @@ function mountEditor(kind: DataGridConditionHistoryKind, initialValue: string, o ); app.mount(host); mountedApps.push({ app, host }); - return { value, input: host.querySelector("textarea") as HTMLTextAreaElement }; + return { value, input: host.querySelector("textarea") as HTMLTextAreaElement, host }; } function mockTextareaMetrics(input: HTMLTextAreaElement, options: { clientWidth: number; scrollWidth?: number; clientHeight?: number; scrollHeight?: number }) { @@ -121,6 +121,44 @@ describe("DataGridConditionEditor quote completion", () => { expect(value.value).toBe("name"); }); + it.each([ + ["where", "z", { ctrlKey: true }], + ["orderBy", "z", { metaKey: true }], + ["where", "z", { ctrlKey: true, shiftKey: true }], + ["orderBy", "y", { ctrlKey: true }], + ] as const)("keeps %s undo/redo shortcuts in the condition editor", (kind, key, modifiers) => { + const { input, host } = mountEditor(kind, "id = 123"); + let bubbled = 0; + host.addEventListener("keydown", () => bubbled++); + + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...modifiers }); + input.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(bubbled).toBe(0); + }); + + it("keeps WHERE and ORDER BY undo history independent", async () => { + const where = mountEditor("where", "id = 123"); + const orderBy = mountEditor("orderBy", "id ASC"); + + where.input.value = "id = 456"; + where.input.dispatchEvent(new Event("input", { bubbles: true })); + orderBy.input.value = "id DESC"; + orderBy.input.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + + orderBy.input.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true, bubbles: true, cancelable: true })); + await nextTick(); + expect(where.value.value).toBe("id = 456"); + expect(orderBy.value.value).toBe("id ASC"); + + orderBy.input.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true, bubbles: true, cancelable: true })); + await nextTick(); + expect(where.value.value).toBe("id = 456"); + expect(orderBy.value.value).toBe("id DESC"); + }); + it("passes the textarea caret range through when accepting a suggestion", async () => { const { value, input } = mountEditor("where", "status = cus AND enabled = 1", { columns: ["customer_id"] }); input.focus();