AdvancedFilterBuilder
freeAdvancedFilterBuilder edits nested rule groups and emits both a filter tree and query string.
@floating-ui/reactdayjsmotionreact-number-format
Preview
Dark
Where
Current Query
Use Apply Filter to commit the current rules.
import { useState } from 'react';
import AdvancedFilterBuilder, {
toQueryString,
type FieldConfig,
type FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
const orderFields: FieldConfig[] = [
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'processing', label: 'Processing' },
{ value: 'completed', label: 'Completed' },
{ value: 'pending', label: 'Pending' },
{ value: 'cancelled', label: 'Cancelled' },
],
},
{ key: 'order_id', label: 'Order ID', type: 'text' },
{ key: 'customer', label: 'Customer', type: 'text' },
{ key: 'total', label: 'Order total', type: 'number' },
{ key: 'placed_at', label: 'Placed date', type: 'date' },
{ key: 'paid', label: 'Paid', type: 'boolean' },
];
const initialOrderFilter: FilterTree = {
id: 'orders-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'order-status',
type: 'rule',
field: 'status',
operator: 'is',
value: 'processing',
},
{
id: 'order-total',
type: 'rule',
field: 'total',
operator: 'greater_equal',
value: 250,
logicalOperator: 'AND',
},
],
};
export default function UsageDemo() {
const [query, setQuery] = useState(() =>
toQueryString(initialOrderFilter),
);
const [applied, setApplied] = useState('');
return (
<div className="w-full space-y-4">
<div className="w-full">
<AdvancedFilterBuilder
fields={orderFields}
value={initialOrderFilter}
onChange={(_, nextQuery) => setQuery(nextQuery)}
onApply={(_, nextQuery) => setApplied(nextQuery)}
/>
</div>
<div className="rounded-card border bg-card p-4 text-sm">
<div className="font-medium">
{applied ? 'Applied query' : 'Current Query'}
</div>
<div className="mt-1 font-mono text-xs text-muted-foreground">
{applied || 'Use Apply Filter to commit the current rules.'}
</div>
</div>
</div>
);
}Installation
Add this component with the NateUI CLI.
npx nateui@latest add AdvancedFilterBuilderExamples
Custom Fields
Preview
Dark
Where
Current query
Start with the first rule.
import { useState } from 'react';
import AdvancedFilterBuilder from '@/components/composites/AdvancedFilterBuilder';
import Tag from '@/components/ui/Tag';
import type { FieldConfig } from '@/components/composites/AdvancedFilterBuilder';
const contactFields: FieldConfig[] = [
{ key: 'contact_email', label: 'Contact email', type: 'text' },
{ key: 'contact_score', label: 'Contact score', type: 'number' },
{
key: 'contact_status',
label: 'Contact status',
type: 'select',
options: [
{ value: 'lead', label: 'Lead' },
{ value: 'qualified', label: 'Qualified' },
{ value: 'customer', label: 'Customer' },
],
},
{ key: 'last_activity_at', label: 'Last activity', type: 'date' },
{ key: 'subscribed', label: 'Subscribed', type: 'boolean' },
];
export default function CustomFieldsDemo() {
const [query, setQuery] = useState('');
return (
<div className="w-full space-y-4">
<div className="w-full">
<div className="min-w-[680px]">
<AdvancedFilterBuilder
fields={contactFields}
onChange={(_, nextQuery) => setQuery(nextQuery)}
/>
</div>
</div>
<div className="rounded-card border bg-card p-4">
<div className="font-medium">
Current query
</div>
<div className="mt-1 font-mono text-xs text-foreground">
{query || 'Start with the first rule.'}
</div>
</div>
</div>
);
}Initial Value
Preview
Dark
Where
(status=active AND rating>=4 OR category=electronics)
import { useState } from 'react';
import AdvancedFilterBuilder, {
toQueryString,
type FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
import Tag from '@/components/ui/Tag';
const savedView: FilterTree = {
id: 'saved-view',
type: 'group',
condition: 'AND',
children: [
{
id: 'saved-status',
type: 'rule',
field: 'status',
operator: 'is',
value: 'active',
},
{
id: 'saved-rating',
type: 'rule',
field: 'rating',
operator: 'greater_equal',
value: 4,
logicalOperator: 'AND',
},
{
id: 'saved-category',
type: 'rule',
field: 'category',
operator: 'is',
value: 'electronics',
logicalOperator: 'OR',
},
],
};
export default function InitialValueDemo() {
const [query, setQuery] = useState(() => toQueryString(savedView));
return (
<div className="w-full space-y-4">
<div className="w-full">
<div className="min-w-[680px]">
<AdvancedFilterBuilder
value={savedView}
onChange={(_, nextQuery) => setQuery(nextQuery)}
/>
</div>
</div>
<div className="rounded-card border bg-card p-4 font-mono text-xs text-muted-foreground">
{query}
</div>
</div>
);
}Popover
Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Popover from '@/components/ui/Popover';
import DebounceInput from '@/components/composites/DebounceInput';
import AdvancedFilterBuilder, {
toQueryString,
type FieldConfig,
type FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
import { PiFunnel, PiMagnifyingGlass } from 'react-icons/pi'
const fields: FieldConfig[] = [
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'processing', label: 'Processing' },
{ value: 'completed', label: 'Completed' },
{ value: 'pending', label: 'Pending' },
{ value: 'cancelled', label: 'Cancelled' },
],
},
{ key: 'order_id', label: 'Order ID', type: 'text' },
{ key: 'customer', label: 'Customer', type: 'text' },
{ key: 'total', label: 'Order total', type: 'number' },
{ key: 'placed_at', label: 'Placed date', type: 'date' },
{ key: 'paid', label: 'Paid', type: 'boolean' },
];
const initialFilter: FilterTree = {
id: 'popover-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'popover-status',
type: 'rule',
field: 'status',
operator: 'is',
value: 'processing',
},
{
id: 'popover-total',
type: 'rule',
field: 'total',
operator: 'greater_equal',
value: 250,
logicalOperator: 'AND',
},
],
};
export default function PopoverDemo() {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<FilterTree>(initialFilter);
const [appliedQuery, setAppliedQuery] = useState(() =>
toQueryString(initialFilter),
);
return (
<div className="w-full">
<div className="flex justify-center">
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom"
className="p-0"
renderTrigger={
<Button icon={<PiFunnel />}>
Filters ({filter.children.length})
</Button>
}
>
<AdvancedFilterBuilder
fields={fields}
value={filter}
onChange={(nextFilter) => setFilter(nextFilter)}
onApply={(nextFilter, nextQuery) => {
setFilter(nextFilter);
setAppliedQuery(nextQuery);
setOpen(false);
}}
onReset={(nextFilter, nextQuery) => {
setFilter(nextFilter);
setAppliedQuery(nextQuery);
}}
/>
</Popover>
</div>
</div>
);
}Nested Groups
Preview
Dark
Where
Where
Last applied
(status=processing OR (customer~=acme AND item_count>2))
import { useState } from 'react';
import AdvancedFilterBuilder, {
toQueryString,
type FieldConfig,
type FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
const fields: FieldConfig[] = [
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'processing', label: 'Processing' },
{ value: 'completed', label: 'Completed' },
{ value: 'pending', label: 'Pending' },
],
},
{ key: 'customer', label: 'Customer', type: 'text' },
{ key: 'item_count', label: 'Item count', type: 'number' },
{ key: 'created_at', label: 'Created date', type: 'date' },
];
const nestedFilter: FilterTree = {
id: 'nested-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'nested-status',
type: 'rule',
field: 'status',
operator: 'is',
value: 'processing',
},
{
id: 'nested-group',
type: 'group',
condition: 'AND',
logicalOperator: 'OR',
children: [
{
id: 'nested-customer',
type: 'rule',
field: 'customer',
operator: 'contains',
value: 'acme',
},
{
id: 'nested-count',
type: 'rule',
field: 'item_count',
operator: 'greater_than',
value: 2,
logicalOperator: 'AND',
},
],
},
],
};
export default function NestedGroupsDemo() {
const [applied, setApplied] = useState(() => toQueryString(nestedFilter));
return (
<div className="w-full space-y-4">
<div className="w-full">
<div className="min-w-[680px]">
<AdvancedFilterBuilder
fields={fields}
value={nestedFilter}
onApply={(_, nextQuery) => setApplied(nextQuery)}
/>
</div>
</div>
<div className="rounded-card border bg-card p-4">
<div className="font-medium">
Last applied
</div>
<div className="mt-1 font-mono text-xs text-foreground">
{applied}
</div>
</div>
</div>
);
}Query Helpers
parseQueryString turns a query string into a FilterTree; toQueryString
serializes the edited tree back to the string form.
Preview
Dark
Where
import { useState } from 'react';
import AdvancedFilterBuilder, {
parseQueryString,
toQueryString,
type FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
const defaultQuery =
'(status=active AND price>=100) OR category=electronics';
export default function QueryHelpersDemo() {
const [draft, setDraft] = useState(defaultQuery);
const [tree, setTree] = useState<FilterTree>(() =>
parseQueryString(defaultQuery),
);
const loadQuery = () => {
setTree(parseQueryString(draft));
};
return (
<div className="w-full space-y-4">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="font-mono"
/>
<Button onClick={loadQuery}>Load query</Button>
</div>
<div className="w-full">
<div className="min-w-[680px]">
<AdvancedFilterBuilder
value={tree}
onChange={(nextTree, nextQuery) => {
setTree(nextTree);
setDraft(nextQuery);
}}
onApply={(nextTree) => {
setDraft(toQueryString(nextTree));
}}
/>
</div>
</div>
</div>
);
}Callback Events
Preview
Dark
Where
Event log
- No callback has fired yet.
import { useState } from 'react';
import AdvancedFilterBuilder from '@/components/composites/AdvancedFilterBuilder';
import type {
FieldConfig,
FilterTree,
} from '@/components/composites/AdvancedFilterBuilder';
const fields: FieldConfig[] = [
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'open', label: 'Open' },
{ value: 'in_review', label: 'In review' },
{ value: 'closed', label: 'Closed' },
],
},
{ key: 'owner', label: 'Owner', type: 'text' },
{ key: 'amount', label: 'Amount', type: 'number' },
];
const initialFilter: FilterTree = {
id: 'callback-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'callback-status',
type: 'rule',
field: 'status',
operator: 'is',
value: 'open',
},
],
};
export default function CallbackEventsDemo() {
const [events, setEvents] = useState<string[]>([
'No callback has fired yet.',
]);
const record = (name: string, query: string) => {
setEvents((prev) => [
`${name}: ${query || 'empty query'}`,
...prev.filter((event) => event !== 'No callback has fired yet.'),
].slice(0, 4));
};
return (
<div className="w-full space-y-4">
<div className="w-full">
<AdvancedFilterBuilder
fields={fields}
value={initialFilter}
onChange={(_, query) => record('change', query)}
onApply={(_, query) => record('apply', query)}
onReset={(_, query) => record('reset', query)}
/>
</div>
<div className="rounded-card border bg-card p-4">
<div className="font-medium">
Event log
</div>
<ul className="mt-2 space-y-2">
{events.map((event, index) => (
<li key={`${event}-${index}`} className="font-mono text-xs text-foreground">
{event}
</li>
))}
</ul>
</div>
</div>
);
}API
AdvancedFilterBuilder stores rule edits internally unless a new value tree is
passed from the parent. All callbacks receive the current tree and the matching
query string.
| Prop | Description | Type | Default |
|---|---|---|---|
fields | Field list used to render the field dropdown, operator list, and value input for each rule. | FieldConfig[] | DEFAULT_FIELDS |
value | Filter tree used to initialize or replace the builder state. | FilterTree | - |
onChange | Called after a rule or group edit. | (tree: FilterTree, queryString: string) => void | - |
onApply | Called when the Apply Filter button is pressed. | (tree: FilterTree, queryString: string) => void | - |
onReset | Called after Reset restores a single empty rule. | (tree: FilterTree, queryString: string) => void | - |
Utilities
| Export | Description | Type |
|---|---|---|
toQueryString | Serializes a FilterTree to the query string form used by callbacks. | (tree: FilterTree) => string |
parseQueryString | Parses a query string into a FilterTree. Field type coercion currently follows the built-in default fields. | (query: string) => FilterTree |
Types
type FieldType = 'text' | 'number' | 'select' | 'date' | 'boolean';
type FieldConfig = {
key: string;
label: string;
type: FieldType;
options?: Array<{ value: string; label: string }>;
};
type Rule = {
id: string;
type: 'rule';
field: string;
operator: string;
value: string | number | boolean;
logicalOperator?: 'AND' | 'OR' | 'NOT';
};
type RuleGroup = {
id: string;
type: 'group';
condition: 'AND' | 'OR';
children: Array<Rule | RuleGroup>;
logicalOperator?: 'AND' | 'OR' | 'NOT';
};
type FilterTree = RuleGroup;