mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(data-grid): treat untouched new rows as placeholders
This commit is contained in:
@@ -15,6 +15,7 @@ const props = defineProps<{
|
||||
sql: string;
|
||||
sqlFormatDialect?: SqlFormatDialect;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
}>();
|
||||
@@ -243,7 +244,7 @@ onMounted(() => {
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!hasSql" class="flex items-center justify-center h-full text-xs text-muted-foreground">
|
||||
{{ t("grid.previewSqlEmpty") }}
|
||||
{{ emptyMessage || t("grid.previewSqlEmpty") }}
|
||||
</div>
|
||||
|
||||
<!-- Shiki highlighted SQL -->
|
||||
|
||||
@@ -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<DisplayRowRef[]>(() => {
|
||||
// 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) }}
|
||||
</div>
|
||||
@@ -16216,7 +16229,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:title="t('grid.resizeSqlPreview')"
|
||||
@pointerdown.prevent="onSqlPreviewResizeStart"
|
||||
/>
|
||||
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
|
||||
<SqlPreviewPanel :sql="previewSqlText" :empty-message="previewValidationMessage || undefined" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
|
||||
@@ -583,6 +583,54 @@ describe("useDataGridEditor result snapshots", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<ConditionalUpdateExecution>();
|
||||
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<string[]> {
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -2986,6 +2986,7 @@ export default withEnglishFallback({
|
||||
binaryImportFailed: "ファイルのインポートに失敗しました:{message}",
|
||||
binaryImportTooLarge: "ファイルが大きすぎます({size}、上限 {limit})。大きなファイルの単一セルへのインポートはサポートされていません。",
|
||||
cut: "切り取りました",
|
||||
uncommittedBlankRow: "未入力の新しい行は、保存時に無視されます",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||
|
||||
@@ -2913,6 +2913,7 @@ export default withEnglishFallback({
|
||||
xlsxHeaderNameAndCommentRows: "필드 이름과 주석을 두 헤더 행으로 사용",
|
||||
xlsxHeaderNameAndCommentRowsHint: "1행은 필드 이름, 2행은 주석이며 데이터는 3행부터 시작합니다. 다시 가져오기 전에 주석 행을 삭제하면 필드 이름과 일치합니다.",
|
||||
batchAppendPasteNoMatchingColumns: "INSERT 문의 열 이름이 현재 결과 집합의 어떤 열과도 일치하지 않습니다.",
|
||||
uncommittedBlankRow: "입력되지 않은 새 행은 저장 시 무시됩니다.",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3046,6 +3046,7 @@ export default withEnglishFallback({
|
||||
truncatedHint: "Результаты были усечены после загрузки {count} строк. Используйте пагинацию в нижней части для просмотра загруженных данных; экспорт полного результата повторно выполнит запрос к базе данных.",
|
||||
victoriaMetricsTruncatedHint: "Результаты превысили лимит загрузки, поэтому загружены только первые {count} строк. «Строк на страницу» пагинирует только загруженные данные; сократите диапазон времени или добавьте фильтры, чтобы получить полный результат.",
|
||||
cut: "Вырезать",
|
||||
uncommittedBlankRow: "Незаполненная новая строка будет проигнорирована при сохранении",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "Потоковый экспорт не поддерживается для этого запроса. Упростите его или используйте поддерживаемый драйвер.",
|
||||
|
||||
@@ -3271,6 +3271,7 @@ export default withEnglishFallback({
|
||||
truncatedHint: "ผลลัพธ์ถูกตัดหลังโหลด {count} แถว ใช้การแบ่งหน้าที่ส่วนท้ายเพื่อเรียกดูข้อมูลที่โหลด การส่งออกผลลัพธ์เต็มจะเรียกใช้ Query ฐานข้อมูลใหม่",
|
||||
victoriaMetricsTruncatedHint: "ผลลัพธ์เกินขีดจำกัดการโหลด จึงโหลดเฉพาะ {count} แถวแรก แถวต่อหน้าจะแบ่งเฉพาะข้อมูลที่โหลด ลดช่วงเวลาหรือเพิ่มตัวกรองเพื่อดึงผลลัพธ์ให้ครบ",
|
||||
cut: "ตัด",
|
||||
uncommittedBlankRow: "แถวใหม่ที่ยังไม่ได้กรอกจะถูกละเว้นเมื่อบันทึก",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "ไม่รองรับการส่งออกแบบ Streaming สำหรับ Query นี้ ทำให้เรียบง่ายขึ้นหรือใช้ Driver ที่รองรับ",
|
||||
|
||||
@@ -2932,6 +2932,7 @@ export default withEnglishFallback({
|
||||
statusEdited: "更新项",
|
||||
statusDeleted: "删除项",
|
||||
pendingChanges: "{count} 项待保存",
|
||||
uncommittedBlankRow: "未填写的新行,保存时会被忽略",
|
||||
selectedCells: "已选 {count} 个单元格",
|
||||
bulkEditSelection: "批量修改所选",
|
||||
bulkEditTitle: "批量修改所选",
|
||||
|
||||
@@ -2966,6 +2966,7 @@ export default withEnglishFallback({
|
||||
xlsxFilterPrompt: "請選擇匯出的 Excel 是否包含篩選:",
|
||||
xlsxIncludeAutoFilter: "包含篩選",
|
||||
cut: "已剪下",
|
||||
uncommittedBlankRow: "未填寫的新行,儲存時會被忽略",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "目前查詢暫不支援串流匯出,請簡化查詢或使用支援的驅動程式。",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user