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;
}
@@ -0,0 +1,31 @@
import { effectScope, nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
describe("useDataGridAsyncSurface", () => {
it("defers mounting until first open and keeps the surface mounted afterward", async () => {
const open = ref(false);
const scope = effectScope();
const mounted = scope.run(() => useDataGridAsyncSurface(open))!;
expect(mounted.value).toBe(false);
open.value = true;
await nextTick();
expect(mounted.value).toBe(true);
open.value = false;
await nextTick();
expect(mounted.value).toBe(true);
scope.stop();
});
it("mounts immediately when restored with an already-open surface", () => {
const scope = effectScope();
const mounted = scope.run(() => useDataGridAsyncSurface(ref(true)))!;
expect(mounted.value).toBe(true);
scope.stop();
});
});
@@ -0,0 +1,32 @@
import { computed, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
describe("useDataGridAutoRefresh", () => {
afterEach(() => vi.useRealTimers());
it("refreshes on the configured interval when enabled", async () => {
vi.useFakeTimers();
const refresh = vi.fn();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => true), refresh, initialIntervalSeconds: 5 });
autoRefresh.toggle();
vi.advanceTimersByTime(5000);
await nextTick();
expect(refresh).toHaveBeenCalledOnce();
autoRefresh.stop();
});
it("does not refresh while the operation is unavailable", () => {
vi.useFakeTimers();
const refresh = vi.fn();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => false), refresh, initialIntervalSeconds: 1 });
autoRefresh.toggle();
vi.advanceTimersByTime(1000);
expect(refresh).not.toHaveBeenCalled();
autoRefresh.stop();
});
});
@@ -0,0 +1,160 @@
import { effectScope } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars } from "@/composables/useDataGridScrollbars";
function createFrameDriver() {
let nextId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
const driver: DataGridAnimationFrameDriver = {
request(callback) {
const id = nextId++;
callbacks.set(id, callback);
return id;
},
cancel(id) {
callbacks.delete(id);
},
};
return { driver, callbacks, run: (id: number) => callbacks.get(id)?.(0) };
}
function createObserver() {
const observe = vi.fn();
const disconnect = vi.fn();
return { observer: { observe, disconnect } as unknown as ResizeObserver, observe, disconnect };
}
describe("data grid visual runtimes", () => {
it("coalesces canvas frames and cancels all resources on dispose", () => {
const frames = createFrameDriver();
const resize = createObserver();
const draw = vi.fn();
const viewport = { children: [] } as unknown as Element;
const runtime = useDataGridCanvasRuntime({
draw,
syncViewport: vi.fn(),
getViewport: () => viewport,
refreshPixelRatio: vi.fn(),
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeViewport();
runtime.scheduleDraw();
runtime.scheduleDraw();
runtime.schedulePixelRatioRefresh();
expect(frames.callbacks.size).toBe(2);
runtime.dispose();
expect(frames.callbacks.size).toBe(0);
expect(resize.disconnect).toHaveBeenCalledOnce();
expect(draw).not.toHaveBeenCalled();
});
it("keeps a large scroll burst within one scheduled draw frame", () => {
const frames = createFrameDriver();
const draw = vi.fn();
const runtime = useDataGridCanvasRuntime({
draw,
syncViewport: vi.fn(),
getViewport: () => ({ children: [] }) as unknown as Element,
frameDriver: frames.driver,
});
for (let index = 0; index < 10_000; index++) runtime.scheduleDraw();
expect(frames.callbacks.size).toBe(1);
const frameId = [...frames.callbacks.keys()][0];
frames.run(frameId);
expect(draw).toHaveBeenCalledOnce();
runtime.dispose();
});
it("keeps the latest drag coordinate and disconnects observed scroll content", () => {
const frames = createFrameDriver();
const resize = createObserver();
const horizontalDrag = vi.fn();
const child = { children: [] } as unknown as Element;
const scroller = { children: [child] } as unknown as Element;
const runtime = useDataGridScrollbars({
update: vi.fn(),
getScroller: () => scroller,
applyHorizontalDrag: horizontalDrag,
applyVerticalDrag: vi.fn(),
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeScroller();
runtime.scheduleHorizontalDrag(10);
runtime.scheduleHorizontalDrag(25);
runtime.flushHorizontalDrag();
expect(horizontalDrag).toHaveBeenCalledWith(25);
expect(resize.observe).toHaveBeenCalledTimes(2);
runtime.dispose();
expect(resize.disconnect).toHaveBeenCalledOnce();
expect(frames.callbacks.size).toBe(0);
});
it("tears down pending canvas work when its Vue scope stops", () => {
const frames = createFrameDriver();
const resize = createObserver();
const draw = vi.fn();
const syncViewport = vi.fn();
const scope = effectScope();
const runtime = scope.run(() =>
useDataGridCanvasRuntime({
draw,
syncViewport,
getViewport: () => ({ children: [] }) as unknown as Element,
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
}),
)!;
runtime.observeViewport();
runtime.scheduleDraw();
const lateDraw = [...frames.callbacks.values()][0];
scope.stop();
expect(runtime.disposed).toBe(true);
expect(runtime.active).toBe(false);
expect(frames.callbacks.size).toBe(0);
expect(resize.disconnect).toHaveBeenCalledOnce();
lateDraw(0);
expect(draw).not.toHaveBeenCalled();
expect(syncViewport).toHaveBeenCalledOnce();
});
it("does not revive scroll work after disposal", () => {
const frames = createFrameDriver();
const resize = createObserver();
const update = vi.fn();
const verticalDrag = vi.fn();
const runtime = useDataGridScrollbars({
update,
getScroller: () => ({ children: [] }) as unknown as Element,
applyHorizontalDrag: vi.fn(),
applyVerticalDrag: verticalDrag,
frameDriver: frames.driver,
createResizeObserver: () => resize.observer,
});
runtime.observeScroller();
runtime.scheduleVerticalDrag(42);
const lateCallbacks = [...frames.callbacks.values()];
runtime.dispose();
runtime.resume();
runtime.scheduleUpdate();
runtime.flushVerticalDrag();
lateCallbacks.forEach((callback) => callback(0));
expect(runtime.disposed).toBe(true);
expect(runtime.active).toBe(false);
expect(frames.callbacks.size).toBe(0);
expect(update).not.toHaveBeenCalled();
expect(verticalDrag).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { effectScope, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
describe("useDataGridColumnLayout", () => {
beforeEach(() => localStorage.clear());
it("builds cumulative offsets", () => {
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
});
it("windows columns while preserving spacer widths", () => {
const widths = [100, 100, 100, 100, 100];
const offsets = dataGridColumnOffsets(widths);
expect(dataGridHorizontalColumnWindow({ widths, offsets, columnCount: 5, scrollLeft: 250, viewportWidth: 100, rowNumberWidth: 40, bufferPx: 0 })).toEqual({ start: 2, end: 4, beforeWidth: 200, afterWidth: 100 });
});
it("returns an empty window without columns", () => {
expect(dataGridHorizontalColumnWindow({ widths: [], offsets: [0], columnCount: 0, scrollLeft: 0, viewportWidth: 0, rowNumberWidth: 40, bufferPx: 900 })).toEqual({
start: 0,
end: 0,
beforeWidth: 0,
afterWidth: 0,
});
});
it("owns visibility, null-column toggles, and persisted ordering", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "empty"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([2]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "empty\0\0"]),
layoutScopeKey: ref("test-layout"),
tableScopeKey: ref(""),
}),
)!;
state.toggleColumnVisibility(1);
expect(state.visibleColumnIndexes.value).toEqual([0, 2]);
state.toggleAllNullColumns();
expect(state.visibleColumnIndexes.value).toEqual([0]);
state.showAllColumns();
expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]);
state.persistColumnOrder([1, 0, 2]);
expect(state.orderedDisplayableColumnIndexes.value).toEqual([1, 0, 2]);
scope.stop();
});
it("keeps a new resize active when the previous resize completion frame is pending", () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
const frame = nextFrame++;
frames.set(frame, callback);
return frame;
}),
);
vi.stubGlobal(
"cancelAnimationFrame",
vi.fn((frame: number) => frames.delete(frame)),
);
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id"]),
visibleColumnIndexes: ref([0]),
renderedColumnWidths: ref([100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
}),
)!;
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
window.dispatchEvent(new MouseEvent("mouseup"));
expect(frames.size).toBe(1);
layout.startColumnHeaderResize(0, new MouseEvent("mousedown"));
expect(frames.size).toBe(0);
expect(layout.columnHeaderResizeActive.value).toBe(true);
scope.stop();
});
it("commits column drag order and removes global interaction state on disposal", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const first = document.createElement("div");
first.dataset.visibleColIndex = "0";
first.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
const second = document.createElement("div");
second.dataset.visibleColIndex = "1";
second.getBoundingClientRect = () => ({ left: 100, width: 100, right: 200, top: 0, bottom: 20, height: 20, x: 100, y: 0, toJSON: () => ({}) });
const header = document.createElement("div");
header.append(first, second);
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
expect(document.body.style.userSelect).toBe("none");
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledWith([1, 0]);
expect(document.body.style.userSelect).toBe("");
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 20, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
scope.stop();
expect(document.body.style.userSelect).toBe("");
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
import { computed, nextTick, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
import { describe, expect, it } from "vitest";
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean }) {
@@ -0,0 +1,137 @@
import { effectScope, nextTick, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridConditionEditor } from "@/composables/useDataGridConditionEditor";
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
});
function keyboardEvent(key: string) {
return { key, shiftKey: false, preventDefault: vi.fn() } as unknown as KeyboardEvent;
}
describe("useDataGridConditionEditor", () => {
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it("replaces the active token and supports clamped keyboard navigation", async () => {
const value = ref("status = cus");
const editor = useDataGridConditionEditor({
kind: "where",
value,
columns: ["customer_id", "customer_name"],
historyScope: {},
});
value.value = "status = cust";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["customer_id", "customer_name"]));
expect(editor.navigate(1)).toBe(true);
expect(editor.highlightedIndex.value).toBe(1);
expect(editor.navigate(1)).toBe(true);
expect(editor.highlightedIndex.value).toBe(1);
expect(editor.accept()).toBe(true);
expect(value.value).toBe("status = customer_name");
});
it("ignores stale asynchronous suggestion responses", async () => {
vi.useFakeTimers();
const value = ref("");
const resolvers = new Map<string, (values: string[]) => void>();
const editor = useDataGridConditionEditor({
kind: "where",
value,
historyScope: {},
suggestionProvider: ({ token }) => new Promise((resolve) => resolvers.set(token, resolve)),
suggestionDebounceMs: 10,
});
value.value = "cus";
await nextTick();
vi.advanceTimersByTime(10);
await nextTick();
value.value = "ord";
await nextTick();
vi.advanceTimersByTime(10);
await nextTick();
resolvers.get("ord")?.(["order_id"]);
await Promise.resolve();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
resolvers.get("cus")?.(["customer_id"]);
await Promise.resolve();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]);
});
it("loads, filters, accepts, and deletes scoped history", () => {
const scope = { connectionId: "connection", database: "db", tableName: "users" };
rememberDataGridConditionHistory("where", scope, "status = 'active'");
rememberDataGridConditionHistory("where", scope, "customer_id > 10");
const value = ref("status");
const editor = useDataGridConditionEditor({ kind: "where", value, historyScope: scope });
editor.openHistory();
expect(editor.suggestions.value.map((item) => item.value)).toEqual(["status = 'active'"]);
expect(editor.accept(0)).toBe(true);
expect(value.value).toBe("status = 'active'");
editor.openHistory();
editor.deleteHistory("status = 'active'");
expect(editor.suggestions.value).toEqual([]);
expect(editor.historyOpen.value).toBe(true);
});
it("aborts pending suggestion work when its scope is disposed", async () => {
vi.useFakeTimers();
const value = ref("");
const aborted = vi.fn();
const scope = effectScope();
scope.run(() => {
useDataGridConditionEditor({
kind: "orderBy",
value,
historyScope: {},
suggestionDebounceMs: 10,
suggestionProvider: ({ signal }) => {
signal.addEventListener("abort", aborted);
return new Promise(() => {});
},
});
});
value.value = "created";
await nextTick();
vi.advanceTimersByTime(10);
scope.stop();
expect(aborted).toHaveBeenCalledOnce();
});
it("maps Enter, Tab, arrows, and Escape without applying stale selections", async () => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind: "orderBy", value, columns: ["name"], historyScope: {} });
value.value = "na";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(1));
const down = keyboardEvent("ArrowDown");
expect(editor.handleKeydown(down)).toBe("navigate");
expect(down.preventDefault).toHaveBeenCalledOnce();
const tab = keyboardEvent("Tab");
expect(editor.handleKeydown(tab)).toBe("accept");
expect(value.value).toBe("name");
const enter = keyboardEvent("Enter");
expect(editor.handleKeydown(enter)).toBe("apply");
editor.openHistory();
const escape = keyboardEvent("Escape");
expect(editor.handleKeydown(escape)).toBe("dismiss");
expect(editor.dropdownOpen.value).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
describe("useDataGridFilterBuilder", () => {
it("normalizes values when modes change", () => {
const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" });
builder.ensureRule();
builder.updateRule("rule-1", { rawValue: "1", rawEndValue: "2", mode: "is-null" });
expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" });
});
it("skips disabled rules and applies conjunctions", async () => {
let nextId = 0;
const builder = useDataGridFilterBuilder({
columns: ["id", "name"],
createId: () => `rule-${++nextId}`,
isComplete: (rule) => !!rule.rawValue,
buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`,
});
builder.ensureRule();
builder.updateRule("rule-1", { rawValue: "1" });
builder.addRule();
builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" });
expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')");
});
it("groups conditions in rule order", () => {
const rule = (id: string, conjunction: "AND" | "OR"): DataGridStructuredFilterRule => ({ id, columnName: id, mode: "equals", rawValue: id, rawEndValue: "", conjunction });
expect(
buildDataGridStructuredWhere([
{ rule: rule("a", "AND"), condition: "a" },
{ rule: rule("b", "AND"), condition: "b" },
{ rule: rule("c", "OR"), condition: "c" },
]),
).toBe("((a) AND (b)) OR (c)");
});
});
@@ -0,0 +1,28 @@
import { nextTick, ref } from "vue";
import { describe, expect, it } from "vitest";
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
describe("useDataGridResultLifecycle", () => {
it("invalidates operations when the result identity changes", async () => {
const result = ref<object>({ id: 1 });
const lifecycle = useDataGridResultLifecycle({ resultKey: result });
const operation = lifecycle.beginOperation();
result.value = { id: 2 };
await nextTick();
expect(lifecycle.isCurrent(operation)).toBe(false);
lifecycle.cleanup();
});
it("invalidates operations explicitly and cleanup is idempotent", () => {
const lifecycle = useDataGridResultLifecycle({ resultKey: ref({ id: 1 }) });
const operation = lifecycle.beginOperation();
lifecycle.invalidate();
lifecycle.cleanup();
lifecycle.cleanup();
expect(lifecycle.isCurrent(operation)).toBe(false);
});
});
@@ -0,0 +1,27 @@
import { nextTick, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridSearch } from "@/composables/useDataGridSearch";
describe("useDataGridSearch", () => {
it("debounces matching across columns and cells", async () => {
vi.useFakeTimers();
const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") });
search.searchText.value = "ali";
await nextTick();
expect(search.matches.value).toEqual([]);
vi.advanceTimersByTime(150);
await nextTick();
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
vi.useRealTimers();
});
it("suggests columns and replaces only the active token", async () => {
const columns = ref(["customer_id", "created_at"]);
const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" });
search.searchText.value = "status = cus";
await nextTick();
expect(search.suggestions.value).toEqual(["customer_id"]);
expect(search.acceptSuggestion()).toBe(true);
expect(search.searchText.value).toBe("status = customer_id");
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { useDataGridSort } from "@/composables/useDataGridSort";
describe("useDataGridSort", () => {
it("stores the selected database or local sort state", () => {
const sort = useDataGridSort();
sort.setSort("name", 2, "desc", "local");
expect(sort.sortColumn.value).toBe("name");
expect(sort.sortColumnIndex.value).toBe(2);
expect(sort.sortDirection.value).toBe("desc");
expect(sort.sortMode.value).toBe("local");
});
it("resets to the default unsorted state", () => {
const sort = useDataGridSort();
sort.setSort("name", 2, "desc", "local");
sort.clearSort();
expect(sort.sortColumn.value).toBeNull();
expect(sort.sortColumnIndex.value).toBeNull();
expect(sort.sortDirection.value).toBe("asc");
expect(sort.sortMode.value).toBe("database");
});
});
@@ -0,0 +1,16 @@
import { ref, toValue, watch, type MaybeRefOrGetter } from "vue";
export function useDataGridAsyncSurface(open: MaybeRefOrGetter<boolean>) {
const mounted = ref(false);
watch(
() => toValue(open),
(value) => {
// Preserve the existing dialog instance after its chunk loads so closing it does not reset local UI state.
if (value) mounted.value = true;
},
{ immediate: true },
);
return mounted;
}
@@ -0,0 +1,51 @@
import { onBeforeUnmount, ref, type ComputedRef } from "vue";
export interface UseDataGridAutoRefreshOptions {
canRefresh: ComputedRef<boolean>;
refresh: () => void | Promise<void>;
initialIntervalSeconds?: number;
}
export function useDataGridAutoRefresh(options: UseDataGridAutoRefreshOptions) {
const intervalSeconds = ref(options.initialIntervalSeconds ?? 10);
const enabled = ref(false);
let timer: ReturnType<typeof setInterval> | undefined;
function stop() {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
}
function tick() {
if (!enabled.value || !options.canRefresh.value) return;
void options.refresh();
}
function start() {
stop();
if (!enabled.value) return;
timer = setInterval(tick, intervalSeconds.value * 1000);
}
function setIntervalSeconds(seconds: number) {
intervalSeconds.value = seconds;
if (enabled.value) start();
}
function toggle() {
enabled.value = !enabled.value;
if (enabled.value) start();
else stop();
}
onBeforeUnmount(stop);
return {
intervalSeconds,
enabled,
start,
stop,
setIntervalSeconds,
toggle,
};
}
@@ -0,0 +1,116 @@
import { getCurrentScope, onScopeDispose } from "vue";
export interface DataGridAnimationFrameDriver {
request(callback: FrameRequestCallback): number;
cancel(frameId: number): void;
}
export interface DataGridCanvasRuntimeOptions {
draw(): void;
syncViewport(): void;
getViewport(): Element | null;
refreshPixelRatio?: () => void;
frameDriver?: DataGridAnimationFrameDriver;
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
startPaused?: boolean;
}
export interface DataGridCanvasRuntime {
readonly active: boolean;
readonly disposed: boolean;
scheduleDraw(): void;
drawNow(): void;
schedulePixelRatioRefresh(): void;
observeViewport(): void;
pause(): void;
resume(): void;
dispose(): void;
}
function defaultFrameDriver(): DataGridAnimationFrameDriver {
return {
request: (callback) => requestAnimationFrame(callback),
cancel: (frameId) => cancelAnimationFrame(frameId),
};
}
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
}
export function useDataGridCanvasRuntime(options: DataGridCanvasRuntimeOptions): DataGridCanvasRuntime {
const frameDriver = options.frameDriver ?? defaultFrameDriver();
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
let active = !options.startPaused;
let disposed = false;
let drawFrame: number | null = null;
let pixelRatioFrame: number | null = null;
let resizeObserver: ResizeObserver | null = null;
const cancelFrames = () => {
if (drawFrame !== null) frameDriver.cancel(drawFrame);
if (pixelRatioFrame !== null) frameDriver.cancel(pixelRatioFrame);
drawFrame = null;
pixelRatioFrame = null;
};
const runtime: DataGridCanvasRuntime = {
get active() {
return active;
},
get disposed() {
return disposed;
},
scheduleDraw() {
if (!active || disposed || drawFrame !== null) return;
drawFrame = frameDriver.request(() => {
drawFrame = null;
if (active && !disposed) options.draw();
});
},
drawNow() {
if (!active || disposed) return;
if (drawFrame !== null) frameDriver.cancel(drawFrame);
drawFrame = null;
options.draw();
},
schedulePixelRatioRefresh() {
if (!active || disposed || pixelRatioFrame !== null || !options.refreshPixelRatio) return;
pixelRatioFrame = frameDriver.request(() => {
pixelRatioFrame = null;
if (active && !disposed) options.refreshPixelRatio?.();
});
},
observeViewport() {
resizeObserver?.disconnect();
resizeObserver = null;
if (!active || disposed) return;
options.syncViewport();
const viewport = options.getViewport();
if (!viewport) return;
resizeObserver = createResizeObserver(() => {
if (active && !disposed) options.syncViewport();
});
resizeObserver?.observe(viewport);
},
pause() {
active = false;
resizeObserver?.disconnect();
resizeObserver = null;
cancelFrames();
},
resume() {
if (disposed) return;
active = true;
runtime.observeViewport();
},
dispose() {
if (disposed) return;
runtime.pause();
disposed = true;
},
};
if (getCurrentScope()) onScopeDispose(runtime.dispose);
return runtime;
}
@@ -0,0 +1,67 @@
import { nextTick, ref, watch, type Ref } from "vue";
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
export function useDataGridCellDetail(options: { detail: Ref<DataGridCellDetail>; editValue: Ref<string>; onCancel: () => void }) {
const settingsStore = useSettingsStore();
const { isDark, themePalette } = useTheme();
const geometryPreviewOpen = ref(false);
const geometryCanvas = ref<HTMLCanvasElement | null>(null);
const detailsEditorContainer = ref<HTMLElement>();
const sideJsonPreviewContainer = ref<HTMLElement>();
let detailsEditor: UseCellDetailEditorReturn | null = null;
let sideJsonEditor: UseCellDetailEditorReturn | null = null;
const editorOptions = () => ({
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,
});
watch(geometryPreviewOpen, async (open) => {
if (!open) return;
await nextTick();
const canvas = geometryCanvas.value;
const detail = options.detail.value;
if (canvas && detail.value !== null && detail.value !== undefined) renderWktOnCanvas(canvas, String(detail.value));
});
watch(detailsEditorContainer, async (element) => {
if (element && !detailsEditor) {
detailsEditor = useCellDetailEditor({ onChange: (value) => (options.editValue.value = value), onEscape: options.onCancel, ...editorOptions() });
await detailsEditor.create(element, options.editValue.value, options.detail.value.type);
} else if (!element && detailsEditor) {
detailsEditor.destroy();
detailsEditor = null;
}
});
watch(sideJsonPreviewContainer, async (element) => {
if (element && !sideJsonEditor) {
sideJsonEditor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() });
await sideJsonEditor.create(element, options.detail.value.formattedJson ?? "", "json");
} else if (!element && sideJsonEditor) {
sideJsonEditor.destroy();
sideJsonEditor = null;
}
});
watch(
() => options.detail.value.formattedJson ?? "",
(value) => sideJsonEditor?.setValue(value, "json"),
);
watch(options.editValue, (value) => detailsEditor?.setValue(value, options.detail.value.type));
return {
geometryPreviewOpen,
geometryCanvas,
detailsEditorContainer,
sideJsonPreviewContainer,
openSearch: () => detailsEditor?.openSearch() ?? sideJsonEditor?.openSearch() ?? false,
};
}
@@ -0,0 +1,512 @@
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
loadDataGridColumnOrder,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
removeDataGridColumnOrder,
removeTableDataGridColumnOrder,
saveDataGridColumnOrder,
saveTableDataGridColumnOrder,
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
export type RenderedDataGridColumn = {
visibleColIdx: number;
actualColIdx: number;
name: string;
};
export type DataGridHorizontalColumnWindow = {
start: number;
end: number;
beforeWidth: number;
afterWidth: number;
};
type ColumnHeaderDragState = {
sourceVisibleIndex: number;
targetVisibleIndex: number;
startX: number;
startY: number;
currentX: number;
columnRects: { visibleIndex: number; left: number; width: number }[];
dragging: boolean;
};
export function dataGridColumnOffsets(widths: readonly number[]): number[] {
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
return offsets;
}
export function dataGridHorizontalColumnWindow(options: { widths: readonly number[]; offsets: readonly number[]; columnCount: number; scrollLeft: number; viewportWidth: number; rowNumberWidth: number; bufferPx: number }): DataGridHorizontalColumnWindow {
const { widths, offsets, columnCount } = options;
if (columnCount === 0 || widths.length === 0) return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 };
const viewportStart = Math.max(0, options.scrollLeft - options.rowNumberWidth - options.bufferPx);
const viewportEnd = Math.max(options.viewportWidth, 1) + Math.max(0, options.scrollLeft - options.rowNumberWidth) + options.bufferPx;
let low = 0;
let high = columnCount - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((offsets[mid + 1] ?? 0) < viewportStart) low = mid + 1;
else high = mid;
}
const start = low;
let end = start;
while (end < columnCount && (offsets[end] ?? 0) < viewportEnd) end++;
const columnsWidth = offsets[columnCount] ?? 0;
const visibleWidth = offsets[end] ?? offsets[start] ?? 0;
return { start, end, beforeWidth: offsets[start] ?? 0, afterWidth: Math.max(0, columnsWidth - visibleWidth) };
}
export function useDataGridColumnLayoutState(options: {
columns: MaybeRefOrGetter<readonly string[]>;
sourceColumns?: MaybeRefOrGetter<readonly (string | undefined)[] | undefined>;
commentByColumn?: MaybeRefOrGetter<ReadonlyMap<string, string>>;
displayableColumnIndexes: MaybeRefOrGetter<readonly number[]>;
allNullColumnIndexes: MaybeRefOrGetter<readonly number[]>;
columnOrderKeys: MaybeRefOrGetter<readonly string[]>;
layoutScopeKey: MaybeRefOrGetter<string>;
tableScopeKey: MaybeRefOrGetter<string>;
onRefreshMetrics?: () => void;
}) {
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const persistedColumnOrderKeys = ref<string[]>([]);
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnIndexes.value.length);
const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length);
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value));
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1));
function filteredColumnVisibilityOptions(query: string) {
const displayable = new Set(toValue(options.displayableColumnIndexes));
return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index));
}
function isColumnVisible(columnIndex: number) {
return !hiddenColumnIndexes.value.has(columnIndex);
}
function toggleColumnVisibility(columnIndex: number) {
hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value });
}
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
}
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value);
}
function loadColumnOrder() {
const tableScopeKey = toValue(options.tableScopeKey);
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
}
function persistColumnOrder(indexes: number[]) {
const tableScopeKey = toValue(options.tableScopeKey);
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
persistedColumnOrderKeys.value = [];
return;
}
const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
persistedColumnOrderKeys.value = keys;
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys);
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, keys);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
}
function resetColumnOrder() {
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
persistedColumnOrderKeys.value = [];
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
function showAllNullColumns() {
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
}
function hideAllNullColumns() {
const next = hiddenColumnIndexesWithAllNullColumns({ availableIndexes: [...toValue(options.displayableColumnIndexes)], hiddenIndexes: hiddenColumnIndexes.value, allNullIndexes: new Set(toValue(options.allNullColumnIndexes)) });
hiddenColumnIndexes.value = next.hiddenIndexes;
autoHiddenNullColumnIndexes.value = next.autoHiddenIndexes;
nullColumnsHidden.value = next.autoHiddenIndexes.size > 0;
}
function toggleAllNullColumns() {
if (nullColumnsHidden.value) showAllNullColumns();
else hideAllNullColumns();
}
function onTableDataGridColumnOrderChanged(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined;
if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return;
loadColumnOrder();
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
function resetColumnVisibility() {
hiddenColumnIndexes.value = new Set();
autoHiddenNullColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
}
watch(
() => [...toValue(options.allNullColumnIndexes)],
() => {
if (!nullColumnsHidden.value) return;
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
hideAllNullColumns();
},
);
watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true });
return {
hiddenColumnIndexes,
nullColumnsHidden,
orderedDisplayableColumnIndexes,
visibleColumnIndexes,
displayableColumnCount,
hiddenColumnCount,
allNullColumnCount,
hasCustomColumnOrder,
canToggleAllNullColumns,
filteredColumnVisibilityOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
persistColumnOrder,
resetColumnOrder,
toggleAllNullColumns,
resetColumnVisibility,
onTableDataGridColumnOrderChanged,
};
}
export function useDataGridColumnLayout(options: {
columnNames: MaybeRefOrGetter<readonly string[]>;
visibleColumnIndexes: MaybeRefOrGetter<readonly number[]>;
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
scrollLeft: MaybeRefOrGetter<number>;
viewportWidth: MaybeRefOrGetter<number>;
rowNumberWidth: number;
bufferPx?: number;
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
hiddenColumnIndexes?: MaybeRefOrGetter<ReadonlySet<number>>;
getIsResizing?: () => boolean;
onResizeStart?: (visibleColIdx: number, event: MouseEvent) => void;
onCanvasMouseLeave?: () => void;
onCanvasDrawSchedule?: () => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const horizontalColumnWindow = computed(() =>
dataGridHorizontalColumnWindow({
widths: toValue(options.renderedColumnWidths),
offsets: renderedColumnOffsets.value,
columnCount: toValue(options.visibleColumnIndexes).length,
scrollLeft: toValue(options.scrollLeft),
viewportWidth: toValue(options.viewportWidth),
rowNumberWidth: options.rowNumberWidth,
bufferPx: options.bufferPx ?? 900,
}),
);
const renderedGridColumns = computed<RenderedDataGridColumn[]>(() => {
const columnNames = toValue(options.columnNames);
const visibleIndexes = toValue(options.visibleColumnIndexes);
const window = horizontalColumnWindow.value;
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
});
function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
}
function columnContentOffsetLeft(visibleColIdx: number): number {
return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
}
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
const columnHeaderResizeActive = ref(false);
let columnHeaderDragClickGuardUntil = 0;
let columnHeaderSuppressNextClick = false;
let columnHeaderSuppressClickTimer = 0;
let columnHeaderDragFrame = 0;
let columnHeaderResizeFrame = 0;
let columnHeaderPendingClientX = 0;
let columnHeaderResizeListenersCleanup: (() => void) | null = null;
const columnHeaderTooltipsDisabled = computed(() =>
columnHeaderTooltipDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
}),
);
function columnHeaderPointerInteractionActive(): boolean {
return columnHeaderCanvasPointerDisabled({
columnDragActive: columnHeaderDragState.value !== null,
columnResizeActive: columnHeaderResizeActive.value,
});
}
function clearColumnHeaderResizeListeners() {
columnHeaderResizeListenersCleanup?.();
columnHeaderResizeListenersCleanup = null;
}
function clearColumnHeaderClickGuard() {
columnHeaderSuppressNextClick = false;
columnHeaderDragClickGuardUntil = 0;
if (columnHeaderSuppressClickTimer) {
window.clearTimeout(columnHeaderSuppressClickTimer);
columnHeaderSuppressClickTimer = 0;
}
}
function armColumnHeaderClickGuard() {
clearColumnHeaderClickGuard();
columnHeaderSuppressNextClick = true;
columnHeaderDragClickGuardUntil = Date.now() + 800;
columnHeaderSuppressClickTimer = window.setTimeout(clearColumnHeaderClickGuard, 800);
}
function finishColumnHeaderResizeInteraction() {
clearColumnHeaderResizeListeners();
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = requestAnimationFrame(() => {
columnHeaderResizeFrame = 0;
columnHeaderResizeActive.value = false;
});
}
function startColumnHeaderResize(visibleColIdx: number, event: MouseEvent) {
clearColumnHeaderResizeListeners();
if (columnHeaderResizeFrame) {
cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = 0;
}
columnHeaderResizeActive.value = true;
armColumnHeaderClickGuard();
options.onCanvasMouseLeave?.();
const finishResize = () => {
armColumnHeaderClickGuard();
finishColumnHeaderResizeInteraction();
};
columnHeaderResizeListenersCleanup = () => {
window.removeEventListener("mouseup", finishResize, true);
window.removeEventListener("blur", finishResize, true);
};
window.addEventListener("mouseup", finishResize, true);
window.addEventListener("blur", finishResize, true);
options.onResizeStart?.(visibleColIdx, event);
}
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
}
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
const state = columnHeaderDragState.value;
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
for (const rect of state.columnRects) {
if (clientX < rect.left + rect.width / 2) return rect.visibleIndex;
}
return toValue(options.visibleColumnIndexes).length;
}
function applyColumnHeaderDragPreview() {
columnHeaderDragFrame = 0;
const state = columnHeaderDragState.value;
if (!state?.dragging) return;
state.currentX = columnHeaderPendingClientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(columnHeaderPendingClientX);
options.onCanvasDrawSchedule?.();
}
function scheduleColumnHeaderDragPreview(clientX: number) {
columnHeaderPendingClientX = clientX;
if (columnHeaderDragFrame) return;
columnHeaderDragFrame = requestAnimationFrame(applyColumnHeaderDragPreview);
}
function flushColumnHeaderDragPreview() {
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
applyColumnHeaderDragPreview();
}
function cancelColumnHeaderDragPreview() {
if (!columnHeaderDragFrame) return;
cancelAnimationFrame(columnHeaderDragFrame);
columnHeaderDragFrame = 0;
}
function columnHeaderLayoutRects() {
const header = toValue(options.headerRef);
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
.map((element) => {
const rect = element.getBoundingClientRect();
return { visibleIndex: Number(element.dataset.visibleColIndex), left: rect.left, width: rect.width };
})
.filter((rect) => Number.isFinite(rect.visibleIndex));
}
function stopColumnHeaderDrag(commit: boolean) {
const state = columnHeaderDragState.value;
if (!state) return;
const hadCanvasPreview = state.dragging;
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
cancelColumnHeaderDragPreview();
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (hadCanvasPreview) options.onCanvasDrawSchedule?.();
if (state.dragging) armColumnHeaderClickGuard();
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
const next = moveVisibleColumnIndex({
orderedIndexes: toValue(options.orderedColumnIndexes ?? options.visibleColumnIndexes),
hiddenIndexes: toValue(options.hiddenColumnIndexes ?? (() => new Set<number>())),
fromVisibleIndex: state.sourceVisibleIndex,
toVisibleIndex: state.targetVisibleIndex,
});
options.onPersistColumnOrder?.(next);
options.onRefreshMetrics?.();
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5;
if (!state.dragging && moved) {
state.dragging = true;
document.body.style.userSelect = "none";
options.onCanvasMouseLeave?.();
}
if (!state.dragging) return;
event.preventDefault();
scheduleColumnHeaderDragPreview(event.clientX);
}
function onColumnHeaderPointerUp(event: PointerEvent) {
columnHeaderPendingClientX = event.clientX;
flushColumnHeaderDragPreview();
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
stopColumnHeaderDrag(false);
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
columnRects: columnHeaderLayoutRects(),
dragging: false,
};
columnHeaderPendingClientX = event.clientX;
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
}
function suppressHeaderClickIfNeeded(event: MouseEvent): boolean {
if (!columnHeaderClickShouldBeSuppressed({ now: Date.now(), guardUntil: columnHeaderDragClickGuardUntil, suppressNextClick: columnHeaderSuppressNextClick })) return false;
clearColumnHeaderClickGuard();
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return true;
}
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return { "z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state) return 0;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
visibleColIdx,
sourceVisibleIndex: state.sourceVisibleIndex,
targetVisibleIndex: state.targetVisibleIndex,
startX: state.startX,
currentX: state.currentX,
sourceWidth: toValue(options.renderedColumnWidths)[state.sourceVisibleIndex] ?? 0,
});
}
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
if (!offset) return style;
return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" };
}
const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
const state = columnHeaderDragState.value;
return state?.dragging ? state.sourceVisibleIndex : null;
});
function disposeColumnHeaderInteractions() {
stopColumnHeaderDrag(false);
clearColumnHeaderResizeListeners();
clearColumnHeaderClickGuard();
if (columnHeaderDragFrame) cancelAnimationFrame(columnHeaderDragFrame);
if (columnHeaderResizeFrame) cancelAnimationFrame(columnHeaderResizeFrame);
columnHeaderResizeFrame = 0;
document.body.style.userSelect = "";
}
onScopeDispose(disposeColumnHeaderInteractions);
return {
renderedColumnOffsets,
horizontalColumnWindow,
renderedGridColumns,
renderedColumnStyle,
columnContentOffsetLeft,
columnHeaderDragState,
columnHeaderResizeActive,
columnHeaderTooltipsDisabled,
columnHeaderPreviewOffsets,
columnHeaderPreviewSourceVisibleIndex,
columnHeaderPointerInteractionActive,
startColumnHeaderResize,
startColumnHeaderDrag,
suppressHeaderClickIfNeeded,
columnHeaderDragClass,
columnHeaderStyle,
disposeColumnHeaderInteractions,
};
}
@@ -0,0 +1,213 @@
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
export type DataGridConditionSuggestionKind = "column" | "history";
export interface DataGridConditionSuggestion {
value: string;
kind: DataGridConditionSuggestionKind;
}
export interface DataGridConditionSuggestionContext {
kind: DataGridConditionHistoryKind;
value: string;
token: string;
signal: AbortSignal;
}
export type DataGridConditionSuggestionProvider = (context: DataGridConditionSuggestionContext) => readonly string[] | Promise<readonly string[]>;
export interface UseDataGridConditionEditorOptions {
kind: DataGridConditionHistoryKind;
value: Ref<string>;
columns?: MaybeRefOrGetter<readonly string[] | undefined>;
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
suggestionProvider?: DataGridConditionSuggestionProvider;
suggestionDebounceMs?: number;
suggestionLimit?: number;
}
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
return (
value
.trim()
.split(kind === "where" ? /[\s,()><=!&|]+/ : /[\s,()]+/)
.pop() ?? ""
);
}
function replaceActiveToken(kind: DataGridConditionHistoryKind, value: string, replacement: string): string {
const match = value.match(kind === "where" ? WHERE_TOKEN_PATTERN : ORDER_BY_TOKEN_PATTERN);
if (!match) return value;
return `${value.slice(0, -match[1].length)}${replacement}`;
}
export function useDataGridConditionEditor(options: UseDataGridConditionEditorOptions) {
const suggestions = ref<DataGridConditionSuggestion[]>([]);
const highlightedIndex = ref(-1);
const historyOpen = ref(false);
const suggestionsLoading = ref(false);
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
let suggestionRequestId = 0;
let suggestionAbortController: AbortController | undefined;
let suppressNextValueSuggestion = false;
const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value);
function cancelSuggestionRequest() {
if (suggestionTimer) clearTimeout(suggestionTimer);
suggestionTimer = undefined;
suggestionRequestId += 1;
suggestionAbortController?.abort();
suggestionAbortController = undefined;
suggestionsLoading.value = false;
}
function dismiss() {
cancelSuggestionRequest();
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
}
function defaultSuggestions(token: string): readonly string[] {
const normalizedToken = token.toLowerCase();
if (!normalizedToken) return [];
return (toValue(options.columns) ?? []).filter((column) => column.toLowerCase().startsWith(normalizedToken) && column.toLowerCase() !== normalizedToken);
}
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
const token = activeToken(options.kind, value);
if (!token) return;
suggestionsLoading.value = true;
try {
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : defaultSuggestions(token);
// A slower request must never replace suggestions for a newer editor value.
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
const limit = options.suggestionLimit ?? 8;
suggestions.value = [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }));
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
} catch (error) {
if (!controller.signal.aborted && requestId === suggestionRequestId) {
suggestions.value = [];
highlightedIndex.value = -1;
console.warn("[DBX][condition-editor] Failed to load suggestions", error);
}
} finally {
if (requestId === suggestionRequestId) suggestionsLoading.value = false;
}
}
function scheduleSuggestions(value: string) {
cancelSuggestionRequest();
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
if (!value.trim()) return;
const requestId = suggestionRequestId;
const controller = new AbortController();
suggestionAbortController = controller;
suggestionTimer = setTimeout(() => {
suggestionTimer = undefined;
void loadSuggestions(value, requestId, controller);
}, options.suggestionDebounceMs ?? 0);
}
function openHistory() {
cancelSuggestionRequest();
if (dropdownOpen.value) {
dismiss();
return;
}
historyOpen.value = true;
suggestions.value = loadDataGridConditionHistory(options.kind, toValue(options.historyScope), options.value.value).map((value) => ({ value, kind: "history" }));
highlightedIndex.value = -1;
}
function deleteHistory(value: string) {
const history = forgetDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
const query = options.value.value.trim().toLowerCase();
suggestions.value = history.filter((item) => !query || item.toLowerCase().includes(query)).map((item) => ({ value: item, kind: "history" }));
highlightedIndex.value = suggestions.value.length > 0 ? Math.min(Math.max(highlightedIndex.value, 0), suggestions.value.length - 1) : -1;
historyOpen.value = true;
}
function rememberHistory(value = options.value.value) {
return rememberDataGridConditionHistory(options.kind, toValue(options.historyScope), value);
}
function navigate(delta: number) {
if (suggestions.value.length === 0) return false;
if (highlightedIndex.value < 0) {
highlightedIndex.value = delta > 0 ? 0 : suggestions.value.length - 1;
} else {
highlightedIndex.value = Math.min(Math.max(highlightedIndex.value + delta, 0), suggestions.value.length - 1);
}
return true;
}
function accept(index = highlightedIndex.value) {
const suggestion = suggestions.value[index];
if (!suggestion) return false;
suppressNextValueSuggestion = true;
options.value.value = suggestion.kind === "history" ? suggestion.value : replaceActiveToken(options.kind, options.value.value, suggestion.value);
dismiss();
return true;
}
function handleKeydown(event: KeyboardEvent): "accept" | "apply" | "dismiss" | "navigate" | undefined {
if (dropdownOpen.value && event.key === "Escape") {
event.preventDefault();
dismiss();
return "dismiss";
}
if (suggestions.value.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
navigate(event.key === "ArrowDown" ? 1 : -1);
return "navigate";
}
if (suggestions.value.length > 0 && event.key === "Tab") {
event.preventDefault();
accept();
return "accept";
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
accept();
return "accept";
}
return "apply";
}
return undefined;
}
watch(options.value, (value) => {
if (suppressNextValueSuggestion) {
suppressNextValueSuggestion = false;
return;
}
scheduleSuggestions(value);
});
if (getCurrentScope()) onScopeDispose(cancelSuggestionRequest);
return {
suggestions,
highlightedIndex,
historyOpen,
suggestionsLoading,
dropdownOpen,
scheduleSuggestions,
openHistory,
deleteHistory,
rememberHistory,
dismiss,
navigate,
accept,
handleKeydown,
};
}
@@ -0,0 +1,93 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
export type DataGridStructuredFilterRule = {
id: string;
columnName: string;
mode: DataGridContextFilterMode;
rawValue: string;
rawEndValue: string;
conjunction: "AND" | "OR";
disabled?: boolean;
};
export type UseDataGridFilterBuilderOptions = {
columns: MaybeRefOrGetter<readonly string[]>;
createId?: () => string;
isComplete: (rule: DataGridStructuredFilterRule) => boolean;
buildCondition: (rule: DataGridStructuredFilterRule) => Promise<string | undefined>;
};
export function buildDataGridStructuredWhere(items: Array<{ rule: DataGridStructuredFilterRule; condition: string }>): string {
if (!items.length) return "";
let result = items[0].condition;
for (let index = 1; index < items.length; index++) {
result = `(${result}) ${items[index].rule.conjunction} (${items[index].condition})`;
}
return result;
}
export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOptions) {
const rules = ref<DataGridStructuredFilterRule[]>([]);
const open = ref(false);
const columnSearch = ref("");
const appliedWhereInput = ref("");
const filteredColumns = computed(() => {
const query = columnSearch.value.trim().toLowerCase();
return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)];
});
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
function defaultRule(): DataGridStructuredFilterRule {
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
}
function ensureRule() {
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
}
function addRule() {
ensureRule();
rules.value = [...rules.value, defaultRule()];
}
function removeRule(id: string) {
rules.value = rules.value.filter((rule) => rule.id !== id);
if (!rules.value.length) appliedWhereInput.value = "";
}
function updateRule(id: string, patch: Partial<DataGridStructuredFilterRule>) {
rules.value = rules.value.map((rule) => {
if (rule.id !== id) return rule;
const next = { ...rule, ...patch };
if (!filterModeNeedsValue(next.mode)) next.rawValue = next.rawEndValue = "";
else if (!filterModeUsesRange(next.mode)) next.rawEndValue = "";
return next;
});
}
function reset() {
appliedWhereInput.value = "";
rules.value = toValue(options.columns).length ? [defaultRule()] : [];
}
async function buildWhere() {
const items = (await Promise.all(rules.value.map(async (rule) => ({ rule, condition: !rule.disabled && rule.columnName && options.isComplete(rule) ? await options.buildCondition(rule) : undefined })))).filter(
(item): item is { rule: DataGridStructuredFilterRule; condition: string } => !!item.condition,
);
return buildDataGridStructuredWhere(items);
}
async function apply() {
appliedWhereInput.value = await buildWhere();
open.value = false;
return appliedWhereInput.value;
}
watch(open, (value) => {
if (!value) columnSearch.value = "";
});
watch(
() => [...toValue(options.columns)],
(columns) => {
if (!columns.length) rules.value = [];
else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
},
);
return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, reset, buildWhere, apply };
}
@@ -0,0 +1,38 @@
import { onUnmounted, watch, type Ref } from "vue";
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
export interface UseDataGridResultLifecycleOptions {
resultKey: Ref<unknown>;
runtimeScope?: DataGridRuntimeScope;
}
export function useDataGridResultLifecycle(options: UseDataGridResultLifecycleOptions) {
let version = 0;
let disposed = false;
const invalidate = () => {
version += 1;
};
const stopWatchingResult = watch(options.resultKey, invalidate);
const cleanup = () => {
if (disposed) return;
disposed = true;
invalidate();
stopWatchingResult();
};
if (options.runtimeScope) options.runtimeScope.addCleanup(cleanup);
else onUnmounted(cleanup);
return {
beginOperation() {
return version;
},
isCurrent(operationVersion: number) {
return !disposed && operationVersion === version;
},
invalidate,
cleanup,
};
}
@@ -0,0 +1,126 @@
import { getCurrentScope, onScopeDispose } from "vue";
import type { DataGridAnimationFrameDriver } from "./useDataGridCanvasRuntime";
export interface DataGridScrollbarsOptions {
update(): void;
getScroller(): Element | null;
applyHorizontalDrag(clientX: number): void;
applyVerticalDrag(clientY: number): void;
frameDriver: DataGridAnimationFrameDriver;
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
startPaused?: boolean;
}
export interface DataGridScrollbarsRuntime {
readonly active: boolean;
readonly disposed: boolean;
scheduleUpdate(): void;
scheduleHorizontalDrag(clientX: number): void;
scheduleVerticalDrag(clientY: number): void;
flushHorizontalDrag(): void;
flushVerticalDrag(): void;
observeScroller(): void;
pause(): void;
resume(): void;
dispose(): void;
}
function defaultResizeObserverFactory(callback: ResizeObserverCallback): ResizeObserver | null {
return typeof ResizeObserver === "undefined" ? null : new ResizeObserver(callback);
}
export function useDataGridScrollbars(options: DataGridScrollbarsOptions): DataGridScrollbarsRuntime {
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory;
let active = !options.startPaused;
let disposed = false;
let updateFrame: number | null = null;
let horizontalFrame: number | null = null;
let verticalFrame: number | null = null;
let pendingClientX = 0;
let pendingClientY = 0;
let resizeObserver: ResizeObserver | null = null;
const cancelFrame = (frame: number | null) => {
if (frame !== null) options.frameDriver.cancel(frame);
};
const runtime: DataGridScrollbarsRuntime = {
get active() {
return active;
},
get disposed() {
return disposed;
},
scheduleUpdate() {
if (!active || disposed || updateFrame !== null) return;
updateFrame = options.frameDriver.request(() => {
updateFrame = null;
if (active && !disposed) options.update();
});
},
scheduleHorizontalDrag(clientX) {
pendingClientX = clientX;
if (!active || disposed || horizontalFrame !== null) return;
horizontalFrame = options.frameDriver.request(() => {
horizontalFrame = null;
if (active && !disposed) options.applyHorizontalDrag(pendingClientX);
});
},
scheduleVerticalDrag(clientY) {
pendingClientY = clientY;
if (!active || disposed || verticalFrame !== null) return;
verticalFrame = options.frameDriver.request(() => {
verticalFrame = null;
if (active && !disposed) options.applyVerticalDrag(pendingClientY);
});
},
flushHorizontalDrag() {
if (!active || disposed || horizontalFrame === null) return;
options.frameDriver.cancel(horizontalFrame);
horizontalFrame = null;
options.applyHorizontalDrag(pendingClientX);
},
flushVerticalDrag() {
if (!active || disposed || verticalFrame === null) return;
options.frameDriver.cancel(verticalFrame);
verticalFrame = null;
options.applyVerticalDrag(pendingClientY);
},
observeScroller() {
resizeObserver?.disconnect();
resizeObserver = null;
if (!active || disposed) return;
const scroller = options.getScroller();
if (scroller) {
resizeObserver = createResizeObserver(runtime.scheduleUpdate);
resizeObserver?.observe(scroller);
// Child size changes can alter scrollWidth without resizing the scroller itself.
for (const child of Array.from(scroller.children)) resizeObserver?.observe(child);
}
runtime.scheduleUpdate();
},
pause() {
active = false;
resizeObserver?.disconnect();
resizeObserver = null;
cancelFrame(updateFrame);
cancelFrame(horizontalFrame);
cancelFrame(verticalFrame);
updateFrame = null;
horizontalFrame = null;
verticalFrame = null;
},
resume() {
if (disposed) return;
active = true;
runtime.observeScroller();
},
dispose() {
if (disposed) return;
runtime.pause();
disposed = true;
},
};
if (getCurrentScope()) onScopeDispose(runtime.dispose);
return runtime;
}
@@ -0,0 +1,131 @@
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
export type DataGridSearchMatch = {
kind: "cell" | "column";
displayRow: number;
col: number;
};
export type UseDataGridSearchOptions<Row> = {
columns: MaybeRefOrGetter<readonly string[]>;
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
rows: MaybeRefOrGetter<readonly Row[]>;
getCellText: (row: Row, columnIndex: number) => string;
debounceMs?: number;
onNavigate?: (match: DataGridSearchMatch) => void;
};
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
const SEARCH_TOKEN_SUFFIX = /([^\s,()><=!&|]+)$/;
const SEARCH_PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
const searchText = ref("");
const deferredSearchText = ref("");
const overlayVisible = ref(false);
const currentMatchIndex = ref(-1);
const suggestions = ref<string[]>([]);
const suggestionIndex = ref(-1);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const matches = computed<DataGridSearchMatch[]>(() => {
const query = deferredSearchText.value;
if (!query) return [];
const result: DataGridSearchMatch[] = [];
const columns = toValue(options.columns);
columns.forEach((column, col) => {
if (column.toLowerCase().includes(query)) result.push({ kind: "column", displayRow: -1, col });
});
toValue(options.rows).forEach((row, displayRow) => {
columns.forEach((_, col) => {
if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col });
});
});
return result;
});
const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`)));
const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null);
function clearTimer() {
if (searchTimer !== undefined) clearTimeout(searchTimer);
searchTimer = undefined;
}
watch(searchText, (value) => {
clearTimer();
const query = value.trim().toLowerCase();
if (!query) deferredSearchText.value = "";
else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150);
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
suggestions.value = lastToken
? toValue(options.suggestionColumns ?? options.columns)
.filter((column) => column.toLowerCase().startsWith(lastToken) && column.toLowerCase() !== lastToken)
.slice(0, 8)
: [];
suggestionIndex.value = suggestions.value.length ? 0 : -1;
});
watch(matches, (value) => {
currentMatchIndex.value = value.length ? 0 : -1;
if (value[0]) nextTick(() => options.onNavigate?.(value[0]));
});
function acceptSuggestion(index = suggestionIndex.value) {
const suggestion = suggestions.value[index];
if (!suggestion) return false;
const token = searchText.value.match(SEARCH_TOKEN_SUFFIX)?.[1];
if (token) searchText.value = searchText.value.slice(0, -token.length) + suggestion;
suggestions.value = [];
suggestionIndex.value = -1;
return true;
}
function navigateSuggestion(delta: number) {
if (!suggestions.value.length) return;
suggestionIndex.value = Math.min(Math.max(suggestionIndex.value + delta, 0), suggestions.value.length - 1);
}
function navigateMatch(delta: number) {
if (!matches.value.length) return;
currentMatchIndex.value = (currentMatchIndex.value + delta + matches.value.length) % matches.value.length;
const match = currentMatch.value;
if (match) options.onNavigate?.(match);
}
function close() {
clearTimer();
overlayVisible.value = false;
searchText.value = "";
deferredSearchText.value = "";
suggestions.value = [];
}
function onKeydown(event: KeyboardEvent) {
const pair = SEARCH_PAIRS[event.key];
const input = event.target as HTMLInputElement;
if (pair && !event.ctrlKey && !event.metaKey && input?.setSelectionRange) {
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? start;
event.preventDefault();
const selected = searchText.value.slice(start, end);
searchText.value = `${searchText.value.slice(0, start)}${event.key}${selected}${pair}${searchText.value.slice(end)}`;
nextTick(() => input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length));
return;
}
if (suggestions.value.length && event.key === "Tab") {
event.preventDefault();
acceptSuggestion();
} else if (suggestions.value.length && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
navigateSuggestion(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Enter") {
event.preventDefault();
navigateMatch(event.shiftKey ? -1 : 1);
}
}
if (getCurrentScope()) onScopeDispose(clearTimer);
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown };
}
@@ -1,5 +1,6 @@
import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import type { DataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
type CellValue = string | number | boolean | null;
@@ -24,6 +25,7 @@ export interface UseDataGridSelectionOptions {
gridRef: Ref<HTMLDivElement | undefined>;
getScrollElement?: () => HTMLElement | null;
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
runtimeScope?: DataGridRuntimeScope;
}
const AUTO_SCROLL_EDGE_SIZE = 40;
@@ -341,7 +343,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
document.addEventListener("mousemove", handleSelectionPointerMove);
}
if (getCurrentScope()) onScopeDispose(finishCellSelection);
if (options.runtimeScope) options.runtimeScope.addCleanup(finishCellSelection);
else if (getCurrentScope()) onScopeDispose(finishCellSelection);
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
@@ -0,0 +1,29 @@
import { ref } from "vue";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
export function useDataGridSort() {
const sortColumn = ref<string | null>(null);
const sortColumnIndex = ref<number | null>(null);
const sortDirection = ref<DataGridSortDirection>("asc");
const sortMode = ref<DataGridSortMode>("database");
function setSort(column: string | null, columnIndex: number | null, direction: DataGridSortDirection, mode: DataGridSortMode) {
sortColumn.value = column;
sortColumnIndex.value = columnIndex;
sortDirection.value = direction;
sortMode.value = mode;
}
function clearSort() {
setSort(null, null, "asc", "database");
}
return {
sortColumn,
sortColumnIndex,
sortDirection,
sortMode,
setSort,
clearSort,
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { claimDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
function target(nativeClipboard: boolean): EventTarget {
return {
@@ -49,3 +49,26 @@ describe("claimDataGridPaste", () => {
expect(event.stopPropagation).toHaveBeenCalledOnce();
});
});
describe("planDataGridPaste", () => {
it("flattens a clipboard matrix within grid bounds", () => {
expect(
planDataGridPaste(
[
["a", "b"],
["c", "d"],
],
2,
1,
),
).toEqual([
{ rowOffset: 0, columnOffset: 0, value: "a" },
{ rowOffset: 1, columnOffset: 0, value: "c" },
]);
});
it("returns no cells for empty bounds", () => {
expect(planDataGridPaste([["a"]], 0, 1)).toEqual([]);
expect(planDataGridPaste([["a"]], 1, 0)).toEqual([]);
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { createDataGridCellContextMenuItems, createDataGridColumnContextMenuItems, createDataGridCompactColumnActionItems, createDataGridFilterSubmenu, createDataGridRowContextMenuItems, createDataGridSortMenuItems, dataGridSelectedSortMenuValue } from "@/lib/dataGrid/dataGridContextMenu";
const icon = {};
describe("dataGridContextMenu", () => {
it("marks only the active sort action and enables clear", () => {
const state = { column: "id", columnIndex: 0, direction: "desc" as const, mode: "local" as const };
const items = createDataGridSortMenuItems({
column: "id",
columnIndex: 0,
state,
labels: { databaseAscending: "db asc", databaseDescending: "db desc", currentPageAscending: "page asc", currentPageDescending: "page desc", clear: "clear" },
icons: { database: icon, ascending: icon, descending: icon, clear: icon },
});
expect(items.map((item) => item.checked)).toEqual([false, false, false, true, undefined]);
expect(items.at(-1)?.disabled).toBe(false);
expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc");
});
it("omits unavailable server actions and disables unavailable formatter", () => {
const items = createDataGridCompactColumnActionItems({
labels: { formatter: "formatter", localFilter: "local", serverFilter: "server" },
icons: { formatter: icon, filter: icon, database: icon },
formatterAvailable: false,
serverFilterAvailable: false,
});
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter"]);
expect(items[0]?.disabled).toBe(true);
});
it("builds typed column, cell, and row capability groups", () => {
const action = vi.fn();
const filter = createDataGridFilterSubmenu({
label: "filter",
icon,
labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" },
apply: action,
clear: action,
});
const columnItems = createDataGridColumnContextMenuItems({
headerColumn: true,
contextColumn: true,
canCopyAlterSql: true,
canFilter: true,
hasSort: true,
sortMode: "database",
labels: { copyName: "copy name", copyNames: "copy names", details: "details", copyAlterSql: "alter", databaseAscending: "db asc", databaseDescending: "db desc", localAscending: "local asc", localDescending: "local desc", clearSort: "clear sort" },
icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon },
actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action },
filterSubmenu: filter,
});
expect(columnItems.map((item) => item.label)).toContain("filter");
const cellItems = createDataGridCellContextMenuItems({
hasCell: true,
hasColumn: true,
headerColumn: false,
editable: true,
hasCellSelection: true,
hasEditableSelection: false,
hasSelection: true,
labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" },
icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon },
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
copySubmenu: { label: "copy" },
selectionSubmenu: { label: "selection" },
});
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
const rowItems = createDataGridRowContextMenuItems({
editable: true,
hasRow: true,
canClone: true,
deleted: false,
canDelete: true,
labels: { clone: "clone", restore: "restore", delete: "delete" },
icons: { clone: icon, restore: icon, delete: icon },
actions: { clone: action, restore: action, delete: action },
});
expect(rowItems.find((item) => item.label === "delete")?.variant).toBe("destructive");
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { dataGridEditAdapterKind } from "@/lib/dataGrid/dataGridEditAdapter";
describe("dataGridEditAdapter", () => {
it("prioritizes custom save handlers", () => {
expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: true, editable: true })).toBe("custom");
});
it("distinguishes document and relational editing", () => {
expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: false, editable: true })).toBe("document");
expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: true })).toBe("relational");
});
it("keeps read-only and unknown contexts unsupported", () => {
expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: false })).toBe("unsupported");
expect(dataGridEditAdapterKind({ custom: false, editable: true })).toBe("unsupported");
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
describe("data grid filter builder persistence", () => {
it("isolates scopes and returns cloned state", () => {
saveDataGridStructuredFilterState("cache", {
scopeKey: "scope-a",
manualWhereInput: "status = 1",
rules: [{ id: "rule-1", columnName: "status", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
appliedWhereInput: '"status" = 1',
serverColumnFilters: { 0: { condition: '"status" = 1', keys: ["1"], labels: ["1"] } },
});
expect(loadDataGridStructuredFilterState("cache", "scope-b")).toBeUndefined();
const restored = loadDataGridStructuredFilterState("cache", "scope-a")!;
restored.rules[0].rawValue = "2";
restored.serverColumnFilters[0].keys.push("2");
expect(loadDataGridStructuredFilterState("cache", "scope-a")).toMatchObject({
rules: [{ rawValue: "1" }],
serverColumnFilters: { 0: { keys: ["1"] } },
});
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { moveDataGridCell, navigateDataGridCell } from "@/lib/dataGrid/dataGridNavigation";
const bounds = { rowCount: 3, visibleColumnCount: 4 };
describe("dataGridNavigation", () => {
it("clamps directional movement to grid boundaries", () => {
expect(moveDataGridCell({ rowIndex: 0, colIndex: 0 }, -1, -1, bounds)).toEqual({ rowIndex: 0, colIndex: 0 });
expect(moveDataGridCell({ rowIndex: 2, colIndex: 3 }, 1, 1, bounds)).toEqual({ rowIndex: 2, colIndex: 3 });
expect(moveDataGridCell({ rowIndex: 1, colIndex: 2 }, -1, 1, bounds)).toEqual({ rowIndex: 0, colIndex: 3 });
});
it("supports home and end navigation without changing the row", () => {
expect(navigateDataGridCell({ rowIndex: 2, colIndex: 2 }, "home", bounds)).toEqual({ rowIndex: 2, colIndex: 0 });
expect(navigateDataGridCell({ rowIndex: 2, colIndex: 0 }, "end", bounds)).toEqual({ rowIndex: 2, colIndex: 3 });
});
it("returns no target for an empty grid", () => {
expect(navigateDataGridCell({ rowIndex: 0, colIndex: 0 }, "down", { rowCount: 0, visibleColumnCount: 4 })).toBeNull();
});
});
@@ -0,0 +1,46 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
describe("dataGridRuntime", () => {
it("disposes registered resources in reverse order and only once", () => {
const scope = createDataGridRuntimeScope();
const calls: string[] = [];
const removeFirst = scope.addCleanup(() => calls.push("first"));
scope.addCleanup(() => calls.push("second"));
removeFirst();
scope.dispose();
scope.dispose();
expect(calls).toEqual(["second"]);
expect(scope.disposed).toBe(true);
});
it("runs cleanup immediately when registered after disposal", () => {
const scope = createDataGridRuntimeScope();
const cleanup = vi.fn();
scope.dispose();
const unregister = scope.addCleanup(cleanup);
unregister();
expect(cleanup).toHaveBeenCalledOnce();
});
it("keeps runtime modules independent from the DataGrid component", () => {
const sourceRoots = [join(process.cwd(), "apps/desktop/src/lib/dataGrid"), join(process.cwd(), "apps/desktop/src/composables")];
const sourceFiles: string[] = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) visit(path);
else if (entry.name.endsWith(".ts") && (!directory.endsWith("/composables") || entry.name.startsWith("useDataGrid"))) sourceFiles.push(path);
}
};
sourceRoots.forEach(visit);
expect(sourceFiles.filter((path) => readFileSync(path, "utf8").includes("DataGrid.vue"))).toEqual([]);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { dataGridToolbarIntervalOptions, selectDataGridToolbarAutoRefreshInterval, selectDataGridToolbarExportItem, toggleDataGridToolbarAutoRefresh, triggerDataGridToolbarAction, type DataGridToolbarAutoRefreshCapability } from "@/lib/dataGrid/dataGridToolbar";
function autoRefreshCapability(overrides: Partial<DataGridToolbarAutoRefreshCapability> = {}): DataGridToolbarAutoRefreshCapability {
return {
label: "Auto-refresh",
shortLabel: "Auto",
startLabel: "Start auto-refresh",
stopLabel: "Stop auto-refresh",
enabled: false,
intervalSeconds: 10,
intervalOptions: [5, 10, 30],
intervalLabel: (seconds) => `${seconds}s`,
onToggle: vi.fn(),
onSelectInterval: vi.fn(),
...overrides,
};
}
describe("data grid toolbar capabilities", () => {
it("does not invoke hidden or disabled actions", async () => {
const onTrigger = vi.fn();
await expect(triggerDataGridToolbarAction({ label: "Save", visible: false, onTrigger })).resolves.toBe(false);
await expect(triggerDataGridToolbarAction({ label: "Save", disabled: true, onTrigger })).resolves.toBe(false);
expect(onTrigger).not.toHaveBeenCalled();
});
it("invokes enabled save and rollback callbacks independently", async () => {
const save = vi.fn();
const rollback = vi.fn();
await expect(triggerDataGridToolbarAction({ label: "Save", onTrigger: save })).resolves.toBe(true);
await expect(triggerDataGridToolbarAction({ label: "Rollback", onTrigger: rollback })).resolves.toBe(true);
expect(save).toHaveBeenCalledOnce();
expect(rollback).toHaveBeenCalledOnce();
});
it("keeps preset intervals ordered and preserves a persisted custom interval", () => {
expect(dataGridToolbarIntervalOptions([30, 5, 10, 10, 0, -1, 2.5], 15)).toEqual([5, 10, 15, 30]);
});
it("toggles auto-refresh and selects valid intervals", async () => {
const onToggle = vi.fn();
const onSelectInterval = vi.fn();
const capability = autoRefreshCapability({ onToggle, onSelectInterval });
await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(true);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 30)).resolves.toBe(true);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 99)).resolves.toBe(false);
expect(onToggle).toHaveBeenCalledOnce();
expect(onSelectInterval).toHaveBeenCalledOnce();
expect(onSelectInterval).toHaveBeenCalledWith(30);
});
it("blocks auto-refresh changes while the capability is disabled", async () => {
const onToggle = vi.fn();
const onSelectInterval = vi.fn();
const capability = autoRefreshCapability({ disabled: true, onToggle, onSelectInterval });
await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(false);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 10)).resolves.toBe(false);
expect(onToggle).not.toHaveBeenCalled();
expect(onSelectInterval).not.toHaveBeenCalled();
});
it("rejects disabled or unknown export items", async () => {
const onSelect = vi.fn();
const capability = {
label: "Export",
items: [
{ value: "csv", label: "CSV" },
{ value: "sql", label: "SQL", disabled: true },
],
onSelect,
};
await expect(selectDataGridToolbarExportItem(capability, "csv")).resolves.toBe(true);
await expect(selectDataGridToolbarExportItem(capability, "sql")).resolves.toBe(false);
await expect(selectDataGridToolbarExportItem(capability, "xlsx")).resolves.toBe(false);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith("csv");
});
});
@@ -2,6 +2,12 @@ import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard";
export type DataGridPasteIntent = "native" | "block" | "paste";
export interface DataGridPasteCell {
rowOffset: number;
columnOffset: number;
value: string;
}
interface DataGridPasteEvent {
target?: EventTarget | null;
preventDefault(): void;
@@ -14,3 +20,14 @@ export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean,
event.stopPropagation();
return editable && hasSelection ? "paste" : "block";
}
export function planDataGridPaste(rows: readonly (readonly string[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] {
if (maxRows <= 0 || maxColumns <= 0) return [];
const cells: DataGridPasteCell[] = [];
rows.slice(0, maxRows).forEach((row, rowOffset) => {
row.slice(0, maxColumns).forEach((value, columnOffset) => {
cells.push({ rowOffset, columnOffset, value });
});
});
return cells;
}
@@ -0,0 +1,216 @@
import type { Component } from "vue";
export type DataGridColumnMenuItem = {
label: string;
value: string;
icon?: Component;
disabled?: boolean;
separatorBefore?: boolean;
checked?: boolean;
};
export type DataGridContextMenuItem = {
label: string;
action?: () => void;
disabled?: boolean;
separator?: boolean;
icon?: Component;
iconClass?: string;
shortcut?: string;
variant?: "default" | "destructive";
visible?: boolean;
children?: DataGridContextMenuItem[];
};
export type DataGridContextMenuIcons = {
copy: Component;
filter: Component;
selection: Component;
export: Component;
database: Component;
ascending: Component;
descending: Component;
clearSort: Component;
cellDetails: Component;
rowDetails: Component;
columnDetails: Component;
setNull: Component;
bulkEdit: Component;
transpose: Component;
clone: Component;
restore: Component;
delete: Component;
};
export function createDataGridFilterSubmenu(options: {
label: string;
icon: Component;
labels: Record<"equals" | "notEquals" | "like" | "notLike" | "lessThan" | "greaterThan" | "isNull" | "isNotNull" | "clear", string>;
apply: (mode: "equals" | "not-equals" | "like" | "not-like" | "less-than" | "greater-than" | "is-null" | "is-not-null") => void;
clear: () => void;
}): DataGridContextMenuItem {
const { labels } = options;
return {
label: options.label,
icon: options.icon,
children: [
{ label: labels.equals, action: () => options.apply("equals") },
{ label: labels.notEquals, action: () => options.apply("not-equals") },
{ label: labels.like, action: () => options.apply("like") },
{ label: labels.notLike, action: () => options.apply("not-like") },
{ label: labels.lessThan, action: () => options.apply("less-than") },
{ label: labels.greaterThan, action: () => options.apply("greater-than") },
{ label: "", separator: true },
{ label: labels.isNull, action: () => options.apply("is-null") },
{ label: labels.isNotNull, action: () => options.apply("is-not-null") },
{ label: "", separator: true },
{ label: labels.clear, action: options.clear },
],
};
}
export function createDataGridColumnContextMenuItems(options: {
headerColumn: boolean;
contextColumn: boolean;
canCopyAlterSql: boolean;
canFilter: boolean;
hasSort: boolean;
sortMode: "database" | "local";
labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort", string>;
icons: Pick<DataGridContextMenuIcons, "copy" | "columnDetails" | "database" | "ascending" | "descending" | "clearSort">;
actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void };
filterSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
if (options.headerColumn) {
items.push({ label: options.labels.copyName, action: options.actions.copyName, icon: options.icons.copy });
items.push({ label: options.labels.copyNames, action: options.actions.copyNames, icon: options.icons.copy });
items.push({ label: options.labels.details, action: options.actions.details, icon: options.icons.columnDetails });
if (options.canCopyAlterSql) items.push({ label: options.labels.copyAlterSql, action: options.actions.copyAlterSql, icon: options.icons.copy });
}
if (!options.contextColumn) return items;
items.push(
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
{ label: "", separator: true },
{ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending },
{ label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending },
);
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
items.push({ label: "", separator: true });
return items;
}
export function createDataGridCellContextMenuItems(options: {
hasCell: boolean;
hasColumn: boolean;
headerColumn: boolean;
editable: boolean;
hasCellSelection: boolean;
hasEditableSelection: boolean;
hasSelection: boolean;
labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>;
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
downloadItem?: DataGridContextMenuItem | null;
copySubmenu: DataGridContextMenuItem;
selectionSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
if (options.hasCell) {
if (options.hasColumn) {
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
if (options.downloadItem) items.push(options.downloadItem);
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
}
items.push({ label: options.labels.rowDetails, action: options.actions.rowDetails, icon: options.icons.rowDetails }, { label: "", separator: true });
}
if (!options.headerColumn) items.push(options.copySubmenu);
if (options.editable && options.hasCellSelection) {
if (!options.headerColumn) items.push({ label: options.labels.setNull, action: options.actions.setNull, disabled: !options.hasEditableSelection, icon: options.icons.setNull });
items.push({ label: options.labels.bulkEdit, action: options.actions.bulkEdit, disabled: !options.hasEditableSelection, icon: options.icons.bulkEdit });
}
if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose });
if (options.hasSelection) items.push(options.selectionSubmenu);
return items;
}
export function createDataGridRowContextMenuItems(options: {
editable: boolean;
hasRow: boolean;
canClone: boolean;
deleted: boolean;
canDelete: boolean;
labels: Record<"clone" | "restore" | "delete", string>;
icons: Pick<DataGridContextMenuIcons, "clone" | "restore" | "delete">;
actions: Record<"clone" | "restore" | "delete", () => void>;
}): DataGridContextMenuItem[] {
if (!options.editable || !options.hasRow) return [];
const items: DataGridContextMenuItem[] = [{ label: "", separator: true }];
if (options.canClone) items.push({ label: options.labels.clone, action: options.actions.clone, icon: options.icons.clone });
if (options.deleted) items.push({ label: options.labels.restore, action: options.actions.restore, icon: options.icons.restore });
else if (options.canDelete) items.push({ label: options.labels.delete, action: options.actions.delete, icon: options.icons.delete, variant: "destructive" });
items.push({ label: "", separator: true });
return items;
}
export function createDataGridContextMenuItems(...groups: Array<readonly DataGridContextMenuItem[]>): DataGridContextMenuItem[] {
return groups.flat();
}
export type DataGridColumnSortState = {
column: string | null;
columnIndex: number | null;
direction: "asc" | "desc";
mode: "database" | "local";
};
type SortMenuLabels = {
databaseAscending: string;
databaseDescending: string;
currentPageAscending: string;
currentPageDescending: string;
clear: string;
};
type SortMenuIcons = {
database: Component;
ascending: Component;
descending: Component;
clear: Component;
};
export function dataGridColumnIsSorted(state: DataGridColumnSortState, column: string, columnIndex: number): boolean {
return state.column === column && state.columnIndex === columnIndex;
}
export function dataGridSelectedSortMenuValue(state: DataGridColumnSortState, column: string, columnIndex: number): string | undefined {
return dataGridColumnIsSorted(state, column, columnIndex) ? `${state.mode}-${state.direction}` : undefined;
}
export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons }): DataGridColumnMenuItem[] {
const { column, columnIndex, state, labels, icons } = options;
const sorted = dataGridColumnIsSorted(state, column, columnIndex);
return [
{ label: labels.databaseAscending, value: "database-asc", icon: icons.database, checked: sorted && state.direction === "asc" && state.mode === "database" },
{ label: labels.databaseDescending, value: "database-desc", icon: icons.database, checked: sorted && state.direction === "desc" && state.mode === "database" },
{ label: labels.currentPageAscending, value: "local-asc", icon: icons.ascending, checked: sorted && state.direction === "asc" && state.mode === "local", separatorBefore: true },
{ label: labels.currentPageDescending, value: "local-desc", icon: icons.descending, checked: sorted && state.direction === "desc" && state.mode === "local" },
{ label: labels.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true },
];
}
export function createDataGridCompactColumnActionItems(options: {
labels: { formatter: string; localFilter: string; serverFilter: string };
icons: { formatter: Component; filter: Component; database: Component };
formatterAvailable: boolean;
serverFilterAvailable: boolean;
}): DataGridColumnMenuItem[] {
const { labels, icons } = options;
return [
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable },
{ label: labels.localFilter, value: "localFilter", icon: icons.filter },
...(options.serverFilterAvailable ? [{ label: labels.serverFilter, value: "serverFilter", icon: icons.database }] : []),
];
}
@@ -0,0 +1,20 @@
import type { DatabaseType } from "@/types/database";
import type { DataGridEditAdapter } from "@/lib/dataGrid/dataGridRuntime";
export type DataGridEditAdapterKind = "relational" | "document" | "custom" | "unsupported";
export interface DataGridEditAdapterContext {
databaseType?: DatabaseType;
custom: boolean;
editable: boolean;
}
export function dataGridEditAdapterKind(context: DataGridEditAdapterContext): DataGridEditAdapterKind {
if (context.custom) return "custom";
if (!context.editable) return "unsupported";
if (context.databaseType === "mongodb") return "document";
if (context.databaseType) return "relational";
return "unsupported";
}
export type { DataGridEditAdapter };
@@ -0,0 +1,39 @@
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
export type DataGridCachedServerColumnFilter = {
condition: string;
keys: string[];
labels: string[];
};
export type DataGridStructuredFilterCacheState = {
scopeKey: string;
manualWhereInput: string;
rules: DataGridStructuredFilterRule[];
appliedWhereInput: string;
serverColumnFilters: Record<number, DataGridCachedServerColumnFilter>;
};
const structuredFilterStateCache = new Map<string, DataGridStructuredFilterCacheState>();
export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] {
return rules.map((rule) => ({ ...rule }));
}
export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined {
const cached = structuredFilterStateCache.get(cacheKey);
if (!cached || cached.scopeKey !== scopeKey) return undefined;
return {
...cached,
rules: cloneDataGridStructuredFilterRules(cached.rules),
serverColumnFilters: structuredClone(cached.serverColumnFilters),
};
}
export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) {
structuredFilterStateCache.set(cacheKey, {
...state,
rules: cloneDataGridStructuredFilterRules(state.rules),
serverColumnFilters: structuredClone(state.serverColumnFilters),
});
}
@@ -0,0 +1,38 @@
import type { CellPosition } from "@/lib/dataGrid/gridSelection";
export type DataGridNavigationDirection = "up" | "down" | "left" | "right" | "home" | "end";
export interface DataGridNavigationBounds {
rowCount: number;
visibleColumnCount: number;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
export function moveDataGridCell(position: CellPosition, rowDelta: number, columnDelta: number, bounds: DataGridNavigationBounds): CellPosition | null {
if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null;
return {
rowIndex: clamp(position.rowIndex + rowDelta, 0, bounds.rowCount - 1),
colIndex: clamp(position.colIndex + columnDelta, 0, bounds.visibleColumnCount - 1),
};
}
export function navigateDataGridCell(position: CellPosition, direction: DataGridNavigationDirection, bounds: DataGridNavigationBounds): CellPosition | null {
if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null;
switch (direction) {
case "up":
return moveDataGridCell(position, -1, 0, bounds);
case "down":
return moveDataGridCell(position, 1, 0, bounds);
case "left":
return moveDataGridCell(position, 0, -1, bounds);
case "right":
return moveDataGridCell(position, 0, 1, bounds);
case "home":
return { rowIndex: position.rowIndex, colIndex: 0 };
case "end":
return { rowIndex: position.rowIndex, colIndex: bounds.visibleColumnCount - 1 };
}
}
@@ -0,0 +1,84 @@
import type { CellPosition, CellSelectionRange } from "@/lib/dataGrid/gridSelection";
export type GridCoordinate = CellPosition;
export type GridRange = CellSelectionRange;
export interface GridEditPatch<Row = unknown, Value = unknown> {
rowIndex: number;
columnIndex: number;
previousValue: Value;
nextValue: Value;
row: Row;
}
export interface GridColumnLayout {
order: readonly string[];
hidden: ReadonlySet<string>;
pinned: ReadonlySet<string>;
widths: Readonly<Record<string, number>>;
}
export interface DataGridRuntimeScope {
readonly disposed: boolean;
addCleanup(cleanup: () => void): () => void;
dispose(): void;
}
export interface DataGridCapability {
readonly name: string;
dispose?(): void;
}
export interface DataGridSelectionCapability extends DataGridCapability {
getRange(): GridRange | null;
setRange(range: GridRange | null): void;
isSelected(position: GridCoordinate): boolean;
}
export interface DataGridNavigationCapability extends DataGridCapability {
moveFocus(position: GridCoordinate, direction: "up" | "down" | "left" | "right" | "home" | "end"): GridCoordinate;
}
export interface DataGridEditAdapter<Row = unknown, Value = unknown, Patch = GridEditPatch<Row, Value>> {
canEditCell(row: Row, columnIndex: number): boolean;
createPatch(input: GridEditPatch<Row, Value>): Patch | null;
applyPatch(patch: Patch): Promise<void> | void;
}
export interface DataGridRuntimeDependencies<Result = unknown> {
readonly result: Result;
readonly rowCount: number;
readonly columnCount: number;
readonly registerCleanup: DataGridRuntimeScope["addCleanup"];
}
export function createDataGridRuntimeScope(): DataGridRuntimeScope {
const cleanups: Array<() => void> = [];
let disposed = false;
return {
get disposed() {
return disposed;
},
addCleanup(cleanup) {
if (disposed) {
cleanup();
return () => undefined;
}
cleanups.push(cleanup);
let registered = true;
return () => {
if (!registered) return;
registered = false;
const index = cleanups.indexOf(cleanup);
if (index >= 0) cleanups.splice(index, 1);
};
},
dispose() {
if (disposed) return;
disposed = true;
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
},
};
}
@@ -1,3 +1,88 @@
export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
export type DataGridReloadIntent = "refresh";
export interface DataGridToolbarActionCapability {
label: string;
tooltip?: string;
visible?: boolean;
disabled?: boolean;
active?: boolean;
loading?: boolean;
onTrigger: () => void | Promise<void>;
}
export interface DataGridToolbarSaveCapability extends DataGridToolbarActionCapability {
pendingCount: number;
shortcutLabel?: string;
}
export interface DataGridToolbarMenuItem {
value: string;
label: string;
disabled?: boolean;
separatorBefore?: boolean;
}
export interface DataGridToolbarExportCapability {
label: string;
visible?: boolean;
disabled?: boolean;
items: readonly DataGridToolbarMenuItem[];
onSelect: (value: string) => void | Promise<void>;
}
export interface DataGridToolbarAutoRefreshCapability {
label: string;
shortLabel: string;
startLabel: string;
stopLabel: string;
visible?: boolean;
disabled?: boolean;
enabled: boolean;
intervalSeconds: number;
intervalOptions: readonly number[];
intervalLabel: (seconds: number) => string;
onToggle: () => void | Promise<void>;
onSelectInterval: (seconds: number) => void | Promise<void>;
}
export function isDataGridToolbarCapabilityVisible(capability: { visible?: boolean } | undefined): boolean {
return !!capability && capability.visible !== false;
}
export function isDataGridToolbarCapabilityDisabled(capability: { disabled?: boolean; loading?: boolean } | undefined): boolean {
return !capability || capability.disabled === true;
}
export async function triggerDataGridToolbarAction(capability: DataGridToolbarActionCapability | undefined): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
await capability.onTrigger();
return true;
}
export function dataGridToolbarIntervalOptions(intervalOptions: readonly number[], currentIntervalSeconds: number): number[] {
// Keep a persisted custom interval selectable even when it is not in today's preset list.
return [...new Set([...intervalOptions, currentIntervalSeconds].filter((seconds) => Number.isInteger(seconds) && seconds > 0))].sort((left, right) => left - right);
}
export async function toggleDataGridToolbarAutoRefresh(capability: DataGridToolbarAutoRefreshCapability | undefined): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
await capability.onToggle();
return true;
}
export async function selectDataGridToolbarAutoRefreshInterval(capability: DataGridToolbarAutoRefreshCapability | undefined, seconds: number): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
if (!dataGridToolbarIntervalOptions(capability.intervalOptions, capability.intervalSeconds).includes(seconds)) return false;
await capability.onSelectInterval(seconds);
return true;
}
export async function selectDataGridToolbarExportItem(capability: DataGridToolbarExportCapability | undefined, value: string): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || capability.disabled) return false;
const item = capability.items.find((candidate) => candidate.value === value);
if (!item || item.disabled) return false;
await capability.onSelect(value);
return true;
}