diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index f4fab0781d..10cf8643ea 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2769,6 +2769,8 @@ const { isSaving, saveError, useTransaction, + beginBatch, + commitBatch, exitTransaction, startEdit, commitEdit, @@ -5474,28 +5476,33 @@ function fillSelectionWithValue(value: string | null): boolean { const range = selectedRange.value; let applied = false; const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); - if (range) { - for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + beginBatch(); + try { + if (range) { + for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + const item = displayItemAt(rowIndex); + if (!item) continue; + for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { + applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue) || applied; + } + } + return applied; + } + + if (!hasColumnSelection.value) return false; + const visibleColumnIndexes = selectedVisibleColumnIndexes(); + if (!visibleColumnIndexes.length) return false; + for (let rowIndex = 0; rowIndex < displayRowCount.value; rowIndex++) { const item = displayItemAt(rowIndex); if (!item) continue; - for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { - applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue) || applied; + for (const visibleCol of visibleColumnIndexes) { + applied = applyVisibleSelectedCellValue(item, visibleCol, value) || applied; } } return applied; + } finally { + commitBatch(); } - - if (!hasColumnSelection.value) return false; - const visibleColumnIndexes = selectedVisibleColumnIndexes(); - if (!visibleColumnIndexes.length) return false; - for (let rowIndex = 0; rowIndex < displayRowCount.value; rowIndex++) { - const item = displayItemAt(rowIndex); - if (!item) continue; - for (const visibleCol of visibleColumnIndexes) { - applied = applyVisibleSelectedCellValue(item, visibleCol, value) || applied; - } - } - return applied; } function selectionHasEditableCells(): boolean { @@ -5578,9 +5585,14 @@ function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue const values = generateCellValues(kind, cells.length, { startValue }); const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); let applied = false; - cells.forEach((cell, index) => { - applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, values[index] ?? null, allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied; - }); + beginBatch(); + try { + cells.forEach((cell, index) => { + applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, values[index] ?? null, allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied; + }); + } finally { + commitBatch(); + } if (applied) toast(t("grid.generatedValuesApplied", { count: cells.length })); return applied; } @@ -5634,12 +5646,17 @@ function cutSelection() { copySelectionTsv(); const range = selectedRange.value; const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); - for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { - const item = displayItemAt(rowIndex); - if (!item) continue; - for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { - applyVisibleSelectedCellValue(item, visibleCol, null, allowDraftSelectionValue); + beginBatch(); + try { + for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + const item = displayItemAt(rowIndex); + if (!item) continue; + for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { + applyVisibleSelectedCellValue(item, visibleCol, null, allowDraftSelectionValue); + } } + } finally { + commitBatch(); } } diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 3cc6bd568d..e056e46442 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1,4 +1,4 @@ -import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue"; +import { ref, shallowRef, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue"; import * as api from "@/lib/backend/api"; import type { CellValue } from "@/lib/dataGrid/cellValue"; import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; @@ -206,7 +206,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const editingCell = ref<{ rowId: number; col: number } | null>(null); const editValue = ref(""); const scrollerRef = ref(null); - const dirtyRows = ref>>(new Map()); + const dirtyRows = shallowRef>>(new Map()); const newRows = ref([]); const deletedRows = ref>(new Set()); const quickEntryDraftRow = ref([]); @@ -500,10 +500,45 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { }) as CellValue; } + let isBatching = false; + let batchUndoSnapshotPushed = false; + let batchMutated = false; + let batchColumnInfoCache: Map | null = null; + + function beginBatch() { + if (isBatching) return; + isBatching = true; + batchUndoSnapshotPushed = false; + batchMutated = false; + batchColumnInfoCache = new Map(); + } + + function commitBatch() { + if (!isBatching) return; + isBatching = false; + batchColumnInfoCache = null; + if (!batchMutated) return; + dirtyRows.value = new Map(dirtyRows.value); + newRows.value = [...newRows.value]; + touchPendingChanges(); + } + + function markBatchMutated() { + if (isBatching) batchMutated = true; + } + function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined { + if (isBatching && batchColumnInfoCache) { + if (batchColumnInfoCache.has(columnIndex)) { + return batchColumnInfoCache.get(columnIndex); + } + } const columnName = sourceColumns.value?.[columnIndex] ?? result.value.columns[columnIndex]; - if (!columnName) return undefined; - return tableMeta.value?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()); + const info = columnName ? tableMeta.value?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()) : undefined; + if (isBatching && batchColumnInfoCache) { + batchColumnInfoCache.set(columnIndex, info); + } + return info; } function canEditColumn(columnIndex: number): boolean { @@ -747,9 +782,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const nextDraftRow = [...quickEntryDraftRow.value]; nextDraftRow[col] = value === null ? null : coerceCellValue(value, oldVal, col, options); if (nextDraftRow[col] === oldVal) return; - pushUndoSnapshot(); + if (isBatching) { + if (!batchUndoSnapshotPushed) { + pushUndoSnapshot(); + batchUndoSnapshotPushed = true; + } + } else { + pushUndoSnapshot(); + } quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow(); - touchPendingChanges(); + markBatchMutated(); + if (!isBatching) { + touchPendingChanges(); + } scheduleQuickEntryDraftPromotion(); return; } @@ -761,10 +806,20 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const oldVal = row[col]; const newVal = value === null ? null : coerceCellValue(value, oldVal, col, options); if (newVal === oldVal) return; - pushUndoSnapshot(); + if (isBatching) { + if (!batchUndoSnapshotPushed) { + pushUndoSnapshot(); + batchUndoSnapshotPushed = true; + } + } else { + pushUndoSnapshot(); + } row[col] = newVal; - newRows.value = [...newRows.value]; - touchPendingChanges(); + markBatchMutated(); + if (!isBatching) { + newRows.value = [...newRows.value]; + touchPendingChanges(); + } return; } @@ -778,19 +833,39 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const newVal = value === null ? null : coerceCellValue(value, oldVal, col, options); if (newVal === currentVal) return; if (newVal !== oldVal) { - pushUndoSnapshot(); + if (isBatching) { + if (!batchUndoSnapshotPushed) { + pushUndoSnapshot(); + batchUndoSnapshotPushed = true; + } + } else { + pushUndoSnapshot(); + } if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map()); dirtyRows.value.get(item.sourceIndex)!.set(col, newVal); + markBatchMutated(); if (useTransaction.value && !transactionActive.value) { enterTransaction(); } } else { - if (hasPendingCellChange) pushUndoSnapshot(); + if (hasPendingCellChange) { + if (isBatching) { + if (!batchUndoSnapshotPushed) { + pushUndoSnapshot(); + batchUndoSnapshotPushed = true; + } + } else { + pushUndoSnapshot(); + } + } rowChanges?.delete(col); if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex); + if (hasPendingCellChange) markBatchMutated(); + } + if (!isBatching) { + dirtyRows.value = new Map(dirtyRows.value); + touchPendingChanges(); } - dirtyRows.value = new Map(dirtyRows.value); - touchPendingChanges(); } function restoreCellValue(rowId: number, col: number) { @@ -1330,9 +1405,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function discardChanges() { - dirtyRows.value.clear(); + dirtyRows.value = new Map(); newRows.value = []; - deletedRows.value.clear(); + deletedRows.value = new Set(); quickEntryDraftRow.value = emptyDraftRow(); queuedAutoSaveChanges.clear(); editingCell.value = null; @@ -1477,6 +1552,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { isSaving, saveError, useTransaction, + beginBatch, + commitBatch, enterTransaction, exitTransaction, startEdit, diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index 5343bb1e02..38c2b51f7b 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -773,6 +773,24 @@ test("a failed NULL save keeps the pending cell edit", async () => { assert.equal(editor.hasPendingChanges.value, true); }); +test("committing a no-op edit batch keeps the existing save error", () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + const editor = createPeopleGridEditor(); + + editor.applyCellValue(0, 1, "Ada Lovelace"); + editor.saveError.value = "Previous save failed"; + const version = editor.pendingChangesVersion.value; + + editor.beginBatch(); + editor.applyCellValue(0, 1, "Ada Lovelace"); + editor.commitBatch(); + + assert.equal(editor.saveError.value, "Previous save failed"); + assert.equal(editor.pendingChangesVersion.value, version); + assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada Lovelace"); +}); + test("a NOT NULL validation failure keeps the pending NULL cell edit recoverable", async () => { setActivePinia(createPinia()); installBrowserTestGlobals();