mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(grid): show deleted row details in confirmation
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user