From d2f4e808e57c8e97d66ae2663a8108cb0eadf72c Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 6 Jan 2026 08:04:28 +1000 Subject: [PATCH] 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 --- .../content/docs/fragments/filter-bar.mdx | 156 ++---- .../default/example/filter-bar-demo.tsx | 48 +- apps/studio/components/grid/SupabaseGrid.tsx | 9 +- .../grid/components/header/HeaderNew.tsx | 504 ++++++++++++++++++ .../header/filter/FilterPopoverNew.tsx | 211 ++++++++ .../header/filter/FilterPopoverNew.utils.ts | 99 ++++ .../Settings/Logs/LogsPreviewer.tsx | 15 +- apps/studio/data/ai/sql-filter-mutation.ts | 61 +++ apps/studio/lib/api/filterHelpers.test.ts | 265 +++++++++ apps/studio/lib/api/filterHelpers.ts | 124 +++++ apps/studio/pages/api/ai/sql/filter-v1.ts | 114 ++++ apps/studio/proxy.ts | 1 + packages/ui-patterns/package.json | 12 +- .../ui-patterns/src/FilterBar/FilterBar.tsx | 319 ++++------- .../src/FilterBar/FilterBarContext.tsx | 318 +++++++++++ .../src/FilterBar/FilterCondition.tsx | 253 +++++---- .../ui-patterns/src/FilterBar/FilterGroup.tsx | 302 ++++------- .../ui-patterns/src/FilterBar/hooks.test.ts | 24 +- packages/ui-patterns/src/FilterBar/hooks.ts | 29 +- packages/ui-patterns/src/FilterBar/index.ts | 4 +- .../ui-patterns/src/FilterBar/menuItems.ts | 70 ++- packages/ui-patterns/src/FilterBar/types.ts | 29 +- .../src/FilterBar/useCommandHandling.ts | 222 ++++---- .../src/FilterBar/useCommandMenu.ts | 19 +- .../src/FilterBar/useKeyboardNavigation.ts | 500 ++++++++--------- packages/ui-patterns/src/FilterBar/utils.ts | 14 +- 26 files changed, 2547 insertions(+), 1175 deletions(-) create mode 100644 apps/studio/components/grid/components/header/HeaderNew.tsx create mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx create mode 100644 apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts create mode 100644 apps/studio/data/ai/sql-filter-mutation.ts create mode 100644 apps/studio/lib/api/filterHelpers.test.ts create mode 100644 apps/studio/lib/api/filterHelpers.ts create mode 100644 apps/studio/pages/api/ai/sql/filter-v1.ts create mode 100644 packages/ui-patterns/src/FilterBar/FilterBarContext.tsx diff --git a/apps/design-system/content/docs/fragments/filter-bar.mdx b/apps/design-system/content/docs/fragments/filter-bar.mdx index fed27c7219d..34cbf1ac0e3 100644 --- a/apps/design-system/content/docs/fragments/filter-bar.mdx +++ b/apps/design-system/content/docs/fragments/filter-bar.mdx @@ -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 | FilterGroupType> -} - -const FilterGroup: z.ZodType = z.lazy(() => - z.object({ - logicalOperator: z.enum(['AND', 'OR']), - conditions: z.array(z.union([FilterCondition, FilterGroup])), - }) -) - -export async function POST(req: Request) { - const { prompt, filterProperties } = await req.json() - const filterPropertiesString = JSON.stringify(filterProperties) - - try { - const { object } = await generateObject({ - model: openai('gpt-4-mini'), - schema: FilterGroup, - prompt: `Generate a filter group based on the following prompt: "${prompt}". - Use only these filter properties: ${filterPropertiesString}. - Each property has its own set of valid operators defined in the operators field. - Return a filter group with a logical operator ('AND'/'OR') and an array of conditions. - Each condition can be either a filter condition or another filter group. - Filter conditions should have the structure: { propertyName: string, value: string | number | boolean | null, operator: string }. - Ensure that the generated filters use only the provided property names and their corresponding operators.`, - }) - - // Validate that all propertyNames exist in filterProperties - const validatePropertyNames = (group: FilterGroupType): boolean => { - return group.conditions.every((condition) => { - if ('logicalOperator' in condition) { - return validatePropertyNames(condition as FilterGroupType) - } - const property = filterProperties.find( - (p: z.infer) => p.name === condition.propertyName - ) - if (!property) return false - // Validate operator is valid for this property - return property.operators?.includes(condition.operator) ?? false - }) - } - - if (!validatePropertyNames(object)) { - throw new Error('Invalid property names or operators in generated filter') - } - - // Zod will throw an error if the object doesn't match the schema - const validatedFilters = FilterGroup.parse(object) - return Response.json(validatedFilters) - } catch (error: any) { - console.error('Error in AI filtering:', error) - return Response.json({ error: error.message || 'AI filtering failed' }, { status: 500 }) - } -} -``` - -### Usage with AI +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(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 ( - - ) -} + ``` diff --git a/apps/design-system/registry/default/example/filter-bar-demo.tsx b/apps/design-system/registry/default/example/filter-bar-demo.tsx index d69e08ab0fc..0b11a8889df 100644 --- a/apps/design-system/registry/default/example/filter-bar-demo.tsx +++ b/apps/design-system/registry/default/example/filter-bar-demo.tsx @@ -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( + const [date, setDate] = useState( 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 ( -
-

Select Time

- setTime(e.target.value)} /> -
- - Cancel - - onChange(time)}>Apply -
-
- ) -} - -function CustomRangePicker({ onChange, onCancel, search }: CustomOptionProps) { - const [range, setRange] = useState(search || '') - - return ( -
-

Select Range

- setRange(e.target.value)} /> -
- - Cancel - - onChange(range)}>Apply -
-
- ) -} - 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 = [ ), }, - triggerOnPropertyClick: true, operators: ['=', '!=', '>', '<', '>=', '<='], }, ] diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 9226a7f0041..ba7cde6ab58 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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(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 (
-
{ + const snap = useTableEditorTableStateSnapshot() + const showInsertButton = snap.selectedRows.size === 0 + + return ( +
+
+ {customHeader ? ( + customHeader + ) : snap.selectedRows.size > 0 ? ( + + ) : ( + + )} +
+ + {showInsertButton && } +
+
+
+ ) +} + +const DefaultHeader = ({ + tableQueriesEnabled = true, +}: Pick) => { + return ( + <> +
+ +
+ + + ) +} + +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 ( + + + + + + {[ + ...(onAddRow !== undefined + ? [ + +
+
+
+
+
+
+

Insert row

+

Insert a new row into {snap.table.name}

+
+ , + ] + : []), + ...(onAddColumn !== undefined + ? [ + +
+
+
+
+
+
+

Insert column

+

+ Insert a new column into {snap.table.name} +

+
+ , + ] + : []), + ...(onImportData !== undefined + ? [ + { + onImportData() + sendEvent({ + action: 'import_data_button_clicked', + properties: { tableType: 'Existing Table' }, + groups: { + project: projectRef ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + }} + > +
+ + +
+
+

Import data from CSV

+

Insert new rows from a CSV

+
+
, + ] + : []), + ]} + + + ) +} + +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 ( + <> +
+ {snap.editable && ( + } + 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`} + + )} + + {!snap.allRowsSelected ? ( + + + + + + onCopyRows('csv')}>Copy as CSV + onCopyRows('sql')}>Copy as SQL + onCopyRows('json')}>Copy as JSON + + + ) : ( + + Copy + + )} + + + + + + + Export as CSV + Export as SQL + {snap.allRowsSelected ? ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ ) : ( + Export as JSON + )} +
+
+ + {!snap.allRowsSelected && totalRows > allRows.length && ( + <> +
+ +
+ + + )} +
+ + setShowExportModal(false)} + /> + + {exportCsvConfirmationModal} + {exportSqlConfirmationModal} + {exportJsonConfirmationModal} + + ) +} diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx new file mode 100644 index 00000000000..cdaf1d37be7 --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx @@ -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(search ? new Date(search) : undefined) + + return ( +
+ +
+ + +
+
+ ) +} + +type OptionLike = FilterOption | null | undefined + +const isOptionRecord = (value: unknown): value is Record => + 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(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) => , + }, + ], + } + } + 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 ( + + ) +} diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts new file mode 100644 index 00000000000..1c0be090500 --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts @@ -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, + } +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index a35bf61a4f8..c64a8ae4c07 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -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) => { - const useUniversalFilterBar = useFlag('universalFilterBar') - const router = useRouter() const { db } = useParams() const { data: organization } = useSelectedOrganizationQuery() @@ -293,13 +290,7 @@ export const LogsPreviewer = ({ return (
- {useUniversalFilterBar ? ( - // Experimental Universal Filter Bar - - ) : ( - // Legacy Filter Panel - - )} + {children}
> + +export const useSqlFilterGenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + 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, + }) +} diff --git a/apps/studio/lib/api/filterHelpers.test.ts b/apps/studio/lib/api/filterHelpers.test.ts new file mode 100644 index 00000000000..b54d120e1cf --- /dev/null +++ b/apps/studio/lib/api/filterHelpers.test.ts @@ -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: [] }) + }) +}) diff --git a/apps/studio/lib/api/filterHelpers.ts b/apps/studio/lib/api/filterHelpers.ts new file mode 100644 index 00000000000..668bd505a38 --- /dev/null +++ b/apps/studio/lib/api/filterHelpers.ts @@ -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 | FilterGroupType> +} + +export const filterGroupSchema: z.ZodType = 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 +): condition is FilterGroupType { + return 'logicalOperator' in condition +} + +export function validateFilterGroup( + group: FilterGroupType, + properties: z.infer[] +): 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['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['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 : ['='] +} diff --git a/apps/studio/pages/api/ai/sql/filter-v1.ts b/apps/studio/pages/api/ai/sql/filter-v1.ts new file mode 100644 index 00000000000..64ddbaca735 --- /dev/null +++ b/apps/studio/pages/api/ai/sql/filter-v1.ts @@ -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 diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 08380bd1bd1..5ad9f17a8ae 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -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', diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 1ae452590a5..f461362343e 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -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" diff --git a/packages/ui-patterns/src/FilterBar/FilterBar.tsx b/packages/ui-patterns/src/FilterBar/FilterBar.tsx index cd8a359f971..63b8cbb072c 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBar.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBar.tsx @@ -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(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 ( -
+
- -
- handleItemSelect(item)} - setActiveInput={setActiveInput} - /> +
+
+ + +
{(error || optionsError) && (
{error || optionsError}
@@ -252,3 +58,64 @@ export function FilterBar({
) } + +/** + * FilterBar - A composable filter bar component + * + * Simple usage: + * ```tsx + * + * ``` + * + * Composable usage: + * ```tsx + * + * + * + * ``` + */ +export function FilterBar({ + filterProperties, + filters, + onFilterChange, + freeformText, + onFreeformTextChange, + actions, + isLoading, + className, + supportsOperators = false, + variant = 'default', +}: FilterBarProps) { + return ( + + + + ) +} + +// Composable API exports +FilterBar.Root = FilterBarRoot +FilterBar.Content = FilterBarContent +FilterBar.Group = FilterGroup diff --git a/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx b/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx new file mode 100644 index 00000000000..e17ba0f0d68 --- /dev/null +++ b/packages/ui-patterns/src/FilterBar/FilterBarContext.tsx @@ -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) => 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 + loadingOptions: Record + loadPropertyOptions: (property: FilterProperty, search: string) => void + optionsError: string | null + + // Config + supportsOperators: boolean + variant: FilterBarVariant + actions?: FilterBarAction[] + + // Refs + rootRef: React.RefObject +} + +const FilterBarContext = createContext(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(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 ( + +
+ {children} +
+
+ ) +} diff --git a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx index b00c05f550d..265c00f8c9c 100644 --- a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx @@ -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) => void - onRemove: () => void - // Local context - rootFilters: FilterGroupType - path: number[] - propertyOptionsCache: Record - loadingOptions: Record - 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(null) const valueRef = useRef(null) const wrapperRef = useRef(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, onBlur) - const handleValueBlur = useDeferredBlur(wrapperRef as React.RefObject, onBlur) - - if (!property) return null + const handleOperatorBlur = useDeferredBlur( + wrapperRef as React.RefObject, + handleInputBlur + ) + const handleValueBlur = useDeferredBlur( + wrapperRef as React.RefObject, + 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) => { + setHasTypedOperator(true) + handleOperatorChange(path, e.target.value) + }, + [handleOperatorChange, path] + ) + + const onValueChange = useCallback( + (e: React.ChangeEvent) => { + setHasTypedValue(true) + handleInputChange(path, e.target.value) + }, + [handleInputChange, path] + ) + + const onRemove = useCallback(() => { + handleRemoveCondition(path) + }, [handleRemoveCondition, path]) + + if (!property) return null + return (
handleLabelClick(path)} > {property.label} 0}> - 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}`} - /> +
+ 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}`} + /> + + {condition.operator || ' '} + +
{ const target = e.target as Node if (wrapperRef.current && !wrapperRef.current.contains(target)) { - onBlur() + handleInputBlur() } }} >
0)}> - 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}`} - /> +
+ 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}`} + /> + + {(condition.value ?? '').toString() || ' '} + +
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={ } 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`} />
diff --git a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx index eb1dd8ff000..660ca9ebf24 100644 --- a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx @@ -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) => 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 - loadingOptions: Record - // 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(null) const wrapperRef = useRef(null) const [isHoveringOperator, setIsHoveringOperator] = useState(false) - const isActive = - isGroupFreeformActive && - activeInput?.type === 'group' && - path.length === activeInput.path.length && - path.every((v, i) => v === activeInput.path[i]) + + 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, onBlur) + const handleFreeformBlur = useDeferredBlur( + wrapperRef as React.RefObject, + handleInputBlur + ) const handleFreeformChange = (e: React.ChangeEvent) => { 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 (
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' : ''}`} > -
+
{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} )} {'logicalOperator' in condition ? ( - + ) : ( onOperatorChange(currentPath, value)} - filterProperties={filterProperties} - onValueChange={(value) => onValueChange(currentPath, value)} - onOperatorFocus={() => onOperatorFocus(currentPath)} - onValueFocus={() => onValueFocus(currentPath)} - onBlur={onBlur} - onLabelClick={() => onLabelClick(currentPath)} - onKeyDown={onKeyDown} - onRemove={() => onRemove(currentPath)} - rootFilters={rootFilters} - path={currentPath} - propertyOptionsCache={propertyOptionsCache} - loadingOptions={loadingOptions} - aiApiUrl={aiApiUrl} - onSelectMenuItem={onSelectMenuItem} - setActiveInput={setActiveInput} /> )} @@ -265,33 +162,40 @@ export function FilterGroup({ })} 0}> - 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 ? ( + 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} + /> + ) : ( +
+ 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} + /> + + {(isActive ? freeformText : localFreeformValue) || '+'} + +
+ )}
{ const target = e.target as Node if (wrapperRef.current && !wrapperRef.current.contains(target)) { - onBlur() + handleInputBlur() } }} > diff --git a/packages/ui-patterns/src/FilterBar/hooks.test.ts b/packages/ui-patterns/src/FilterBar/hooks.test.ts index 0473f58efa3..c08c2e4d822 100644 --- a/packages/ui-patterns/src/FilterBar/hooks.test.ts +++ b/packages/ui-patterns/src/FilterBar/hooks.test.ts @@ -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] }) }) }) diff --git a/packages/ui-patterns/src/FilterBar/hooks.ts b/packages/ui-patterns/src/FilterBar/hooks.ts index 290cbc189f2..e79e13ed886 100644 --- a/packages/ui-patterns/src/FilterBar/hooks.ts +++ b/packages/ui-patterns/src/FilterBar/hooks.ts @@ -13,48 +13,22 @@ export type ActiveInput = export function useFilterBarState() { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) const [isCommandMenuVisible, setIsCommandMenuVisible] = useState(false) const hideTimeoutRef = useRef(null) const [activeInput, setActiveInput] = useState(null) const newPathRef = useRef([]) - const [dialogContent, setDialogContent] = useState(null) - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [pendingPath, setPendingPath] = useState(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, onBlur: () => void) { return useCallback( - (e: React.FocusEvent) => { + (_e: React.FocusEvent) => { setTimeout(() => { const active = document.activeElement as HTMLElement | null if (active && wrapperRef.current && wrapperRef.current.contains(active)) { diff --git a/packages/ui-patterns/src/FilterBar/index.ts b/packages/ui-patterns/src/FilterBar/index.ts index 4208cd92a61..c136ae38e0e 100644 --- a/packages/ui-patterns/src/FilterBar/index.ts +++ b/packages/ui-patterns/src/FilterBar/index.ts @@ -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' diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts index 8894f2bb7f8..aed549c0f0d 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.ts @@ -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 | 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, loadingOptions: Record, - 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...', diff --git a/packages/ui-patterns/src/FilterBar/types.ts b/packages/ui-patterns/src/FilterBar/types.ts index 935dbd03828..37c5102cf6e 100644 --- a/packages/ui-patterns/src/FilterBar/types.ts +++ b/packages/ui-patterns/src/FilterBar/types.ts @@ -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 +} + +export type SerializableFilterProperty = Pick & { + options?: string[] +} + +export type AIFilterRequestPayload = { + prompt: string + filterProperties: SerializableFilterProperty[] + currentPath: number[] +} diff --git a/packages/ui-patterns/src/FilterBar/useCommandHandling.ts b/packages/ui-patterns/src/FilterBar/useCommandHandling.ts index a696bac33c1..dbe7f6cbf55 100644 --- a/packages/ui-patterns/src/FilterBar/useCommandHandling.ts +++ b/packages/ui-patterns/src/FilterBar/useCommandHandling.ts @@ -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 - 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, } diff --git a/packages/ui-patterns/src/FilterBar/useCommandMenu.ts b/packages/ui-patterns/src/FilterBar/useCommandMenu.ts index 676fc97af8b..8e8263e358c 100644 --- a/packages/ui-patterns/src/FilterBar/useCommandMenu.ts +++ b/packages/ui-patterns/src/FilterBar/useCommandMenu.ts @@ -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( diff --git a/packages/ui-patterns/src/FilterBar/useKeyboardNavigation.ts b/packages/ui-patterns/src/FilterBar/useKeyboardNavigation.ts index f4940d6bf17..a18d0231639 100644 --- a/packages/ui-patterns/src/FilterBar/useKeyboardNavigation.ts +++ b/packages/ui-patterns/src/FilterBar/useKeyboardNavigation.ts @@ -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) => { + 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) => { + 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) => { + 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) => { - 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) => { - 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) => { - 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 { diff --git a/packages/ui-patterns/src/FilterBar/utils.ts b/packages/ui-patterns/src/FilterBar/utils.ts index 6276c18aa53..709bd6ef117 100644 --- a/packages/ui-patterns/src/FilterBar/utils.ts +++ b/packages/ui-patterns/src/FilterBar/utils.ts @@ -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 }, ], } }