feat(mongo): refine document browsing and structured filter experience
This commit is contained in:
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?.()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user