feat(grid): improve structured filter interactions
This commit is contained in:
parent
00f58ebe16
commit
54363b6edf
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -31,24 +32,168 @@ const emit = defineEmits<{
|
|||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
"update:columnSearch": [value: string];
|
||||
}>();
|
||||
const columnSearchInputs = new Map<string, HTMLInputElement>();
|
||||
const filterRuleElements = new Map<string, HTMLElement>();
|
||||
const pendingValueFocus = new Set<string>();
|
||||
const pendingKeyboardAddFocus = new Set<string>();
|
||||
const openColumnSelectIds = ref(new Set<string>());
|
||||
const activeColumnIndexes = ref<Record<string, number>>({});
|
||||
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
|
||||
|
||||
function usesExpandedLayout(mode: DataGridContextFilterMode) {
|
||||
return filterModeUsesList(mode) || filterModeUsesRange(mode);
|
||||
}
|
||||
|
||||
function updateRuleColumn(id: string, value: unknown) {
|
||||
emit("updateRule", id, { columnName: String(value) });
|
||||
function updateRuleColumn(rule: DataGridStructuredFilterRule, value: unknown, focusValue = true) {
|
||||
if (focusValue && filterModeNeedsValue(rule.mode)) pendingValueFocus.add(rule.id);
|
||||
emit("updateRule", rule.id, { columnName: String(value) });
|
||||
emit("update:columnSearch", "");
|
||||
}
|
||||
|
||||
function handleColumnSearchKeydown(event: KeyboardEvent) {
|
||||
function setColumnSearchInput(id: string, element: unknown) {
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
columnSearchInputs.delete(id);
|
||||
return;
|
||||
}
|
||||
columnSearchInputs.set(id, element);
|
||||
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus());
|
||||
}
|
||||
|
||||
function setFilterRuleElement(id: string, element: unknown) {
|
||||
if (element instanceof HTMLElement) filterRuleElements.set(id, element);
|
||||
else filterRuleElements.delete(id);
|
||||
}
|
||||
|
||||
function activeColumnIndex(id: string): number {
|
||||
return activeColumnIndexes.value[id] ?? -1;
|
||||
}
|
||||
|
||||
function setActiveColumnIndex(id: string, index: number) {
|
||||
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" });
|
||||
});
|
||||
}
|
||||
|
||||
function setColumnSelectOpen(id: string, open: boolean) {
|
||||
const next = new Set(openColumnSelectIds.value);
|
||||
if (open) {
|
||||
next.clear();
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
openColumnSelectIds.value = next;
|
||||
}
|
||||
|
||||
async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open: boolean) {
|
||||
setColumnSelectOpen(rule.id, open);
|
||||
if (!open) return;
|
||||
const selectedIndex = props.filteredColumns.indexOf(rule.columnName);
|
||||
setActiveColumnIndex(rule.id, selectedIndex >= 0 ? selectedIndex : 0);
|
||||
await nextTick();
|
||||
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
|
||||
}
|
||||
|
||||
function handleColumnCloseAutoFocus(id: string, event: Event) {
|
||||
if (pendingKeyboardAddFocus.delete(id)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!pendingValueFocus.delete(id)) return;
|
||||
event.preventDefault();
|
||||
void focusFilterRuleValue(id);
|
||||
}
|
||||
|
||||
async function focusFilterRuleValue(id: string) {
|
||||
await nextTick();
|
||||
window.requestAnimationFrame(() => filterRuleElements.get(id)?.querySelector<HTMLElement>("[data-filter-value-editor]")?.focus());
|
||||
}
|
||||
|
||||
function updateColumnSearch(id: string, event: Event) {
|
||||
emit("update:columnSearch", (event.target as HTMLInputElement).value);
|
||||
setActiveColumnIndex(id, 0);
|
||||
}
|
||||
|
||||
function addRuleAndOpenColumnSelect() {
|
||||
ruleIdsBeforeKeyboardAdd = new Set(props.rules.map((item) => item.id));
|
||||
emit("add");
|
||||
}
|
||||
|
||||
function selectActiveColumn(rule: DataGridStructuredFilterRule, addAnother: boolean) {
|
||||
const column = props.filteredColumns[activeColumnIndex(rule.id)];
|
||||
if (!column) return;
|
||||
updateRuleColumn(rule, column, !addAnother);
|
||||
if (addAnother) pendingKeyboardAddFocus.add(rule.id);
|
||||
setColumnSelectOpen(rule.id, false);
|
||||
if (!addAnother) return;
|
||||
addRuleAndOpenColumnSelect();
|
||||
}
|
||||
|
||||
function moveColumnSearchCaret(event: KeyboardEvent) {
|
||||
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (typeof input.setSelectionRange !== "function") return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const nextPosition = event.key === "ArrowLeft" ? (start === end ? Math.max(0, start - 1) : start) : start === end ? Math.min(input.value.length, end + 1) : end;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
input.setSelectionRange(nextPosition, nextPosition);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.rules,
|
||||
(rules) => {
|
||||
if (!ruleIdsBeforeKeyboardAdd) return;
|
||||
const addedRule = rules.find((item) => !ruleIdsBeforeKeyboardAdd?.has(item.id));
|
||||
ruleIdsBeforeKeyboardAdd = undefined;
|
||||
if (addedRule) void handleColumnSelectOpen(addedRule, true);
|
||||
},
|
||||
);
|
||||
|
||||
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredFilterRule) {
|
||||
if (event.isComposing || event.key === "Process") {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (["Escape", "Tab", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveColumnIndex(rule.id, activeColumnIndex(rule.id) + (event.key === "ArrowDown" ? 1 : -1));
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectActiveColumn(rule, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
moveColumnSearchCaret(event);
|
||||
return;
|
||||
}
|
||||
if (["Escape", "Tab"].includes(event.key)) return;
|
||||
if (["Home", "End", "PageUp", "PageDown"].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
event.preventDefault();
|
||||
if (!event.shiftKey) {
|
||||
emit("apply");
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
if (!event.repeat) addRuleAndOpenColumnSelect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -63,38 +208,61 @@ function handleColumnSearchKeydown(event: KeyboardEvent) {
|
|||
<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 :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,1fr)_80px_auto]' : 'grid-cols-[minmax(0,1fr)_80px_minmax(0,1fr)_auto]'">
|
||||
<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">
|
||||
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
|
||||
<SelectValue v-else :placeholder="t('grid.filterBuilderColumn')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" class="max-h-72" :hide-scroll-buttons="true" @close-auto-focus="(event: Event) => handleColumnCloseAutoFocus(rule.id, event)">
|
||||
<SelectItem
|
||||
v-for="(column, columnIndex) in props.filteredColumns"
|
||||
:key="column"
|
||||
:value="column"
|
||||
class="rounded-none"
|
||||
:class="activeColumnIndex(rule.id) === columnIndex ? 'bg-accent text-accent-foreground' : ''"
|
||||
:data-filter-active="activeColumnIndex(rule.id) === columnIndex ? '' : undefined"
|
||||
@pointermove="setActiveColumnIndex(rule.id, columnIndex)"
|
||||
>
|
||||
{{ 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="emit('update:columnSearch', ($event.target as HTMLInputElement).value)"
|
||||
@input="updateColumnSearch(rule.id, $event)"
|
||||
@click.stop
|
||||
@keydown="handleColumnSearchKeydown"
|
||||
@keydown="handleColumnSearchKeydown($event, rule)"
|
||||
@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>
|
||||
<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>
|
||||
<SelectContent
|
||||
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem></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">
|
||||
<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')" />
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
: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="handleValueEditorKeydown"
|
||||
/>
|
||||
<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="handleValueEditorKeydown" />
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
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"
|
||||
|
|
@ -106,12 +274,13 @@ function handleColumnSearchKeydown(event: KeyboardEvent) {
|
|||
/>
|
||||
<Input
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
data-filter-value-editor
|
||||
: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')"
|
||||
@keydown="handleValueEditorKeydown"
|
||||
/>
|
||||
<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' : ''">
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ 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-[380px] max-w-[calc(100vw-24px)] gap-3 p-3">
|
||||
<PopoverContent align="start" class="w-[480px] 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>
|
||||
|
|
|
|||
|
|
@ -311,18 +311,185 @@ describe("DataGridColumnHeader", () => {
|
|||
});
|
||||
|
||||
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: "" });
|
||||
it("clips long selected values inside the filter grid", () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["appointmentStatusWithAnExceptionallyLongName", "name"],
|
||||
filteredColumns: ["name"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
});
|
||||
const selects = findAll(mounted.root, (node) => node.props["data-stub"] === "Select");
|
||||
const selectContents = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectContent");
|
||||
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,1fr)_80px_minmax(0,1fr)_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"] === "");
|
||||
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(selects[0].props["onUpdate:open"]).toEqual(expect.any(Function));
|
||||
expect(selects[0].props["onUpdate:modelValue"]).toEqual(expect.any(Function));
|
||||
expect(selectContents[0].props.onCloseAutoFocus).toEqual(expect.any(Function));
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(hostText(selectValues[0])).toBe("appointmentStatusWithAnExceptionallyLongName");
|
||||
expect(items).toHaveLength(2);
|
||||
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,1fr)_80px_minmax(0,1fr)_auto]");
|
||||
for (const trigger of triggers) {
|
||||
expect(String(trigger.props.class)).toContain("w-full");
|
||||
expect(String(trigger.props.class)).toContain("overflow-hidden");
|
||||
expect(String(trigger.props.class)).toContain("[&_[data-slot=select-value]]:min-w-0");
|
||||
expect(String(trigger.props.class)).toContain("[&_[data-slot=select-value]]:truncate");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps search focus while navigating and selecting filtered columns", async () => {
|
||||
const onUpdateRule = vi.fn();
|
||||
const onAdd = vi.fn();
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["id", "image_size_bytes"],
|
||||
filteredColumns: ["id", "image_size_bytes"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
onUpdateRule,
|
||||
onAdd,
|
||||
});
|
||||
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
|
||||
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
|
||||
|
||||
columnSelect.props["onUpdate:open"](true);
|
||||
await nextTick();
|
||||
|
||||
let columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
|
||||
expect(columnItems[0].props["data-filter-active"]).toBe("");
|
||||
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);
|
||||
|
||||
const arrowDown = dispatch(searchInput, "keydown", { key: "ArrowDown" });
|
||||
expect(arrowDown.defaultPrevented).toBe(true);
|
||||
expect(arrowDown.propagationStopped).toBe(true);
|
||||
await nextTick();
|
||||
columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
|
||||
expect(columnItems[1].props["data-filter-active"]).toBe("");
|
||||
|
||||
const leftInput = { value: "image_", selectionStart: 4, selectionEnd: 4, setSelectionRange: vi.fn() };
|
||||
const leftArrow = dispatch(searchInput, "keydown", { key: "ArrowLeft", currentTarget: leftInput });
|
||||
expect(leftArrow.defaultPrevented).toBe(true);
|
||||
expect(leftArrow.propagationStopped).toBe(true);
|
||||
expect(leftInput.setSelectionRange).toHaveBeenCalledWith(3, 3);
|
||||
|
||||
const rightInput = { value: "image_", selectionStart: 1, selectionEnd: 4, setSelectionRange: vi.fn() };
|
||||
const rightArrow = dispatch(searchInput, "keydown", { key: "ArrowRight", currentTarget: rightInput });
|
||||
expect(rightArrow.defaultPrevented).toBe(true);
|
||||
expect(rightArrow.propagationStopped).toBe(true);
|
||||
expect(rightInput.setSelectionRange).toHaveBeenCalledWith(4, 4);
|
||||
|
||||
const enter = dispatch(searchInput, "keydown", { key: "Enter" });
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
expect(enter.propagationStopped).toBe(true);
|
||||
expect(onUpdateRule).toHaveBeenCalledWith("r1", { columnName: "image_size_bytes" });
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(dispatch(searchInput, "keydown", { key: "Process", isComposing: true }).propagationStopped).toBe(true);
|
||||
});
|
||||
|
||||
it("adds another rule after selecting a column with shift-enter", async () => {
|
||||
const onUpdateRule = vi.fn();
|
||||
const secondRule = { id: "r2", columnName: "id", mode: "equals" as const, rawValue: "", rawEndValue: "", conjunction: "AND" as const };
|
||||
let mounted: ReturnType<typeof mountComponent>;
|
||||
const onAdd = vi.fn(() => {
|
||||
void mounted.setProps({ rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }, secondRule] });
|
||||
});
|
||||
mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["id"],
|
||||
filteredColumns: ["id"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
onUpdateRule,
|
||||
onAdd,
|
||||
});
|
||||
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
|
||||
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
|
||||
|
||||
columnSelect.props["onUpdate:open"](true);
|
||||
await nextTick();
|
||||
const shiftEnter = dispatch(searchInput, "keydown", { key: "Enter", shiftKey: true });
|
||||
|
||||
expect(shiftEnter.defaultPrevented).toBe(true);
|
||||
expect(shiftEnter.propagationStopped).toBe(true);
|
||||
expect(onUpdateRule).toHaveBeenCalledWith("r1", { columnName: "id" });
|
||||
expect(onAdd).toHaveBeenCalledOnce();
|
||||
await nextTick();
|
||||
const columnSelects = findAll(mounted.root, (node) => node.props["data-stub"] === "Select").filter((_node, index) => index % 2 === 0);
|
||||
const firstSelectContent = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectContent")[0];
|
||||
const closeAutoFocus = dispatch(firstSelectContent, "closeAutoFocus");
|
||||
expect(closeAutoFocus.defaultPrevented).toBe(true);
|
||||
expect(columnSelects).toHaveLength(2);
|
||||
expect(columnSelects[0].props.open).toBe(false);
|
||||
expect(columnSelects[1].props.open).toBe(true);
|
||||
});
|
||||
|
||||
it("adds a rule instead of applying when shift-enter is pressed in a value editor", () => {
|
||||
const onAdd = vi.fn();
|
||||
const onApply = vi.fn();
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["id"],
|
||||
filteredColumns: ["id"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
onAdd,
|
||||
onApply,
|
||||
});
|
||||
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
|
||||
|
||||
const shiftEnter = dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
|
||||
expect(shiftEnter.defaultPrevented).toBe(true);
|
||||
expect(shiftEnter.propagationStopped).toBe(true);
|
||||
expect(onAdd).toHaveBeenCalledOnce();
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
|
||||
dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: false });
|
||||
expect(onApply).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridQueryControls", () => {
|
||||
it("gives filter rules enough horizontal space for longer column names", () => {
|
||||
const mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "",
|
||||
orderByInput: "",
|
||||
columns: ["appointmentStatusWithAnExceptionallyLongName"],
|
||||
conditionColumns: ["appointmentStatusWithAnExceptionallyLongName"],
|
||||
historyScope: {},
|
||||
canUseWhereSearch: true,
|
||||
compact: false,
|
||||
leadingBorder: false,
|
||||
filterBuilderOpen: true,
|
||||
filterButtonActive: false,
|
||||
filterButtonCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterCount: 0,
|
||||
localFilterSummaries: [],
|
||||
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
filteredColumns: ["appointmentStatusWithAnExceptionallyLongName"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
applyWhere: vi.fn(),
|
||||
applyOrderBy: vi.fn(),
|
||||
clearOrderBy: vi.fn(),
|
||||
});
|
||||
const popoverContent = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
|
||||
|
||||
expect(String(popoverContent.props.class)).toContain("w-[480px]");
|
||||
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-24px)]");
|
||||
});
|
||||
|
||||
it("keeps filter actions available in the popover", () => {
|
||||
const clearFilters = vi.fn();
|
||||
const applyFilters = vi.fn();
|
||||
|
|
|
|||
Loading…
Reference in New Issue