fix(grid): edit multi-cell selections inline

This commit is contained in:
大熊 authored and GitHub committed 2026-08-20 20:41:51 +08:00
1 parent 5741bc1256
commit d2e2270d84
6 files changed
+158 -13

No files matched your search

+59 -11
View File
@@ -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<string[]>([]);
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<boolean> {
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();
@@ -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()");
});
});
@@ -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 {
@@ -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({
@@ -8,10 +8,13 @@ export interface CoerceDataGridCellValueOptions {
databaseType: DatabaseType | undefined;
columnInfo: Pick<ColumnInfo, "data_type"> | 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;
@@ -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;
}