add filter bar component

This commit is contained in:
Saxon Fletcher committed 2025-01-10 15:09:44 +10:00
1 parent c791dd9e29
commit cc9aa9ba09
11 files changed
+1409

No files matched your search

+11
View File
@@ -1787,6 +1787,17 @@ export const Index: Record<string, any> = {
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",
+5
View File
@@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
],
},
{
@@ -0,0 +1,214 @@
---
title: Filter Bar
description: An advanced filtering component with support for multiple conditions and operators.
component: true
fragment: true
---
<ComponentPreview
name="filter-bar-demo"
description="An advanced filtering component with support for multiple conditions and operators."
peekCode
showDottedGrid
wide
/>
## 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<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
return (
<FilterBar
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
)
}
```
## API Reference
### FilterProperty
```tsx
interface FilterProperty {
label: string
name: string
type: 'string' | 'number' | 'date' | 'boolean'
operators: string[]
options?: string[] | ((search?: string) => Promise<string[]> | 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<z.infer<typeof FilterCondition> | FilterGroupType>
}
const FilterGroup: z.ZodType<FilterGroupType> = 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<typeof FilterProperty>) => 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<FilterGroup>(initialFilters)
return (
<FilterBar
filterProperties={filterProperties}
filters={filters}
onFilterChange={setFilters}
aiApiUrl="/api/filter-ai" // Enable AI filtering
/>
)
}
```
@@ -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<FilterGroup>(initialFilters)
const [freeformText, setFreeformText] = useState('')
return (
<div className="w-full">
<FilterBar
filterProperties={filterProperties}
freeformText={freeformText}
onFreeformTextChange={setFreeformText}
filters={filters}
onFilterChange={setFilters}
/>
</div>
)
}
+6
View File
@@ -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',
@@ -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<string | null>(null)
const commandRef = useRef<HTMLDivElement>(null)
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0)
const [isCommandMenuVisible, setIsCommandMenuVisible] = useState(false)
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [activeInput, setActiveInput] = useState<ActiveInput>(null)
const newPathRef = useRef<number[]>([])
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({})
const [propertyOptionsCache, setPropertyOptionsCache] = useState<
Record<string, { options: string[]; searchValue: string }>
>({})
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(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: <Sparkles className="mr-2 h-4 w-4" strokeWidth={1.25} />,
})
}
} 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<HTMLInputElement>) => {
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 (
<div className="w-full space-y-2 relative">
<div className="relative flex items-center w-full rounded-md border border-control bg-foreground/[.026]">
<Search className="absolute left-1 top-1/2 transform -translate-y-1/2 text-foreground-muted w-4 h-4" />
<div className="flex-1 flex flex-wrap items-center pl-6 pr-1 py-1 gap-1 h-full">
<FilterGroupComponent
group={activeFilters}
filterProperties={filterProperties}
path={[]}
isLoading={isLoading}
activeInput={activeInput}
onOperatorChange={handleOperatorChange}
onValueChange={handleInputChange}
onOperatorFocus={handleOperatorFocus}
onValueFocus={handleInputFocus}
onBlur={handleInputBlur}
onLabelClick={handleLabelClick}
onKeyDown={handleKeyDown}
onGroupFreeformChange={handleGroupFreeformChange}
onGroupFreeformFocus={handleGroupFreeformFocus}
groupFreeformValue={freeformText}
isGroupFreeformActive={activeInput?.type === 'group' ?? false}
onLogicalOperatorChange={handleLogicalOperatorChange}
/>
</div>
</div>
{error && <div className="text-red-500 text-xs mt-1">{error}</div>}
{isCommandMenuVisible && !isLoading && activeInput !== null && commandItems.length > 0 && (
<Command_Shadcn_ ref={commandRef} className="absolute z-10 w-full h-auto shadow-md">
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{commandItems.map((item, index) => (
<CommandItem_Shadcn_
key={item.value}
onSelect={() => handleCommandSelect(item.value)}
className={`text-xs font-mono ${
index === selectedCommandIndex ? 'bg-surface-400' : ''
}`}
>
{item.icon}
{item.label}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
)}
</div>
)
}
@@ -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<HTMLInputElement>) => void
}
export function FilterCondition({
condition,
filterProperties,
id,
isActive,
isOperatorActive,
isLoading,
onOperatorChange,
onValueChange,
onOperatorFocus,
onValueFocus,
onBlur,
onLabelClick,
onKeyDown,
}: FilterConditionProps) {
const operatorRef = useRef<HTMLInputElement>(null)
const valueRef = useRef<HTMLInputElement>(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 (
<div className="flex items-center rounded px-2 h-6 bg-surface-400">
<span className="text-xs font-medium mr-1 font-mono cursor-pointer" onClick={onLabelClick}>
{property.label}
</span>
<Input_Shadcn_
ref={operatorRef}
value={condition.operator}
onChange={(e) => 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}`}
/>
<Input_Shadcn_
ref={valueRef}
value={condition.value?.toString() || ''}
onChange={(e) => 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}`}
/>
</div>
)
}
@@ -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<HTMLInputElement>) => 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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
className={`flex items-center gap-1 rounded ${
path.length > 0
? "before:content-['('] before:text-foreground-muted after:content-[')'] after:text-foreground-muted"
: ''
}`}
>
<div className="flex items-center gap-1">
{group.conditions.map((condition, index) => {
const currentPath = [...path, index]
return (
<React.Fragment key={index}>
{index > 0 && (
<span
className={`text-xs font-medium cursor-pointer ${
isHoveringOperator
? 'bg-surface-400 text-foreground dark:bg-surface-400'
: 'text-foreground-muted'
}`}
onClick={handleLogicalOperatorClick}
onMouseEnter={handleLogicalOperatorMouseEnter}
onMouseLeave={handleLogicalOperatorMouseLeave}
>
{group.logicalOperator}
</span>
)}
{/* Render condition or nested group */}
{'logicalOperator' in condition ? (
<FilterGroup
filterProperties={filterProperties}
group={condition}
path={currentPath}
isLoading={isLoading}
activeInput={activeInput}
onOperatorChange={onOperatorChange}
onValueChange={onValueChange}
onOperatorFocus={onOperatorFocus}
onValueFocus={onValueFocus}
onBlur={onBlur}
onLabelClick={onLabelClick}
onKeyDown={onKeyDown}
onGroupFreeformChange={onGroupFreeformChange}
onGroupFreeformFocus={onGroupFreeformFocus}
groupFreeformValue={groupFreeformValue}
isGroupFreeformActive={isGroupFreeformActive}
onLogicalOperatorChange={onLogicalOperatorChange}
/>
) : (
<FilterCondition
id={`filter-${currentPath.join('-')}`}
condition={condition}
isActive={isConditionActive(currentPath)}
isOperatorActive={isOperatorActive(currentPath)}
isLoading={isLoading}
onOperatorChange={(value) => onOperatorChange(currentPath, value)}
filterProperties={filterProperties}
onValueChange={(value) => onValueChange(currentPath, value)}
onOperatorFocus={() => onOperatorFocus(currentPath)}
onValueFocus={() => onValueFocus(currentPath)}
onBlur={onBlur}
onLabelClick={() => onLabelClick(currentPath)}
onKeyDown={onKeyDown}
/>
)}
</React.Fragment>
)
})}
{/* Add freeform input at the end */}
<Input_Shadcn_
ref={freeformInputRef}
value={isActive ? groupFreeformValue : localFreeformValue}
onChange={handleFreeformChange}
onFocus={() => 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',
}}
/>
</div>
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './FilterBar'
export * from './types'
+18
View File
@@ -0,0 +1,18 @@
export type FilterProperty = {
label: string
name: string
type: 'string' | 'number' | 'date' | 'boolean'
options?: string[] | ((search?: string) => Promise<string[]>) | ((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<FilterCondition | FilterGroup>
}
+1
View File
@@ -22,3 +22,4 @@ export * from './PromoToast'
export * from './admonition'
export * from './ComputeBadge'
export * from './TimestampInfo'
export * from './FilterBar'