diff --git a/apps/desktop/src/components/editor/SqlPreviewPanel.vue b/apps/desktop/src/components/editor/SqlPreviewPanel.vue index 6c06aa299c..3b6f78b032 100644 --- a/apps/desktop/src/components/editor/SqlPreviewPanel.vue +++ b/apps/desktop/src/components/editor/SqlPreviewPanel.vue @@ -15,6 +15,7 @@ const props = defineProps<{ sql: string; sqlFormatDialect?: SqlFormatDialect; loading?: boolean; + emptyMessage?: string; canUndo?: boolean; canRedo?: boolean; }>(); @@ -243,7 +244,7 @@ onMounted(() => {
- {{ t("grid.previewSqlEmpty") }} + {{ emptyMessage || t("grid.previewSqlEmpty") }}
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5924e97726..fff84c59e0 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -4543,6 +4543,7 @@ const { transactionActive, isSaving, saveError, + previewValidationMessage, useTransaction, beginBatch, commitBatch, @@ -4592,6 +4593,7 @@ const { recordScrollPosition, isPreviewLoading, previewChanges, + isUntouchedNewRow, } = editor; getGridNewRows = () => newRows.value; getGridRowData = rowDataWithChanges; @@ -4673,7 +4675,7 @@ function schedulePreviewRefresh() { async function openSqlPreview() { const stmts = await previewChanges(); previewSqlText.value = stmts.join("\n"); - if (stmts.length > 0) { + if (stmts.length > 0 || previewValidationMessage.value) { showSqlPreview.value = true; } } @@ -5396,8 +5398,15 @@ const displayRowRefs = computed(() => { // Pending rows must remain visible while a column filter is active so // users can fill in and review newly inserted records before saving. if (!row) continue; - const status: RowStatus = "new"; - if (!matchesRowStatusFilter(status, rowStatusFilter.value)) continue; + // A blank row created as a placeholder is not a pending INSERT. Keep it + // visible, but give it the clean row-number treatment so the green + // marker only identifies rows that will be submitted. + const isUntouched = isUntouchedNewRow(row, newIndex); + const status: RowStatus = isUntouched ? "clean" : "new"; + // Placeholders remain discoverable under the "new" filter so adding a + // row does not hide the blank editor the user just created. + const filterStatus: RowStatus = isUntouched ? "new" : status; + if (!matchesRowStatusFilter(filterStatus, rowStatusFilter.value)) continue; refs.push({ id: -(newIndex + 1), displayIndex: refs.length, @@ -7640,6 +7649,7 @@ function rowNumberPageOffset(): number { function rowNumberText(item: RowItem | undefined): string { if (!item) return ""; + if (item.isNew && item.status === "clean") return "*"; return resolveDataGridRowNumberLabel({ displayIndex: item.displayIndex, sourceIndex: item.sourceIndex, @@ -7718,23 +7728,25 @@ function dataGridRowStyle(item: RowItem): CSSProperties { ? settingsStore.editorSettings.dataGridZebraRowBg?.trim() || `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})` : "var(--data-grid-background)"; const rowNumberBg = - item.status === "new" - ? dark - ? "rgb(33, 45, 40)" - : "rgb(219, 244, 233)" - : item.status === "edited" + item.isNew && item.status === "clean" + ? rowBg + : item.status === "new" ? dark - ? "rgb(48, 41, 28)" - : "rgb(253, 241, 219)" - : item.status === "deleted" + ? "rgb(33, 45, 40)" + : "rgb(219, 244, 233)" + : item.status === "edited" ? dark - ? "rgb(55, 31, 32)" - : "rgb(255, 244, 244)" - : isRowActive(item.displayIndex) && !item.isDeleted - ? activeRowBg - : dark - ? "rgb(35, 37, 42)" - : "rgb(255, 255, 255)"; + ? "rgb(48, 41, 28)" + : "rgb(253, 241, 219)" + : item.status === "deleted" + ? dark + ? "rgb(55, 31, 32)" + : "rgb(255, 244, 244)" + : isRowActive(item.displayIndex) && !item.isDeleted + ? activeRowBg + : dark + ? "rgb(35, 37, 42)" + : "rgb(255, 255, 255)"; return { "--data-grid-cell-bg": rowBg, "--data-grid-row-number-bg": rowNumberBg, @@ -15349,6 +15361,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @mousedown="onRowNumberMouseDown(item, $event)" @dblclick.stop="toggleTranspose(item.displayIndex)" @contextmenu="onRowContext(item.id, item.displayIndex)" + :title="item.isNew && item.status === 'clean' ? t('grid.uncommittedBlankRow') : undefined" > {{ rowNumberText(item) }} @@ -16216,7 +16229,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val :title="t('grid.resizeSqlPreview')" @pointerdown.prevent="onSqlPreviewResizeStart" /> - + { }); describe("useDataGridEditor row deletion confirmation", () => { + it("removes untouched added rows immediately and supports undo and redo", () => { + const editor = createEditor(undefined, true); + editor.newRows.value = []; + editor.addRows(10); + editor.applyCellValue(-1, 0, "keep me"); + + editor.requestDeleteRows([-9, -10]); + + expect(editor.showDeleteRowConfirm.value).toBe(false); + expect(editor.pendingDeleteRowIds.value).toEqual([]); + expect(editor.newRows.value).toHaveLength(8); + expect(editor.newRows.value[0]![0]).toBe("keep me"); + expect(editor.deletedRows.value.size).toBe(0); + editor.undoPendingChange(); + expect(editor.newRows.value).toHaveLength(10); + expect(editor.newRowMeta.value[9]?.untouched).toBe(true); + editor.redoPendingChange(); + expect(editor.newRows.value).toHaveLength(8); + }); + + it("still confirms deletion of an edited row even after its values are cleared", () => { + const editor = createEditor(undefined, true); + editor.newRows.value = []; + editor.addRows(1); + editor.applyCellValue(-1, 0, "entered"); + editor.restoreCellValue(-1, 0); + + editor.requestDeleteRow(-1); + + expect(editor.showDeleteRowConfirm.value).toBe(true); + expect(editor.newRows.value).toEqual([[null, null, null]]); + }); + + it("keeps mixed existing-row and untouched-row deletion pending until confirmation", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["existing", null, null]]); + editor.newRows.value = []; + editor.addRows(1); + + editor.requestDeleteRows([0, -1]); + + expect(editor.showDeleteRowConfirm.value).toBe(true); + expect(editor.newRows.value).toHaveLength(1); + expect(editor.deletedRows.value.size).toBe(0); + editor.confirmDeleteRow(); + expect(editor.newRows.value).toEqual([]); + expect(editor.deletedRows.value).toEqual(new Set([0])); + }); + it("keeps the row pending until confirmation when confirmation is enabled", () => { const editor = createEditor(undefined, true); @@ -1384,7 +1432,11 @@ describe("useDataGridEditor saveChanges reload", () => { pageSize: ref(100), currentPage, cacheKey: computed(() => undefined), - getRowItem: () => undefined, + getRowItem: (rowId) => { + const newIndex = -rowId - 1; + const data = rowId < 0 ? editor.newRows.value[newIndex] : undefined; + return data ? { id: rowId, newIndex, data, isNew: true, isDeleted: false, isDirtyCol: [false, false], status: "new" } : undefined; + }, prepareFullReload: options.prepareFullReload, refreshSavedRows: options.refreshSavedRows, prepareSaveBaseline: options.prepareSaveBaseline, @@ -1393,6 +1445,128 @@ describe("useDataGridEditor saveChanges reload", () => { return { editor, emit, currentPage, ensureManualTransactionSession }; } + it("previews and saves only the nine filled rows out of ten added rows", async () => { + mocks.prepareDataGridSave.mockImplementation(async (options) => ({ + statements: options.newRows.map((row: CellValue[]) => `INSERT INTO orders_test VALUES (${row[0]}, 'filled')`), + rollbackStatements: [], + validationError: options.newRows.some((row: CellValue[]) => row[0] === null) ? 'Column "id" does not allow NULL.' : undefined, + })); + mocks.executeInTransaction.mockResolvedValue({ affected_rows: 9 }); + const { editor } = createSaveTestEditor(); + editor.addRows(10); + for (let index = 0; index < 9; index++) editor.applyCellValue(-(index + 1), 0, String(index + 1)); + + expect(editor.newRowCount.value).toBe(9); + expect(editor.pendingChangeCount.value).toBe(9); + expect(await editor.previewChanges()).toHaveLength(9); + expect(editor.newRows.value).toHaveLength(10); + await editor.saveChanges(); + + expect(mocks.prepareDataGridSave).toHaveBeenCalledTimes(2); + for (const [options] of mocks.prepareDataGridSave.mock.calls) { + expect(options.newRows).toHaveLength(9); + expect(options.deletedRows).toEqual([]); + } + expect(mocks.executeInTransaction).toHaveBeenCalledTimes(1); + expect(editor.saveError.value).toBe(""); + expect(editor.newRows.value).toEqual([]); + expect(editor.newRowMeta.value).toEqual([]); + expect(editor.hasPendingChanges.value).toBe(false); + }); + + it("clears a batch of untouched rows without validation or database execution", async () => { + const { editor } = createSaveTestEditor(); + editor.addRows(10); + + expect(await editor.previewChanges()).toEqual([]); + await editor.saveChanges(); + + expect(mocks.prepareDataGridSave).not.toHaveBeenCalled(); + expect(mocks.executeBatch).not.toHaveBeenCalled(); + expect(mocks.executeInTransaction).not.toHaveBeenCalled(); + expect(editor.newRows.value).toEqual([]); + expect(editor.hasPendingChanges.value).toBe(false); + expect(editor.transactionActive.value).toBe(false); + }); + + it("does not treat preview validation as a save error while a row is incomplete", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: [], validationError: 'Column "status" does not allow NULL.' }); + const { editor } = createSaveTestEditor(); + editor.addRows(1); + editor.applyCellValue(-1, 0, "1"); + + await editor.previewChanges(); + + expect(editor.saveError.value).toBe(""); + expect(editor.previewValidationMessage.value).toContain('Column "status"'); + expect(editor.pendingChangeCount.value).toBe(1); + expect(editor.hasPendingChanges.value).toBe(true); + }); + + it("keeps an incomplete row staged when autosave encounters validation", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: [], validationError: 'Column "status" does not allow NULL.' }); + const { editor } = createSaveTestEditor(); + editor.addRows(1); + editor.applyCellValue(-1, 0, "1"); + + await editor.saveChanges({ autoSave: true }); + + expect(editor.saveError.value).toBe(""); + expect(editor.newRows.value).toHaveLength(1); + expect(editor.hasPendingChanges.value).toBe(true); + }); + + it("retains all filled rows after validation fails and still omits unused blanks on retry", async () => { + mocks.prepareDataGridSave.mockResolvedValueOnce({ statements: [], validationError: 'Column "status" does not allow NULL.' }); + const { editor } = createSaveTestEditor(); + editor.addRows(10); + for (let index = 0; index < 9; index++) editor.applyCellValue(-(index + 1), 0, String(index + 1)); + + await editor.saveChanges(); + + expect(editor.newRows.value).toHaveLength(10); + expect(editor.newRows.value[8]![0]).toBe(9); + expect(editor.saveError.value).toContain('Column "status"'); + mocks.prepareDataGridSave.mockResolvedValue({ statements: ["INSERT INTO orders_test VALUES (1, 'filled')"], rollbackStatements: [] }); + mocks.executeBatch.mockResolvedValue({ affected_rows: 9 }); + await editor.saveChanges(); + expect(mocks.prepareDataGridSave.mock.lastCall?.[0].newRows).toHaveLength(9); + expect(editor.newRows.value).toEqual([]); + }); + + it("keeps deliberately cleared and pasted NULL rows in the save preview", async () => { + mocks.prepareDataGridSave.mockResolvedValue({ statements: [], rollbackStatements: [] }); + const { editor } = createSaveTestEditor(); + editor.addRows(3); + editor.applyCellValue(-1, 0, "1"); + editor.restoreCellValue(-1, 0); + editor.appendPastedRowsToNewRow(-2, [[null, null]], [0, 1]); + + await editor.previewChanges(); + + expect(mocks.prepareDataGridSave.mock.lastCall?.[0].newRows).toEqual([ + [null, null], + [null, null], + ]); + }); + + it("filters custom save previews and saves while keeping row metadata aligned", async () => { + const preview = vi.fn().mockResolvedValue(["insert filled"]); + const save = vi.fn().mockResolvedValue(undefined); + const { editor } = createSaveTestEditor({ customSaveHandler: { save, preview, canInsert: true } }); + editor.addRows(3); + editor.applyCellValue(-2, 0, "2"); + const token = editor.newRowMeta.value[1]!.token; + + await editor.previewChanges(); + await editor.saveChanges(); + + for (const callback of [preview, save]) { + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ newRows: [[2, null]], newRowMeta: [expect.objectContaining({ token })] })); + } + expect(editor.newRows.value).toEqual([]); + }); + it.each(["batch", "transaction", "manual"] as const)("passes reviewed BLOB bindings through the %s save entrypoint", async (route) => { const statements = route === "transaction" ? ["reviewed BLOB save", "ordinary update"] : ["reviewed BLOB save"]; const boundStatements = statements.map((previewSql, index) => ({ previewSql, sql: index === 0 ? "UPDATE t SET b=?" : previewSql, blobParameters: index === 0 ? ["00ff80"] : [] })); @@ -2087,6 +2261,7 @@ describe("useDataGridEditor saveChanges reload", () => { const { editor } = createSaveTestEditor({ confirmSaveRequest, databaseType: "salesforce" }); editor.dirtyRows.value.set(0, new Map([[1, "shipped"]])); editor.addRows(1); + editor.applyCellValue(-1, 0, "3"); editor.deletedRows.value.add(1); await editor.saveChanges(); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index cb60bdadb5..c81a9e686f 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -314,12 +314,15 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { function cloneNewRowMeta(meta: readonly GridNewRowMeta[]): GridNewRowMeta[] { return toRaw(meta).map((entry) => { const item = toRaw(entry); - return { token: item.token, placement: item.placement ? { ...toRaw(item.placement) } : null, sourceIndex: item.sourceIndex, editedColumns: item.editedColumns ? [...toRaw(item.editedColumns)] : undefined }; + const cloned: GridNewRowMeta = { token: item.token, placement: item.placement ? { ...toRaw(item.placement) } : null, sourceIndex: item.sourceIndex, editedColumns: item.editedColumns ? [...toRaw(item.editedColumns)] : undefined }; + if (item.untouched === true) cloned.untouched = true; + return cloned; }); } - function updateClonedRowEditedColumns(newIndex: number, col: number, value: CellValue) { + function updateNewRowEditedColumns(newIndex: number, col: number, value: CellValue) { const meta = newRowMeta.value[newIndex]; + if (meta) delete meta.untouched; if (!meta || meta.sourceIndex === undefined) return; const baseline = result.value.rows[meta.sourceIndex]?.[col]; const edited = new Set(meta.editedColumns); @@ -406,7 +409,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const dirtyRowCount = computed(() => dirtyRows.value.size); - const newRowCount = computed(() => newRows.value.length); + // Untouched blank rows are layout placeholders. They remain available for + // editing, but do not represent a change that can be submitted. + const newRowCount = computed(() => newRows.value.reduce((count, row, index) => count + (isUntouchedNewRow(row, index) ? 0 : 1), 0)); const deletedRowCount = computed(() => deletedRows.value.size); const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value); const hasPendingChanges = computed(() => pendingChangeCount.value > 0); @@ -418,6 +423,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const transactionActive = ref(false); const isSaving = ref(false); const saveError = ref(""); + const previewValidationMessage = ref(""); const conditionalUpdateExecution = shallowRef(); const isConditionalUpdateActive = computed(() => conditionalUpdateExecution.value !== undefined); @@ -484,6 +490,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { function touchPendingChanges() { // Save errors describe the previous pending snapshot; edits, undo/redo, and rollback make them stale. saveError.value = ""; + previewValidationMessage.value = ""; pendingChangesVersion.value++; } @@ -973,7 +980,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (changed) pushUndoSnapshot(); if (newRows.value[item.newIndex]) { newRows.value[item.newIndex][col] = newVal; - updateClonedRowEditedColumns(item.newIndex, col, newVal); + if (changed) updateNewRowEditedColumns(item.newIndex, col, newVal); } newRows.value = [...newRows.value]; newRowMeta.value = [...newRowMeta.value]; @@ -1093,7 +1100,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { pushUndoSnapshot(); } row[col] = newVal; - updateClonedRowEditedColumns(item.newIndex, col, newVal); + updateNewRowEditedColumns(item.newIndex, col, newVal); markBatchMutated(); if (!isBatching) { newRows.value = [...newRows.value]; @@ -1199,7 +1206,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (!row || row[col] === null) return; pushUndoSnapshot(); row[col] = null; - updateClonedRowEditedColumns(item.newIndex, col, null); + updateNewRowEditedColumns(item.newIndex, col, null); newRows.value = [...newRows.value]; newRowMeta.value = [...newRowMeta.value]; touchPendingChanges(); @@ -1244,7 +1251,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { pushUndoSnapshot(); rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value); newRows.value.push(result.value.columns.map(() => null)); - newRowMeta.value.push(allocateNewRowMeta(null)); + newRowMeta.value.push({ ...allocateNewRowMeta(null), untouched: true }); newRows.value = [...newRows.value]; newRowMeta.value = [...newRowMeta.value]; touchPendingChanges(); @@ -1290,7 +1297,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (count > batchSize) yield { completed: 0, total: count }; for (let i = 0; i < count; i++) { preparedRows.push([...blankRow]); - preparedMeta.push(allocateNewRowMeta(placement)); + preparedMeta.push({ ...allocateNewRowMeta(placement), untouched: true }); if (count > batchSize && ((i + 1) % batchSize === 0 || i + 1 === count)) yield { completed: i + 1, total: count }; } if (!isCurrent()) return undefined; @@ -1423,6 +1430,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } mappedRows.push(nextRow); mappedMeta.push(rowIndex < reusableNewRowCount && sourceMeta[targetNewIndex! + rowIndex] ? cloneNewRowMeta([sourceMeta[targetNewIndex! + rowIndex]!])[0]! : allocateNewRowMeta(targetPlacement)); + delete mappedMeta[mappedMeta.length - 1]!.untouched; if (pastedRows.length > batchSize && ((rowIndex + 1) % batchSize === 0 || rowIndex + 1 === pastedRows.length)) yield { completed: rowIndex + 1, total: pastedRows.length }; } @@ -1588,7 +1596,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function requestDeleteRows(rowIds: number[]) { - if (!confirmDangerousRowDeletion.value) { + const onlyUntouchedRows = rowIds.every((rowId) => { + const item = getRowItem(rowId); + return item?.isNew && item.newIndex !== undefined && isUntouchedNewRow(item.data, item.newIndex); + }); + if (!confirmDangerousRowDeletion.value || onlyUntouchedRows) { applyDeleteRows(rowIds); return; } @@ -1641,13 +1653,20 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } // --- Save/Discard --- + function isUntouchedNewRow(row: readonly CellValue[], newIndex: number): boolean { + return newRowMeta.value[newIndex]?.untouched === true && row.every((value) => value === null); + } + function snapshotPendingSaveChanges(): PendingSaveSnapshot { const currentNewRows = [...newRows.value]; + const includedIndexes = currentNewRows.flatMap((row, index) => (isUntouchedNewRow(row, index) ? [] : [index])); return { dirtyRows: new Map([...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])), - newRows: currentNewRows.map((row) => [...row]), + newRows: includedIndexes.map((index) => [...currentNewRows[index]!]), + // Also consume unused blanks after success, and lock them while saving + // so an edit cannot be lost when this snapshot is cleared. newRowRefs: currentNewRows, - newRowMeta: cloneNewRowMeta(newRowMeta.value), + newRowMeta: includedIndexes.flatMap((index) => (newRowMeta.value[index] ? cloneNewRowMeta([newRowMeta.value[index]!]) : [])), deletedRows: new Set(deletedRows.value), }; } @@ -1682,7 +1701,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } dirtyRows.value = new Map(dirtyRows.value); - if (snapshot.newRows.length > 0) { + if (snapshot.newRowRefs.length > 0) { const savedNewRows = new Set(snapshot.newRowRefs); const keptRows: CellValue[][] = []; const keptMeta: GridNewRowMeta[] = []; @@ -2028,6 +2047,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const snapshot = snapshotPendingSaveChanges(); if (!hasPendingSaveChanges(snapshot)) { + if (snapshot.newRowRefs.length > 0) { + clearSavedPendingChanges(snapshot); + if (!hasPendingChanges.value) exitTransaction(); + clearPendingChangeHistory(); + } return; } const customHandler = customSaveHandler?.value; @@ -2059,6 +2083,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { return; } saveError.value = ""; + previewValidationMessage.value = ""; isSaving.value = true; snapshot.newRowRefs.forEach((row) => savingNewRows.add(row)); const shouldReloadAfterSave = snapshot.newRows.length > 0 || snapshot.deletedRows.size > 0; @@ -2145,6 +2170,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } } if (preparedSave?.validationError) { + if (saveOptions.autoSave) { + // Autosave can run while a new row is still being filled. Keep the + // draft staged until it becomes valid instead of surfacing a save + // failure for an expected intermediate state. + previewValidationMessage.value = preparedSave.validationError; + await finishInterruptedSaveChanges(snapshot); + return; + } saveError.value = preparedSave.validationError; await finishInterruptedSaveChanges(snapshot); return; @@ -2388,7 +2421,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const scroll = includeScroll ? (readScrollPosition() ?? pendingScrollRestore) : undefined; if (includeScroll) pendingScrollRestore = scroll; const quickEntryDraftRowSnapshot = draftRowHasValue() ? [...quickEntryDraftRow.value] : undefined; - if (!hasPendingChanges.value && !quickEntryDraftRowSnapshot && !(includeEditing && editingCell.value) && !scroll) { + if (!hasPendingChanges.value && newRows.value.length === 0 && !quickEntryDraftRowSnapshot && !(includeEditing && editingCell.value) && !scroll) { pendingChangesCache.delete(k); return; } @@ -2464,19 +2497,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { async function previewChanges(): Promise { isPreviewLoading.value = true; previewStatements.value = []; + previewValidationMessage.value = ""; try { + const snapshot = snapshotPendingSaveChanges(); + if (!hasPendingSaveChanges(snapshot)) return []; if (customSaveHandler?.value) { const preview = customSaveHandler.value.preview; - if (preview) return await preview({ dirtyRows: dirtyRows.value, newRows: newRows.value, newRowMeta: cloneNewRowMeta(newRowMeta.value), deletedRows: deletedRows.value, columns: result.value.columns, rows: result.value.rows }); + if (preview) return await preview({ dirtyRows: snapshot.dirtyRows, newRows: snapshot.newRows, newRowMeta: snapshot.newRowMeta, deletedRows: snapshot.deletedRows, columns: result.value.columns, rows: result.value.rows }); return []; } - const snapshot = snapshotPendingSaveChanges(); if (options.prepareSaveBaseline) await options.prepareSaveBaseline(snapshot); const stmtOptions = saveStatementOptions(snapshot); if (!stmtOptions) return []; const prepared = await prepareSaveStatements(stmtOptions); if (prepared?.validationError) { - saveError.value = prepared.validationError; + previewValidationMessage.value = prepared.validationError; return []; } const stmts = prepared?.statements ?? []; @@ -2511,6 +2546,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { transactionActive, isSaving, saveError, + previewValidationMessage, isConditionalUpdateActive, conditionalUpdateExecution, useTransaction, @@ -2568,6 +2604,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { previewStatements, isPreviewLoading, previewChanges, + isUntouchedNewRow, savePendingSnapshot, // Exposed for tests: the window listener is only registered inside a component // instance, so specs drive the tab-switch path directly. diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 539f5821c5..abb8dc4a71 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2998,6 +2998,7 @@ export default { statusEdited: "Updated Items", statusDeleted: "Deleted Items", pendingChanges: "{count} pending", + uncommittedBlankRow: "Empty new row; it will be ignored when saving", selectedCells: "{count} selected", bulkEditSelection: "Bulk Edit Selection", bulkEditTitle: "Bulk Edit Selection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b175e317c0..bcd8014c4e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3089,6 +3089,7 @@ export default withEnglishFallback({ binaryImportFailed: "Error al importar el archivo: {message}", binaryImportTooLarge: "El archivo es demasiado grande ({size}, límite {limit}). No se admite importar archivos grandes en una sola celda.", cut: "Cortado", + uncommittedBlankRow: "Fila nueva sin completar, se ignorará al guardar.", }, exportProgress: { streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e3b49b3158..a97b53ac88 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2962,6 +2962,7 @@ export default withEnglishFallback({ binaryImportFailed: "Importazione del file non riuscita: {message}", binaryImportTooLarge: "Il file è troppo grande ({size}, limite {limit}). L'importazione di file di grandi dimensioni in una singola cella non è supportata.", cut: "Tagliato", + uncommittedBlankRow: "Nuova riga non compilata, verrà ignorata al salvataggio", }, exportProgress: { streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b0eb9f413f..c572241419 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2986,6 +2986,7 @@ export default withEnglishFallback({ binaryImportFailed: "ファイルのインポートに失敗しました:{message}", binaryImportTooLarge: "ファイルが大きすぎます({size}、上限 {limit})。大きなファイルの単一セルへのインポートはサポートされていません。", cut: "切り取りました", + uncommittedBlankRow: "未入力の新しい行は、保存時に無視されます", }, exportProgress: { streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1f88eb9501..9f9ee9cd86 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2913,6 +2913,7 @@ export default withEnglishFallback({ xlsxHeaderNameAndCommentRows: "필드 이름과 주석을 두 헤더 행으로 사용", xlsxHeaderNameAndCommentRowsHint: "1행은 필드 이름, 2행은 주석이며 데이터는 3행부터 시작합니다. 다시 가져오기 전에 주석 행을 삭제하면 필드 이름과 일치합니다.", batchAppendPasteNoMatchingColumns: "INSERT 문의 열 이름이 현재 결과 집합의 어떤 열과도 일치하지 않습니다.", + uncommittedBlankRow: "입력되지 않은 새 행은 저장 시 무시됩니다.", }, exportProgress: { streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 110cc851a9..fdd2d459f8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2962,6 +2962,7 @@ export default withEnglishFallback({ binaryImportFailed: "Falha ao importar o arquivo: {message}", binaryImportTooLarge: "O arquivo é muito grande ({size}, limite {limit}). Não há suporte para importar arquivos grandes em uma única célula.", cut: "Recortado", + uncommittedBlankRow: "Nova linha não preenchida, será ignorada ao salvar", }, exportProgress: { streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index c77b128c80..0f715417e7 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -3046,6 +3046,7 @@ export default withEnglishFallback({ truncatedHint: "Результаты были усечены после загрузки {count} строк. Используйте пагинацию в нижней части для просмотра загруженных данных; экспорт полного результата повторно выполнит запрос к базе данных.", victoriaMetricsTruncatedHint: "Результаты превысили лимит загрузки, поэтому загружены только первые {count} строк. «Строк на страницу» пагинирует только загруженные данные; сократите диапазон времени или добавьте фильтры, чтобы получить полный результат.", cut: "Вырезать", + uncommittedBlankRow: "Незаполненная новая строка будет проигнорирована при сохранении", }, exportProgress: { streamingUnsupported: "Потоковый экспорт не поддерживается для этого запроса. Упростите его или используйте поддерживаемый драйвер.", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index a259beec96..204d7bdd10 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -3271,6 +3271,7 @@ export default withEnglishFallback({ truncatedHint: "ผลลัพธ์ถูกตัดหลังโหลด {count} แถว ใช้การแบ่งหน้าที่ส่วนท้ายเพื่อเรียกดูข้อมูลที่โหลด การส่งออกผลลัพธ์เต็มจะเรียกใช้ Query ฐานข้อมูลใหม่", victoriaMetricsTruncatedHint: "ผลลัพธ์เกินขีดจำกัดการโหลด จึงโหลดเฉพาะ {count} แถวแรก แถวต่อหน้าจะแบ่งเฉพาะข้อมูลที่โหลด ลดช่วงเวลาหรือเพิ่มตัวกรองเพื่อดึงผลลัพธ์ให้ครบ", cut: "ตัด", + uncommittedBlankRow: "แถวใหม่ที่ยังไม่ได้กรอกจะถูกละเว้นเมื่อบันทึก", }, exportProgress: { streamingUnsupported: "ไม่รองรับการส่งออกแบบ Streaming สำหรับ Query นี้ ทำให้เรียบง่ายขึ้นหรือใช้ Driver ที่รองรับ", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 538cdc514a..2bb5c7b98b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2932,6 +2932,7 @@ export default withEnglishFallback({ statusEdited: "更新项", statusDeleted: "删除项", pendingChanges: "{count} 项待保存", + uncommittedBlankRow: "未填写的新行,保存时会被忽略", selectedCells: "已选 {count} 个单元格", bulkEditSelection: "批量修改所选", bulkEditTitle: "批量修改所选", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4814875f92..57d92ccfdd 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2966,6 +2966,7 @@ export default withEnglishFallback({ xlsxFilterPrompt: "請選擇匯出的 Excel 是否包含篩選:", xlsxIncludeAutoFilter: "包含篩選", cut: "已剪下", + uncommittedBlankRow: "未填寫的新行,儲存時會被忽略", }, exportProgress: { streamingUnsupported: "目前查詢暫不支援串流匯出,請簡化查詢或使用支援的驅動程式。", diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index 35f39ffc86..bd5edb0640 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -509,7 +509,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions): boolean ? theme.rowNumberActive : rowInSelection ? theme.cellSelected - : theme.rowNumberDefault; + : item.isNew && item.status === "clean" + ? rowBase + : theme.rowNumberDefault; ctx.fillStyle = rowNumberFill; ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT); // 与 DOM 的 .data-grid-row-number--selected 对齐:选中行号左侧 3px 蓝色条 @@ -534,13 +536,15 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions): boolean ctx.rect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT); ctx.clip(); ctx.fillText( - resolveDataGridRowNumberLabel({ - displayIndex: item.displayIndex, - sourceIndex: item.sourceIndex, - isDraft: item.isDraft, - sourceRowNumbers: rowNumberMode === "source", - pageOffset: infiniteScrollEnabled ? 0 : pageOffset, - }), + item.isNew && item.status === "clean" + ? "*" + : resolveDataGridRowNumberLabel({ + displayIndex: item.displayIndex, + sourceIndex: item.sourceIndex, + isDraft: item.isDraft, + sourceRowNumbers: rowNumberMode === "source", + pageOffset: infiniteScrollEnabled ? 0 : pageOffset, + }), rowNumberTextX, textY, ); diff --git a/apps/desktop/src/lib/dataGrid/gridNewRowPlacement.ts b/apps/desktop/src/lib/dataGrid/gridNewRowPlacement.ts index 0f7559f9c2..c4a60477f4 100644 --- a/apps/desktop/src/lib/dataGrid/gridNewRowPlacement.ts +++ b/apps/desktop/src/lib/dataGrid/gridNewRowPlacement.ts @@ -29,6 +29,8 @@ export interface GridNewRowMeta { sourceIndex?: number; /** Columns whose values differ from the cloned source row. */ editedColumns?: number[]; + /** Blank row allocated by Add Row that has not had a value changed yet. */ + untouched?: boolean; } export type GridOrderedRowEntry = { kind: "source"; sourceIndex: number } | { kind: "new"; newIndex: number };