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:
Saxon Fletcher authored and GitHub committed 2026-01-06 08:04:28 +10:00
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: ['=', '!=', '>', '<', '>=', '<='],
},
]
+7 -2
View File
@@ -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,
})
}
+265
View File
@@ -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: [] })
})
})
+124
View File
@@ -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 : ['=']
}
+114
View File
@@ -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
+1
View File
@@ -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',
+4 -8
View File
@@ -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"
+93 -226
View File
@@ -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>
+103 -199
View File
@@ -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] })
})
})
+1 -28
View File
@@ -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)) {
+2 -2
View File
@@ -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'
+50 -20
View File
@@ -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...',
+28 -1
View File
@@ -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 {
+13 -1
View File
@@ -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 },
],
}
}