diff --git a/examples/prototype/src/FilterBuilderDemo.tsx b/examples/prototype/src/FilterBuilderDemo.tsx new file mode 100644 index 000000000..3d57d15be --- /dev/null +++ b/examples/prototype/src/FilterBuilderDemo.tsx @@ -0,0 +1,317 @@ +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 with advanced field types and operators' + } + } + ] + }, + + // Example 1: User Data Filtering with Date and Select + { + 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: 'Advanced filtering with date, select, and boolean fields' + } + } + ] + }, + { + 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: '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', + logic: 'and', + conditions: [ + { + id: 'cond-1', + field: 'status', + operator: 'equals', + value: 'active' + } + ] + } + } + } + ] + }, + { + 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: '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', + 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( - + , ) diff --git a/packages/components/docs/FilterBuilder.md b/packages/components/docs/FilterBuilder.md new file mode 100644 index 000000000..786aa3a8a --- /dev/null +++ b/packages/components/docs/FilterBuilder.md @@ -0,0 +1,268 @@ +# 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, 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 + +## 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 Select Fields + +```typescript +{ + 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: 'select', + options: [ + { value: 'electronics', label: 'Electronics' }, + { value: 'clothing', label: 'Clothing' }, + { value: 'food', label: 'Food' } + ] + }, + { value: 'inStock', label: 'In Stock', type: 'boolean' } + ], + value: { + id: 'root', + logic: 'and', + conditions: [] + } +} +``` + +### 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 + +| 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 | +| `showClearAll` | `boolean` | ❌ | Show "Clear all" button (default: true) | + +### Field Type + +```typescript +interface Field { + value: string; // Field identifier + label: string; // Display label + type?: string; // Field type: 'text' | 'number' | 'boolean' | 'date' | 'select' + options?: Array<{ value: string; label: string }> // For select fields +} +``` + +### 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 + +### 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: + +```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() 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..8c973c85f --- /dev/null +++ b/packages/components/src/ui/filter-builder.tsx @@ -0,0 +1,359 @@ +"use client" + +import * as React from "react" +import { X, Plus, Trash2 } 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 + options?: Array<{ value: string; label: string }> // For select fields + }> + value?: FilterGroup + onChange?: (value: FilterGroup) => void + className?: string + showClearAll?: boolean +} + +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" }, + { 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 || { + id: "root", + logic: "and", + conditions: [], + } + ) + + React.useEffect(() => { + if (value && JSON.stringify(value) !== JSON.stringify(filterGroup)) { + setFilterGroup(value) + } + }, [value]) + + const handleChange = (newGroup: FilterGroup) => { + setFilterGroup(newGroup) + onChange?.(newGroup) + } + + const addCondition = () => { + const newCondition: FilterCondition = { + id: crypto.randomUUID(), + 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 clearAllConditions = () => { + handleChange({ + ...filterGroup, + conditions: [], + }) + } + + 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 "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)) + } + } + + const needsValueInput = (operator: string) => { + 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 + 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 ( + handleValueChange(e.target.value)} + /> + ) + } + + return ( +
+
+
+ Where + {filterGroup.conditions.length > 1 && ( + + )} +
+ {showClearAll && filterGroup.conditions.length > 0 && ( + + )} +
+ +
+ {filterGroup.conditions.map((condition) => ( +
+
+
+ +
+ +
+ +
+ + {needsValueInput(condition.operator) && ( +
+ {renderValueInput(condition)} +
+ )} +
+ + +
+ ))} +
+ + +
+ ) +} + +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';