feat(grid): support applying a single structured filter condition

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-11 10:52:57 +08:00
1 parent a216e0938e
commit 7b32836f27
19 files changed
+143 -6

No files matched your search

+43 -3
View File
@@ -1595,13 +1595,47 @@ function buildGroupedWhere(conditions: string[], rules: StructuredFilterRule[]):
return result;
}
async function applyStructuredFilters() {
if (!canUseWhereSearch.value) return;
appliedStructuredWhereInput.value = await buildStructuredWhereFromRules(structuredFilterRules.value);
async function applyStructuredWhere(where: string) {
appliedStructuredWhereInput.value = where;
if (!isFilterEditorPinnedOpen.value) filterBuilderOpen.value = false;
await applyWhereFilter();
}
async function applyStructuredFilters() {
if (!canUseWhereSearch.value) return;
await applyStructuredWhere(await buildStructuredWhereFromRules(structuredFilterRules.value));
}
const applyingOnlyStructuredFilter = ref(false);
async function applyOnlyStructuredFilter(ruleId: string) {
if (!canUseWhereSearch.value || applyingOnlyStructuredFilter.value || isApplyingWhere.value) return;
const rule = structuredFilterRules.value.find((item) => item.id === ruleId);
if (!rule) return;
if (!rule.columnName || !isStructuredFilterRuleComplete(rule)) {
toast(t("grid.filterBuilderCompleteRuleFirst"));
return;
}
applyingOnlyStructuredFilter.value = true;
const scopeKey = structuredFilterScopeKey.value;
const cacheKey = structuredFilterCacheKey.value;
const rulesSnapshot = JSON.stringify(structuredFilterRules.value);
try {
// Build before changing enabled states so an invalid condition cannot clear the filter.
const where = await buildStructuredWhereFromRules([{ ...rule, disabled: false }]);
if (scopeKey !== structuredFilterScopeKey.value || cacheKey !== structuredFilterCacheKey.value || rulesSnapshot !== JSON.stringify(structuredFilterRules.value)) return;
if (!where) {
toast(t("grid.filterBuilderCompleteRuleFirst"));
return;
}
filterBuilder.enableOnlyRule(ruleId);
await applyStructuredWhere(where);
} catch (error: unknown) {
toast(error instanceof Error ? error.message : String(error));
} finally {
applyingOnlyStructuredFilter.value = false;
}
}
let structuredFilterPreviewRequestId = 0;
async function refreshStructuredFilterPreview() {
const requestId = ++structuredFilterPreviewRequestId;
@@ -11521,6 +11555,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@apply-filters="applyStructuredFilters"
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
@apply-only="applyOnlyStructuredFilter"
@reset-filters="resetStructuredFilters"
@clear-filters="clearAllFilters"
@remove-rule="removeStructuredFilterRule"
@@ -11659,6 +11695,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@apply="applyStructuredFilters"
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
@apply-only="applyOnlyStructuredFilter"
@reset="resetStructuredFilters"
@clear="clearAllFilters"
@copy-sql="copyFilterSqlPreview"
@@ -11681,6 +11719,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@apply="applyStructuredFilters"
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
@apply-only="applyOnlyStructuredFilter"
@reset="resetStructuredFilters"
@clear="clearAllFilters"
@copy-sql="copyFilterSqlPreview"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Check, Eye, EyeOff, GripVertical, Plus, Search, Trash2, X } from "@lucide/vue";
import { Check, Eye, EyeOff, Focus, GripVertical, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -32,6 +32,8 @@ const props = withDefaults(
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
disabled?: boolean;
showApplyOnly?: boolean;
applyOnlyBusy?: boolean;
showHeader?: boolean;
showFooter?: boolean;
layout?: "popover" | "panel" | "text";
@@ -41,6 +43,7 @@ const props = withDefaults(
const emit = defineEmits<{
add: [];
apply: [];
applyOnly: [id: string];
reset: [];
clear: [];
remove: [id: string];
@@ -707,6 +710,9 @@ function blurValueRule(id: string) {
{{ t("grid.filterBuilderValueShortcutHint") }}
</div>
<div class="flex items-center gap-0.5" :class="props.layout === 'text' ? 'col-start-6 row-start-1' : usesExpandedLayout(rule.mode) ? 'col-start-5 row-start-1 row-span-2' : 'col-start-5 row-start-1'">
<Button v-if="props.showApplyOnly" variant="ghost" size="icon" class="h-7 w-7" :disabled="props.disabled || props.applyOnlyBusy" :title="t('grid.filterBuilderApplyOnly')" :aria-label="t('grid.filterBuilderApplyOnly')" @click="emit('applyOnly', rule.id)"
><Focus class="h-3.5 w-3.5" />
</Button>
<template v-if="props.layout === 'text'">
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="t('grid.filterBuilderAddRule')" @click="emit('add')"><Plus class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
@@ -14,6 +14,7 @@ const props = defineProps<{
filteredColumns: string[];
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
applyOnlyBusy?: boolean;
disabled?: boolean;
}>();
@@ -21,6 +22,7 @@ const emit = defineEmits<{
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
applyOnly: [id: string];
apply: [];
reset: [];
clear: [];
@@ -55,6 +57,8 @@ watch(
<DataGridFilterBuilder
class="min-w-[560px]"
:rules="rules"
:show-apply-only="true"
:apply-only-busy="applyOnlyBusy"
:columns="columns"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
@@ -64,6 +68,7 @@ watch(
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@apply-only="emit('applyOnly', $event)"
@apply="emit('apply')"
@reset="emit('reset')"
@clear="emit('clear')"
@@ -41,6 +41,7 @@ const props = defineProps<{
filteredColumns: string[];
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
applyOnlyBusy?: boolean;
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
clearOrderBy: () => void | Promise<void>;
@@ -53,6 +54,7 @@ const emit = defineEmits<{
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
applyOnly: [id: string];
applyFilters: [];
resetFilters: [];
clearFilters: [];
@@ -202,6 +204,8 @@ onUnmounted(onResizeEnd);
<DataGridFilterBuilder
ref="filterBuilderRef"
:rules="rules"
:show-apply-only="true"
:apply-only-busy="applyOnlyBusy"
:columns="[...columns]"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
@@ -209,6 +213,7 @@ onUnmounted(onResizeEnd);
:disabled="!canUseWhereSearch"
:show-header="false"
@add="emit('addRule')"
@apply-only="emit('applyOnly', $event)"
@apply="emit('applyFilters')"
@reset="emit('resetFilters')"
@clear="emit('clearFilters')"
@@ -17,6 +17,7 @@ const props = defineProps<{
filteredColumns: string[];
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
applyOnlyBusy?: boolean;
disabled?: boolean;
}>();
@@ -25,6 +26,7 @@ const emit = defineEmits<{
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
applyOnly: [id: string];
apply: [];
reset: [];
clear: [];
@@ -144,6 +146,8 @@ watch(
<DataGridFilterBuilder
class="min-w-[520px]"
:rules="rules"
:show-apply-only="true"
:apply-only-busy="applyOnlyBusy"
:columns="columns"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
@@ -153,6 +157,7 @@ watch(
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@apply-only="emit('applyOnly', $event)"
@apply="emit('apply')"
@reset="emit('reset')"
@clear="emit('clear')"
@@ -28,6 +28,7 @@ vi.mock("@lucide/vue", async () => {
ChevronsRight: icon,
Download: icon,
Filter: icon,
Focus: icon,
FileDiff: icon,
Loader2: icon,
FileUp: icon,
@@ -618,6 +619,34 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
it.each(["popover", "panel", "text"])("offers apply-only for disabled rules in the %s layout only when enabled", async (layout) => {
const applyOnly = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND", disabled: true }],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
layout,
onApplyOnly: applyOnly,
});
const buttons = () => findAll(mounted.root, (node) => node.props["aria-label"] === "grid.filterBuilderApplyOnly");
expect(buttons()).toHaveLength(0);
await mounted.setProps({ showApplyOnly: true });
expect(buttons()).toHaveLength(1);
expect(buttons()[0].props.disabled).toBeFalsy();
dispatch(buttons()[0], "click");
expect(applyOnly).toHaveBeenCalledWith("r1");
await mounted.setProps({ applyOnlyBusy: true });
expect(buttons()[0].props.disabled).toBe(true);
await mounted.setProps({ applyOnlyBusy: false });
expect(buttons()[0].props.disabled).toBeFalsy();
dispatch(buttons()[0], "click");
expect(applyOnly).toHaveBeenCalledTimes(2);
await mounted.setProps({ disabled: true });
expect(buttons()[0].props.disabled).toBe(true);
});
it("renders a compact text rule without framed form controls", async () => {
const updateRule = vi.fn();
const add = vi.fn();
@@ -7,7 +7,7 @@ import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilt
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const icon = defineComponent({ setup: () => () => h("span") });
return { Check: icon, Eye: icon, EyeOff: icon, GripVertical: icon, Plus: icon, Search: icon, Trash2: icon, X: icon };
return { Check: icon, Eye: icon, EyeOff: icon, Focus: icon, GripVertical: icon, Plus: icon, Search: icon, Trash2: icon, X: icon };
});
function passthrough(tag = "div") {