From be2590347f9b18b4db60e82b4bb835cf3dc05e34 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 5 Aug 2026 01:10:57 +0800 Subject: [PATCH] fix(grid): keep row copy actions consistent after refresh --- apps/desktop/src/components/grid/DataGrid.vue | 9 ++- .../src/components/ui/CustomContextMenu.vue | 9 +-- .../ui/__tests__/CustomContextMenu.spec.ts | 67 +++++++++++++++++++ .../__tests__/useDataGridExport.spec.ts | 41 +++++++++++- .../src/composables/useDataGridExport.ts | 5 ++ .../dataGrid/dataGridContextMenuState.spec.ts | 14 ++++ 6 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index f3039dbfee..0dc0d228e1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5534,6 +5534,8 @@ const { copyText, copyCell, copyRow, + copyRowCount, + canCopyRow, copyAll, copyWithExtractor, previewWithExtractor, @@ -7113,6 +7115,7 @@ watch( } clearCellSelection(); clearRowSelection(); + invalidateSyntheticContextSelection(); closeCellDetails(); closeDetailDialogs(); if (shouldPreserveTranspose) { @@ -8298,7 +8301,11 @@ function copySubmenu(): ContextMenuItem { if (contextColumn.value) { items.push({ label: t("grid.copyCell"), action: copyCell }); } - items.push({ label: isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow"), action: copyRow }); + items.push({ + label: copyRowCount.value > 1 ? t("grid.copyRows", { count: copyRowCount.value }) : t("grid.copyRow"), + action: copyRow, + disabled: () => !canCopyRow.value, + }); items.push({ label: "", separator: true }); items.push(...buildExtractorContextItems()); items.push({ label: "", separator: true }); diff --git a/apps/desktop/src/components/ui/CustomContextMenu.vue b/apps/desktop/src/components/ui/CustomContextMenu.vue index 99ffa27ac6..6bbd9a7548 100644 --- a/apps/desktop/src/components/ui/CustomContextMenu.vue +++ b/apps/desktop/src/components/ui/CustomContextMenu.vue @@ -86,7 +86,8 @@ function onScroll(e: Event) { } function onKeydown(e: KeyboardEvent) { - if (e.key === "Escape") close(); + if (["Alt", "Control", "Meta", "Shift"].includes(e.key)) return; + close(); } function onResize() { @@ -97,12 +98,12 @@ watch(show, (val) => { contextMenuRegistration?.setOpen(val); if (val) { document.addEventListener("pointerdown", onPointerDownOutside, true); - document.addEventListener("keydown", onKeydown); + document.addEventListener("keydown", onKeydown, true); document.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); } else { document.removeEventListener("pointerdown", onPointerDownOutside, true); - document.removeEventListener("keydown", onKeydown); + document.removeEventListener("keydown", onKeydown, true); document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); } @@ -263,7 +264,7 @@ onBeforeUnmount(() => { contextMenuRegistration?.dispose(); contextMenuRegistration = null; document.removeEventListener("pointerdown", onPointerDownOutside, true); - document.removeEventListener("keydown", onKeydown); + document.removeEventListener("keydown", onKeydown, true); document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }); diff --git a/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts b/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts index 4d51efeb04..4f27cb6626 100644 --- a/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts +++ b/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts @@ -20,6 +20,38 @@ afterEach(() => { }); describe("CustomContextMenu lifecycle", () => { + it("removes the capture keydown listener when unmounted while open", async () => { + const documentAdd = vi.spyOn(document, "addEventListener"); + const documentRemove = vi.spyOn(document, "removeEventListener"); + const root = defineComponent({ + setup() { + return () => + h( + CustomContextMenu, + { items: [{ label: "Inspect" }] }, + { + default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu }, "Target"), + }, + ); + }, + }); + const container = document.createElement("div"); + mountedContainers.push(container); + document.body.append(container); + const app = createApp(root); + app.mount(container); + + container.querySelector("#context-target")?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + await nextTick(); + const keydownListener = callsFor(documentAdd, "keydown").at(-1)?.[1]; + expect(keydownListener).toBeTruthy(); + + app.unmount(); + await nextTick(); + + expect(documentRemove.mock.calls).toContainEqual(["keydown", keydownListener, true]); + }); + it("uses one shared listener set across repeated bulk mount cycles", async () => { const documentAdd = vi.spyOn(document, "addEventListener"); const documentRemove = vi.spyOn(document, "removeEventListener"); @@ -97,6 +129,41 @@ describe("CustomContextMenu lifecycle", () => { app.unmount(); }); + it("closes the menu when an application shortcut is pressed", async () => { + const root = defineComponent({ + setup() { + return () => + h( + CustomContextMenu, + { items: [{ label: "Refresh row" }] }, + { + default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu, onKeydown: (event: KeyboardEvent) => event.stopPropagation() }, "Target"), + }, + ); + }, + }); + const container = document.createElement("div"); + mountedContainers.push(container); + document.body.append(container); + const app = createApp(root); + app.mount(container); + + const target = container.querySelector("#context-target"); + target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + await nextTick(); + expect(document.body.textContent).toContain("Refresh row"); + + target?.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true, bubbles: true })); + await nextTick(); + expect(document.body.textContent).toContain("Refresh row"); + + target?.dispatchEvent(new KeyboardEvent("keydown", { key: "r", metaKey: true, bubbles: true })); + await nextTick(); + expect(document.body.textContent).not.toContain("Refresh row"); + + app.unmount(); + }); + it("renders checked state after the menu label", async () => { const root = defineComponent({ setup() { diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 676987956a..7f515a3781 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -111,9 +111,10 @@ function createExportState( hasColumnSelection = false, visibleColumnIndexes?: number[], isSyntheticContext = false, + contextRowId?: number | null, ) { const rows = (rowDataList ?? [rowData ?? columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))]).map((data, index) => ({ ...row(data), id: index + 1 })); - const item = rows[0]!; + const resolvedContextRowId = contextRowId === undefined ? (rows[0]?.id ?? null) : contextRowId; const selectedRowIds = ref(new Set(selectedRowIdValues)); const options: UseDataGridExportOptions = { columns: computed(() => columns), @@ -136,7 +137,7 @@ function createExportState( selectedCells: computed(() => selectedCellMatrix ?? selectedCellsOverride ?? { columns: [], rows: [] }), selectedCellMatrix: computed(() => selectedCellMatrix ?? null), selectedRange: computed(() => null), - contextCell: ref({ rowId: item.id, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }), + contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }), contextSelectionIsSynthetic: ref(isSyntheticContext), getRowItem: (rowId) => rows.find((candidate) => candidate.id === rowId), selectedRowIds, @@ -160,6 +161,42 @@ describe("useDataGridExport prepared row statements", () => { clearDataGridClipboardCopy(); }); + it("disables row copy when the result has no rows", () => { + const state = createExportState(editableTable, ["id", "name"], undefined, undefined, undefined, [], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, null); + + expect(state.canCopyRow.value).toBe(false); + }); + + it("disables row copy when rows exist but none is selected or targeted", () => { + const state = createExportState(editableTable, ["id", "name"], undefined, [1, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, null); + + expect(state.canCopyRow.value).toBe(false); + }); + + it("enables row copy for a valid context row without a prior selection", () => { + const state = createExportState(editableTable, ["id", "name"], undefined, [1, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1); + + expect(state.canCopyRow.value).toBe(true); + }); + + it("counts only visible non-draft rows selected for copying", () => { + const first = { ...row([1, "Ada"]), sourceIndex: 0 }; + const draft = { ...row([2, "Draft"]), id: 2, sourceIndex: 1, isDraft: true }; + const state = createMongoExportState({ + columns: ["id", "name"], + item: first, + items: [first, draft], + mongoDocuments: [ + { id: 1, name: "Ada" }, + { id: 2, name: "Draft" }, + ], + selectedRowIds: new Set([1, 2, 999]), + }); + + expect(state.copyRowCount.value).toBe(1); + expect(state.canCopyRow.value).toBe(true); + }); + it("builds SQL UPDATE from only selected writable columns while retaining a hidden primary key", async () => { const item = row([7, "Ada", true]); const matrix: CellSelectionMatrix = { diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 355e16ded3..f0e74b2f40 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -363,6 +363,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return item && !item.isDraft ? [item] : []; } + const copyRowCount = computed(() => targetedRows().length); + const canCopyRow = computed(() => copyRowCount.value > 0); + function selectionInsertData(): CopyInsertData | null { const matrix = selectedCellMatrix.value; if (!matrix) return null; @@ -1308,6 +1311,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { copyText, copyCell, copyRow, + copyRowCount, + canCopyRow, copyAll, copyWithExtractor, previewWithExtractor, diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts new file mode 100644 index 0000000000..77768481ac --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8"); + +describe("DataGrid context menu state", () => { + it("clears the context target whenever the result is replaced", () => { + expect(dataGridSource).toMatch(/watch\(\s*\(\) => props\.result,[\s\S]*?clearRowSelection\(\);\s*invalidateSyntheticContextSelection\(\);[\s\S]*?exitTransaction\(\);/); + }); + + it("uses the actual copyable row count in the copy menu", () => { + expect(dataGridSource).toContain('label: copyRowCount.value > 1 ? t("grid.copyRows", { count: copyRowCount.value }) : t("grid.copyRow")'); + }); +});