From cc9aa9ba093f383f9cf2ea2d20f06c51aa8fda60 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 10 Jan 2025 15:09:44 +1000 Subject: [PATCH] add filter bar component --- apps/design-system/__registry__/index.tsx | 11 + apps/design-system/config/docs.ts | 5 + .../content/docs/fragments/filter-bar.mdx | 214 +++++ .../default/example/filter-bar-demo.tsx | 64 ++ apps/design-system/registry/examples.ts | 6 + packages/ui-patterns/FilterBar/FilterBar.tsx | 803 ++++++++++++++++++ .../ui-patterns/FilterBar/FilterCondition.tsx | 86 ++ .../ui-patterns/FilterBar/FilterGroup.tsx | 199 +++++ packages/ui-patterns/FilterBar/index.ts | 2 + packages/ui-patterns/FilterBar/types.ts | 18 + packages/ui-patterns/index.tsx | 1 + 11 files changed, 1409 insertions(+) create mode 100644 apps/design-system/content/docs/fragments/filter-bar.mdx create mode 100644 apps/design-system/registry/default/example/filter-bar-demo.tsx create mode 100644 packages/ui-patterns/FilterBar/FilterBar.tsx create mode 100644 packages/ui-patterns/FilterBar/FilterCondition.tsx create mode 100644 packages/ui-patterns/FilterBar/FilterGroup.tsx create mode 100644 packages/ui-patterns/FilterBar/index.ts create mode 100644 packages/ui-patterns/FilterBar/types.ts diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index ba7caca35fa..73d5c26439f 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1787,6 +1787,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "filter-bar-demo": { + name: "filter-bar-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/filter-bar-demo")), + source: "", + files: ["registry/default/example/filter-bar-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "info-tooltip-demo": { name: "info-tooltip-demo", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index fa2e91a1c20..26003a56117 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/multi-select', items: [], }, + { + title: 'Filter Bar', + href: '/docs/fragments/filter-bar', + items: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/filter-bar.mdx b/apps/design-system/content/docs/fragments/filter-bar.mdx new file mode 100644 index 00000000000..fed27c7219d --- /dev/null +++ b/apps/design-system/content/docs/fragments/filter-bar.mdx @@ -0,0 +1,214 @@ +--- +title: Filter Bar +description: An advanced filtering component with support for multiple conditions and operators. +component: true +fragment: true +--- + + + +## Usage + +The Filter Bar component provides advanced filtering capabilities with support for multiple conditions, operators, and different field types. It can be used with both static and async options. + +```tsx +const filterProperties = [ + { + label: 'Name', + name: 'name', + type: 'string', + operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], + }, + { + label: 'Status', + name: 'status', + type: 'string', + options: ['active', 'inactive', 'pending'], + operators: ['=', '!='], + }, +] + +export function FilterDemo() { + const [filters, setFilters] = useState(initialFilters) + const [freeformText, setFreeformText] = useState('') + + return ( + + ) +} +``` + +## API Reference + +### FilterProperty + +```tsx +interface FilterProperty { + label: string + name: string + type: 'string' | 'number' | 'date' | 'boolean' + operators: string[] + options?: string[] | ((search?: string) => Promise | string[]) +} +``` + +### FilterGroup + +```tsx +interface FilterGroup { + logicalOperator: 'AND' | 'OR' + conditions: (FilterCondition | FilterGroup)[] +} +``` + +### FilterCondition + +```tsx +interface FilterCondition { + propertyName: string + value: string | number | boolean | Date + operator: string +} +``` + +### Component Props + +| Prop | Type | Description | +| -------------------- | ------------------------------ | ---------------------------------------- | +| filterProperties | FilterProperty[] | Array of properties that can be filtered | +| filters | FilterGroup | Current filter state | +| onFilterChange | (filters: FilterGroup) => void | Callback when filters change | +| freeformText | string | Current free-form search text | +| onFreeformTextChange | (text: string) => void | Callback when free-form text changes | +| aiApiUrl | string? | Optional URL for AI-powered filtering | + +## AI Integration + +The Filter Bar component supports AI-powered filtering through an optional API endpoint. When `aiApiUrl` is provided, the component will send natural language queries to be converted into structured filters. + +### API Endpoint + +The AI API endpoint should accept POST requests with the following structure: + +```typescript +// Request body +interface AIFilterRequest { + prompt: string // Natural language query + filterProperties: FilterProperty[] // Available filter properties +} + +// Response body +interface AIFilterResponse { + logicalOperator: 'AND' | 'OR' + conditions: (FilterCondition | FilterGroup)[] +} +``` + +### Example API Implementation + +```typescript +import { generateObject } from 'ai' +import { openai } from '@ai-sdk/openai' +import { z } from 'zod' + +// Define schemas for validation +const FilterProperty = z.object({ + label: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'date', 'boolean']), + options: z.array(z.string()).optional(), + operators: z.array(z.string()).optional(), +}) + +const FilterCondition = z.object({ + propertyName: z.string(), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]), + operator: z.string(), +}) + +type FilterGroupType = { + logicalOperator: 'AND' | 'OR' + conditions: Array | FilterGroupType> +} + +const FilterGroup: z.ZodType = z.lazy(() => + z.object({ + logicalOperator: z.enum(['AND', 'OR']), + conditions: z.array(z.union([FilterCondition, FilterGroup])), + }) +) + +export async function POST(req: Request) { + const { prompt, filterProperties } = await req.json() + const filterPropertiesString = JSON.stringify(filterProperties) + + try { + const { object } = await generateObject({ + model: openai('gpt-4-mini'), + schema: FilterGroup, + prompt: `Generate a filter group based on the following prompt: "${prompt}". + Use only these filter properties: ${filterPropertiesString}. + Each property has its own set of valid operators defined in the operators field. + Return a filter group with a logical operator ('AND'/'OR') and an array of conditions. + Each condition can be either a filter condition or another filter group. + Filter conditions should have the structure: { propertyName: string, value: string | number | boolean | null, operator: string }. + Ensure that the generated filters use only the provided property names and their corresponding operators.`, + }) + + // Validate that all propertyNames exist in filterProperties + const validatePropertyNames = (group: FilterGroupType): boolean => { + return group.conditions.every((condition) => { + if ('logicalOperator' in condition) { + return validatePropertyNames(condition as FilterGroupType) + } + const property = filterProperties.find( + (p: z.infer) => p.name === condition.propertyName + ) + if (!property) return false + // Validate operator is valid for this property + return property.operators?.includes(condition.operator) ?? false + }) + } + + if (!validatePropertyNames(object)) { + throw new Error('Invalid property names or operators in generated filter') + } + + // Zod will throw an error if the object doesn't match the schema + const validatedFilters = FilterGroup.parse(object) + return Response.json(validatedFilters) + } catch (error: any) { + console.error('Error in AI filtering:', error) + return Response.json({ error: error.message || 'AI filtering failed' }, { status: 500 }) + } +} +``` + +### Usage with AI + +```tsx +export function FilterDemoWithAI() { + const [filters, setFilters] = useState(initialFilters) + + return ( + + ) +} +``` diff --git a/apps/design-system/registry/default/example/filter-bar-demo.tsx b/apps/design-system/registry/default/example/filter-bar-demo.tsx new file mode 100644 index 00000000000..9c5e93544ec --- /dev/null +++ b/apps/design-system/registry/default/example/filter-bar-demo.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { FilterBar, FilterGroup } from 'ui-patterns' + +const filterProperties = [ + { + label: 'Name', + name: 'name', + type: 'string' as const, + operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'], + }, + { + label: 'Status', + name: 'status', + type: 'string' as const, + options: ['active', 'inactive', 'pending'], + operators: ['=', '!='], + }, + { + label: 'Type', + name: 'type', + type: 'string' as const, + options: async (search?: string) => { + await new Promise((resolve) => setTimeout(resolve, 500)) + const allOptions = ['user', 'admin', 'guest'] + return search + ? allOptions.filter((option) => option.toLowerCase().includes(search.toLowerCase())) + : allOptions + }, + operators: ['=', '!='], + }, +] + +const initialFilters: FilterGroup = { + logicalOperator: 'AND', + conditions: [ + { + propertyName: 'name', + value: '', + operator: '=', + }, + { + propertyName: 'status', + value: '', + operator: '=', + }, + ], +} + +export default function FilterBarDemo() { + const [filters, setFilters] = useState(initialFilters) + const [freeformText, setFreeformText] = useState('') + + return ( +
+ +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index eab719afe0e..1ee3f74e70d 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -467,6 +467,12 @@ export const examples: Registry = [ registryDependencies: ['dropdown-menu', 'radio-group'], files: ['example/dropdown-menu-radio-group.tsx'], }, + { + name: 'filter-bar', + type: 'components:example', + registryDependencies: ['filter-bar'], + files: ['example/filter-bar-demo.tsx'], + }, { name: 'hover-card-demo', type: 'components:example', diff --git a/packages/ui-patterns/FilterBar/FilterBar.tsx b/packages/ui-patterns/FilterBar/FilterBar.tsx new file mode 100644 index 00000000000..f105b798932 --- /dev/null +++ b/packages/ui-patterns/FilterBar/FilterBar.tsx @@ -0,0 +1,803 @@ +import React, { useRef, KeyboardEvent, useMemo, useState, useCallback, useEffect } from 'react' +import { Search, Sparkles } from 'lucide-react' +import { + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, +} from 'ui' +import { FilterGroup as FilterGroupComponent } from './FilterGroup' +import { FilterProperty, FilterCondition, FilterGroup } from './types' + +export type ActiveInput = + | { type: 'value'; path: number[] } + | { type: 'operator'; path: number[] } + | { type: 'group'; path: number[] } + | null + +type FilterBarProps = { + filterProperties: FilterProperty[] + onFilterChange: (filters: FilterGroup) => void + freeformText: string + onFreeformTextChange: (freeformText: string) => void + filters: FilterGroup + aiApiUrl?: string +} + +const isGroup = (condition: FilterCondition | FilterGroup): condition is FilterGroup => { + return 'logicalOperator' in condition +} + +export function FilterBar({ + filterProperties, + filters: activeFilters, + onFilterChange, + freeformText, + onFreeformTextChange, + aiApiUrl, +}: FilterBarProps) { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const commandRef = useRef(null) + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) + const [isCommandMenuVisible, setIsCommandMenuVisible] = useState(false) + const hideTimeoutRef = useRef(null) + const [activeInput, setActiveInput] = useState(null) + const newPathRef = useRef([]) + const [loadingOptions, setLoadingOptions] = useState>({}) + const [propertyOptionsCache, setPropertyOptionsCache] = useState< + Record + >({}) + const loadTimeoutRef = useRef(null) + + const handleInputChange = useCallback( + (path: number[], value: string) => { + const updateNestedValue = ( + group: FilterGroup, + currentPath: number[], + newValue: string + ): FilterGroup => { + if (currentPath.length === 1) { + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === currentPath[0] ? { ...condition, value: newValue } : condition + ), + } + } + + const [current, ...rest] = currentPath + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current + ? isGroup(condition) + ? updateNestedValue(condition, rest, newValue) + : condition + : condition + ), + } + } + + const updatedFilters = updateNestedValue(activeFilters, path, value) + onFilterChange(updatedFilters) + setSelectedCommandIndex(0) + setIsCommandMenuVisible(true) + }, + [activeFilters] + ) + + const handleOperatorChange = useCallback( + (path: number[], value: string) => { + const updateNestedOperator = ( + group: FilterGroup, + currentPath: number[], + newOperator: string + ): FilterGroup => { + if (currentPath.length === 1) { + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === currentPath[0] ? { ...condition, operator: newOperator } : condition + ), + } + } + + const [current, ...rest] = currentPath + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current + ? isGroup(condition) + ? updateNestedOperator(condition, rest, newOperator) + : condition + : condition + ), + } + } + + const updatedFilters = updateNestedOperator(activeFilters, path, value) + onFilterChange(updatedFilters) + setSelectedCommandIndex(0) + setIsCommandMenuVisible(true) + }, + [activeFilters] + ) + + const handleInputFocus = useCallback((path: number[]) => { + setActiveInput({ type: 'value', path }) + setIsCommandMenuVisible(true) + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + } + }, []) + + const handleOperatorFocus = useCallback((path: number[]) => { + setActiveInput({ type: 'operator', path }) + setIsCommandMenuVisible(true) + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + } + }, []) + + const handleGroupFreeformFocus = useCallback((path: number[]) => { + setActiveInput({ type: 'group', path }) + setIsCommandMenuVisible(true) + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + } + }, []) + + const handleInputBlur = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + } + hideTimeoutRef.current = setTimeout(() => { + setIsCommandMenuVisible(false) + setActiveInput(null) + }, 150) + }, []) + + const handleAIFilter = async () => { + if (!activeInput || activeInput.type !== 'group' || !aiApiUrl) return + + setIsLoading(true) + setError(null) + + try { + const response = await fetch(aiApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: freeformText, + filterProperties, + currentPath: activeInput.path, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'AI filtering failed') + } + + const data = await response.json() + console.log(data) + if (!data || !Array.isArray(data.conditions)) { + throw new Error('Invalid response from AI filter') + } + + // Process the nested structure + const processConditions = (conditions: any[]): any[] => { + return conditions.map((condition) => { + if (isGroup(condition)) { + // This is a group + return { + logicalOperator: condition.logicalOperator, + conditions: processConditions(condition.conditions), + } + } else { + // This is a condition + const matchedProperty = filterProperties.find( + (prop) => prop.name === condition.propertyName + ) + if (!matchedProperty) { + throw new Error(`Invalid property: ${condition.propertyName}`) + } + return { + propertyName: matchedProperty.name, + value: condition.value, + operator: condition.operator || '=', + } + } + }) + } + + const processedGroup = { + logicalOperator: data.logicalOperator || 'AND', + conditions: processConditions(data.conditions), + } + + // Update the active filters by replacing the group at the current path + const updateGroupAtPath = ( + group: FilterGroup, + path: number[], + newGroup: FilterGroup + ): FilterGroup => { + if (path.length === 0) { + return newGroup + } + + const [current, ...rest] = path + return { + ...group, + conditions: group.conditions.map((condition, index) => + index === current + ? updateGroupAtPath(condition as FilterGroup, rest, newGroup) + : condition + ), + } + } + + const updatedFilters = updateGroupAtPath(activeFilters, activeInput.path, processedGroup) + onFilterChange(updatedFilters) + + onFreeformTextChange('') + setIsCommandMenuVisible(false) + } catch (error: any) { + console.error('Error in AI filtering:', error) + setError(error.message || 'AI filtering failed. Please try again.') + onFreeformTextChange('') + } finally { + setIsLoading(false) + } + } + + const handleGroupFreeformChange = useCallback((path: number[], value: string) => { + onFreeformTextChange(value) + setSelectedCommandIndex(0) + setIsCommandMenuVisible(true) + }, []) + + const addFilterToGroup = useCallback( + (group: FilterGroup, path: number[], property: FilterProperty): FilterGroup => { + if (path.length === 0) { + return { + ...group, + conditions: [ + ...group.conditions, + { propertyName: property.name, value: null, operator: '=' }, + ], + } + } + + const [current, ...rest] = path + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current ? addFilterToGroup(condition as FilterGroup, rest, property) : condition + ), + } + }, + [] + ) + + const addGroupToGroup = useCallback((group: FilterGroup, path: number[]): FilterGroup => { + if (path.length === 0) { + return { + ...group, + conditions: [...group.conditions, { logicalOperator: 'AND', conditions: [] }], + } + } + + const [current, ...rest] = path + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current ? addGroupToGroup(condition as FilterGroup, rest) : condition + ), + } + }, []) + + const handleCommandSelect = (selectedValue: string) => { + if (selectedValue === 'ai-filter') { + handleAIFilter() + return + } + + if (selectedValue === 'group') { + if (activeInput && activeInput.type === 'group') { + const currentPath = activeInput.path + const group = findGroupByPath(activeFilters, currentPath) + if (!group) return + + const updatedFilters = addGroupToGroup(activeFilters, currentPath) + onFilterChange(updatedFilters) + // Store the new path for use after the state update + newPathRef.current = [...currentPath, group.conditions.length] + // Focus the new group's freeform input after the state update + setTimeout(() => { + setActiveInput({ type: 'group', path: newPathRef.current }) + setIsCommandMenuVisible(true) + }, 0) + onFreeformTextChange('') + } + return + } + + if (activeInput && activeInput.type === 'value') { + const path = activeInput.path + handleInputChange(path, selectedValue) + // Focus the parent group's input after setting the value + setTimeout(() => { + setActiveInput({ type: 'group', path: path.slice(0, -1) }) + setIsCommandMenuVisible(true) + }, 0) + } else if (activeInput && activeInput.type === 'operator') { + const path = activeInput.path + handleOperatorChange(path, selectedValue) + setActiveInput(null) + } else if (activeInput && activeInput.type === 'group') { + const selectedProperty = filterProperties.find((prop) => prop.name === selectedValue) + if (selectedProperty) { + const currentPath = activeInput.path + const group = findGroupByPath(activeFilters, currentPath) + if (!group) return + + const updatedFilters = addFilterToGroup(activeFilters, currentPath, selectedProperty) + onFilterChange(updatedFilters) + // Store the new path for use after the state update + newPathRef.current = [...currentPath, group.conditions.length] + // Focus the new condition's value input after the state update + setTimeout(() => { + setActiveInput({ type: 'value', path: newPathRef.current }) + setIsCommandMenuVisible(true) + }, 0) + onFreeformTextChange('') + } else { + setError(`Invalid property: ${selectedValue}`) + } + } + } + + const findConditionByPath = (group: FilterGroup, path: number[]): FilterCondition | null => { + if (path.length === 0) return null + + const [current, ...rest] = path + const condition = group.conditions[current] + if (!condition) return null + + if (rest.length === 0) { + return isGroup(condition) ? null : condition + } + + if (isGroup(condition)) { + return findConditionByPath(condition, rest) + } + + return null + } + + const loadPropertyOptions = useCallback( + async (property: FilterProperty, search: string = '') => { + if (!property.options || Array.isArray(property.options)) return + + // Check if we have cached options for this exact search + const cached = propertyOptionsCache[property.name] + if (cached && cached.searchValue === search) return + + // Clear any pending loads + if (loadTimeoutRef.current) { + clearTimeout(loadTimeoutRef.current) + } + + // Debounce the load + loadTimeoutRef.current = setTimeout(async () => { + if (loadingOptions[property.name]) return // Prevent duplicate loads + + try { + setLoadingOptions((prev) => ({ ...prev, [property.name]: true })) + const options = + typeof property.options === 'function' ? await property.options(search) : [] + setPropertyOptionsCache((prev) => ({ + ...prev, + [property.name]: { options, searchValue: search }, + })) + } catch (error) { + console.error(`Error loading options for ${property.name}:`, error) + setError(`Failed to load options for ${property.label}`) + } finally { + setLoadingOptions((prev) => ({ ...prev, [property.name]: false })) + } + }, 300) // Debounce time + }, + [loadingOptions, propertyOptionsCache] + ) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (loadTimeoutRef.current) { + clearTimeout(loadTimeoutRef.current) + } + } + }, []) + + const commandItems = useMemo(() => { + if (activeInput?.type === 'operator') { + const condition = findConditionByPath(activeFilters, activeInput.path) + const property = filterProperties.find((p) => p.name === condition?.propertyName) + const operatorValue = condition?.operator?.toUpperCase() || '' + const availableOperators = property?.operators || ['='] + return availableOperators + .filter((op) => op.toUpperCase().includes(operatorValue)) + .map((op) => ({ value: op, label: op, icon: undefined })) + } + + const inputValue = + activeInput?.type === 'group' + ? freeformText + : activeInput?.type === 'value' + ? findConditionByPath(activeFilters, activeInput.path)?.value?.toString() || '' + : '' + + const items: { value: string; label: string; icon?: React.ReactNode }[] = [] + + if (activeInput?.type === 'group') { + items.push( + ...filterProperties + .filter((prop) => prop.label.toLowerCase().includes(inputValue.toLowerCase())) + .map((prop) => ({ + value: prop.name, + label: prop.label, + icon: undefined, + })) + ) + + items.push({ + value: 'group', + label: 'New Group', + icon: undefined, + }) + + if (inputValue.trim().length > 0 && aiApiUrl) { + items.push({ + value: 'ai-filter', + label: 'Filter by AI', + icon: , + }) + } + } else if (activeInput?.type === 'value') { + const activeCondition = findConditionByPath(activeFilters, activeInput.path) + const property = filterProperties.find((p) => p.name === activeCondition?.propertyName) + + if (property) { + if (loadingOptions[property.name]) { + items.push({ + value: 'loading', + label: 'Loading options...', + icon: undefined, + }) + } else if (Array.isArray(property.options)) { + items.push( + ...property.options + .filter((option) => option.toLowerCase().includes(inputValue.toLowerCase())) + .map((option) => ({ + value: option, + label: option, + icon: undefined, + })) + ) + } else if (propertyOptionsCache[property.name]) { + items.push( + ...propertyOptionsCache[property.name].options + .filter((option) => option.toLowerCase().includes(inputValue.toLowerCase())) + .map((option) => ({ + value: option, + label: option, + icon: undefined, + })) + ) + } + + // Load options if not cached or if search value has changed + if ( + typeof property.options === 'function' && + !loadingOptions[property.name] && + (!propertyOptionsCache[property.name] || + propertyOptionsCache[property.name].searchValue !== inputValue) + ) { + loadPropertyOptions(property, inputValue) + } + } + } + + return items + }, [ + activeInput, + freeformText, + activeFilters, + filterProperties, + propertyOptionsCache, + loadingOptions, + loadPropertyOptions, + ]) + + const removeFilterByPath = useCallback( + (path: number[]) => { + const removeFromGroup = (group: FilterGroup, currentPath: number[]): FilterGroup => { + if (currentPath.length === 1) { + return { + ...group, + conditions: group.conditions.filter((_, i) => i !== currentPath[0]), + } + } + + const [current, ...rest] = currentPath + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current ? removeFromGroup(condition as FilterGroup, rest) : condition + ), + } + } + + const updatedFilters = removeFromGroup(activeFilters, path) + onFilterChange(updatedFilters) + }, + [activeFilters] + ) + + const removeGroupByPath = useCallback( + (path: number[]) => { + const removeFromGroup = (group: FilterGroup, currentPath: number[]): FilterGroup => { + if (currentPath.length === 1) { + return { + ...group, + conditions: group.conditions.filter((_, i) => i !== currentPath[0]), + } + } + + const [current, ...rest] = currentPath + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current ? removeFromGroup(condition as FilterGroup, rest) : condition + ), + } + } + + const updatedFilters = removeFromGroup(activeFilters, path) + onFilterChange(updatedFilters) + }, + [activeFilters] + ) + + const findGroupByPath = (group: FilterGroup, path: number[]): FilterGroup | null => { + if (path.length === 0) return group + + const [current, ...rest] = path + const condition = group.conditions[current] + if (!condition) return null + + if (rest.length === 0) { + return isGroup(condition) ? condition : null + } + + if (isGroup(condition)) { + return findGroupByPath(condition, rest) + } + + return null + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedCommandIndex((prevIndex) => + prevIndex < commandItems.length - 1 ? prevIndex + 1 : prevIndex + ) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedCommandIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (commandItems[selectedCommandIndex]) { + handleCommandSelect(commandItems[selectedCommandIndex].value) + } + } else if (e.key === 'Backspace') { + // Skip backspace handling for operator inputs + if (activeInput?.type === 'operator') return + + const inputElement = e.target as HTMLInputElement + const isEmpty = inputElement.value === '' + + if (activeInput?.type === 'group' && isEmpty) { + e.preventDefault() + const group = findGroupByPath(activeFilters, activeInput.path) + + if (group && group.conditions.length > 0) { + // Remove the last condition in the group + const lastConditionPath = [...activeInput.path, group.conditions.length - 1] + removeFilterByPath(lastConditionPath) + // Keep focus on the group's input + setActiveInput({ type: 'group', path: activeInput.path }) + } else if (group && group.conditions.length === 0) { + // Remove the empty group + removeGroupByPath(activeInput.path) + + // Focus the parent group's freeform input + if (activeInput.path.length > 0) { + setActiveInput({ + type: 'group', + path: activeInput.path.slice(0, -1), + }) + } else { + setActiveInput(null) + } + } + } else if (activeInput?.type === 'value' && isEmpty) { + const condition = findConditionByPath(activeFilters, activeInput.path) + if (condition && !condition.value) { + e.preventDefault() + removeFilterByPath(activeInput.path) + + // Focus the group's input + setActiveInput({ + type: 'group', + path: activeInput.path.slice(0, -1), + }) + } + } + } else if (e.key === ' ' && activeInput?.type === 'value') { + e.preventDefault() + setActiveInput({ type: 'group', path: [] }) + } else if (e.key === 'ArrowLeft') { + const inputElement = e.target as HTMLInputElement + if (inputElement.selectionStart === 0) { + e.preventDefault() + if (activeInput?.type === 'value') { + const lastIndex = activeInput.path[activeInput.path.length - 1] + if (lastIndex > 0) { + setActiveInput({ + type: 'value', + path: [...activeInput.path.slice(0, -1), lastIndex - 1], + }) + } else { + setActiveInput({ + type: 'operator', + path: activeInput.path, + }) + } + } else if (activeInput?.type === 'group') { + const group = findGroupByPath(activeFilters, activeInput.path) + if (group && group.conditions.length > 0) { + // If the group has conditions, select the last one + const lastConditionPath = [...activeInput.path, group.conditions.length - 1] + setActiveInput({ type: 'value', path: lastConditionPath }) + } else if (activeInput.path.length > 0) { + // If no conditions but has parent, select parent group's input + setActiveInput({ + type: 'group', + path: activeInput.path.slice(0, -1), + }) + } + } + } + } else if (e.key === 'ArrowRight') { + const inputElement = e.target as HTMLInputElement + if (inputElement.selectionStart === inputElement.value.length) { + e.preventDefault() + if (activeInput?.type === 'value') { + const group = findGroupByPath(activeFilters, activeInput.path.slice(0, -1)) + const lastIndex = activeInput.path[activeInput.path.length - 1] + if (group && lastIndex < group.conditions.length - 1) { + setActiveInput({ + type: 'value', + path: [...activeInput.path.slice(0, -1), lastIndex + 1], + }) + } else { + setActiveInput({ + type: 'group', + path: activeInput.path.slice(0, -1), + }) + } + } else if (activeInput?.type === 'operator') { + setActiveInput({ type: 'value', path: activeInput.path }) + } + } + } else if (e.key === 'Escape') { + setIsCommandMenuVisible(false) + setActiveInput(null) + } + } + + const handleLabelClick = useCallback((path: number[]) => { + setActiveInput({ type: 'value', path }) + }, []) + + const handleLogicalOperatorChange = useCallback( + (path: number[]) => { + const updateNestedLogicalOperator = ( + group: FilterGroup, + currentPath: number[] + ): FilterGroup => { + if (currentPath.length === 0) { + return { + ...group, + logicalOperator: group.logicalOperator === 'AND' ? 'OR' : 'AND', + } + } + + const [current, ...rest] = currentPath + return { + ...group, + conditions: group.conditions.map((condition, i) => + i === current + ? isGroup(condition) + ? updateNestedLogicalOperator(condition, rest) + : condition + : condition + ), + } + } + + const updatedFilters = updateNestedLogicalOperator(activeFilters, path) + onFilterChange(updatedFilters) + }, + [activeFilters] + ) + + return ( +
+
+ +
+ +
+
+ {error &&
{error}
} + {isCommandMenuVisible && !isLoading && activeInput !== null && commandItems.length > 0 && ( + + + No results found. + + {commandItems.map((item, index) => ( + handleCommandSelect(item.value)} + className={`text-xs font-mono ${ + index === selectedCommandIndex ? 'bg-surface-400' : '' + }`} + > + {item.icon} + {item.label} + + ))} + + + + )} +
+ ) +} diff --git a/packages/ui-patterns/FilterBar/FilterCondition.tsx b/packages/ui-patterns/FilterBar/FilterCondition.tsx new file mode 100644 index 00000000000..f958534f860 --- /dev/null +++ b/packages/ui-patterns/FilterBar/FilterCondition.tsx @@ -0,0 +1,86 @@ +import React, { useRef, useEffect } from 'react' +import { Input_Shadcn_ } from 'ui' +import { FilterCondition as FilterConditionType, FilterProperty } from './types' + +type FilterConditionProps = { + condition: FilterConditionType + filterProperties: FilterProperty[] + id: string + isActive: boolean + isOperatorActive: boolean + isLoading?: boolean + onOperatorChange: (value: string) => void + onValueChange: (value: string) => void + onOperatorFocus: () => void + onValueFocus: () => void + onBlur: () => void + onLabelClick: () => void + onKeyDown: (e: React.KeyboardEvent) => void +} + +export function FilterCondition({ + condition, + filterProperties, + id, + isActive, + isOperatorActive, + isLoading, + onOperatorChange, + onValueChange, + onOperatorFocus, + onValueFocus, + onBlur, + onLabelClick, + onKeyDown, +}: FilterConditionProps) { + const operatorRef = useRef(null) + const valueRef = useRef(null) + const property = filterProperties.find((p) => p.name === condition.propertyName) + + useEffect(() => { + if (isActive && valueRef.current) { + valueRef.current.focus() + } else if (isOperatorActive && operatorRef.current) { + operatorRef.current.focus() + } + }, [isActive, isOperatorActive]) + + if (!property) return null + + return ( +
+ + {property.label} + + onOperatorChange(e.target.value)} + onFocus={onOperatorFocus} + onBlur={onBlur} + className="border-none bg-transparent p-0 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 font-mono h-6 mr-1 text-foreground-light" + style={{ + width: `${Math.max(condition.operator.length, 1)}ch`, + minWidth: '1ch', + }} + disabled={isLoading} + aria-label={`Operator for ${property.label}`} + /> + onValueChange(e.target.value)} + onFocus={onValueFocus} + onBlur={onBlur} + onKeyDown={onKeyDown} + className="border-none bg-transparent p-0 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 font-mono h-6" + style={{ + width: `${Math.max(condition.value?.toString().length || 0, 1)}ch`, + minWidth: '1ch', + }} + disabled={isLoading} + aria-label={`Value for ${property.label}`} + /> +
+ ) +} diff --git a/packages/ui-patterns/FilterBar/FilterGroup.tsx b/packages/ui-patterns/FilterBar/FilterGroup.tsx new file mode 100644 index 00000000000..8cd71755e36 --- /dev/null +++ b/packages/ui-patterns/FilterBar/FilterGroup.tsx @@ -0,0 +1,199 @@ +import React, { useState, useRef, useEffect } from 'react' +import { FilterProperty, FilterGroup as FilterGroupType } from './types' +import { ActiveInput } from './FilterBar' +import { FilterCondition } from './FilterCondition' +import { Input_Shadcn_ } from 'ui' + +type FilterGroupProps = { + group: FilterGroupType + path: number[] + isLoading?: boolean + filterProperties: FilterProperty[] + // Active state + activeInput: ActiveInput + onOperatorChange: (path: number[], value: string) => void + onValueChange: (path: number[], value: string) => void + onOperatorFocus: (path: number[]) => void + onValueFocus: (path: number[]) => void + onBlur: () => void + onLabelClick: (path: number[]) => void + onKeyDown: (e: React.KeyboardEvent) => void + // Group specific props + onGroupFreeformChange: (path: number[], value: string) => void + onGroupFreeformFocus: (path: number[]) => void + groupFreeformValue: string + isGroupFreeformActive: boolean + // Logical operator props + onLogicalOperatorChange?: (path: number[]) => void +} + +export function FilterGroup({ + group, + path, + isLoading, + activeInput, + filterProperties, + onOperatorChange, + onValueChange, + onOperatorFocus, + onValueFocus, + onBlur, + onLabelClick, + onKeyDown, + onGroupFreeformChange, + onGroupFreeformFocus, + groupFreeformValue, + isGroupFreeformActive, + onLogicalOperatorChange, +}: FilterGroupProps) { + const [localFreeformValue, setLocalFreeformValue] = useState('') + const freeformInputRef = useRef(null) + const [isHoveringOperator, setIsHoveringOperator] = useState(false) + const isActive = + isGroupFreeformActive && + activeInput?.type === 'group' && + path.length === activeInput.path.length && + path.every((v, i) => v === activeInput.path[i]) + + // Reset local value when group freeform value is cleared + useEffect(() => { + if (groupFreeformValue === '') { + setLocalFreeformValue('') + } + }, [groupFreeformValue]) + + useEffect(() => { + if (isActive && freeformInputRef.current) { + freeformInputRef.current.focus() + } + }, [isActive]) + + const handleFreeformChange = (e: React.ChangeEvent) => { + setLocalFreeformValue(e.target.value) + onGroupFreeformChange(path, e.target.value) + } + + const handleLogicalOperatorClick = () => { + onLogicalOperatorChange?.(path) + } + + const handleLogicalOperatorMouseEnter = () => { + setIsHoveringOperator(true) + } + + const handleLogicalOperatorMouseLeave = () => { + setIsHoveringOperator(false) + } + + const isConditionActive = (conditionPath: number[]) => { + if (!activeInput) return false + return ( + activeInput.type === 'value' && + conditionPath.length === activeInput.path.length && + conditionPath.every((v, i) => v === activeInput.path[i]) + ) + } + + const isOperatorActive = (conditionPath: number[]) => { + if (!activeInput) return false + return ( + activeInput.type === 'operator' && + conditionPath.length === activeInput.path.length && + conditionPath.every((v, i) => v === activeInput.path[i]) + ) + } + + return ( +
0 + ? "before:content-['('] before:text-foreground-muted after:content-[')'] after:text-foreground-muted" + : '' + }`} + > +
+ {group.conditions.map((condition, index) => { + const currentPath = [...path, index] + + return ( + + {index > 0 && ( + + {group.logicalOperator} + + )} + {/* Render condition or nested group */} + {'logicalOperator' in condition ? ( + + ) : ( + onOperatorChange(currentPath, value)} + filterProperties={filterProperties} + onValueChange={(value) => onValueChange(currentPath, value)} + onOperatorFocus={() => onOperatorFocus(currentPath)} + onValueFocus={() => onValueFocus(currentPath)} + onBlur={onBlur} + onLabelClick={() => onLabelClick(currentPath)} + onKeyDown={onKeyDown} + /> + )} + + ) + })} + {/* Add freeform input at the end */} + onGroupFreeformFocus(path)} + onBlur={onBlur} + onKeyDown={onKeyDown} + className="border-none bg-transparent p-0 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 font-mono h-6" + placeholder={ + path.length === 0 && group.conditions.length === 0 ? 'Search or filter...' : '+' + } + disabled={isLoading} + style={{ + width: `${Math.max( + (isActive ? groupFreeformValue : localFreeformValue).length || 1, + path.length === 0 && group.conditions.length === 0 ? 18 : 1 + )}ch`, + minWidth: path.length === 0 && group.conditions.length === 0 ? '18ch' : '1ch', + }} + /> +
+
+ ) +} diff --git a/packages/ui-patterns/FilterBar/index.ts b/packages/ui-patterns/FilterBar/index.ts new file mode 100644 index 00000000000..8f852e36afb --- /dev/null +++ b/packages/ui-patterns/FilterBar/index.ts @@ -0,0 +1,2 @@ +export * from './FilterBar' +export * from './types' diff --git a/packages/ui-patterns/FilterBar/types.ts b/packages/ui-patterns/FilterBar/types.ts new file mode 100644 index 00000000000..6092c19f7c4 --- /dev/null +++ b/packages/ui-patterns/FilterBar/types.ts @@ -0,0 +1,18 @@ +export type FilterProperty = { + label: string + name: string + type: 'string' | 'number' | 'date' | 'boolean' + options?: string[] | ((search?: string) => Promise) | ((search?: string) => string[]) + operators?: string[] +} + +export type FilterCondition = { + propertyName: string + value: string | number | boolean | Date | null + operator: string +} + +export type FilterGroup = { + logicalOperator: 'AND' | 'OR' + conditions: Array +} diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx index 198641b0d1e..cea2ffb2038 100644 --- a/packages/ui-patterns/index.tsx +++ b/packages/ui-patterns/index.tsx @@ -22,3 +22,4 @@ export * from './PromoToast' export * from './admonition' export * from './ComputeBadge' export * from './TimestampInfo' +export * from './FilterBar'