feat(grid): support batch pasting into new rows

This commit is contained in:
zhangsan authored and GitHub committed 2026-07-30 00:27:54 +08:00
1 parent 9173cb754d
commit ffde91499a
10 files changed
+380 -6

No files matched your search

@@ -0,0 +1,222 @@
import { computed, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "@/composables/useDataGridEditor";
import type { CellValue } from "@/lib/dataGrid/cellValue";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: mocks.getConfig }),
}));
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: () => ({}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(sourceColumns?: Array<string | undefined>) {
let editor: ReturnType<typeof useDataGridEditor>;
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
columns: ["first", "hidden", "last"],
rows: [],
});
editor = useDataGridEditor({
result: computed(() => result.value),
editable: computed(() => true),
databaseType: computed(() => "postgres"),
connectionId: computed(() => "connection-1"),
database: computed(() => "app"),
tableMeta: computed(() => ({
tableName: "people",
columns: [
{ name: "first", data_type: "varchar" },
{ name: "hidden", data_type: "varchar" },
{ name: "last", data_type: "varchar" },
],
primaryKeys: [],
})),
sourceColumns: computed(() => sourceColumns),
onExecuteSql: computed(() => undefined),
sql: computed(() => undefined),
searchText: ref(""),
whereFilterInput: ref(""),
currentWhereInput: computed(() => undefined),
orderByInput: ref(""),
rowStatusFilter: ref("all"),
pageSize: ref(100),
currentPage: ref(1),
getRowItem: (rowId) => {
if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) {
return {
id: rowId,
data: editor.quickEntryDraftRow.value,
isNew: false,
isDraft: true,
isDeleted: false,
isDirtyCol: [false, false, false],
status: "draft",
};
}
const newIndex = -rowId - 1;
const row = editor.newRows.value[newIndex];
if (!row) return undefined;
return {
id: rowId,
newIndex,
data: row,
isNew: true,
isDeleted: false,
isDirtyCol: [false, false, false],
status: "new",
};
},
emit: vi.fn(),
});
editor.newRows.value = [[null, null, null]];
return editor;
}
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
beforeEach(() => {
mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" });
});
it("fills the selected blank new row and appends remaining rows using visible columns", () => {
const editor = createEditor();
const result = editor.appendPastedRowsToNewRow(
-1,
[
["Ada", "Lovelace"],
["Grace", "Hopper"],
],
[0, 2],
);
expect(result).toEqual({ ok: true, rowCount: 2 });
expect(editor.newRows.value).toEqual([
["Ada", null, "Lovelace"],
["Grace", null, "Hopper"],
]);
expect(editor.hasPendingChanges.value).toBe(true);
});
it("fills following blank new rows before adding more rows", () => {
const editor = createEditor();
editor.newRows.value = [
[null, null, null],
[null, null, null],
];
const result = editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0, 2]);
expect(result).toEqual({ ok: true, rowCount: 2 });
expect(editor.newRows.value).toEqual([
["Ada", null, null],
["Grace", null, null],
]);
});
it("turns rows pasted into the terminal new-row draft into pending rows", () => {
const editor = createEditor();
editor.newRows.value = [];
const result = editor.appendPastedRowsToNewRow(
DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID,
[
["Ada", "Lovelace"],
["Grace", "Hopper"],
],
[0, 2],
);
expect(result).toEqual({ ok: true, rowCount: 2 });
expect(editor.newRows.value).toEqual([
["Ada", null, "Lovelace"],
["Grace", null, "Hopper"],
]);
expect(editor.quickEntryDraftRow.value).toEqual([null, null, null]);
expect(editor.hasPendingChanges.value).toBe(true);
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([]);
expect(editor.quickEntryDraftRow.value).toEqual([null, null, null]);
editor.redoPendingChange();
expect(editor.newRows.value).toEqual([
["Ada", null, "Lovelace"],
["Grace", null, "Hopper"],
]);
});
it("rejects a non-empty terminal new-row draft", () => {
const editor = createEditor();
editor.newRows.value = [];
editor.quickEntryDraftRow.value = ["already", null, null];
const result = editor.appendPastedRowsToNewRow(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, [["Ada"]], [0, 2]);
expect(result).toEqual({ ok: false, reason: "target-not-empty" });
expect(editor.newRows.value).toEqual([]);
expect(editor.quickEntryDraftRow.value).toEqual(["already", null, null]);
});
it("truncates pasted columns that exceed the visible table columns", () => {
const editor = createEditor();
const result = editor.appendPastedRowsToNewRow(-1, [["Ada", "Byron", "Lovelace"]], [0, 2]);
expect(result).toEqual({ ok: true, rowCount: 1 });
expect(editor.newRows.value).toEqual([["Ada", null, "Byron"]]);
expect(editor.canUndoPendingChange.value).toBe(true);
});
it("rejects an empty textual clipboard payload without changing pending rows", () => {
const editor = createEditor();
const result = editor.appendPastedRowsToNewRow(-1, [[""]], [0, 2]);
expect(result).toEqual({ ok: false, reason: "empty-paste" });
expect(editor.newRows.value).toEqual([[null, null, null]]);
expect(editor.canUndoPendingChange.value).toBe(false);
});
it("rejects a paste that targets a read-only visible column", () => {
const editor = createEditor(["first", undefined, "last"]);
const result = editor.appendPastedRowsToNewRow(-1, [["Ada"]], [1]);
expect(result).toEqual({ ok: false, reason: "readonly-column" });
expect(editor.newRows.value).toEqual([[null, null, null]]);
});
it("does not overwrite an existing new row selected as the append target", () => {
const editor = createEditor();
editor.newRows.value = [["already", null, null]];
const result = editor.appendPastedRowsToNewRow(-1, [["Ada"]], [0, 2]);
expect(result).toEqual({ ok: false, reason: "target-not-empty" });
expect(editor.newRows.value).toEqual([["already", null, null]]);
});
it("treats a batch append as one undoable change", () => {
const editor = createEditor();
editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0, 2]);
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([[null, null, null]]);
editor.redoPendingChange();
expect(editor.newRows.value).toEqual([
["Ada", null, null],
["Grace", null, null],
]);
});
});
@@ -31,6 +31,8 @@ export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER;
type RowKind = "none" | "existing" | "new" | "draft";
export type DataGridAppendPastedRowsResult = { ok: true; rowCount: number } | { ok: false; reason: "not-editable" | "invalid-target" | "target-not-empty" | "empty-paste" | "readonly-column" };
type CommitEditResult =
| {
changed: false;
@@ -944,6 +946,67 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
});
}
function isBlankNewRow(row: readonly CellValue[]): boolean {
return row.every((value) => value === null || (typeof value === "string" && value.trim() === ""));
}
function appendPastedRowsToNewRow(targetRowId: number, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[]): DataGridAppendPastedRowsResult {
if (!editable.value) return { ok: false, reason: "not-editable" };
if (pastedRows.every((row) => row.every((value) => value === ""))) {
return { ok: false, reason: "empty-paste" };
}
const target = getRowItem(targetRowId);
if ((!target?.isNew && !target?.isDraft) || target.isDeleted || isSavingNewRow(target)) {
return { ok: false, reason: "invalid-target" };
}
const targetIsDraft = target.isDraft === true;
if (targetIsDraft) ensureQuickEntryDraftRow();
const targetNewIndex = target.newIndex;
const targetRow = targetIsDraft ? quickEntryDraftRow.value : targetNewIndex === undefined ? undefined : newRows.value[targetNewIndex];
if (!targetRow || !isBlankNewRow(targetRow)) return { ok: false, reason: "target-not-empty" };
const pastedColumnCount = Math.max(...pastedRows.map((row) => row.length));
if (pastedColumnCount <= 0) return { ok: false, reason: "empty-paste" };
const targetColumns = columnIndexes.slice(0, pastedColumnCount);
if (targetColumns.some((columnIndex) => !canEditColumn(columnIndex))) return { ok: false, reason: "readonly-column" };
const nextRows = newRows.value.map((row) => [...row]);
let reusableNewRowCount = 0;
if (!targetIsDraft) {
for (let rowIndex = targetNewIndex!; rowIndex < nextRows.length && reusableNewRowCount < pastedRows.length; rowIndex++) {
if (!isBlankNewRow(nextRows[rowIndex]!)) break;
reusableNewRowCount++;
}
}
const mappedRows = pastedRows.map((pastedRow, rowIndex) => {
const nextRow = rowIndex < reusableNewRowCount ? nextRows[targetNewIndex! + rowIndex]! : emptyDraftRow();
for (let columnOffset = 0; columnOffset < Math.min(pastedRow.length, targetColumns.length); columnOffset++) {
const columnIndex = targetColumns[columnOffset]!;
const value = pastedRow[columnOffset];
nextRow[columnIndex] = value === null ? null : coerceCellValue(value, nextRow[columnIndex], columnIndex);
}
return nextRow;
});
pushUndoSnapshot();
if (targetIsDraft) {
nextRows.push(...mappedRows);
quickEntryDraftRow.value = emptyDraftRow();
} else {
nextRows.splice(targetNewIndex!, reusableNewRowCount, ...mappedRows);
}
newRows.value = nextRows;
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
touchPendingChanges();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
return { ok: true, rowCount: mappedRows.length };
}
function clonedRowData(item: RowItem): CellValue[] {
const columnInfoByName = new Map((tableMeta.value?.columns ?? []).map((column) => [column.name.toLowerCase(), column]));
return item.data.map((val, i) => {
@@ -1566,6 +1629,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
cancelEdit,
onEditKeydown,
addRow,
appendPastedRowsToNewRow,
cloneRow,
cloneRows,
applyDeleteRows,