From d2e2270d840621d5b20bec8c9d09ae88d1607b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=86=8A?= <49681654+xzxlove@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:41:51 +0800 Subject: [PATCH] fix(grid): edit multi-cell selections inline --- apps/desktop/src/components/grid/DataGrid.vue | 70 ++++++++++++++++--- .../__tests__/DataGridInlineBulkEdit.spec.ts | 42 +++++++++++ .../src/composables/useDataGridEditor.ts | 8 ++- .../dataGrid/dataGridCellCoercion.spec.ts | 34 +++++++++ .../src/lib/dataGrid/dataGridCellCoercion.ts | 3 + .../lib/dataGrid/dataGridInlineBulkEdit.ts | 14 ++++ 6 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 78a2d6f8fa..90699cfc26 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -242,6 +242,7 @@ import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridC import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation"; +import { dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; import type { CellPosition } from "@/lib/dataGrid/gridSelection"; import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; @@ -822,6 +823,7 @@ function onGridContextMenuClose() { const bulkEditDialogOpen = ref(false); const bulkEditValue = ref(""); +const inlineBulkEditActive = ref(false); const copyColumnNamesDialogOpen = ref(false); const copyColumnNamesDialogColumns = ref([]); const insertRowsDialogOpen = ref(false); @@ -3699,13 +3701,16 @@ function cellUsesExpandedEditor(rowId: number | undefined, columnIndex: number): return !!expandedCellEditor.value && expandedCellEditor.value.rowId === rowId && expandedCellEditor.value.col === columnIndex; } -async function startCellEdit(rowId: number, columnIndex: number, expanded: boolean) { - if (!(await hydrateLargeValueCell(rowId, columnIndex))) return; +async function startCellEdit(rowId: number, columnIndex: number, expanded: boolean, initialValue?: string): Promise { + if (!(await hydrateLargeValueCell(rowId, columnIndex))) return false; closeReadonlyCellTextSelection(); expandedCellEditor.value = expanded ? { rowId, col: columnIndex } : null; const item = getRowItem(rowId); - startEdit(rowId, columnIndex); - if (item) editValue.value = inlineCellEditorText(item.data[columnIndex] ?? null, columnIndex); + startEdit(rowId, columnIndex, initialValue === undefined); + const started = editingCell.value?.rowId === rowId && editingCell.value.col === columnIndex; + if (!started) return false; + editValue.value = initialValue ?? (item ? inlineCellEditorText(item.data[columnIndex] ?? null, columnIndex) : editValue.value); + return true; } async function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) { @@ -7514,14 +7519,14 @@ function selectedVisibleColumnIndexes(): number[] { return [...selectedColumnIndexes.value].filter((index) => index >= 0 && index < visibleColumns.value.length).sort((a, b) => a - b); } -function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string | null, options: { preserveEmptyString?: boolean } = {}): boolean { +function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean { const actualCol = actualColumnIndex(visibleCol); if (!canEditCellItem(item, actualCol)) return false; applyCellValue(item.id, actualCol, value, options); return true; } -function applyVisibleSelectedCellValue(item: RowItem, visibleCol: number, value: string | null, allowDraft = selectedRangeTargetsOnlyDraftRow(), options: { preserveEmptyString?: boolean } = {}): boolean { +function applyVisibleSelectedCellValue(item: RowItem, visibleCol: number, value: string | null, allowDraft = selectedRangeTargetsOnlyDraftRow(), options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean { if (!canApplyGridSelectionValue({ isDraft: !!item.isDraft, allowDraft })) return false; return applyVisibleCellValue(item, visibleCol, value, options); } @@ -7533,7 +7538,7 @@ function selectedRangeTargetsOnlyDraftRow(): boolean { return displayItemAt(range.startRow)?.isDraft === true; } -function fillSelectionWithValue(value: string | null): boolean { +function fillSelectionWithValue(value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean { const range = selectedRange.value; let applied = false; const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); @@ -7544,7 +7549,7 @@ function fillSelectionWithValue(value: string | null): boolean { const item = displayItemAt(rowIndex); if (!item) continue; for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { - applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue) || applied; + applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue, options) || applied; } } return applied; @@ -7557,7 +7562,7 @@ function fillSelectionWithValue(value: string | null): boolean { const item = displayItemAt(rowIndex); if (!item) continue; for (const visibleCol of visibleColumnIndexes) { - applied = applyVisibleSelectedCellValue(item, visibleCol, value) || applied; + applied = applyVisibleSelectedCellValue(item, visibleCol, value, undefined, options) || applied; } } return applied; @@ -7639,6 +7644,18 @@ function editableSelectionCells(): EditableSelectionCell[] { return cells; } +function beginInlineBulkEdit(initialValue: string): boolean { + if (!props.editable) return false; + const target = editableSelectionCells()[0]; + if (!target || selectedCellCount.value <= 1) return false; + const actualCol = actualColumnIndex(target.visibleCol); + inlineBulkEditActive.value = true; + void startCellEdit(target.item.id, actualCol, false, initialValue).then((started) => { + if (!started) inlineBulkEditActive.value = false; + }); + return true; +} + function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue = 1n): boolean { if (!props.editable) return false; const cells = editableSelectionCells(); @@ -8038,9 +8055,20 @@ function currentIoTDBTimestampEditValue(): CellValue | undefined { function commitGridEdit(value?: CellValue) { if (value === undefined) value = currentIoTDBTimestampEditValue(); + if (commitInlineBulkEdit(value)) return; void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true }))); } +function commitInlineBulkEdit(value?: CellValue): boolean { + if (!inlineBulkEditActive.value) return false; + inlineBulkEditActive.value = false; + const nextValue = value === undefined ? editValue.value : value === null ? null : String(value); + cancelEdit(); + fillSelectionWithValue(nextValue, { emptyStringAsNull: true }); + nextTick(() => gridRef.value?.focus({ preventScroll: true })); + return true; +} + function commitBooleanGridEdit(value?: string | null) { if (value === undefined) { commitGridEdit(); @@ -8055,9 +8083,10 @@ function commitBooleanGridEdit(value?: string | null) { } async function commitEditFromCellBlur() { + const timestamp = currentIoTDBTimestampEditValue(); + if (commitInlineBulkEdit(timestamp)) return; const target = pendingQuickEntryDraftCellFocus.value; pendingQuickEntryDraftCellFocus.value = null; - const timestamp = currentIoTDBTimestampEditValue(); const timestampOptions = timestamp === undefined ? {} : { explicitValue: timestamp }; if (target && editingCell.value?.rowId === quickEntryDraftRowId && target.rowId === quickEntryDraftRowId) { await commitEditFromBlur({ ...timestampOptions, promoteDraft: false }); @@ -8110,13 +8139,25 @@ async function onCellEditKeydown(event: KeyboardEvent) { if (isSaveShortcut(event, settingsStore.editorSettings.shortcuts)) { event.preventDefault(); event.stopPropagation(); - commitEdit(); + if (!commitInlineBulkEdit(currentIoTDBTimestampEditValue())) commitEdit(); await nextTick(); if (await saveGridChangesFromShortcut()) { gridRef.value?.focus({ preventScroll: true }); } return; } + if (inlineBulkEditActive.value) { + if (event.key === "Enter") { + event.preventDefault(); + commitInlineBulkEdit(currentIoTDBTimestampEditValue()); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + inlineBulkEditActive.value = false; + cancelEdit(); + } + return; + } onEditKeydown(event); } @@ -8246,6 +8287,12 @@ async function onGridKeydown(event: KeyboardEvent) { event.preventDefault(); return; } + const inlineBulkEditValue = dataGridInlineBulkEditValue(event, selectedCellCount.value); + if (inlineBulkEditValue !== undefined && beginInlineBulkEdit(inlineBulkEditValue)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (event.key === "Enter" && editSelectedCell()) { event.preventDefault(); return; @@ -9212,6 +9259,7 @@ watch(editingCell, (cell) => { scheduleActiveCellEditTextareaResize(); if (cell) nextTick(observeCellEditResizeBounds); else { + inlineBulkEditActive.value = false; resetCellEditTextareaScrollOnResize = false; expandedCellEditor.value = null; disconnectCellEditResizeObserver(); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts new file mode 100644 index 0000000000..0d89c73cc5 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridInlineBulkEdit.spec.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit"; + +const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +function functionBody(name: string, nextName: string): string { + const start = dataGridSource.indexOf(`function ${name}`); + const end = dataGridSource.indexOf(`function ${nextName}`, start + 1); + return start >= 0 && end > start ? dataGridSource.slice(start, end) : ""; +} + +describe("DataGrid inline bulk editing", () => { + it.each([ + ["1", "1"], + ["删", "删"], + ["🙂", "🙂"], + ])("starts from the printable %j key", (key, expected) => { + expect(dataGridInlineBulkEditValue({ key }, 2)).toBe(expected); + }); + + it("starts empty with Enter and leaves single-cell editing unchanged", () => { + expect(dataGridInlineBulkEditValue({ key: "Enter" }, 2)).toBe(""); + expect(dataGridInlineBulkEditValue({ key: "1" }, 1)).toBeUndefined(); + }); + + it.each([{ key: "v", ctrlKey: true }, { key: "1", altKey: true }, { key: "删", isComposing: true }, { key: "删", keyCode: 229 }, { key: "Process" }])("ignores shortcut, composition, and control keys: %j", (event) => { + expect(dataGridInlineBulkEditValue(event, 2)).toBeUndefined(); + }); + + it("starts a cell editor instead of opening the bulk edit dialog", () => { + const keydown = functionBody("onGridKeydown", "copyDetailValue"); + expect(keydown).toContain("beginInlineBulkEdit(inlineBulkEditValue)"); + expect(keydown).not.toContain("openBulkEditDialog"); + }); + + it("commits the inline value to the full selection", () => { + const commit = functionBody("commitInlineBulkEdit", "commitBooleanGridEdit"); + expect(commit).toContain("fillSelectionWithValue(nextValue, { emptyStringAsNull: true })"); + expect(commit).toContain("cancelEdit()"); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 80cbdfd907..a35c70497c 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -348,6 +348,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { input.select(); input.setSelectionRange?.(0, input.value.length); } + } else if (input) { + input.setSelectionRange?.(input.value.length, input.value.length); } }; nextTick(() => { @@ -554,6 +556,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { // --- Cell value coercion --- interface ApplyCellValueOptions { preserveEmptyString?: boolean; + emptyStringAsNull?: boolean; } function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number, options: ApplyCellValueOptions = {}): CellValue { @@ -563,6 +566,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { databaseType: resolvedDatabaseType.value, columnInfo: tableColumnForGridColumn(columnIndex), preserveEmptyString: options.preserveEmptyString, + emptyStringAsNull: options.emptyStringAsNull, }) as CellValue; } @@ -710,7 +714,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } // --- Inline editing --- - function startEdit(rowId: number, colIdx: number) { + function startEdit(rowId: number, colIdx: number, selectOnFocus = true) { if (!editable.value) return; if (!canEditColumn(colIdx)) return; const item = getRowItem(rowId); @@ -726,7 +730,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { databaseType: resolvedDatabaseType.value, columnInfo: tableColumnForGridColumn(colIdx), }); - focusEditInput(); + focusEditInput(selectOnFocus); } function commitEdit(options: CommitEditOptions = {}): CommitEditResult { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts index 1917432ee9..cfd3cec7b1 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts @@ -66,6 +66,40 @@ describe("coerceDataGridCellValue", () => { expect(coerceDataGridCellValue({ ...options, preserveEmptyString: true })).toBe(""); }); + it.each([ + ["numeric non-nullable", 42, "integer", false], + ["boolean nullable", true, "boolean", true], + ["text nullable", "before", "text", true], + ["text non-nullable", "before", "varchar(255)", false], + ["previously NULL text", null, "varchar(255)", true], + ])("uses SQL NULL for an empty inline bulk edit in a %s cell", (_name, oldValue, dataType, _isNullable) => { + expect( + coerceDataGridCellValue({ + value: "", + oldValue, + databaseType: "postgres", + columnInfo: { data_type: dataType }, + emptyStringAsNull: true, + }), + ).toBeNull(); + }); + + it.each([ + ["numeric", 42, "integer", "7", 7], + ["boolean", false, "boolean", "true", true], + ["text", "before", "text", "after", "after"], + ])("keeps per-column coercion for non-empty inline bulk edits: %s", (_name, oldValue, dataType, value, expected) => { + expect( + coerceDataGridCellValue({ + value, + oldValue, + databaseType: "postgres", + columnInfo: { data_type: dataType }, + emptyStringAsNull: true, + }), + ).toBe(expected); + }); + it("strips unambiguous thousands separators before numeric coercion", () => { expect( coerceDataGridCellValue({ diff --git a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts index 5321a35c8c..0b1128648d 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts @@ -8,10 +8,13 @@ export interface CoerceDataGridCellValueOptions { databaseType: DatabaseType | undefined; columnInfo: Pick | undefined; preserveEmptyString?: boolean; + /** Treat an empty inline bulk edit as SQL NULL before type coercion. */ + emptyStringAsNull?: boolean; } export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue { const { value, oldValue } = options; + if (value === "" && options.emptyStringAsNull) return null; if (value === "" && oldValue === null && !options.preserveEmptyString) return null; const postgresArrayValue = coercePostgresArrayValue(options); if (postgresArrayValue !== undefined) return postgresArrayValue; diff --git a/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts b/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts new file mode 100644 index 0000000000..e1a968fdf4 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridInlineBulkEdit.ts @@ -0,0 +1,14 @@ +export interface DataGridInlineBulkEditKeyEvent { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + isComposing?: boolean; + keyCode?: number; +} + +export function dataGridInlineBulkEditValue(event: DataGridInlineBulkEditKeyEvent, selectedCellCount: number): string | undefined { + if (selectedCellCount <= 1 || event.ctrlKey || event.metaKey || event.altKey || event.isComposing || event.keyCode === 229) return undefined; + if (event.key === "Enter") return ""; + return event.key !== "Process" && Array.from(event.key).length === 1 ? event.key : undefined; +}