mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
add filter bar component
This commit is contained in:
1 parent
c791dd9e29
commit
cc9aa9ba09
11 files changed
+1409
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './FilterBar'
|
||||
export * from './types'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from './PromoToast'
|
||||
export * from './admonition'
|
||||
export * from './ComputeBadge'
|
||||
export * from './TimestampInfo'
|
||||
export * from './FilterBar'
|
||||
Reference in new issue
Block a user