Merge pull request #804 from serenez/fix/528-detail-dialog-ui

fix(grid): polish cell/row/column detail dialogs (#528)
This commit is contained in:
skyler 2026-06-07 09:30:38 +08:00 committed by GitHub
commit 795806186d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 193 additions and 46 deletions

View File

@ -138,6 +138,7 @@ import {
dataGridColumnDetailTsv,
dataGridRowDetailJson,
dataGridRowDetailTsv,
filterDataGridDetailFields,
type DataGridCellDetail,
} from "@/lib/dataGridDetail";
import {
@ -428,6 +429,9 @@ const rowDetailDialogOpen = ref(false);
const rowDetailDialogRowId = ref<number | null>(null);
const columnDetailDialogOpen = ref(false);
const columnDetailDialogColumnIndex = ref<number | null>(null);
const cellDetailJsonView = ref(false);
const rowDetailSearch = ref("");
const columnDetailSearch = ref("");
const isResizingDetail = ref(false);
const imagePreviewOpen = ref(false);
const imagePreviewSrc = ref("");
@ -2699,16 +2703,31 @@ const columnDetail = computed(() => {
});
});
const filteredRowDetailFields = computed(() =>
rowDetail.value ? filterDataGridDetailFields(rowDetail.value.fields, rowDetailSearch.value) : [],
);
const filteredColumnDetailFields = computed(() =>
columnDetail.value ? filterDataGridDetailFields(columnDetail.value.fields, columnDetailSearch.value) : [],
);
watch(cellDetailDialogOpen, (open) => {
if (!open) cellDetailDialogTarget.value = null;
if (open) cellDetailJsonView.value = false;
else cellDetailDialogTarget.value = null;
});
watch(rowDetailDialogOpen, (open) => {
if (!open) rowDetailDialogRowId.value = null;
if (!open) {
rowDetailDialogRowId.value = null;
rowDetailSearch.value = "";
}
});
watch(columnDetailDialogOpen, (open) => {
if (!open) columnDetailDialogColumnIndex.value = null;
if (!open) {
columnDetailDialogColumnIndex.value = null;
columnDetailSearch.value = "";
}
});
watch(sideGeometryPreviewOpen, async (open) => {
@ -4469,6 +4488,14 @@ function copyDialogCellFormattedJson() {
copyText(detail.formattedJson);
}
function copyDialogCellCurrentValue() {
if (cellDetailJsonView.value && dialogCellDetail.value?.formattedJson) {
copyDialogCellFormattedJson();
} else {
copyDialogCellValue();
}
}
function copyDialogCellColumnName() {
const detail = dialogCellDetail.value;
if (!detail) return;
@ -7999,6 +8026,17 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="flex items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="flex items-center gap-1">
<Button
v-if="dialogCellDetail.formattedJson"
:variant="cellDetailJsonView ? 'secondary' : 'ghost'"
size="sm"
class="h-6 gap-1 px-2 text-xs"
:title="t('grid.formattedJson')"
@click="cellDetailJsonView = !cellDetailJsonView"
>
<Code2 class="h-3 w-3" />
{{ t("grid.formattedJson") }}
</Button>
<Button
v-if="dialogCellDetail.isEditable"
variant="ghost"
@ -8014,7 +8052,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
size="icon"
class="h-6 w-6"
:title="t('grid.copyValue')"
@click="copyDialogCellValue"
@click="copyDialogCellCurrentValue"
>
<Copy class="h-3 w-3" />
</Button>
@ -8079,46 +8117,37 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<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.rawValuePreview }}</pre
>{{
cellDetailJsonView && dialogCellDetail.formattedJson
? dialogCellDetail.formattedJson
: dialogCellDetail.rawValuePreview
}}</pre
>
<div
v-if="dialogCellDetail.isValuePreviewTruncated && !cellDetailJsonView"
class="text-[11px] text-muted-foreground"
>
<div v-if="dialogCellDetail.isValuePreviewTruncated" class="text-[11px] text-muted-foreground">
{{ t("grid.largeValuePreviewHint", { count: dialogCellDetail.rawValuePreview.length }) }}
</div>
</div>
<div v-if="dialogCellDetail.displayValuePreview !== dialogCellDetail.rawValuePreview" class="space-y-1">
<div
v-if="dialogCellDetail.displayValuePreview !== dialogCellDetail.rawValuePreview && !cellDetailJsonView"
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.displayValuePreview
}}</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
class="max-h-40 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words"
>{{ dialogCellDetail.displayValuePreview }}</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="copyDialogCellColumnName">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
</Button>
</div>
<div class="flex flex-wrap gap-2"></div>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyDialogCellColumnName">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -8132,8 +8161,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</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 class="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span class="whitespace-nowrap">{{ t("grid.columnsCount", { count: rowDetail.fields.length }) }}</span>
<div class="relative ml-auto w-56 max-w-full">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="rowDetailSearch"
:placeholder="t('grid.detailSearchPlaceholder')"
class="h-7 pl-7 text-xs"
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded border">
@ -8148,13 +8187,13 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</thead>
<tbody>
<tr
v-for="(field, fieldIndex) in rowDetail.fields"
v-for="(field, fieldIndex) in filteredRowDetailFields"
: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="font-medium break-words">{{ field.column }}</div>
<div
:class="field.type ? typeColorClass(field.type) : 'text-muted-foreground'"
class="mt-1 text-[11px]"
@ -8165,7 +8204,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ field.comment }}
</div>
</td>
<td class="min-w-0 px-3 py-2">
<td class="w-full max-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 }}
@ -8216,15 +8255,23 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</tr>
</tbody>
</table>
<div
v-if="rowDetailSearch && !filteredRowDetailFields.length"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
{{ t("grid.detailSearchNoMatch") }}
</div>
</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 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="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>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@ -8261,6 +8308,20 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span class="whitespace-nowrap">{{ t("grid.rowCount") }}: {{ columnDetail.fields.length }}</span>
<div class="relative ml-auto w-56 max-w-full">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="columnDetailSearch"
:placeholder="t('grid.detailSearchPlaceholder')"
class="h-7 pl-7 text-xs"
/>
</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">
@ -8272,12 +8333,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</thead>
<tbody>
<tr
v-for="field in columnDetail.fields"
v-for="field in filteredColumnDetailFields"
: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">
<td class="w-full max-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 }}
@ -8328,6 +8389,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</tr>
</tbody>
</table>
<div
v-if="columnDetailSearch && !filteredColumnDetailFields.length"
class="px-3 py-6 text-center text-xs text-muted-foreground"
>
{{ t("grid.detailSearchNoMatch") }}
</div>
</div>
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@ -595,6 +595,8 @@ export default {
noComment: "No comment",
formattedJson: "Formatted JSON",
formattedValue: "Formatted Value",
detailSearchPlaceholder: "Search field or value…",
detailSearchNoMatch: "No matches",
rawValue: "Raw Value",
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
copyValue: "Copy Value",

View File

@ -561,6 +561,8 @@ export default {
noComment: "Sin comentario",
formattedJson: "JSON formateado",
formattedValue: "Valor formateado",
detailSearchPlaceholder: "Buscar campo o valor…",
detailSearchNoMatch: "Sin coincidencias",
rawValue: "Valor sin procesar",
largeValuePreviewHint: "Mostrando los primeros {count} caracteres. Copiar sigue usando el valor completo.",
copyValue: "Copiar valor",

View File

@ -600,6 +600,8 @@ export default {
noComment: "Nessun commento",
formattedJson: "JSON Formattato",
formattedValue: "Valore Formattato",
detailSearchPlaceholder: "Cerca campo o valore…",
detailSearchNoMatch: "Nessun risultato",
rawValue: "Valore Grezzo",
largeValuePreviewHint: "Anteprima dei primi {count} caratteri. La copia utilizzerà comunque il valore completo.",
copyValue: "Copia Valore",

View File

@ -599,6 +599,8 @@ export default {
noComment: "Sem comentário",
formattedJson: "JSON Formatado",
formattedValue: "Valor Formatado",
detailSearchPlaceholder: "Buscar campo ou valor…",
detailSearchNoMatch: "Nenhuma correspondência",
rawValue: "Valor Bruto",
largeValuePreviewHint: "Visualizando os primeiros {count} caracteres. A cópia ainda usa o valor completo.",
copyValue: "Copiar Valor",

View File

@ -590,6 +590,8 @@ export default {
noComment: "暂无注释",
formattedJson: "格式化 JSON",
formattedValue: "格式化值",
detailSearchPlaceholder: "搜索字段名或值…",
detailSearchNoMatch: "无匹配项",
rawValue: "原始值",
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
copyValue: "复制值",

View File

@ -590,6 +590,8 @@ export default {
noComment: "暫無註解",
formattedJson: "格式化 JSON",
formattedValue: "格式化值",
detailSearchPlaceholder: "搜尋欄位名或值…",
detailSearchNoMatch: "無相符項目",
rawValue: "原始值",
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
copyValue: "複製值",

View File

@ -189,6 +189,18 @@ export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string {
return detail.fields.map((field) => displayCellValue(field.value)).join("\n");
}
export function filterDataGridDetailFields<T extends DataGridCellDetail>(fields: readonly T[], keyword: string): T[] {
if (keyword.trim() === "") return [...fields];
const kw = keyword.trim().toLowerCase();
return fields.filter(
(field) =>
field.column.toLowerCase().includes(kw) ||
field.rawValuePreview.toLowerCase().includes(kw) ||
field.displayValuePreview.toLowerCase().includes(kw) ||
String(field.rowNumber).includes(kw),
);
}
function looksLikeJsonContainer(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");

View File

@ -9,6 +9,8 @@ import {
buildDataGridRowDetail,
dataGridRowDetailJson,
dataGridRowDetailTsv,
filterDataGridDetailFields,
type DataGridCellDetail,
} from "../../apps/desktop/src/lib/dataGridDetail.ts";
import type { CellValue } from "../../apps/desktop/src/lib/cellValue.ts";
@ -219,3 +221,57 @@ test("dataGridColumnDetailJson and dataGridColumnDetailTsv format copy payloads"
);
assert.equal(dataGridColumnDetailTsv(detail), "Ada\nNULL");
});
const detailFields: DataGridCellDetail[] = [
{ rowNumber: 1, column: "id", rawValuePreview: "7", displayValuePreview: "7" } as DataGridCellDetail,
{ rowNumber: 2, column: "Email", rawValuePreview: "ada@example.com", displayValuePreview: "ada@example.com" } as DataGridCellDetail,
{ rowNumber: 3, column: "status", rawValuePreview: "ACTIVE", displayValuePreview: "Active" } as DataGridCellDetail,
];
test("filterDataGridDetailFields returns all fields for an empty keyword", () => {
const result = filterDataGridDetailFields(detailFields, " ");
assert.deepEqual(result, detailFields);
assert.notEqual(result, detailFields);
});
test("filterDataGridDetailFields matches column names case-insensitively", () => {
const result = filterDataGridDetailFields(detailFields, "EMAIL");
assert.deepEqual(
result.map((field) => field.column),
["Email"],
);
});
test("filterDataGridDetailFields matches raw value previews", () => {
const result = filterDataGridDetailFields(detailFields, "example.com");
assert.deepEqual(
result.map((field) => field.column),
["Email"],
);
});
test("filterDataGridDetailFields matches row numbers", () => {
const result = filterDataGridDetailFields(detailFields, "3");
assert.deepEqual(
result.map((field) => field.rowNumber),
[3],
);
});
test("filterDataGridDetailFields returns an empty array when nothing matches", () => {
const result = filterDataGridDetailFields(detailFields, "nope");
assert.deepEqual(result, []);
});
test("filterDataGridDetailFields does not mutate the input array", () => {
const original = [...detailFields];
const result = filterDataGridDetailFields(detailFields, "id");
assert.deepEqual(detailFields, original);
assert.notEqual(result, detailFields);
});