feat(grid): decompose data grid interaction surfaces
This commit is contained in:
1 parent
71497f0851
commit
8825b6335f
56 files changed
+6446
-4051
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const value = defineModel<string>("value", { default: "" });
|
||||
defineProps<{ selectedCellCount: number }>();
|
||||
const emit = defineEmits<{ apply: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("grid.bulkEditTitle") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">{{ t("grid.bulkEditDescription", { count: selectedCellCount }) }}</p>
|
||||
<textarea
|
||||
v-model="value"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
rows="5"
|
||||
class="min-h-24 w-full min-w-0 resize-y rounded-[6px] border border-input bg-transparent px-2.5 py-1.5 text-base outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm"
|
||||
:placeholder="t('grid.bulkEditValuePlaceholder')"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button
|
||||
><Button @click="emit('apply')">{{ t("grid.applyBulkEdit") }}</Button></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Code2, Copy, Eye, Info, Pencil, Upload } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { BINARY_CELL_DOWNLOAD_MODES, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import { isHexGeometry, renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
|
||||
const props = defineProps<{
|
||||
detail: DataGridCellDetail | null;
|
||||
typeColorClass: (type: string) => string;
|
||||
openImagePreview: (src: string, title: string) => void;
|
||||
copyText: (text: string) => void;
|
||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const geometryPreviewOpen = ref(false);
|
||||
const geometryCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const jsonPreviewContainer = ref<HTMLElement>();
|
||||
let jsonPreviewEditor: UseCellDetailEditorReturn | null = null;
|
||||
|
||||
const jsonFormatted = computed(() => settingsStore.editorSettings.cellDetailJsonFormatted);
|
||||
const jsonView = computed(() => jsonFormatted.value && !!props.detail?.formattedJson);
|
||||
|
||||
function toggleJsonFormatted() {
|
||||
settingsStore.updateEditorSettings({ cellDetailJsonFormatted: !jsonFormatted.value });
|
||||
}
|
||||
|
||||
function copyCurrentValue() {
|
||||
const detail = props.detail;
|
||||
if (!detail) return;
|
||||
if (jsonView.value && detail.formattedJson) {
|
||||
props.copyText(detail.formattedJson);
|
||||
return;
|
||||
}
|
||||
props.copyText(detail.value === null ? "" : detail.rawValue);
|
||||
}
|
||||
|
||||
function copyColumnName() {
|
||||
if (props.detail) props.copyText(props.detail.column);
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) geometryPreviewOpen.value = false;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.detail,
|
||||
() => {
|
||||
geometryPreviewOpen.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(geometryPreviewOpen, async (isOpen) => {
|
||||
if (!isOpen) return;
|
||||
await nextTick();
|
||||
const canvas = geometryCanvas.value;
|
||||
const detail = props.detail;
|
||||
if (canvas && detail?.value !== null && detail?.value !== undefined) {
|
||||
renderWktOnCanvas(canvas, String(detail.value));
|
||||
}
|
||||
});
|
||||
|
||||
watch(jsonPreviewContainer, async (element) => {
|
||||
if (element && !jsonPreviewEditor) {
|
||||
jsonPreviewEditor = useCellDetailEditor({
|
||||
language: "json",
|
||||
readOnly: true,
|
||||
editorTheme: () => settingsStore.editorSettings.theme,
|
||||
appAppearance: () => (isDark.value ? "dark" : "light"),
|
||||
appPalette: () => themePalette.value,
|
||||
fontSize: () => settingsStore.editorSettings.fontSize,
|
||||
fontFamily: () => settingsStore.editorSettings.fontFamily,
|
||||
});
|
||||
await jsonPreviewEditor.create(element, props.detail?.formattedJson ?? "", "json");
|
||||
} else if (!element && jsonPreviewEditor) {
|
||||
jsonPreviewEditor.destroy();
|
||||
jsonPreviewEditor = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.detail?.formattedJson ?? "",
|
||||
(value) => jsonPreviewEditor?.setValue(value, "json"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent v-if="detail" 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">{{ detail.column }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
|
||||
<div>{{ detail.rowNumber }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div :class="detail.type ? typeColorClass(detail.type) : 'text-muted-foreground'">{{ detail.type || "-" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
<div>{{ detail.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">{{ detail.comment || t("grid.noComment") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<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="detail.formattedJson" :variant="jsonView ? 'secondary' : 'ghost'" size="sm" class="h-6 gap-1 px-2 text-xs" :title="t('grid.formattedJson')" @click="toggleJsonFormatted">
|
||||
<Code2 class="h-3 w-3" />
|
||||
{{ t("grid.formattedJson") }}
|
||||
</Button>
|
||||
<Button v-if="detail.isEditable" variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.editValue')" @click="emit('edit')">
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<DropdownMenu v-if="canDownloadBinaryValue(detail)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.downloadBinaryValue')">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
<DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">
|
||||
{{ t(`grid.binaryDownload.${mode}`) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<!-- Unsupported geometry values can arrive as a hex fallback that cannot be rendered. -->
|
||||
<Popover v-if="isGeometryColumnType(detail.type) && detail.value !== null && !isHexGeometry(detail.value as string)" v-model:open="geometryPreviewOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.geometryPreview')">
|
||||
<Eye class="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-1.5" align="end">
|
||||
<canvas v-show="geometryPreviewOpen" ref="geometryCanvas" width="400" height="280" class="block rounded" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<a v-if="detail.imagePreviewUrl" :href="detail.imagePreviewUrl" role="button" class="block max-h-72 overflow-hidden rounded border bg-muted/20" @click.prevent="openImagePreview(detail.imagePreviewUrl, detail.column)">
|
||||
<img :src="detail.imagePreviewUrl" :alt="detail.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-72 w-full object-contain" />
|
||||
</a>
|
||||
<div v-if="jsonView && detail.formattedJson" ref="jsonPreviewContainer" data-cell-detail-editor-root class="h-[44vh] min-h-60 overflow-hidden rounded border bg-muted/20 p-3" />
|
||||
<pre v-else 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': detail.value === null }">{{ detail.rawValuePreview }}</pre>
|
||||
<div v-if="detail.isValuePreviewTruncated && !jsonView" class="text-[11px] text-muted-foreground">
|
||||
{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}
|
||||
</div>
|
||||
</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="ghost" size="sm" class="h-7 text-xs" @click="copyColumnName">
|
||||
<Copy class="mr-1.5 h-3 w-3" />
|
||||
{{ t("grid.copyColumnName") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from "vue";
|
||||
import { Code2, Copy, Eye, Pencil, Upload, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
|
||||
import { useDataGridCellDetail } from "@/composables/useDataGridCellDetail";
|
||||
import { BINARY_CELL_DOWNLOAD_MODES, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
|
||||
import { isHexGeometry } from "@/lib/dataGrid/geometryPreview";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
import type { TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
detail: DataGridCellDetail;
|
||||
panelIsBottom: boolean;
|
||||
metadataCollapsed: boolean;
|
||||
valueFillsHeight: boolean;
|
||||
editing: boolean;
|
||||
editorStyle?: Record<string, string>;
|
||||
temporalEditorConfig?: TemporalCellEditorConfig;
|
||||
sideJsonView: boolean;
|
||||
showCompactJson: boolean;
|
||||
canCompactJson: boolean;
|
||||
typeColorClass: (type: string) => string;
|
||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
||||
openImagePreview: (src: string, title: string) => void;
|
||||
canCopySqlCondition: () => boolean;
|
||||
}>();
|
||||
|
||||
const detailEditValue = defineModel<string>("value", { default: "" });
|
||||
|
||||
const emit = defineEmits<{
|
||||
startEdit: [];
|
||||
compactJson: [];
|
||||
toggleFormatted: [];
|
||||
copyValue: [];
|
||||
commit: [];
|
||||
cancel: [];
|
||||
setNull: [];
|
||||
copyColumnName: [];
|
||||
copySqlCondition: [];
|
||||
}>();
|
||||
|
||||
const { geometryPreviewOpen, geometryCanvas, detailsEditorContainer, sideJsonPreviewContainer, openSearch } = useDataGridCellDetail({ detail: toRef(props, "detail"), editValue: detailEditValue, onCancel: () => emit("cancel") });
|
||||
void geometryCanvas;
|
||||
void detailsEditorContainer;
|
||||
void sideJsonPreviewContainer;
|
||||
|
||||
defineExpose({ openSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent value="details" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<div data-native-clipboard class="flex-1 min-h-0 overflow-auto px-3 pt-3 text-xs" :class="[valueFillsHeight ? 'flex flex-col gap-3' : 'space-y-3', editing && !panelIsBottom ? 'pb-1' : 'pb-3']">
|
||||
<div v-if="panelIsBottom && !metadataCollapsed" class="grid grid-cols-[minmax(180px,1.6fr)_repeat(4,minmax(74px,0.55fr))_minmax(160px,1fr)] gap-3 rounded border bg-muted/20 p-2">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="truncate font-medium" :title="detail.column">{{ detail.column }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
|
||||
<div>{{ detail.rowNumber }}</div>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div class="truncate" :class="detail.type ? typeColorClass(detail.type) : 'text-muted-foreground'" :title="detail.type || '-'">{{ detail.type || "-" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.nullValue") }}</div>
|
||||
<div>{{ detail.value === null ? "true" : "false" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
<div>{{ detail.length }}</div>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
|
||||
<div class="truncate" :title="detail.comment || t('grid.noComment')">{{ detail.comment || t("grid.noComment") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="!metadataCollapsed && !editing">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="font-medium break-all">{{ detail.column }}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
|
||||
<div>{{ detail.rowNumber }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div :class="detail.type ? typeColorClass(detail.type) : 'text-muted-foreground'">{{ detail.type || "-" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.nullValue") }}</div>
|
||||
<div>{{ detail.value === null ? "true" : "false" }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
|
||||
<div>{{ detail.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">{{ detail.comment || t("grid.noComment") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-1" :class="[{ 'min-h-0 flex flex-col': valueFillsHeight }, valueFillsHeight && !(detail.imagePreviewUrl && !editing) ? 'flex-1' : '', detail.imagePreviewUrl && !editing ? 'shrink-0' : '']">
|
||||
<div class="flex min-h-5 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="showCompactJson" variant="ghost" size="sm" class="h-5 gap-1 px-1.5 text-xs" :disabled="!canCompactJson" :title="t('grid.compactJson')" @click="emit('compactJson')"><Code2 class="h-3 w-3" />{{ t("grid.compactJson") }}</Button>
|
||||
<Button v-if="!editing && detail.formattedJson" :variant="sideJsonView ? 'secondary' : 'ghost'" size="sm" class="h-5 gap-1 px-1.5 text-xs" :title="t('grid.formattedJson')" @click="emit('toggleFormatted')"><Code2 class="h-3 w-3" />{{ t("grid.formattedJson") }}</Button>
|
||||
<Button v-if="!editing && detail.isEditable" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.editValue')" @click="emit('startEdit')"><Pencil class="h-3 w-3" /></Button>
|
||||
<Button v-if="!editing" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.copyValue')" @click="emit('copyValue')"><Copy class="h-3 w-3" /></Button>
|
||||
<DropdownMenu v-if="!editing && canDownloadBinaryValue(detail)"
|
||||
><DropdownMenuTrigger as-child
|
||||
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Upload class="h-3 w-3" /></Button></DropdownMenuTrigger
|
||||
><DropdownMenuContent align="end" class="w-44"
|
||||
><DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">{{ t(`grid.binaryDownload.${mode}`) }}</DropdownMenuItem></DropdownMenuContent
|
||||
></DropdownMenu
|
||||
>
|
||||
<Popover v-if="isGeometryColumnType(detail.type) && detail.value !== null && !editing && !isHexGeometry(detail.value as string)" v-model:open="geometryPreviewOpen"
|
||||
><PopoverTrigger as-child
|
||||
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.geometryPreview')"><Eye class="h-3 w-3" /></Button></PopoverTrigger
|
||||
><PopoverContent class="w-auto p-1.5" align="end"><canvas v-show="geometryPreviewOpen" ref="geometryCanvas" width="400" height="280" class="block rounded" /></PopoverContent
|
||||
></Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.imagePreviewUrl && !editing" class="shrink-0 space-y-1.5">
|
||||
<div class="text-muted-foreground">{{ t("grid.imagePreview") }}</div>
|
||||
<a :href="detail.imagePreviewUrl" role="button" class="flex overflow-hidden rounded border bg-muted/20" :class="panelIsBottom ? 'max-h-28' : 'max-h-40'" @click.prevent="openImagePreview(detail.imagePreviewUrl, detail.column)"
|
||||
><img :src="detail.imagePreviewUrl" :alt="detail.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-full w-full object-contain"
|
||||
/></a>
|
||||
</div>
|
||||
<template v-if="editing"
|
||||
><div class="min-h-0 flex-1" :style="editorStyle">
|
||||
<TemporalCellEditor v-if="temporalEditorConfig" v-model="detailEditValue" :kind="temporalEditorConfig.kind" :fraction-precision="temporalEditorConfig.fractionPrecision" variant="inline" :commit-on-close="false" @cancel="emit('cancel')" @commit="emit('commit')" />
|
||||
<div v-else ref="detailsEditorContainer" data-cell-detail-editor-root class="min-h-0 h-full w-full rounded border overflow-hidden" />
|
||||
</div>
|
||||
<div v-if="!panelIsBottom" class="flex shrink-0 gap-1 py-0.5">
|
||||
<Button size="sm" class="h-6 text-xs" @click="emit('commit')">{{ t("dangerDialog.confirm") }}</Button
|
||||
><Button variant="outline" size="sm" class="h-6 text-xs" @click="emit('cancel')">{{ t("dangerDialog.cancel") }}</Button>
|
||||
</div></template
|
||||
>
|
||||
<div
|
||||
v-else-if="detail.formattedJson"
|
||||
ref="sideJsonPreviewContainer"
|
||||
data-cell-detail-editor-root
|
||||
class="overflow-hidden rounded border bg-muted/20 p-2"
|
||||
:class="[{ 'cursor-text': detail.isEditable }, valueFillsHeight ? 'min-h-0 flex-1' : 'h-72 max-h-[42vh]']"
|
||||
@dblclick.capture="emit('startEdit')"
|
||||
/>
|
||||
<pre
|
||||
v-else
|
||||
class="overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
|
||||
:class="[{ 'cursor-text': detail.isEditable }, panelIsBottom && detail.imagePreviewUrl ? 'min-h-24 max-h-32 shrink-0' : '', valueFillsHeight && !detail.imagePreviewUrl ? 'min-h-0 flex-1' : '']"
|
||||
@dblclick="emit('startEdit')"
|
||||
>{{ detail.rawValuePreview }}</pre
|
||||
>
|
||||
<div v-if="detail.isValuePreviewTruncated && !sideJsonView" class="text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t flex gap-1 overflow-hidden bg-background p-1.5" :class="panelIsBottom ? 'shrink-0 items-center' : 'shrink-0 flex-col'">
|
||||
<div v-if="editing && panelIsBottom" class="flex shrink-0 gap-1 mr-auto">
|
||||
<Button size="sm" class="h-6 text-xs" @click="emit('commit')">{{ t("dangerDialog.confirm") }}</Button
|
||||
><Button variant="outline" size="sm" class="h-6 text-xs" @click="emit('cancel')">{{ t("dangerDialog.cancel") }}</Button>
|
||||
</div>
|
||||
<div class="flex gap-1" :class="panelIsBottom ? 'ml-auto shrink-0 justify-end' : 'flex-col'">
|
||||
<Button v-if="detail.isEditable && detail.value !== null" variant="ghost" size="sm" class="h-6 justify-start text-xs" @click="emit('setNull')"><X class="w-3 h-3 mr-2" />{{ t("grid.setNull") }}</Button
|
||||
><Button variant="ghost" size="sm" class="h-6 justify-start text-xs" @click="emit('copyColumnName')"><Copy class="w-3 h-3 mr-2" />{{ t("grid.copyColumnName") }}</Button
|
||||
><Button variant="ghost" size="sm" class="h-6 justify-start text-xs" :disabled="!canCopySqlCondition()" @click="emit('copySqlCondition')"><Code2 class="w-3 h-3 mr-2" />{{ t("grid.copySqlCondition") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties, HTMLAttributes } from "vue";
|
||||
import { Copy } from "@lucide/vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
|
||||
defineProps<{
|
||||
name: string;
|
||||
actualColumnIndex: number;
|
||||
visibleColumnIndex: number;
|
||||
selected?: boolean;
|
||||
searchMatch?: boolean;
|
||||
dark?: boolean;
|
||||
tooltipDisabled?: boolean;
|
||||
columnType?: string;
|
||||
columnComment?: string;
|
||||
tooltipColumnType?: string;
|
||||
tooltipColumnComment?: string;
|
||||
typeClass?: HTMLAttributes["class"];
|
||||
dragClass?: HTMLAttributes["class"];
|
||||
columnStyle?: CSSProperties;
|
||||
copyColumnNameLabel: string;
|
||||
columnNameLabel: string;
|
||||
columnTypeLabel: string;
|
||||
columnCommentLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pointerdown: [event: PointerEvent];
|
||||
clickCapture: [event: MouseEvent];
|
||||
click: [event: MouseEvent];
|
||||
contextmenu: [event: MouseEvent];
|
||||
resizeStart: [event: MouseEvent];
|
||||
autoFit: [];
|
||||
copyName: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
|
||||
<div
|
||||
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-gray-200 dark:hover:bg-gray-800 select-none relative overflow-hidden"
|
||||
:class="[dark && 'data-grid-header-cell--dark', selected && 'data-grid-header-cell--selected outline outline-primary -outline-offset-1', searchMatch && 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40', dragClass]"
|
||||
:style="columnStyle"
|
||||
:data-grid-column-index="actualColumnIndex"
|
||||
:data-visible-col-index="visibleColumnIndex"
|
||||
@pointerdown="emit('pointerdown', $event)"
|
||||
@click.capture="emit('clickCapture', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@contextmenu="emit('contextmenu', $event)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<span class="min-w-0 truncate leading-4">{{ name }}</span>
|
||||
<span v-if="columnType" class="min-w-0 truncate text-[10px] font-normal leading-3" :class="typeClass" :title="columnType">{{ columnType }}</span>
|
||||
<span v-if="columnComment" class="min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground" :title="columnComment">{{ columnComment }}</span>
|
||||
</span>
|
||||
<slot name="actions" />
|
||||
</span>
|
||||
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="emit('resizeStart', $event)" @click.stop.prevent @dblclick.stop="emit('autoFit')" />
|
||||
</div>
|
||||
<template #content>
|
||||
<div class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">
|
||||
<span class="text-background/70">{{ columnNameLabel }}</span>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ name }}</span>
|
||||
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<template v-if="tooltipColumnType ?? columnType">
|
||||
<span class="text-background/70">{{ columnTypeLabel }}</span>
|
||||
<span :class="typeClass">{{ tooltipColumnType ?? columnType }}</span>
|
||||
</template>
|
||||
<template v-if="tooltipColumnComment ?? columnComment">
|
||||
<span class="text-background/70">{{ columnCommentLabel }}</span>
|
||||
<span>{{ tooltipColumnComment ?? columnComment }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</LightTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-grid-header-cell {
|
||||
background-color: rgb(239, 239, 239);
|
||||
}
|
||||
|
||||
.data-grid-header-cell--dark {
|
||||
background-color: rgb(32, 32, 34) !important;
|
||||
}
|
||||
|
||||
.data-grid-header-cell--dark:hover {
|
||||
background-color: rgb(46, 47, 51) !important;
|
||||
}
|
||||
|
||||
.data-grid-header-cell--selected {
|
||||
background-color: rgb(209, 213, 219) !important;
|
||||
}
|
||||
|
||||
.data-grid-header-cell--dark.data-grid-header-cell--selected {
|
||||
background-color: rgb(66, 67, 70) !important;
|
||||
color: rgb(244, 244, 245) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,540 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch, type CSSProperties } from "vue";
|
||||
import { ChevronDown, X } from "@lucide/vue";
|
||||
import { useDataGridConditionEditor, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
|
||||
import { getDataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: DataGridConditionHistoryKind;
|
||||
columns?: readonly string[];
|
||||
historyScope: DataGridConditionHistoryScope;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
historyEmptyText?: string;
|
||||
historyNoMatchesText?: string;
|
||||
suggestionProvider?: DataGridConditionSuggestionProvider;
|
||||
suggestionDebounceMs?: number;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
apply?: (value: string) => void | boolean | Promise<void | boolean>;
|
||||
clear?: () => void | Promise<void>;
|
||||
}>(),
|
||||
{
|
||||
columns: () => [],
|
||||
placeholder: "",
|
||||
ariaLabel: "",
|
||||
historyEmptyText: "No history yet",
|
||||
historyNoMatchesText: "No matching history",
|
||||
suggestionDebounceMs: 0,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const modelValue = defineModel<string>({ default: "" });
|
||||
const emit = defineEmits<{
|
||||
apply: [value: string];
|
||||
clear: [];
|
||||
}>();
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement>();
|
||||
const suggestionListId = `${useId()}-${props.kind}-condition-suggestions`;
|
||||
const overlayRef = ref<HTMLTextAreaElement>();
|
||||
const controlRef = ref<HTMLDivElement>();
|
||||
const dropdownRef = ref<HTMLDivElement>();
|
||||
const expanded = ref(false);
|
||||
const expandedRect = ref({ left: 0, top: 0, width: 0 });
|
||||
const expandedHeight = ref(56);
|
||||
const suggestionPosition = ref({ left: 0, top: 0, width: 180 });
|
||||
const historyPreview = ref<{ value: string; left: number; top: number; maxWidth: number; arrowTop: number; side: "left" | "right" } | null>(null);
|
||||
let collapseTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: props.kind,
|
||||
value: modelValue,
|
||||
columns: () => props.columns,
|
||||
historyScope: () => props.historyScope,
|
||||
suggestionProvider: props.suggestionProvider,
|
||||
suggestionDebounceMs: props.suggestionDebounceMs,
|
||||
});
|
||||
|
||||
const activeEditor = computed(() => overlayRef.value ?? inputRef.value);
|
||||
const hasValue = computed(() => modelValue.value.trim().length > 0);
|
||||
const emptyHistoryText = computed(() => (modelValue.value.trim() ? props.historyNoMatchesText : props.historyEmptyText));
|
||||
const activeSuggestionId = computed(() => (editor.highlightedIndex.value >= 0 ? `${suggestionListId}-${editor.highlightedIndex.value}` : undefined));
|
||||
const suggestionStyle = computed<CSSProperties>(() => ({
|
||||
left: `${suggestionPosition.value.left}px`,
|
||||
top: `${suggestionPosition.value.top}px`,
|
||||
width: `${suggestionPosition.value.width}px`,
|
||||
}));
|
||||
const overlayStyle = computed<CSSProperties>(() => ({
|
||||
left: `${expandedRect.value.left}px`,
|
||||
top: `${expandedRect.value.top}px`,
|
||||
width: `${expandedRect.value.width}px`,
|
||||
height: `${expandedHeight.value}px`,
|
||||
}));
|
||||
const previewStyle = computed<CSSProperties>(() => {
|
||||
const preview = historyPreview.value;
|
||||
return preview ? { left: `${preview.left}px`, top: `${preview.top}px`, maxWidth: `${preview.maxWidth}px` } : {};
|
||||
});
|
||||
const previewArrowStyle = computed<CSSProperties>(() => ({ top: `${historyPreview.value?.arrowTop ?? 0}px` }));
|
||||
|
||||
function createTextProbe(input: HTMLTextAreaElement, wrap: boolean) {
|
||||
const probe = document.createElement(wrap ? "div" : "span");
|
||||
const style = window.getComputedStyle(input);
|
||||
probe.textContent = input.value || input.placeholder || "";
|
||||
probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${input.clientWidth}px;white-space:pre-wrap;overflow-wrap:anywhere;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`;
|
||||
document.body.appendChild(probe);
|
||||
return probe;
|
||||
}
|
||||
|
||||
function shouldExpand(input: HTMLTextAreaElement) {
|
||||
if (!input.value) return false;
|
||||
const probe = createTextProbe(input, false);
|
||||
const should = probe.getBoundingClientRect().width > input.clientWidth + 1;
|
||||
probe.remove();
|
||||
return should;
|
||||
}
|
||||
|
||||
function measureExpandedHeight(input: HTMLTextAreaElement) {
|
||||
const probe = createTextProbe(input, true);
|
||||
const style = window.getComputedStyle(input);
|
||||
const lineHeight = Number.parseFloat(style.lineHeight) || 24;
|
||||
const contentHeight = probe.scrollHeight;
|
||||
probe.remove();
|
||||
const availableHeight = Math.max(56, window.innerHeight - input.getBoundingClientRect().top - 12);
|
||||
return Math.min(Math.max(56, lineHeight * 2.5, contentHeight), Math.min(260, availableHeight));
|
||||
}
|
||||
|
||||
function updateSuggestionPosition() {
|
||||
void nextTick(() => {
|
||||
const target = activeEditor.value;
|
||||
if (!target) return;
|
||||
suggestionPosition.value = getDataGridConditionSuggestionPosition(target.getBoundingClientRect(), { viewportWidth: window.innerWidth });
|
||||
});
|
||||
}
|
||||
|
||||
function resizeEditor(forceExpand = false) {
|
||||
void nextTick(() => {
|
||||
const input = inputRef.value;
|
||||
if (!input) return;
|
||||
const focused = document.activeElement === input || document.activeElement === overlayRef.value;
|
||||
const nextExpanded = focused && shouldExpand(input) && (forceExpand || expanded.value);
|
||||
if (nextExpanded) {
|
||||
expandedRect.value = input.getBoundingClientRect();
|
||||
expandedHeight.value = measureExpandedHeight(input);
|
||||
}
|
||||
expanded.value = nextExpanded;
|
||||
updateSuggestionPosition();
|
||||
if (nextExpanded && document.activeElement === input) {
|
||||
void nextTick(() => {
|
||||
const overlay = overlayRef.value;
|
||||
if (!overlay) return;
|
||||
const start = input.selectionStart;
|
||||
overlay.focus();
|
||||
overlay.setSelectionRange(start, start);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focus(select = false) {
|
||||
const target = activeEditor.value ?? inputRef.value;
|
||||
target?.focus();
|
||||
if (select) target?.select();
|
||||
resizeEditor(true);
|
||||
}
|
||||
|
||||
function scheduleCollapse() {
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
collapseTimer = setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (active === inputRef.value || active === overlayRef.value) return;
|
||||
expanded.value = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
resizeEditor(true);
|
||||
updateSuggestionPosition();
|
||||
}
|
||||
|
||||
async function applyCondition() {
|
||||
editor.dismiss();
|
||||
const applied = props.apply ? await props.apply(modelValue.value) : emit("apply", modelValue.value);
|
||||
if (applied !== false && modelValue.value.trim()) editor.rememberHistory();
|
||||
}
|
||||
|
||||
async function clearCondition() {
|
||||
modelValue.value = "";
|
||||
editor.dismiss();
|
||||
expanded.value = false;
|
||||
if (props.clear) await props.clear();
|
||||
else emit("clear");
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const action = editor.handleKeydown(event);
|
||||
if (action === "apply") void applyCondition();
|
||||
if (action === "accept") void nextTick(() => focus());
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
editor.openHistory();
|
||||
updateSuggestionPosition();
|
||||
focus();
|
||||
}
|
||||
|
||||
function acceptSuggestion(index: number) {
|
||||
editor.accept(index);
|
||||
void nextTick(() => focus());
|
||||
}
|
||||
|
||||
function eventInside(event: Event, element?: HTMLElement) {
|
||||
if (!element) return false;
|
||||
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
||||
return path.includes(element) || (event.target instanceof Node && element.contains(event.target));
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (eventInside(event, controlRef.value) || eventInside(event, overlayRef.value?.parentElement ?? undefined) || eventInside(event, dropdownRef.value)) return;
|
||||
editor.dismiss();
|
||||
}
|
||||
|
||||
function onViewportResize() {
|
||||
resizeEditor();
|
||||
}
|
||||
|
||||
function showHistoryPreview(value: string, event: MouseEvent) {
|
||||
const target = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
const text = target?.querySelector<HTMLElement>("[data-condition-history-text]");
|
||||
if (!target || !text || text.scrollWidth <= text.clientWidth + 1) return hideHistoryPreview();
|
||||
const rect = target.getBoundingClientRect();
|
||||
const gap = 10;
|
||||
const availableRight = window.innerWidth - rect.right - gap - 8;
|
||||
const availableLeft = rect.left - gap - 8;
|
||||
const placeRight = availableRight >= 280 || availableRight >= availableLeft;
|
||||
const maxWidth = Math.max(280, Math.min(560, placeRight ? availableRight : availableLeft));
|
||||
const estimatedHeight = Math.min(260, Math.max(56, Math.ceil(value.length / 58) * 18 + 24));
|
||||
const top = Math.min(Math.max(8, rect.top), Math.max(8, window.innerHeight - estimatedHeight - 8));
|
||||
historyPreview.value = {
|
||||
value,
|
||||
left: placeRight ? rect.right + gap : Math.max(8, rect.left - gap - maxWidth),
|
||||
top,
|
||||
maxWidth,
|
||||
arrowTop: Math.min(Math.max(14, rect.top + rect.height / 2 - top), estimatedHeight - 14),
|
||||
side: placeRight ? "left" : "right",
|
||||
};
|
||||
}
|
||||
|
||||
function hideHistoryPreview() {
|
||||
historyPreview.value = null;
|
||||
}
|
||||
|
||||
watch(modelValue, () => resizeEditor());
|
||||
watch(
|
||||
() => editor.dropdownOpen.value,
|
||||
(open) => {
|
||||
if (open) updateSuggestionPosition();
|
||||
else hideHistoryPreview();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", onViewportResize);
|
||||
window.visualViewport?.addEventListener("resize", onViewportResize);
|
||||
window.addEventListener("dbx:ui-scale-applied", onViewportResize);
|
||||
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
||||
if (typeof ResizeObserver !== "undefined" && controlRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => resizeEditor());
|
||||
resizeObserver.observe(controlRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", onViewportResize);
|
||||
window.visualViewport?.removeEventListener("resize", onViewportResize);
|
||||
window.removeEventListener("dbx:ui-scale-applied", onViewportResize);
|
||||
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
|
||||
});
|
||||
|
||||
defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberHistory });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="controlRef" class="relative flex min-w-0 flex-1 items-stretch">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span class="data-grid-topbar-condition-label" :class="[props.kind === 'where' ? 'data-grid-topbar-condition-label--where' : 'data-grid-topbar-condition-label--order', { 'data-grid-topbar-condition-label--compact': props.compact }]">
|
||||
{{ props.kind === "where" ? "WHERE" : "ORDER BY" }}
|
||||
</span>
|
||||
<div class="relative h-6 min-w-0 flex-1 overflow-hidden">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
wrap="off"
|
||||
rows="1"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
:disabled="props.disabled"
|
||||
:placeholder="props.placeholder"
|
||||
:aria-label="props.ariaLabel || props.placeholder"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="editor.dropdownOpen.value"
|
||||
:aria-controls="suggestionListId"
|
||||
:aria-activedescendant="activeSuggestionId"
|
||||
class="data-grid-topbar-condition-input absolute inset-x-0 top-0 h-6 min-w-0 resize-none bg-transparent outline-none"
|
||||
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
|
||||
style="height: 24px"
|
||||
@focus="resizeEditor(true)"
|
||||
@blur="scheduleCollapse"
|
||||
@click="resizeEditor(true)"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="shrink-0 text-muted-foreground hover:text-foreground" :disabled="props.disabled" @mousedown.prevent @click="openHistory">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
<button v-if="hasValue" type="button" class="shrink-0 text-muted-foreground hover:text-foreground" :disabled="props.disabled" @mousedown.prevent @click="clearCondition">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="expanded" class="data-grid-topbar-condition-pane--expanded fixed z-[80] flex min-w-0 items-start gap-1" :style="overlayStyle">
|
||||
<textarea
|
||||
ref="overlayRef"
|
||||
v-model="modelValue"
|
||||
:disabled="props.disabled"
|
||||
:placeholder="props.placeholder"
|
||||
:aria-label="props.ariaLabel || props.placeholder"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="editor.dropdownOpen.value"
|
||||
:aria-controls="suggestionListId"
|
||||
:aria-activedescendant="activeSuggestionId"
|
||||
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--expanded absolute resize-none outline-none"
|
||||
:class="[props.kind === 'where' ? 'data-grid-topbar-condition-input--where' : 'data-grid-topbar-condition-input--order', { 'data-grid-topbar-condition-input--compact': props.compact }]"
|
||||
@blur="scheduleCollapse"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div class="data-grid-topbar-condition-floating-controls pointer-events-none absolute inset-x-2 z-[1] flex h-6 min-w-0 items-center gap-1">
|
||||
<span class="data-grid-topbar-condition-label" :class="[props.kind === 'where' ? 'data-grid-topbar-condition-label--where' : 'data-grid-topbar-condition-label--order', { 'data-grid-topbar-condition-label--compact': props.compact }]">
|
||||
{{ props.kind === "where" ? "WHERE" : "ORDER BY" }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<button type="button" class="data-grid-topbar-condition-icon-control pointer-events-auto shrink-0 text-muted-foreground hover:text-foreground" :disabled="props.disabled" @mousedown.prevent @click="openHistory">
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
<button v-if="hasValue" type="button" class="data-grid-topbar-condition-icon-control pointer-events-auto shrink-0 text-muted-foreground hover:text-foreground" :disabled="props.disabled" @mousedown.prevent @click="clearCondition">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="editor.dropdownOpen.value" :id="suggestionListId" ref="dropdownRef" role="listbox" class="fixed z-[90] min-w-[180px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md" :style="suggestionStyle">
|
||||
<div
|
||||
v-for="(suggestion, index) in editor.suggestions.value"
|
||||
:key="`${suggestion.kind}:${suggestion.value}`"
|
||||
:id="`${suggestionListId}-${index}`"
|
||||
role="option"
|
||||
:aria-selected="index === editor.highlightedIndex.value"
|
||||
class="flex cursor-pointer items-center px-3 py-1.5 text-xs"
|
||||
:class="index === editor.highlightedIndex.value ? 'bg-accent text-accent-foreground' : 'hover:bg-gray-200 dark:hover:bg-gray-800'"
|
||||
@mousedown.prevent="acceptSuggestion(index)"
|
||||
@mouseenter="
|
||||
editor.highlightedIndex.value = index;
|
||||
suggestion.kind === 'history' && showHistoryPreview(suggestion.value, $event);
|
||||
"
|
||||
@mouseleave="hideHistoryPreview"
|
||||
>
|
||||
<span data-condition-history-text class="min-w-0 flex-1 truncate font-mono">{{ suggestion.value }}</span>
|
||||
<button v-if="suggestion.kind === 'history'" type="button" class="ml-2 shrink-0 text-muted-foreground hover:text-foreground" @mousedown.stop.prevent="editor.deleteHistory(suggestion.value)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="editor.suggestions.value.length === 0" class="px-3 py-2 text-xs text-muted-foreground">{{ emptyHistoryText }}</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="historyPreview" class="pointer-events-none fixed z-[140] rounded-md bg-foreground shadow-xl" :style="previewStyle">
|
||||
<span class="absolute h-3 w-3 rotate-45 bg-foreground" :class="historyPreview.side === 'left' ? '-left-1.5' : '-right-1.5'" :style="previewArrowStyle" />
|
||||
<div class="max-h-[min(320px,calc(100vh-16px))] overflow-auto rounded-md px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words text-background">{{ historyPreview.value }}</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-grid-topbar-condition-label {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
max-width: 5rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
user-select: none;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
max-width var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
opacity 240ms ease 60ms,
|
||||
transform var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
color 240ms ease;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-label--where {
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-label--order {
|
||||
color: rgb(234 88 12);
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-label--where {
|
||||
color: rgb(96 165 250);
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-label--order {
|
||||
color: rgb(251 146 60);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-label--compact {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 0.125rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: pre;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
scrollbar-width: none;
|
||||
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings:
|
||||
"liga" 0,
|
||||
"calt" 0;
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-input {
|
||||
color: rgb(244, 244, 245);
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-icon-control {
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-floating-controls {
|
||||
top: var(--data-grid-condition-controls-top);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-pane--expanded {
|
||||
--data-grid-condition-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, var(--background) 96%, var(--muted) 4%);
|
||||
box-shadow:
|
||||
inset 0 -1px 0 var(--border),
|
||||
0 8px 16px rgb(15 23 42 / 8%);
|
||||
transition:
|
||||
height 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
--data-grid-expanded-scrollbar-offset: 8px;
|
||||
--data-grid-condition-controls-top: 0.125rem;
|
||||
--data-grid-condition-input-top: 0.125rem;
|
||||
--data-grid-condition-prefix-indent: 0px;
|
||||
--data-grid-condition-suffix-width: 0px;
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-pane--expanded {
|
||||
background: rgb(24, 24, 27) !important;
|
||||
color: rgb(244, 244, 245);
|
||||
box-shadow:
|
||||
inset 0 -1px 0 rgb(63, 63, 70),
|
||||
0 8px 16px rgb(0 0 0 / 32%);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input--expanded {
|
||||
top: var(--data-grid-condition-input-top);
|
||||
right: 0.5rem;
|
||||
bottom: 0.125rem;
|
||||
left: 0.5rem;
|
||||
height: auto;
|
||||
line-height: 1.5rem;
|
||||
margin-left: 0;
|
||||
width: calc(100% - 1rem + var(--data-grid-expanded-scrollbar-offset));
|
||||
max-width: none;
|
||||
margin-right: calc(-1 * var(--data-grid-expanded-scrollbar-offset));
|
||||
padding: 0 calc(var(--data-grid-condition-suffix-width) + 0.5rem) 0.0625rem 0.125rem;
|
||||
text-indent: var(--data-grid-condition-prefix-indent);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input--expanded::-webkit-scrollbar {
|
||||
display: initial;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input::placeholder {
|
||||
color: transparent;
|
||||
transition: color 240ms ease;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder {
|
||||
color: rgb(59 130 246 / 70%);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder {
|
||||
color: rgb(249 115 22 / 70%);
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder {
|
||||
color: rgb(147 197 253 / 70%);
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder {
|
||||
color: rgb(253 186 116 / 70%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { Copy, ListTree, Search, TableProperties } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { filterDataGridDetailFields, type DataGridCellDetail, type DataGridColumnDetail, type DataGridRowDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps<{
|
||||
rowDetail: DataGridRowDetail | null;
|
||||
columnDetail: DataGridColumnDetail | null;
|
||||
typeColorClass: (type: string) => string;
|
||||
openImagePreview: (src: string, title: string) => void;
|
||||
copyRowDetailFieldValue: (field: DataGridCellDetail) => void;
|
||||
copyColumnDetailFieldValue: (field: DataGridCellDetail) => void;
|
||||
copyRowDetailJson: () => void;
|
||||
copyRowDetailTsv: () => void;
|
||||
copyColumnDetailJson: () => void;
|
||||
copyColumnDetailTsv: () => void;
|
||||
copyColumnDetailColumnName: () => void;
|
||||
}>();
|
||||
|
||||
const rowOpen = defineModel<boolean>("rowOpen", { default: false });
|
||||
const columnOpen = defineModel<boolean>("columnOpen", { default: false });
|
||||
const rowSearch = ref("");
|
||||
const columnSearch = ref("");
|
||||
const filteredRowFields = computed(() => (props.rowDetail ? filterDataGridDetailFields(props.rowDetail.fields, rowSearch.value) : []));
|
||||
const filteredColumnFields = computed(() => (props.columnDetail ? filterDataGridDetailFields(props.columnDetail.fields, columnSearch.value) : []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="rowOpen">
|
||||
<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-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="rowSearch" :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-[640px] text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted text-muted-foreground">
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(field, index) in filteredRowFields" :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">{{ index + 1 }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium break-words">{{ 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="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 }}</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.rawValuePreview }}</pre>
|
||||
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}</div>
|
||||
<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 v-if="rowSearch && !filteredRowFields.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"
|
||||
><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>
|
||||
|
||||
<Dialog v-model:open="columnOpen">
|
||||
<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>
|
||||
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
|
||||
<div class="font-medium break-all">{{ columnDetail.column }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
|
||||
<div :class="columnDetail.type ? typeColorClass(columnDetail.type) : 'text-muted-foreground'">{{ columnDetail.type || "-" }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("grid.rowCount") }}</div>
|
||||
<div>{{ columnDetail.fields.length }}</div>
|
||||
</div>
|
||||
<div class="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="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="columnSearch" :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 text-muted-foreground">
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in filteredColumnFields" :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="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 }}</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.rawValuePreview }}</pre>
|
||||
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}</div>
|
||||
<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 v-if="columnSearch && !filteredColumnFields.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"
|
||||
><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>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Upload } from "@lucide/vue";
|
||||
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
||||
|
||||
defineProps<{
|
||||
items: LightDropdownItem[];
|
||||
label: string;
|
||||
onSelect: (value: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="items"
|
||||
:aria-label="label"
|
||||
:trigger-icon="Upload"
|
||||
:trigger-label="label"
|
||||
trigger-class="inline-flex h-6 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-3.5 w-3.5"
|
||||
:show-trigger-label="true"
|
||||
:show-chevron="true"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { filterModeNeedsValue, filterModeUsesList, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps<{
|
||||
rules: DataGridStructuredFilterRule[];
|
||||
columns: string[];
|
||||
filteredColumns: string[];
|
||||
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
|
||||
columnSearch: string;
|
||||
disabled?: boolean;
|
||||
showHeader?: boolean;
|
||||
showFooter?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
add: [];
|
||||
apply: [];
|
||||
reset: [];
|
||||
clear: [];
|
||||
remove: [id: string];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
"update:columnSearch": [value: string];
|
||||
}>();
|
||||
|
||||
function usesExpandedLayout(mode: DataGridContextFilterMode) {
|
||||
return filterModeUsesList(mode) || filterModeUsesRange(mode);
|
||||
}
|
||||
|
||||
function updateRuleColumn(id: string, value: unknown) {
|
||||
emit("updateRule", id, { columnName: String(value) });
|
||||
emit("update:columnSearch", "");
|
||||
}
|
||||
|
||||
function handleColumnSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing || event.key === "Process") {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (["Escape", "Tab", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return;
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete")) event.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="props.disabled || !props.columns.length" @click="emit('add')"> <Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }} </Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.rules.length" class="space-y-2">
|
||||
<template v-for="(rule, index) in props.rules" :key="rule.id">
|
||||
<div v-if="index > 0" class="flex justify-center">
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
|
||||
</div>
|
||||
<div class="grid items-center gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.2fr)_auto]'">
|
||||
<Select :model-value="rule.columnName" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule.id, value)">
|
||||
<SelectTrigger class="h-8 min-w-0 text-xs"><SelectValue :placeholder="t('grid.filterBuilderColumn')" /></SelectTrigger>
|
||||
<SelectContent position="popper" class="max-h-72" :hide-scroll-buttons="true">
|
||||
<SelectItem v-for="column in props.filteredColumns" :key="column" :value="column">{{ column }}</SelectItem>
|
||||
<div v-if="!props.filteredColumns.length" class="px-2 py-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoMatchingColumns") }}</div>
|
||||
<div class="sticky bottom-0 mt-1 flex items-center gap-1.5 border-t bg-popover px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
:value="props.columnSearch"
|
||||
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none"
|
||||
:placeholder="t('grid.filterBuilderSearchColumns')"
|
||||
@input="emit('update:columnSearch', ($event.target as HTMLInputElement).value)"
|
||||
@click.stop
|
||||
@keydown="handleColumnSearchKeydown"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select :model-value="rule.mode" :disabled="rule.disabled" @update:model-value="(value: any) => emit('updateRule', rule.id, { mode: value as DataGridContextFilterMode })">
|
||||
<SelectTrigger class="h-8 min-w-0 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent
|
||||
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem></SelectContent
|
||||
>
|
||||
</Select>
|
||||
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-2 flex gap-2">
|
||||
<Input :model-value="rule.rawValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeStart')" @update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" />
|
||||
<Input :model-value="rule.rawEndValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeEnd')" @update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" />
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
:value="rule.rawValue"
|
||||
rows="2"
|
||||
class="col-span-2 min-h-14 resize-y rounded-md border bg-transparent px-2.5 py-1 text-xs outline-none"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
:model-value="rule.rawValue"
|
||||
class="h-8 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<div v-else class="flex h-8 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
|
||||
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : ''">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">{{ t("grid.filterBuilderEmpty") }}</div>
|
||||
<div v-if="props.showFooter !== false" class="flex justify-between gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="emit('clear')">{{ t("grid.clearFilter") }}</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="emit('reset')">{{ t("grid.resetFilterBuilder") }}</Button
|
||||
><Button size="sm" class="h-8 px-3 text-xs" :disabled="props.disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { Code2, Copy, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
|
||||
const props = defineProps<{
|
||||
fullText: string;
|
||||
text: string;
|
||||
usesCodeEditor: boolean;
|
||||
panelStyle?: Record<string, string>;
|
||||
resizing: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
close: [];
|
||||
resizeStart: [event: MouseEvent];
|
||||
contextMenu: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const previewContainer = ref<HTMLElement>();
|
||||
let previewEditor: UseCellDetailEditorReturn | null = null;
|
||||
|
||||
watch(previewContainer, async (element) => {
|
||||
if (element && !previewEditor) {
|
||||
previewEditor = useCellDetailEditor({
|
||||
language: "json",
|
||||
readOnly: true,
|
||||
editorTheme: () => settingsStore.editorSettings.theme,
|
||||
appAppearance: () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance,
|
||||
appPalette: () => themePalette.value,
|
||||
fontSize: () => settingsStore.editorSettings.fontSize,
|
||||
fontFamily: () => settingsStore.editorSettings.fontFamily,
|
||||
});
|
||||
await previewEditor.create(element, props.text, "json");
|
||||
} else if (!element && previewEditor) {
|
||||
previewEditor.destroy();
|
||||
previewEditor = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.text,
|
||||
(value) => previewEditor?.setValue(value, "json"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative col-start-3 row-start-1 flex min-w-0 flex-col border-l bg-background" :class="{ 'detail-drawer-resizing': resizing }" :style="panelStyle" @contextmenu="emit('contextMenu', $event)">
|
||||
<div class="absolute bottom-0 left-0 top-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30" @mousedown.prevent="emit('resizeStart', $event)" />
|
||||
<div class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ t("grid.mongoJsonPreview") }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!fullText" :title="t('grid.copyJson')" @click="emit('copy')"><Copy class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')"><X class="h-3 w-3" /></Button>
|
||||
</div>
|
||||
<div v-if="text" class="flex min-h-0 flex-1 flex-col overflow-hidden p-2">
|
||||
<div v-if="usesCodeEditor" ref="previewContainer" data-cell-detail-editor-root class="min-h-0 flex-1 overflow-hidden" />
|
||||
<template v-else>
|
||||
<pre class="min-h-0 flex-1 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words">{{ text }}</pre>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: text.length }) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs text-muted-foreground">{{ t("grid.mongoJsonPreviewEmpty") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize";
|
||||
import DataGridExportMenu from "@/components/grid/DataGridExportMenu.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
selectionSummary: { cellCount: number; rowCount: number } | null;
|
||||
selectionSummarySumText: string;
|
||||
loading: boolean;
|
||||
infiniteScrollEnabled: boolean;
|
||||
infiniteScrollAllLoaded: boolean;
|
||||
pageSize: number;
|
||||
pageSizeMenuItems: LightDropdownItem[];
|
||||
exportMenuItems: LightDropdownItem[];
|
||||
currentPage: number;
|
||||
canGoNextPage: boolean;
|
||||
canJumpLastPage: boolean;
|
||||
}>();
|
||||
|
||||
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectPageSize: [value: string];
|
||||
applyCustomPageSize: [];
|
||||
firstPage: [];
|
||||
previousPage: [];
|
||||
nextPage: [];
|
||||
lastPage: [];
|
||||
selectExport: [value: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-max items-center justify-end gap-1">
|
||||
<div v-if="selectionSummary" class="flex shrink-0 items-center gap-3 tabular-nums">
|
||||
<span class="shrink-0">{{ t("grid.selectionSum", { value: selectionSummarySumText }) }}</span>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<span class="shrink-0">{{ t("grid.selectionCells", { count: selectionSummary.cellCount }) }}</span>
|
||||
<span class="shrink-0">{{ t("grid.rows", { count: selectionSummary.rowCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Loader2 v-if="loading" class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<template v-if="infiniteScrollEnabled">
|
||||
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
|
||||
</template>
|
||||
<template v-if="!infiniteScrollEnabled">
|
||||
<LightDropdown
|
||||
:model-value="String(pageSize)"
|
||||
:items="pageSizeMenuItems"
|
||||
:trigger-label="`${pageSize}${t('grid.rowsPerPageShort')}`"
|
||||
trigger-class="inline-flex h-5 shrink-0 items-center justify-center whitespace-nowrap rounded-md px-1.5 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
content-class="w-36"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="emit('selectPageSize', $event)"
|
||||
>
|
||||
<div class="bg-border -mx-1 my-1 h-px" />
|
||||
<div class="text-muted-foreground px-1.5 py-1 text-xs">{{ t("grid.customRowsPerPage") }}</div>
|
||||
<div class="flex items-center gap-1 px-1.5 pb-1" @click.stop @keydown.stop>
|
||||
<Input
|
||||
v-model="customPageSizeInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="MIN_RESULT_PAGE_SIZE"
|
||||
:max="MAX_RESULT_PAGE_SIZE"
|
||||
class="h-6 w-20 px-1.5 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@keydown.enter.prevent.stop="emit('applyCustomPageSize')"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child
|
||||
><Button variant="outline" size="icon" class="h-6 w-6 shrink-0" :aria-label="t('grid.applyPageSize')" @click.stop="emit('applyCustomPageSize')"><Check class="h-3 w-3" /></Button
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ t("grid.applyPageSize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</LightDropdown>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||
</template>
|
||||
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { Filter, Plus, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
|
||||
type LocalFilterSummary = {
|
||||
columnIndex: number;
|
||||
columnName: string;
|
||||
values: string[];
|
||||
hiddenValueCount: number;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
whereInput: string;
|
||||
orderByInput: string;
|
||||
columns: readonly string[];
|
||||
historyScope: DataGridConditionHistoryScope;
|
||||
canUseWhereSearch: boolean;
|
||||
compact: boolean;
|
||||
leadingBorder: boolean;
|
||||
filterBuilderOpen: boolean;
|
||||
filterButtonActive: boolean;
|
||||
filterButtonCount: number;
|
||||
hasLocalColumnFilters: boolean;
|
||||
localFilterCount: number;
|
||||
localFilterSummaries: LocalFilterSummary[];
|
||||
rules: DataGridStructuredFilterRule[];
|
||||
filteredColumns: string[];
|
||||
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
|
||||
columnSearch: string;
|
||||
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
clearWhere: () => void | Promise<void>;
|
||||
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
clearOrderBy: () => void | Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:whereInput": [value: string];
|
||||
"update:orderByInput": [value: string];
|
||||
"update:filterBuilderOpen": [value: boolean];
|
||||
"update:columnSearch": [value: string];
|
||||
ensureRule: [];
|
||||
addRule: [];
|
||||
applyFilters: [];
|
||||
resetFilters: [];
|
||||
clearFilters: [];
|
||||
removeRule: [id: string];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
clearLocalFilter: [columnIndex?: number];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const whereWidth = ref<number | null>(null);
|
||||
const resizing = ref(false);
|
||||
let resizeStartX = 0;
|
||||
let resizeStartWidth = 0;
|
||||
|
||||
const wherePaneStyle = computed(() => (whereWidth.value == null ? {} : { flex: `0 0 ${whereWidth.value}px` }));
|
||||
|
||||
function containerWidth(): number {
|
||||
return containerRef.value?.getBoundingClientRect().width ?? 0;
|
||||
}
|
||||
|
||||
function onResizeStart(event: MouseEvent) {
|
||||
const width = containerWidth();
|
||||
if (width <= 0) return;
|
||||
event.preventDefault();
|
||||
resizing.value = true;
|
||||
resizeStartX = event.clientX;
|
||||
resizeStartWidth = clampSearchSplitWidth({ containerWidth: width, desiredWidth: whereWidth.value ?? undefined });
|
||||
whereWidth.value = resizeStartWidth;
|
||||
document.body.classList.add("select-none", "cursor-col-resize");
|
||||
window.addEventListener("mousemove", onResizeMove);
|
||||
window.addEventListener("mouseup", onResizeEnd);
|
||||
}
|
||||
|
||||
function onResizeMove(event: MouseEvent) {
|
||||
if (!resizing.value) return;
|
||||
const width = containerWidth();
|
||||
if (width <= 0) return;
|
||||
whereWidth.value = clampSearchSplitWidth({ containerWidth: width, desiredWidth: resizeStartWidth + event.clientX - resizeStartX });
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
resizing.value = false;
|
||||
document.body.classList.remove("select-none", "cursor-col-resize");
|
||||
window.removeEventListener("mousemove", onResizeMove);
|
||||
window.removeEventListener("mouseup", onResizeEnd);
|
||||
}
|
||||
|
||||
function resetWidth() {
|
||||
const width = containerWidth();
|
||||
whereWidth.value = width > 0 ? clampSearchSplitWidth({ containerWidth: width }) : null;
|
||||
}
|
||||
|
||||
function updateRule(id: string, patch: Partial<DataGridStructuredFilterRule>) {
|
||||
emit("updateRule", id, patch);
|
||||
}
|
||||
|
||||
onUnmounted(onResizeEnd);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': leadingBorder }" :style="wherePaneStyle">
|
||||
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
|
||||
:class="filterButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
:disabled="!canUseWhereSearch"
|
||||
@click="emit('ensureRule')"
|
||||
>
|
||||
<Filter class="h-3 w-3" />
|
||||
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[380px] max-w-[calc(100vw-24px)] gap-3 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
|
||||
<Filter class="h-3.5 w-3.5 shrink-0" /><span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="emit('clearLocalFilter')"><X class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearLocalFiltersShort") }}</Button>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs">
|
||||
<span class="truncate font-medium text-foreground" :title="summary.columnName">{{ summary.columnName }}</span>
|
||||
<span class="min-w-0 truncate font-mono text-muted-foreground">
|
||||
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex"
|
||||
><span v-if="valueIndex > 0">, </span><span>{{ value }}</span></template
|
||||
>
|
||||
<span v-if="summary.hiddenValueCount">{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="emit('clearLocalFilter', summary.columnIndex)"><X class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataGridFilterBuilder
|
||||
:rules="rules"
|
||||
:columns="[...columns]"
|
||||
:filtered-columns="filteredColumns"
|
||||
:mode-options="modeOptions"
|
||||
:column-search="columnSearch"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
@add="emit('addRule')"
|
||||
@apply="emit('applyFilters')"
|
||||
@reset="emit('resetFilters')"
|
||||
@clear="emit('clearFilters')"
|
||||
@remove="emit('removeRule', $event)"
|
||||
@update-rule="updateRule"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DataGridConditionEditor
|
||||
:model-value="whereInput"
|
||||
kind="where"
|
||||
:columns="columns"
|
||||
:history-scope="historyScope"
|
||||
placeholder="WHERE"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
:history-no-matches-text="t('grid.conditionHistoryNoMatches')"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:compact="compact"
|
||||
:apply="applyWhere"
|
||||
:clear="clearWhere"
|
||||
@update:model-value="emit('update:whereInput', $event)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex w-2 shrink-0 cursor-col-resize items-center justify-center border-l border-r border-border/80 bg-muted/15 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
aria-label="Resize WHERE and ORDER BY"
|
||||
@mousedown="onResizeStart"
|
||||
@dblclick.stop="resetWidth"
|
||||
>
|
||||
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
||||
</button>
|
||||
<div class="flex flex-1 items-center px-2 py-0.5 border-r min-w-0">
|
||||
<DataGridConditionEditor
|
||||
:model-value="orderByInput"
|
||||
kind="orderBy"
|
||||
:columns="columns"
|
||||
:history-scope="historyScope"
|
||||
placeholder="ORDER BY"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
:history-no-matches-text="t('grid.conditionHistoryNoMatches')"
|
||||
:compact="compact"
|
||||
:apply="applyOrderBy"
|
||||
:clear="clearOrderBy"
|
||||
@update:model-value="emit('update:orderByInput', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Search, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
suggestions: string[];
|
||||
suggestionIndex: number;
|
||||
matchCount: number;
|
||||
currentMatchIndex: number;
|
||||
hasDeferredSearchText: boolean;
|
||||
}>();
|
||||
|
||||
const searchText = defineModel<string>("text", { default: "" });
|
||||
const emit = defineEmits<{
|
||||
keydown: [event: KeyboardEvent];
|
||||
close: [];
|
||||
acceptSuggestion: [index: number];
|
||||
hoverSuggestion: [index: number];
|
||||
}>();
|
||||
|
||||
const searchInput = ref<HTMLInputElement>();
|
||||
|
||||
function onSuggestionMouseDown(event: MouseEvent, index: number) {
|
||||
event.preventDefault();
|
||||
emit("acceptSuggestion", index);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: (select = false) => {
|
||||
searchInput.value?.focus();
|
||||
if (select) searchInput.value?.select();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition enter-active-class="transition-opacity duration-150" leave-active-class="transition-opacity duration-100" enter-from-class="opacity-0" leave-to-class="opacity-0">
|
||||
<div v-if="props.open" class="absolute top-1 right-2 z-20 flex items-center gap-1 px-2 py-1 bg-background border rounded-md shadow-md">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchText"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-48 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.search')"
|
||||
@keydown="emit('keydown', $event)"
|
||||
/>
|
||||
<div v-if="props.suggestions.length > 0" class="absolute top-full right-0 mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md">
|
||||
<div
|
||||
v-for="(suggestion, index) in props.suggestions"
|
||||
:key="suggestion"
|
||||
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
|
||||
:class="index === props.suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-gray-200 dark:hover:bg-gray-800'"
|
||||
@mousedown="onSuggestionMouseDown($event, index)"
|
||||
@mouseenter="emit('hoverSuggestion', index)"
|
||||
>
|
||||
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
|
||||
<span>{{ suggestion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="props.matchCount > 0" class="text-xs text-muted-foreground shrink-0">{{ props.currentMatchIndex + 1 }}/{{ props.matchCount }}</span>
|
||||
<span v-else-if="props.hasDeferredSearchText" class="text-xs text-muted-foreground shrink-0">0</span>
|
||||
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0" @click="emit('close')"><X class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Check, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Upload } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
dataGridToolbarIntervalOptions,
|
||||
isDataGridToolbarCapabilityDisabled,
|
||||
isDataGridToolbarCapabilityVisible,
|
||||
selectDataGridToolbarAutoRefreshInterval,
|
||||
selectDataGridToolbarExportItem,
|
||||
toggleDataGridToolbarAutoRefresh,
|
||||
triggerDataGridToolbarAction,
|
||||
type DataGridToolbarActionCapability,
|
||||
type DataGridToolbarAutoRefreshCapability,
|
||||
type DataGridToolbarExportCapability,
|
||||
type DataGridToolbarSaveCapability,
|
||||
} from "@/lib/dataGrid/dataGridToolbar";
|
||||
|
||||
const props = defineProps<{
|
||||
compact?: boolean;
|
||||
refresh: DataGridToolbarActionCapability;
|
||||
autoRefresh?: DataGridToolbarAutoRefreshCapability;
|
||||
addRow?: DataGridToolbarActionCapability;
|
||||
exportData?: DataGridToolbarExportCapability;
|
||||
transpose?: DataGridToolbarActionCapability;
|
||||
tableInfo?: DataGridToolbarActionCapability;
|
||||
preview?: DataGridToolbarActionCapability;
|
||||
save?: DataGridToolbarSaveCapability;
|
||||
rollback?: DataGridToolbarActionCapability;
|
||||
}>();
|
||||
|
||||
const actionButtonClass = computed(() => ["data-grid-topbar-action-button h-5 shrink-0 px-1.5 text-xs", props.compact ? "data-grid-topbar-action-button--compact" : ""]);
|
||||
const autoRefreshIntervals = computed(() => (props.autoRefresh ? dataGridToolbarIntervalOptions(props.autoRefresh.intervalOptions, props.autoRefresh.intervalSeconds) : []));
|
||||
|
||||
function actionLabelClass() {
|
||||
return { "data-grid-topbar-action-label--compact": props.compact };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-1 px-1">
|
||||
<slot name="leading" />
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(refresh)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(refresh)" @click="void triggerDataGridToolbarAction(refresh)">
|
||||
<Loader2 v-if="refresh.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
|
||||
<RefreshCcw v-else class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ refresh.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ refresh.tooltip ?? refresh.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu v-if="isDataGridToolbarCapabilityVisible(autoRefresh)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="[...actionButtonClass, autoRefresh?.enabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
|
||||
:disabled="isDataGridToolbarCapabilityDisabled(autoRefresh)"
|
||||
:title="autoRefresh?.label"
|
||||
:aria-label="autoRefresh?.label"
|
||||
:aria-pressed="autoRefresh?.enabled"
|
||||
>
|
||||
<Timer class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-40">
|
||||
<DropdownMenuItem class="gap-2" :disabled="autoRefresh?.disabled" @select="void toggleDataGridToolbarAutoRefresh(autoRefresh)">
|
||||
<Check v-if="autoRefresh?.enabled" class="h-3.5 w-3.5" />
|
||||
<span v-else class="h-3.5 w-3.5" />
|
||||
{{ autoRefresh?.enabled ? autoRefresh.stopLabel : autoRefresh?.startLabel }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="seconds in autoRefreshIntervals" :key="seconds" class="gap-2" :disabled="autoRefresh?.disabled" @select="void selectDataGridToolbarAutoRefreshInterval(autoRefresh, seconds)">
|
||||
<Check v-if="autoRefresh?.intervalSeconds === seconds" class="h-3.5 w-3.5" />
|
||||
<span v-else class="h-3.5 w-3.5" />
|
||||
{{ autoRefresh?.intervalLabel(seconds) }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<slot name="navigation" />
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(addRow)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(addRow)" @click="void triggerDataGridToolbarAction(addRow)">
|
||||
<Plus class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ addRow?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ addRow?.tooltip ?? addRow?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu v-if="isDataGridToolbarCapabilityVisible(exportData)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
|
||||
<Upload class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ exportData?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" class="min-w-52">
|
||||
<template v-for="item in exportData?.items ?? []" :key="item.value">
|
||||
<DropdownMenuSeparator v-if="item.separatorBefore" />
|
||||
<DropdownMenuItem :disabled="item.disabled" @select="void selectDataGridToolbarExportItem(exportData, item.value)">
|
||||
{{ item.label }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(transpose)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="[...actionButtonClass, transpose?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']" :disabled="isDataGridToolbarCapabilityDisabled(transpose)" :aria-pressed="transpose?.active" @click="void triggerDataGridToolbarAction(transpose)">
|
||||
<Rows3 class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ transpose?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ transpose?.tooltip ?? transpose?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(tableInfo)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="[...actionButtonClass, tableInfo?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']" :disabled="isDataGridToolbarCapabilityDisabled(tableInfo)" :aria-pressed="tableInfo?.active" @click="void triggerDataGridToolbarAction(tableInfo)">
|
||||
<TableProperties class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ tableInfo?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ tableInfo?.tooltip ?? tableInfo?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(preview)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="[...actionButtonClass, 'text-sky-600 hover:bg-sky-500/10 hover:text-sky-700']" :disabled="isDataGridToolbarCapabilityDisabled(preview)" @click="void triggerDataGridToolbarAction(preview)">
|
||||
<Loader2 v-if="preview?.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
|
||||
<Eye v-else class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ preview?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-sm">{{ preview?.tooltip ?? preview?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(save)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="default" size="sm" :class="[...actionButtonClass, 'data-grid-topbar-action-button--commit relative']" :disabled="isDataGridToolbarCapabilityDisabled(save)" @click="void triggerDataGridToolbarAction(save)">
|
||||
<Loader2 v-if="save?.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
|
||||
<Save v-else-if="compact || !save?.pendingCount" class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span
|
||||
v-if="save && save.pendingCount > 0"
|
||||
:class="
|
||||
compact
|
||||
? 'absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400'
|
||||
: 'mr-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400'
|
||||
"
|
||||
>
|
||||
{{ save.pendingCount }}
|
||||
</span>
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ save?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-sm"
|
||||
>{{ save?.tooltip ?? save?.label }}<template v-if="save?.shortcutLabel"> ({{ save.shortcutLabel }})</template></TooltipContent
|
||||
>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="isDataGridToolbarCapabilityVisible(rollback)">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(rollback)" @click="void triggerDataGridToolbarAction(rollback)">
|
||||
<RotateCcw class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ rollback?.label }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ rollback?.tooltip ?? rollback?.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-grid-topbar-action-button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 9rem;
|
||||
min-width: 1.25rem;
|
||||
gap: 0;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-width var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
min-width var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
padding-inline var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
color 220ms ease,
|
||||
background-color 220ms ease,
|
||||
border-color 220ms ease;
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-button--compact {
|
||||
max-width: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-button--commit.data-grid-topbar-action-button--compact {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-icon {
|
||||
display: block;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
margin-inline-end var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
transform var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-button:not(.data-grid-topbar-action-button--compact) .data-grid-topbar-action-icon {
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-button--compact .data-grid-topbar-action-icon {
|
||||
margin-inline-end: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1rem;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
max-width var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||
opacity 240ms ease 60ms,
|
||||
transform var(--data-grid-topbar-transition-duration, 340ms) var(--data-grid-topbar-transition-easing, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
.data-grid-topbar-action-label--compact {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findAll, findOne, hostText, mountComponent } from "./vueHostHarness";
|
||||
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
editor: { create: vi.fn(), destroy: vi.fn(), setValue: vi.fn(), openSearch: vi.fn() },
|
||||
updateSettings: vi.fn(),
|
||||
renderWkt: vi.fn(),
|
||||
panelCancel: vi.fn(),
|
||||
panelOpenSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
const icon = createPassthroughStub("Icon", "i");
|
||||
return { Check: icon, ChevronLeft: icon, ChevronRight: icon, ChevronsLeft: icon, ChevronsRight: icon, Loader2: icon, Upload: icon, Search: icon, X: icon, Code2: icon, Copy: icon, Eye: icon, EyeOff: icon, Info: icon, Pencil: icon, Plus: icon, Trash2: icon };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
vi.mock("@/components/ui/input", async () => ({ Input: (await import("./vueHostHarness")).createPassthroughStub("Input", "input") }));
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { Dialog: createPassthroughStub("Dialog"), DialogContent: createPassthroughStub("DialogContent"), DialogFooter: createPassthroughStub("DialogFooter"), DialogHeader: createPassthroughStub("DialogHeader"), DialogTitle: createPassthroughStub("DialogTitle") };
|
||||
});
|
||||
vi.mock("@/components/ui/dropdown-menu", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { DropdownMenu: createPassthroughStub("DropdownMenu"), DropdownMenuContent: createPassthroughStub("DropdownMenuContent"), DropdownMenuItem: createPassthroughStub("DropdownMenuItem", "button"), DropdownMenuTrigger: createPassthroughStub("DropdownMenuTrigger") };
|
||||
});
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { Popover: createPassthroughStub("Popover"), PopoverContent: createPassthroughStub("PopoverContent"), PopoverTrigger: createPassthroughStub("PopoverTrigger") };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { Tooltip: createPassthroughStub("Tooltip"), TooltipContent: createPassthroughStub("TooltipContent"), TooltipTrigger: createPassthroughStub("TooltipTrigger") };
|
||||
});
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { Select: createPassthroughStub("Select"), SelectContent: createPassthroughStub("SelectContent"), SelectItem: createPassthroughStub("SelectItem"), SelectTrigger: createPassthroughStub("SelectTrigger"), SelectValue: createPassthroughStub("SelectValue") };
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => ({ TabsContent: (await import("./vueHostHarness")).createPassthroughStub("TabsContent") }));
|
||||
vi.mock("@/components/ui/LightDropdown.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightDropdown") }));
|
||||
vi.mock("@/components/ui/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") }));
|
||||
vi.mock("@/components/grid/TemporalCellEditor.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("TemporalCellEditor") }));
|
||||
vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () => mocks.editor }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: {} } }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { cellDetailJsonFormatted: true, theme: "default", fontSize: 13, fontFamily: "monospace" }, updateEditorSettings: mocks.updateSettings }) }));
|
||||
vi.mock("@/lib/dataGrid/geometryPreview", () => ({ isHexGeometry: () => false, renderWktOnCanvas: mocks.renderWkt }));
|
||||
vi.mock("@/composables/useDataGridCellDetail", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
useDataGridCellDetail: ({ onCancel }: { onCancel: () => void }) => {
|
||||
mocks.panelCancel.mockImplementation(onCancel);
|
||||
return { geometryPreviewOpen: ref(false), geometryCanvas: ref(), detailsEditorContainer: ref(), sideJsonPreviewContainer: ref(), openSearch: mocks.panelOpenSearch };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import DataGridCellDetailDialog from "@/components/grid/DataGridCellDetailDialog.vue";
|
||||
import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.vue";
|
||||
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
|
||||
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
|
||||
|
||||
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
|
||||
return {
|
||||
rowNumber: 1,
|
||||
rowId: 0,
|
||||
colIndex: 0,
|
||||
column: "payload",
|
||||
type: "JSON",
|
||||
comment: "",
|
||||
value: '{"a":1}',
|
||||
rawValue: '{"a":1}',
|
||||
rawValuePreview: '{"a":1}',
|
||||
displayValue: '{"a":1}',
|
||||
displayValuePreview: '{"a":1}',
|
||||
isValuePreviewTruncated: false,
|
||||
imagePreviewUrl: null,
|
||||
length: 7,
|
||||
formattedJson: '{\n "a": 1\n}',
|
||||
isEditable: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("DataGridSearchBar", () => {
|
||||
it("focuses/selects the input and forwards keyboard and suggestion interactions", () => {
|
||||
const keydown = vi.fn();
|
||||
const acceptSuggestion = vi.fn();
|
||||
const hoverSuggestion = vi.fn();
|
||||
const close = vi.fn();
|
||||
const mounted = mountComponent(DataGridSearchBar, {
|
||||
open: true,
|
||||
text: "pay",
|
||||
suggestions: ["payload"],
|
||||
suggestionIndex: 0,
|
||||
matchCount: 2,
|
||||
currentMatchIndex: 0,
|
||||
hasDeferredSearchText: false,
|
||||
onKeydown: keydown,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onHoverSuggestion: hoverSuggestion,
|
||||
onClose: close,
|
||||
});
|
||||
const input = findOne(mounted.root, (node) => node.type === "input");
|
||||
|
||||
mounted.exposed.value.focus(true);
|
||||
expect(input.focused).toBe(true);
|
||||
expect(input.selected).toBe(true);
|
||||
dispatch(input, "keydown", { key: "Enter" });
|
||||
expect(keydown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
|
||||
|
||||
const suggestion = findOne(mounted.root, (node) => hostText(node) === "payload" && !!node.props.onMousedown);
|
||||
const mouseDown = dispatch(suggestion, "mousedown");
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
expect(acceptSuggestion).toHaveBeenCalledWith(0);
|
||||
dispatch(suggestion, "mouseenter");
|
||||
expect(hoverSuggestion).toHaveBeenCalledWith(0);
|
||||
|
||||
const closeButton = findOne(mounted.root, (node) => node.type === "button");
|
||||
dispatch(closeButton, "click");
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridPagination", () => {
|
||||
it("enforces first/previous/next/last disabled boundaries", async () => {
|
||||
const firstPage = vi.fn();
|
||||
const previousPage = vi.fn();
|
||||
const nextPage = vi.fn();
|
||||
const lastPage = vi.fn();
|
||||
const mounted = mountComponent(DataGridPagination, {
|
||||
selectionSummary: null,
|
||||
selectionSummarySumText: "",
|
||||
loading: false,
|
||||
infiniteScrollEnabled: false,
|
||||
infiniteScrollAllLoaded: false,
|
||||
pageSize: 100,
|
||||
customPageSizeInput: "",
|
||||
pageSizeMenuItems: [],
|
||||
exportMenuItems: [],
|
||||
currentPage: 1,
|
||||
canGoNextPage: false,
|
||||
canJumpLastPage: false,
|
||||
onFirstPage: firstPage,
|
||||
onPreviousPage: previousPage,
|
||||
onNextPage: nextPage,
|
||||
onLastPage: lastPage,
|
||||
});
|
||||
const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
|
||||
|
||||
expect(navigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
|
||||
navigation.forEach((node) => dispatch(node, "click"));
|
||||
expect(firstPage).not.toHaveBeenCalled();
|
||||
expect(previousPage).not.toHaveBeenCalled();
|
||||
expect(nextPage).not.toHaveBeenCalled();
|
||||
expect(lastPage).not.toHaveBeenCalled();
|
||||
|
||||
await mounted.setProps({ currentPage: 2, canGoNextPage: true, canJumpLastPage: true });
|
||||
const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
|
||||
expect(enabledNavigation.map((node) => node.props.disabled)).toEqual([false, false, false, false]);
|
||||
enabledNavigation.forEach((node) => dispatch(node, "click"));
|
||||
expect(firstPage).toHaveBeenCalledOnce();
|
||||
expect(previousPage).toHaveBeenCalledOnce();
|
||||
expect(nextPage).toHaveBeenCalledOnce();
|
||||
expect(lastPage).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridColumnHeader", () => {
|
||||
it("cancels resize-handle clicks without leaking header click events", () => {
|
||||
const click = vi.fn();
|
||||
const clickCapture = vi.fn();
|
||||
const resizeStart = vi.fn();
|
||||
const autoFit = vi.fn();
|
||||
const mounted = mountComponent(DataGridColumnHeader, {
|
||||
name: "id",
|
||||
actualColumnIndex: 0,
|
||||
visibleColumnIndex: 0,
|
||||
dark: true,
|
||||
copyColumnNameLabel: "copy",
|
||||
columnNameLabel: "name",
|
||||
columnTypeLabel: "type",
|
||||
columnCommentLabel: "comment",
|
||||
onClick: click,
|
||||
onClickCapture: clickCapture,
|
||||
onResizeStart: resizeStart,
|
||||
onAutoFit: autoFit,
|
||||
});
|
||||
const handle = findOne(mounted.root, (node) => node.props["data-column-resize-handle"] === "");
|
||||
const header = findOne(mounted.root, (node) => node.props["data-grid-column-index"] === 0);
|
||||
expect(String(header.props.class)).toContain("data-grid-header-cell--dark");
|
||||
|
||||
const down = dispatch(handle, "mousedown");
|
||||
expect(down.propagationStopped).toBe(true);
|
||||
expect(resizeStart).toHaveBeenCalledOnce();
|
||||
const handleClick = dispatch(handle, "click");
|
||||
expect(handleClick.propagationStopped).toBe(true);
|
||||
expect(handleClick.defaultPrevented).toBe(true);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
expect(clickCapture).not.toHaveBeenCalled();
|
||||
dispatch(handle, "dblclick");
|
||||
expect(autoFit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridFilterBuilder", () => {
|
||||
it("isolates text-entry keys while preserving popup navigation keys", () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, { rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }], columns: ["id"], filteredColumns: ["id"], modeOptions: [{ value: "equals", labelKey: "equals" }], columnSearch: "" });
|
||||
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
|
||||
|
||||
expect(dispatch(searchInput, "keydown", { key: "a" }).propagationStopped).toBe(true);
|
||||
expect(dispatch(searchInput, "keydown", { key: "Backspace" }).propagationStopped).toBe(true);
|
||||
expect(dispatch(searchInput, "keydown", { key: "ArrowDown" }).propagationStopped).toBe(false);
|
||||
expect(dispatch(searchInput, "keydown", { key: "Process", isComposing: true }).propagationStopped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cell detail surfaces", () => {
|
||||
it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => {
|
||||
const copyText = vi.fn();
|
||||
const edit = vi.fn();
|
||||
const updateOpen = vi.fn();
|
||||
const mounted = mountComponent(DataGridCellDetailDialog, { open: true, detail: detail(), typeColorClass: () => "", openImagePreview: vi.fn(), copyText, canDownloadBinaryValue: () => false, downloadBinaryValue: vi.fn(), onEdit: edit, "onUpdate:open": updateOpen });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const copyValue = findOne(mounted.root, (node) => node.props.title === "grid.copyValue");
|
||||
dispatch(copyValue, "click");
|
||||
expect(copyText).toHaveBeenCalledWith('{\n "a": 1\n}');
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props.title === "grid.editValue"),
|
||||
"click",
|
||||
);
|
||||
expect(edit).toHaveBeenCalledOnce();
|
||||
|
||||
await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) });
|
||||
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
|
||||
await mounted.setProps({ detail: detail({ value: null, rawValue: "", formattedJson: "" }) });
|
||||
expect(mocks.editor.destroy).toHaveBeenCalledOnce();
|
||||
|
||||
const dialog = findOne(mounted.root, (node) => node.props["data-stub"] === "Dialog");
|
||||
dialog.props["onUpdate:open"](false);
|
||||
expect(updateOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("forwards panel edit/copy/cancel actions and exposes search", () => {
|
||||
const startEdit = vi.fn();
|
||||
const copyValue = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
const mounted = mountComponent(DataGridCellDetailPanel, {
|
||||
detail: detail({ formattedJson: "" }),
|
||||
panelIsBottom: false,
|
||||
metadataCollapsed: false,
|
||||
valueFillsHeight: false,
|
||||
editing: false,
|
||||
sideJsonView: false,
|
||||
showCompactJson: false,
|
||||
canCompactJson: false,
|
||||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => false,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
onStartEdit: startEdit,
|
||||
onCopyValue: copyValue,
|
||||
onCancel: cancel,
|
||||
});
|
||||
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props.title === "grid.editValue"),
|
||||
"click",
|
||||
);
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props.title === "grid.copyValue"),
|
||||
"click",
|
||||
);
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.type === "pre"),
|
||||
"dblclick",
|
||||
);
|
||||
expect(startEdit).toHaveBeenCalledTimes(2);
|
||||
expect(copyValue).toHaveBeenCalledOnce();
|
||||
mocks.panelCancel();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
mounted.exposed.value.openSearch();
|
||||
expect(mocks.panelOpenSearch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createRenderer, defineComponent, h, nextTick, reactive, ref, type Component } from "vue";
|
||||
|
||||
export interface HostNode {
|
||||
type: string;
|
||||
props: Record<string, any>;
|
||||
children: Array<HostNode | string>;
|
||||
parent: HostNode | null;
|
||||
focused: boolean;
|
||||
selected: boolean;
|
||||
value?: unknown;
|
||||
listeners: Map<string, Array<(event: any) => void>>;
|
||||
focus(): void;
|
||||
select(): void;
|
||||
addEventListener(name: string, listener: (event: any) => void): void;
|
||||
removeEventListener(name: string, listener: (event: any) => void): void;
|
||||
}
|
||||
|
||||
function createHostNode(type: string): HostNode {
|
||||
const node: HostNode = {
|
||||
type,
|
||||
props: {},
|
||||
children: [],
|
||||
parent: null,
|
||||
focused: false,
|
||||
selected: false,
|
||||
listeners: new Map(),
|
||||
focus() {
|
||||
node.focused = true;
|
||||
},
|
||||
select() {
|
||||
node.selected = true;
|
||||
},
|
||||
addEventListener(name, listener) {
|
||||
const listeners = node.listeners.get(name) ?? [];
|
||||
listeners.push(listener);
|
||||
node.listeners.set(name, listeners);
|
||||
},
|
||||
removeEventListener(name, listener) {
|
||||
node.listeners.set(
|
||||
name,
|
||||
(node.listeners.get(name) ?? []).filter((candidate) => candidate !== listener),
|
||||
);
|
||||
},
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function insert(child: HostNode, parent: HostNode, anchor?: HostNode | null) {
|
||||
child.parent = parent;
|
||||
if (!anchor) {
|
||||
parent.children.push(child);
|
||||
return;
|
||||
}
|
||||
const index = parent.children.indexOf(anchor);
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, child);
|
||||
}
|
||||
|
||||
const renderer = createRenderer<HostNode, HostNode>({
|
||||
patchProp(node, key, _previous, next) {
|
||||
if (next === null || next === undefined) delete node.props[key];
|
||||
else node.props[key] = next;
|
||||
if (key === "value") node.value = next;
|
||||
},
|
||||
insert,
|
||||
remove(node) {
|
||||
if (!node.parent) return;
|
||||
const index = node.parent.children.indexOf(node);
|
||||
if (index >= 0) node.parent.children.splice(index, 1);
|
||||
node.parent = null;
|
||||
},
|
||||
createElement: createHostNode,
|
||||
createText(text) {
|
||||
const node = createHostNode("#text");
|
||||
node.children = [text];
|
||||
return node;
|
||||
},
|
||||
createComment(text) {
|
||||
const node = createHostNode("#comment");
|
||||
node.children = [text];
|
||||
return node;
|
||||
},
|
||||
setText(node, text) {
|
||||
node.children = [text];
|
||||
},
|
||||
setElementText(node, text) {
|
||||
node.children = [text];
|
||||
},
|
||||
parentNode: (node) => node.parent,
|
||||
nextSibling(node) {
|
||||
if (!node.parent) return null;
|
||||
const index = node.parent.children.indexOf(node);
|
||||
return (node.parent.children[index + 1] as HostNode | undefined) ?? null;
|
||||
},
|
||||
setScopeId(node, id) {
|
||||
node.props[id] = "";
|
||||
},
|
||||
cloneNode(node) {
|
||||
return { ...node, props: { ...node.props }, children: [...node.children], parent: null, listeners: new Map(node.listeners) };
|
||||
},
|
||||
insertStaticContent(content, parent, anchor) {
|
||||
const node = createHostNode("#static");
|
||||
node.children = [content];
|
||||
insert(node, parent, anchor);
|
||||
return [node, node];
|
||||
},
|
||||
});
|
||||
|
||||
export function createPassthroughStub(name: string, tag = "div") {
|
||||
return defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function mountComponent(component: Component, initialProps: Record<string, unknown>) {
|
||||
const props = reactive({ ...initialProps });
|
||||
const componentRef = ref<any>();
|
||||
const root = createHostNode("root");
|
||||
const wrapper = defineComponent({
|
||||
setup() {
|
||||
return () => h(component, { ...props, ref: componentRef });
|
||||
},
|
||||
});
|
||||
const app = renderer.createApp(wrapper);
|
||||
app.mount(root);
|
||||
return {
|
||||
root,
|
||||
exposed: componentRef,
|
||||
async setProps(patch: Record<string, unknown>) {
|
||||
Object.assign(props, patch);
|
||||
await nextTick();
|
||||
},
|
||||
unmount() {
|
||||
app.unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function findAll(node: HostNode, predicate: (node: HostNode) => boolean): HostNode[] {
|
||||
const matches: HostNode[] = [];
|
||||
if (predicate(node)) matches.push(node);
|
||||
for (const child of node.children) {
|
||||
if (typeof child !== "string") matches.push(...findAll(child, predicate));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findOne(node: HostNode, predicate: (node: HostNode) => boolean): HostNode {
|
||||
const match = findAll(node, predicate)[0];
|
||||
if (!match) throw new Error("Expected host node was not rendered");
|
||||
return match;
|
||||
}
|
||||
|
||||
export function hostText(node: HostNode): string {
|
||||
return node.children.map((child) => (typeof child === "string" ? child : hostText(child))).join("");
|
||||
}
|
||||
|
||||
function eventPropName(name: string) {
|
||||
return `on${name[0].toUpperCase()}${name.slice(1)}`;
|
||||
}
|
||||
|
||||
export function dispatch(node: HostNode, name: string, overrides: Record<string, unknown> = {}) {
|
||||
const event = {
|
||||
type: name,
|
||||
target: node,
|
||||
currentTarget: node,
|
||||
defaultPrevented: false,
|
||||
propagationStopped: false,
|
||||
preventDefault() {
|
||||
event.defaultPrevented = true;
|
||||
},
|
||||
stopPropagation() {
|
||||
event.propagationStopped = true;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
if (node.props.disabled) return event;
|
||||
for (const listener of node.listeners.get(name) ?? []) listener(event);
|
||||
const handler = node.props[eventPropName(name)];
|
||||
for (const callback of Array.isArray(handler) ? handler : handler ? [handler] : []) callback(event);
|
||||
return event;
|
||||
}
|
||||
Reference in new issue
Block a user