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