fix(grid): edit multi-cell selections inline
This commit is contained in:
6 files changed
+158
-13
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user