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 };