feat(grid): support batch pasting into new rows
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user