feat(grid): enhance field search and filter interaction
This commit is contained in:
26 files changed
+491
-35
No files matched your search
@@ -720,7 +720,9 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
|
||||
{ value: "like", labelKey: "grid.filterBuilderContains" },
|
||||
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
|
||||
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
|
||||
{ value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
|
||||
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
|
||||
{ value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
|
||||
{ value: "in", labelKey: "grid.filterBuilderIn" },
|
||||
{ value: "not-in", labelKey: "grid.filterBuilderNotIn" },
|
||||
{ value: "between", labelKey: "grid.filterBuilderBetween" },
|
||||
@@ -1565,8 +1567,8 @@ watch(
|
||||
[structuredFilterRules, appliedStructuredWhereInput, serverColumnFilters],
|
||||
() => {
|
||||
const columns = filterBuilderColumnOptions.value;
|
||||
if (columns.length > 0 && structuredFilterRules.value.some((rule) => !columns.includes(rule.columnName))) {
|
||||
structuredFilterRules.value = structuredFilterRules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
|
||||
if (columns.length > 0 && structuredFilterRules.value.some((rule) => rule.columnName && !columns.includes(rule.columnName))) {
|
||||
structuredFilterRules.value = structuredFilterRules.value.map((rule) => (!rule.columnName || columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
|
||||
return;
|
||||
}
|
||||
persistStructuredFilterState();
|
||||
|
||||
@@ -134,6 +134,13 @@ async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open:
|
||||
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
|
||||
}
|
||||
|
||||
async function openFirstEmptyRuleColumnSearch() {
|
||||
const rule = props.rules.find((item) => !item.columnName && !item.disabled);
|
||||
if (rule) await handleColumnSelectOpen(rule, true);
|
||||
}
|
||||
|
||||
defineExpose({ openFirstEmptyRuleColumnSearch });
|
||||
|
||||
function handleColumnCloseAutoFocus(id: string, event: Event) {
|
||||
if (pendingKeyboardAddFocus.delete(id)) {
|
||||
event.preventDefault();
|
||||
@@ -263,7 +270,7 @@ function blurValueRule(id: string) {
|
||||
<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 :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center justify-start gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,260px)_88px_auto]' : 'grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]'">
|
||||
<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]'">
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { Filter, Trash2 } from "@lucide/vue";
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { Filter, Trash2, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -61,6 +61,9 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const filterBuilderRef = ref<InstanceType<typeof DataGridFilterBuilder>>();
|
||||
const pendingFirstEmptyRuleColumnSearch = ref(false);
|
||||
let openingFirstEmptyRuleColumnSearch = false;
|
||||
const whereWidth = ref<number | null>(null);
|
||||
const resizing = ref(false);
|
||||
let resizeStartX = 0;
|
||||
@@ -112,6 +115,32 @@ function clearWhere() {
|
||||
emit("clearFilters");
|
||||
}
|
||||
|
||||
async function openPendingFirstEmptyRuleColumnSearch() {
|
||||
if (openingFirstEmptyRuleColumnSearch || !pendingFirstEmptyRuleColumnSearch.value || !props.filterBuilderOpen || !filterBuilderRef.value) return;
|
||||
if (!props.rules.some((rule) => !rule.columnName && !rule.disabled)) return;
|
||||
openingFirstEmptyRuleColumnSearch = true;
|
||||
await nextTick();
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
try {
|
||||
if (!pendingFirstEmptyRuleColumnSearch.value || !props.filterBuilderOpen || !filterBuilderRef.value) return;
|
||||
pendingFirstEmptyRuleColumnSearch.value = false;
|
||||
await filterBuilderRef.value.openFirstEmptyRuleColumnSearch();
|
||||
} finally {
|
||||
openingFirstEmptyRuleColumnSearch = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterButtonClick() {
|
||||
const shouldFocusColumnSearch = !props.filterBuilderOpen && props.rules.every((rule) => !rule.columnName);
|
||||
pendingFirstEmptyRuleColumnSearch.value = shouldFocusColumnSearch;
|
||||
emit("ensureRule");
|
||||
if (!shouldFocusColumnSearch) return;
|
||||
await nextTick();
|
||||
await openPendingFirstEmptyRuleColumnSearch();
|
||||
}
|
||||
|
||||
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
|
||||
|
||||
onUnmounted(onResizeEnd);
|
||||
</script>
|
||||
|
||||
@@ -125,7 +154,7 @@ onUnmounted(onResizeEnd);
|
||||
class="relative flex h-5 w-5 -translate-x-1 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')"
|
||||
@click="handleFilterButtonClick"
|
||||
>
|
||||
<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>
|
||||
@@ -159,6 +188,7 @@ onUnmounted(onResizeEnd);
|
||||
</div>
|
||||
|
||||
<DataGridFilterBuilder
|
||||
ref="filterBuilderRef"
|
||||
:rules="rules"
|
||||
:columns="[...columns]"
|
||||
:filtered-columns="filteredColumns"
|
||||
|
||||
@@ -174,6 +174,23 @@ describe("DataGridConditionEditor quote completion", () => {
|
||||
expect(document.querySelector('[role="option"][aria-selected="true"]')?.textContent).toContain("name");
|
||||
});
|
||||
|
||||
it("keeps suggestions closed after Enter applies a complete condition", async () => {
|
||||
const { input } = mountEditor("where", "", { columns: ["id", "order0", "status"] });
|
||||
input.focus();
|
||||
input.value = "id > 0";
|
||||
input.setSelectionRange(6, 6);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
input.setSelectionRange(0, 0);
|
||||
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps expanded input first-line indent and wraps long tokens", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
@@ -387,6 +387,21 @@ describe("DataGridColumnHeader", () => {
|
||||
});
|
||||
|
||||
describe("DataGridFilterBuilder", () => {
|
||||
it("opens the first empty rule column search on request", async () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["id"],
|
||||
filteredColumns: ["id"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
});
|
||||
|
||||
await mounted.exposed.value.openFirstEmptyRuleColumnSearch();
|
||||
|
||||
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
|
||||
expect(columnSelect.props.open).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps selected columns and values readable without stretching the controls", () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
@@ -400,7 +415,7 @@ 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)_88px_minmax(0,210px)_auto]"));
|
||||
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_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"] === "");
|
||||
|
||||
@@ -414,7 +429,7 @@ 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)_88px_minmax(0,210px)_auto]");
|
||||
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]");
|
||||
expect(String(ruleGrid.props.class)).toContain("justify-start");
|
||||
for (const trigger of triggers) {
|
||||
expect(String(trigger.props.class)).toContain("w-full");
|
||||
@@ -616,6 +631,88 @@ describe("DataGridFilterBuilder", () => {
|
||||
});
|
||||
|
||||
describe("DataGridQueryControls", () => {
|
||||
it("opens column search when the filter button creates the first rule", async () => {
|
||||
let mounted: ReturnType<typeof mountComponent>;
|
||||
const firstRule = { id: "r1", columnName: "", mode: "equals" as const, rawValue: "", rawEndValue: "", conjunction: "AND" as const };
|
||||
const ensureRule = vi.fn(() => {
|
||||
void mounted.setProps({ rules: [firstRule], filterBuilderOpen: true });
|
||||
});
|
||||
mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "",
|
||||
orderByInput: "",
|
||||
columns: ["id"],
|
||||
conditionColumns: ["id"],
|
||||
historyScope: {},
|
||||
canUseWhereSearch: true,
|
||||
compact: false,
|
||||
leadingBorder: false,
|
||||
filterBuilderOpen: false,
|
||||
filterButtonActive: false,
|
||||
filterButtonCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterCount: 0,
|
||||
localFilterSummaries: [],
|
||||
rules: [],
|
||||
filteredColumns: ["id"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
applyWhere: vi.fn(),
|
||||
applyOrderBy: vi.fn(),
|
||||
clearOrderBy: vi.fn(),
|
||||
onEnsureRule: ensureRule,
|
||||
});
|
||||
|
||||
const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
|
||||
dispatch(filterButton, "click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await nextTick();
|
||||
|
||||
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
|
||||
expect(ensureRule).toHaveBeenCalledOnce();
|
||||
expect(columnSelect.props.open).toBe(true);
|
||||
});
|
||||
|
||||
it("does not open column search when filter rules already exist", async () => {
|
||||
let mounted: ReturnType<typeof mountComponent>;
|
||||
const ensureRule = vi.fn(() => {
|
||||
void mounted.setProps({ filterBuilderOpen: true });
|
||||
});
|
||||
mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "id = 1",
|
||||
orderByInput: "",
|
||||
columns: ["id"],
|
||||
conditionColumns: ["id"],
|
||||
historyScope: {},
|
||||
canUseWhereSearch: true,
|
||||
compact: false,
|
||||
leadingBorder: false,
|
||||
filterBuilderOpen: false,
|
||||
filterButtonActive: true,
|
||||
filterButtonCount: 1,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterCount: 0,
|
||||
localFilterSummaries: [],
|
||||
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
|
||||
filteredColumns: ["id"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
applyWhere: vi.fn(),
|
||||
applyOrderBy: vi.fn(),
|
||||
clearOrderBy: vi.fn(),
|
||||
onEnsureRule: ensureRule,
|
||||
});
|
||||
|
||||
const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
|
||||
dispatch(filterButton, "click");
|
||||
await nextTick();
|
||||
|
||||
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
|
||||
expect(ensureRule).toHaveBeenCalledOnce();
|
||||
expect(columnSelect.props.open).toBe(false);
|
||||
});
|
||||
|
||||
it("gives filter rules enough horizontal space for longer column names", () => {
|
||||
const mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "",
|
||||
|
||||
Reference in new issue
Block a user