feat(grid): support batch pasting into new rows

This commit is contained in:
zhangsan 2026-07-30 00:27:54 +08:00 committed by GitHub
parent 9173cb754d
commit ffde91499a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 380 additions and 6 deletions

View File

@ -2826,6 +2826,7 @@ const {
cancelEdit,
onEditKeydown,
addRow: addEditorRow,
appendPastedRowsToNewRow,
cloneRow,
showDeleteRowConfirm,
requestDeleteRow,
@ -2854,6 +2855,7 @@ const {
previewChanges,
} = editor;
const pendingQuickEntryDraftCellFocus = ref<{ rowId: number; col: number } | null>(null);
const batchAppendPasteRowId = ref<number | null>(null);
const showSqlPreview = ref(false);
const previewSqlText = ref("");
@ -4819,7 +4821,7 @@ function onCanvasMouseDown(event: MouseEvent) {
commitHiddenCanvasEditBeforeCellInteraction();
if (!item) return;
if (hit.rowNumber) {
beginRowSelection(item.displayIndex, item.id, event);
onRowNumberMouseDown(item, event);
} else {
handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event);
}
@ -5544,7 +5546,51 @@ async function pasteClipboardIntoSelection() {
const operation = dataGridResultLifecycle.beginOperation();
const text = await readTextFromClipboard();
if (!dataGridResultLifecycle.isCurrent(operation)) return;
pasteTextIntoSelection(text);
pasteTextIntoGrid(text);
}
function batchAppendPasteTargetRowId(): number | null {
const rowId = batchAppendPasteRowId.value;
if (rowId === null) return null;
const item = getRowItem(rowId);
if ((!item?.isNew && !item?.isDraft) || item.isDeleted || selectedRowIds.value.size !== 1 || !selectedRowIds.value.has(rowId)) {
batchAppendPasteRowId.value = null;
return null;
}
return rowId;
}
function canAppendPastedRows(): boolean {
return !!props.editable && batchAppendPasteTargetRowId() !== null;
}
function batchAppendPasteError(reason: string): string {
const messages: Record<string, string> = {
"not-editable": "grid.batchAppendPasteNotEditable",
"invalid-target": "grid.batchAppendPasteInvalidTarget",
"target-not-empty": "grid.batchAppendPasteTargetNotEmpty",
"empty-paste": "grid.batchAppendPasteEmpty",
"readonly-column": "grid.batchAppendPasteReadonlyColumn",
};
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 pasteTextIntoSelection(text: string): boolean {
@ -5570,12 +5616,12 @@ function pasteTextIntoSelection(text: string): boolean {
}
function onGridPaste(event: ClipboardEvent) {
const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value);
const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value || canAppendPastedRows());
if (intent === "native") return;
if (intent === "block") return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined) return;
pasteTextIntoSelection(text);
pasteTextIntoGrid(text);
}
function pasteStartCell() {
@ -5984,7 +6030,13 @@ async function commitEditFromCellBlur() {
await commitEditFromBlur();
}
function onRowNumberMouseDown(item: RowItem, event: MouseEvent) {
beginRowSelection(item.displayIndex, item.id, event);
batchAppendPasteRowId.value = item.isNew || item.isDraft ? item.id : null;
}
function prepareDataCellMouseDown(item: RowItem, actualColIdx: number) {
batchAppendPasteRowId.value = null;
const editing = editingCell.value;
if (editing?.rowId === quickEntryDraftRowId && item.isDraft && item.id === quickEntryDraftRowId && editing.col !== actualColIdx) {
pendingQuickEntryDraftCellFocus.value = { rowId: item.id, col: actualColIdx };
@ -6146,7 +6198,7 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (clipboardShortcut(event, "v")) {
const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value);
const intent = claimDataGridPaste(event, props.editable, !!selectedRange.value || hasColumnSelection.value || canAppendPastedRows());
if (intent === "native") return;
// A focused grid owns the shortcut even when read-only; otherwise the webview may paste into the previously focused SQL editor.
if (intent === "block") return;
@ -6860,6 +6912,7 @@ watch(editingCell, (cell) => {
watch(editValue, scheduleActiveCellEditTextareaResize);
function onRowContext(rowId: number, rowIndex: number) {
batchAppendPasteRowId.value = null;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
@ -8661,7 +8714,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div
class="data-grid-row-number w-(--row-num-w) shrink-0 px-2 py-1 border-r text-center select-none cursor-default sticky left-0 z-10"
:class="[rowNumberStatusClass(item), { 'data-grid-row-number--selected': isRowSelected(item.id) }]"
@mousedown="beginRowSelection(item.displayIndex, item.id, $event)"
@mousedown="onRowNumberMouseDown(item, $event)"
@dblclick.stop="toggleTranspose(item.displayIndex)"
@contextmenu="onRowContext(item.id, item.displayIndex)"
>

View File

@ -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],
]);
});
});

View File

@ -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,

View File

@ -1227,6 +1227,11 @@ export default {
freezeSelectedColumns: "Freeze Selected Columns",
unfreezeColumns: "Unfreeze Columns",
pasted: "Pasted!",
batchAppendPasteNotEditable: "This result cannot be edited.",
batchAppendPasteInvalidTarget: "Select the row number of an empty new row before pasting.",
batchAppendPasteTargetNotEmpty: "The selected new row already contains data.",
batchAppendPasteEmpty: "The clipboard does not contain rows to add.",
batchAppendPasteReadonlyColumn: "The pasted data includes a column that cannot be edited.",
search: "Search...",
searchOrWhere: "Search, or enter a WHERE clause...",
applyWhere: "Apply WHERE",

View File

@ -1343,6 +1343,11 @@ export default withEnglishFallback({
numericColumnAlign: "Alineación de columna numérica",
numericColumnAlignLeft: "Alineación izquierda",
numericColumnAlignRight: "Alineación derecha",
batchAppendPasteNotEditable: "El resultado actual no se puede editar.",
batchAppendPasteInvalidTarget: "Primero seleccione una celda de número de fila de una nueva fila vacía y luego pegue.",
batchAppendPasteTargetNotEmpty: "La nueva fila seleccionada ya contiene datos.",
batchAppendPasteEmpty: "No hay filas nuevas en el portapapeles.",
batchAppendPasteReadonlyColumn: "Los datos pegados contienen columnas no editables.",
xlsxHeaderTitle: "Formato de encabezado",
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
xlsxHeaderOriginal: "Encabezado usando nombres de campos",

View File

@ -1341,6 +1341,11 @@ export default withEnglishFallback({
numericColumnAlign: "Allineamento colonna numerica",
numericColumnAlignLeft: "Allineamento a sinistra",
numericColumnAlignRight: "Allineamento a destra",
batchAppendPasteNotEditable: "Il risultato corrente non è modificabile.",
batchAppendPasteInvalidTarget: "Seleziona prima una cella vuota della riga nuova, poi incolla.",
batchAppendPasteTargetNotEmpty: "La riga nuova selezionata contiene già dati.",
batchAppendPasteEmpty: "Negli appunti non ci sono righe da aggiungere.",
batchAppendPasteReadonlyColumn: "I dati incollati contengono colonne non modificabili.",
xlsxHeaderTitle: "Formato intestazione",
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
xlsxHeaderOriginal: "Intestazione con nome campo",

View File

@ -1342,6 +1342,11 @@ export default withEnglishFallback({
numericColumnAlign: "数値列の配置",
numericColumnAlignLeft: "左揃え",
numericColumnAlignRight: "右揃え",
batchAppendPasteNotEditable: "現在の結果は編集できません。",
batchAppendPasteInvalidTarget: "空の新規行の行番号セルを選択してから貼り付けてください。",
batchAppendPasteTargetNotEmpty: "選択した新規行には既にデータが含まれています。",
batchAppendPasteEmpty: "クリップボードに追加可能な行がありません。",
batchAppendPasteReadonlyColumn: "貼り付けたデータに編集不可能な列が含まれています。",
xlsxHeaderTitle: "ヘッダー形式",
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
xlsxHeaderOriginal: "ヘッダーにフィールド名を使用",

View File

@ -1343,6 +1343,11 @@ export default withEnglishFallback({
numericColumnAlign: "Alinhamento de colunas numéricas",
numericColumnAlignLeft: "Alinhamento à esquerda",
numericColumnAlignRight: "Alinhamento à direita",
batchAppendPasteNotEditable: "O resultado atual não é editável.",
batchAppendPasteInvalidTarget: "Selecione primeiro o número da linha de uma nova linha vazia e depois cole.",
batchAppendPasteTargetNotEmpty: "A nova linha selecionada já contém dados.",
batchAppendPasteEmpty: "Não há linhas para adicionar na área de transferência.",
batchAppendPasteReadonlyColumn: "Os dados colados contêm colunas não editáveis.",
xlsxHeaderTitle: "Formato do cabeçalho",
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",

View File

@ -1228,6 +1228,11 @@ export default withEnglishFallback({
freezeSelectedColumns: "冻结选中列",
unfreezeColumns: "取消冻结",
pasted: "已粘贴!",
batchAppendPasteNotEditable: "当前结果不可编辑。",
batchAppendPasteInvalidTarget: "请先选中一个空新增行的行号单元格,再进行粘贴。",
batchAppendPasteTargetNotEmpty: "选中的新增行已包含数据。",
batchAppendPasteEmpty: "剪贴板中没有可新增的行。",
batchAppendPasteReadonlyColumn: "粘贴的数据包含不可编辑的列。",
search: "搜索...",
searchOrWhere: "搜索,或输入 WHERE 条件...",
applyWhere: "应用 WHERE",

View File

@ -1342,6 +1342,11 @@ export default withEnglishFallback({
numericColumnAlign: "數值列對齊",
numericColumnAlignLeft: "左對齊",
numericColumnAlignRight: "右對齊",
batchAppendPasteNotEditable: "目前結果不可編輯。",
batchAppendPasteInvalidTarget: "請先選取一個空白新增行的行號儲存格,再進行貼上。",
batchAppendPasteTargetNotEmpty: "選取的新增行已包含資料。",
batchAppendPasteEmpty: "剪貼簿中沒有可新增的行。",
batchAppendPasteReadonlyColumn: "貼上的資料包含不可編輯的列。",
xlsxHeaderTitle: "表頭格式",
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
xlsxHeaderOriginal: "表頭使用欄位名稱",