From 3978a68255a63e7cfe37fe573beeb0825a98b02c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:41:44 +0000 Subject: [PATCH 1/9] Initial plan From 5500938f70e42197b492fe2bd9ce7afa92938d3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:47:21 +0000 Subject: [PATCH 2/9] Add Airtable-like filter builder component Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- .../metadata/FilterBuilder.component.yml | 39 +++ .../src/renderers/complex/filter-builder.tsx | 67 +++++ .../components/src/renderers/complex/index.ts | 1 + packages/components/src/ui/filter-builder.tsx | 231 ++++++++++++++++++ packages/components/src/ui/index.ts | 1 + 5 files changed, 339 insertions(+) create mode 100644 packages/components/metadata/FilterBuilder.component.yml create mode 100644 packages/components/src/renderers/complex/filter-builder.tsx create mode 100644 packages/components/src/ui/filter-builder.tsx diff --git a/packages/components/metadata/FilterBuilder.component.yml b/packages/components/metadata/FilterBuilder.component.yml new file mode 100644 index 000000000..5125c0cf7 --- /dev/null +++ b/packages/components/metadata/FilterBuilder.component.yml @@ -0,0 +1,39 @@ +name: FilterBuilder +label: Filter Builder +description: Airtable-like filter builder for creating complex query conditions +category: complex +version: 1.0.0 +framework: react + +props: + - name: label + type: string + description: Label text displayed above the filter builder + - name: name + type: string + required: true + description: Form field name for the filter value + - name: fields + type: array + required: true + description: Available fields for filtering + schema: + - value: string + - label: string + - type: string + - name: value + type: object + description: Current filter configuration + schema: + - id: string + - logic: enum[and, or] + - conditions: array + +events: + - name: onChange + payload: "{ name: string, value: FilterGroup }" + +features: + dynamic_conditions: true + multiple_operators: true + field_type_aware: true diff --git a/packages/components/src/renderers/complex/filter-builder.tsx b/packages/components/src/renderers/complex/filter-builder.tsx new file mode 100644 index 000000000..0518d2f84 --- /dev/null +++ b/packages/components/src/renderers/complex/filter-builder.tsx @@ -0,0 +1,67 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { FilterBuilder, type FilterGroup } from '@/ui/filter-builder'; + +ComponentRegistry.register('filter-builder', + ({ schema, className, onChange, ...props }) => { + const handleChange = (value: FilterGroup) => { + if (onChange) { + onChange({ + target: { + name: schema.name, + value: value, + }, + }); + } + }; + + return ( +
+ {schema.label && ( + + )} + +
+ ); + }, + { + label: 'Filter Builder', + inputs: [ + { name: 'label', type: 'string', label: 'Label' }, + { name: 'name', type: 'string', label: 'Name', required: true }, + { + name: 'fields', + type: 'array', + label: 'Fields', + description: 'Array of { value: string, label: string, type?: string } objects', + required: true + }, + { + name: 'value', + type: 'object', + label: 'Initial Value', + description: 'FilterGroup object with conditions' + } + ], + defaultProps: { + label: 'Filters', + name: 'filters', + fields: [ + { value: 'name', label: 'Name', type: 'text' }, + { value: 'email', label: 'Email', type: 'text' }, + { value: 'age', label: 'Age', type: 'number' }, + { value: 'status', label: 'Status', type: 'text' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [] + } + } + } +); diff --git a/packages/components/src/renderers/complex/index.ts b/packages/components/src/renderers/complex/index.ts index 4957d296e..29d7b0fa2 100644 --- a/packages/components/src/renderers/complex/index.ts +++ b/packages/components/src/renderers/complex/index.ts @@ -1,4 +1,5 @@ import './carousel'; +import './filter-builder'; import './scroll-area'; import './resizable'; import './table'; diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx new file mode 100644 index 000000000..94465fe58 --- /dev/null +++ b/packages/components/src/ui/filter-builder.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { X, Plus } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/ui/select" +import { Input } from "@/ui/input" + +export interface FilterCondition { + id: string + field: string + operator: string + value: string | number | boolean +} + +export interface FilterGroup { + id: string + logic: "and" | "or" + conditions: FilterCondition[] +} + +export interface FilterBuilderProps { + fields?: Array<{ value: string; label: string; type?: string }> + value?: FilterGroup + onChange?: (value: FilterGroup) => void + className?: string +} + +const defaultOperators = [ + { value: "equals", label: "Equals" }, + { value: "notEquals", label: "Does not equal" }, + { value: "contains", label: "Contains" }, + { value: "notContains", label: "Does not contain" }, + { value: "isEmpty", label: "Is empty" }, + { value: "isNotEmpty", label: "Is not empty" }, + { value: "greaterThan", label: "Greater than" }, + { value: "lessThan", label: "Less than" }, + { value: "greaterOrEqual", label: "Greater than or equal" }, + { value: "lessOrEqual", label: "Less than or equal" }, +] + +const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"] +const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"] +const booleanOperators = ["equals", "notEquals"] + +function FilterBuilder({ + fields = [], + value, + onChange, + className, +}: FilterBuilderProps) { + const [filterGroup, setFilterGroup] = React.useState( + value || { + id: "root", + logic: "and", + conditions: [], + } + ) + + React.useEffect(() => { + if (value && value !== filterGroup) { + setFilterGroup(value) + } + }, [value]) + + const handleChange = (newGroup: FilterGroup) => { + setFilterGroup(newGroup) + onChange?.(newGroup) + } + + const addCondition = () => { + const newCondition: FilterCondition = { + id: `condition-${Date.now()}`, + field: fields[0]?.value || "", + operator: "equals", + value: "", + } + handleChange({ + ...filterGroup, + conditions: [...filterGroup.conditions, newCondition], + }) + } + + const removeCondition = (conditionId: string) => { + handleChange({ + ...filterGroup, + conditions: filterGroup.conditions.filter((c) => c.id !== conditionId), + }) + } + + const updateCondition = (conditionId: string, updates: Partial) => { + handleChange({ + ...filterGroup, + conditions: filterGroup.conditions.map((c) => + c.id === conditionId ? { ...c, ...updates } : c + ), + }) + } + + const toggleLogic = () => { + handleChange({ + ...filterGroup, + logic: filterGroup.logic === "and" ? "or" : "and", + }) + } + + const getOperatorsForField = (fieldValue: string) => { + const field = fields.find((f) => f.value === fieldValue) + const fieldType = field?.type || "text" + + switch (fieldType) { + case "number": + return defaultOperators.filter((op) => numberOperators.includes(op.value)) + case "boolean": + return defaultOperators.filter((op) => booleanOperators.includes(op.value)) + case "text": + default: + return defaultOperators.filter((op) => textOperators.includes(op.value)) + } + } + + const needsValueInput = (operator: string) => { + return !["isEmpty", "isNotEmpty"].includes(operator) + } + + return ( +
+
+ Where + {filterGroup.conditions.length > 1 && ( + + )} +
+ +
+ {filterGroup.conditions.map((condition) => ( +
+
+
+ +
+ +
+ +
+ + {needsValueInput(condition.operator) && ( +
+ + updateCondition(condition.id, { value: e.target.value }) + } + /> +
+ )} +
+ + +
+ ))} +
+ + +
+ ) +} + +FilterBuilder.displayName = "FilterBuilder" + +export { FilterBuilder } diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 7d71e7829..309d56584 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -20,6 +20,7 @@ export * from './drawer'; export * from './dropdown-menu'; export * from './empty'; export * from './field'; +export * from './filter-builder'; export * from './form'; export * from './hover-card'; export * from './input-group'; From 257561410d2b4f976d2a887cde5ea3ef16fdb04d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:50:44 +0000 Subject: [PATCH 3/9] Add filter builder demo and example usage Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- examples/prototype/src/FilterBuilderDemo.tsx | 283 +++++++++++++++++++ examples/prototype/src/main.tsx | 9 +- 2 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 examples/prototype/src/FilterBuilderDemo.tsx diff --git a/examples/prototype/src/FilterBuilderDemo.tsx b/examples/prototype/src/FilterBuilderDemo.tsx new file mode 100644 index 000000000..1b871a6ce --- /dev/null +++ b/examples/prototype/src/FilterBuilderDemo.tsx @@ -0,0 +1,283 @@ +import { SchemaRenderer } from '@object-ui/react'; +import '@object-ui/components'; + +const filterBuilderSchema = { + type: 'div', + className: 'min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-8', + body: [ + { + type: 'div', + className: 'max-w-5xl mx-auto space-y-8', + body: [ + // Header + { + type: 'div', + className: 'space-y-2', + body: [ + { + type: 'div', + className: 'text-3xl font-bold tracking-tight', + body: { type: 'text', content: 'Filter Builder Demo' } + }, + { + type: 'div', + className: 'text-muted-foreground', + body: { + type: 'text', + content: 'Airtable-like filter component for building complex query conditions' + } + } + ] + }, + + // Example 1: User Data Filtering + { + type: 'card', + className: 'shadow-lg', + body: [ + { + type: 'div', + className: 'p-6 border-b', + body: [ + { + type: 'div', + className: 'text-xl font-semibold', + body: { type: 'text', content: 'User Data Filters' } + }, + { + type: 'div', + className: 'text-sm text-muted-foreground mt-1', + body: { + type: 'text', + content: 'Filter users by name, email, age, and status' + } + } + ] + }, + { + type: 'div', + className: 'p-6', + body: { + type: 'filter-builder', + name: 'userFilters', + label: 'User Filters', + fields: [ + { value: 'name', label: 'Name', type: 'text' }, + { value: 'email', label: 'Email', type: 'text' }, + { value: 'age', label: 'Age', type: 'number' }, + { value: 'status', label: 'Status', type: 'text' }, + { value: 'department', label: 'Department', type: 'text' }, + { value: 'joinDate', label: 'Join Date', type: 'text' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [ + { + id: 'cond-1', + field: 'status', + operator: 'equals', + value: 'active' + } + ] + } + } + } + ] + }, + + // Example 2: Product Filtering + { + type: 'card', + className: 'shadow-lg', + body: [ + { + type: 'div', + className: 'p-6 border-b', + body: [ + { + type: 'div', + className: 'text-xl font-semibold', + body: { type: 'text', content: 'Product Filters' } + }, + { + type: 'div', + className: 'text-sm text-muted-foreground mt-1', + body: { + type: 'text', + content: 'Filter products by name, price, category, and stock' + } + } + ] + }, + { + type: 'div', + className: 'p-6', + body: { + type: 'filter-builder', + name: 'productFilters', + label: 'Product Filters', + fields: [ + { value: 'name', label: 'Product Name', type: 'text' }, + { value: 'price', label: 'Price', type: 'number' }, + { value: 'category', label: 'Category', type: 'text' }, + { value: 'stock', label: 'Stock Quantity', type: 'number' }, + { value: 'brand', label: 'Brand', type: 'text' }, + { value: 'rating', label: 'Rating', type: 'number' } + ], + value: { + id: 'root', + logic: 'or', + conditions: [ + { + id: 'cond-1', + field: 'price', + operator: 'lessThan', + value: '100' + }, + { + id: 'cond-2', + field: 'category', + operator: 'equals', + value: 'Electronics' + } + ] + } + } + } + ] + }, + + // Example 3: Empty Filter Builder + { + type: 'card', + className: 'shadow-lg', + body: [ + { + type: 'div', + className: 'p-6 border-b', + body: [ + { + type: 'div', + className: 'text-xl font-semibold', + body: { type: 'text', content: 'Order Filters' } + }, + { + type: 'div', + className: 'text-sm text-muted-foreground mt-1', + body: { + type: 'text', + content: 'Start with an empty filter - try adding conditions!' + } + } + ] + }, + { + type: 'div', + className: 'p-6', + body: { + type: 'filter-builder', + name: 'orderFilters', + label: 'Order Filters', + fields: [ + { value: 'orderId', label: 'Order ID', type: 'text' }, + { value: 'customer', label: 'Customer Name', type: 'text' }, + { value: 'total', label: 'Total Amount', type: 'number' }, + { value: 'status', label: 'Order Status', type: 'text' }, + { value: 'date', label: 'Order Date', type: 'text' }, + { value: 'shipped', label: 'Shipped', type: 'boolean' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [] + } + } + } + ] + }, + + // Features section + { + type: 'card', + className: 'shadow-lg bg-primary/5 border-primary/20', + body: { + type: 'div', + className: 'p-6', + body: [ + { + type: 'div', + className: 'text-lg font-semibold mb-4', + body: { type: 'text', content: '✨ Features' } + }, + { + type: 'div', + className: 'grid md:grid-cols-2 gap-4 text-sm', + body: [ + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Dynamic add/remove filter conditions' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Field-type aware operators' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'AND/OR logic toggling' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Tailwind CSS styled' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Shadcn UI components' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Schema-driven configuration' } } + ] + } + ] + } + ] + } + } + ] + } + ] +}; + +function FilterBuilderDemo() { + return ( + + ); +} + +export default FilterBuilderDemo; diff --git a/examples/prototype/src/main.tsx b/examples/prototype/src/main.tsx index bef5202a3..3ce6029b4 100644 --- a/examples/prototype/src/main.tsx +++ b/examples/prototype/src/main.tsx @@ -2,9 +2,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import FilterBuilderDemo from './FilterBuilderDemo.tsx' + +// Check if URL parameter specifies which demo to show +const urlParams = new URLSearchParams(window.location.search); +const demo = urlParams.get('demo'); + +const DemoApp = demo === 'filter-builder' ? FilterBuilderDemo : App; createRoot(document.getElementById('root')!).render( - + , ) From 84413cd975bdd77b0cc43dfb94add984a83953bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:52:17 +0000 Subject: [PATCH 4/9] Address code review feedback - improve ID generation and type safety Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/components/src/ui/filter-builder.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx index 94465fe58..be07b019b 100644 --- a/packages/components/src/ui/filter-builder.tsx +++ b/packages/components/src/ui/filter-builder.tsx @@ -60,7 +60,7 @@ function FilterBuilder({ ) React.useEffect(() => { - if (value && value !== filterGroup) { + if (value && JSON.stringify(value) !== JSON.stringify(filterGroup)) { setFilterGroup(value) } }, [value]) @@ -72,7 +72,7 @@ function FilterBuilder({ const addCondition = () => { const newCondition: FilterCondition = { - id: `condition-${Date.now()}`, + id: `condition-${crypto.randomUUID()}`, field: fields[0]?.value || "", operator: "equals", value: "", @@ -190,7 +190,7 @@ function FilterBuilder({ updateCondition(condition.id, { value: e.target.value }) } From 3b87d452a906b587e8c176c11610fdb65c687190 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:54:36 +0000 Subject: [PATCH 5/9] Add documentation for Filter Builder component Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/components/docs/FilterBuilder.md | 219 ++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 packages/components/docs/FilterBuilder.md diff --git a/packages/components/docs/FilterBuilder.md b/packages/components/docs/FilterBuilder.md new file mode 100644 index 000000000..3786aa2fd --- /dev/null +++ b/packages/components/docs/FilterBuilder.md @@ -0,0 +1,219 @@ +# Filter Builder Component + +An Airtable-like filter builder component for building complex query conditions in Object UI. + +## Overview + +The Filter Builder component provides a user-friendly interface for creating and managing filter conditions. It supports: + +- ✅ Dynamic add/remove filter conditions +- ✅ Field selection from configurable list +- ✅ Type-aware operators (text, number, boolean) +- ✅ AND/OR logic toggling +- ✅ Schema-driven configuration +- ✅ Tailwind CSS styled with Shadcn UI components + +## Usage + +### Basic Example + +```typescript +{ + type: 'filter-builder', + name: 'userFilters', + label: 'User Filters', + fields: [ + { value: 'name', label: 'Name', type: 'text' }, + { value: 'email', label: 'Email', type: 'text' }, + { value: 'age', label: 'Age', type: 'number' }, + { value: 'status', label: 'Status', type: 'text' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [ + { + id: 'cond-1', + field: 'status', + operator: 'equals', + value: 'active' + } + ] + } +} +``` + +### With Initial Empty State + +```typescript +{ + type: 'filter-builder', + name: 'productFilters', + label: 'Product Filters', + fields: [ + { value: 'name', label: 'Product Name', type: 'text' }, + { value: 'price', label: 'Price', type: 'number' }, + { value: 'inStock', label: 'In Stock', type: 'boolean' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [] + } +} +``` + +## Props + +### Schema Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | `string` | ✅ | Must be `'filter-builder'` | +| `name` | `string` | ✅ | Form field name for the filter value | +| `label` | `string` | ❌ | Label displayed above the filter builder | +| `fields` | `Field[]` | ✅ | Array of available fields for filtering | +| `value` | `FilterGroup` | ❌ | Initial filter configuration | + +### Field Type + +```typescript +interface Field { + value: string; // Field identifier + label: string; // Display label + type?: string; // Field type: 'text' | 'number' | 'boolean' +} +``` + +### FilterGroup Type + +```typescript +interface FilterGroup { + id: string; // Group identifier + logic: 'and' | 'or'; // Logic operator + conditions: FilterCondition[]; // Array of conditions +} + +interface FilterCondition { + id: string; // Condition identifier + field: string; // Field value + operator: string; // Operator (see below) + value: string | number | boolean; // Filter value +} +``` + +## Operators + +The available operators change based on the field type: + +### Text Fields +- `equals` - Equals +- `notEquals` - Does not equal +- `contains` - Contains +- `notContains` - Does not contain +- `isEmpty` - Is empty +- `isNotEmpty` - Is not empty + +### Number Fields +- `equals` - Equals +- `notEquals` - Does not equal +- `greaterThan` - Greater than +- `lessThan` - Less than +- `greaterOrEqual` - Greater than or equal +- `lessOrEqual` - Less than or equal +- `isEmpty` - Is empty +- `isNotEmpty` - Is not empty + +### Boolean Fields +- `equals` - Equals +- `notEquals` - Does not equal + +## Events + +The component emits change events when the filter configuration is modified: + +```typescript +{ + target: { + name: 'filters', + value: { + id: 'root', + logic: 'and', + conditions: [...] + } + } +} +``` + +## Demo + +To see the filter builder in action: + +```bash +pnpm --filter prototype dev +# Visit http://localhost:5173/?demo=filter-builder +``` + +## Styling + +The component is fully styled with Tailwind CSS and follows the Object UI design system. All Shadcn UI components are used for consistent look and feel. + +You can customize the appearance using the `className` prop or by overriding Tailwind classes. + +## Integration + +The Filter Builder integrates seamlessly with Object UI's schema system and can be used in: + +- Forms +- Data tables +- Search interfaces +- Admin panels +- Dashboard filters + +## Example in Context + +```typescript +const pageSchema = { + type: 'page', + title: 'User Management', + body: [ + { + type: 'card', + body: [ + { + type: 'filter-builder', + name: 'userFilters', + label: 'Filter Users', + fields: [ + { value: 'name', label: 'Name', type: 'text' }, + { value: 'email', label: 'Email', type: 'text' }, + { value: 'age', label: 'Age', type: 'number' }, + { value: 'department', label: 'Department', type: 'text' }, + { value: 'active', label: 'Active', type: 'boolean' } + ] + } + ] + }, + { + type: 'table', + // table configuration... + } + ] +}; +``` + +## Technical Details + +- Built with React 18+ hooks +- Uses Radix UI primitives (Select, Popover) +- Type-safe with TypeScript +- Accessible keyboard navigation +- Responsive design + +## Browser Support + +Works in all modern browsers that support: +- ES6+ +- CSS Grid +- Flexbox +- crypto.randomUUID() From 9b7358e811adff454a616b45f259240d3a1d770a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:46:48 +0000 Subject: [PATCH 6/9] Add advanced features: date/select fields, clear all button, and enhanced input types Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- examples/prototype/src/FilterBuilderDemo.tsx | 50 ++++++- packages/components/docs/FilterBuilder.md | 55 ++++++- packages/components/src/ui/filter-builder.tsx | 139 +++++++++++++++--- 3 files changed, 216 insertions(+), 28 deletions(-) diff --git a/examples/prototype/src/FilterBuilderDemo.tsx b/examples/prototype/src/FilterBuilderDemo.tsx index 1b871a6ce..3d57d15be 100644 --- a/examples/prototype/src/FilterBuilderDemo.tsx +++ b/examples/prototype/src/FilterBuilderDemo.tsx @@ -24,13 +24,13 @@ const filterBuilderSchema = { className: 'text-muted-foreground', body: { type: 'text', - content: 'Airtable-like filter component for building complex query conditions' + content: 'Airtable-like filter component with advanced field types and operators' } } ] }, - // Example 1: User Data Filtering + // Example 1: User Data Filtering with Date and Select { type: 'card', className: 'shadow-lg', @@ -49,7 +49,7 @@ const filterBuilderSchema = { className: 'text-sm text-muted-foreground mt-1', body: { type: 'text', - content: 'Filter users by name, email, age, and status' + content: 'Advanced filtering with date, select, and boolean fields' } } ] @@ -65,9 +65,29 @@ const filterBuilderSchema = { { value: 'name', label: 'Name', type: 'text' }, { value: 'email', label: 'Email', type: 'text' }, { value: 'age', label: 'Age', type: 'number' }, - { value: 'status', label: 'Status', type: 'text' }, - { value: 'department', label: 'Department', type: 'text' }, - { value: 'joinDate', label: 'Join Date', type: 'text' } + { + value: 'status', + label: 'Status', + type: 'select', + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' } + ] + }, + { + value: 'department', + label: 'Department', + type: 'select', + options: [ + { value: 'engineering', label: 'Engineering' }, + { value: 'sales', label: 'Sales' }, + { value: 'marketing', label: 'Marketing' }, + { value: 'support', label: 'Support' } + ] + }, + { value: 'joinDate', label: 'Join Date', type: 'date' }, + { value: 'isVerified', label: 'Is Verified', type: 'boolean' } ], value: { id: 'root', @@ -85,8 +105,6 @@ const filterBuilderSchema = { } ] }, - - // Example 2: Product Filtering { type: 'card', className: 'shadow-lg', @@ -238,6 +256,22 @@ const filterBuilderSchema = { { type: 'div', body: { type: 'text', content: 'AND/OR logic toggling' } } ] }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Date & Select field support' } } + ] + }, + { + type: 'div', + className: 'flex items-start gap-2', + body: [ + { type: 'div', body: { type: 'text', content: '✓' }, className: 'text-primary font-bold' }, + { type: 'div', body: { type: 'text', content: 'Clear all filters button' } } + ] + }, { type: 'div', className: 'flex items-start gap-2', diff --git a/packages/components/docs/FilterBuilder.md b/packages/components/docs/FilterBuilder.md index 3786aa2fd..786aa3a8a 100644 --- a/packages/components/docs/FilterBuilder.md +++ b/packages/components/docs/FilterBuilder.md @@ -8,8 +8,11 @@ The Filter Builder component provides a user-friendly interface for creating and - ✅ Dynamic add/remove filter conditions - ✅ Field selection from configurable list -- ✅ Type-aware operators (text, number, boolean) +- ✅ Type-aware operators (text, number, boolean, date, select) - ✅ AND/OR logic toggling +- ✅ Clear all filters button +- ✅ Date picker support for date fields +- ✅ Dropdown support for select fields - ✅ Schema-driven configuration - ✅ Tailwind CSS styled with Shadcn UI components @@ -43,7 +46,7 @@ The Filter Builder component provides a user-friendly interface for creating and } ``` -### With Initial Empty State +### With Select Fields ```typescript { @@ -53,6 +56,16 @@ The Filter Builder component provides a user-friendly interface for creating and fields: [ { value: 'name', label: 'Product Name', type: 'text' }, { value: 'price', label: 'Price', type: 'number' }, + { + value: 'category', + label: 'Category', + type: 'select', + options: [ + { value: 'electronics', label: 'Electronics' }, + { value: 'clothing', label: 'Clothing' }, + { value: 'food', label: 'Food' } + ] + }, { value: 'inStock', label: 'In Stock', type: 'boolean' } ], value: { @@ -63,6 +76,23 @@ The Filter Builder component provides a user-friendly interface for creating and } ``` +### With Date Fields + +```typescript +{ + type: 'filter-builder', + name: 'orderFilters', + label: 'Order Filters', + fields: [ + { value: 'orderId', label: 'Order ID', type: 'text' }, + { value: 'amount', label: 'Amount', type: 'number' }, + { value: 'orderDate', label: 'Order Date', type: 'date' }, + { value: 'shipped', label: 'Shipped', type: 'boolean' } + ], + showClearAll: true +} +``` + ## Props ### Schema Properties @@ -74,6 +104,7 @@ The Filter Builder component provides a user-friendly interface for creating and | `label` | `string` | ❌ | Label displayed above the filter builder | | `fields` | `Field[]` | ✅ | Array of available fields for filtering | | `value` | `FilterGroup` | ❌ | Initial filter configuration | +| `showClearAll` | `boolean` | ❌ | Show "Clear all" button (default: true) | ### Field Type @@ -81,7 +112,8 @@ The Filter Builder component provides a user-friendly interface for creating and interface Field { value: string; // Field identifier label: string; // Display label - type?: string; // Field type: 'text' | 'number' | 'boolean' + type?: string; // Field type: 'text' | 'number' | 'boolean' | 'date' | 'select' + options?: Array<{ value: string; label: string }> // For select fields } ``` @@ -128,6 +160,23 @@ The available operators change based on the field type: - `equals` - Equals - `notEquals` - Does not equal +### Date Fields +- `equals` - Equals +- `notEquals` - Does not equal +- `before` - Before +- `after` - After +- `between` - Between +- `isEmpty` - Is empty +- `isNotEmpty` - Is not empty + +### Select Fields +- `equals` - Equals +- `notEquals` - Does not equal +- `in` - In +- `notIn` - Not in +- `isEmpty` - Is empty +- `isNotEmpty` - Is not empty + ## Events The component emits change events when the filter configuration is modified: diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx index be07b019b..a612ceb89 100644 --- a/packages/components/src/ui/filter-builder.tsx +++ b/packages/components/src/ui/filter-builder.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { X, Plus } from "lucide-react" +import { X, Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/ui/button" @@ -22,10 +22,16 @@ export interface FilterGroup { } export interface FilterBuilderProps { - fields?: Array<{ value: string; label: string; type?: string }> + fields?: Array<{ + value: string + label: string + type?: string + options?: Array<{ value: string; label: string }> // For select fields + }> value?: FilterGroup onChange?: (value: FilterGroup) => void className?: string + showClearAll?: boolean } const defaultOperators = [ @@ -39,17 +45,25 @@ const defaultOperators = [ { value: "lessThan", label: "Less than" }, { value: "greaterOrEqual", label: "Greater than or equal" }, { value: "lessOrEqual", label: "Less than or equal" }, + { value: "before", label: "Before" }, + { value: "after", label: "After" }, + { value: "between", label: "Between" }, + { value: "in", label: "In" }, + { value: "notIn", label: "Not in" }, ] const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"] const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"] const booleanOperators = ["equals", "notEquals"] +const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty"] +const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"] function FilterBuilder({ fields = [], value, onChange, className, + showClearAll = true, }: FilterBuilderProps) { const [filterGroup, setFilterGroup] = React.useState( value || { @@ -90,6 +104,13 @@ function FilterBuilder({ }) } + const clearAllConditions = () => { + handleChange({ + ...filterGroup, + conditions: [], + }) + } + const updateCondition = (conditionId: string, updates: Partial) => { handleChange({ ...filterGroup, @@ -115,6 +136,10 @@ function FilterBuilder({ return defaultOperators.filter((op) => numberOperators.includes(op.value)) case "boolean": return defaultOperators.filter((op) => booleanOperators.includes(op.value)) + case "date": + return defaultOperators.filter((op) => dateOperators.includes(op.value)) + case "select": + return defaultOperators.filter((op) => selectOperators.includes(op.value)) case "text": default: return defaultOperators.filter((op) => textOperators.includes(op.value)) @@ -125,18 +150,105 @@ function FilterBuilder({ return !["isEmpty", "isNotEmpty"].includes(operator) } + const getInputType = (fieldValue: string) => { + const field = fields.find((f) => f.value === fieldValue) + const fieldType = field?.type || "text" + + switch (fieldType) { + case "number": + return "number" + case "date": + return "date" + default: + return "text" + } + } + + const renderValueInput = (condition: FilterCondition) => { + const field = fields.find((f) => f.value === condition.field) + + // For select fields with options + if (field?.type === "select" && field.options) { + return ( + + ) + } + + // For boolean fields + if (field?.type === "boolean") { + return ( + + ) + } + + // Default input for text, number, date + return ( + + updateCondition(condition.id, { value: e.target.value }) + } + /> + ) + } + return (
-
- Where - {filterGroup.conditions.length > 1 && ( +
+
+ Where + {filterGroup.conditions.length > 1 && ( + + )} +
+ {showClearAll && filterGroup.conditions.length > 0 && ( )}
@@ -187,14 +299,7 @@ function FilterBuilder({ {needsValueInput(condition.operator) && (
- - updateCondition(condition.id, { value: e.target.value }) - } - /> + {renderValueInput(condition)}
)}
From 4f7b1a12486e75e6f3f73468e081c281bc6b12f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:49:44 +0000 Subject: [PATCH 7/9] Fix type conversion for number and date inputs Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/components/src/ui/filter-builder.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx index a612ceb89..d016ecd11 100644 --- a/packages/components/src/ui/filter-builder.tsx +++ b/packages/components/src/ui/filter-builder.tsx @@ -211,15 +211,38 @@ function FilterBuilder({ } // Default input for text, number, date + const inputType = getInputType(condition.field) + + // Format value based on field type + const formatValue = () => { + if (!condition.value) return "" + if (inputType === "date" && typeof condition.value === "string") { + // Ensure date is in YYYY-MM-DD format + return condition.value.split('T')[0] + } + return String(condition.value) + } + + // Handle value change with proper type conversion + const handleValueChange = (newValue: string) => { + let convertedValue: string | number | boolean = newValue + + if (field?.type === "number" && newValue !== "") { + convertedValue = parseFloat(newValue) || 0 + } else if (field?.type === "date") { + convertedValue = newValue // Keep as ISO string + } + + updateCondition(condition.id, { value: convertedValue }) + } + return ( - updateCondition(condition.id, { value: e.target.value }) - } + value={formatValue()} + onChange={(e) => handleValueChange(e.target.value)} /> ) } From d5d2b84be519dadf14228ac16a4b3b2b0b519aa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:55:17 +0000 Subject: [PATCH 8/9] Fix useEffect dependency array and simplify UUID generation Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/components/src/ui/filter-builder.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx index d016ecd11..70fe57a73 100644 --- a/packages/components/src/ui/filter-builder.tsx +++ b/packages/components/src/ui/filter-builder.tsx @@ -77,7 +77,7 @@ function FilterBuilder({ if (value && JSON.stringify(value) !== JSON.stringify(filterGroup)) { setFilterGroup(value) } - }, [value]) + }, [value, filterGroup]) const handleChange = (newGroup: FilterGroup) => { setFilterGroup(newGroup) @@ -86,7 +86,7 @@ function FilterBuilder({ const addCondition = () => { const newCondition: FilterCondition = { - id: `condition-${crypto.randomUUID()}`, + id: crypto.randomUUID(), field: fields[0]?.value || "", operator: "equals", value: "", From 750b2cb70b4a2f78e4e3a15a32c838017367a645 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:57:06 +0000 Subject: [PATCH 9/9] Revert useEffect dependency - prevent infinite loop Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/components/src/ui/filter-builder.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/ui/filter-builder.tsx b/packages/components/src/ui/filter-builder.tsx index 70fe57a73..8c973c85f 100644 --- a/packages/components/src/ui/filter-builder.tsx +++ b/packages/components/src/ui/filter-builder.tsx @@ -77,7 +77,7 @@ function FilterBuilder({ if (value && JSON.stringify(value) !== JSON.stringify(filterGroup)) { setFilterGroup(value) } - }, [value, filterGroup]) + }, [value]) const handleChange = (newGroup: FilterGroup) => { setFilterGroup(newGroup)