From 669f3a4914b4f7f298139cfdcaa3d465bd64fa14 Mon Sep 17 00:00:00 2001 From: LRcoding <38545827+LwClick@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:25:25 +0800 Subject: [PATCH] feat(grid): add set NULL context menu action --- apps/desktop/src/components/grid/DataGrid.vue | 16 ++- .../src/composables/useDataGridEditor.ts | 2 + .../app-tests/dataGridContextMenu.test.ts | 33 ++++++ packages/app-tests/dataGridEditor.test.ts | 103 ++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/app-tests/dataGridContextMenu.test.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6d689ef284..64fb40e9d7 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -7046,6 +7046,11 @@ function selectionHasEditableCells(): boolean { return false; } +function setSelectionNull() { + if (!props.editable || !selectionHasEditableCells()) return; + fillSelectionWithValue(null); +} + function openBulkEditDialog() { if (!props.editable || !selectionHasEditableCells()) return; bulkEditValue.value = ""; @@ -8989,10 +8994,19 @@ const gridContextMenuItems = computed(() => { } if (props.editable && hasCellSelection.value) { + const hasEditableSelection = selectionHasEditableCells(); + if (!contextHeaderColumn.value) { + items.push({ + label: t("grid.setNull"), + action: setSelectionNull, + disabled: !hasEditableSelection, + icon: X, + }); + } items.push({ label: t("grid.bulkEditSelection"), action: openBulkEditDialog, - disabled: !selectionHasEditableCells(), + disabled: !hasEditableSelection, icon: Pencil, }); } diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index bb4b95b323..fb3b247307 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -299,6 +299,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function touchPendingChanges() { + // Save errors describe the previous pending snapshot; edits, undo/redo, and rollback make them stale. + saveError.value = ""; pendingChangesVersion.value++; } diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts new file mode 100644 index 0000000000..1033044a56 --- /dev/null +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { compileScript, parse } from "vue/compiler-sfc"; + +const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue"; +const dataGridSource = readFileSync(dataGridPath, "utf8"); + +test("DataGrid context menu script compiles", () => { + const { descriptor, errors } = parse(dataGridSource, { filename: dataGridPath }); + + assert.deepEqual(errors, []); + assert.ok(descriptor.scriptSetup); + compileScript(descriptor, { id: "data-grid-context-menu-test" }); +}); + +test("set NULL applies a real null value only to editable selections", () => { + const handler = dataGridSource.match(/function setSelectionNull\(\) \{[^]*?\n\}/)?.[0] ?? ""; + + assert.match(handler, /if \(!props\.editable \|\| !selectionHasEditableCells\(\)\) return;/); + assert.match(handler, /fillSelectionWithValue\(null\);/); + assert.doesNotMatch(handler, /fillSelectionWithValue\(["'](?:NULL)?["']\)/); +}); + +test("editable cell selections expose set NULL before bulk edit", () => { + const menuBlock = dataGridSource.match(/if \(props\.editable && hasCellSelection\.value\) \{[^]*?\n \}/)?.[0] ?? ""; + + assert.match(menuBlock, /const hasEditableSelection = selectionHasEditableCells\(\);/); + assert.match(menuBlock, /if \(!contextHeaderColumn\.value\) \{/); + assert.match(menuBlock, /label: t\("grid\.setNull"\),\s+action: setSelectionNull,\s+disabled: !hasEditableSelection,\s+icon: X,/); + assert.match(menuBlock, /label: t\("grid\.bulkEditSelection"\),\s+action: openBulkEditDialog,\s+disabled: !hasEditableSelection,/); + assert.ok(menuBlock.indexOf('t("grid.setNull")') < menuBlock.indexOf('t("grid.bulkEditSelection")')); +}); diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index 9edda0e933..673f4a02fa 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -618,6 +618,109 @@ test("undo and redo restore pending cell edits before save", () => { assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]); }); +test("setting a cell to NULL records pending SQL and supports undo and redo", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "name"], + rows: [[1, "Ada"] as CellValue[]], + })); + const editor = createPeopleGridEditor(result); + + editor.applyCellValue(0, 1, null); + + assert.equal(editor.dirtyRows.value.get(0)?.get(1), null); + assert.equal(editor.hasPendingChanges.value, true); + assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, null]); + + const statements = await editor.previewChanges(); + assert.deepEqual(statements, ['UPDATE "people" SET "name" = NULL WHERE "id" = 1;']); + assert.doesNotMatch(statements.join("\n"), /["']NULL["']/); + + editor.undoPendingChange(); + assert.equal(editor.dirtyRows.value.size, 0); + assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]); + + editor.redoPendingChange(); + assert.equal(editor.dirtyRows.value.get(0)?.get(1), null); + assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, null]); +}); + +test("setting an existing NULL cell to NULL is a no-op", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "name"], + rows: [[1, null] as CellValue[]], + })); + const editor = createPeopleGridEditor(result); + + editor.applyCellValue(0, 1, null); + + assert.equal(editor.dirtyRows.value.size, 0); + assert.equal(editor.hasPendingChanges.value, false); + assert.equal(editor.canUndoPendingChange.value, false); + assert.deepEqual(await editor.previewChanges(), []); +}); + +test("a failed NULL save keeps the pending cell edit", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + const editor = createQuickEntryEditor({ + quickEntryEnabled: false, + save: async () => { + throw new Error("NULL write rejected"); + }, + }); + + editor.applyCellValue(0, 1, null); + await editor.saveChanges(); + + assert.equal(editor.saveError.value, "NULL write rejected"); + assert.equal(editor.dirtyRows.value.get(0)?.get(1), null); + assert.equal(editor.hasPendingChanges.value, true); +}); + +test("a NOT NULL validation failure keeps the pending NULL cell edit recoverable", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + globalThis.fetch = (async (input) => { + if (String(input) !== "/api/query/prepare-data-grid-save") return new Response("unexpected request", { status: 500 }); + return new Response( + JSON.stringify({ + validationError: 'Column "name" does not allow NULL.', + statements: [], + rollbackStatements: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch; + const editor = createPeopleGridEditor(); + + editor.applyCellValue(0, 1, null); + await editor.saveChanges(); + + assert.equal(editor.saveError.value, 'Column "name" does not allow NULL.'); + assert.equal(editor.dirtyRows.value.get(0)?.get(1), null); + assert.equal(editor.hasPendingChanges.value, true); + assert.equal(editor.canUndoPendingChange.value, true); + + editor.undoPendingChange(); + assert.equal(editor.dirtyRows.value.size, 0); + assert.equal(editor.saveError.value, ""); + + editor.redoPendingChange(); + await editor.saveChanges(); + assert.equal(editor.saveError.value, 'Column "name" does not allow NULL.'); + + editor.discardChanges(); + assert.equal(editor.dirtyRows.value.size, 0); + assert.equal(editor.hasPendingChanges.value, false); + assert.equal(editor.saveError.value, ""); +}); + test("restoring a pending cell edit records undo and redo history", () => { setActivePinia(createPinia()); installBrowserTestGlobals();