feat(grid): add bulk edit selection action

This commit is contained in:
t8y2 2026-06-04 00:01:17 +08:00
parent 0e66b5d128
commit f540770bce
9 changed files with 268 additions and 34 deletions

View File

@ -179,9 +179,11 @@ import {
removeAutoHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "@/lib/dataGridColumnVisibility";
import { parseClipboardTable } from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
import { readTextFromClipboard } from "@/lib/clipboard";
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
@ -406,6 +408,8 @@ function typeColorClass(t: string): string {
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const contextHeaderColumn = ref<string | null>(null);
const contextHeaderColumnIndex = ref<number | null>(null);
const bulkEditDialogOpen = ref(false);
const bulkEditValue = ref("");
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null);
const quickDownloadMenuCell = ref<{ rowIndex: number; col: number } | null>(null);
@ -1791,10 +1795,9 @@ const isResultsContext = computed(() => props.context === "results");
const showQueryEditReadyBadge = computed(
() => isResultsContext.value && hasData.value && !!props.editable && !!props.tableMeta,
);
const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
const tableUsesSyntheticRowId = computed(() =>
usesSyntheticRowIdKey(props.databaseType, props.tableMeta?.primaryKeys ?? []),
);
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
const canEditExistingRows = computed(
() =>
@ -2026,7 +2029,7 @@ const showDataGridTopbar = computed(
() =>
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSave)) ||
hasLocalColumnFilters.value ||
canUseWhereSearch.value ||
canShowWhereSearch.value ||
hasSearchBarSlot.value ||
showQueryEditReadyBadge.value ||
props.context !== "results" ||
@ -2337,8 +2340,10 @@ const {
columnIsSelected,
selectedRangeStart,
selectedRowIds,
selectedColumnIndexes,
hasRowSelection,
selectedRowCount,
hasColumnSelection,
clearRowSelection,
handleRowClick,
handleDataCellMousedown,
@ -2901,8 +2906,29 @@ async function clearContextFilter() {
await clearAllFilters();
}
function waitForTableMeta(timeoutMs = 2500): Promise<DataGridTableMeta | null> {
if (props.tableMeta) return Promise.resolve(props.tableMeta);
return new Promise((resolve) => {
let stop: (() => void) | undefined;
const timer = window.setTimeout(() => {
stop?.();
resolve(null);
}, timeoutMs);
stop = watch(
() => props.tableMeta,
(tableMeta) => {
if (!tableMeta) return;
window.clearTimeout(timer);
stop?.();
resolve(tableMeta);
},
{ flush: "sync" },
);
});
}
async function applyOrderBySearch() {
if (!props.tableMeta || !props.onExecuteSql) return;
if (!props.onExecuteSql) return;
const orderByClause = orderByInput.value.trim() || undefined;
emit("update:orderByInput", orderByInput.value);
isApplyingWhere.value = true;
@ -2912,16 +2938,18 @@ async function applyOrderBySearch() {
sortColIndex.value = null;
sortDir.value = "asc";
try {
const tableMeta = await waitForTableMeta();
if (!tableMeta) return;
const sql = await buildTableSelectSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
columns: props.tableMeta.columns.map((column) => column.name),
primaryKeys: props.tableMeta.primaryKeys,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
columns: tableMeta.columns.map((column) => column.name),
primaryKeys: tableMeta.primaryKeys,
orderBy: orderByClause,
limit: pageSize.value,
whereInput: currentWhereInput(),
includeRowId: tableUsesSyntheticRowId.value,
includeRowId: usesSyntheticRowIdKey(props.databaseType, tableMeta.primaryKeys),
});
await props.onExecuteSql(sql);
} catch (e: any) {
@ -2932,23 +2960,25 @@ async function applyOrderBySearch() {
}
async function applyWhereFilter() {
if (!props.tableMeta || !props.onExecuteSql) return;
if (!props.onExecuteSql) return;
isApplyingWhere.value = true;
saveError.value = "";
currentPage.value = 1;
try {
const tableMeta = await waitForTableMeta();
if (!tableMeta) return;
const sql = await buildTableSelectSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
columns: props.tableMeta.columns.map((column) => column.name),
primaryKeys: props.tableMeta.primaryKeys,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
columns: tableMeta.columns.map((column) => column.name),
primaryKeys: tableMeta.primaryKeys,
orderBy:
orderByInput.value.trim() ||
(sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined),
limit: pageSize.value,
whereInput: currentWhereInput(),
includeRowId: tableUsesSyntheticRowId.value,
includeRowId: usesSyntheticRowIdKey(props.databaseType, tableMeta.primaryKeys),
});
await props.onExecuteSql(sql);
} catch (e: any) {
@ -3727,29 +3757,125 @@ function clipboardShortcut(event: KeyboardEvent, key: string): boolean {
return (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === key;
}
function parseClipboardTable(text: string): string[][] {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, "");
if (!normalized) return [[""]];
return normalized.split("\n").map((row) => row.split("\t"));
}
let lastPasteEventAt = 0;
async function pasteClipboardIntoSelection() {
if (!props.editable) return;
const start = selectedRangeStart();
if (!start) return;
const text = await readTextFromClipboard();
pasteTextIntoSelection(text);
}
const text = await navigator.clipboard.readText();
function pasteTextIntoSelection(text: string): boolean {
const rows = parseClipboardTable(text);
if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) {
toast(t("grid.pasted"));
return true;
}
const start = pasteStartCell();
if (!start) return false;
let applied = false;
rows.forEach((row, rowOffset) => {
const item = displayItems.value[start.rowIndex + rowOffset];
if (!item) return;
row.forEach((value, colOffset) => {
const visibleCol = start.colIndex + colOffset;
if (visibleCol >= visibleColumns.value.length) return;
applyCellValue(item.id, actualColumnIndex(visibleCol), value);
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
});
});
toast(t("grid.pasted"));
if (applied) toast(t("grid.pasted"));
return applied;
}
function onGridPaste(event: ClipboardEvent) {
if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined) return;
event.preventDefault();
lastPasteEventAt = Date.now();
pasteTextIntoSelection(text);
}
function pasteStartCell() {
const start = selectedRangeStart();
if (start) return start;
if (!hasColumnSelection.value) return null;
const firstCol = selectedVisibleColumnIndexes()[0];
return firstCol === undefined ? null : { rowIndex: 0, colIndex: firstCol };
}
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): boolean {
const actualCol = actualColumnIndex(visibleCol);
if (!canEditCellItem(item, actualCol)) return false;
applyCellValue(item.id, actualCol, value);
return true;
}
function fillSelectionWithValue(value: string): boolean {
const range = selectedRange.value;
let applied = false;
if (range) {
for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) {
const item = displayItems.value[rowIndex];
if (!item) continue;
for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) {
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
}
}
return applied;
}
if (!hasColumnSelection.value) return false;
const visibleColumnIndexes = selectedVisibleColumnIndexes();
if (!visibleColumnIndexes.length) return false;
for (const item of displayItems.value) {
for (const visibleCol of visibleColumnIndexes) {
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
}
}
return applied;
}
function selectionHasEditableCells(): boolean {
const range = selectedRange.value;
if (range) {
for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) {
const item = displayItems.value[rowIndex];
if (!item) continue;
for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) {
if (canEditCellItem(item, actualColumnIndex(visibleCol))) return true;
}
}
return false;
}
if (!hasColumnSelection.value) return false;
const visibleColumnIndexes = selectedVisibleColumnIndexes();
for (const item of displayItems.value) {
for (const visibleCol of visibleColumnIndexes) {
if (canEditCellItem(item, actualColumnIndex(visibleCol))) return true;
}
}
return false;
}
function openBulkEditDialog() {
if (!props.editable || !selectionHasEditableCells()) return;
bulkEditValue.value = "";
bulkEditDialogOpen.value = true;
}
function applyBulkEditValue() {
if (!fillSelectionWithValue(bulkEditValue.value)) return;
bulkEditDialogOpen.value = false;
}
function cutSelection() {
@ -4018,9 +4144,15 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (clipboardShortcut(event, "v")) {
if (!props.editable || !selectedRange.value) return;
event.preventDefault();
await pasteClipboardIntoSelection();
if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return;
const keydownAt = Date.now();
window.setTimeout(() => {
if (lastPasteEventAt >= keydownAt) return;
pasteClipboardIntoSelection().catch((e) =>
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000),
);
}, 50);
return;
}
}
@ -4454,8 +4586,10 @@ watch(
// --- Context menu handlers ---
function onHeaderContext(col: string, columnIndex: number) {
contextCell.value = null;
clearCellSelection();
clearRowSelection();
const visibleColIdx = visibleColumnIndexes.value.indexOf(columnIndex);
if (visibleColIdx >= 0 && !columnIsSelected(visibleColIdx)) {
selectColumn(visibleColIdx);
}
contextHeaderColumn.value = col;
contextHeaderColumnIndex.value = columnIndex;
}
@ -5167,6 +5301,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
items.push(copySubmenu());
}
if (props.editable && hasCellSelection.value) {
items.push({
label: t("grid.bulkEditSelection"),
action: openBulkEditDialog,
disabled: !selectionHasEditableCells(),
icon: Pencil,
});
}
// 5. Transpose
if (contextCell.value) {
items.push({ label: t("grid.transpose"), action: openContextTranspose, icon: Rows3 });
@ -5219,10 +5362,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:style="gridStyle"
tabindex="0"
@keydown="onGridKeydown"
@paste="onGridPaste"
>
<CustomContextMenu :items="gridContextMenuItems" v-slot="{ onContextMenu }">
<div
v-if="hasData || canUseWhereSearch"
v-if="hasData || canShowWhereSearch"
class="flex-1 flex flex-col overflow-hidden"
@contextmenu="onContextMenu"
>
@ -5273,7 +5417,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</template>
<template v-if="canUseWhereSearch">
<template v-if="canShowWhereSearch">
<div ref="searchSplitContainerRef" class="flex flex-1 min-w-0">
<div
class="flex flex-1 items-center gap-1 px-2 py-0.5 border-l min-w-0 relative"
@ -5289,6 +5433,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15'
: 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'
"
:disabled="!canUseWhereSearch"
@click="ensureStructuredFilterRule"
>
<Filter class="h-3 w-3" />
@ -7847,6 +7992,28 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</DialogContent>
</Dialog>
<Dialog v-model:open="bulkEditDialogOpen">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("grid.bulkEditTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">
{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}
</p>
<Input
v-model="bulkEditValue"
:placeholder="t('grid.bulkEditValuePlaceholder')"
@keydown.enter.prevent="applyBulkEditValue"
/>
</div>
<DialogFooter>
<Button variant="outline" @click="bulkEditDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button @click="applyBulkEditValue">{{ t("grid.applyBulkEdit") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="

View File

@ -552,6 +552,11 @@ export default {
statusDeleted: "Deleted Items",
pendingChanges: "{count} pending",
selectedCells: "{count} selected",
bulkEditSelection: "Bulk Edit Selection",
bulkEditTitle: "Bulk Edit Selection",
bulkEditDescription: "Set {count} selected cell(s) to this value.",
bulkEditValuePlaceholder: "Value, or NULL",
applyBulkEdit: "Apply",
cellDetails: "Cell Details",
cellDetailLayoutBottom: "Move to Bottom",
cellDetailLayoutRight: "Move to Right",

View File

@ -518,6 +518,11 @@ export default {
statusDeleted: "Elementos eliminados",
pendingChanges: "{count} pendientes",
selectedCells: "{count} seleccionadas",
bulkEditSelection: "Edición masiva de selección",
bulkEditTitle: "Edición masiva de selección",
bulkEditDescription: "Establecer {count} celda(s) seleccionada(s) en este valor.",
bulkEditValuePlaceholder: "Valor, o NULL",
applyBulkEdit: "Aplicar",
cellDetails: "Detalles de celda",
cellDetailLayoutBottom: "Mover abajo",
cellDetailLayoutRight: "Mover a la derecha",

View File

@ -547,6 +547,11 @@ export default {
statusDeleted: "删除项",
pendingChanges: "{count} 项待保存",
selectedCells: "已选 {count} 个单元格",
bulkEditSelection: "批量修改所选",
bulkEditTitle: "批量修改所选",
bulkEditDescription: "将已选 {count} 个单元格设置为这个值。",
bulkEditValuePlaceholder: "输入值,或 NULL",
applyBulkEdit: "应用",
cellDetails: "单元格详情",
cellDetailLayoutBottom: "移动到底部",
cellDetailLayoutRight: "移动到右侧",

View File

@ -547,6 +547,11 @@ export default {
statusDeleted: "刪除列",
pendingChanges: "{count} 項待儲存",
selectedCells: "已選 {count} 個儲存格",
bulkEditSelection: "批次修改所選",
bulkEditTitle: "批次修改所選",
bulkEditDescription: "將已選 {count} 個儲存格設定為這個值。",
bulkEditValuePlaceholder: "輸入值,或 NULL",
applyBulkEdit: "套用",
cellDetails: "儲存格詳情",
cellDetailLayoutBottom: "移到底部",
cellDetailLayoutRight: "移到右側",

View File

@ -1,6 +1,7 @@
import { isTauriRuntime } from "./tauriRuntime";
interface ClipboardApi {
readText?: () => Promise<string> | string;
writeText?: (text: string) => Promise<void> | void;
}
@ -36,6 +37,25 @@ export interface ClipboardEnvironment {
document?: ClipboardDocument;
}
export async function readTextFromClipboard(
env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment,
): Promise<string> {
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
try {
const { readText } = await import("@tauri-apps/plugin-clipboard-manager");
return await readText();
} catch {
// Fall through to Web Clipboard when the native plugin is unavailable.
}
}
if (env.navigator?.clipboard?.readText) {
return await env.navigator.clipboard.readText();
}
throw new Error("Clipboard API is not available");
}
export async function copyToClipboard(
text: string,
env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment,

View File

@ -17,6 +17,12 @@ export interface SelectionData {
rows: GridCellValue[][];
}
export function parseClipboardTable(text: string): string[][] {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, "");
if (!normalized) return [[""]];
return normalized.split("\n").map((row) => row.split("\t"));
}
export function normalizeSelectedColumnIndexes(columnIndexes: Iterable<number>): number[] {
return [...new Set(columnIndexes)].filter((index) => index >= 0).sort((a, b) => a - b);
}

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { copyToClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
import { copyToClipboard, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
test("copyToClipboard falls back when navigator clipboard is unavailable", async () => {
const appended: unknown[] = [];
@ -48,3 +48,15 @@ test("copyToClipboard falls back when navigator clipboard is unavailable", async
assert.equal(appended[0], textarea);
assert.equal(removed[0], textarea);
});
test("readTextFromClipboard uses navigator clipboard when available", async () => {
const text = await readTextFromClipboard({
navigator: {
clipboard: {
readText: async () => "orders\t42",
},
},
});
assert.equal(text, "orders\t42");
});

View File

@ -11,6 +11,7 @@ import {
formatSelectionAsTsv,
isCellInSelection,
normalizeSelectionRange,
parseClipboardTable,
rowSelectionRange,
} from "../../apps/desktop/src/lib/gridSelection.ts";
@ -102,3 +103,11 @@ test("formats selected cells as TSV, CSV, JSON, and SQL values", () => {
);
assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)");
});
test("parses clipboard table text with spreadsheet line endings", () => {
assert.deepEqual(parseClipboardTable("0"), [["0"]]);
assert.deepEqual(parseClipboardTable("a\tb\r\nc\td\r\n"), [
["a", "b"],
["c", "d"],
]);
});