diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9e75683fe..d494d251e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -42,6 +42,7 @@ import { KeyRound, Link2, ListTree, + Maximize2, TableProperties, LockKeyhole, } from "lucide-vue-next"; @@ -59,6 +60,7 @@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/compon import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue"; import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; @@ -94,7 +96,6 @@ import { } from "@/lib/dataGridTranspose"; import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; -import { cellImagePreviewUrl } from "@/lib/cellImageUrl"; import { canFormatCellDetailJson, cellDetailEditorText, @@ -105,6 +106,16 @@ import { visibleCellDetailTabs, type CellDetailTab, } from "@/lib/cellDetailPresentation"; +import { + buildDataGridCellDetail, + buildDataGridColumnDetail, + buildDataGridRowDetail, + dataGridColumnDetailJson, + dataGridColumnDetailTsv, + dataGridRowDetailJson, + dataGridRowDetailTsv, + type DataGridCellDetail, +} from "@/lib/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, @@ -310,10 +321,17 @@ function typeColorClass(t: string): string { } const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null); const contextHeaderColumn = ref(null); +const contextHeaderColumnIndex = ref(null); const detailCell = ref<{ rowIndex: number; col: number } | null>(null); const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null); const showCellDetail = ref(false); const activeCellDetailTab = ref(defaultCellDetailTab()); +const cellDetailDialogOpen = ref(false); +const cellDetailDialogTarget = ref<{ rowIndex: number; col: number } | null>(null); +const rowDetailDialogOpen = ref(false); +const rowDetailDialogRowId = ref(null); +const columnDetailDialogOpen = ref(false); +const columnDetailDialogColumnIndex = ref(null); const detailWidth = ref(320); const isResizingDetail = ref(false); const imagePreviewOpen = ref(false); @@ -2060,41 +2078,77 @@ const contextCellValue = computed(() => { if (!contextCell.value || contextCell.value.col < 0) return null; return contextRowItem.value?.data[contextCell.value.col] ?? null; }); +function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null { + const item = displayItems.value[rowIndex]; + if (!item) return null; + return buildDataGridCellDetail({ + rowIndex, + rowId: item.id, + row: item.data, + columns: props.result.columns, + columnIndex, + typeByColumn: columnTypeMap.value, + commentByColumn: columnCommentMap.value, + displayValue: (value, index) => formatCell(value, index), + isEditable: canEditCellItem(item, columnIndex), + }); +} + const activeCellDetail = computed(() => { const cell = detailCell.value; - if (!cell) return null; - const item = displayItems.value[cell.rowIndex]; - const column = props.result.columns[cell.col]; - if (!item || !column) return null; - const value = item.data[cell.col] ?? null; - const rawValue = displayCellValue(value); - const displayValue = formatCell(value, cell.col); - const valueText = value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value); - const trimmed = valueText.trim(); - const maybeJson = typeof value === "string" && (trimmed.startsWith("{") || trimmed.startsWith("[")); - let formattedJson = ""; - if (maybeJson) { - try { - formattedJson = JSON.stringify(JSON.parse(value), null, 2); - } catch { - formattedJson = ""; - } - } - return { - rowNumber: cell.rowIndex + 1, + return cell ? cellDetailFor(cell.rowIndex, cell.col) : null; +}); + +const dialogCellDetail = computed(() => { + const target = cellDetailDialogTarget.value; + return target ? cellDetailFor(target.rowIndex, target.col) : null; +}); + +const rowDetail = computed(() => { + if (rowDetailDialogRowId.value === null) return null; + const item = getRowItem(rowDetailDialogRowId.value); + if (!item) return null; + return buildDataGridRowDetail({ + rowIndex: item.displayIndex, rowId: item.id, - colIndex: cell.col, - column, - type: columnTypeMap.value.get(column) || "", - comment: columnCommentMap.value.get(column) || "", - value, - rawValue, - displayValue, - imagePreviewUrl: cellImagePreviewUrl(value), - length: value === null ? 0 : String(value).length, - formattedJson, - isEditable: canEditCellItem(item, cell.col), - }; + row: item.data, + columns: props.result.columns, + columnIndexes: displayableColumnIndexes.value, + typeByColumn: columnTypeMap.value, + commentByColumn: columnCommentMap.value, + displayValue: (value, index) => formatCell(value, index), + isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex), + }); +}); + +const columnDetail = computed(() => { + if (columnDetailDialogColumnIndex.value === null) return null; + const columnIndex = columnDetailDialogColumnIndex.value; + return buildDataGridColumnDetail({ + rows: displayItems.value.map((item) => ({ + rowIndex: item.displayIndex, + rowId: item.id, + row: item.data, + isEditable: canEditCellItem(item, columnIndex), + })), + columns: props.result.columns, + columnIndex, + typeByColumn: columnTypeMap.value, + commentByColumn: columnCommentMap.value, + displayValue: (value, index) => formatCell(value, index), + }); +}); + +watch(cellDetailDialogOpen, (open) => { + if (!open) cellDetailDialogTarget.value = null; +}); + +watch(rowDetailDialogOpen, (open) => { + if (!open) rowDetailDialogRowId.value = null; +}); + +watch(columnDetailDialogOpen, (open) => { + if (!open) columnDetailDialogColumnIndex.value = null; }); const activeCellDetailTabs = computed(() => { @@ -2676,6 +2730,71 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, showCellDetails(rowIndex, actualColIdx); } +function openCellDetailDialog(rowIndex: number, columnIndex: number) { + cellDetailDialogTarget.value = { rowIndex, col: columnIndex }; + cellDetailDialogOpen.value = true; +} + +function openColumnDetailDialog(columnIndex: number) { + if (!props.result.columns[columnIndex]) return; + columnDetailDialogColumnIndex.value = columnIndex; + columnDetailDialogOpen.value = true; +} + +function openContextCellDetailDialog() { + const cell = contextCell.value; + if (!cell || cell.col < 0) return; + openCellDetailDialog(cell.rowIndex, cell.col); +} + +function openContextColumnDetailDialog() { + const cell = contextCell.value; + if (cell && cell.col >= 0) { + openColumnDetailDialog(cell.col); + return; + } + if (contextHeaderColumnIndex.value === null) return; + openColumnDetailDialog(contextHeaderColumnIndex.value); +} + +function openActiveCellDetailDialog() { + const detail = activeCellDetail.value; + if (!detail) return; + openCellDetailDialog(detail.rowNumber - 1, detail.colIndex); +} + +function openActiveColumnDetailDialog() { + const detail = activeCellDetail.value; + if (!detail) return; + openColumnDetailDialog(detail.colIndex); +} + +function openRowDetailDialog(rowId: number) { + rowDetailDialogRowId.value = rowId; + rowDetailDialogOpen.value = true; +} + +function openContextRowDetailDialog() { + const cell = contextCell.value; + if (!cell) return; + openRowDetailDialog(cell.rowId); +} + +function openActiveRowDetailDialog() { + const detail = activeCellDetail.value; + if (!detail) return; + openRowDetailDialog(detail.rowId); +} + +function closeDetailDialogs() { + cellDetailDialogOpen.value = false; + cellDetailDialogTarget.value = null; + rowDetailDialogOpen.value = false; + rowDetailDialogRowId.value = null; + columnDetailDialogOpen.value = false; + columnDetailDialogColumnIndex.value = null; +} + function transposeCellIsSelected(rowIndex: number, actualColIdx: number) { const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); return visibleColIdx >= 0 && cellIsSelected(rowIndex, visibleColIdx); @@ -2689,6 +2808,7 @@ function selectTransposeCell(rowIndex: number, actualColIdx: number, event: Mous const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); if (visibleColIdx < 0) return; contextHeaderColumn.value = null; + contextHeaderColumnIndex.value = null; clearRowSelection(); if (event.shiftKey || event.metaKey || event.ctrlKey) { extendCellSelectionTo(rowIndex, visibleColIdx); @@ -3010,6 +3130,69 @@ async function copyDetailSqlCondition() { copyText(detailSqlConditionCopy.value.text); } +function copyDialogCellValue() { + const detail = dialogCellDetail.value; + if (!detail) return; + copyText(detail.value === null ? "" : displayCellValue(detail.value)); +} + +function copyDialogCellFormattedJson() { + const detail = dialogCellDetail.value; + if (!detail?.formattedJson) return; + copyText(detail.formattedJson); +} + +function copyDialogCellColumnName() { + const detail = dialogCellDetail.value; + if (!detail) return; + copyText(detail.column); +} + +function openDialogCellInSidePanel() { + const detail = dialogCellDetail.value; + if (!detail) return; + showCellDetails(detail.rowNumber - 1, detail.colIndex); + cellDetailDialogOpen.value = false; +} + +function copyRowDetailJson() { + const detail = rowDetail.value; + if (!detail) return; + copyText(dataGridRowDetailJson(detail)); +} + +function copyRowDetailTsv() { + const detail = rowDetail.value; + if (!detail) return; + copyText(dataGridRowDetailTsv(detail)); +} + +function copyRowDetailFieldValue(field: DataGridCellDetail) { + copyText(field.value === null ? "" : displayCellValue(field.value)); +} + +function copyColumnDetailJson() { + const detail = columnDetail.value; + if (!detail) return; + copyText(dataGridColumnDetailJson(detail)); +} + +function copyColumnDetailTsv() { + const detail = columnDetail.value; + if (!detail) return; + copyText(dataGridColumnDetailTsv(detail)); +} + +function copyColumnDetailColumnName() { + const detail = columnDetail.value; + if (!detail) return; + copyText(detail.column); +} + +function copyColumnDetailFieldValue(field: DataGridCellDetail) { + copyText(field.value === null ? "" : displayCellValue(field.value)); +} + const TRANSPOSE_RECORD_DEFAULT_WIDTH = 168; const TRANSPOSE_RECORD_MIN_WIDTH = 96; const TRANSPOSE_PINNED_MIN_WIDTH = 104; @@ -3232,6 +3415,7 @@ function selectTransposeRecord(rowIndex: number, event?: MouseEvent) { if (rowIndex < 0 || rowIndex >= displayItems.value.length) return; transposeRowIndex.value = rowIndex; contextHeaderColumn.value = null; + contextHeaderColumnIndex.value = null; const item = displayItems.value[rowIndex]; if (item) { if (event) { @@ -3293,6 +3477,7 @@ watch( clearCellSelection(); clearRowSelection(); closeCellDetails(); + closeDetailDialogs(); if (shouldPreserveTranspose) { applyTransposeState( nextTransposeStateForRecordCount(showTranspose.value, transposeRowIndex.value, displayItems.value.length), @@ -3305,11 +3490,12 @@ watch( ); // --- Context menu handlers --- -function onHeaderContext(col: string) { +function onHeaderContext(col: string, columnIndex: number) { contextCell.value = null; clearCellSelection(); clearRowSelection(); contextHeaderColumn.value = col; + contextHeaderColumnIndex.value = columnIndex; } async function copyHeaderColumn() { if (!contextHeaderColumn.value) return; @@ -3365,6 +3551,7 @@ async function copyAlterColumnSql() { } function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) { contextHeaderColumn.value = null; + contextHeaderColumnIndex.value = null; contextCell.value = { rowId, rowIndex, col: colIdx }; if (hasRowSelection.value && isRowSelected(rowId)) { void prefetchCopyStatements(); @@ -3379,6 +3566,7 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC function onRowContext(rowId: number, rowIndex: number) { contextHeaderColumn.value = null; + contextHeaderColumnIndex.value = null; contextCell.value = { rowId, rowIndex, col: -1 }; if (!isRowSelected(rowId)) { clearCellSelection(); @@ -3870,6 +4058,11 @@ const gridContextMenuItems = computed(() => { // 1. Copy column name if (contextHeaderColumn.value) { items.push({ label: t("grid.copyColumnName"), action: copyHeaderColumn, icon: Copy }); + items.push({ + label: t("grid.openColumnDetailsDialog"), + action: openContextColumnDetailDialog, + icon: TableProperties, + }); if (canCopyAlterColumnSql.value) { items.push({ label: t("grid.copyAlterColumnSql"), action: copyAlterColumnSql, icon: Copy }); } @@ -3891,22 +4084,36 @@ const gridContextMenuItems = computed(() => { items.push({ label: "", separator: true }); } - // 3. Copy submenu + // 3. Detail dialogs + if (contextCell.value) { + if (contextColumn.value) { + items.push({ label: t("grid.openCellDetailsDialog"), action: openContextCellDetailDialog, icon: Maximize2 }); + items.push({ + label: t("grid.openColumnDetailsDialog"), + action: openContextColumnDetailDialog, + icon: TableProperties, + }); + } + items.push({ label: t("grid.openRowDetailsDialog"), action: openContextRowDetailDialog, icon: ListTree }); + items.push({ label: "", separator: true }); + } + + // 4. Copy submenu if (!contextHeaderColumn.value) { items.push(copySubmenu()); } - // 4. Transpose + // 5. Transpose if (contextCell.value) { items.push({ label: t("grid.transpose"), action: openContextTranspose, icon: Rows3 }); } - // 5. Selection submenu + // 6. Selection submenu if (hasCellSelection.value) { items.push(selectionSubmenu()); } - // 6. Row actions + // 7. Row actions if (props.editable && contextRowItem.value) { const labels = rowActionLabels(); items.push({ label: "", separator: true }); @@ -3933,7 +4140,7 @@ const gridContextMenuItems = computed(() => { items.push({ label: "", separator: true }); } - // 7. Export submenu + // 8. Export submenu items.push(exportSubmenu()); return items; @@ -4637,7 +4844,7 @@ const gridContextMenuItems = computed(() => { :style="{ width: `var(--col-w-${colIdx})` }" :data-grid-column-index="actualColumnIndex(colIdx)" @click="selectColumn(colIdx, $event)" - @contextmenu="onHeaderContext(col)" + @contextmenu="onHeaderContext(col, actualColumnIndex(colIdx))" > @@ -5500,6 +5707,33 @@ const gridContextMenuItems = computed(() => {
{{ t("grid.cellDetails") }} + + + @@ -5848,6 +6082,335 @@ const gridContextMenuItems = computed(() => {
+ + + + + + {{ t("grid.cellDetails") }} + + + +
+
+
+
{{ t("grid.columnName") }}
+
{{ dialogCellDetail.column }}
+
+
+
{{ t("grid.rowNumber") }}
+
{{ dialogCellDetail.rowNumber }}
+
+
+
{{ t("grid.columnType") }}
+
+ {{ dialogCellDetail.type || "-" }} +
+
+
+
{{ t("grid.valueLength") }}
+
{{ dialogCellDetail.length }}
+
+
+ +
+
{{ t("grid.columnComment") }}
+
+ {{ dialogCellDetail.comment || t("grid.noComment") }} +
+
+ +
+
{{ t("grid.cellValue") }}
+ + + +
{{ dialogCellDetail.rawValue }}
+
+ +
+
{{ t("grid.formattedValue") }}
+
{{
+              dialogCellDetail.displayValue
+            }}
+
+ +
+
+
{{ t("grid.formattedJson") }}
+ +
+
{{ dialogCellDetail.formattedJson }}
+
+
+ + +
+ + +
+ +
+
+
+ + + + + + + {{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }} + + + +
+ {{ t("grid.columnsCount", { count: rowDetail.fields.length }) }} +
+ +
+ + + + + + + + + + + + + + + + + +
{{ t("grid.fieldIndex") }}{{ t("grid.columnName") }}{{ t("grid.cellValue") }}
{{ fieldIndex + 1 }} +
{{ field.column }}
+
+ {{ field.type || "-" }} +
+
+ {{ field.comment }} +
+
+
+ {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: + {{ field.value === null ? "true" : field.length }} +
+ + + +
{{ field.rawValue }}
+
+
{{ t("grid.formattedJson") }}
+
{{ field.formattedJson }}
+
+
+ +
+
+ + + + + +
+
+ + + + + + + {{ t("grid.columnDetailsFor", { column: columnDetail.column }) }} + + + +
+
+
{{ t("grid.columnName") }}
+
{{ columnDetail.column }}
+
+
+
{{ t("grid.columnType") }}
+
+ {{ columnDetail.type || "-" }} +
+
+
+
{{ t("grid.rowCount") }}
+
{{ columnDetail.fields.length }}
+
+
+
{{ t("grid.columnComment") }}
+
+ {{ columnDetail.comment || t("grid.noComment") }} +
+
+
+ +
+ + + + + + + + + + + + + + + +
{{ t("grid.rowNumber") }}{{ t("grid.cellValue") }}
{{ field.rowNumber }} +
+ {{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}: + {{ field.value === null ? "true" : field.length }} +
+ + + +
{{ field.rawValue }}
+
+
{{ t("grid.formattedJson") }}
+
{{ field.formattedJson }}
+
+
+ +
+
+ + +
+ + +
+ +
+
+
+ ; + commentByColumn?: ReadonlyMap; + displayValue: (value: CellValue, columnIndex: number) => string; + isEditable: boolean; +} + +export interface BuildDataGridRowDetailOptions { + rowIndex: number; + rowId: number; + row: readonly CellValue[]; + columns: readonly string[]; + columnIndexes: readonly number[]; + typeByColumn?: ReadonlyMap; + commentByColumn?: ReadonlyMap; + displayValue: (value: CellValue, columnIndex: number) => string; + isEditableColumn?: (columnIndex: number) => boolean; +} + +export interface BuildDataGridColumnDetailRow { + rowIndex: number; + rowId: number; + row: readonly CellValue[]; + isEditable?: boolean; +} + +export interface BuildDataGridColumnDetailOptions { + rows: readonly BuildDataGridColumnDetailRow[]; + columns: readonly string[]; + columnIndex: number; + typeByColumn?: ReadonlyMap; + commentByColumn?: ReadonlyMap; + displayValue: (value: CellValue, columnIndex: number) => string; +} + +export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions): DataGridCellDetail | null { + const column = options.columns[options.columnIndex]; + if (column === undefined) return null; + + const value = options.row[options.columnIndex] ?? null; + const rawValue = displayCellValue(value); + const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : ""; + + return { + rowNumber: options.rowIndex + 1, + rowId: options.rowId, + colIndex: options.columnIndex, + column, + type: options.typeByColumn?.get(column) ?? "", + comment: options.commentByColumn?.get(column) ?? "", + value, + rawValue, + displayValue: options.displayValue(value, options.columnIndex), + imagePreviewUrl: cellImagePreviewUrl(value), + length: value === null ? 0 : String(value).length, + formattedJson, + isEditable: options.isEditable, + }; +} + +export function buildDataGridColumnDetail(options: BuildDataGridColumnDetailOptions): DataGridColumnDetail | null { + const column = options.columns[options.columnIndex]; + if (column === undefined) return null; + + const fields = options.rows + .map((row) => + buildDataGridCellDetail({ + rowIndex: row.rowIndex, + rowId: row.rowId, + row: row.row, + columns: options.columns, + columnIndex: options.columnIndex, + typeByColumn: options.typeByColumn, + commentByColumn: options.commentByColumn, + displayValue: options.displayValue, + isEditable: row.isEditable ?? false, + }), + ) + .filter((field): field is DataGridCellDetail => field !== null); + + return { + colIndex: options.columnIndex, + column, + type: options.typeByColumn?.get(column) ?? "", + comment: options.commentByColumn?.get(column) ?? "", + fields, + }; +} + +export function buildDataGridRowDetail(options: BuildDataGridRowDetailOptions): DataGridRowDetail { + const fields = options.columnIndexes + .map((columnIndex) => + buildDataGridCellDetail({ + rowIndex: options.rowIndex, + rowId: options.rowId, + row: options.row, + columns: options.columns, + columnIndex, + typeByColumn: options.typeByColumn, + commentByColumn: options.commentByColumn, + displayValue: options.displayValue, + isEditable: options.isEditableColumn?.(columnIndex) ?? false, + }), + ) + .filter((field): field is DataGridCellDetail => field !== null); + + return { + rowNumber: options.rowIndex + 1, + rowId: options.rowId, + fields, + }; +} + +export function dataGridRowDetailJson(detail: DataGridRowDetail): string { + const row: Record = {}; + detail.fields.forEach((field) => { + row[field.column] = field.value; + }); + return JSON.stringify(row, null, 2); +} + +export function dataGridRowDetailTsv(detail: DataGridRowDetail): string { + return detail.fields.map((field) => displayCellValue(field.value)).join("\t"); +} + +export function dataGridColumnDetailJson(detail: DataGridColumnDetail): string { + return JSON.stringify( + detail.fields.map((field) => ({ + row: field.rowNumber, + value: field.value, + })), + null, + 2, + ); +} + +export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string { + return detail.fields.map((field) => displayCellValue(field.value)).join("\n"); +} + +function looksLikeJsonContainer(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith("{") || trimmed.startsWith("["); +} diff --git a/packages/app-tests/dataGridDetail.test.ts b/packages/app-tests/dataGridDetail.test.ts new file mode 100644 index 000000000..63e3680a9 --- /dev/null +++ b/packages/app-tests/dataGridDetail.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDataGridCellDetail, + buildDataGridColumnDetail, + dataGridColumnDetailJson, + dataGridColumnDetailTsv, + buildDataGridRowDetail, + dataGridRowDetailJson, + dataGridRowDetailTsv, +} from "../../apps/desktop/src/lib/dataGridDetail.ts"; +import type { CellValue } from "../../apps/desktop/src/lib/cellValue.ts"; + +test("buildDataGridCellDetail returns null for an invalid column", () => { + const detail = buildDataGridCellDetail({ + rowIndex: 0, + rowId: 10, + row: ["Ada"], + columns: ["name"], + columnIndex: 2, + displayValue: (value) => String(value), + isEditable: false, + }); + + assert.equal(detail, null); +}); + +test("buildDataGridCellDetail preserves full value metadata", () => { + const typeByColumn = new Map([["payload", "jsonb"]]); + const commentByColumn = new Map([["payload", "raw event payload"]]); + const detail = buildDataGridCellDetail({ + rowIndex: 2, + rowId: 42, + row: [7, '{"ok":true,"items":[1,2]}'], + columns: ["id", "payload"], + columnIndex: 1, + typeByColumn, + commentByColumn, + displayValue: (value) => `formatted:${String(value).slice(0, 8)}`, + isEditable: true, + }); + + assert.deepEqual(detail, { + rowNumber: 3, + rowId: 42, + colIndex: 1, + column: "payload", + type: "jsonb", + comment: "raw event payload", + value: '{"ok":true,"items":[1,2]}', + rawValue: '{"ok":true,"items":[1,2]}', + displayValue: 'formatted:{"ok":tr', + imagePreviewUrl: null, + length: 25, + formattedJson: '{\n "ok": true,\n "items": [\n 1,\n 2\n ]\n}', + isEditable: true, + }); +}); + +test("buildDataGridCellDetail reports image preview URLs", () => { + const detail = buildDataGridCellDetail({ + rowIndex: 0, + rowId: 1, + row: ["https://example.com/avatar.png"], + columns: ["avatar"], + columnIndex: 0, + displayValue: (value) => String(value), + isEditable: false, + }); + + assert.equal(detail?.imagePreviewUrl, "https://example.com/avatar.png"); +}); + +test("buildDataGridRowDetail maps requested column indexes in order", () => { + const row: CellValue[] = ["Ada", null, true]; + const detail = buildDataGridRowDetail({ + rowIndex: 4, + rowId: 99, + row, + columns: ["name", "nickname", "active"], + columnIndexes: [2, 0, 1], + displayValue: (value) => (value === null ? "NULL" : String(value)), + isEditableColumn: (columnIndex) => columnIndex !== 2, + }); + + assert.equal(detail.rowNumber, 5); + assert.equal(detail.rowId, 99); + assert.deepEqual( + detail.fields.map((field) => ({ + column: field.column, + colIndex: field.colIndex, + rawValue: field.rawValue, + isEditable: field.isEditable, + })), + [ + { column: "active", colIndex: 2, rawValue: "true", isEditable: false }, + { column: "name", colIndex: 0, rawValue: "Ada", isEditable: true }, + { column: "nickname", colIndex: 1, rawValue: "NULL", isEditable: true }, + ], + ); +}); + +test("buildDataGridRowDetail keeps duplicate columns as separate fields", () => { + const detail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: ["first", "second"], + columns: ["name", "name"], + columnIndexes: [0, 1], + displayValue: (value) => String(value), + }); + + assert.deepEqual( + detail.fields.map((field) => [field.colIndex, field.column, field.rawValue]), + [ + [0, "name", "first"], + [1, "name", "second"], + ], + ); +}); + +test("dataGridRowDetailJson and dataGridRowDetailTsv format copy payloads", () => { + const detail = buildDataGridRowDetail({ + rowIndex: 0, + rowId: 1, + row: [1, "Ada", null], + columns: ["id", "name", "nickname"], + columnIndexes: [0, 1, 2], + displayValue: (value) => (value === null ? "NULL" : String(value)), + }); + + assert.equal(dataGridRowDetailJson(detail), '{\n "id": 1,\n "name": "Ada",\n "nickname": null\n}'); + assert.equal(dataGridRowDetailTsv(detail), "1\tAda\tNULL"); +}); + +test("buildDataGridColumnDetail maps a whole column across rows", () => { + const typeByColumn = new Map([["name", "varchar"]]); + const commentByColumn = new Map([["name", "display name"]]); + const detail = buildDataGridColumnDetail({ + rows: [ + { rowIndex: 0, rowId: 10, row: [1, "Ada"], isEditable: true }, + { rowIndex: 3, rowId: 13, row: [4, null], isEditable: false }, + ], + columns: ["id", "name"], + columnIndex: 1, + typeByColumn, + commentByColumn, + displayValue: (value) => (value === null ? "NULL" : String(value)), + }); + + assert.equal(detail?.column, "name"); + assert.equal(detail?.type, "varchar"); + assert.equal(detail?.comment, "display name"); + assert.deepEqual( + detail?.fields.map((field) => ({ + rowNumber: field.rowNumber, + rowId: field.rowId, + rawValue: field.rawValue, + isEditable: field.isEditable, + })), + [ + { rowNumber: 1, rowId: 10, rawValue: "Ada", isEditable: true }, + { rowNumber: 4, rowId: 13, rawValue: "NULL", isEditable: false }, + ], + ); +}); + +test("buildDataGridColumnDetail returns null for an invalid column", () => { + const detail = buildDataGridColumnDetail({ + rows: [{ rowIndex: 0, rowId: 1, row: ["Ada"] }], + columns: ["name"], + columnIndex: 3, + displayValue: (value) => String(value), + }); + + assert.equal(detail, null); +}); + +test("dataGridColumnDetailJson and dataGridColumnDetailTsv format copy payloads", () => { + const detail = buildDataGridColumnDetail({ + rows: [ + { rowIndex: 0, rowId: 1, row: [1, "Ada"] }, + { rowIndex: 1, rowId: 2, row: [2, null] }, + ], + columns: ["id", "name"], + columnIndex: 1, + displayValue: (value) => (value === null ? "NULL" : String(value)), + }); + + assert.ok(detail); + assert.equal( + dataGridColumnDetailJson(detail), + '[\n {\n "row": 1,\n "value": "Ada"\n },\n {\n "row": 2,\n "value": null\n }\n]', + ); + assert.equal(dataGridColumnDetailTsv(detail), "Ada\nNULL"); +});