fix(grid): add cell row and column detail dialogs (#546)
This commit is contained in:
parent
c95e01e48c
commit
398e040d5c
|
|
@ -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<string | null>(null);
|
||||
const contextHeaderColumnIndex = ref<number | null>(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<CellDetailTab>(defaultCellDetailTab());
|
||||
const cellDetailDialogOpen = ref(false);
|
||||
const cellDetailDialogTarget = ref<{ rowIndex: number; col: number } | null>(null);
|
||||
const rowDetailDialogOpen = ref(false);
|
||||
const rowDetailDialogRowId = ref<number | null>(null);
|
||||
const columnDetailDialogOpen = ref(false);
|
||||
const columnDetailDialogColumnIndex = ref<number | null>(null);
|
||||
const detailWidth = ref(320);
|
||||
const isResizingDetail = ref(false);
|
||||
const imagePreviewOpen = ref(false);
|
||||
|
|
@ -2060,41 +2078,77 @@ const contextCellValue = computed<CellValue | null>(() => {
|
|||
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<ContextMenuItem[]>(() => {
|
|||
// 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<ContextMenuItem[]>(() => {
|
|||
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<ContextMenuItem[]>(() => {
|
|||
items.push({ label: "", separator: true });
|
||||
}
|
||||
|
||||
// 7. Export submenu
|
||||
// 8. Export submenu
|
||||
items.push(exportSubmenu());
|
||||
|
||||
return items;
|
||||
|
|
@ -4637,7 +4844,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:data-grid-column-index="actualColumnIndex(colIdx)"
|
||||
@click="selectColumn(colIdx, $event)"
|
||||
@contextmenu="onHeaderContext(col)"
|
||||
@contextmenu="onHeaderContext(col, actualColumnIndex(colIdx))"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -5500,6 +5707,33 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
|
||||
<Info class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:title="t('grid.openCellDetailsDialog')"
|
||||
@click="openActiveCellDetailDialog"
|
||||
>
|
||||
<Maximize2 class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:title="t('grid.openRowDetailsDialog')"
|
||||
@click="openActiveRowDetailDialog"
|
||||
>
|
||||
<ListTree class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:title="t('grid.openColumnDetailsDialog')"
|
||||
@click="openActiveColumnDetailDialog"
|
||||
>
|
||||
<TableProperties class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeCellDetails">
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
|
|
@ -5848,6 +6082,335 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="cellDetailDialogOpen">
|
||||
<DialogContent v-if="dialogCellDetail" class="sm:max-w-[840px] max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader class="shrink-0 pr-8">
|
||||
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Info class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto pr-1 text-xs space-y-4">
|
||||
<div class="grid gap-3 rounded border bg-muted/20 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="font-medium break-all">{{ dialogCellDetail.column }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
|
||||
<div>{{ dialogCellDetail.rowNumber }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div :class="dialogCellDetail.type ? typeColorClass(dialogCellDetail.type) : 'text-muted-foreground'">
|
||||
{{ dialogCellDetail.type || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
<div>{{ dialogCellDetail.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
|
||||
<div class="whitespace-pre-wrap break-words">
|
||||
{{ dialogCellDetail.comment || t("grid.noComment") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<a
|
||||
v-if="dialogCellDetail.imagePreviewUrl"
|
||||
:href="dialogCellDetail.imagePreviewUrl"
|
||||
role="button"
|
||||
class="block max-h-72 overflow-hidden rounded border bg-muted/20"
|
||||
@click.prevent="openImagePreview(dialogCellDetail.imagePreviewUrl, dialogCellDetail.column)"
|
||||
>
|
||||
<img
|
||||
:src="dialogCellDetail.imagePreviewUrl"
|
||||
:alt="dialogCellDetail.column"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
class="max-h-72 w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
<pre
|
||||
class="max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
:class="{ 'italic text-muted-foreground': dialogCellDetail.value === null }"
|
||||
>{{ dialogCellDetail.rawValue }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogCellDetail.displayValue !== dialogCellDetail.rawValue" class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedValue") }}</div>
|
||||
<pre class="max-h-40 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">{{
|
||||
dialogCellDetail.displayValue
|
||||
}}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogCellDetail.formattedJson" class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyDialogCellFormattedJson"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap"
|
||||
>{{ dialogCellDetail.formattedJson }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellValue">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyValue") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellColumnName">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="dialogCellDetail.isEditable"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="openDialogCellInSidePanel"
|
||||
>
|
||||
<Pencil class="mr-1.5 h-3 w-3" /> {{ t("grid.editValue") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="rowDetailDialogOpen">
|
||||
<DialogContent v-if="rowDetail" class="sm:max-w-[960px] max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader class="shrink-0 pr-8">
|
||||
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ListTree class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 truncate">{{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ t("grid.columnsCount", { count: rowDetail.fields.length }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded border">
|
||||
<table class="w-full min-w-[640px] text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted/80 text-muted-foreground backdrop-blur">
|
||||
<tr class="border-b">
|
||||
<th class="w-16 px-3 py-2 text-left font-medium">{{ t("grid.fieldIndex") }}</th>
|
||||
<th class="w-56 px-3 py-2 text-left font-medium">{{ t("grid.columnName") }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t("grid.cellValue") }}</th>
|
||||
<th class="w-10 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(field, fieldIndex) in rowDetail.fields"
|
||||
:key="`${field.colIndex}:${field.column}`"
|
||||
class="border-b align-top last:border-b-0"
|
||||
>
|
||||
<td class="px-3 py-2 text-muted-foreground tabular-nums">{{ fieldIndex + 1 }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium break-all">{{ field.column }}</div>
|
||||
<div
|
||||
:class="field.type ? typeColorClass(field.type) : 'text-muted-foreground'"
|
||||
class="mt-1 text-[11px]"
|
||||
>
|
||||
{{ field.type || "-" }}
|
||||
</div>
|
||||
<div v-if="field.comment" class="mt-1 text-[11px] text-muted-foreground whitespace-pre-wrap">
|
||||
{{ field.comment }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-0 px-3 py-2">
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">
|
||||
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}:
|
||||
{{ field.value === null ? "true" : field.length }}
|
||||
</div>
|
||||
<a
|
||||
v-if="field.imagePreviewUrl"
|
||||
:href="field.imagePreviewUrl"
|
||||
role="button"
|
||||
class="mb-2 block max-h-48 overflow-hidden rounded border bg-muted/20"
|
||||
@click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
|
||||
>
|
||||
<img
|
||||
:src="field.imagePreviewUrl"
|
||||
:alt="field.column"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
class="max-h-48 w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
<pre
|
||||
class="max-h-44 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
:class="{ 'italic text-muted-foreground': field.value === null }"
|
||||
>{{ field.rawValue }}</pre
|
||||
>
|
||||
<div v-if="field.formattedJson" class="mt-2 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<pre
|
||||
class="max-h-44 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>{{ field.formattedJson }}</pre
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyRowDetailFieldValue(field)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="shrink-0 justify-start gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyRowDetailJson">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyRow") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyRowDetailTsv">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyRowTsv") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="columnDetailDialogOpen">
|
||||
<DialogContent v-if="columnDetail" class="sm:max-w-[900px] max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader class="shrink-0 pr-8">
|
||||
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
|
||||
<TableProperties class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 truncate">{{ t("grid.columnDetailsFor", { column: columnDetail.column }) }}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid shrink-0 gap-3 rounded border bg-muted/20 p-3 text-xs sm:grid-cols-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="font-medium break-all">{{ columnDetail.column }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div :class="columnDetail.type ? typeColorClass(columnDetail.type) : 'text-muted-foreground'">
|
||||
{{ columnDetail.type || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.rowCount") }}</div>
|
||||
<div>{{ columnDetail.fields.length }}</div>
|
||||
</div>
|
||||
<div class="space-y-1 sm:col-span-3">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
|
||||
<div class="whitespace-pre-wrap break-words">
|
||||
{{ columnDetail.comment || t("grid.noComment") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded border">
|
||||
<table class="w-full min-w-[500px] text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted/80 text-muted-foreground backdrop-blur">
|
||||
<tr class="border-b">
|
||||
<th class="w-24 px-3 py-2 text-left font-medium">{{ t("grid.rowNumber") }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t("grid.cellValue") }}</th>
|
||||
<th class="w-10 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in columnDetail.fields"
|
||||
:key="`${field.rowId}:${field.colIndex}`"
|
||||
class="border-b align-top last:border-b-0"
|
||||
>
|
||||
<td class="px-3 py-2 tabular-nums">{{ field.rowNumber }}</td>
|
||||
<td class="min-w-0 px-3 py-2">
|
||||
<div class="mb-1 text-[11px] text-muted-foreground">
|
||||
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}:
|
||||
{{ field.value === null ? "true" : field.length }}
|
||||
</div>
|
||||
<a
|
||||
v-if="field.imagePreviewUrl"
|
||||
:href="field.imagePreviewUrl"
|
||||
role="button"
|
||||
class="mb-2 block max-h-40 overflow-hidden rounded border bg-muted/20"
|
||||
@click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
|
||||
>
|
||||
<img
|
||||
:src="field.imagePreviewUrl"
|
||||
:alt="field.column"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
class="max-h-40 w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
:class="{ 'italic text-muted-foreground': field.value === null }"
|
||||
>{{ field.rawValue }}</pre
|
||||
>
|
||||
<div v-if="field.formattedJson" class="mt-2 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>{{ field.formattedJson }}</pre
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="t('grid.copyValue')"
|
||||
@click="copyColumnDetailFieldValue(field)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyColumnDetailJson">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnValues") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyColumnDetailTsv">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnTsv") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyColumnDetailColumnName">
|
||||
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDeleteRowConfirm"
|
||||
:message="
|
||||
|
|
|
|||
|
|
@ -519,6 +519,16 @@ export default {
|
|||
pendingChanges: "{count} pending",
|
||||
selectedCells: "{count} selected",
|
||||
cellDetails: "Cell Details",
|
||||
openCellDetailsDialog: "Open Cell Details",
|
||||
rowDetails: "Row Details",
|
||||
rowDetailsFor: "Row {row} Details",
|
||||
openRowDetailsDialog: "Open Row Details",
|
||||
openColumnDetailsDialog: "Open Column Details",
|
||||
columnDetailsFor: "{column} Column Details",
|
||||
columnsCount: "{count} columns",
|
||||
rowCount: "Rows",
|
||||
rowsCount: "{count} rows",
|
||||
fieldIndex: "No.",
|
||||
cellValue: "Value",
|
||||
valueEditor: "Value Editor",
|
||||
imagePreview: "Image Preview",
|
||||
|
|
@ -535,8 +545,12 @@ export default {
|
|||
nullValue: "NULL",
|
||||
noComment: "No comment",
|
||||
formattedJson: "Formatted JSON",
|
||||
formattedValue: "Formatted Value",
|
||||
rawValue: "Raw Value",
|
||||
copyValue: "Copy Value",
|
||||
copyRowTsv: "Copy Row (TSV)",
|
||||
copyColumnValues: "Copy Column (JSON)",
|
||||
copyColumnTsv: "Copy Column (TSV)",
|
||||
editValue: "Edit Value",
|
||||
formatJson: "Format JSON",
|
||||
setNull: "Set NULL",
|
||||
|
|
|
|||
|
|
@ -490,6 +490,16 @@ export default {
|
|||
pendingChanges: "{count} pendientes",
|
||||
selectedCells: "{count} seleccionadas",
|
||||
cellDetails: "Detalles de celda",
|
||||
openCellDetailsDialog: "Abrir detalles de celda",
|
||||
rowDetails: "Detalles de fila",
|
||||
rowDetailsFor: "Detalles de fila {row}",
|
||||
openRowDetailsDialog: "Abrir detalles de fila",
|
||||
openColumnDetailsDialog: "Abrir detalles de columna",
|
||||
columnDetailsFor: "Detalles de columna {column}",
|
||||
columnsCount: "{count} columnas",
|
||||
rowCount: "Filas",
|
||||
rowsCount: "{count} filas",
|
||||
fieldIndex: "N.º",
|
||||
cellValue: "Valor",
|
||||
valueEditor: "Editor de valor",
|
||||
imagePreview: "Vista previa de imagen",
|
||||
|
|
@ -506,8 +516,12 @@ export default {
|
|||
nullValue: "NULL",
|
||||
noComment: "Sin comentario",
|
||||
formattedJson: "JSON formateado",
|
||||
formattedValue: "Valor formateado",
|
||||
rawValue: "Valor sin procesar",
|
||||
copyValue: "Copiar valor",
|
||||
copyRowTsv: "Copiar fila (TSV)",
|
||||
copyColumnValues: "Copiar columna (JSON)",
|
||||
copyColumnTsv: "Copiar columna (TSV)",
|
||||
editValue: "Editar valor",
|
||||
formatJson: "Formatear JSON",
|
||||
setNull: "Establecer NULL",
|
||||
|
|
|
|||
|
|
@ -514,6 +514,16 @@ export default {
|
|||
pendingChanges: "{count} 项待保存",
|
||||
selectedCells: "已选 {count} 个单元格",
|
||||
cellDetails: "单元格详情",
|
||||
openCellDetailsDialog: "单元格详情",
|
||||
rowDetails: "行详情",
|
||||
rowDetailsFor: "第 {row} 行详情",
|
||||
openRowDetailsDialog: "行详情",
|
||||
openColumnDetailsDialog: "列详情",
|
||||
columnDetailsFor: "“{column}”列详情",
|
||||
columnsCount: "{count} 列",
|
||||
rowCount: "行数",
|
||||
rowsCount: "{count} 行",
|
||||
fieldIndex: "序号",
|
||||
cellValue: "值",
|
||||
valueEditor: "值编辑器",
|
||||
imagePreview: "图片预览",
|
||||
|
|
@ -530,8 +540,12 @@ export default {
|
|||
nullValue: "NULL",
|
||||
noComment: "暂无注释",
|
||||
formattedJson: "格式化 JSON",
|
||||
formattedValue: "格式化值",
|
||||
rawValue: "原始值",
|
||||
copyValue: "复制值",
|
||||
copyRowTsv: "复制行 (TSV)",
|
||||
copyColumnValues: "复制列 (JSON)",
|
||||
copyColumnTsv: "复制列 (TSV)",
|
||||
editValue: "编辑值",
|
||||
formatJson: "格式化 JSON",
|
||||
setNull: "设为 NULL",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import { cellImagePreviewUrl } from "@/lib/cellImageUrl";
|
||||
import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import { formatJsonText } from "@/lib/cellDetailPresentation";
|
||||
|
||||
export interface DataGridCellDetail {
|
||||
rowNumber: number;
|
||||
rowId: number;
|
||||
colIndex: number;
|
||||
column: string;
|
||||
type: string;
|
||||
comment: string;
|
||||
value: CellValue;
|
||||
rawValue: string;
|
||||
displayValue: string;
|
||||
imagePreviewUrl: string | null;
|
||||
length: number;
|
||||
formattedJson: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridRowDetail {
|
||||
rowNumber: number;
|
||||
rowId: number;
|
||||
fields: DataGridCellDetail[];
|
||||
}
|
||||
|
||||
export interface DataGridColumnDetail {
|
||||
colIndex: number;
|
||||
column: string;
|
||||
type: string;
|
||||
comment: string;
|
||||
fields: DataGridCellDetail[];
|
||||
}
|
||||
|
||||
export interface BuildDataGridCellDetailOptions {
|
||||
rowIndex: number;
|
||||
rowId: number;
|
||||
row: readonly CellValue[];
|
||||
columns: readonly string[];
|
||||
columnIndex: number;
|
||||
typeByColumn?: ReadonlyMap<string, string>;
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
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<string, string>;
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
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<string, string>;
|
||||
commentByColumn?: ReadonlyMap<string, string>;
|
||||
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<string, CellValue> = {};
|
||||
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("[");
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue