From 5acdbfa61ef3d09b26fb0de0de33584a50153695 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Tue, 18 Aug 2026 15:31:15 +0800 Subject: [PATCH] fix(grid): refresh database-computed values after edits --- apps/desktop/src/components/grid/DataGrid.vue | 149 +++++++++++++++++- .../grid/__tests__/DataGridSaveReload.spec.ts | 40 +++++ .../__tests__/useDataGridEditor.spec.ts | 134 +++++++++++++++- .../src/composables/useDataGridEditor.ts | 8 +- .../dataGridSelectionPersistence.spec.ts | 13 ++ 5 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 3f852a73b8..6fdba7e5fb 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -185,7 +185,7 @@ import { buildDataGridColumnLookupItems, dataGridColumnCommentFor, filterDataGri import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { summarizeSelection } from "@/lib/dataGrid/gridSelection"; -import { captureDataGridSelection, restoreDataGridSelection, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence"; +import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence"; import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames"; import { createDataGridCellContextMenuItems, @@ -472,6 +472,12 @@ let preservedSelectionOnNextResult: { selection: PersistedDataGridSelection; sourceResult: QueryResult; } | null = null; +let preservedDetailsOnNextResult: { + sideCell?: PersistedDataGridSelection; + cellDialog?: PersistedDataGridSelection; + rowDialog?: PersistedDataGridSelection; + columnDialog?: PersistedDataGridSelection; +} | null = null; watch( () => props.result, @@ -3396,6 +3402,7 @@ const editor = useDataGridEditor({ currentPage, cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey), onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result), + prepareFullReload, emit, }); @@ -3808,19 +3815,23 @@ function resetInfiniteScrollState() { resetGridVerticalScroll(true); } -async function onToolbarRefresh() { - if (transactionActive.value) { - discardChanges(); - } - // Reset infinite scroll state on refresh +function prepareFullReload() { if (infiniteScrollEnabled.value) { resetInfiniteScrollState(); } const selection = captureCurrentSelectionForRefresh(); preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null; + preservedDetailsOnNextResult = captureDetailsForRefresh(); preserveTransposeOnNextResult.value = showTranspose.value; isRefreshingData.value = true; beginDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner); +} + +async function onToolbarRefresh() { + if (transactionActive.value) { + discardChanges(); + } + prepareFullReload(); emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value, "refresh"); } @@ -4563,14 +4574,20 @@ const { isRowSelected, } = selection; -function captureCurrentSelectionForRefresh(): PersistedDataGridSelection | null { - return captureDataGridSelection({ +function selectionCaptureBase(): Omit { + return { columns: props.result.columns, sourceColumns: props.sourceColumns, rows: props.result.rows, primaryKeys: props.tableMeta?.primaryKeys ?? [], visibleColumnIndexes: visibleColumnIndexes.value, displayItems: displayItems.value, + }; +} + +function captureCurrentSelectionForRefresh(): PersistedDataGridSelection | null { + return captureDataGridSelection({ + ...selectionCaptureBase(), selectedRowIds: selectedRowIds.value, selectedColumnIndexes: selectedColumnIndexes.value, selectedCellKeys: selectedCellKeys.value, @@ -4581,6 +4598,119 @@ function captureCurrentSelectionForRefresh(): PersistedDataGridSelection | null }); } +function captureCellTargetForRefresh(target: { rowIndex: number; col: number } | null): PersistedDataGridSelection | undefined { + if (!target) return undefined; + const visibleColumnIndex = visibleColumnIndexes.value.indexOf(target.col); + if (visibleColumnIndex < 0) return undefined; + return ( + captureDataGridSelection({ + ...selectionCaptureBase(), + selectedRowIds: new Set(), + selectedColumnIndexes: new Set(), + selectedCellKeys: new Set(), + selectionAnchor: { rowIndex: target.rowIndex, colIndex: visibleColumnIndex }, + selectionFocus: { rowIndex: target.rowIndex, colIndex: visibleColumnIndex }, + selectingAll: false, + }) ?? undefined + ); +} + +function captureRowTargetForRefresh(rowId: number | null): PersistedDataGridSelection | undefined { + if (rowId === null) return undefined; + return ( + captureDataGridSelection({ + ...selectionCaptureBase(), + selectedRowIds: new Set([rowId]), + selectedColumnIndexes: new Set(), + selectedCellKeys: new Set(), + selectionAnchor: null, + selectionFocus: null, + selectingAll: false, + }) ?? undefined + ); +} + +function captureColumnTargetForRefresh(columnIndex: number | null): PersistedDataGridSelection | undefined { + if (columnIndex === null) return undefined; + const visibleColumnIndex = visibleColumnIndexes.value.indexOf(columnIndex); + if (visibleColumnIndex < 0) return undefined; + return ( + captureDataGridSelection({ + ...selectionCaptureBase(), + selectedRowIds: new Set(), + selectedColumnIndexes: new Set([visibleColumnIndex]), + selectedCellKeys: new Set(), + selectionAnchor: null, + selectionFocus: null, + selectingAll: false, + }) ?? undefined + ); +} + +function captureDetailsForRefresh() { + const details = { + sideCell: showCellDetail.value ? captureCellTargetForRefresh(detailCell.value) : undefined, + cellDialog: cellDetailDialogOpen.value ? captureCellTargetForRefresh(cellDetailDialogTarget.value) : undefined, + rowDialog: rowDetailDialogOpen.value ? captureRowTargetForRefresh(rowDetailDialogRowId.value) : undefined, + columnDialog: columnDetailDialogOpen.value ? captureColumnTargetForRefresh(columnDetailDialogColumnIndex.value) : undefined, + }; + return Object.values(details).some(Boolean) ? details : null; +} + +function restoreDetailCellAfterRefresh(snapshot: PersistedDataGridSelection | undefined): { rowIndex: number; col: number } | null { + if (!snapshot) return null; + const restored = restoreDataGridSelection({ + snapshot, + columns: props.result.columns, + sourceColumns: props.sourceColumns, + rows: props.result.rows, + visibleColumnIndexes: visibleColumnIndexes.value, + displayItems: displayItems.value, + }); + if (restored?.kind !== "range") return null; + const columnIndex = visibleColumnIndexes.value[restored.focus.colIndex]; + return columnIndex === undefined ? null : { rowIndex: restored.focus.rowIndex, col: columnIndex }; +} + +function restoreDetailsAfterRefresh(details: NonNullable) { + const sideCell = restoreDetailCellAfterRefresh(details.sideCell); + if (sideCell) { + detailCell.value = sideCell; + showCellDetail.value = true; + hydrateCellDetailTarget(sideCell); + } + + const cellDialog = restoreDetailCellAfterRefresh(details.cellDialog); + if (cellDialog) openCellDetailDialog(cellDialog.rowIndex, cellDialog.col); + + if (details.rowDialog) { + const restored = restoreDataGridSelection({ + snapshot: details.rowDialog, + columns: props.result.columns, + sourceColumns: props.sourceColumns, + rows: props.result.rows, + visibleColumnIndexes: visibleColumnIndexes.value, + displayItems: displayItems.value, + }); + if (restored?.kind === "rows" && restored.rowIds[0] !== undefined) openRowDetailDialog(restored.rowIds[0]); + } + + if (details.columnDialog) { + const restored = restoreDataGridSelection({ + snapshot: details.columnDialog, + columns: props.result.columns, + sourceColumns: props.sourceColumns, + rows: props.result.rows, + visibleColumnIndexes: visibleColumnIndexes.value, + displayItems: displayItems.value, + }); + if (restored?.kind === "columns") { + const columnIndex = visibleColumnIndexes.value[restored.columnIndexes[0] ?? -1]; + if (columnIndex !== undefined) openColumnDetailDialog(columnIndex); + } + } +} + function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) { const restored = restoreDataGridSelection({ snapshot, @@ -8634,6 +8764,8 @@ watch( (result, previousResult) => { const selectionSnapshot = preservedSelectionOnNextResult?.selection; preservedSelectionOnNextResult = null; + const detailsSnapshot = preservedDetailsOnNextResult; + preservedDetailsOnNextResult = null; const shouldPreserveTranspose = preserveTransposeOnNextResult.value; preserveTransposeOnNextResult.value = false; if (isDataGridPrefixAppend(previousResult, result)) return; @@ -8654,6 +8786,7 @@ watch( } exitTransaction(); if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot); + if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot); }, ); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts new file mode 100644 index 0000000000..d5a28f30bc --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridSaveReload.spec.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +function functionSource(name: string, nextName: string): string { + const start = dataGridSource.indexOf(`function ${name}`); + const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`)); + expect(start).toBeGreaterThanOrEqual(0); + expect(relativeEnd).toBeGreaterThan(0); + return dataGridSource.slice(start, start + relativeEnd); +} + +describe("DataGrid save reload integration", () => { + it("shares the toolbar full-reload preparation with editor saves", () => { + const prepareSource = functionSource("prepareFullReload", "onToolbarRefresh"); + const toolbarSource = functionSource("onToolbarRefresh", "setAutoRefreshInterval"); + + expect(dataGridSource).toContain("prepareFullReload,\n emit,"); + expect(toolbarSource).toContain("prepareFullReload();"); + expect(prepareSource).toContain("resetInfiniteScrollState();"); + expect(prepareSource).toContain("captureCurrentSelectionForRefresh();"); + expect(prepareSource).toContain("preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;"); + expect(prepareSource).toContain("preservedDetailsOnNextResult = captureDetailsForRefresh();"); + expect(prepareSource).toContain("preserveTransposeOnNextResult.value = showTranspose.value;"); + expect(dataGridSource).toContain("if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);"); + }); + + it("resets accumulated infinite-scroll pagination before reloading from offset zero", () => { + const resetSource = functionSource("resetInfiniteScrollState", "prepareFullReload"); + const nextPageSource = functionSource("infiniteScrollNextPage", "checkInfiniteScroll"); + + expect(resetSource).toContain("currentPage.value = 1;"); + expect(resetSource).toContain("lastInfiniteScrollPage = 0;"); + expect(resetSource).toContain("infiniteScrollRequestedOffset = undefined;"); + expect(resetSource).toContain("infiniteScrollRequestedLimit = undefined;"); + expect(nextPageSource).toContain("const nextOffset = props.result.rows.length;"); + expect(nextPageSource).toContain('emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());'); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 6c6d1c35f2..acae3f9c8d 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -1,18 +1,26 @@ -import { computed, ref } from "vue"; +import { computed, ref, type Ref } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearDataGridPendingSnapshot, DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "@/composables/useDataGridEditor"; import type { CellValue } from "@/lib/dataGrid/cellValue"; const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), + prepareDataGridSave: vi.fn(), + executeBatch: vi.fn(), + executeInTransaction: vi.fn(), + addHistory: vi.fn(), })); -vi.mock("@/lib/backend/api", () => ({})); +vi.mock("@/lib/backend/api", () => ({ + prepareDataGridSave: mocks.prepareDataGridSave, + executeBatch: mocks.executeBatch, + executeInTransaction: mocks.executeInTransaction, +})); vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: mocks.getConfig }), })); vi.mock("@/stores/historyStore", () => ({ - useHistoryStore: () => ({}), + useHistoryStore: () => ({ add: mocks.addHistory }), })); vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}), @@ -294,3 +302,123 @@ describe("useDataGridEditor appendPastedRowsToNewRow", () => { expect(editor.newRows.value[1]).toEqual(["Ada", "full payload", "Lovelace"]); }); }); + +describe("useDataGridEditor saveChanges reload", () => { + beforeEach(() => { + mocks.prepareDataGridSave.mockReset(); + mocks.executeBatch.mockReset(); + mocks.executeInTransaction.mockReset(); + mocks.addHistory.mockReset(); + mocks.getConfig.mockReset(); + }); + + function createSaveTestEditor(options: { currentPage?: Ref; prepareFullReload?: () => void; customSaveHandler?: { save: ReturnType } } = {}) { + const emit = vi.fn(); + const currentPage = options.currentPage ?? ref(1); + const result = ref<{ columns: string[]; rows: CellValue[][] }>({ + columns: ["id", "status"], + rows: [ + [1, "pending"], + [2, "pending"], + ], + }); + const editor = useDataGridEditor({ + result: computed(() => result.value), + editable: computed(() => true), + databaseType: computed(() => "mysql"), + connectionId: computed(() => "connection-1"), + database: computed(() => "app"), + tableMeta: computed(() => ({ + tableName: "orders_test", + columns: [ + { name: "id", data_type: "int" }, + { name: "status", data_type: "varchar" }, + ], + primaryKeys: ["id"], + })), + sourceColumns: computed(() => undefined), + onExecuteSql: computed(() => undefined), + customSaveHandler: computed(() => options.customSaveHandler), + sql: computed(() => undefined), + searchText: ref(""), + whereFilterInput: ref(""), + currentWhereInput: computed(() => undefined), + orderByInput: ref(""), + rowStatusFilter: ref("all"), + confirmDangerousRowDeletion: computed(() => true), + pageSize: ref(100), + currentPage, + cacheKey: computed(() => undefined), + getRowItem: () => undefined, + prepareFullReload: options.prepareFullReload, + emit, + }); + return { editor, emit, currentPage }; + } + + it("reloads after a pure row update, so database-computed columns (e.g. ON UPDATE CURRENT_TIMESTAMP) refresh without a manual page reload", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: 1 }); + + const { editor, emit } = createSaveTestEditor(); + editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + + await editor.saveChanges(); + + expect(mocks.executeBatch).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0); + }); + + it("does not reload when there are no pending changes to save", async () => { + const { editor, emit } = createSaveTestEditor(); + + await editor.saveChanges(); + + expect(mocks.prepareDataGridSave).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalledWith("reload", expect.anything()); + }); + + it("prepares one first-page reload after saving edits from three accumulated infinite-scroll pages", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ + statements: ["UPDATE orders_test SET status='shipped' WHERE id=1", "UPDATE orders_test SET status='cancelled' WHERE id=2"], + rollbackStatements: [], + }); + mocks.executeInTransaction.mockResolvedValue({ affected_rows: 2 }); + const infiniteScrollState = { + lastPage: 3, + requestedOffset: 200 as number | undefined, + requestedLimit: 100 as number | undefined, + }; + const currentPage = ref(3); + const prepareFullReload = vi.fn(() => { + currentPage.value = 1; + infiniteScrollState.lastPage = 0; + infiniteScrollState.requestedOffset = undefined; + infiniteScrollState.requestedLimit = undefined; + }); + const created = createSaveTestEditor({ currentPage, prepareFullReload }); + created.editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + created.editor.dirtyRows.value.set(1, new Map([[1, "cancelled"]])); + + await created.editor.saveChanges(); + + expect(mocks.executeInTransaction).toHaveBeenCalledTimes(1); + expect(prepareFullReload).toHaveBeenCalledTimes(1); + expect(infiniteScrollState).toEqual({ lastPage: 0, requestedOffset: undefined, requestedLimit: undefined }); + expect(created.emit).toHaveBeenCalledTimes(1); + expect(created.emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0); + }); + + it("keeps the custom save path from reloading after a pure update", async () => { + const customSave = vi.fn().mockResolvedValue(undefined); + const prepareFullReload = vi.fn(); + const { editor, emit } = createSaveTestEditor({ customSaveHandler: { save: customSave }, prepareFullReload }); + editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); + + await editor.saveChanges(); + + expect(customSave).toHaveBeenCalledTimes(1); + expect(prepareFullReload).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalledWith("reload", expect.anything()); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index c4161cae57..dc978ec039 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -104,6 +104,7 @@ export interface UseDataGridEditorOptions { cacheKey?: ComputedRef; /** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */ onResultPayloadMutated?: () => void; + prepareFullReload?: () => void; emit: { (event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number): void; }; @@ -1453,6 +1454,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function reloadCurrentData() { + options.prepareFullReload?.(); options.emit("reload", sql.value, searchText.value, options.currentWhereInput.value, orderByInput.value.trim() || undefined, pageSize.value, (currentPage.value - 1) * pageSize.value); } @@ -1490,6 +1492,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { isSaving.value = true; snapshot.newRowRefs.forEach((row) => savingNewRows.add(row)); const shouldReloadAfterSave = snapshot.newRows.length > 0 || snapshot.deletedRows.size > 0; + // SQL saves may update columns the client can't predict (e.g. an ON UPDATE CURRENT_TIMESTAMP + // column or a trigger), so reload after a pure row update too. Custom (non-SQL) data sources + // keep the original behavior below, unchanged. + const shouldReloadAfterSqlSave = shouldReloadAfterSave || snapshot.dirtyRows.size > 0; if (customHandler) { try { @@ -1609,7 +1615,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { clearSavedPendingChanges(snapshot); if (!hasPendingChanges.value) exitTransaction(); clearPendingChangeHistory(); - if (shouldReloadAfterSave) { + if (shouldReloadAfterSqlSave) { reloadCurrentData(); } await finishSaveChanges(snapshot); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSelectionPersistence.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSelectionPersistence.spec.ts index 07b449190b..d4c949cc42 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSelectionPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSelectionPersistence.spec.ts @@ -141,6 +141,19 @@ describe("data grid selection persistence", () => { expect(restore(columnSnapshot, { visibleColumnIndexes: [2, 0] })).toEqual({ kind: "columns", columnIndexes: [1, 0] }); }); + it("restores primary-key row and cell selections after an edited-row reload reorders results", () => { + const rowSnapshot = captureDataGridSelection(baseOptions({ selectedRowIds: new Set([1]), lastClickedRowIndex: 1 }))!; + const cellSnapshot = captureDataGridSelection(baseOptions({ selectionAnchor: { rowIndex: 1, colIndex: 2 }, selectionFocus: { rowIndex: 1, colIndex: 2 } }))!; + const refreshedRows = [ + [3, 20, "Linus"], + [1, 10, "Ada"], + [2, 10, "Grace updated"], + ]; + + expect(restore(rowSnapshot, { rows: refreshedRows })).toEqual({ kind: "rows", rowIds: [2], anchorRowIndex: 2, scrollRowIndex: 2 }); + expect(restore(cellSnapshot, { rows: refreshedRows })).toEqual({ kind: "range", anchor: { rowIndex: 2, colIndex: 2 }, focus: { rowIndex: 2, colIndex: 2 }, selectingAll: false, scrollRowIndex: 2 }); + }); + it("keeps select-all semantics when the refreshed result gains rows", () => { const snapshot = captureDataGridSelection(baseOptions({ selectionAnchor: { rowIndex: 0, colIndex: 0 }, selectionFocus: { rowIndex: 2, colIndex: 2 }, selectingAll: true }))!;