feat(grid): paste rows from blank new cells

Closes #6144
This commit is contained in:
t8y2 committed 2026-08-24 18:56:44 +00:00
1 parent 332daba302
commit adb7578998
2 files changed
+364 -17

No files matched your search

+34 -17
View File
@@ -8096,26 +8096,43 @@ function batchAppendPasteError(reason: string): string {
return t(messages[reason] ?? "grid.batchAppendPasteInvalidTarget");
}
function pasteTextIntoGrid(text: string): boolean {
const targetRowId = batchAppendPasteTargetRowId();
if (targetRowId !== null) {
const result = appendPastedRowsToNewRow(targetRowId, parseDataGridClipboard(text), visibleColumnIndexes.value);
if (!result.ok) {
if (result.reason === "invalid-target" || result.reason === "target-not-empty") {
batchAppendPasteRowId.value = null;
}
toast(batchAppendPasteError(result.reason), 5000);
return false;
}
batchAppendPasteRowId.value = null;
toast(t("grid.pasted"));
return true;
}
return pasteTextIntoSelection(text);
function blankCellBatchAppendPasteTarget(pastedRows: readonly (readonly (string | null)[])[]): { rowId: number; columnIndexes: number[] } | null {
if (pastedRows.length <= 1) return null;
const range = selectedRange.value;
if (!range || range.startRow !== range.endRow || range.startCol !== range.endCol) return null;
const item = displayItemAt(range.startRow);
if ((!item?.isNew && !item?.isDraft) || item.isDeleted || item.data.some((value) => value !== null && (typeof value !== "string" || value.trim() !== ""))) return null;
const columnIndex = visibleColumnIndexes.value[range.startCol];
if (columnIndex === undefined || !canEditCellItem(item, columnIndex)) return null;
return { rowId: item.id, columnIndexes: visibleColumnIndexes.value.slice(range.startCol) };
}
function pasteTextIntoSelection(text: string): boolean {
function appendParsedRowsToBlankTarget(targetRowId: number, rows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[]): boolean {
const result = appendPastedRowsToNewRow(targetRowId, rows, columnIndexes);
if (!result.ok) {
if (result.reason === "invalid-target" || result.reason === "target-not-empty") {
batchAppendPasteRowId.value = null;
}
toast(batchAppendPasteError(result.reason), 5000);
return false;
}
batchAppendPasteRowId.value = null;
toast(t("grid.pasted"));
return true;
}
function pasteTextIntoGrid(text: string): boolean {
const rows = parseDataGridClipboard(text);
const targetRowId = batchAppendPasteTargetRowId();
if (targetRowId !== null) {
return appendParsedRowsToBlankTarget(targetRowId, rows, visibleColumnIndexes.value);
}
const cellTarget = blankCellBatchAppendPasteTarget(rows);
if (cellTarget) return appendParsedRowsToBlankTarget(cellTarget.rowId, rows, cellTarget.columnIndexes);
return pasteRowsIntoSelection(rows);
}
function pasteRowsIntoSelection(rows: readonly (readonly (string | null)[])[]): boolean {
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) {
@@ -0,0 +1,330 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120, 120, 120, 120]),
totalWidth: ref(480),
columnVars: ref({ "--total-w": "480px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const RecycleScroller = defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
attrs,
props.items.map((item) => slots.default?.({ item })),
);
},
});
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function defaultResult(): QueryResult {
return {
columns: ["c0", "hidden", "c2", "c3"],
rows: [[1, null, "seed-2", "seed-3"]],
affected_rows: 0,
execution_time_ms: 0,
};
}
function mountGrid(options: { result?: QueryResult; quickEntry?: boolean; hideNullColumns?: boolean; readonlyColumnIndexes?: number[] } = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ dataGridRenderMode: "canvas", dataGridHideNullColumns: options.hideNullColumns ?? false, dataGridQuickEntry: options.quickEntry ?? false });
const gridResult = markRaw(options.result ?? defaultResult());
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result: gridResult,
databaseType: "dameng",
context: "table-data",
editable: true,
readonlyColumnIndexes: options.readonlyColumnIndexes,
tableMeta: {
tableName: "paste_target",
columns: gridResult.columns.map((name, index) => ({
name,
data_type: index === 0 ? "int" : "varchar",
is_nullable: index !== 0,
column_default: null,
is_primary_key: index === 0,
extra: null,
})),
primaryKeys: ["c0"],
},
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.component("RecycleScroller", RecycleScroller);
app.mount(host);
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
const mounted = { app, host };
mountedApps.push(mounted);
return mounted;
}
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function gridRoot(host: HTMLElement): HTMLElement {
const root = host.querySelector<HTMLElement>("[data-grid-root]");
if (!root) throw new Error("Data grid root not found");
return root;
}
async function addBlankRow(host: HTMLElement) {
gridRoot(host).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "n" }));
await settle();
const editor = host.querySelector<HTMLElement>(".cell-edit-input");
editor?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" }));
await settle();
}
async function selectCell(cell: HTMLElement, options: { shiftKey?: boolean; ctrlKey?: boolean } = {}) {
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, ...options }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0, ...options }));
await settle();
}
async function selectRowNumber(row: HTMLElement) {
const rowNumber = row.querySelector<HTMLElement>(".data-grid-row-number");
if (!rowNumber) throw new Error("Row number not found");
rowNumber.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
async function selectColumnHeader(host: HTMLElement, actualColumnIndex: number) {
const header = host.querySelector<HTMLElement>(`[data-grid-column-index="${actualColumnIndex}"]`);
if (!header) throw new Error(`Column header not found: ${actualColumnIndex}`);
header.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
async function paste(host: HTMLElement, text: string) {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { getData: () => text } });
gridRoot(host).dispatchEvent(event);
await settle();
}
function pendingRows(host: HTMLElement, existingRowCount = 1): HTMLElement[] {
return [...host.querySelectorAll<HTMLElement>(".data-grid-row")].slice(existingRowCount);
}
function displayRows(host: HTMLElement): HTMLElement[] {
return [...host.querySelectorAll<HTMLElement>(".data-grid-row")];
}
function visibleCells(row: HTMLElement): HTMLElement[] {
return [...row.querySelectorAll<HTMLElement>("[data-visible-col-index]")];
}
function visibleCellTexts(row: HTMLElement): Array<string | undefined> {
return visibleCells(row).map((cell) => cell.textContent?.trim());
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGrid multi-row paste from a blank cell", () => {
it("starts at the selected visible column, skips hidden columns, and appends rows", async () => {
const { host } = mountGrid({ hideNullColumns: true });
await settle();
await addBlankRow(host);
const [blankRow] = pendingRows(host);
expect(blankRow).toBeDefined();
const cells = visibleCells(blankRow!);
expect(cells).toHaveLength(3);
await selectCell(cells[1]!);
expect(cells[1]!.className).toContain("cell-selected");
await paste(host, "a\tb\nc\td");
const rows = pendingRows(host);
expect(rows).toHaveLength(2);
expect(visibleCellTexts(rows[0]!)).toEqual(["NULL", "a", "b"]);
expect(visibleCellTexts(rows[1]!)).toEqual(["NULL", "c", "d"]);
});
it("appends rows from the terminal quick-entry draft cell", async () => {
const { host } = mountGrid({ quickEntry: true, hideNullColumns: true });
await settle();
const draftRow = displayRows(host).at(-1);
expect(draftRow).toBeDefined();
const cells = visibleCells(draftRow!);
await selectCell(cells[1]!);
await paste(host, "a\tb\nc\td");
const rows = pendingRows(host);
expect(rows).toHaveLength(2);
expect(visibleCellTexts(rows[0]!)).toEqual(["NULL", "a", "b"]);
expect(visibleCellTexts(rows[1]!)).toEqual(["NULL", "c", "d"]);
});
it("keeps row-number paste priority and starts from the first visible column", async () => {
const { host } = mountGrid({ hideNullColumns: true });
await settle();
await addBlankRow(host);
const [blankRow] = pendingRows(host);
expect(blankRow).toBeDefined();
await selectRowNumber(blankRow!);
await paste(host, "10\t20\n30\t40");
const rows = pendingRows(host);
expect(rows).toHaveLength(2);
expect(visibleCellTexts(rows[0]!)).toEqual(["10", "20", "NULL"]);
expect(visibleCellTexts(rows[1]!)).toEqual(["30", "40", "NULL"]);
});
it("keeps nonblank new rows on the ordinary bounded paste path", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
let [row] = pendingRows(host);
await selectCell(visibleCells(row!)[2]!);
await paste(host, "occupied");
[row] = pendingRows(host);
await selectCell(visibleCells(row!)[2]!);
await paste(host, "first\nsecond");
const rows = pendingRows(host);
expect(rows).toHaveLength(1);
expect(visibleCellTexts(rows[0]!)[2]).toBe("first");
});
it("keeps existing rows on the ordinary bounded paste path", async () => {
const { host } = mountGrid();
await settle();
const [row] = displayRows(host);
await selectCell(visibleCells(row!)[2]!);
await paste(host, "first\nsecond");
const rows = displayRows(host);
expect(rows).toHaveLength(1);
expect(visibleCellTexts(rows[0]!)[2]).toBe("first");
});
it("does not append from a multi-cell range", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
const [row] = pendingRows(host);
const cells = visibleCells(row!);
await selectCell(cells[1]!);
await selectCell(cells[2]!, { shiftKey: true });
await paste(host, "a\tb\nc\td");
const rows = pendingRows(host);
expect(rows).toHaveLength(1);
expect(visibleCellTexts(rows[0]!).slice(1, 3)).toEqual(["a", "b"]);
});
it("does not append from a column selection", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
await selectColumnHeader(host, 2);
await paste(host, "first\nsecond\nthird");
const rows = pendingRows(host);
expect(rows).toHaveLength(1);
expect(visibleCellTexts(rows[0]!)[2]).toBe("second");
});
it("does not append from a readonly cell", async () => {
const { host } = mountGrid({ readonlyColumnIndexes: [2] });
await settle();
await addBlankRow(host);
const [row] = pendingRows(host);
await selectCell(visibleCells(row!)[2]!);
await paste(host, "first\nsecond");
const rows = pendingRows(host);
expect(rows).toHaveLength(1);
expect(visibleCellTexts(rows[0]!)[2]).toBe("NULL");
});
it("does not append an empty clipboard", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
const [row] = pendingRows(host);
await selectCell(visibleCells(row!)[2]!);
await paste(host, "");
expect(pendingRows(host)).toHaveLength(1);
});
it("routes DOM and canvas cell gestures through the same selection preparation", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
const domGesture = source.slice(source.indexOf('@mousedown="\n prepareDataCellMouseDown'), source.indexOf('@mouseenter="onCellMouseenter'));
const canvasGesture = source.slice(source.indexOf("function onCanvasMouseDown"), source.indexOf("function onCanvasContext"));
expect(domGesture).toContain("prepareDataCellMouseDown(item, col.actualColIdx);");
expect(domGesture).toContain("handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);");
expect(canvasGesture).toContain("prepareDataCellMouseDown(item, actualColIdx)");
expect(canvasGesture).toContain("handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event)");
expect(source).toContain("columnIndexes: visibleColumnIndexes.value.slice(range.startCol)");
});
});