mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Filter pattern refactor (#41545)
* filter refactor * update tests * prettier * fix ts * remove filter preview * Filter bar in table editor behind flag (#41546) filter bar in table editor behind flag * fixes * ts fix * filter v1 refactor * rename helpers * unmount timeout * fixes * fix * more
This commit is contained in:
1 parent
a8cf7a8950
commit
d2f4e808e5
26 files changed
+2547
-1175
No files matched your search
@@ -85,130 +85,44 @@ interface FilterCondition {
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| actions | FilterBarAction[]? | Optional custom actions to show in the menu |
|
||||
| isLoading | boolean? | If true, dims the bar while work is in progress |
|
||||
|
||||
## AI Integration
|
||||
## Custom actions (e.g. AI)
|
||||
|
||||
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
|
||||
You can append custom actions to the property menu. Each action receives the current free-form input value and the active group's path so you can plug in AI, saved queries, etc.
|
||||
|
||||
```tsx
|
||||
export function FilterDemoWithAI() {
|
||||
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
|
||||
const actions = [
|
||||
{
|
||||
value: 'ai-filter',
|
||||
label: 'Filter by AI',
|
||||
onSelect: async (inputValue, { path }) => {
|
||||
const response = await fetch('/api/filter-ai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: inputValue, path }),
|
||||
})
|
||||
const group = (await response.json()) as FilterGroup
|
||||
// Replace your filter state at the provided path with the returned group
|
||||
setFilters((prev) => updateGroupAtPath(prev, path, group))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
filterProperties={filterProperties}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
aiApiUrl="/api/filter-ai" // Enable AI filtering
|
||||
/>
|
||||
)
|
||||
}
|
||||
<FilterBar
|
||||
filterProperties={filterProperties}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
freeformText={freeformText}
|
||||
onFreeformTextChange={setFreeformText}
|
||||
actions={actions}
|
||||
/>
|
||||
```
|
||||
@@ -1,10 +1,11 @@
|
||||
import { format } from 'date-fns'
|
||||
import { useState } from 'react'
|
||||
import { Button, Button_Shadcn_, Calendar, Input_Shadcn_ } from 'ui'
|
||||
import { DateRange } from 'react-day-picker'
|
||||
import { Button, Calendar } from 'ui'
|
||||
import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns'
|
||||
|
||||
function CustomDatePicker({ onChange, onCancel, search }: CustomOptionProps) {
|
||||
const [date, setDate] = useState<any | undefined>(
|
||||
const [date, setDate] = useState<DateRange | undefined>(
|
||||
search
|
||||
? {
|
||||
from: new Date(search),
|
||||
@@ -46,46 +47,18 @@ function CustomDatePicker({ onChange, onCancel, search }: CustomOptionProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTimePicker({ onChange, onCancel, search }: CustomOptionProps) {
|
||||
const [time, setTime] = useState(search || '')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Select Time</h3>
|
||||
<Input_Shadcn_ type="time" value={time} onChange={(e) => setTime(e.target.value)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button_Shadcn_ variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button_Shadcn_>
|
||||
<Button_Shadcn_ onClick={() => onChange(time)}>Apply</Button_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomRangePicker({ onChange, onCancel, search }: CustomOptionProps) {
|
||||
const [range, setRange] = useState(search || '')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Select Range</h3>
|
||||
<Input_Shadcn_ type="range" value={range} onChange={(e) => setRange(e.target.value)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button_Shadcn_ variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button_Shadcn_>
|
||||
<Button_Shadcn_ onClick={() => onChange(range)}>Apply</Button_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filterProperties = [
|
||||
{
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
type: 'string' as const,
|
||||
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
|
||||
operators: [
|
||||
{ value: '=', label: 'Equals' },
|
||||
{ value: '!=', label: 'Not equals' },
|
||||
{ value: 'CONTAINS', label: 'Contains' },
|
||||
{ value: 'STARTS WITH', label: 'Starts with' },
|
||||
{ value: 'ENDS WITH', label: 'Ends with' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
@@ -138,7 +111,6 @@ const filterProperties = [
|
||||
</div>
|
||||
),
|
||||
},
|
||||
triggerOnPropertyClick: true,
|
||||
operators: ['=', '!=', '>', '<', '>=', '<='],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DndProvider } from 'react-dnd'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
@@ -17,6 +17,7 @@ import { Shortcuts } from './components/common/Shortcuts'
|
||||
import { Footer } from './components/footer/Footer'
|
||||
import { Grid } from './components/grid/Grid'
|
||||
import { Header, HeaderProps } from './components/header/Header'
|
||||
import { HeaderNew } from './components/header/HeaderNew'
|
||||
import { RowContextMenu } from './components/menu/RowContextMenu'
|
||||
import { GridProps } from './types'
|
||||
|
||||
@@ -45,6 +46,8 @@ export const SupabaseGrid = ({
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const newFilterBarEnabled = useFlag('tableEditorNewFilterBar')
|
||||
|
||||
const { filters } = useTableFilter()
|
||||
const { sorts, onApplySorts } = useTableSort()
|
||||
|
||||
@@ -90,10 +93,12 @@ export const SupabaseGrid = ({
|
||||
|
||||
const rows = data?.rows ?? EMPTY_ARR
|
||||
|
||||
const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend} context={window}>
|
||||
<div className="sb-grid h-full flex flex-col">
|
||||
<Header
|
||||
<HeaderComponent
|
||||
customHeader={customHeader}
|
||||
isRefetching={isRefetching}
|
||||
tableQueriesEnabled={tableQueriesEnabled}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
|
||||
import { useTableSort } from 'components/grid/hooks/useTableSort'
|
||||
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
|
||||
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import {
|
||||
useExportAllRowsAsCsv,
|
||||
useExportAllRowsAsJson,
|
||||
useExportAllRowsAsSql,
|
||||
} from 'components/layouts/TableEditorLayout/ExportAllRows'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
import {
|
||||
useRoleImpersonationStateSnapshot,
|
||||
useSubscribeToImpersonatedRole,
|
||||
} from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
copyToClipboard,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
} from 'ui'
|
||||
import { ExportDialog } from './ExportDialog'
|
||||
import { FilterPopoverNew } from './filter/FilterPopoverNew'
|
||||
import { formatRowsForCSV } from './Header.utils'
|
||||
import { SortPopover } from './sort/SortPopover'
|
||||
|
||||
export type HeaderProps = {
|
||||
customHeader: ReactNode
|
||||
isRefetching: boolean
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
export const HeaderNew = ({
|
||||
customHeader,
|
||||
isRefetching,
|
||||
tableQueriesEnabled = true,
|
||||
}: HeaderProps) => {
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const showInsertButton = snap.selectedRows.size === 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-10 items-center justify-between bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2 overflow-x-auto ">
|
||||
{customHeader ? (
|
||||
customHeader
|
||||
) : snap.selectedRows.size > 0 ? (
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
) : (
|
||||
<DefaultHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<GridHeaderActions table={snap.originalTable} isRefetching={isRefetching} />
|
||||
{showInsertButton && <InsertButton />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DefaultHeader = ({
|
||||
tableQueriesEnabled = true,
|
||||
}: Pick<HeaderProps, 'tableQueriesEnabled'>) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<FilterPopoverNew />
|
||||
</div>
|
||||
<SortPopover tableQueriesEnabled={tableQueriesEnabled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const InsertButton = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const { can: canCreateColumns } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'columns'
|
||||
)
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const onAddRow =
|
||||
snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined
|
||||
const onAddColumn = snap.editable ? tableEditorSnap.onAddColumn : undefined
|
||||
const onImportData = snap.editable ? tableEditorSnap.onImportData : undefined
|
||||
|
||||
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
|
||||
|
||||
if (!canAddNew || !canCreateColumns) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="table-editor-insert-new-row"
|
||||
type="primary"
|
||||
size="tiny"
|
||||
icon={<ChevronDown strokeWidth={1.5} />}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{[
|
||||
...(onAddRow !== undefined
|
||||
? [
|
||||
<DropdownMenuItem key="add-row" className="group space-x-2" onClick={onAddRow}>
|
||||
<div className="-mt-2 pr-1.5">
|
||||
<div className="border border-foreground-lighter w-[15px] h-[4px]" />
|
||||
<div className="border border-foreground-lighter w-[15px] h-[4px] my-[2px]" />
|
||||
<div
|
||||
className={cn([
|
||||
'border border-foreground-light w-[15px] h-[4px] translate-x-0.5',
|
||||
'transition duration-200 group-data-[highlighted]:border-brand group-data-[highlighted]:translate-x-0',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p>Insert row</p>
|
||||
<p className="text-foreground-light">Insert a new row into {snap.table.name}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>,
|
||||
]
|
||||
: []),
|
||||
...(onAddColumn !== undefined
|
||||
? [
|
||||
<DropdownMenuItem
|
||||
key="add-column"
|
||||
className="group space-x-2"
|
||||
onClick={onAddColumn}
|
||||
>
|
||||
<div className="flex -mt-2 pr-1.5">
|
||||
<div className="border border-foreground-lighter w-[4px] h-[15px]" />
|
||||
<div className="border border-foreground-lighter w-[4px] h-[15px] mx-[2px]" />
|
||||
<div
|
||||
className={cn([
|
||||
'border border-foreground-light w-[4px] h-[15px] -translate-y-0.5',
|
||||
'transition duration-200 group-data-[highlighted]:border-brand group-data-[highlighted]:translate-y-0',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p>Insert column</p>
|
||||
<p className="text-foreground-light">
|
||||
Insert a new column into {snap.table.name}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>,
|
||||
]
|
||||
: []),
|
||||
...(onImportData !== undefined
|
||||
? [
|
||||
<DropdownMenuItem
|
||||
key="import-data"
|
||||
className="group space-x-2"
|
||||
onClick={() => {
|
||||
onImportData()
|
||||
sendEvent({
|
||||
action: 'import_data_button_clicked',
|
||||
properties: { tableType: 'Existing Table' },
|
||||
groups: {
|
||||
project: projectRef ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="relative -mt-2">
|
||||
<FileText size={18} strokeWidth={1.5} className="-translate-x-[2px]" />
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
'transition duration-200 absolute bottom-0 right-0 translate-y-1 opacity-0 bg-brand-400 rounded-full',
|
||||
'group-data-[highlighted]:translate-y-0 group-data-[highlighted]:text-brand group-data-[highlighted]:opacity-100'
|
||||
)}
|
||||
strokeWidth={3}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p>Import data from CSV</p>
|
||||
<p className="text-foreground-light">Insert new rows from a CSV</p>
|
||||
</div>
|
||||
</DropdownMenuItem>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type RowHeaderProps = {
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const isImpersonatingRole = roleImpersonationState.role !== undefined
|
||||
|
||||
const { filters } = useTableFilter()
|
||||
const { sorts } = useTableSort()
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
|
||||
const { data } = useTableRowsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
tableId: snap.table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
limit: tableEditorSnap.rowsPerPage,
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
},
|
||||
{ enabled: tableQueriesEnabled }
|
||||
)
|
||||
|
||||
const { data: countData } = useTableRowsCountQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
tableId: snap.table.id,
|
||||
filters,
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
|
||||
},
|
||||
{ placeholderData: keepPreviousData, enabled: tableQueriesEnabled }
|
||||
)
|
||||
|
||||
const allRows = data?.rows ?? []
|
||||
const totalRows = countData?.count ?? 0
|
||||
|
||||
const onSelectAllRows = () => {
|
||||
snap.setSelectedRows(new Set(allRows.map((row) => row.idx)), true)
|
||||
}
|
||||
|
||||
const onRowsDelete = () => {
|
||||
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
|
||||
const rowIdxs = Array.from(snap.selectedRows) as number[]
|
||||
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
|
||||
|
||||
tableEditorSnap.onDeleteRows(rows, {
|
||||
allRowsSelected: snap.allRowsSelected,
|
||||
numRows,
|
||||
callback: () => {
|
||||
snap.setSelectedRows(new Set())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const onCopyRows = (type: 'csv' | 'json' | 'sql') => {
|
||||
const rows = allRows.filter((x) => snap.selectedRows.has(x.idx))
|
||||
|
||||
if (type === 'csv') {
|
||||
const csv = formatRowsForCSV({
|
||||
rows,
|
||||
columns: snap.table!.columns.map((column) => column.name),
|
||||
})
|
||||
copyToClipboard(csv)
|
||||
} else if (type === 'sql') {
|
||||
const sqlStatements = formatTableRowsToSQL(snap.table, rows)
|
||||
copyToClipboard(sqlStatements)
|
||||
} else if (type === 'json') {
|
||||
copyToClipboard(JSON.stringify(rows))
|
||||
}
|
||||
|
||||
toast.success('Copied rows to clipboard')
|
||||
}
|
||||
|
||||
const exportParams = snap.allRowsSelected
|
||||
? ({ type: 'fetch_all', filters, sorts } as const)
|
||||
: ({
|
||||
type: 'provided_rows',
|
||||
table: snap.table,
|
||||
rows: allRows.filter((x) => snap.selectedRows.has(x.idx)),
|
||||
} as const)
|
||||
|
||||
const { exportCsv, confirmationModal: exportCsvConfirmationModal } = useExportAllRowsAsCsv(
|
||||
project
|
||||
? {
|
||||
enabled: true,
|
||||
projectRef: project.ref,
|
||||
connectionString: project?.connectionString ?? null,
|
||||
entity: snap.table,
|
||||
totalRows,
|
||||
...exportParams,
|
||||
}
|
||||
: { enabled: false }
|
||||
)
|
||||
const onRowsExportCSV = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (!project) {
|
||||
toast.error('Project is required')
|
||||
return setIsExporting(false)
|
||||
}
|
||||
|
||||
exportCsv()
|
||||
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
const { exportSql, confirmationModal: exportSqlConfirmationModal } = useExportAllRowsAsSql(
|
||||
project
|
||||
? {
|
||||
enabled: true,
|
||||
projectRef: project.ref,
|
||||
connectionString: project?.connectionString ?? null,
|
||||
entity: snap.table,
|
||||
...exportParams,
|
||||
}
|
||||
: { enabled: false }
|
||||
)
|
||||
const onRowsExportSQL = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (!project) {
|
||||
toast.error('Project is required')
|
||||
return setIsExporting(false)
|
||||
}
|
||||
|
||||
exportSql()
|
||||
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
const { exportJson, confirmationModal: exportJsonConfirmationModal } = useExportAllRowsAsJson(
|
||||
project
|
||||
? {
|
||||
enabled: true,
|
||||
projectRef: project.ref,
|
||||
connectionString: project?.connectionString ?? null,
|
||||
entity: snap.table,
|
||||
...exportParams,
|
||||
}
|
||||
: { enabled: false }
|
||||
)
|
||||
const onRowsExportJSON = async () => {
|
||||
if (!project) {
|
||||
return toast.error('Project is required')
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
|
||||
exportJson()
|
||||
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
function deselectRows() {
|
||||
snap.setSelectedRows(new Set())
|
||||
}
|
||||
|
||||
useSubscribeToImpersonatedRole(() => {
|
||||
if (snap.allRowsSelected || snap.selectedRows.size > 0) {
|
||||
deselectRows()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{snap.editable && (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Trash />}
|
||||
onClick={onRowsDelete}
|
||||
disabled={snap.allRowsSelected && isImpersonatingRole}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text:
|
||||
snap.allRowsSelected && isImpersonatingRole
|
||||
? 'Table truncation is not supported when impersonating a role'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{snap.allRowsSelected
|
||||
? `Delete all rows in table`
|
||||
: snap.selectedRows.size > 1
|
||||
? `Delete ${snap.selectedRows.size} rows`
|
||||
: `Delete ${snap.selectedRows.size} row`}
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
|
||||
{!snap.allRowsSelected ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronDown />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuItem onClick={() => onCopyRows('csv')}>Copy as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('sql')}>Copy as SQL</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('json')}>Copy as JSON</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
disabled
|
||||
type="default"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
className: 'w-64 text-center',
|
||||
text: 'Copy to clipboard is not supported while all rows in the table are selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronDown />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={snap.allRowsSelected ? 'w-52' : 'w-40'}>
|
||||
<DropdownMenuItem onClick={onRowsExportCSV}>Export as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
|
||||
{snap.allRowsSelected ? (
|
||||
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
|
||||
<div>
|
||||
<p className="group-hover:text-foreground">Export via CLI</p>
|
||||
<p className="text-foreground-lighter">Recommended for large tables</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{!snap.allRowsSelected && totalRows > allRows.length && (
|
||||
<>
|
||||
<div className="h-6 ml-0.5">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
<Button type="text" onClick={() => onSelectAllRows()}>
|
||||
Select all rows in table
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportDialog
|
||||
table={snap.table}
|
||||
filters={filters}
|
||||
sorts={sorts}
|
||||
open={showExportModal}
|
||||
onOpenChange={() => setShowExportModal(false)}
|
||||
/>
|
||||
|
||||
{exportCsvConfirmationModal}
|
||||
{exportSqlConfirmationModal}
|
||||
{exportJsonConfirmationModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { format } from 'date-fns'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
|
||||
import type { Filter } from 'components/grid/types'
|
||||
import { useSqlFilterGenerateMutation } from 'data/ai/sql-filter-mutation'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { Button, Calendar } from 'ui'
|
||||
import {
|
||||
CustomOptionProps,
|
||||
FilterBar,
|
||||
FilterGroup,
|
||||
FilterOption,
|
||||
FilterProperty,
|
||||
isGroup,
|
||||
SerializableFilterProperty,
|
||||
updateGroupAtPath,
|
||||
} from 'ui-patterns'
|
||||
import { columnToFilterProperty } from './FilterPopoverNew.utils'
|
||||
|
||||
export interface FilterPopoverProps {
|
||||
portal?: boolean
|
||||
}
|
||||
|
||||
// Convert Filter[] to FilterGroup
|
||||
// Note: pg-meta Filter allows column to be string | string[] (for tuple filters)
|
||||
// We only support simple string columns in the FilterBar
|
||||
function filtersToFilterGroup(filters: Filter[]): FilterGroup {
|
||||
return {
|
||||
logicalOperator: 'AND',
|
||||
conditions: filters
|
||||
.filter((filter) => typeof filter.column === 'string') // Skip tuple filters
|
||||
.map((filter) => ({
|
||||
propertyName: filter.column as string,
|
||||
operator: filter.operator,
|
||||
value: filter.value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert FilterGroup to Filter[]
|
||||
function filterGroupToFilters(group: FilterGroup): Filter[] {
|
||||
const filters: Filter[] = []
|
||||
for (const condition of group.conditions) {
|
||||
if (isGroup(condition)) {
|
||||
filters.push(...filterGroupToFilters(condition))
|
||||
} else {
|
||||
filters.push({
|
||||
column: condition.propertyName,
|
||||
operator: condition.operator as Filter['operator'],
|
||||
value: String(condition.value ?? ''),
|
||||
})
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
// Custom date picker component for the FilterBar
|
||||
function DatePickerOption({ onChange, onCancel, search }: CustomOptionProps) {
|
||||
const [date, setDate] = useState<Date | undefined>(search ? new Date(search) : undefined)
|
||||
|
||||
return (
|
||||
<div className="w-[300px] space-y-4">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="single"
|
||||
defaultMonth={date}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 py-3 px-4 border-t">
|
||||
<Button type="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => onChange(date ? format(date, 'yyyy-MM-dd') : '')}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type OptionLike = FilterOption | null | undefined
|
||||
|
||||
const isOptionRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
const toOptionText = (option: OptionLike): string | null => {
|
||||
if (typeof option === 'string') return option
|
||||
if (!isOptionRecord(option)) return null
|
||||
|
||||
const value = 'value' in option ? option.value : undefined
|
||||
const label = 'label' in option ? option.label : undefined
|
||||
const hasValue = typeof value === 'string'
|
||||
const hasLabel = typeof label === 'string'
|
||||
|
||||
if (hasValue && hasLabel) return value || label
|
||||
if (hasLabel) return label
|
||||
return null
|
||||
}
|
||||
|
||||
function serializeFilterProperties(
|
||||
filterProperties: FilterProperty[]
|
||||
): SerializableFilterProperty[] {
|
||||
return filterProperties.map((property) => ({
|
||||
label: property.label,
|
||||
name: property.name,
|
||||
type: property.type,
|
||||
operators: property.operators,
|
||||
options: Array.isArray(property.options)
|
||||
? property.options.map(toOptionText).filter((value): value is string => Boolean(value))
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export const FilterPopoverNew = ({ portal = true }: FilterPopoverProps) => {
|
||||
const { filters: urlFilters, onApplyFilters } = useTableFilter()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
|
||||
// Local state for immediate UI updates
|
||||
const [localFilters, setLocalFilters] = useState<Filter[]>(urlFilters)
|
||||
const debouncedLocalFilters = useDebounce(localFilters, 500)
|
||||
|
||||
useEffect(() => {
|
||||
onApplyFilters(debouncedLocalFilters)
|
||||
}, [debouncedLocalFilters, onApplyFilters])
|
||||
|
||||
const [freeformText, setFreeformText] = useState('')
|
||||
const { mutateAsync: generateFilters, isPending: isGenerating } = useSqlFilterGenerateMutation()
|
||||
|
||||
// Convert filters to FilterGroup for the FilterBar
|
||||
const filterGroup = useMemo(() => filtersToFilterGroup(localFilters), [localFilters])
|
||||
|
||||
const columns = useMemo(() => snap.table?.columns ?? [], [snap.table?.columns])
|
||||
|
||||
// Create filter properties from table columns
|
||||
// Add the date picker component for date columns (can't be in utils due to React component)
|
||||
const filterProperties: FilterProperty[] = useMemo(() => {
|
||||
return columns.map((column) => {
|
||||
const property = columnToFilterProperty(column)
|
||||
if (property.type === 'date' && Array.isArray(property.options)) {
|
||||
return {
|
||||
...property,
|
||||
options: [
|
||||
...property.options,
|
||||
{
|
||||
label: 'Pick a date...',
|
||||
component: (props: CustomOptionProps) => <DatePickerOption {...props} />,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return property
|
||||
})
|
||||
}, [columns])
|
||||
|
||||
const serializableFilterProperties = useMemo(
|
||||
() => serializeFilterProperties(filterProperties),
|
||||
[filterProperties]
|
||||
)
|
||||
|
||||
// Handle filter changes from FilterBar
|
||||
const handleFilterChange = useCallback((newFilterGroup: FilterGroup) => {
|
||||
const newFilters = filterGroupToFilters(newFilterGroup)
|
||||
// Update local state immediately for responsive UI
|
||||
setLocalFilters(newFilters)
|
||||
}, [])
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'ai-filter',
|
||||
label: 'Filter by AI',
|
||||
onSelect: async (
|
||||
inputValue: string,
|
||||
context: { path: number[]; activeFilters: FilterGroup }
|
||||
) => {
|
||||
const prompt = inputValue.trim()
|
||||
if (!prompt) return
|
||||
|
||||
const aiGroup = await generateFilters({
|
||||
prompt,
|
||||
filterProperties: serializableFilterProperties,
|
||||
currentPath: context.path ?? [],
|
||||
})
|
||||
|
||||
const updatedGroup = updateGroupAtPath(context.activeFilters, context.path ?? [], aiGroup)
|
||||
handleFilterChange(updatedGroup)
|
||||
setFreeformText('')
|
||||
},
|
||||
},
|
||||
],
|
||||
[generateFilters, serializableFilterProperties, handleFilterChange, setFreeformText]
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
filterProperties={filterProperties}
|
||||
filters={filterGroup}
|
||||
onFilterChange={handleFilterChange}
|
||||
freeformText={freeformText}
|
||||
onFreeformTextChange={setFreeformText}
|
||||
actions={actions}
|
||||
isLoading={isGenerating}
|
||||
variant="pill"
|
||||
className="bg-transparent border-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { format } from 'date-fns'
|
||||
|
||||
import type { SupaColumn } from 'components/grid/types'
|
||||
import {
|
||||
isBoolColumn,
|
||||
isDateColumn,
|
||||
isDateTimeColumn,
|
||||
isEnumColumn,
|
||||
isTimeColumn,
|
||||
} from 'components/grid/utils/types'
|
||||
import type { FilterProperty } from 'ui-patterns'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
|
||||
// Check if column is a date/datetime/time type
|
||||
function isDateLikeColumn(column: SupaColumn): boolean {
|
||||
return (
|
||||
isDateColumn(column.format) || isDateTimeColumn(column.format) || isTimeColumn(column.format)
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_OPERATORS = FilterOperatorOptions.map((op) => ({
|
||||
value: op.value,
|
||||
label: op.label,
|
||||
}))
|
||||
|
||||
const STRING_OPERATORS = [
|
||||
{ value: '~~*', label: 'ilike operator' },
|
||||
...DEFAULT_OPERATORS.filter((op) => op.value !== '~~*'),
|
||||
]
|
||||
|
||||
const DATE_OPERATORS = [
|
||||
{ value: '>', label: 'greater than' },
|
||||
{ value: '>=', label: 'greater than or equal' },
|
||||
{ value: '<', label: 'less than' },
|
||||
{ value: '<=', label: 'less than or equal' },
|
||||
{ value: '=', label: 'equals' },
|
||||
{ value: '<>', label: 'not equal' },
|
||||
]
|
||||
|
||||
const BOOLEAN_OPERATORS = [
|
||||
{ value: '=', label: 'equals' },
|
||||
{ value: '<>', label: 'not equal' },
|
||||
]
|
||||
|
||||
export function columnToFilterProperty(column: SupaColumn): FilterProperty {
|
||||
// For enum columns, use the enum values as options
|
||||
if (isEnumColumn(column.dataType) && column.enum) {
|
||||
return {
|
||||
label: column.name,
|
||||
name: column.name,
|
||||
type: 'string' as const,
|
||||
options: column.enum.map((value) => ({ label: value, value })),
|
||||
operators: DEFAULT_OPERATORS,
|
||||
}
|
||||
}
|
||||
|
||||
// For boolean columns
|
||||
if (isBoolColumn(column.dataType)) {
|
||||
return {
|
||||
label: column.name,
|
||||
name: column.name,
|
||||
type: 'boolean' as const,
|
||||
options: [
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
],
|
||||
operators: BOOLEAN_OPERATORS,
|
||||
}
|
||||
}
|
||||
|
||||
// For date/datetime columns
|
||||
if (isDateLikeColumn(column)) {
|
||||
const today = new Date()
|
||||
const yesterday = new Date(Date.now() - 86400000)
|
||||
const lastWeek = new Date(Date.now() - 7 * 86400000)
|
||||
const lastMonth = new Date(Date.now() - 30 * 86400000)
|
||||
|
||||
return {
|
||||
label: column.name,
|
||||
name: column.name,
|
||||
type: 'date' as const,
|
||||
options: [
|
||||
{ label: 'Today', value: format(today, 'yyyy-MM-dd') },
|
||||
{ label: 'Yesterday', value: format(yesterday, 'yyyy-MM-dd') },
|
||||
{ label: 'Last 7 days', value: format(lastWeek, 'yyyy-MM-dd') },
|
||||
{ label: 'Last 30 days', value: format(lastMonth, 'yyyy-MM-dd') },
|
||||
],
|
||||
operators: DATE_OPERATORS,
|
||||
}
|
||||
}
|
||||
|
||||
// For other columns, keep a simple text-based filter
|
||||
return {
|
||||
label: column.name,
|
||||
name: column.name,
|
||||
type: 'string' as const,
|
||||
operators: STRING_OPERATORS,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Rewind } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { useParams } from 'common'
|
||||
import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel'
|
||||
import LoadingOpacity from 'components/ui/LoadingOpacity'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
@@ -12,6 +12,7 @@ import useLogsPreview from 'hooks/analytics/useLogsPreview'
|
||||
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
|
||||
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
|
||||
import useSingleLog from 'hooks/analytics/useSingleLog'
|
||||
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
@@ -27,9 +28,7 @@ import {
|
||||
} from './Logs.constants'
|
||||
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
|
||||
import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
|
||||
import { PreviewFilterPanelWithUniversal } from './PreviewFilterPanelWithUniversal'
|
||||
import UpgradePrompt from './UpgradePrompt'
|
||||
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
|
||||
|
||||
/**
|
||||
* Calculates the appropriate time range for bar click filtering based on the current time range duration.
|
||||
@@ -108,8 +107,6 @@ export const LogsPreviewer = ({
|
||||
EmptyState,
|
||||
filterPanelClassName,
|
||||
}: PropsWithChildren<LogsPreviewerProps>) => {
|
||||
const useUniversalFilterBar = useFlag('universalFilterBar')
|
||||
|
||||
const router = useRouter()
|
||||
const { db } = useParams()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
@@ -293,13 +290,7 @@ export const LogsPreviewer = ({
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{useUniversalFilterBar ? (
|
||||
// Experimental Universal Filter Bar
|
||||
<PreviewFilterPanelWithUniversal {...filterPanelProps} />
|
||||
) : (
|
||||
// Legacy Filter Panel
|
||||
<PreviewFilterPanel {...filterPanelProps} />
|
||||
)}
|
||||
<PreviewFilterPanel {...filterPanelProps} />
|
||||
{children}
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { constructHeaders, fetchHandler } from 'data/fetchers'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { ResponseError, UseCustomMutationOptions } from 'types'
|
||||
import { AIFilterRequestPayload, FilterGroup } from 'ui-patterns/FilterBar'
|
||||
|
||||
export type SqlFilterGenerateResponse = FilterGroup
|
||||
|
||||
export async function generateSqlFilters({
|
||||
prompt,
|
||||
filterProperties,
|
||||
currentPath,
|
||||
}: AIFilterRequestPayload) {
|
||||
const url = `${BASE_PATH}/api/ai/sql/filter-v1`
|
||||
|
||||
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
|
||||
const response = await fetchHandler(url, {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt, filterProperties, currentPath }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to generate filters'
|
||||
try {
|
||||
const errorBody = await response.json()
|
||||
errorMessage = errorBody?.error || errorBody?.message || errorMessage
|
||||
} catch {}
|
||||
throw new ResponseError(errorMessage, response.status)
|
||||
}
|
||||
|
||||
return (await response.json()) as SqlFilterGenerateResponse
|
||||
}
|
||||
|
||||
type SqlFilterGenerateData = Awaited<ReturnType<typeof generateSqlFilters>>
|
||||
|
||||
export const useSqlFilterGenerateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<SqlFilterGenerateData, ResponseError, AIFilterRequestPayload>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<SqlFilterGenerateData, ResponseError, AIFilterRequestPayload>({
|
||||
mutationFn: (vars) => generateSqlFilters(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to generate filters: ${error.message}`)
|
||||
} else {
|
||||
onError(error, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
enforceAndLogicalOperator,
|
||||
FilterGroupType,
|
||||
isFilterGroup,
|
||||
serializeOperators,
|
||||
serializeOptions,
|
||||
validateFilterGroup,
|
||||
} from './filterHelpers'
|
||||
|
||||
describe('isFilterGroup', () => {
|
||||
test('returns true for filter groups', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [],
|
||||
}
|
||||
expect(isFilterGroup(group)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for filter conditions', () => {
|
||||
const condition = {
|
||||
propertyName: 'name',
|
||||
operator: '=',
|
||||
value: 'test',
|
||||
}
|
||||
expect(isFilterGroup(condition)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeOptions', () => {
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(serializeOptions(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns undefined for non-array input', () => {
|
||||
expect(serializeOptions(null as any)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns undefined for empty array', () => {
|
||||
expect(serializeOptions([])).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles string options', () => {
|
||||
expect(serializeOptions(['option1', 'option2'])).toEqual(['option1', 'option2'])
|
||||
})
|
||||
|
||||
test('extracts label from object options', () => {
|
||||
expect(serializeOptions([{ label: 'Label 1' }, { label: 'Label 2' }])).toEqual([
|
||||
'Label 1',
|
||||
'Label 2',
|
||||
])
|
||||
})
|
||||
|
||||
test('falls back to value when label is not present', () => {
|
||||
expect(serializeOptions([{ value: 'value1' }, { value: 'value2' }])).toEqual([
|
||||
'value1',
|
||||
'value2',
|
||||
])
|
||||
})
|
||||
|
||||
test('prefers label over value', () => {
|
||||
expect(serializeOptions([{ label: 'Label', value: 'value' }])).toEqual(['Label'])
|
||||
})
|
||||
|
||||
test('handles mixed option types', () => {
|
||||
expect(
|
||||
serializeOptions([
|
||||
'string',
|
||||
{ label: 'Label' },
|
||||
{ value: 'value' },
|
||||
{ label: 'L', value: 'v' },
|
||||
])
|
||||
).toEqual(['string', 'Label', 'value', 'L'])
|
||||
})
|
||||
|
||||
test('filters out null values from invalid options', () => {
|
||||
expect(serializeOptions(['valid', {} as any, 'also-valid'])).toEqual(['valid', 'also-valid'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeOperators', () => {
|
||||
test('returns default ["="] for undefined input', () => {
|
||||
expect(serializeOperators(undefined)).toEqual(['='])
|
||||
})
|
||||
|
||||
test('returns default ["="] for empty array', () => {
|
||||
expect(serializeOperators([])).toEqual(['='])
|
||||
})
|
||||
|
||||
test('returns default ["="] for non-array input', () => {
|
||||
expect(serializeOperators(null as any)).toEqual(['='])
|
||||
})
|
||||
|
||||
test('handles string operators', () => {
|
||||
expect(serializeOperators(['=', '>', '<'])).toEqual(['=', '>', '<'])
|
||||
})
|
||||
|
||||
test('extracts value from object operators', () => {
|
||||
expect(
|
||||
serializeOperators([
|
||||
{ value: '=', label: 'equals' },
|
||||
{ value: '>', label: 'greater than' },
|
||||
])
|
||||
).toEqual(['=', '>'])
|
||||
})
|
||||
|
||||
test('falls back to label when value is not present', () => {
|
||||
expect(serializeOperators([{ label: 'equals' }, { label: 'greater than' }])).toEqual([
|
||||
'equals',
|
||||
'greater than',
|
||||
])
|
||||
})
|
||||
|
||||
test('prefers value over label', () => {
|
||||
expect(serializeOperators([{ value: '=', label: 'equals' }])).toEqual(['='])
|
||||
})
|
||||
|
||||
test('handles mixed operator types', () => {
|
||||
expect(serializeOperators(['=', { value: '>' }, { label: 'less than' }])).toEqual([
|
||||
'=',
|
||||
'>',
|
||||
'less than',
|
||||
])
|
||||
})
|
||||
|
||||
test('returns default if all operators are invalid', () => {
|
||||
expect(serializeOperators([{} as any, {} as any])).toEqual(['='])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFilterGroup', () => {
|
||||
const properties = [
|
||||
{ label: 'Name', name: 'name', type: 'string' as const, operators: ['=', '~~*'] },
|
||||
{ label: 'Age', name: 'age', type: 'number' as const, operators: ['=', '>', '<'] },
|
||||
{ label: 'Active', name: 'active', type: 'boolean' as const },
|
||||
]
|
||||
|
||||
test('validates simple condition with valid property and operator', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [{ propertyName: 'name', operator: '=', value: 'test' }],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects condition with invalid property name', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [{ propertyName: 'unknown', operator: '=', value: 'test' }],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects condition with invalid operator', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [{ propertyName: 'name', operator: '>', value: 'test' }],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(false)
|
||||
})
|
||||
|
||||
test('allows any operator when property has no operators defined', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [{ propertyName: 'active', operator: 'any-operator', value: true }],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(true)
|
||||
})
|
||||
|
||||
test('validates nested filter groups', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [
|
||||
{ propertyName: 'name', operator: '=', value: 'test' },
|
||||
{
|
||||
logicalOperator: 'OR',
|
||||
conditions: [
|
||||
{ propertyName: 'age', operator: '>', value: 18 },
|
||||
{ propertyName: 'age', operator: '<', value: 65 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects nested group with invalid condition', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [
|
||||
{ propertyName: 'name', operator: '=', value: 'test' },
|
||||
{
|
||||
logicalOperator: 'OR',
|
||||
conditions: [{ propertyName: 'unknown', operator: '=', value: 'invalid' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(false)
|
||||
})
|
||||
|
||||
test('validates empty conditions array', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [],
|
||||
}
|
||||
expect(validateFilterGroup(group, properties)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enforceAndLogicalOperator', () => {
|
||||
test('converts OR to AND at root level', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'OR',
|
||||
conditions: [{ propertyName: 'name', operator: '=', value: 'test' }],
|
||||
}
|
||||
const result = enforceAndLogicalOperator(group)
|
||||
expect(result.logicalOperator).toBe('AND')
|
||||
})
|
||||
|
||||
test('preserves conditions when converting', () => {
|
||||
const condition = { propertyName: 'name', operator: '=', value: 'test' }
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'OR',
|
||||
conditions: [condition],
|
||||
}
|
||||
const result = enforceAndLogicalOperator(group)
|
||||
expect(result.conditions).toEqual([condition])
|
||||
})
|
||||
|
||||
test('recursively converts nested groups to AND', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'OR',
|
||||
conditions: [
|
||||
{ propertyName: 'name', operator: '=', value: 'test' },
|
||||
{
|
||||
logicalOperator: 'OR',
|
||||
conditions: [
|
||||
{ propertyName: 'age', operator: '>', value: 18 },
|
||||
{
|
||||
logicalOperator: 'OR',
|
||||
conditions: [{ propertyName: 'active', operator: '=', value: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = enforceAndLogicalOperator(group)
|
||||
|
||||
expect(result.logicalOperator).toBe('AND')
|
||||
expect((result.conditions[1] as FilterGroupType).logicalOperator).toBe('AND')
|
||||
expect(
|
||||
((result.conditions[1] as FilterGroupType).conditions[1] as FilterGroupType).logicalOperator
|
||||
).toBe('AND')
|
||||
})
|
||||
|
||||
test('handles empty conditions array', () => {
|
||||
const group: FilterGroupType = {
|
||||
logicalOperator: 'OR',
|
||||
conditions: [],
|
||||
}
|
||||
const result = enforceAndLogicalOperator(group)
|
||||
expect(result).toEqual({ logicalOperator: 'AND', conditions: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const filterOptionSchema = z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
|
||||
export const filterOperatorSchema = z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
|
||||
export const filterPropertySchema = z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'date', 'boolean']),
|
||||
operators: z.array(filterOperatorSchema).optional(),
|
||||
options: z.array(filterOptionSchema).optional(),
|
||||
})
|
||||
|
||||
export const filterConditionSchema = z.object({
|
||||
propertyName: z.string(),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
||||
operator: z.string(),
|
||||
})
|
||||
|
||||
export type FilterGroupType = {
|
||||
logicalOperator: 'AND' | 'OR'
|
||||
conditions: Array<z.infer<typeof filterConditionSchema> | FilterGroupType>
|
||||
}
|
||||
|
||||
export const filterGroupSchema: z.ZodType<FilterGroupType> = z.lazy(() =>
|
||||
z.object({
|
||||
logicalOperator: z.enum(['AND', 'OR']),
|
||||
conditions: z.array(z.union([filterConditionSchema, filterGroupSchema])),
|
||||
})
|
||||
)
|
||||
|
||||
export const requestSchema = z.object({
|
||||
prompt: z.string().min(1, 'Prompt is required'),
|
||||
filterProperties: z
|
||||
.array(filterPropertySchema)
|
||||
.min(1, 'At least one filter property is required'),
|
||||
currentPath: z.array(z.number()).optional(),
|
||||
})
|
||||
|
||||
export function isFilterGroup(
|
||||
condition: FilterGroupType | z.infer<typeof filterConditionSchema>
|
||||
): condition is FilterGroupType {
|
||||
return 'logicalOperator' in condition
|
||||
}
|
||||
|
||||
export function validateFilterGroup(
|
||||
group: FilterGroupType,
|
||||
properties: z.infer<typeof filterPropertySchema>[]
|
||||
): boolean {
|
||||
return group.conditions.every((condition) => {
|
||||
if (isFilterGroup(condition)) {
|
||||
return validateFilterGroup(condition, properties)
|
||||
}
|
||||
|
||||
const property = properties.find((prop) => prop.name === condition.propertyName)
|
||||
if (!property) return false
|
||||
|
||||
if (property.operators && property.operators.length > 0) {
|
||||
return property.operators.includes(condition.operator)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function enforceAndLogicalOperator(group: FilterGroupType): FilterGroupType {
|
||||
return {
|
||||
logicalOperator: 'AND',
|
||||
conditions: group.conditions.map((condition) =>
|
||||
isFilterGroup(condition) ? enforceAndLogicalOperator(condition) : condition
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOptions(
|
||||
options?: z.infer<typeof filterPropertySchema>['options']
|
||||
): string[] | undefined {
|
||||
if (!options || !Array.isArray(options)) return undefined
|
||||
|
||||
const serialized = options
|
||||
.map((option) => {
|
||||
if (typeof option === 'string') return option
|
||||
if (option?.label) return option.label
|
||||
if (option?.value) return option.value
|
||||
return null
|
||||
})
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return serialized.length > 0 ? serialized : undefined
|
||||
}
|
||||
|
||||
export function serializeOperators(
|
||||
operators?: z.infer<typeof filterPropertySchema>['operators']
|
||||
): string[] {
|
||||
if (!operators || !Array.isArray(operators) || operators.length === 0) return ['=']
|
||||
|
||||
const serialized = operators
|
||||
.map((operator) => {
|
||||
if (typeof operator === 'string') return operator
|
||||
if (operator?.value) return operator.value
|
||||
if (operator?.label) return operator.label
|
||||
return null
|
||||
})
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return serialized.length > 0 ? serialized : ['=']
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { generateObject } from 'ai'
|
||||
import { source } from 'common-tags'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { getModel } from 'lib/ai/model'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import {
|
||||
enforceAndLogicalOperator,
|
||||
filterGroupSchema,
|
||||
requestSchema,
|
||||
serializeOperators,
|
||||
serializeOptions,
|
||||
validateFilterGroup,
|
||||
} from 'lib/api/filterHelpers'
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
const parseResult = requestSchema.safeParse(req.body)
|
||||
|
||||
if (!parseResult.success) {
|
||||
const errorMessage = parseResult.error.errors.map((e) => e.message).join(', ')
|
||||
return res.status(400).json({ error: errorMessage })
|
||||
}
|
||||
|
||||
const { prompt, filterProperties } = parseResult.data
|
||||
|
||||
try {
|
||||
const { model, error: modelError } = await getModel({
|
||||
provider: 'openai',
|
||||
routingKey: 'sql',
|
||||
})
|
||||
|
||||
if (modelError) {
|
||||
return res.status(500).json({ error: modelError.message })
|
||||
}
|
||||
|
||||
const normalizedFilterProperties = filterProperties.map((property) => ({
|
||||
...property,
|
||||
operators: serializeOperators(property.operators),
|
||||
options: serializeOptions(property.options),
|
||||
}))
|
||||
|
||||
const propertiesForPrompt = normalizedFilterProperties.map((property) => ({
|
||||
name: property.name,
|
||||
label: property.label,
|
||||
type: property.type,
|
||||
operators: property.operators,
|
||||
options: property.options,
|
||||
}))
|
||||
|
||||
const result = await generateObject({
|
||||
model,
|
||||
schema: filterGroupSchema,
|
||||
prompt: source`
|
||||
You are an expert Postgres filter builder. Convert the user's request into structured filters.
|
||||
|
||||
Available columns and allowed operators:
|
||||
${JSON.stringify(propertiesForPrompt)}
|
||||
|
||||
Rules:
|
||||
- Use only the provided property names and operators for each property.
|
||||
- Prefer logical operator "AND" unless the user explicitly asks for "OR".
|
||||
- When unsure, default to simple equality comparisons with reasonable values.
|
||||
- Values should respect property types: booleans must be true/false, dates should be ISO date strings (YYYY-MM-DD), and numbers must be numbers.
|
||||
- If options are provided for a property, choose from those values when appropriate.
|
||||
|
||||
User request: "${prompt}"
|
||||
`,
|
||||
})
|
||||
|
||||
const generatedFilters = result.object
|
||||
|
||||
if (!validateFilterGroup(generatedFilters, normalizedFilterProperties)) {
|
||||
return res.status(400).json({
|
||||
error: 'Generated filters referenced invalid columns or operators.',
|
||||
})
|
||||
}
|
||||
|
||||
return res.json(enforceAndLogicalOperator(generatedFilters))
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`AI filter generation failed: ${error.message}`)
|
||||
|
||||
if (error.message.includes('context_length') || error.message.includes('too long')) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'Your filter prompt is too large for Supabase Assistant to ingest. Try splitting it into smaller prompts.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.error(`Unknown error: ${error}`)
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'There was an unknown error generating filters. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
|
||||
apiWrapper(req, res, handler, { withAuth: true })
|
||||
|
||||
export default wrapper
|
||||
@@ -13,6 +13,7 @@ const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/ai/code/complete',
|
||||
'/ai/sql/cron-v2',
|
||||
'/ai/sql/title-v2',
|
||||
'/ai/sql/filter-v1',
|
||||
'/ai/onboarding/design',
|
||||
'/ai/feedback/classify',
|
||||
'/ai/docs',
|
||||
|
||||
@@ -318,6 +318,10 @@
|
||||
"import": "./src/FilterBar/FilterBar.tsx",
|
||||
"types": "./src/FilterBar/FilterBar.tsx"
|
||||
},
|
||||
"./FilterBar/FilterBarContext": {
|
||||
"import": "./src/FilterBar/FilterBarContext.tsx",
|
||||
"types": "./src/FilterBar/FilterBarContext.tsx"
|
||||
},
|
||||
"./FilterBar/FilterCondition": {
|
||||
"import": "./src/FilterBar/FilterCondition.tsx",
|
||||
"types": "./src/FilterBar/FilterCondition.tsx"
|
||||
@@ -346,18 +350,10 @@
|
||||
"import": "./src/FilterBar/types.ts",
|
||||
"types": "./src/FilterBar/types.ts"
|
||||
},
|
||||
"./FilterBar/useAIFilter": {
|
||||
"import": "./src/FilterBar/useAIFilter.ts",
|
||||
"types": "./src/FilterBar/useAIFilter.ts"
|
||||
},
|
||||
"./FilterBar/useCommandHandling": {
|
||||
"import": "./src/FilterBar/useCommandHandling.ts",
|
||||
"types": "./src/FilterBar/useCommandHandling.ts"
|
||||
},
|
||||
"./FilterBar/useCommandMenu": {
|
||||
"import": "./src/FilterBar/useCommandMenu.ts",
|
||||
"types": "./src/FilterBar/useCommandMenu.ts"
|
||||
},
|
||||
"./FilterBar/useKeyboardNavigation": {
|
||||
"import": "./src/FilterBar/useKeyboardNavigation.ts",
|
||||
"types": "./src/FilterBar/useKeyboardNavigation.ts"
|
||||
|
||||
@@ -1,250 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import React, { useRef, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
import { FilterGroup as FilterGroupComponent } from './FilterGroup'
|
||||
import { FilterProperty, FilterGroup } from './types'
|
||||
import {
|
||||
findConditionByPath,
|
||||
isAsyncOptionsFunction,
|
||||
removeFromGroup,
|
||||
updateNestedValue,
|
||||
updateNestedOperator,
|
||||
updateNestedLogicalOperator,
|
||||
} from './utils'
|
||||
import { useFilterBarState, useOptionsCache } from './hooks'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import { useAIFilter } from './useAIFilter'
|
||||
import { useCommandHandling } from './useCommandHandling'
|
||||
import { MenuItem } from './menuItems'
|
||||
import { FilterBarRoot, useFilterBar, type FilterBarVariant } from './FilterBarContext'
|
||||
import { FilterGroup } from './FilterGroup'
|
||||
import { FilterBarAction, FilterGroup as FilterGroupType, FilterProperty } from './types'
|
||||
|
||||
export type FilterBarProps = {
|
||||
filterProperties: FilterProperty[]
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
onFilterChange: (filters: FilterGroupType) => void
|
||||
freeformText: string
|
||||
onFreeformTextChange: (freeformText: string) => void
|
||||
filters: FilterGroup
|
||||
aiApiUrl?: string
|
||||
filters: FilterGroupType
|
||||
actions?: FilterBarAction[]
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
supportsOperators?: boolean
|
||||
variant?: FilterBarVariant
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filterProperties,
|
||||
filters: activeFilters,
|
||||
onFilterChange,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
aiApiUrl,
|
||||
className,
|
||||
supportsOperators = false,
|
||||
}: FilterBarProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
error,
|
||||
setError,
|
||||
isCommandMenuVisible,
|
||||
setIsCommandMenuVisible,
|
||||
hideTimeoutRef,
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
newPathRef,
|
||||
} = useFilterBarState()
|
||||
|
||||
const {
|
||||
loadingOptions,
|
||||
propertyOptionsCache,
|
||||
loadPropertyOptions,
|
||||
optionsError,
|
||||
setOptionsError,
|
||||
} = useOptionsCache()
|
||||
|
||||
const { handleAIFilter } = useAIFilter({
|
||||
activeInput,
|
||||
aiApiUrl,
|
||||
freeformText,
|
||||
filterProperties,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
onFreeformTextChange,
|
||||
setIsLoading,
|
||||
setError,
|
||||
setIsCommandMenuVisible,
|
||||
})
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(path: number[], value: string) => {
|
||||
const updatedFilters = updateNestedValue(activeFilters, path, value)
|
||||
onFilterChange(updatedFilters)
|
||||
|
||||
// Load async options for this property when value changes
|
||||
const condition = findConditionByPath(updatedFilters, path)
|
||||
if (condition) {
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
if (
|
||||
property &&
|
||||
property.options &&
|
||||
!Array.isArray(property.options) &&
|
||||
isAsyncOptionsFunction(property.options)
|
||||
) {
|
||||
loadPropertyOptions(property, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeFilters, onFilterChange, filterProperties, loadPropertyOptions]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(path: number[], value: string) => {
|
||||
const updatedFilters = updateNestedOperator(activeFilters, path, value)
|
||||
onFilterChange(updatedFilters)
|
||||
},
|
||||
[activeFilters, onFilterChange]
|
||||
)
|
||||
|
||||
const { handleItemSelect } = useCommandHandling({
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
filterProperties,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
handleInputChange,
|
||||
handleOperatorChange,
|
||||
newPathRef,
|
||||
handleAIFilter,
|
||||
})
|
||||
|
||||
const { handleKeyDown } = useKeyboardNavigation({
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
})
|
||||
|
||||
const handleInputFocus = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'value', path })
|
||||
setIsCommandMenuVisible(true)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Load async options for this property
|
||||
const condition = findConditionByPath(activeFilters, path)
|
||||
if (condition) {
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
if (
|
||||
property &&
|
||||
property.options &&
|
||||
!Array.isArray(property.options) &&
|
||||
isAsyncOptionsFunction(property.options)
|
||||
) {
|
||||
loadPropertyOptions(property, condition.value?.toString() || '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeFilters, filterProperties, loadPropertyOptions]
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
// Defer and only clear active state if focus moved outside the entire FilterBar
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
if (activeEl && rootRef.current && rootRef.current.contains(activeEl)) {
|
||||
return
|
||||
}
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
}, 0)
|
||||
}, [setIsCommandMenuVisible, setActiveInput])
|
||||
|
||||
const handleGroupFreeformChange = useCallback((path: number[], value: string) => {
|
||||
onFreeformTextChange(value)
|
||||
}, [])
|
||||
|
||||
const handleLabelClick = useCallback((path: number[]) => {
|
||||
setActiveInput({ type: 'value', path })
|
||||
}, [])
|
||||
|
||||
const handleLogicalOperatorChange = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = updateNestedLogicalOperator(activeFilters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
},
|
||||
[activeFilters, onFilterChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = removeFromGroup(activeFilters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
setActiveInput(null)
|
||||
},
|
||||
[activeFilters, onFilterChange, setActiveInput]
|
||||
)
|
||||
function FilterBarContent({ className }: { className?: string }) {
|
||||
const { filters, error, optionsError, isLoading, variant } = useFilterBar()
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="w-full space-y-2 relative" data-filterbar-root>
|
||||
<div className="w-full space-y-2 relative">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 w-full border rounded-md h-10 bg-foreground/[.026] cursor-text p-0 px-2 overflow-auto',
|
||||
'relative flex items-stretch gap-0 w-full border rounded-md h-full bg-foreground/[.026] cursor-text p-0 pr-2 overflow-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Search className="text-foreground-muted w-4 h-4 sticky left-0 shrink-0" />
|
||||
<div className="flex-1 flex flex-wrap items-center gap-1 h-full">
|
||||
<FilterGroupComponent
|
||||
group={activeFilters}
|
||||
rootFilters={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={Boolean(activeInput?.type === 'group')}
|
||||
onLogicalOperatorChange={supportsOperators ? handleLogicalOperatorChange : undefined}
|
||||
supportsOperators={supportsOperators}
|
||||
onRemove={handleRemoveCondition}
|
||||
propertyOptionsCache={propertyOptionsCache}
|
||||
loadingOptions={loadingOptions}
|
||||
aiApiUrl={aiApiUrl}
|
||||
onSelectMenuItem={(item: MenuItem) => handleItemSelect(item)}
|
||||
setActiveInput={setActiveInput}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center shrink-0 px-2 bg-surface-200',
|
||||
variant === 'pill' ? 'bg-transparent border-r-0' : 'border-r'
|
||||
)}
|
||||
>
|
||||
<Search className="text-foreground-muted w-4 h-4 sticky" />
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex-1 flex flex-wrap items-stretch gap-0"
|
||||
animate={{ opacity: isLoading ? 0.5 : 1 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: isLoading ? Infinity : 0,
|
||||
repeatType: 'reverse',
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<FilterGroup group={filters} path={[]} />
|
||||
</motion.div>
|
||||
</div>
|
||||
{(error || optionsError) && (
|
||||
<div className="text-red-500 text-xs mt-1">{error || optionsError}</div>
|
||||
@@ -252,3 +58,64 @@ export function FilterBar({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterBar - A composable filter bar component
|
||||
*
|
||||
* Simple usage:
|
||||
* ```tsx
|
||||
* <FilterBar
|
||||
* filterProperties={filterProperties}
|
||||
* filters={filters}
|
||||
* onFilterChange={setFilters}
|
||||
* freeformText={freeformText}
|
||||
* onFreeformTextChange={setFreeformText}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* Composable usage:
|
||||
* ```tsx
|
||||
* <FilterBar.Root
|
||||
* filterProperties={filterProperties}
|
||||
* filters={filters}
|
||||
* onFilterChange={setFilters}
|
||||
* freeformText={freeformText}
|
||||
* onFreeformTextChange={setFreeformText}
|
||||
* >
|
||||
* <FilterBar.Content />
|
||||
* </FilterBar.Root>
|
||||
* ```
|
||||
*/
|
||||
export function FilterBar({
|
||||
filterProperties,
|
||||
filters,
|
||||
onFilterChange,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
actions,
|
||||
isLoading,
|
||||
className,
|
||||
supportsOperators = false,
|
||||
variant = 'default',
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<FilterBarRoot
|
||||
filterProperties={filterProperties}
|
||||
filters={filters}
|
||||
onFilterChange={onFilterChange}
|
||||
freeformText={freeformText}
|
||||
onFreeformTextChange={onFreeformTextChange}
|
||||
actions={actions}
|
||||
isLoading={isLoading}
|
||||
supportsOperators={supportsOperators}
|
||||
variant={variant}
|
||||
>
|
||||
<FilterBarContent className={className} />
|
||||
</FilterBarRoot>
|
||||
)
|
||||
}
|
||||
|
||||
// Composable API exports
|
||||
FilterBar.Root = FilterBarRoot
|
||||
FilterBar.Content = FilterBarContent
|
||||
FilterBar.Group = FilterGroup
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react'
|
||||
import { ActiveInput, useFilterBarState, useOptionsCache } from './hooks'
|
||||
import { MenuItem } from './menuItems'
|
||||
import { FilterBarAction, FilterGroup, FilterOptionObject, FilterProperty } from './types'
|
||||
import { useCommandHandling } from './useCommandHandling'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import {
|
||||
findConditionByPath,
|
||||
isAsyncOptionsFunction,
|
||||
removeFromGroup,
|
||||
updateNestedLogicalOperator,
|
||||
updateNestedOperator,
|
||||
updateNestedValue,
|
||||
} from './utils'
|
||||
|
||||
export type FilterBarContextValue = {
|
||||
// Core state
|
||||
filters: FilterGroup
|
||||
filterProperties: FilterProperty[]
|
||||
activeInput: ActiveInput
|
||||
freeformText: string
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Handlers
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
onFreeformTextChange: (text: string) => void
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
handleInputChange: (path: number[], value: string) => void
|
||||
handleOperatorChange: (path: number[], value: string) => void
|
||||
handleRemoveCondition: (path: number[]) => void
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleSelectMenuItem: (item: MenuItem) => void
|
||||
handleInputFocus: (path: number[]) => void
|
||||
handleOperatorFocus: (path: number[]) => void
|
||||
handleGroupFreeformFocus: (path: number[]) => void
|
||||
handleInputBlur: () => void
|
||||
handleGroupFreeformChange: (path: number[], value: string) => void
|
||||
handleLabelClick: (path: number[]) => void
|
||||
handleLogicalOperatorChange: (path: number[]) => void
|
||||
|
||||
// Options cache
|
||||
propertyOptionsCache: Record<string, { options: (string | FilterOptionObject)[]; searchValue: string }>
|
||||
loadingOptions: Record<string, boolean>
|
||||
loadPropertyOptions: (property: FilterProperty, search: string) => void
|
||||
optionsError: string | null
|
||||
|
||||
// Config
|
||||
supportsOperators: boolean
|
||||
variant: FilterBarVariant
|
||||
actions?: FilterBarAction[]
|
||||
|
||||
// Refs
|
||||
rootRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
const FilterBarContext = createContext<FilterBarContextValue | null>(null)
|
||||
|
||||
export function useFilterBar() {
|
||||
const ctx = useContext(FilterBarContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useFilterBar must be used within FilterBar.Root')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type FilterBarRootProps = {
|
||||
children: React.ReactNode
|
||||
filterProperties: FilterProperty[]
|
||||
filters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
freeformText: string
|
||||
onFreeformTextChange: (text: string) => void
|
||||
actions?: FilterBarAction[]
|
||||
isLoading?: boolean
|
||||
supportsOperators?: boolean
|
||||
variant?: FilterBarVariant
|
||||
}
|
||||
|
||||
export type FilterBarVariant = 'default' | 'pill'
|
||||
|
||||
export function FilterBarRoot({
|
||||
children,
|
||||
filterProperties,
|
||||
filters,
|
||||
onFilterChange,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
actions,
|
||||
isLoading: externalLoading,
|
||||
supportsOperators = false,
|
||||
variant = 'default',
|
||||
}: FilterBarRootProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
hideTimeoutRef,
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
newPathRef,
|
||||
setIsCommandMenuVisible,
|
||||
} = useFilterBarState()
|
||||
|
||||
const { loadingOptions, propertyOptionsCache, loadPropertyOptions, optionsError } =
|
||||
useOptionsCache()
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(path: number[], value: string) => {
|
||||
const updatedFilters = updateNestedValue(filters, path, value)
|
||||
onFilterChange(updatedFilters)
|
||||
|
||||
const condition = findConditionByPath(updatedFilters, path)
|
||||
if (condition) {
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
if (
|
||||
property &&
|
||||
property.options &&
|
||||
!Array.isArray(property.options) &&
|
||||
isAsyncOptionsFunction(property.options)
|
||||
) {
|
||||
loadPropertyOptions(property, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
[filters, onFilterChange, filterProperties, loadPropertyOptions]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(path: number[], value: string) => {
|
||||
const updatedFilters = updateNestedOperator(filters, path, value)
|
||||
onFilterChange(updatedFilters)
|
||||
},
|
||||
[filters, onFilterChange]
|
||||
)
|
||||
|
||||
const { handleItemSelect } = useCommandHandling({
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
activeFilters: filters,
|
||||
onFilterChange,
|
||||
filterProperties,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
handleInputChange,
|
||||
handleOperatorChange,
|
||||
newPathRef,
|
||||
setIsCommandMenuVisible,
|
||||
})
|
||||
|
||||
const { handleKeyDown } = useKeyboardNavigation({
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
activeFilters: filters,
|
||||
onFilterChange,
|
||||
})
|
||||
|
||||
const handleInputFocus = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'value', path })
|
||||
setIsCommandMenuVisible(true)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
|
||||
const condition = findConditionByPath(filters, path)
|
||||
if (condition) {
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
if (
|
||||
property &&
|
||||
property.options &&
|
||||
!Array.isArray(property.options) &&
|
||||
isAsyncOptionsFunction(property.options)
|
||||
) {
|
||||
loadPropertyOptions(property, condition.value?.toString() || '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
filters,
|
||||
filterProperties,
|
||||
loadPropertyOptions,
|
||||
setActiveInput,
|
||||
setIsCommandMenuVisible,
|
||||
hideTimeoutRef,
|
||||
]
|
||||
)
|
||||
|
||||
const handleOperatorFocus = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'operator', path })
|
||||
setIsCommandMenuVisible(true)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[setActiveInput, setIsCommandMenuVisible, hideTimeoutRef]
|
||||
)
|
||||
|
||||
const handleGroupFreeformFocus = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'group', path })
|
||||
setIsCommandMenuVisible(true)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[setActiveInput, setIsCommandMenuVisible, hideTimeoutRef]
|
||||
)
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
if (activeEl && rootRef.current && rootRef.current.contains(activeEl)) {
|
||||
return
|
||||
}
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
}, 0)
|
||||
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef])
|
||||
|
||||
const handleGroupFreeformChange = useCallback(
|
||||
(_path: number[], value: string) => {
|
||||
onFreeformTextChange(value)
|
||||
},
|
||||
[onFreeformTextChange]
|
||||
)
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'value', path })
|
||||
},
|
||||
[setActiveInput]
|
||||
)
|
||||
|
||||
const handleLogicalOperatorChange = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = updateNestedLogicalOperator(filters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
},
|
||||
[filters, onFilterChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = removeFromGroup(filters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
setActiveInput(null)
|
||||
},
|
||||
[filters, onFilterChange, setActiveInput]
|
||||
)
|
||||
|
||||
// Cleanup hideTimeoutRef on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [hideTimeoutRef])
|
||||
|
||||
const loading = externalLoading ?? isLoading
|
||||
|
||||
const contextValue: FilterBarContextValue = {
|
||||
// Core state
|
||||
filters,
|
||||
filterProperties,
|
||||
activeInput,
|
||||
freeformText,
|
||||
isLoading: loading,
|
||||
error,
|
||||
|
||||
// Handlers
|
||||
onFilterChange,
|
||||
onFreeformTextChange,
|
||||
setActiveInput,
|
||||
handleInputChange,
|
||||
handleOperatorChange,
|
||||
handleRemoveCondition,
|
||||
handleKeyDown,
|
||||
handleSelectMenuItem: handleItemSelect,
|
||||
handleInputFocus,
|
||||
handleOperatorFocus,
|
||||
handleGroupFreeformFocus,
|
||||
handleInputBlur,
|
||||
handleGroupFreeformChange,
|
||||
handleLabelClick,
|
||||
handleLogicalOperatorChange,
|
||||
|
||||
// Options cache
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
loadPropertyOptions,
|
||||
optionsError,
|
||||
|
||||
// Config
|
||||
supportsOperators,
|
||||
variant,
|
||||
actions,
|
||||
|
||||
// Refs
|
||||
rootRef,
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterBarContext.Provider value={contextValue}>
|
||||
<div ref={rootRef} data-filterbar-root className="h-full min-h-[26px] flex items-stretch">
|
||||
{children}
|
||||
</div>
|
||||
</FilterBarContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +1,73 @@
|
||||
import React, { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { ActiveInput } from './hooks'
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Input_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverAnchor_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
} from 'ui'
|
||||
import { buildOperatorItems, buildValueItems, MenuItem } from './menuItems'
|
||||
import { FilterGroup as FilterGroupType } from './types'
|
||||
import { FilterCondition as FilterConditionType, FilterProperty } from './types'
|
||||
import { useDeferredBlur, useHighlightNavigation } from './hooks'
|
||||
import { DefaultCommandList } from './DefaultCommandList'
|
||||
import { useFilterBar } from './FilterBarContext'
|
||||
import { useDeferredBlur, useHighlightNavigation } from './hooks'
|
||||
import { buildOperatorItems, buildValueItems } from './menuItems'
|
||||
import { FilterCondition as FilterConditionType } from './types'
|
||||
|
||||
type FilterConditionProps = {
|
||||
export type FilterConditionProps = {
|
||||
condition: FilterConditionType
|
||||
filterProperties: FilterProperty[]
|
||||
id: string
|
||||
path: number[]
|
||||
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
|
||||
onRemove: () => void
|
||||
// Local context
|
||||
rootFilters: FilterGroupType
|
||||
path: number[]
|
||||
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>
|
||||
loadingOptions: Record<string, boolean>
|
||||
aiApiUrl?: string
|
||||
onSelectMenuItem: (item: MenuItem) => void
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
}
|
||||
|
||||
export function FilterCondition({
|
||||
condition,
|
||||
filterProperties,
|
||||
id,
|
||||
path,
|
||||
isActive,
|
||||
isOperatorActive,
|
||||
isLoading,
|
||||
onOperatorChange,
|
||||
onValueChange,
|
||||
onOperatorFocus,
|
||||
onValueFocus,
|
||||
onBlur,
|
||||
onLabelClick,
|
||||
onKeyDown,
|
||||
onRemove,
|
||||
rootFilters,
|
||||
path,
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
aiApiUrl,
|
||||
onSelectMenuItem,
|
||||
setActiveInput,
|
||||
}: FilterConditionProps) {
|
||||
const {
|
||||
filters: rootFilters,
|
||||
filterProperties,
|
||||
isLoading,
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
handleOperatorChange,
|
||||
handleInputChange,
|
||||
handleOperatorFocus,
|
||||
handleInputFocus,
|
||||
handleInputBlur,
|
||||
handleLabelClick,
|
||||
handleKeyDown,
|
||||
handleRemoveCondition,
|
||||
handleSelectMenuItem,
|
||||
setActiveInput,
|
||||
variant,
|
||||
} = useFilterBar()
|
||||
|
||||
const operatorRef = useRef<HTMLInputElement>(null)
|
||||
const valueRef = useRef<HTMLInputElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
const [showValueCustom, setShowValueCustom] = useState(false)
|
||||
const [hasTypedOperator, setHasTypedOperator] = useState(false)
|
||||
const [hasTypedValue, setHasTypedValue] = useState(false)
|
||||
|
||||
// Reset "has typed" state when focus changes
|
||||
useEffect(() => {
|
||||
if (!isOperatorActive) {
|
||||
setHasTypedOperator(false)
|
||||
}
|
||||
}, [isOperatorActive, setHasTypedOperator])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHasTypedValue(false)
|
||||
}
|
||||
}, [isActive, setHasTypedValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && valueRef.current) {
|
||||
@@ -76,27 +77,46 @@ export function FilterCondition({
|
||||
}
|
||||
}, [isActive, isOperatorActive])
|
||||
|
||||
const handleOperatorBlur = useDeferredBlur(wrapperRef as React.RefObject<HTMLElement>, onBlur)
|
||||
const handleValueBlur = useDeferredBlur(wrapperRef as React.RefObject<HTMLElement>, onBlur)
|
||||
|
||||
if (!property) return null
|
||||
const handleOperatorBlur = useDeferredBlur(
|
||||
wrapperRef as React.RefObject<HTMLElement>,
|
||||
handleInputBlur
|
||||
)
|
||||
const handleValueBlur = useDeferredBlur(
|
||||
wrapperRef as React.RefObject<HTMLElement>,
|
||||
handleInputBlur
|
||||
)
|
||||
|
||||
const operatorItems = useMemo(
|
||||
() => buildOperatorItems({ type: 'operator', path } as any, rootFilters, filterProperties),
|
||||
[path, rootFilters, filterProperties]
|
||||
() =>
|
||||
buildOperatorItems(
|
||||
{ type: 'operator', path },
|
||||
rootFilters,
|
||||
filterProperties,
|
||||
hasTypedOperator
|
||||
),
|
||||
[path, rootFilters, filterProperties, hasTypedOperator]
|
||||
)
|
||||
|
||||
const valueItems = useMemo(
|
||||
() =>
|
||||
buildValueItems(
|
||||
{ type: 'value', path } as any,
|
||||
{ type: 'value', path },
|
||||
rootFilters,
|
||||
filterProperties,
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
(condition.value ?? '').toString()
|
||||
(condition.value ?? '').toString(),
|
||||
hasTypedValue
|
||||
),
|
||||
[path, rootFilters, filterProperties, propertyOptionsCache, loadingOptions, condition.value]
|
||||
[
|
||||
path,
|
||||
rootFilters,
|
||||
filterProperties,
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
condition.value,
|
||||
hasTypedValue,
|
||||
]
|
||||
)
|
||||
|
||||
const customValueItem = useMemo(
|
||||
@@ -118,7 +138,7 @@ export function FilterCondition({
|
||||
handleKeyDown: handleOperatorKeyDown,
|
||||
reset: resetOpHighlight,
|
||||
} = useHighlightNavigation(operatorItems.length, (index) => {
|
||||
if (operatorItems[index]) onSelectMenuItem(operatorItems[index])
|
||||
if (operatorItems[index]) handleSelectMenuItem(operatorItems[index])
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -133,48 +153,75 @@ export function FilterCondition({
|
||||
if (item.isCustom) {
|
||||
setShowValueCustom(true)
|
||||
} else {
|
||||
onSelectMenuItem(item)
|
||||
handleSelectMenuItem(item)
|
||||
}
|
||||
},
|
||||
onKeyDown
|
||||
handleKeyDown
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOperatorActive) resetOpHighlight()
|
||||
}, [isOperatorActive, resetOpHighlight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) resetValHighlight()
|
||||
}, [isActive, resetValHighlight])
|
||||
|
||||
const onOperatorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHasTypedOperator(true)
|
||||
handleOperatorChange(path, e.target.value)
|
||||
},
|
||||
[handleOperatorChange, path]
|
||||
)
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHasTypedValue(true)
|
||||
handleInputChange(path, e.target.value)
|
||||
},
|
||||
[handleInputChange, path]
|
||||
)
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
handleRemoveCondition(path)
|
||||
}, [handleRemoveCondition, path])
|
||||
|
||||
if (!property) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="flex items-center rounded px-2 h-6 bg-muted border group shrink-0"
|
||||
className={cn(
|
||||
'flex items-stretch px-0 bg-muted group shrink-0',
|
||||
variant === 'pill' ? 'rounded border' : 'border-r'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-medium mr-1 cursor-pointer shrink-0 whitespace-nowrap"
|
||||
onClick={onLabelClick}
|
||||
className="text-xs pl-2 pr-1 cursor-pointer shrink-0 whitespace-nowrap text-foreground-light h-full flex items-center"
|
||||
onClick={() => handleLabelClick(path)}
|
||||
>
|
||||
{property.label}
|
||||
</span>
|
||||
<Popover_Shadcn_ open={isOperatorActive && !isLoading && operatorItems.length > 0}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<Input_Shadcn_
|
||||
ref={operatorRef}
|
||||
type="text"
|
||||
value={condition.operator}
|
||||
onChange={(e) => onOperatorChange(e.target.value)}
|
||||
onFocus={onOperatorFocus}
|
||||
onBlur={handleOperatorBlur}
|
||||
onKeyDown={handleOperatorKeyDown}
|
||||
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 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}`}
|
||||
/>
|
||||
<div className="relative inline-block">
|
||||
<Input_Shadcn_
|
||||
ref={operatorRef}
|
||||
type="text"
|
||||
value={condition.operator}
|
||||
onChange={onOperatorChange}
|
||||
onFocus={() => handleOperatorFocus(path)}
|
||||
onBlur={handleOperatorBlur}
|
||||
onKeyDown={handleOperatorKeyDown}
|
||||
className="h-full border-none bg-transparent py-0 px-1 text-center text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-brand w-full absolute left-0 top-0"
|
||||
disabled={isLoading}
|
||||
aria-label={`Operator for ${property.label}`}
|
||||
/>
|
||||
<span className="invisible whitespace-pre text-xs block px-1 shrink-0 px-1">
|
||||
{condition.operator || ' '}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverAnchor_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="min-w-[220px] p-0"
|
||||
@@ -186,55 +233,57 @@ export function FilterCondition({
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Node
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
|
||||
onBlur()
|
||||
handleInputBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DefaultCommandList
|
||||
items={operatorItems}
|
||||
highlightedIndex={opHighlightedIndex}
|
||||
onSelect={onSelectMenuItem}
|
||||
onSelect={handleSelectMenuItem}
|
||||
includeIcon={false}
|
||||
/>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
<Popover_Shadcn_ open={isActive && !isLoading && (showValueCustom || valueItems.length > 0)}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<Input_Shadcn_
|
||||
ref={valueRef}
|
||||
type="text"
|
||||
value={(condition.value ?? '').toString()}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
onFocus={onValueFocus}
|
||||
onBlur={handleValueBlur}
|
||||
onKeyDown={handleValueKeyDown}
|
||||
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 h-6 mr-1"
|
||||
style={{
|
||||
width: `${Math.max((condition.value ?? '').toString().length, 1)}ch`,
|
||||
minWidth: '1ch',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-label={`Value for ${property.label}`}
|
||||
/>
|
||||
<div className="relative inline-block max-w-[150px]">
|
||||
<Input_Shadcn_
|
||||
ref={valueRef}
|
||||
type="text"
|
||||
value={(condition.value ?? '').toString()}
|
||||
onChange={onValueChange}
|
||||
onFocus={() => handleInputFocus(path)}
|
||||
onBlur={handleValueBlur}
|
||||
onKeyDown={handleValueKeyDown}
|
||||
className="h-full border-none bg-transparent py-0 px-1 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full absolute left-0 top-0"
|
||||
disabled={isLoading}
|
||||
aria-label={`Value for ${property.label}`}
|
||||
/>
|
||||
<span className="invisible whitespace-pre text-xs block px-1">
|
||||
{(condition.value ?? '').toString() || ' '}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverAnchor_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="min-w-[220px] w-fit p-0"
|
||||
align="start"
|
||||
side="bottom"
|
||||
portal
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Node
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
|
||||
onBlur()
|
||||
handleInputBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showValueCustom && customValueItem && customValueItem.customOption ? (
|
||||
customValueItem.customOption({
|
||||
onChange: (value: string) => {
|
||||
onValueChange(value)
|
||||
handleInputChange(path, value)
|
||||
setShowValueCustom(false)
|
||||
// Return focus to group's freeform after selection in next tick
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'group', path: path.slice(0, -1) })
|
||||
}, 0)
|
||||
@@ -250,7 +299,7 @@ export function FilterCondition({
|
||||
items={valueItems}
|
||||
highlightedIndex={valHighlightedIndex}
|
||||
onSelect={(item) =>
|
||||
item.isCustom ? setShowValueCustom(true) : onSelectMenuItem(item)
|
||||
item.isCustom ? setShowValueCustom(true) : handleSelectMenuItem(item)
|
||||
}
|
||||
includeIcon
|
||||
/>
|
||||
@@ -262,13 +311,13 @@ export function FilterCondition({
|
||||
size="tiny"
|
||||
icon={
|
||||
<X
|
||||
strokeWidth={1.5}
|
||||
size={12}
|
||||
className="group-hover:text-foreground text-foreground-light"
|
||||
strokeWidth={1}
|
||||
size={10}
|
||||
className="group-hover:text-foreground text-foreground-lighter"
|
||||
/>
|
||||
}
|
||||
onClick={onRemove}
|
||||
className="group hover:text-foreground !hover:bg-surface-600 p-0"
|
||||
className="group hover:text-foreground hover:!bg-surface-600 rounded-none px-1 h-auto py-0"
|
||||
aria-label={`Remove ${property.label} filter`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,89 +1,56 @@
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { FilterProperty, FilterGroup as FilterGroupType } from './types'
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterCondition } from './FilterCondition'
|
||||
import { Input_Shadcn_, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverAnchor_Shadcn_ } from 'ui'
|
||||
import { buildPropertyItems, MenuItem } from './menuItems'
|
||||
import { useDeferredBlur, useHighlightNavigation } from './hooks'
|
||||
import { DefaultCommandList } from './DefaultCommandList'
|
||||
'use client'
|
||||
|
||||
type FilterGroupProps = {
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
cn,
|
||||
Input_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverAnchor_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
} from 'ui'
|
||||
import { DefaultCommandList } from './DefaultCommandList'
|
||||
import { useFilterBar } from './FilterBarContext'
|
||||
import { FilterCondition } from './FilterCondition'
|
||||
import { useDeferredBlur, useHighlightNavigation } from './hooks'
|
||||
import { buildPropertyItems } from './menuItems'
|
||||
import { FilterGroup as FilterGroupType } from './types'
|
||||
import { pathsEqual } from './utils'
|
||||
|
||||
export type FilterGroupProps = {
|
||||
group: FilterGroupType
|
||||
path: number[]
|
||||
isLoading?: boolean
|
||||
rootFilters: FilterGroupType
|
||||
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
|
||||
supportsOperators?: boolean
|
||||
// Remove functionality
|
||||
onRemove: (path: number[]) => void
|
||||
// Options/async
|
||||
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>
|
||||
loadingOptions: Record<string, boolean>
|
||||
// Menu/selection
|
||||
aiApiUrl?: string
|
||||
onSelectMenuItem: (item: MenuItem) => void
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
}
|
||||
|
||||
export function FilterGroup({
|
||||
group,
|
||||
path,
|
||||
isLoading,
|
||||
rootFilters,
|
||||
activeInput,
|
||||
filterProperties,
|
||||
onOperatorChange,
|
||||
onValueChange,
|
||||
onOperatorFocus,
|
||||
onValueFocus,
|
||||
onBlur,
|
||||
onLabelClick,
|
||||
onKeyDown,
|
||||
onGroupFreeformChange,
|
||||
onGroupFreeformFocus,
|
||||
groupFreeformValue,
|
||||
isGroupFreeformActive,
|
||||
onLogicalOperatorChange,
|
||||
supportsOperators = false,
|
||||
onRemove,
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
aiApiUrl,
|
||||
onSelectMenuItem,
|
||||
setActiveInput,
|
||||
}: FilterGroupProps) {
|
||||
export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
const {
|
||||
filterProperties,
|
||||
activeInput,
|
||||
freeformText,
|
||||
isLoading,
|
||||
supportsOperators,
|
||||
actions,
|
||||
variant,
|
||||
handleInputBlur,
|
||||
handleGroupFreeformFocus,
|
||||
handleGroupFreeformChange,
|
||||
handleLogicalOperatorChange,
|
||||
handleKeyDown,
|
||||
handleSelectMenuItem,
|
||||
} = useFilterBar()
|
||||
|
||||
const [localFreeformValue, setLocalFreeformValue] = useState('')
|
||||
const freeformInputRef = useRef<HTMLInputElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(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])
|
||||
|
||||
const isActive = activeInput?.type === 'group' && pathsEqual(path, activeInput.path)
|
||||
|
||||
// Reset local value when group freeform value is cleared
|
||||
useEffect(() => {
|
||||
if (groupFreeformValue === '') {
|
||||
if (freeformText === '') {
|
||||
setLocalFreeformValue('')
|
||||
}
|
||||
}, [groupFreeformValue])
|
||||
}, [freeformText])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && freeformInputRef.current) {
|
||||
@@ -91,76 +58,43 @@ export function FilterGroup({
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleFreeformBlur = useDeferredBlur(wrapperRef as React.RefObject<HTMLElement>, onBlur)
|
||||
const handleFreeformBlur = useDeferredBlur(
|
||||
wrapperRef as React.RefObject<HTMLElement>,
|
||||
handleInputBlur
|
||||
)
|
||||
|
||||
const handleFreeformChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalFreeformValue(e.target.value)
|
||||
onGroupFreeformChange(path, e.target.value)
|
||||
handleGroupFreeformChange(path, e.target.value)
|
||||
}
|
||||
|
||||
const handleLogicalOperatorClick = () => {
|
||||
onLogicalOperatorChange?.(path)
|
||||
}
|
||||
|
||||
const handleLogicalOperatorMouseEnter = () => {
|
||||
setIsHoveringOperator(true)
|
||||
}
|
||||
|
||||
const handleLogicalOperatorMouseLeave = () => {
|
||||
setIsHoveringOperator(false)
|
||||
handleLogicalOperatorChange(path)
|
||||
}
|
||||
|
||||
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])
|
||||
)
|
||||
return activeInput.type === 'value' && pathsEqual(conditionPath, activeInput.path)
|
||||
}
|
||||
|
||||
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 activeInput.type === 'operator' && pathsEqual(conditionPath, activeInput.path)
|
||||
}
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildPropertyItems({
|
||||
filterProperties,
|
||||
inputValue: (isActive ? groupFreeformValue : localFreeformValue) || '',
|
||||
aiApiUrl,
|
||||
inputValue: (isActive ? freeformText : localFreeformValue) || '',
|
||||
actions,
|
||||
supportsOperators,
|
||||
}),
|
||||
[
|
||||
filterProperties,
|
||||
isActive,
|
||||
groupFreeformValue,
|
||||
localFreeformValue,
|
||||
aiApiUrl,
|
||||
supportsOperators,
|
||||
]
|
||||
[filterProperties, isActive, freeformText, localFreeformValue, actions, supportsOperators]
|
||||
)
|
||||
|
||||
// Determine if this group is the last among its siblings to flex-grow and let input fill
|
||||
const isLastGroupInParent = useMemo(() => {
|
||||
if (path.length === 0) return true
|
||||
const parentPath = path.slice(0, -1)
|
||||
let current: any = rootFilters
|
||||
for (let i = 0; i < parentPath.length; i++) {
|
||||
const idx = parentPath[i]
|
||||
const next = current?.conditions?.[idx]
|
||||
if (!next || !('logicalOperator' in next)) return false
|
||||
current = next
|
||||
}
|
||||
const myIndex = path[path.length - 1]
|
||||
const siblings = current?.conditions ?? []
|
||||
return myIndex === siblings.length - 1
|
||||
}, [path, rootFilters])
|
||||
// Only the root group should expand to fill available space
|
||||
const isRootGroup = path.length === 0
|
||||
|
||||
const {
|
||||
highlightedIndex,
|
||||
@@ -169,9 +103,9 @@ export function FilterGroup({
|
||||
} = useHighlightNavigation(
|
||||
items.length,
|
||||
(index) => {
|
||||
if (items[index]) onSelectMenuItem(items[index])
|
||||
if (items[index]) handleSelectMenuItem(items[index])
|
||||
},
|
||||
onKeyDown
|
||||
handleKeyDown
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,13 +115,19 @@ export function FilterGroup({
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`flex items-center gap-1 rounded ${
|
||||
className={`flex items-stretch gap-0 shrink-0 ${
|
||||
path.length > 0
|
||||
? "before:content-['('] before:text-foreground-muted after:content-[')'] after:text-foreground-muted"
|
||||
: ''
|
||||
} ${isLastGroupInParent ? 'flex-1 min-w-0' : ''}`}
|
||||
} ${isRootGroup ? 'flex-1 min-w-0' : ''} ${variant === 'pill' ? 'py-2' : ''}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${isLastGroupInParent ? 'flex-1 min-w-0' : ''}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch',
|
||||
isRootGroup ? 'flex-1 min-w-0' : '',
|
||||
variant === 'pill' ? 'gap-1' : 'gap-0'
|
||||
)}
|
||||
>
|
||||
{group.conditions.map((condition, index) => {
|
||||
const currentPath = [...path, index]
|
||||
|
||||
@@ -201,63 +141,20 @@ export function FilterGroup({
|
||||
: 'text-foreground-muted'
|
||||
}`}
|
||||
onClick={handleLogicalOperatorClick}
|
||||
onMouseEnter={handleLogicalOperatorMouseEnter}
|
||||
onMouseLeave={handleLogicalOperatorMouseLeave}
|
||||
onMouseEnter={() => setIsHoveringOperator(true)}
|
||||
onMouseLeave={() => setIsHoveringOperator(false)}
|
||||
>
|
||||
{group.logicalOperator}
|
||||
</span>
|
||||
)}
|
||||
{'logicalOperator' in condition ? (
|
||||
<FilterGroup
|
||||
filterProperties={filterProperties}
|
||||
group={condition}
|
||||
path={currentPath}
|
||||
isLoading={isLoading}
|
||||
rootFilters={rootFilters}
|
||||
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}
|
||||
supportsOperators={supportsOperators}
|
||||
onRemove={onRemove}
|
||||
propertyOptionsCache={propertyOptionsCache}
|
||||
loadingOptions={loadingOptions}
|
||||
aiApiUrl={aiApiUrl}
|
||||
onSelectMenuItem={onSelectMenuItem}
|
||||
setActiveInput={setActiveInput}
|
||||
/>
|
||||
<FilterGroup group={condition} path={currentPath} />
|
||||
) : (
|
||||
<FilterCondition
|
||||
id={`filter-${currentPath.join('-')}`}
|
||||
condition={condition}
|
||||
path={currentPath}
|
||||
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}
|
||||
onRemove={() => onRemove(currentPath)}
|
||||
rootFilters={rootFilters}
|
||||
path={currentPath}
|
||||
propertyOptionsCache={propertyOptionsCache}
|
||||
loadingOptions={loadingOptions}
|
||||
aiApiUrl={aiApiUrl}
|
||||
onSelectMenuItem={onSelectMenuItem}
|
||||
setActiveInput={setActiveInput}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -265,33 +162,40 @@ export function FilterGroup({
|
||||
})}
|
||||
<Popover_Shadcn_ open={isActive && !isLoading && items.length > 0}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<Input_Shadcn_
|
||||
ref={freeformInputRef}
|
||||
type="text"
|
||||
value={isActive ? groupFreeformValue : localFreeformValue}
|
||||
onChange={handleFreeformChange}
|
||||
onFocus={() => onGroupFreeformFocus(path)}
|
||||
onBlur={handleFreeformBlur}
|
||||
onKeyDown={handleFreeformKeyDown}
|
||||
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 h-6 ${
|
||||
isLastGroupInParent ? 'w-full flex-1 min-w-0' : ''
|
||||
}`}
|
||||
placeholder={
|
||||
path.length === 0 && group.conditions.length === 0 ? 'Search or filter...' : '+'
|
||||
}
|
||||
disabled={isLoading}
|
||||
style={
|
||||
isLastGroupInParent
|
||||
? { width: '100%', minWidth: 0 }
|
||||
: {
|
||||
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',
|
||||
}
|
||||
}
|
||||
/>
|
||||
{isRootGroup ? (
|
||||
<Input_Shadcn_
|
||||
ref={freeformInputRef}
|
||||
type="text"
|
||||
value={isActive ? freeformText : localFreeformValue}
|
||||
onChange={handleFreeformChange}
|
||||
onFocus={() => handleGroupFreeformFocus(path)}
|
||||
onBlur={handleFreeformBlur}
|
||||
onKeyDown={handleFreeformKeyDown}
|
||||
className="border-none bg-transparent text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full flex-1 h-auto min-w-0 px-2 py-0"
|
||||
placeholder={
|
||||
group.conditions.length === 0 ? 'Search or filter...' : '+ Search or filter...'
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative inline-block">
|
||||
<Input_Shadcn_
|
||||
ref={freeformInputRef}
|
||||
type="text"
|
||||
value={isActive ? freeformText : localFreeformValue}
|
||||
onChange={handleFreeformChange}
|
||||
onFocus={() => handleGroupFreeformFocus(path)}
|
||||
onBlur={handleFreeformBlur}
|
||||
onKeyDown={handleFreeformKeyDown}
|
||||
className="h-full border-none bg-transparent py-0 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 w-full absolute left-0 top-0 px-2"
|
||||
placeholder="+ Add filter"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="invisible whitespace-pre text-xs block">
|
||||
{(isActive ? freeformText : localFreeformValue) || '+'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</PopoverAnchor_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="min-w-[220px] p-0"
|
||||
@@ -303,14 +207,14 @@ export function FilterGroup({
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Node
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
|
||||
onBlur()
|
||||
handleInputBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DefaultCommandList
|
||||
items={items}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSelect={onSelectMenuItem}
|
||||
onSelect={handleSelectMenuItem}
|
||||
includeIcon
|
||||
/>
|
||||
</PopoverContent_Shadcn_>
|
||||
|
||||
@@ -18,36 +18,26 @@ describe('FilterBar Hooks', () => {
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.selectedCommandIndex).toBe(0)
|
||||
expect(result.current.isCommandMenuVisible).toBe(false)
|
||||
expect(result.current.activeInput).toBeNull()
|
||||
expect(result.current.dialogContent).toBeNull()
|
||||
expect(result.current.isDialogOpen).toBe(false)
|
||||
expect(result.current.pendingPath).toBeNull()
|
||||
expect(result.current.hideTimeoutRef.current).toBeNull()
|
||||
expect(result.current.newPathRef.current).toEqual([])
|
||||
})
|
||||
|
||||
it('resets state when resetState is called', () => {
|
||||
it('updates state correctly', () => {
|
||||
const { result } = renderHook(() => useFilterBarState())
|
||||
|
||||
act(() => {
|
||||
result.current.setIsLoading(true)
|
||||
result.current.setError('Test error')
|
||||
result.current.setSelectedCommandIndex(5)
|
||||
result.current.setIsCommandMenuVisible(true)
|
||||
result.current.setActiveInput({ type: 'value', path: [0] })
|
||||
result.current.setIsDialogOpen(true)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.resetState()
|
||||
})
|
||||
|
||||
expect(result.current.isLoading).toBe(true) // Loading state is not reset
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.selectedCommandIndex).toBe(0)
|
||||
expect(result.current.isCommandMenuVisible).toBe(false)
|
||||
expect(result.current.activeInput).toBeNull()
|
||||
expect(result.current.isDialogOpen).toBe(false)
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.error).toBe('Test error')
|
||||
expect(result.current.isCommandMenuVisible).toBe(true)
|
||||
expect(result.current.activeInput).toEqual({ type: 'value', path: [0] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,48 +13,22 @@ export type ActiveInput =
|
||||
export function useFilterBarState() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 [dialogContent, setDialogContent] = useState<React.ReactElement | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [pendingPath, setPendingPath] = useState<number[] | null>(null)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setError(null)
|
||||
setSelectedCommandIndex(0)
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
setDialogContent(null)
|
||||
setIsDialogOpen(false)
|
||||
setPendingPath(null)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
error,
|
||||
setError,
|
||||
selectedCommandIndex,
|
||||
setSelectedCommandIndex,
|
||||
isCommandMenuVisible,
|
||||
setIsCommandMenuVisible,
|
||||
hideTimeoutRef,
|
||||
activeInput,
|
||||
setActiveInput,
|
||||
newPathRef,
|
||||
dialogContent,
|
||||
setDialogContent,
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
pendingPath,
|
||||
setPendingPath,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,10 +99,9 @@ export function useOptionsCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared utilities
|
||||
export function useDeferredBlur(wrapperRef: React.RefObject<HTMLElement>, onBlur: () => void) {
|
||||
return useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
(_e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active && wrapperRef.current && wrapperRef.current.contains(active)) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from './FilterBar'
|
||||
export * from './FilterBarContext'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
export * from './hooks'
|
||||
export * from './useKeyboardNavigation'
|
||||
export * from './useCommandMenu'
|
||||
export * from './useAIFilter'
|
||||
export * from './useCommandHandling'
|
||||
export * from './DefaultCommandList'
|
||||
export * from './menuItems'
|
||||
@@ -1,8 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterGroup, FilterProperty } from './types'
|
||||
import { findConditionByPath, isCustomOptionObject, isFilterOptionObject } from './utils'
|
||||
import { FilterBarAction, FilterGroup, FilterProperty } from './types'
|
||||
import { findConditionByPath, isCustomOptionObject, isFilterOptionObject, isFilterOperatorObject } from './utils'
|
||||
|
||||
export type MenuItem = {
|
||||
value: string
|
||||
@@ -10,12 +8,16 @@ export type MenuItem = {
|
||||
icon?: React.ReactNode
|
||||
isCustom?: boolean
|
||||
customOption?: (props: any) => React.ReactElement
|
||||
isAction?: boolean
|
||||
action?: FilterBarAction
|
||||
actionInputValue?: string
|
||||
}
|
||||
|
||||
export function buildOperatorItems(
|
||||
activeInput: Extract<ActiveInput, { type: 'operator' }> | null,
|
||||
activeFilters: FilterGroup,
|
||||
filterProperties: FilterProperty[]
|
||||
filterProperties: FilterProperty[],
|
||||
hasTypedSinceFocus: boolean = true
|
||||
): MenuItem[] {
|
||||
if (!activeInput) return []
|
||||
const condition = findConditionByPath(activeFilters, activeInput.path)
|
||||
@@ -23,18 +25,30 @@ export function buildOperatorItems(
|
||||
const operatorValue = condition?.operator?.toUpperCase() || ''
|
||||
const availableOperators = property?.operators || ['=']
|
||||
|
||||
// Only filter if user has typed since focusing
|
||||
const shouldFilter = hasTypedSinceFocus && operatorValue.length > 0
|
||||
|
||||
return availableOperators
|
||||
.filter((op) => op.toUpperCase().includes(operatorValue))
|
||||
.map((op) => ({ value: op, label: op }))
|
||||
.filter((op) => {
|
||||
if (!shouldFilter) return true
|
||||
const searchText = isFilterOperatorObject(op) ? op.value : op
|
||||
return searchText.toUpperCase().includes(operatorValue)
|
||||
})
|
||||
.map((op) => {
|
||||
if (isFilterOperatorObject(op)) {
|
||||
return { value: op.value, label: op.label }
|
||||
}
|
||||
return { value: op, label: op }
|
||||
})
|
||||
}
|
||||
|
||||
export function buildPropertyItems(params: {
|
||||
filterProperties: FilterProperty[]
|
||||
inputValue: string
|
||||
aiApiUrl?: string
|
||||
supportsOperators?: boolean
|
||||
actions?: FilterBarAction[]
|
||||
}): MenuItem[] {
|
||||
const { filterProperties, inputValue, aiApiUrl, supportsOperators } = params
|
||||
const { filterProperties, inputValue, supportsOperators, actions } = params
|
||||
const items: MenuItem[] = []
|
||||
|
||||
items.push(
|
||||
@@ -47,11 +61,17 @@ export function buildPropertyItems(params: {
|
||||
items.push({ value: 'group', label: 'New Group' })
|
||||
}
|
||||
|
||||
if (inputValue.trim().length > 0 && aiApiUrl) {
|
||||
items.push({
|
||||
value: 'ai-filter',
|
||||
label: 'Filter by AI',
|
||||
icon: React.createElement(Sparkles, { className: 'mr-2 h-4 w-4', strokeWidth: 1.25 }),
|
||||
const trimmedInput = inputValue.trim()
|
||||
if (actions && trimmedInput.length > 0) {
|
||||
actions.forEach((action) => {
|
||||
items.push({
|
||||
value: action.value,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
isAction: true,
|
||||
action,
|
||||
actionInputValue: trimmedInput,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,7 +84,8 @@ export function buildValueItems(
|
||||
filterProperties: FilterProperty[],
|
||||
propertyOptionsCache: Record<string, { options: any[]; searchValue: string }>,
|
||||
loadingOptions: Record<string, boolean>,
|
||||
inputValue: string
|
||||
inputValue: string,
|
||||
hasTypedSinceFocus: boolean = true
|
||||
): MenuItem[] {
|
||||
if (!activeInput) return []
|
||||
const activeCondition = findConditionByPath(activeFilters, activeInput.path)
|
||||
@@ -83,7 +104,7 @@ export function buildValueItems(
|
||||
} else if (loadingOptions[property.name]) {
|
||||
items.push({ value: 'loading', label: 'Loading options...' })
|
||||
} else if (Array.isArray(property.options)) {
|
||||
items.push(...getArrayOptionItems(property.options, inputValue))
|
||||
items.push(...getArrayOptionItems(property.options, inputValue, hasTypedSinceFocus))
|
||||
} else if (propertyOptionsCache[property.name]) {
|
||||
items.push(...getCachedOptionItems(propertyOptionsCache[property.name].options))
|
||||
}
|
||||
@@ -91,19 +112,28 @@ export function buildValueItems(
|
||||
return items
|
||||
}
|
||||
|
||||
function getArrayOptionItems(options: any[], inputValue: string): MenuItem[] {
|
||||
function getArrayOptionItems(
|
||||
options: any[],
|
||||
inputValue: string,
|
||||
hasTypedSinceFocus: boolean
|
||||
): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
const normalizedInput = inputValue.toLowerCase()
|
||||
|
||||
// Only filter if user has typed since focusing
|
||||
const shouldFilter = hasTypedSinceFocus && inputValue.length > 0
|
||||
|
||||
for (const option of options) {
|
||||
if (typeof option === 'string') {
|
||||
if (option.toLowerCase().includes(inputValue.toLowerCase())) {
|
||||
if (!shouldFilter || option.toLowerCase().includes(normalizedInput)) {
|
||||
items.push({ value: option, label: option })
|
||||
}
|
||||
} else if (isFilterOptionObject(option)) {
|
||||
if (option.label.toLowerCase().includes(inputValue.toLowerCase())) {
|
||||
if (!shouldFilter || option.label.toLowerCase().includes(normalizedInput)) {
|
||||
items.push({ value: option.value, label: option.label })
|
||||
}
|
||||
} else if (isCustomOptionObject(option)) {
|
||||
if (option.label?.toLowerCase().includes(inputValue.toLowerCase()) ?? true) {
|
||||
if (!shouldFilter || (option.label?.toLowerCase().includes(normalizedInput) ?? true)) {
|
||||
items.push({
|
||||
value: 'custom',
|
||||
label: option.label || 'Custom...',
|
||||
|
||||
@@ -21,12 +21,19 @@ export type FilterOption = string | FilterOptionObject | CustomOptionObject
|
||||
export type AsyncOptionsFunction = (search?: string) => Promise<(string | FilterOptionObject)[]>
|
||||
export type SyncOptionsFunction = (search?: string) => (string | FilterOptionObject)[]
|
||||
|
||||
export type FilterOperatorObject = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type FilterOperator = string | FilterOperatorObject
|
||||
|
||||
export type FilterProperty = {
|
||||
label: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'date' | 'boolean'
|
||||
options?: FilterOption[] | AsyncOptionsFunction | SyncOptionsFunction | CustomOptionObject
|
||||
operators?: string[]
|
||||
operators?: FilterOperator[]
|
||||
}
|
||||
|
||||
export type FilterCondition = {
|
||||
@@ -43,3 +50,23 @@ export type FilterGroup = {
|
||||
export function isGroup(condition: FilterCondition | FilterGroup): condition is FilterGroup {
|
||||
return 'logicalOperator' in condition
|
||||
}
|
||||
|
||||
export type FilterBarAction = {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onSelect: (
|
||||
inputValue: string,
|
||||
context: { path: number[]; activeFilters: FilterGroup }
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
export type SerializableFilterProperty = Pick<FilterProperty, 'label' | 'name' | 'type' | 'operators'> & {
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export type AIFilterRequestPayload = {
|
||||
prompt: string
|
||||
filterProperties: SerializableFilterProperty[]
|
||||
currentPath: number[]
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterProperty, FilterGroup } from './types'
|
||||
import {
|
||||
findGroupByPath,
|
||||
addFilterToGroup,
|
||||
addGroupToGroup,
|
||||
isCustomOptionObject,
|
||||
updateNestedValue,
|
||||
removeFromGroup,
|
||||
} from './utils'
|
||||
|
||||
import { MenuItem } from './menuItems'
|
||||
import { FilterGroup, FilterProperty } from './types'
|
||||
import { addFilterToGroup, addGroupToGroup, findGroupByPath, isCustomOptionObject } from './utils'
|
||||
|
||||
export function useCommandHandling({
|
||||
activeInput,
|
||||
@@ -23,7 +15,7 @@ export function useCommandHandling({
|
||||
handleInputChange,
|
||||
handleOperatorChange,
|
||||
newPathRef,
|
||||
handleAIFilter,
|
||||
setIsCommandMenuVisible,
|
||||
}: {
|
||||
activeInput: ActiveInput
|
||||
setActiveInput: (input: ActiveInput) => void
|
||||
@@ -35,21 +27,105 @@ export function useCommandHandling({
|
||||
handleInputChange: (path: number[], value: string) => void
|
||||
handleOperatorChange: (path: number[], value: string) => void
|
||||
newPathRef: React.MutableRefObject<number[]>
|
||||
handleAIFilter: () => void
|
||||
setIsCommandMenuVisible: (visible: boolean) => void
|
||||
}) {
|
||||
const removeFilterByPath = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = removeFromGroup(activeFilters, path)
|
||||
const handleGroupCommand = useCallback(() => {
|
||||
if (activeInput && activeInput.type === 'group') {
|
||||
const currentPath = activeInput.path
|
||||
const group = findGroupByPath(activeFilters, currentPath)
|
||||
if (!group) return
|
||||
|
||||
const updatedFilters = addGroupToGroup(activeFilters, currentPath)
|
||||
onFilterChange(updatedFilters)
|
||||
newPathRef.current = [...currentPath, group.conditions.length]
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'group', path: newPathRef.current })
|
||||
}, 0)
|
||||
onFreeformTextChange('')
|
||||
}
|
||||
}, [activeInput, activeFilters, onFilterChange, newPathRef, setActiveInput, onFreeformTextChange])
|
||||
|
||||
const handleValueCommand = useCallback(
|
||||
(item: MenuItem) => {
|
||||
if (!activeInput || activeInput.type !== 'value') return
|
||||
|
||||
const path = activeInput.path
|
||||
handleInputChange(path, item.value)
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'group', path: path.slice(0, -1) })
|
||||
}, 0)
|
||||
},
|
||||
[activeFilters, onFilterChange]
|
||||
[activeInput, handleInputChange, setActiveInput]
|
||||
)
|
||||
|
||||
const handleOperatorCommand = useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!activeInput || activeInput.type !== 'operator') return
|
||||
|
||||
const path = activeInput.path
|
||||
handleOperatorChange(path, selectedValue)
|
||||
setActiveInput(null)
|
||||
},
|
||||
[activeInput, handleOperatorChange, setActiveInput]
|
||||
)
|
||||
|
||||
const handlePropertySelection = useCallback(
|
||||
(selectedProperty: FilterProperty, currentPath: number[], group: FilterGroup) => {
|
||||
const updatedFilters = addFilterToGroup(activeFilters, currentPath, selectedProperty)
|
||||
onFilterChange(updatedFilters)
|
||||
const newPath = [...currentPath, group.conditions.length]
|
||||
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'value', path: newPath })
|
||||
}, 0)
|
||||
},
|
||||
[activeFilters, onFilterChange, setActiveInput]
|
||||
)
|
||||
|
||||
const handleGroupPropertyCommand = useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!activeInput || activeInput.type !== 'group') return
|
||||
|
||||
const selectedProperty = filterProperties.find((p) => p.name === selectedValue)
|
||||
if (!selectedProperty) {
|
||||
console.error(`Invalid property: ${selectedValue}`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentPath = activeInput.path
|
||||
const group = findGroupByPath(activeFilters, currentPath)
|
||||
if (!group) return
|
||||
|
||||
if (
|
||||
selectedProperty.options &&
|
||||
!Array.isArray(selectedProperty.options) &&
|
||||
isCustomOptionObject(selectedProperty.options)
|
||||
) {
|
||||
handlePropertySelection(selectedProperty, currentPath, group)
|
||||
} else {
|
||||
handlePropertySelection(selectedProperty, currentPath, group)
|
||||
}
|
||||
onFreeformTextChange('')
|
||||
},
|
||||
[activeInput, filterProperties, activeFilters, onFreeformTextChange, handlePropertySelection]
|
||||
)
|
||||
|
||||
const handleItemSelect = useCallback(
|
||||
(item: MenuItem) => {
|
||||
const selectedValue = item.value
|
||||
if (item.value === 'ai-filter') {
|
||||
handleAIFilter()
|
||||
if (item.isAction && item.action) {
|
||||
const path = activeInput?.type === 'group' ? activeInput.path : []
|
||||
Promise.resolve(
|
||||
item.action.onSelect(item.actionInputValue ?? freeformText ?? '', {
|
||||
path,
|
||||
activeFilters,
|
||||
})
|
||||
)
|
||||
.catch((error) => console.error('FilterBar action failed', error))
|
||||
.finally(() => {
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,114 +145,16 @@ export function useCommandHandling({
|
||||
[
|
||||
activeInput,
|
||||
activeFilters,
|
||||
filterProperties,
|
||||
freeformText,
|
||||
handleAIFilter,
|
||||
handleInputChange,
|
||||
handleOperatorChange,
|
||||
setActiveInput,
|
||||
handleGroupCommand,
|
||||
handleValueCommand,
|
||||
handleOperatorCommand,
|
||||
handleGroupPropertyCommand,
|
||||
setIsCommandMenuVisible,
|
||||
]
|
||||
)
|
||||
|
||||
const handleGroupCommand = useCallback(() => {
|
||||
if (activeInput && activeInput.type === 'group') {
|
||||
const currentPath = activeInput.path
|
||||
const group = findGroupByPath(activeFilters, currentPath)
|
||||
if (!group) return
|
||||
|
||||
const updatedFilters = addGroupToGroup(activeFilters, currentPath)
|
||||
onFilterChange(updatedFilters)
|
||||
newPathRef.current = [...currentPath, group.conditions.length]
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'group', path: newPathRef.current })
|
||||
}, 0)
|
||||
onFreeformTextChange('')
|
||||
}
|
||||
}, [activeInput, activeFilters, onFilterChange, setActiveInput, onFreeformTextChange])
|
||||
|
||||
const handleValueCommand = useCallback(
|
||||
(item: MenuItem) => {
|
||||
if (!activeInput || activeInput.type !== 'value') return
|
||||
|
||||
const path = activeInput.path
|
||||
|
||||
// Custom value handled inline in popover; do nothing here
|
||||
|
||||
// Handle regular options
|
||||
handleInputChange(path, item.value)
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'group', path: path.slice(0, -1) })
|
||||
}, 0)
|
||||
},
|
||||
[activeInput, handleInputChange, setActiveInput, removeFilterByPath]
|
||||
)
|
||||
|
||||
const handleOperatorCommand = useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!activeInput || activeInput.type !== 'operator') return
|
||||
|
||||
const path = activeInput.path
|
||||
handleOperatorChange(path, selectedValue)
|
||||
setActiveInput(null)
|
||||
},
|
||||
[activeInput, handleOperatorChange, setActiveInput]
|
||||
)
|
||||
|
||||
const handleGroupPropertyCommand = useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!activeInput || activeInput.type !== 'group') return
|
||||
|
||||
const selectedProperty = filterProperties.find((p) => p.name === selectedValue)
|
||||
if (!selectedProperty) {
|
||||
console.error(`Invalid property: ${selectedValue}`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentPath = activeInput.path
|
||||
const group = findGroupByPath(activeFilters, currentPath)
|
||||
if (!group) return
|
||||
|
||||
// Check if the property itself is a custom option object
|
||||
if (
|
||||
selectedProperty.options &&
|
||||
!Array.isArray(selectedProperty.options) &&
|
||||
isCustomOptionObject(selectedProperty.options)
|
||||
) {
|
||||
handleCustomPropertySelection(selectedProperty, currentPath, group)
|
||||
} else {
|
||||
handleNormalPropertySelection(selectedProperty, currentPath, group)
|
||||
}
|
||||
onFreeformTextChange('')
|
||||
},
|
||||
[activeInput, filterProperties, activeFilters, onFilterChange, onFreeformTextChange]
|
||||
)
|
||||
|
||||
const handleCustomPropertySelection = useCallback(
|
||||
(selectedProperty: FilterProperty, currentPath: number[], group: FilterGroup) => {
|
||||
const updatedFilters = addFilterToGroup(activeFilters, currentPath, selectedProperty)
|
||||
onFilterChange(updatedFilters)
|
||||
const newPath = [...currentPath, group.conditions.length]
|
||||
|
||||
// Focus the newly added condition's value input so its popover opens immediately
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'value', path: newPath })
|
||||
}, 0)
|
||||
},
|
||||
[activeFilters, onFilterChange, setActiveInput, removeFilterByPath]
|
||||
)
|
||||
|
||||
const handleNormalPropertySelection = useCallback(
|
||||
(selectedProperty: FilterProperty, currentPath: number[], group: FilterGroup) => {
|
||||
const updatedFilters = addFilterToGroup(activeFilters, currentPath, selectedProperty)
|
||||
onFilterChange(updatedFilters)
|
||||
const newPath = [...currentPath, group.conditions.length]
|
||||
|
||||
setTimeout(() => {
|
||||
setActiveInput({ type: 'value', path: newPath })
|
||||
}, 0)
|
||||
},
|
||||
[activeFilters, onFilterChange, setActiveInput]
|
||||
)
|
||||
|
||||
return {
|
||||
handleItemSelect,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import * as React from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterProperty, FilterGroup } from './types'
|
||||
import { findConditionByPath, isCustomOptionObject, isFilterOptionObject } from './utils'
|
||||
import {
|
||||
findConditionByPath,
|
||||
isCustomOptionObject,
|
||||
isFilterOptionObject,
|
||||
isFilterOperatorObject,
|
||||
} from './utils'
|
||||
// Deprecated soon; kept for compatibility during refactor
|
||||
|
||||
export type CommandItem = {
|
||||
@@ -97,8 +102,16 @@ function getOperatorItems(
|
||||
const availableOperators = property?.operators || ['=']
|
||||
|
||||
return availableOperators
|
||||
.filter((op) => op.toUpperCase().includes(operatorValue))
|
||||
.map((op) => ({ value: op, label: op }))
|
||||
.filter((op) => {
|
||||
const searchText = isFilterOperatorObject(op) ? op.value : op
|
||||
return searchText.toUpperCase().includes(operatorValue)
|
||||
})
|
||||
.map((op) => {
|
||||
if (isFilterOperatorObject(op)) {
|
||||
return { value: op.value, label: op.label }
|
||||
}
|
||||
return { value: op, label: op }
|
||||
})
|
||||
}
|
||||
|
||||
function getInputValue(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { KeyboardEvent, useCallback } from 'react'
|
||||
import { KeyboardEvent, useCallback } from 'react'
|
||||
import { ActiveInput } from './hooks'
|
||||
import { FilterGroup } from './types'
|
||||
import { findGroupByPath, findConditionByPath, removeFromGroup } from './utils'
|
||||
@@ -14,7 +14,7 @@ export function useKeyboardNavigation({
|
||||
activeFilters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
}) {
|
||||
const removeFilterByPath = useCallback(
|
||||
const removeByPath = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = removeFromGroup(activeFilters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
@@ -22,12 +22,231 @@ export function useKeyboardNavigation({
|
||||
[activeFilters, onFilterChange]
|
||||
)
|
||||
|
||||
const removeGroupByPath = useCallback(
|
||||
(path: number[]) => {
|
||||
const updatedFilters = removeFromGroup(activeFilters, path)
|
||||
onFilterChange(updatedFilters)
|
||||
const findFirstConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
const firstCondition = group.conditions[0]
|
||||
if (!('logicalOperator' in firstCondition)) {
|
||||
return [...groupPath, 0]
|
||||
}
|
||||
return findFirstConditionInGroup([...groupPath, 0])
|
||||
},
|
||||
[activeFilters, onFilterChange]
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findLastConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
const lastCondition = group.conditions[group.conditions.length - 1]
|
||||
const lastIndex = group.conditions.length - 1
|
||||
if (!('logicalOperator' in lastCondition)) {
|
||||
return [...groupPath, lastIndex]
|
||||
}
|
||||
return findLastConditionInGroup([...groupPath, lastIndex])
|
||||
},
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findPreviousCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
const groupPath = currentPath.slice(0, -1)
|
||||
const conditionIndex = currentPath[currentPath.length - 1]
|
||||
|
||||
if (conditionIndex > 0) {
|
||||
const prevPath = [...groupPath, conditionIndex - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
const prevCondition = group?.conditions[conditionIndex - 1]
|
||||
if (prevCondition && !('logicalOperator' in prevCondition)) {
|
||||
return prevPath
|
||||
}
|
||||
if (prevCondition && 'logicalOperator' in prevCondition) {
|
||||
return findLastConditionInGroup(prevPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (groupPath.length > 0) {
|
||||
return findPreviousCondition(groupPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findLastConditionInGroup]
|
||||
)
|
||||
|
||||
const findNextCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
const groupPath = currentPath.slice(0, -1)
|
||||
const conditionIndex = currentPath[currentPath.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextPath = [...groupPath, conditionIndex + 1]
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if (!('logicalOperator' in nextCondition)) {
|
||||
return nextPath
|
||||
}
|
||||
return findFirstConditionInGroup(nextPath)
|
||||
}
|
||||
|
||||
if (groupPath.length > 0) {
|
||||
return findNextCondition(groupPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findFirstConditionInGroup]
|
||||
)
|
||||
|
||||
const findPreviousConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (group && group.conditions.length > 0) {
|
||||
return findLastConditionInGroup(groupPath)
|
||||
}
|
||||
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
if (groupIndex > 0) {
|
||||
const prevSiblingPath = [...parentPath, groupIndex - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
const prevSibling = parentGroup?.conditions[groupIndex - 1]
|
||||
if (prevSibling) {
|
||||
if ('logicalOperator' in prevSibling) {
|
||||
return findLastConditionInGroup(prevSiblingPath)
|
||||
} else {
|
||||
return prevSiblingPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return findPreviousConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findLastConditionInGroup]
|
||||
)
|
||||
|
||||
const findNextConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
|
||||
if (parentGroup && groupIndex < parentGroup.conditions.length - 1) {
|
||||
const nextSiblingPath = [...parentPath, groupIndex + 1]
|
||||
const nextSibling = parentGroup.conditions[groupIndex + 1]
|
||||
if ('logicalOperator' in nextSibling) {
|
||||
return findFirstConditionInGroup(nextSiblingPath)
|
||||
} else {
|
||||
return nextSiblingPath
|
||||
}
|
||||
}
|
||||
return findNextConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findFirstConditionInGroup]
|
||||
)
|
||||
|
||||
const handleBackspace = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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) {
|
||||
const lastConditionPath = [...activeInput.path, group.conditions.length - 1]
|
||||
removeByPath(lastConditionPath)
|
||||
setActiveInput({ type: 'group', path: activeInput.path })
|
||||
} else if (group && group.conditions.length === 0 && activeInput.path.length > 0) {
|
||||
// Only remove nested empty groups, not the root group
|
||||
removeByPath(activeInput.path)
|
||||
setActiveInput({
|
||||
type: 'group',
|
||||
path: activeInput.path.slice(0, -1),
|
||||
})
|
||||
}
|
||||
// For root group with no conditions, do nothing - keep menu open
|
||||
} else if (activeInput?.type === 'value' && isEmpty) {
|
||||
const condition = findConditionByPath(activeFilters, activeInput.path)
|
||||
if (condition && !condition.value) {
|
||||
e.preventDefault()
|
||||
removeByPath(activeInput.path)
|
||||
setActiveInput({
|
||||
type: 'group',
|
||||
path: activeInput.path.slice(0, -1),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, activeFilters, removeByPath, setActiveInput]
|
||||
)
|
||||
|
||||
const handleArrowLeft = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === 0) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
const prevPath = findPreviousCondition(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
const prevPath = findPreviousConditionFromGroup(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, findPreviousCondition, findPreviousConditionFromGroup, setActiveInput]
|
||||
)
|
||||
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === inputElement.value.length) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
const groupPath = activeInput.path.slice(0, -1)
|
||||
const conditionIndex = activeInput.path[activeInput.path.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if ('logicalOperator' in nextCondition) {
|
||||
const nextPath = findFirstConditionInGroup([...groupPath, conditionIndex + 1])
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
} else {
|
||||
setActiveInput({ type: 'value', path: [...groupPath, conditionIndex + 1] })
|
||||
}
|
||||
} else {
|
||||
setActiveInput({ type: 'group', path: groupPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
const nextPath = findNextConditionFromGroup(activeInput.path)
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, activeFilters, findFirstConditionInGroup, findNextConditionFromGroup, setActiveInput]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -45,272 +264,7 @@ export function useKeyboardNavigation({
|
||||
setActiveInput(null)
|
||||
}
|
||||
},
|
||||
[activeInput, activeFilters]
|
||||
)
|
||||
|
||||
const handleBackspace = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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) {
|
||||
const lastConditionPath = [...activeInput.path, group.conditions.length - 1]
|
||||
removeFilterByPath(lastConditionPath)
|
||||
setActiveInput({ type: 'group', path: activeInput.path })
|
||||
} else if (group && group.conditions.length === 0) {
|
||||
removeGroupByPath(activeInput.path)
|
||||
|
||||
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)
|
||||
setActiveInput({
|
||||
type: 'group',
|
||||
path: activeInput.path.slice(0, -1),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, activeFilters, removeFilterByPath, removeGroupByPath, setActiveInput]
|
||||
)
|
||||
|
||||
const findPreviousCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
const [groupPath, conditionIndex] = [
|
||||
currentPath.slice(0, -1),
|
||||
currentPath[currentPath.length - 1],
|
||||
]
|
||||
|
||||
// Try previous condition in same group
|
||||
if (conditionIndex > 0) {
|
||||
const prevPath = [...groupPath, conditionIndex - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
const prevCondition = group?.conditions[conditionIndex - 1]
|
||||
// If previous is a condition (not a group), return its path
|
||||
if (prevCondition && !('logicalOperator' in prevCondition)) {
|
||||
return prevPath
|
||||
}
|
||||
// If previous is a group, find its last condition recursively
|
||||
if (prevCondition && 'logicalOperator' in prevCondition) {
|
||||
return findLastConditionInGroup(prevPath)
|
||||
}
|
||||
}
|
||||
|
||||
// No previous condition in this group, go up to parent
|
||||
if (groupPath.length > 0) {
|
||||
return findPreviousCondition(groupPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findNextCondition = useCallback(
|
||||
(currentPath: number[]): number[] | null => {
|
||||
const [groupPath, conditionIndex] = [
|
||||
currentPath.slice(0, -1),
|
||||
currentPath[currentPath.length - 1],
|
||||
]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
|
||||
// Try next condition in same group
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
const nextPath = [...groupPath, conditionIndex + 1]
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
// If next is a condition, return its path
|
||||
if (!('logicalOperator' in nextCondition)) {
|
||||
return nextPath
|
||||
}
|
||||
// If next is a group, find its first condition recursively
|
||||
return findFirstConditionInGroup(nextPath)
|
||||
}
|
||||
|
||||
// No next condition in this group, go up to parent and find next
|
||||
if (groupPath.length > 0) {
|
||||
return findNextCondition(groupPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findFirstConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
const firstCondition = group.conditions[0]
|
||||
if (!('logicalOperator' in firstCondition)) {
|
||||
return [...groupPath, 0]
|
||||
}
|
||||
// First item is a group, recurse
|
||||
return findFirstConditionInGroup([...groupPath, 0])
|
||||
},
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findLastConditionInGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (!group || group.conditions.length === 0) return null
|
||||
|
||||
const lastCondition = group.conditions[group.conditions.length - 1]
|
||||
const lastIndex = group.conditions.length - 1
|
||||
if (!('logicalOperator' in lastCondition)) {
|
||||
return [...groupPath, lastIndex]
|
||||
}
|
||||
// Last item is a group, recurse
|
||||
return findLastConditionInGroup([...groupPath, lastIndex])
|
||||
},
|
||||
[activeFilters]
|
||||
)
|
||||
|
||||
const findPreviousConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
// If this group has conditions, find the last one
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
if (group && group.conditions.length > 0) {
|
||||
return findLastConditionInGroup(groupPath)
|
||||
}
|
||||
|
||||
// No conditions in this group, find previous sibling or parent
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
if (groupIndex > 0) {
|
||||
// Find last condition in previous sibling
|
||||
const prevSiblingPath = [...parentPath, groupIndex - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
const prevSibling = parentGroup?.conditions[groupIndex - 1]
|
||||
if (prevSibling) {
|
||||
if ('logicalOperator' in prevSibling) {
|
||||
return findLastConditionInGroup(prevSiblingPath)
|
||||
} else {
|
||||
return prevSiblingPath
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look at parent group
|
||||
return findPreviousConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findLastConditionInGroup]
|
||||
)
|
||||
|
||||
const findNextConditionFromGroup = useCallback(
|
||||
(groupPath: number[]): number[] | null => {
|
||||
// Find next sibling or dive into nested groups
|
||||
if (groupPath.length > 0) {
|
||||
const parentPath = groupPath.slice(0, -1)
|
||||
const groupIndex = groupPath[groupPath.length - 1]
|
||||
const parentGroup = findGroupByPath(activeFilters, parentPath)
|
||||
|
||||
if (parentGroup && groupIndex < parentGroup.conditions.length - 1) {
|
||||
// Find first condition in next sibling
|
||||
const nextSiblingPath = [...parentPath, groupIndex + 1]
|
||||
const nextSibling = parentGroup.conditions[groupIndex + 1]
|
||||
if ('logicalOperator' in nextSibling) {
|
||||
return findFirstConditionInGroup(nextSiblingPath)
|
||||
} else {
|
||||
return nextSiblingPath
|
||||
}
|
||||
}
|
||||
// Look at parent group
|
||||
return findNextConditionFromGroup(parentPath)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[activeFilters, findFirstConditionInGroup]
|
||||
)
|
||||
|
||||
const handleArrowLeft = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === 0) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
const prevPath = findPreviousCondition(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
// From freeform input, find the last condition in the previous group/condition
|
||||
const prevPath = findPreviousConditionFromGroup(activeInput.path)
|
||||
if (prevPath) {
|
||||
setActiveInput({ type: 'value', path: prevPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeInput, findPreviousCondition, findPreviousConditionFromGroup, setActiveInput]
|
||||
)
|
||||
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
if (inputElement.selectionStart === inputElement.value.length) {
|
||||
e.preventDefault()
|
||||
if (activeInput?.type === 'value') {
|
||||
// Check if there's a next condition in the same group first
|
||||
const groupPath = activeInput.path.slice(0, -1)
|
||||
const conditionIndex = activeInput.path[activeInput.path.length - 1]
|
||||
const group = findGroupByPath(activeFilters, groupPath)
|
||||
|
||||
if (group && conditionIndex < group.conditions.length - 1) {
|
||||
// There's a next condition, navigate to it
|
||||
const nextCondition = group.conditions[conditionIndex + 1]
|
||||
if ('logicalOperator' in nextCondition) {
|
||||
// Next is a group, find its first condition
|
||||
const nextPath = findFirstConditionInGroup([...groupPath, conditionIndex + 1])
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
} else {
|
||||
// Next is a condition
|
||||
setActiveInput({ type: 'value', path: [...groupPath, conditionIndex + 1] })
|
||||
}
|
||||
} else {
|
||||
// No next condition in this group, move to group's freeform input
|
||||
setActiveInput({ type: 'group', path: groupPath })
|
||||
}
|
||||
} else if (activeInput?.type === 'group') {
|
||||
// From freeform input, find what's to the right of this group
|
||||
const nextPath = findNextConditionFromGroup(activeInput.path)
|
||||
if (nextPath) {
|
||||
setActiveInput({ type: 'value', path: nextPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeInput,
|
||||
activeFilters,
|
||||
findGroupByPath,
|
||||
findFirstConditionInGroup,
|
||||
findNextConditionFromGroup,
|
||||
setActiveInput,
|
||||
]
|
||||
[activeInput, handleBackspace, handleArrowLeft, handleArrowRight, setActiveInput]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
FilterProperty,
|
||||
CustomOptionObject,
|
||||
FilterOptionObject,
|
||||
FilterOperatorObject,
|
||||
AsyncOptionsFunction,
|
||||
SyncOptionsFunction,
|
||||
isGroup,
|
||||
} from './types'
|
||||
|
||||
export function pathsEqual(a: number[], b: number[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
||||
export function findGroupByPath(group: FilterGroup, path: number[]): FilterGroup | null {
|
||||
if (path.length === 0) return group
|
||||
|
||||
@@ -53,6 +58,10 @@ export function isFilterOptionObject(option: any): option is FilterOptionObject
|
||||
return typeof option === 'object' && option !== null && 'value' in option && 'label' in option
|
||||
}
|
||||
|
||||
export function isFilterOperatorObject(operator: any): operator is FilterOperatorObject {
|
||||
return typeof operator === 'object' && operator !== null && 'value' in operator && 'label' in operator
|
||||
}
|
||||
|
||||
export function isAsyncOptionsFunction(
|
||||
options: FilterProperty['options']
|
||||
): options is AsyncOptionsFunction {
|
||||
@@ -122,11 +131,14 @@ export function addFilterToGroup(
|
||||
property: FilterProperty
|
||||
): FilterGroup {
|
||||
if (path.length === 0) {
|
||||
const firstOperator = property.operators?.[0] || '='
|
||||
const operatorValue = isFilterOperatorObject(firstOperator) ? firstOperator.value : firstOperator
|
||||
|
||||
return {
|
||||
...group,
|
||||
conditions: [
|
||||
...group.conditions,
|
||||
{ propertyName: property.name, value: '', operator: property.operators?.[0] || '=' },
|
||||
{ propertyName: property.name, value: '', operator: operatorValue },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user