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:
commit
795806186d
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -590,6 +590,8 @@ export default {
|
|||
noComment: "暂无注释",
|
||||
formattedJson: "格式化 JSON",
|
||||
formattedValue: "格式化值",
|
||||
detailSearchPlaceholder: "搜索字段名或值…",
|
||||
detailSearchNoMatch: "无匹配项",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
||||
copyValue: "复制值",
|
||||
|
|
|
|||
|
|
@ -590,6 +590,8 @@ export default {
|
|||
noComment: "暫無註解",
|
||||
formattedJson: "格式化 JSON",
|
||||
formattedValue: "格式化值",
|
||||
detailSearchPlaceholder: "搜尋欄位名或值…",
|
||||
detailSearchNoMatch: "無相符項目",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
||||
copyValue: "複製值",
|
||||
|
|
|
|||
|
|
@ -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("[");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue