mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user