fix(grid): show deleted row details in confirmation

This commit is contained in:
Melody Ruch
2026-08-20 20:49:47 +08:00
committed by GitHub
parent b3acf1e558
commit e2a4b26379
7 changed files with 284 additions and 23 deletions
+31 -5
View File
@@ -146,7 +146,18 @@ import {
} from "@/lib/dataGrid/binaryCellDownload";
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
buildDataGridRowDetail,
buildDeleteRowConfirmDetails,
CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH,
dataGridColumnDetailJson,
dataGridColumnDetailTsv,
dataGridRowDetailJson,
dataGridRowDetailTsv,
type DataGridCellDetail,
} from "@/lib/dataGrid/dataGridDetail";
import {
applyColumnFormatter,
buildColumnFormatterKey,
@@ -4463,6 +4474,10 @@ function formatGridItemCell(item: RowItem, columnIndex: number): string {
return formatCellCached(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex));
}
function formatGridItemCellForConfirmation(item: RowItem, columnIndex: number): string {
return formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false);
}
function normalizedLargeValueIdentityPart(value: CellValue): string {
if (value === null) return "null";
return `${typeof value}:${JSON.stringify(value)}`;
@@ -4656,7 +4671,16 @@ const exportContextCell = computed(() => {
return { ...contextCell.value, col: visibleCol };
});
const deleteRowDetails = computed(() => (props.tableMeta?.tableName ? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName }) : t("dangerDialog.deleteRowDetailsNoTable")));
const deleteRowDetails = computed(() => {
const header = props.tableMeta?.tableName ? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName }) : t("dangerDialog.deleteRowDetailsNoTable");
return buildDeleteRowConfirmDetails({
header,
rowIds: pendingDeleteRowIds.value,
columns: resultSourceColumns.value,
getRow: getRowItem,
formatCell: (item, columnIndex) => formatGridItemCellForConfirmation(item, columnIndex),
});
});
const hasVisibleRows = computed(() => displayRowCount.value > 0);
const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value);
@@ -5894,10 +5918,11 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
return `${columnIndex ?? -1}\u0000${typeof value}\u0000${String(value)}`;
}
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number): string {
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string {
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
return limitDataGridCellDisplay(formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)), resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined);
const display = formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex));
return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display;
}
const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex);
const displayColumnInfo = columnInfo ?? (columnIndex === undefined ? undefined : resultColumnInfoForGridColumn(columnIndex));
@@ -5912,7 +5937,7 @@ function formatCell(value: CellValue, columnIndex?: number, originalBytes?: numb
const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnIndex === undefined ? undefined : allColumnTypes.value[columnIndex], originalBytes);
if (binaryDisplay !== null) return binaryDisplay;
const s = applyColumnFormatter(value, formatter);
return limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined);
return limitDisplay ? limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : s;
}
function formatCellCached(value: CellValue, columnIndex?: number, originalBytes?: number): string {
@@ -12609,6 +12634,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
"
:details="deleteRowDetails"
:confirm-label="pendingDeleteRowIds.length > 1 ? t('grid.deleteRows', { count: pendingDeleteRowIds.length }) : t('grid.deleteRow')"
:close-on-confirm="false"
@confirm="confirmDeleteRow"
/>
<DangerConfirmDialog
@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid delete row confirmation details", () => {
it("still wires deleteRowDetails into the delete-row DangerConfirmDialog", () => {
const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"');
const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart);
expect(dialogStart).toBeGreaterThanOrEqual(0);
expect(dialogEnd).toBeGreaterThan(dialogStart);
const dialogSource = dataGridSource.slice(dialogStart, dialogEnd);
expect(dialogSource).toContain(':details="deleteRowDetails"');
});
it("uses the unbounded confirmation formatter for delete details", () => {
const detailsStart = dataGridSource.indexOf("const deleteRowDetails = computed");
const detailsEnd = dataGridSource.indexOf("const hasVisibleRows", detailsStart);
expect(detailsStart).toBeGreaterThanOrEqual(0);
expect(detailsEnd).toBeGreaterThan(detailsStart);
expect(dataGridSource.slice(detailsStart, detailsEnd)).toContain("formatGridItemCellForConfirmation");
expect(dataGridSource).toContain("formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false)");
});
it("keeps close-on-confirm disabled so confirmDeleteRow runs before the dialog auto-closes", () => {
const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"');
const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart);
expect(dialogStart).toBeGreaterThanOrEqual(0);
expect(dialogEnd).toBeGreaterThan(dialogStart);
const dialogSource = dataGridSource.slice(dialogStart, dialogEnd);
expect(dialogSource).toContain(':close-on-confirm="false"');
});
});
@@ -154,6 +154,56 @@ describe("useDataGridEditor row deletion confirmation", () => {
expect(editor.showDeleteRowConfirm.value).toBe(false);
expect(editor.newRows.value).toHaveLength(0);
});
it("populates pendingDeleteRowIds for a single-row delete request", () => {
const editor = createEditor(undefined, true);
editor.requestDeleteRow(-1);
expect(editor.pendingDeleteRowIds.value).toEqual([-1]);
});
it("populates pendingDeleteRowIds for a multi-row delete request and clears it on confirm", () => {
const editor = createEditor(undefined, true);
editor.newRows.value = [
[null, null, null],
[null, null, null],
];
editor.requestDeleteRows([-1, -2]);
expect(editor.pendingDeleteRowIds.value).toEqual([-1, -2]);
editor.confirmDeleteRow();
expect(editor.pendingDeleteRowIds.value).toEqual([]);
expect(editor.newRows.value).toHaveLength(0);
});
it("clears pendingDeleteRowIds when the confirmation dialog is closed without confirming", () => {
const editor = createEditor(undefined, true);
editor.requestDeleteRow(-1);
expect(editor.pendingDeleteRowIds.value).toEqual([-1]);
editor.showDeleteRowConfirm.value = false;
expect(editor.pendingDeleteRowIds.value).toEqual([]);
expect(editor.newRows.value).toHaveLength(1); // row itself was never actually deleted
});
it("confirmDeleteRow deletes the row and closes the dialog itself, without racing the cancel watcher", () => {
const editor = createEditor(undefined, true);
editor.requestDeleteRow(-1);
expect(editor.newRows.value).toHaveLength(1);
editor.confirmDeleteRow();
expect(editor.newRows.value).toHaveLength(0); // the row must actually be deleted
expect(editor.showDeleteRowConfirm.value).toBe(false); // and the dialog closes on its own
expect(editor.pendingDeleteRowIds.value).toEqual([]);
});
});
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
@@ -1243,16 +1243,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const showDeleteRowConfirm = ref(false);
const pendingDeleteRowId = ref<number | null>(null);
const pendingDeleteRowIds = ref<number[]>([]);
function requestDeleteRow(rowId: number) {
if (!confirmDangerousRowDeletion.value) {
applyDeleteRow(rowId);
return;
}
pendingDeleteRowId.value = rowId;
showDeleteRowConfirm.value = true;
requestDeleteRows([rowId]);
}
function requestDeleteRows(rowIds: number[]) {
@@ -1265,16 +1259,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function confirmDeleteRow() {
if (pendingDeleteRowIds.value.length > 0) {
applyDeleteRows(pendingDeleteRowIds.value);
pendingDeleteRowIds.value = [];
return;
}
if (pendingDeleteRowId.value === null) return;
applyDeleteRow(pendingDeleteRowId.value);
pendingDeleteRowId.value = null;
const rowIds = pendingDeleteRowIds.value;
pendingDeleteRowIds.value = [];
showDeleteRowConfirm.value = false;
if (rowIds.length === 0) return;
applyDeleteRows(rowIds);
}
watch(
showDeleteRowConfirm,
(isOpen) => {
if (!isOpen) pendingDeleteRowIds.value = [];
},
{ flush: "sync" },
);
function restoreRow(rowId: number) {
const item = getRowItem(rowId);
if (item?.sourceIndex !== undefined && deletedRows.value.has(item.sourceIndex)) {
@@ -1798,7 +1797,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
applyDeleteRows,
applyDeleteRow,
showDeleteRowConfirm,
pendingDeleteRowId,
pendingDeleteRowIds,
requestDeleteRow,
requestDeleteRows,
@@ -1,6 +1,6 @@
import { onMounted, ref } from "vue";
import { useTheme } from "@/composables/useTheme";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { type SqlHighlighter, createShikiSqlHighlighter, escapeHtml } from "@/lib/sql/sqlHighlighter";
export function useSqlHighlighter() {
const { isDark } = useTheme();
@@ -13,7 +13,7 @@ export function useSqlHighlighter() {
});
function highlight(sql: string): string {
return sqlHighlighter.value?.(sql) ?? sql;
return sqlHighlighter.value?.(sql) ?? escapeHtml(sql);
}
return { highlight, sqlHighlighter };
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { buildDeleteRowConfirmDetails } from "../dataGridDetail";
type TestRow = string[];
function testRowFormatter(row: TestRow, columnIndex: number): string {
return `FORMATTED(${row[columnIndex]})`;
}
describe("buildDeleteRowConfirmDetails", () => {
const rows = new Map<number, TestRow>([
[1, ["1", "Alice"]],
[2, ["2", "Bob"]],
[3, ["3", "Carol"]],
]);
const columns = ["id", "name"];
const getRow = (rowId: number) => rows.get(rowId);
it("appends a single formatted row line under the header", () => {
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [1],
columns,
getRow,
formatCell: testRowFormatter,
});
expect(result).toBe('Table: people\n{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}');
});
it("appends one line per row, in the given order, for multi-row deletes", () => {
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [2, 1, 3],
columns,
getRow,
formatCell: testRowFormatter,
});
expect(result).toBe(["Table: people", '{"id":"FORMATTED(2)","name":"FORMATTED(Bob)"}', '{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}', '{"id":"FORMATTED(3)","name":"FORMATTED(Carol)"}'].join("\n"));
});
it("skips row ids that no longer resolve instead of throwing or inserting a blank line", () => {
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [1, 999, 2],
columns,
getRow,
formatCell: testRowFormatter,
});
expect(result).toBe(["Table: people", '{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}', '{"id":"FORMATTED(2)","name":"FORMATTED(Bob)"}'].join("\n"));
});
it("falls back to just the header when no rows resolve", () => {
const result = buildDeleteRowConfirmDetails({
header: "Current result row",
rowIds: [],
columns,
getRow,
formatCell: testRowFormatter,
});
expect(result).toBe("Current result row");
});
it("does not truncate the row list itself, leaving that to DangerConfirmDialog's own bounded preview", () => {
const manyRows = new Map<number, TestRow>();
const manyRowIds: number[] = [];
for (let id = 1; id <= 500; id += 1) {
manyRows.set(id, [String(id), `Name${id}`]);
manyRowIds.push(id);
}
const getManyRows = (rowId: number) => manyRows.get(rowId);
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: manyRowIds,
columns,
getRow: getManyRows,
formatCell: testRowFormatter,
});
const lines = result.split("\n");
expect(lines).toHaveLength(501); // header + 500 rows
expect(lines).toContain('{"id":"FORMATTED(1)","name":"FORMATTED(Name1)"}');
expect(lines).toContain('{"id":"FORMATTED(500)","name":"FORMATTED(Name500)"}');
});
it("keeps embedded separators and line breaks inside one structured row", () => {
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [1],
columns: ["id", "name"],
getRow: () => ["1\nid=999", "Alice, role=admin"],
formatCell: (row, columnIndex) => row[columnIndex],
});
expect(result).toBe('Table: people\n{"id":"1\\nid=999","name":"Alice, role=admin"}');
expect(result.split("\n")).toHaveLength(2);
});
it("keeps the complete formatted value instead of truncating confirmation data", () => {
const longValue = `${"x".repeat(300)}A`;
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [1],
columns: ["payload"],
getRow: () => [longValue],
formatCell: (row, columnIndex) => row[columnIndex],
});
expect(result).toContain(longValue);
expect(result).not.toContain(`${"x".repeat(256)}...`);
});
it("formats every cell through the provided formatCell callback rather than stringifying values itself", () => {
let calls = 0;
const result = buildDeleteRowConfirmDetails({
header: "Table: people",
rowIds: [1],
columns,
getRow,
formatCell: (row, columnIndex) => {
calls += 1;
return `custom:${row[columnIndex]}`;
},
});
expect(calls).toBe(columns.length);
expect(result).toBe('Table: people\n{"id":"custom:1","name":"custom:Alice"}');
});
});
@@ -228,6 +228,22 @@ export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string {
return detail.fields.map((field) => displayCellValue(field.value)).join("\n");
}
export interface BuildDeleteRowConfirmDetailsOptions<TRow> {
header: string;
rowIds: readonly number[];
columns: readonly string[];
getRow: (rowId: number) => TRow | undefined;
formatCell: (row: TRow, columnIndex: number) => string;
}
export function buildDeleteRowConfirmDetails<TRow>(options: BuildDeleteRowConfirmDetailsOptions<TRow>): string {
const rowLines = options.rowIds
.map((rowId) => options.getRow(rowId))
.filter((row): row is TRow => row !== undefined)
.map((row) => JSON.stringify(Object.fromEntries(options.columns.map((name, columnIndex) => [name, options.formatCell(row, columnIndex)]))));
return rowLines.length > 0 ? [options.header, ...rowLines].join("\n") : options.header;
}
export function filterDataGridDetailFields<T extends DataGridCellDetail>(fields: readonly T[], keyword: string): T[] {
if (keyword.trim() === "") return [...fields];
const kw = keyword.trim().toLowerCase();