diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 4662a75b1c..01d315ed66 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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])) { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts new file mode 100644 index 0000000000..3784a0f527 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts @@ -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(); + 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, + 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("[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(".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(".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(`[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(".data-grid-row")].slice(existingRowCount); +} + +function displayRows(host: HTMLElement): HTMLElement[] { + return [...host.querySelectorAll(".data-grid-row")]; +} + +function visibleCells(row: HTMLElement): HTMLElement[] { + return [...row.querySelectorAll("[data-visible-col-index]")]; +} + +function visibleCellTexts(row: HTMLElement): Array { + 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)"); + }); +});