feat(grid): enhance field search and filter interaction

This commit is contained in:
zipg authored and GitHub committed 2026-08-07 13:43:55 +08:00
1 parent 13878a1a30
commit f9e93b356f
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: "",