feat: when a value is pasted or any other string is typed, default to equals (#43611)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Right now when users type a value or paste a value after selecting a
column on the new filter bar, it kinda just fails. This just allows them
to default to equals and continue


https://github.com/user-attachments/assets/6af52c1d-3f6a-4bdb-8e22-61d7e5a756ae
This commit is contained in:
Ali Waseem
2026-03-11 08:28:01 -06:00
committed by GitHub
parent 7157fa3710
commit 0e96bf20a1
7 changed files with 201 additions and 37 deletions
+32
View File
@@ -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'
@@ -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 (
<FilterBar
filterProperties={mockFilterProperties}
filters={filters}
onFilterChange={(next) => {
onFilterChange(next)
setFilters(next)
}}
freeformText=""
onFreeformTextChange={mockOnFreeformTextChange}
/>
)
}
render(<Wrapper />)
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[] = [
@@ -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<HTMLElement>,
handleInputBlur
)
const handleOperatorBlur = useDeferredBlur(wrapperRef as React.RefObject<HTMLElement>, () => {
setLocalOperator(conditionOperator)
handleInputBlur()
})
const handleValueBlur = useDeferredBlur(
wrapperRef as React.RefObject<HTMLElement>,
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setHasTypedOperator(true)
handleOperatorChange(path, e.target.value)
},
[handleOperatorChange, path]
)
const onOperatorChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setHasTypedOperator(true)
setLocalOperator(e.target.value)
}, [])
const onValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -327,9 +332,12 @@ export function FilterCondition({
<Input_Shadcn_
ref={operatorRef}
type="text"
value={condition.operator}
value={isOperatorActive ? localOperator : conditionOperator}
onChange={onOperatorChange}
onFocus={() => 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"
/>
<span className="invisible whitespace-pre text-xs block px-1 shrink-0 px-1">
{condition.operator || ' '}
{(isOperatorActive ? localOperator : conditionOperator) || ' '}
</span>
</div>
</PopoverAnchor_Shadcn_>
@@ -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 = {
@@ -10,18 +10,19 @@ export function buildOperatorItems(
activeInput: Extract<ActiveInputState, { type: 'operator' }> | 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: {
@@ -97,6 +97,8 @@ export type MenuItem = {
actionInputValue?: string
group?: FilterOperatorGroup
operatorSymbol?: string
isDefaultOperator?: boolean
defaultValue?: string
}
export type GroupedMenuItem = {
@@ -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,
]
)