feat(grid): decompose data grid interaction surfaces

This commit is contained in:
t8y2 committed 2026-07-16 14:00:02 +08:00
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;
}