feat(mongo): refine document browsing and structured filter experience

This commit is contained in:
zipg authored and GitHub committed 2026-08-08 11:36:57 +08:00
1 parent 0107d5fafb
commit e49d86b101
22 files changed
+1031 -171

No files matched your search

@@ -2986,6 +2986,7 @@ const editor = useDataGridEditor({
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
initialEditColumn: firstVisibleColumnIndex,
getRowItem,
pageSize,
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { Eye, EyeOff, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -14,6 +14,11 @@ const { t } = useI18n();
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
const VALUE_SHORTCUT_HINT_MAX_DAYS = 3;
const VALUE_SHORTCUT_HINT_MAX_PER_DAY = 2;
const IME_COMPOSITION_END_GRACE_MS = 120;
const COLUMN_CONTROL_MIN_WIDTH = 88;
const COLUMN_CONTROL_MAX_WIDTH = 178;
const COLUMN_CONTROL_CHROME_WIDTH = 40;
const COLUMN_CHARACTER_WIDTH = 6.5;
type ValueShortcutHintDay = { date: string; count: number };
const props = withDefaults(
defineProps<{
@@ -46,8 +51,23 @@ const activeColumnIndexes = ref<Record<string, number>>({});
const focusedValueRuleId = ref<string>();
const valueShortcutHintRuleId = ref<string>();
const valueShortcutHintShownDays = ref(readValueShortcutHintShownDays());
const composingEditors = new Set<string>();
const compositionEndedAt = new Map<string, number>();
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
function columnNameDisplayUnits(value: string) {
return Array.from(value).reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
}
const filterBuilderStyle = computed(() => {
const longestColumnUnits = Math.max(0, ...props.columns.map(columnNameDisplayUnits));
const columnWidth = Math.min(COLUMN_CONTROL_MAX_WIDTH, Math.max(COLUMN_CONTROL_MIN_WIDTH, Math.ceil(longestColumnUnits * COLUMN_CHARACTER_WIDTH + COLUMN_CONTROL_CHROME_WIDTH)));
return {
"--filter-builder-column-width": `${columnWidth}px`,
"--filter-builder-value-width": `${COLUMN_CONTROL_MAX_WIDTH}px`,
};
});
function usesExpandedLayout(mode: DataGridContextFilterMode) {
return filterModeUsesList(mode) || filterModeUsesRange(mode);
}
@@ -92,7 +112,7 @@ function setColumnSearchInput(id: string, element: unknown) {
return;
}
columnSearchInputs.set(id, element);
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus());
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus({ preventScroll: true }));
}
function setFilterRuleElement(id: string, element: unknown) {
@@ -104,14 +124,16 @@ function activeColumnIndex(id: string): number {
return activeColumnIndexes.value[id] ?? -1;
}
function setActiveColumnIndex(id: string, index: number) {
function scrollColumnIntoView(id: string, index: number, block: ScrollLogicalPosition) {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[index]?.scrollIntoView?.({ block });
}
function setActiveColumnIndex(id: string, index: number, scrollBlock: ScrollLogicalPosition = "nearest") {
const count = props.filteredColumns.length;
const nextIndex = count ? ((index % count) + count) % count : -1;
activeColumnIndexes.value = { ...activeColumnIndexes.value, [id]: nextIndex };
window.requestAnimationFrame(() => {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[nextIndex]?.scrollIntoView?.({ block: "nearest" });
});
window.requestAnimationFrame(() => scrollColumnIntoView(id, nextIndex, scrollBlock));
}
function setColumnSelectOpen(id: string, open: boolean) {
@@ -129,9 +151,13 @@ async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open:
setColumnSelectOpen(rule.id, open);
if (!open) return;
const selectedIndex = props.filteredColumns.indexOf(rule.columnName);
setActiveColumnIndex(rule.id, selectedIndex >= 0 ? selectedIndex : 0);
const nextIndex = selectedIndex >= 0 ? selectedIndex : 0;
setActiveColumnIndex(rule.id, nextIndex, "start");
await nextTick();
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
window.requestAnimationFrame(() => {
columnSearchInputs.get(rule.id)?.focus({ preventScroll: true });
window.requestAnimationFrame(() => scrollColumnIntoView(rule.id, nextIndex, "start"));
});
}
async function openFirstEmptyRuleColumnSearch() {
@@ -188,6 +214,23 @@ function moveColumnSearchCaret(event: KeyboardEvent) {
input.setSelectionRange(nextPosition, nextPosition);
}
function startImeComposition(editorKey: string) {
composingEditors.add(editorKey);
compositionEndedAt.delete(editorKey);
}
function endImeComposition(editorKey: string) {
composingEditors.delete(editorKey);
compositionEndedAt.set(editorKey, Date.now());
}
function isImeCompositionKey(event: KeyboardEvent, editorKey: string) {
const endedAt = compositionEndedAt.get(editorKey);
const justEnded = event.key === "Enter" && endedAt !== undefined && Date.now() - endedAt <= IME_COMPOSITION_END_GRACE_MS;
if (justEnded || (endedAt !== undefined && event.key !== "Process")) compositionEndedAt.delete(editorKey);
return event.isComposing || event.key === "Process" || event.keyCode === 229 || composingEditors.has(editorKey) || justEnded;
}
watch(
() => props.rules,
(rules) => {
@@ -199,7 +242,7 @@ watch(
);
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredFilterRule) {
if (event.isComposing || event.key === "Process") {
if (isImeCompositionKey(event, `column:${rule.id}`)) {
event.stopPropagation();
return;
}
@@ -227,8 +270,12 @@ function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructure
if (!event.ctrlKey && !event.metaKey && !event.altKey && (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete")) event.stopPropagation();
}
function handleValueEditorKeydown(event: KeyboardEvent) {
if (event.key !== "Enter" || event.isComposing) return;
function handleValueEditorKeydown(event: KeyboardEvent, editorKey: string) {
if (isImeCompositionKey(event, editorKey)) {
event.stopPropagation();
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
if (!event.shiftKey) {
emit("apply");
@@ -259,20 +306,20 @@ function blurValueRule(id: string) {
</script>
<template>
<div class="space-y-3">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-3">
<div class="w-fit max-w-full space-y-2" :style="filterBuilderStyle">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-2">
<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('clear')"> <Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }} </Button>
</div>
<div v-if="props.rules.length" class="space-y-2">
<div v-if="props.rules.length" class="space-y-1.5">
<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>
<Button variant="ghost" size="sm" class="h-5 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
</div>
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center justify-start gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,260px)_92px_auto]' : 'grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]'">
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto] items-center justify-start gap-1.5">
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
<SelectValue v-else :placeholder="t('grid.filterBuilderColumn')" />
</SelectTrigger>
@@ -289,48 +336,56 @@ function blurValueRule(id: string) {
{{ 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
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="props.columnSearch"
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="updateColumnSearch(rule.id, $event)"
@click.stop
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
<template #footer>
<div class="flex items-center gap-1.5 border-t bg-popover px-2 py-1">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="props.columnSearch"
class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="updateColumnSearch(rule.id, $event)"
@click.stop
@compositionend="endImeComposition(`column:${rule.id}`)"
@compositionstart="startImeComposition(`column:${rule.id}`)"
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
</template>
</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 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value" class="rounded-none">{{ t(option.labelKey) }}</SelectItem></SelectContent
>
</Select>
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-2 flex gap-2">
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-3 flex gap-1.5">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value-start:${rule.id}`)"
@compositionstart="startImeComposition(`value-start:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
/>
<Input
:model-value="rule.rawEndValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value-end:${rule.id}`)"
@compositionstart="startImeComposition(`value-end:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
/>
</div>
<textarea
@@ -338,7 +393,7 @@ function blurValueRule(id: string) {
data-filter-value-editor
: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"
class="col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@@ -349,31 +404,33 @@ function blurValueRule(id: string) {
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value:${rule.id}`)"
@compositionstart="startImeComposition(`value:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<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 v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-2 -mt-1' : 'col-start-3 row-start-2 -mt-1'">
<div v-else class="flex h-7 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-3 -mt-0.5' : 'col-start-3 row-start-2 -mt-0.5'">
{{ t("grid.filterBuilderValueShortcutHint") }}
</div>
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<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)"><X class="h-3.5 w-3.5" /></Button>
<div class="flex items-center gap-0.5" :class="usesExpandedLayout(rule.mode) ? 'col-start-4 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<Button variant="ghost" size="icon" class="h-7 w-7" @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-7 w-7" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X 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" :disabled="props.disabled || !props.columns.length" @click="emit('add')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</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 v-else class="rounded-md border border-dashed px-3 py-3 text-center text-xs text-muted-foreground">{{ t("grid.filterBuilderEmpty") }}</div>
<div v-if="props.showFooter !== false" class="flex justify-between gap-2">
<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 class="flex gap-1.5">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('reset')">{{ t("grid.resetFilterBuilder") }}</Button
><Button size="sm" class="h-7 px-3 text-xs" :disabled="props.disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
</div>
</div>
</div>
@@ -160,21 +160,21 @@ onUnmounted(onResizeEnd);
<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-[624px] max-w-[calc(100vw-24px)] gap-3 p-3">
<div class="flex items-center justify-between gap-3">
<PopoverContent align="start" class="w-fit max-w-[calc(100vw-16px)] gap-2 p-2.5">
<div class="flex items-center justify-between gap-2">
<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('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</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 v-if="hasLocalColumnFilters" class="space-y-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<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>
<Button variant="ghost" size="sm" class="h-6 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">
<div class="space-y-0.5">
<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-1.5 rounded border border-primary/10 bg-background/70 px-2 py-0.5 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"
@@ -182,7 +182,7 @@ onUnmounted(onResizeEnd);
>
<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>
<Button variant="ghost" size="icon" class="h-5 w-5 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>
@@ -415,7 +415,8 @@ describe("DataGridFilterBuilder", () => {
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]"));
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@@ -429,7 +430,8 @@ describe("DataGridFilterBuilder", () => {
expect(items.every((item) => String(item.props.class).includes("rounded-none"))).toBe(true);
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]");
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "178px", "--filter-builder-value-width": "178px" });
expect(String(ruleGrid.props.class)).toContain("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@@ -439,6 +441,19 @@ describe("DataGridFilterBuilder", () => {
}
});
it("sizes the column control from the longest available column", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "88px", "--filter-builder-value-width": "178px" });
});
it("keeps search focus while navigating and selecting filtered columns", async () => {
const onUpdateRule = vi.fn();
const onAdd = vi.fn();
@@ -459,6 +474,15 @@ describe("DataGridFilterBuilder", () => {
let columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
expect(columnItems[0].props["data-filter-active"]).toBe("");
const imeKeyCodeEnter = dispatch(searchInput, "keydown", { key: "Enter", keyCode: 229 });
expect(imeKeyCodeEnter.defaultPrevented).toBe(false);
expect(imeKeyCodeEnter.propagationStopped).toBe(true);
dispatch(searchInput, "compositionstart");
dispatch(searchInput, "compositionend");
const imeCompositionEndEnter = dispatch(searchInput, "keydown", { key: "Enter", keyCode: 13 });
expect(imeCompositionEndEnter.defaultPrevented).toBe(false);
expect(imeCompositionEndEnter.propagationStopped).toBe(true);
expect(onUpdateRule).not.toHaveBeenCalled();
expect(dispatch(searchInput, "keydown", { key: "a" }).propagationStopped).toBe(true);
expect(dispatch(searchInput, "keydown", { key: "Backspace" }).propagationStopped).toBe(true);
@@ -597,6 +621,17 @@ describe("DataGridFilterBuilder", () => {
await nextTick();
expect(hostText(exhaustedMounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
const imeKeyCodeEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", keyCode: 229 });
expect(imeKeyCodeEnter.defaultPrevented).toBe(false);
expect(imeKeyCodeEnter.propagationStopped).toBe(true);
dispatch(secondValueEditor, "compositionstart");
dispatch(secondValueEditor, "compositionend");
const imeCompositionEndEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", keyCode: 13 });
expect(imeCompositionEndEnter.defaultPrevented).toBe(false);
expect(imeCompositionEndEnter.propagationStopped).toBe(true);
expect(onAdd).not.toHaveBeenCalled();
expect(onApply).not.toHaveBeenCalled();
const shiftEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
expect(shiftEnter.defaultPrevented).toBe(true);
expect(shiftEnter.propagationStopped).toBe(true);
@@ -739,8 +774,8 @@ describe("DataGridQueryControls", () => {
});
const popoverContent = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
expect(String(popoverContent.props.class)).toContain("w-[624px]");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-24px)]");
expect(String(popoverContent.props.class)).toContain("w-fit");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-16px)]");
});
it("keeps filter actions available in the popover", () => {
@@ -110,7 +110,7 @@ export function createPassthroughStub(name: string, tag = "div") {
name,
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.()]);
return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.(), slots.footer?.()]);
},
});
}