diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 6902d69eb2..66bdc76f29 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -371,6 +371,38 @@ test.describe('Filter Bar', () => { } }) + test('Enter on equals fallback applies filter and returns focus to freeform', async ({ + page, + ref, + }) => { + const tableName = `${tableNamePrefix}_op_default_equals` + const columnName = 'name' + + await createTable(tableName, columnName, [{ name: 'forgecode' }, { name: 'other' }]) + + try { + await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`)) + await navigateToTable(page, ref, tableName) + + await selectColumnFilter(page, columnName) + + const operatorInput = page.getByTestId(`filter-operator-${columnName}`) + await operatorInput.fill('forgecode') + + await expect(page.getByText('Equals: "forgecode"')).toBeVisible() + + const rowsWaiter = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-') + await page.keyboard.press('Enter') + await rowsWaiter + + await expect(getFilterBarInput(page)).toBeFocused() + await expect(page.getByRole('gridcell', { name: 'forgecode' })).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'other' })).not.toBeVisible() + } finally { + await dropTable(tableName) + } + }) + test('Backspace on empty operator removes condition', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_op_bksp` const columnName = 'name' diff --git a/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx b/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx index a84b0afb61..eb6dcf8fea 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBar.test.tsx @@ -1,5 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import React, { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FilterBar } from './FilterBar' @@ -176,6 +177,53 @@ describe('FilterBar', () => { expect((updatedValueInput as HTMLInputElement).value).toBe('active') }) + it('shows an equals fallback and applies it on enter for non-operator input', async () => { + const user = userEvent.setup() + const onFilterChange = vi.fn() + + function Wrapper() { + const [filters, setFilters] = useState(initialFilters) + + return ( + { + onFilterChange(next) + setFilters(next) + }} + freeformText="" + onFreeformTextChange={mockOnFreeformTextChange} + /> + ) + } + + render() + + await user.click(screen.getByPlaceholderText('Filter by Name, Status, Count')) + await user.click(screen.getByText('Name')) + + const operatorInput = await screen.findByLabelText('Operator for Name') + await user.click(operatorInput) + await user.type(operatorInput, 'abc') + + expect(onFilterChange).toHaveBeenCalledTimes(1) + expect(await screen.findByText('Equals: "abc"')).toBeInTheDocument() + + await user.keyboard('{Enter}') + + await waitFor(() => { + expect(onFilterChange).toHaveBeenLastCalledWith({ + logicalOperator: 'AND', + conditions: [{ propertyName: 'name', operator: '=', value: 'abc' }], + }) + }) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Add more filters...')).toHaveFocus() + }) + }) + it('renders and applies custom value component inside popover', async () => { const user = userEvent.setup() const customProps: FilterProperty[] = [ diff --git a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx index 32b279e143..697d72f5a8 100644 --- a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx @@ -40,7 +40,6 @@ export function FilterCondition({ isLoading, propertyOptionsCache, loadingOptions, - handleOperatorChange, handleInputChange, handleOperatorFocus, handleInputFocus, @@ -62,9 +61,11 @@ export function FilterCondition({ const [showValueCustom, setShowValueCustom] = useState(false) const [hasTypedOperator, setHasTypedOperator] = useState(false) const [hasTypedValue, setHasTypedValue] = useState(false) + const [localOperator, setLocalOperator] = useState(condition.operator) const [localValue, setLocalValue] = useState((condition.value ?? '').toString()) const [propertySearchText, setPropertySearchText] = useState('') + const conditionOperator = condition.operator ?? '' const conditionValue = (condition.value ?? '').toString() // Reset "has typed" state when focus changes @@ -72,6 +73,12 @@ export function FilterCondition({ if (!isOperatorActive) setHasTypedOperator(false) }, [isOperatorActive]) + useEffect(() => { + if (!isOperatorActive && localOperator !== conditionOperator) { + setLocalOperator(conditionOperator) + } + }, [conditionOperator, isOperatorActive, localOperator]) + useEffect(() => { if (!isActive) setHasTypedValue(false) }, [isActive]) @@ -84,17 +91,17 @@ export function FilterCondition({ }, [conditionValue]) useEffect(() => { - if (isActive && valueRef.current) { - valueRef.current.focus() - } else if (isOperatorActive && operatorRef.current) { + if (isOperatorActive && operatorRef.current) { operatorRef.current.focus() + } else if (isActive && valueRef.current) { + valueRef.current.focus() } }, [isActive, isOperatorActive]) - const handleOperatorBlur = useDeferredBlur( - wrapperRef as React.RefObject, - handleInputBlur - ) + const handleOperatorBlur = useDeferredBlur(wrapperRef as React.RefObject, () => { + setLocalOperator(conditionOperator) + handleInputBlur() + }) const handleValueBlur = useDeferredBlur( wrapperRef as React.RefObject, handleInputBlur @@ -143,9 +150,10 @@ export function FilterCondition({ { type: 'operator', path }, rootFilters, filterProperties, - hasTypedOperator + hasTypedOperator, + localOperator ), - [path, rootFilters, filterProperties, hasTypedOperator] + [path, rootFilters, filterProperties, hasTypedOperator, localOperator] ) const valueItems = useMemo( @@ -186,13 +194,13 @@ export function FilterCondition({ const handleOperatorBackspace = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Backspace' && condition.operator === '') { + if (e.key === 'Backspace' && localOperator === '') { e.preventDefault() handleRemoveCondition(path) setActiveInput({ type: 'group', path: path.slice(0, -1) }) } }, - [condition.operator, setActiveInput, path, handleRemoveCondition] + [localOperator, setActiveInput, path, handleRemoveCondition] ) const { @@ -233,13 +241,10 @@ export function FilterCondition({ if (!isActive) resetValHighlight() }, [isActive, resetValHighlight]) - const onOperatorChange = useCallback( - (e: React.ChangeEvent) => { - setHasTypedOperator(true) - handleOperatorChange(path, e.target.value) - }, - [handleOperatorChange, path] - ) + const onOperatorChange = useCallback((e: React.ChangeEvent) => { + setHasTypedOperator(true) + setLocalOperator(e.target.value) + }, []) const onValueChange = useCallback( (e: React.ChangeEvent) => { @@ -327,9 +332,12 @@ export function FilterCondition({ handleOperatorFocus(path)} + onFocus={() => { + setLocalOperator(conditionOperator) + 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-foreground w-full absolute left-0 top-0" @@ -343,7 +351,7 @@ export function FilterCondition({ data-form-type="other" /> - {condition.operator || ' '} + {(isOperatorActive ? localOperator : conditionOperator) || ' '} diff --git a/packages/ui-patterns/src/FilterBar/menuItems.test.ts b/packages/ui-patterns/src/FilterBar/menuItems.test.ts index 14dfa07478..73e73972b6 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.test.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildValueItems } from './menuItems' +import { buildOperatorItems, buildValueItems } from './menuItems' import { FilterGroup, FilterProperty } from './types' const stringProperty: FilterProperty = { @@ -33,6 +33,50 @@ const booleanProperty: FilterProperty = { const filterProperties: FilterProperty[] = [stringProperty, booleanProperty] +describe('buildOperatorItems', () => { + it('returns matching operators for operator draft text', () => { + const filters: FilterGroup = { + logicalOperator: 'AND', + conditions: [{ propertyName: 'name', operator: '', value: '' }], + } + + const items = buildOperatorItems( + { type: 'operator', path: [0] }, + filters, + filterProperties, + true, + 'is' + ) + + expect(items).toEqual([{ value: 'is', label: 'Is', group: 'setNull', operatorSymbol: 'is' }]) + }) + + it('adds an equals fallback when operator draft does not match', () => { + const filters: FilterGroup = { + logicalOperator: 'AND', + conditions: [{ propertyName: 'name', operator: '', value: '' }], + } + + const items = buildOperatorItems( + { type: 'operator', path: [0] }, + filters, + filterProperties, + true, + 'abc' + ) + + expect(items).toEqual([ + { + value: '=', + label: 'Equals: "abc"', + operatorSymbol: '=', + isDefaultOperator: true, + defaultValue: 'abc', + }, + ]) + }) +}) + describe('buildValueItems', () => { it('returns NULL and NOT NULL options when IS operator is selected', () => { const filters: FilterGroup = { diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts index 80f69fccc6..bff559cfa5 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.ts @@ -10,18 +10,19 @@ export function buildOperatorItems( activeInput: Extract | null, activeFilters: FilterGroup, filterProperties: FilterProperty[], - hasTypedSinceFocus: boolean = true + hasTypedSinceFocus: boolean = true, + inputValue?: string ): MenuItem[] { if (!activeInput) return [] const condition = findConditionByPath(activeFilters, activeInput.path) const property = filterProperties.find((p) => p.name === condition?.propertyName) - const operatorValue = condition?.operator?.toUpperCase() || '' + const operatorValue = (inputValue ?? condition?.operator ?? '').toUpperCase() const availableOperators = property?.operators || ['='] // Only filter if user has typed since focusing const shouldFilter = hasTypedSinceFocus && operatorValue.length > 0 - return availableOperators + const items: MenuItem[] = availableOperators .filter((op) => { if (!shouldFilter) return true if (isFilterOperatorObject(op)) { @@ -43,6 +44,25 @@ export function buildOperatorItems( } return { value: op, label: op, operatorSymbol: op } }) + + if (shouldFilter && items.length === 0) { + const equalsOperator = availableOperators.find((op) => + isFilterOperatorObject(op) ? op.value === '=' : op === '=' + ) + + if (equalsOperator) { + const equalsLabel = isFilterOperatorObject(equalsOperator) ? equalsOperator.label : 'Equals' + items.push({ + value: '=', + label: `${equalsLabel}: "${inputValue ?? condition?.operator ?? ''}"`, + operatorSymbol: '=', + isDefaultOperator: true, + defaultValue: inputValue ?? condition?.operator ?? '', + }) + } + } + + return items } export function buildPropertyItems(params: { diff --git a/packages/ui-patterns/src/FilterBar/types.ts b/packages/ui-patterns/src/FilterBar/types.ts index e1ba3837c8..5f94dbce3b 100644 --- a/packages/ui-patterns/src/FilterBar/types.ts +++ b/packages/ui-patterns/src/FilterBar/types.ts @@ -97,6 +97,8 @@ export type MenuItem = { actionInputValue?: string group?: FilterOperatorGroup operatorSymbol?: string + isDefaultOperator?: boolean + defaultValue?: string } export type GroupedMenuItem = { diff --git a/packages/ui-patterns/src/FilterBar/useCommandHandling.ts b/packages/ui-patterns/src/FilterBar/useCommandHandling.ts index d7baf32ce0..5b3d4ccaa1 100644 --- a/packages/ui-patterns/src/FilterBar/useCommandHandling.ts +++ b/packages/ui-patterns/src/FilterBar/useCommandHandling.ts @@ -1,7 +1,13 @@ import { useCallback } from 'react' import { ActiveInputState, FilterGroup, FilterProperty, MenuItem } from './types' -import { addFilterToGroup, addGroupToGroup, findGroupByPath, isCustomOptionObject } from './utils' +import { + addFilterToGroup, + addGroupToGroup, + findGroupByPath, + updateNestedOperator, + updateNestedValue, +} from './utils' export function useCommandHandling({ activeInput, @@ -95,15 +101,7 @@ export function useCommandHandling({ 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) - } + handlePropertySelection(selectedProperty, currentPath, group) onFreeformTextChange('') }, [activeInput, filterProperties, activeFilters, onFreeformTextChange, handlePropertySelection] @@ -136,7 +134,18 @@ export function useCommandHandling({ if (activeInput?.type === 'value') { handleValueCommand(item) } else if (activeInput?.type === 'operator') { - handleOperatorCommand(selectedValue) + if (item.isDefaultOperator) { + const path = activeInput.path + const filtersWithOperator = updateNestedOperator(activeFilters, path, item.value) + onFilterChange(updateNestedValue(filtersWithOperator, path, item.defaultValue ?? '')) + + // Added minor delay to ensure the filter is updated before navigating to the group + setTimeout(() => { + setActiveInput({ type: 'group', path: path.slice(0, -1) }) + }, 0) + } else { + handleOperatorCommand(selectedValue) + } } else if (activeInput?.type === 'group') { handleGroupPropertyCommand(selectedValue) } @@ -150,6 +159,7 @@ export function useCommandHandling({ handleValueCommand, handleOperatorCommand, handleGroupPropertyCommand, + onFilterChange, setIsCommandMenuVisible, ] )