Skip to content

Commit 5500938

Browse files
Copilothuangyiirene
andcommitted
Add Airtable-like filter builder component
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
1 parent 3978a68 commit 5500938

5 files changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: FilterBuilder
2+
label: Filter Builder
3+
description: Airtable-like filter builder for creating complex query conditions
4+
category: complex
5+
version: 1.0.0
6+
framework: react
7+
8+
props:
9+
- name: label
10+
type: string
11+
description: Label text displayed above the filter builder
12+
- name: name
13+
type: string
14+
required: true
15+
description: Form field name for the filter value
16+
- name: fields
17+
type: array
18+
required: true
19+
description: Available fields for filtering
20+
schema:
21+
- value: string
22+
- label: string
23+
- type: string
24+
- name: value
25+
type: object
26+
description: Current filter configuration
27+
schema:
28+
- id: string
29+
- logic: enum[and, or]
30+
- conditions: array
31+
32+
events:
33+
- name: onChange
34+
payload: "{ name: string, value: FilterGroup }"
35+
36+
features:
37+
dynamic_conditions: true
38+
multiple_operators: true
39+
field_type_aware: true
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ComponentRegistry } from '@object-ui/core';
2+
import { FilterBuilder, type FilterGroup } from '@/ui/filter-builder';
3+
4+
ComponentRegistry.register('filter-builder',
5+
({ schema, className, onChange, ...props }) => {
6+
const handleChange = (value: FilterGroup) => {
7+
if (onChange) {
8+
onChange({
9+
target: {
10+
name: schema.name,
11+
value: value,
12+
},
13+
});
14+
}
15+
};
16+
17+
return (
18+
<div className={schema.wrapperClass || ''}>
19+
{schema.label && (
20+
<label className="text-sm font-medium mb-2 block">{schema.label}</label>
21+
)}
22+
<FilterBuilder
23+
fields={schema.fields || []}
24+
value={schema.value || props.value}
25+
onChange={handleChange}
26+
className={className}
27+
{...props}
28+
/>
29+
</div>
30+
);
31+
},
32+
{
33+
label: 'Filter Builder',
34+
inputs: [
35+
{ name: 'label', type: 'string', label: 'Label' },
36+
{ name: 'name', type: 'string', label: 'Name', required: true },
37+
{
38+
name: 'fields',
39+
type: 'array',
40+
label: 'Fields',
41+
description: 'Array of { value: string, label: string, type?: string } objects',
42+
required: true
43+
},
44+
{
45+
name: 'value',
46+
type: 'object',
47+
label: 'Initial Value',
48+
description: 'FilterGroup object with conditions'
49+
}
50+
],
51+
defaultProps: {
52+
label: 'Filters',
53+
name: 'filters',
54+
fields: [
55+
{ value: 'name', label: 'Name', type: 'text' },
56+
{ value: 'email', label: 'Email', type: 'text' },
57+
{ value: 'age', label: 'Age', type: 'number' },
58+
{ value: 'status', label: 'Status', type: 'text' }
59+
],
60+
value: {
61+
id: 'root',
62+
logic: 'and',
63+
conditions: []
64+
}
65+
}
66+
}
67+
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import './carousel';
2+
import './filter-builder';
23
import './scroll-area';
34
import './resizable';
45
import './table';
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { X, Plus } from "lucide-react"
5+
6+
import { cn } from "@/lib/utils"
7+
import { Button } from "@/ui/button"
8+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/ui/select"
9+
import { Input } from "@/ui/input"
10+
11+
export interface FilterCondition {
12+
id: string
13+
field: string
14+
operator: string
15+
value: string | number | boolean
16+
}
17+
18+
export interface FilterGroup {
19+
id: string
20+
logic: "and" | "or"
21+
conditions: FilterCondition[]
22+
}
23+
24+
export interface FilterBuilderProps {
25+
fields?: Array<{ value: string; label: string; type?: string }>
26+
value?: FilterGroup
27+
onChange?: (value: FilterGroup) => void
28+
className?: string
29+
}
30+
31+
const defaultOperators = [
32+
{ value: "equals", label: "Equals" },
33+
{ value: "notEquals", label: "Does not equal" },
34+
{ value: "contains", label: "Contains" },
35+
{ value: "notContains", label: "Does not contain" },
36+
{ value: "isEmpty", label: "Is empty" },
37+
{ value: "isNotEmpty", label: "Is not empty" },
38+
{ value: "greaterThan", label: "Greater than" },
39+
{ value: "lessThan", label: "Less than" },
40+
{ value: "greaterOrEqual", label: "Greater than or equal" },
41+
{ value: "lessOrEqual", label: "Less than or equal" },
42+
]
43+
44+
const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"]
45+
const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"]
46+
const booleanOperators = ["equals", "notEquals"]
47+
48+
function FilterBuilder({
49+
fields = [],
50+
value,
51+
onChange,
52+
className,
53+
}: FilterBuilderProps) {
54+
const [filterGroup, setFilterGroup] = React.useState<FilterGroup>(
55+
value || {
56+
id: "root",
57+
logic: "and",
58+
conditions: [],
59+
}
60+
)
61+
62+
React.useEffect(() => {
63+
if (value && value !== filterGroup) {
64+
setFilterGroup(value)
65+
}
66+
}, [value])
67+
68+
const handleChange = (newGroup: FilterGroup) => {
69+
setFilterGroup(newGroup)
70+
onChange?.(newGroup)
71+
}
72+
73+
const addCondition = () => {
74+
const newCondition: FilterCondition = {
75+
id: `condition-${Date.now()}`,
76+
field: fields[0]?.value || "",
77+
operator: "equals",
78+
value: "",
79+
}
80+
handleChange({
81+
...filterGroup,
82+
conditions: [...filterGroup.conditions, newCondition],
83+
})
84+
}
85+
86+
const removeCondition = (conditionId: string) => {
87+
handleChange({
88+
...filterGroup,
89+
conditions: filterGroup.conditions.filter((c) => c.id !== conditionId),
90+
})
91+
}
92+
93+
const updateCondition = (conditionId: string, updates: Partial<FilterCondition>) => {
94+
handleChange({
95+
...filterGroup,
96+
conditions: filterGroup.conditions.map((c) =>
97+
c.id === conditionId ? { ...c, ...updates } : c
98+
),
99+
})
100+
}
101+
102+
const toggleLogic = () => {
103+
handleChange({
104+
...filterGroup,
105+
logic: filterGroup.logic === "and" ? "or" : "and",
106+
})
107+
}
108+
109+
const getOperatorsForField = (fieldValue: string) => {
110+
const field = fields.find((f) => f.value === fieldValue)
111+
const fieldType = field?.type || "text"
112+
113+
switch (fieldType) {
114+
case "number":
115+
return defaultOperators.filter((op) => numberOperators.includes(op.value))
116+
case "boolean":
117+
return defaultOperators.filter((op) => booleanOperators.includes(op.value))
118+
case "text":
119+
default:
120+
return defaultOperators.filter((op) => textOperators.includes(op.value))
121+
}
122+
}
123+
124+
const needsValueInput = (operator: string) => {
125+
return !["isEmpty", "isNotEmpty"].includes(operator)
126+
}
127+
128+
return (
129+
<div className={cn("space-y-3", className)}>
130+
<div className="flex items-center gap-2">
131+
<span className="text-sm font-medium">Where</span>
132+
{filterGroup.conditions.length > 1 && (
133+
<Button
134+
variant="outline"
135+
size="sm"
136+
onClick={toggleLogic}
137+
className="h-7 text-xs"
138+
>
139+
{filterGroup.logic.toUpperCase()}
140+
</Button>
141+
)}
142+
</div>
143+
144+
<div className="space-y-2">
145+
{filterGroup.conditions.map((condition) => (
146+
<div key={condition.id} className="flex items-start gap-2">
147+
<div className="flex-1 grid grid-cols-12 gap-2">
148+
<div className="col-span-4">
149+
<Select
150+
value={condition.field}
151+
onValueChange={(value) =>
152+
updateCondition(condition.id, { field: value })
153+
}
154+
>
155+
<SelectTrigger className="h-9 text-sm">
156+
<SelectValue placeholder="Select field" />
157+
</SelectTrigger>
158+
<SelectContent>
159+
{fields.map((field) => (
160+
<SelectItem key={field.value} value={field.value}>
161+
{field.label}
162+
</SelectItem>
163+
))}
164+
</SelectContent>
165+
</Select>
166+
</div>
167+
168+
<div className="col-span-4">
169+
<Select
170+
value={condition.operator}
171+
onValueChange={(value) =>
172+
updateCondition(condition.id, { operator: value })
173+
}
174+
>
175+
<SelectTrigger className="h-9 text-sm">
176+
<SelectValue placeholder="Operator" />
177+
</SelectTrigger>
178+
<SelectContent>
179+
{getOperatorsForField(condition.field).map((op) => (
180+
<SelectItem key={op.value} value={op.value}>
181+
{op.label}
182+
</SelectItem>
183+
))}
184+
</SelectContent>
185+
</Select>
186+
</div>
187+
188+
{needsValueInput(condition.operator) && (
189+
<div className="col-span-4">
190+
<Input
191+
className="h-9 text-sm"
192+
placeholder="Value"
193+
value={condition.value as string}
194+
onChange={(e) =>
195+
updateCondition(condition.id, { value: e.target.value })
196+
}
197+
/>
198+
</div>
199+
)}
200+
</div>
201+
202+
<Button
203+
variant="ghost"
204+
size="icon-sm"
205+
className="h-9 w-9 shrink-0"
206+
onClick={() => removeCondition(condition.id)}
207+
>
208+
<X className="h-4 w-4" />
209+
<span className="sr-only">Remove condition</span>
210+
</Button>
211+
</div>
212+
))}
213+
</div>
214+
215+
<Button
216+
variant="outline"
217+
size="sm"
218+
onClick={addCondition}
219+
className="h-8"
220+
disabled={fields.length === 0}
221+
>
222+
<Plus className="h-3 w-3" />
223+
Add filter
224+
</Button>
225+
</div>
226+
)
227+
}
228+
229+
FilterBuilder.displayName = "FilterBuilder"
230+
231+
export { FilterBuilder }

packages/components/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from './drawer';
2020
export * from './dropdown-menu';
2121
export * from './empty';
2222
export * from './field';
23+
export * from './filter-builder';
2324
export * from './form';
2425
export * from './hover-card';
2526
export * from './input-group';

0 commit comments

Comments
 (0)