mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix: updated filter bar logic to support selection of columns once set (#43408)
## 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? - Allow selecting the column, it respects different types and updates the values correctly. If the values are different it gets wiped due to mismatches - Updated E2E tests and unit tests
This commit is contained in:
1 parent
c4eee6d63e
commit
e40bf39b9c
11 files changed
+665
-49
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
selectOperator,
|
||||
selectOperatorByClick,
|
||||
setupFilterBarPage,
|
||||
switchProperty,
|
||||
} from '../utils/filter-bar-helpers.js'
|
||||
import { test } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
@@ -166,7 +167,6 @@ test.describe('Filter Bar', () => {
|
||||
await freeformInput.click()
|
||||
await expect(freeformInput).toBeFocused()
|
||||
|
||||
// Tab should exit the filter bar (freeform input should lose focus)
|
||||
await page.keyboard.press('Tab')
|
||||
|
||||
await expect(freeformInput).not.toBeFocused()
|
||||
@@ -511,14 +511,12 @@ test.describe('Filter Bar', () => {
|
||||
const valueInput = page.getByTestId(`filter-value-${columnName}`)
|
||||
await valueInput.fill('HelloWorld')
|
||||
|
||||
// Move cursor to middle (after "Hello") and insert text
|
||||
await page.keyboard.press('Home')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
await page.keyboard.type('_Middle_')
|
||||
|
||||
// Verify the text was inserted in the middle, not appended at the end
|
||||
await expect(valueInput).toHaveValue('Hello_Middle_World')
|
||||
} finally {
|
||||
await dropTable(tableName)
|
||||
@@ -777,7 +775,6 @@ test.describe('Filter Bar', () => {
|
||||
await selectColumnFilter(page, 'is_active')
|
||||
await selectOperator(page, 'is_active', '=')
|
||||
|
||||
// Navigate to 'false' option (second item) and select with Enter
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
@@ -803,7 +800,6 @@ test.describe('Filter Bar', () => {
|
||||
await freeformInput.click()
|
||||
await expect(freeformInput).toBeFocused()
|
||||
|
||||
// Shift+Tab should exit the filter bar (freeform input should lose focus)
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
|
||||
await expect(freeformInput).not.toBeFocused()
|
||||
@@ -925,10 +921,8 @@ test.describe('Filter Bar', () => {
|
||||
const removeButton = page.getByRole('button', { name: 'Remove filters' })
|
||||
await expect(removeButton).toBeVisible()
|
||||
|
||||
// Clicking "Remove filters" should clear filters and restore data
|
||||
await removeButton.click()
|
||||
|
||||
// Filter pill should be gone and data should be visible again
|
||||
await expect(page.getByTestId('filter-condition-name')).not.toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible()
|
||||
@@ -937,4 +931,139 @@ test.describe('Filter Bar', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Property Switching', () => {
|
||||
test('clicking property label opens picker and selecting new column switches the filter', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = `${tableNamePrefix}_prop_switch`
|
||||
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
first_name text,
|
||||
last_name text
|
||||
)`
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO ${tableName} (first_name, last_name) VALUES ('Alice', 'Smith'), ('Bob', 'Jones')`
|
||||
)
|
||||
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await navigateToTable(page, ref, tableName)
|
||||
|
||||
await addFilter(page, ref, 'first_name', '=', 'Alice')
|
||||
await expect(page.getByTestId('filter-condition-first_name')).toBeVisible()
|
||||
|
||||
await switchProperty(page, 'first_name', 'last_name')
|
||||
|
||||
await expect(page.getByTestId('filter-condition-first_name')).not.toBeVisible()
|
||||
await expect(page.getByTestId('filter-condition-last_name')).toBeVisible()
|
||||
} finally {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
})
|
||||
|
||||
test('switching between same-type columns preserves compatible operator and value', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = `${tableNamePrefix}_prop_compat`
|
||||
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
first_name text,
|
||||
last_name text
|
||||
)`
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO ${tableName} (first_name, last_name) VALUES ('Alice', 'Smith'), ('Bob', 'Smith'), ('Alice', 'Jones')`
|
||||
)
|
||||
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await navigateToTable(page, ref, tableName)
|
||||
|
||||
await addFilter(page, ref, 'first_name', '=', 'Alice')
|
||||
await expect(page.getByRole('gridcell', { name: 'Bob' })).not.toBeVisible()
|
||||
|
||||
// '=' is compatible with text→text, so operator and value ("Alice") are preserved
|
||||
const rowsWaiter = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-')
|
||||
await switchProperty(page, 'first_name', 'last_name')
|
||||
await rowsWaiter
|
||||
|
||||
// last_name = Alice matches no rows (last names are Smith/Jones)
|
||||
const rows = page.locator('[role="row"]')
|
||||
await expect(rows).toHaveCount(1) // header only
|
||||
} finally {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
})
|
||||
|
||||
test('switching to column with incompatible operator resets operator', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = `${tableNamePrefix}_prop_incompat`
|
||||
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
name text,
|
||||
is_active boolean
|
||||
)`
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO ${tableName} (name, is_active) VALUES ('Alice', true), ('Bob', false)`
|
||||
)
|
||||
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await navigateToTable(page, ref, tableName)
|
||||
|
||||
// '~~' (Like) only exists on text columns, not boolean
|
||||
await selectColumnFilter(page, 'name')
|
||||
await selectOperatorByClick(page, 'name', '~~')
|
||||
const valueInput = page.getByTestId('filter-value-name')
|
||||
await valueInput.fill('Alice')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await switchProperty(page, 'name', 'is_active')
|
||||
|
||||
await expect(page.getByTestId('filter-operator-is_active')).toBeFocused()
|
||||
} finally {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
})
|
||||
|
||||
test('Escape closes property picker without changing the filter', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_prop_esc`
|
||||
const columnName = 'name'
|
||||
|
||||
await createTable(tableName, columnName, [{ name: 'Alice' }, { name: 'Bob' }])
|
||||
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await navigateToTable(page, ref, tableName)
|
||||
|
||||
await addFilter(page, ref, columnName, '=', 'Alice')
|
||||
|
||||
const conditionEl = page.getByTestId(`filter-condition-${columnName}`)
|
||||
await conditionEl.locator('span', { hasText: columnName }).first().click()
|
||||
|
||||
const searchInput = page.getByTestId(`filter-property-search-${columnName}`)
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByTestId(`filter-condition-${columnName}`)).toBeVisible()
|
||||
await expect(searchInput).not.toBeVisible()
|
||||
} finally {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,24 @@ export async function addFilterWithDropdownValue(
|
||||
await rowsWaiter
|
||||
}
|
||||
|
||||
export async function switchProperty(page: Page, currentColumnName: string, newColumnName: string) {
|
||||
const conditionEl = page.getByTestId(`filter-condition-${currentColumnName}`)
|
||||
await conditionEl
|
||||
.locator(`span`, { hasText: new RegExp(`^${currentColumnName}$`, 'i') })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const searchInput = page.getByTestId(`filter-property-search-${currentColumnName}`)
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
await searchInput.fill(newColumnName)
|
||||
|
||||
await expect(page.getByTestId(`filter-menu-item-${newColumnName}`)).toBeVisible()
|
||||
await page.getByTestId(`filter-menu-item-${newColumnName}`).click()
|
||||
|
||||
await expect(page.getByTestId(`filter-condition-${newColumnName}`)).toBeVisible()
|
||||
}
|
||||
|
||||
export async function setupFilterBarPage(page: Page, ref: string, editorUrl: string) {
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(editorUrl)
|
||||
|
||||
@@ -90,19 +90,15 @@ describe('FilterBar', () => {
|
||||
freeform.focus()
|
||||
await user.click(freeform)
|
||||
|
||||
// Should show property items in popover
|
||||
expect(await screen.findByText('Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
|
||||
// Select a property
|
||||
await user.click(screen.getByText('Status'))
|
||||
|
||||
// Wait for filter change callback and re-render with new state
|
||||
await waitFor(() => {
|
||||
expect(handleFilterChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Re-render with updated filters
|
||||
rerender(
|
||||
<FilterBar
|
||||
filterProperties={mockFilterProperties}
|
||||
@@ -113,7 +109,6 @@ describe('FilterBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Value input should appear for selected property
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Value for Status')).toBeInTheDocument()
|
||||
})
|
||||
@@ -159,13 +154,10 @@ describe('FilterBar', () => {
|
||||
})
|
||||
valueInput.focus()
|
||||
|
||||
// Popover should show value options
|
||||
expect(await screen.findByText('active')).toBeInTheDocument()
|
||||
|
||||
// Select 'active' (first item)
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// Wait for value to be updated
|
||||
await waitFor(() => {
|
||||
expect(handleFilterChange).toHaveBeenCalledTimes(2) // Once for property, once for value
|
||||
})
|
||||
@@ -244,19 +236,16 @@ describe('FilterBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Wait for FilterCondition to be created
|
||||
const valueInput = await waitFor(() => screen.getByLabelText('Value for Tag'), {
|
||||
timeout: 3000,
|
||||
})
|
||||
|
||||
// Focus the value input to show the popover
|
||||
await user.click(valueInput)
|
||||
|
||||
// Custom UI should render inside the popover automatically (no menu for single custom option)
|
||||
// When value options contain only a custom component, the popover renders it directly (no menu)
|
||||
const pickFoo = await screen.findByText('Pick Foo')
|
||||
await user.click(pickFoo)
|
||||
|
||||
// Wait for value change callback
|
||||
await waitFor(() => {
|
||||
expect(handleFilterChange).toHaveBeenCalledTimes(2) // Once for property, once for value
|
||||
})
|
||||
@@ -271,7 +260,6 @@ describe('FilterBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Value should be applied
|
||||
const updatedValueInput = await screen.findByLabelText('Value for Tag')
|
||||
expect((updatedValueInput as HTMLInputElement).value).toBe('foo')
|
||||
})
|
||||
@@ -361,6 +349,99 @@ describe('FilterBar', () => {
|
||||
expect(screen.queryByText('AND')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows switching filter property by clicking the label', async () => {
|
||||
const user = userEvent.setup()
|
||||
let currentFilters: FilterGroup = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [
|
||||
{
|
||||
propertyName: 'name',
|
||||
value: 'test',
|
||||
operator: '=',
|
||||
},
|
||||
],
|
||||
}
|
||||
const handleFilterChange = vi.fn((filters) => {
|
||||
currentFilters = filters
|
||||
})
|
||||
|
||||
const { rerender } = render(
|
||||
<FilterBar
|
||||
filterProperties={mockFilterProperties}
|
||||
filters={currentFilters}
|
||||
onFilterChange={handleFilterChange}
|
||||
freeformText=""
|
||||
onFreeformTextChange={mockOnFreeformTextChange}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('test')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Name'))
|
||||
|
||||
// Dropdown excludes current property ("Name" is visible as label but not in the picker list)
|
||||
expect(await screen.findByText('Status')).toBeInTheDocument()
|
||||
expect(screen.getByText('Count')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Status'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleFilterChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const updatedCondition = currentFilters.conditions[0] as {
|
||||
propertyName: string
|
||||
value: string
|
||||
operator: string
|
||||
}
|
||||
expect(updatedCondition.propertyName).toBe('status')
|
||||
expect(updatedCondition.operator).toBe('=')
|
||||
})
|
||||
|
||||
it('resets operator when switching to property with incompatible operators', async () => {
|
||||
const user = userEvent.setup()
|
||||
// CONTAINS exists on Name but not Status, so switching should reset operator
|
||||
let currentFilters: FilterGroup = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [
|
||||
{
|
||||
propertyName: 'name',
|
||||
value: 'test',
|
||||
operator: 'CONTAINS',
|
||||
},
|
||||
],
|
||||
}
|
||||
const handleFilterChange = vi.fn((filters) => {
|
||||
currentFilters = filters
|
||||
})
|
||||
|
||||
render(
|
||||
<FilterBar
|
||||
filterProperties={mockFilterProperties}
|
||||
filters={currentFilters}
|
||||
onFilterChange={handleFilterChange}
|
||||
freeformText=""
|
||||
onFreeformTextChange={mockOnFreeformTextChange}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Name'))
|
||||
await user.click(await screen.findByText('Status'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleFilterChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const updatedCondition = currentFilters.conditions[0] as {
|
||||
propertyName: string
|
||||
value: string
|
||||
operator: string
|
||||
}
|
||||
expect(updatedCondition.propertyName).toBe('status')
|
||||
expect(updatedCondition.operator).toBe('')
|
||||
})
|
||||
|
||||
it('hides logical operators by default', () => {
|
||||
const multipleFilters: FilterGroup = {
|
||||
logicalOperator: 'AND',
|
||||
@@ -385,7 +466,6 @@ describe('FilterBar', () => {
|
||||
onFilterChange={mockOnFilterChange}
|
||||
freeformText=""
|
||||
onFreeformTextChange={mockOnFreeformTextChange}
|
||||
// supportsOperators defaults to false
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
findConditionByPath,
|
||||
isAsyncOptionsFunction,
|
||||
removeFromGroup,
|
||||
resolvePropertyChange,
|
||||
updateNestedLogicalOperator,
|
||||
updateNestedOperator,
|
||||
updateNestedPropertyName,
|
||||
updateNestedValue,
|
||||
} from './utils'
|
||||
|
||||
export type FilterBarContextValue = {
|
||||
// Core state
|
||||
filters: FilterGroup
|
||||
filterProperties: FilterProperty[]
|
||||
activeInput: ActiveInputState
|
||||
@@ -32,7 +33,6 @@ export type FilterBarContextValue = {
|
||||
error: string | null
|
||||
highlightedConditionPath: number[] | null
|
||||
|
||||
// Handlers
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
onFreeformTextChange: (text: string) => void
|
||||
setActiveInput: (input: ActiveInputState) => void
|
||||
@@ -47,9 +47,9 @@ export type FilterBarContextValue = {
|
||||
handleInputBlur: () => void
|
||||
handleGroupFreeformChange: (path: number[], value: string) => void
|
||||
handleLabelClick: (path: number[]) => void
|
||||
handlePropertyChange: (path: number[], newPropertyName: string) => void
|
||||
handleLogicalOperatorChange: (path: number[]) => void
|
||||
|
||||
// Options cache
|
||||
propertyOptionsCache: Record<
|
||||
string,
|
||||
{ options: (string | FilterOptionObject)[]; searchValue: string }
|
||||
@@ -58,13 +58,11 @@ export type FilterBarContextValue = {
|
||||
loadPropertyOptions: (property: FilterProperty, search: string) => void
|
||||
optionsError: string | null
|
||||
|
||||
// Config
|
||||
supportsOperators: boolean
|
||||
variant: FilterBarVariant
|
||||
actions?: FilterBarAction[]
|
||||
icon?: React.ReactNode
|
||||
|
||||
// Refs
|
||||
rootRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
@@ -240,14 +238,12 @@ export function FilterBarRoot({
|
||||
}
|
||||
setIsCommandMenuVisible(false)
|
||||
setActiveInput(null)
|
||||
// Clear highlight when clicking outside
|
||||
setHighlightedConditionPath(null)
|
||||
}, 0)
|
||||
}, [setIsCommandMenuVisible, setActiveInput, hideTimeoutRef, setHighlightedConditionPath])
|
||||
|
||||
const handleGroupFreeformChange = useCallback(
|
||||
(_path: number[], value: string) => {
|
||||
// Clear highlight when user types
|
||||
if (highlightedConditionPath) {
|
||||
setHighlightedConditionPath(null)
|
||||
}
|
||||
@@ -258,9 +254,40 @@ export function FilterBarRoot({
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
(path: number[]) => {
|
||||
setActiveInput({ type: 'value', path })
|
||||
setActiveInput({ type: 'property', path })
|
||||
setIsCommandMenuVisible(true)
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[setActiveInput]
|
||||
[setActiveInput, setIsCommandMenuVisible, hideTimeoutRef]
|
||||
)
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(path: number[], newPropertyName: string) => {
|
||||
const condition = findConditionByPath(filters, path)
|
||||
if (!condition) return
|
||||
|
||||
const newProperty = filterProperties.find((p) => p.name === newPropertyName)
|
||||
if (!newProperty) return
|
||||
|
||||
const { operator, value, focusTarget } = resolvePropertyChange(
|
||||
condition.operator,
|
||||
(condition.value ?? '').toString(),
|
||||
newProperty
|
||||
)
|
||||
|
||||
const updatedFilters = updateNestedPropertyName(
|
||||
filters,
|
||||
path,
|
||||
newPropertyName,
|
||||
operator,
|
||||
value
|
||||
)
|
||||
onFilterChange(updatedFilters)
|
||||
setActiveInput({ type: focusTarget, path })
|
||||
},
|
||||
[filters, filterProperties, onFilterChange, setActiveInput]
|
||||
)
|
||||
|
||||
const handleLogicalOperatorChange = useCallback(
|
||||
@@ -292,7 +319,6 @@ export function FilterBarRoot({
|
||||
const loading = externalLoading ?? isLoading
|
||||
|
||||
const contextValue: FilterBarContextValue = {
|
||||
// Core state
|
||||
filters,
|
||||
filterProperties,
|
||||
activeInput,
|
||||
@@ -301,7 +327,6 @@ export function FilterBarRoot({
|
||||
error,
|
||||
highlightedConditionPath,
|
||||
|
||||
// Handlers
|
||||
onFilterChange,
|
||||
onFreeformTextChange,
|
||||
setActiveInput,
|
||||
@@ -316,27 +341,25 @@ export function FilterBarRoot({
|
||||
handleInputBlur,
|
||||
handleGroupFreeformChange,
|
||||
handleLabelClick,
|
||||
handlePropertyChange,
|
||||
handleLogicalOperatorChange,
|
||||
|
||||
// Options cache
|
||||
propertyOptionsCache,
|
||||
loadingOptions,
|
||||
loadPropertyOptions,
|
||||
optionsError,
|
||||
|
||||
// Config
|
||||
supportsOperators,
|
||||
variant,
|
||||
actions,
|
||||
icon,
|
||||
|
||||
// Refs
|
||||
rootRef,
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterBarContext.Provider value={contextValue}>
|
||||
<div ref={rootRef} data-filterbar-root className="h-full min-h-[26px] flex items-stretch">
|
||||
<div ref={rootRef} data-filterbar-root className="h-full min-h-[32px] flex items-stretch">
|
||||
{children}
|
||||
</div>
|
||||
</FilterBarContext.Provider>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { DefaultCommandList } from './DefaultCommandList'
|
||||
import { useFilterBar } from './FilterBarContext'
|
||||
import { useDeferredBlur, useHighlightNavigation } from './hooks'
|
||||
import { buildOperatorItems, buildValueItems } from './menuItems'
|
||||
import { buildOperatorItems, buildPropertyChangeItems, buildValueItems } from './menuItems'
|
||||
import { FilterCondition as FilterConditionType } from './types'
|
||||
|
||||
export type FilterConditionProps = {
|
||||
@@ -22,6 +22,7 @@ export type FilterConditionProps = {
|
||||
path: number[]
|
||||
isActive: boolean
|
||||
isOperatorActive: boolean
|
||||
isPropertyActive: boolean
|
||||
isHighlighted: boolean
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ export function FilterCondition({
|
||||
path,
|
||||
isActive,
|
||||
isOperatorActive,
|
||||
isPropertyActive,
|
||||
isHighlighted,
|
||||
}: FilterConditionProps) {
|
||||
const {
|
||||
@@ -44,6 +46,7 @@ export function FilterCondition({
|
||||
handleInputFocus,
|
||||
handleInputBlur,
|
||||
handleLabelClick,
|
||||
handlePropertyChange,
|
||||
handleKeyDown,
|
||||
handleRemoveCondition,
|
||||
handleSelectMenuItem,
|
||||
@@ -55,10 +58,12 @@ export function FilterCondition({
|
||||
const valueRef = useRef<HTMLInputElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const property = filterProperties.find((p) => p.name === condition.propertyName)
|
||||
const propertyLabelRef = useRef<HTMLDivElement>(null)
|
||||
const [showValueCustom, setShowValueCustom] = useState(false)
|
||||
const [hasTypedOperator, setHasTypedOperator] = useState(false)
|
||||
const [hasTypedValue, setHasTypedValue] = useState(false)
|
||||
const [localValue, setLocalValue] = useState((condition.value ?? '').toString())
|
||||
const [propertySearchText, setPropertySearchText] = useState('')
|
||||
|
||||
const conditionValue = (condition.value ?? '').toString()
|
||||
|
||||
@@ -95,6 +100,43 @@ export function FilterCondition({
|
||||
handleInputBlur
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPropertyActive) setPropertySearchText('')
|
||||
}, [isPropertyActive])
|
||||
|
||||
const propertyItems = useMemo(
|
||||
() =>
|
||||
buildPropertyChangeItems({
|
||||
filterProperties,
|
||||
currentPropertyName: condition.propertyName,
|
||||
inputValue: propertySearchText,
|
||||
}),
|
||||
[filterProperties, condition.propertyName, propertySearchText]
|
||||
)
|
||||
|
||||
const {
|
||||
highlightedIndex: propHighlightedIndex,
|
||||
handleKeyDown: handlePropertyKeyDown,
|
||||
reset: resetPropHighlight,
|
||||
} = useHighlightNavigation(
|
||||
propertyItems.length,
|
||||
(index) => {
|
||||
if (propertyItems[index]) {
|
||||
handlePropertyChange(path, propertyItems[index].value)
|
||||
}
|
||||
},
|
||||
handleKeyDown
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPropertyActive) resetPropHighlight()
|
||||
}, [isPropertyActive, resetPropHighlight])
|
||||
|
||||
const handlePropertyBlur = useDeferredBlur(
|
||||
wrapperRef as React.RefObject<HTMLElement>,
|
||||
handleInputBlur
|
||||
)
|
||||
|
||||
const operatorItems = useMemo(
|
||||
() =>
|
||||
buildOperatorItems(
|
||||
@@ -218,19 +260,67 @@ export function FilterCondition({
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
'flex items-stretch px-0 bg-muted group shrink-0',
|
||||
'flex items-stretch px-0 py-1 bg-muted group shrink-0',
|
||||
variant === 'pill' ? 'rounded border' : 'border-r',
|
||||
isHighlighted && 'ring-2 ring-primary'
|
||||
)}
|
||||
data-testid={`filter-condition-${property.name}`}
|
||||
data-highlighted={isHighlighted}
|
||||
>
|
||||
<span
|
||||
className="text-xs pl-2 pr-1 cursor-pointer shrink-0 whitespace-nowrap text-foreground-light h-full flex items-center"
|
||||
onClick={() => handleLabelClick(path)}
|
||||
>
|
||||
{property.label}
|
||||
</span>
|
||||
<Popover_Shadcn_ open={isPropertyActive && !isLoading && propertyItems.length > 0}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<div ref={propertyLabelRef} className="relative inline-flex items-center shrink-0">
|
||||
{isPropertyActive ? (
|
||||
<Input_Shadcn_
|
||||
type="text"
|
||||
value={propertySearchText}
|
||||
onChange={(e) => setPropertySearchText(e.target.value)}
|
||||
onBlur={handlePropertyBlur}
|
||||
onKeyDown={handlePropertyKeyDown}
|
||||
className="h-full border-none bg-transparent py-0 pl-2 pr-1 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-foreground-light w-full absolute left-0 top-0"
|
||||
placeholder={property.label}
|
||||
autoFocus
|
||||
aria-label={`Change property from ${property.label}`}
|
||||
data-testid={`filter-property-search-${property.name}`}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs pl-2 pr-1 shrink-0 whitespace-nowrap text-foreground-light h-full flex items-center cursor-pointer hover:text-foreground transition-colors',
|
||||
isPropertyActive && 'invisible'
|
||||
)}
|
||||
onClick={() => handleLabelClick(path)}
|
||||
>
|
||||
{property.label}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverAnchor_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="min-w-[220px] p-0"
|
||||
align="start"
|
||||
side="bottom"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Node
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
|
||||
handleInputBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DefaultCommandList
|
||||
items={propertyItems}
|
||||
highlightedIndex={propHighlightedIndex}
|
||||
onSelect={(item) => handlePropertyChange(path, item.value)}
|
||||
includeIcon={false}
|
||||
/>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
<Popover_Shadcn_ open={isOperatorActive && !isLoading && operatorItems.length > 0}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<div className="relative inline-block">
|
||||
@@ -281,7 +371,7 @@ export function FilterCondition({
|
||||
</Popover_Shadcn_>
|
||||
<Popover_Shadcn_ open={isActive && !isLoading && (showValueCustom || valueItems.length > 0)}>
|
||||
<PopoverAnchor_Shadcn_ asChild>
|
||||
<div className="relative inline-block max-w-[150px]">
|
||||
<div className="relative inline-block max-w-[180px]">
|
||||
<Input_Shadcn_
|
||||
ref={valueRef}
|
||||
type="text"
|
||||
@@ -349,7 +439,7 @@ export function FilterCondition({
|
||||
icon={
|
||||
<X
|
||||
strokeWidth={1}
|
||||
size={10}
|
||||
size={12}
|
||||
className="group-hover:text-foreground text-foreground-lighter"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
|
||||
const isActive = activeInput?.type === 'group' && pathsEqual(path, activeInput.path)
|
||||
|
||||
// Reset local value when group freeform value is cleared
|
||||
useEffect(() => {
|
||||
if (freeformText === '') {
|
||||
setLocalFreeformValue('')
|
||||
@@ -84,6 +83,11 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
return activeInput.type === 'operator' && pathsEqual(conditionPath, activeInput.path)
|
||||
}
|
||||
|
||||
const isPropertyActiveForCondition = (conditionPath: number[]) => {
|
||||
if (!activeInput) return false
|
||||
return activeInput.type === 'property' && pathsEqual(conditionPath, activeInput.path)
|
||||
}
|
||||
|
||||
const isConditionHighlighted = (conditionPath: number[]) => {
|
||||
if (!highlightedConditionPath) return false
|
||||
return pathsEqual(conditionPath, highlightedConditionPath)
|
||||
@@ -171,6 +175,7 @@ export function FilterGroup({ group, path }: FilterGroupProps) {
|
||||
path={currentPath}
|
||||
isActive={isConditionActive(currentPath)}
|
||||
isOperatorActive={isOperatorActive(currentPath)}
|
||||
isPropertyActive={isPropertyActiveForCondition(currentPath)}
|
||||
isHighlighted={isConditionHighlighted(currentPath)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,19 @@ export function buildPropertyItems(params: {
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildPropertyChangeItems(params: {
|
||||
filterProperties: FilterProperty[]
|
||||
currentPropertyName: string
|
||||
inputValue: string
|
||||
}): MenuItem[] {
|
||||
const { filterProperties, currentPropertyName, inputValue } = params
|
||||
|
||||
return filterProperties
|
||||
.filter((prop) => prop.name !== currentPropertyName)
|
||||
.filter((prop) => prop.label.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
.map((prop) => ({ value: prop.name, label: prop.label }))
|
||||
}
|
||||
|
||||
export function buildValueItems(
|
||||
activeInput: Extract<ActiveInputState, { type: 'value' }> | null,
|
||||
activeFilters: FilterGroup,
|
||||
|
||||
@@ -130,6 +130,7 @@ export type ActiveInputState =
|
||||
| { type: 'value'; path: ConditionPath }
|
||||
| { type: 'operator'; path: ConditionPath }
|
||||
| { type: 'group'; path: ConditionPath }
|
||||
| { type: 'property'; path: ConditionPath }
|
||||
| null
|
||||
|
||||
export type KeyboardNavigationConfig = {
|
||||
@@ -140,3 +141,9 @@ export type KeyboardNavigationConfig = {
|
||||
highlightedConditionPath: ConditionPath | null
|
||||
setHighlightedConditionPath: (path: ConditionPath | null) => void
|
||||
}
|
||||
|
||||
export type ResolvedPropertyChange = {
|
||||
operator: string
|
||||
value: string
|
||||
focusTarget: 'operator' | 'value'
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function useKeyboardNavigation({
|
||||
|
||||
const handleBackspace = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (activeInput?.type === 'operator') return
|
||||
if (activeInput?.type === 'operator' || activeInput?.type === 'property') return
|
||||
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
const isEmpty = inputElement.value === ''
|
||||
@@ -172,6 +172,8 @@ export function useKeyboardNavigation({
|
||||
|
||||
const handleArrowLeft = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (activeInput?.type === 'property') return
|
||||
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
const isEmpty = inputElement.value === ''
|
||||
|
||||
@@ -201,6 +203,8 @@ export function useKeyboardNavigation({
|
||||
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (activeInput?.type === 'property') return
|
||||
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
const isEmpty = inputElement.value === ''
|
||||
|
||||
@@ -255,7 +259,10 @@ export function useKeyboardNavigation({
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
handleArrowRight(e)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (highlightedConditionPath) {
|
||||
if (activeInput?.type === 'property') {
|
||||
e.preventDefault()
|
||||
setActiveInput({ type: 'value', path: activeInput.path })
|
||||
} else if (highlightedConditionPath) {
|
||||
e.preventDefault()
|
||||
setHighlightedConditionPath(null)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildPropertyChangeItems } from './menuItems'
|
||||
import { FilterGroup, FilterProperty, MenuItem } from './types'
|
||||
import {
|
||||
addFilterToGroup,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
isFilterOptionObject,
|
||||
isSyncOptionsFunction,
|
||||
removeFromGroup,
|
||||
resolvePropertyChange,
|
||||
truncateText,
|
||||
updateNestedLogicalOperator,
|
||||
updateNestedOperator,
|
||||
updateNestedPropertyName,
|
||||
updateNestedValue,
|
||||
} from './utils'
|
||||
|
||||
@@ -194,6 +197,141 @@ describe('FilterBar Utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateNestedPropertyName', () => {
|
||||
it('updates property name at root path', () => {
|
||||
const result = updateNestedPropertyName(sampleFilterGroup, [0], 'email')
|
||||
expect(result.conditions[0]).toEqual({
|
||||
propertyName: 'email',
|
||||
value: 'test',
|
||||
operator: '=',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates property name at nested path', () => {
|
||||
const result = updateNestedPropertyName(sampleFilterGroup, [1, 0], 'role')
|
||||
const nestedGroup = result.conditions[1] as FilterGroup
|
||||
expect(nestedGroup.conditions[0]).toEqual({
|
||||
propertyName: 'role',
|
||||
value: 'active',
|
||||
operator: '=',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates property name and resets operator and value', () => {
|
||||
const result = updateNestedPropertyName(sampleFilterGroup, [0], 'email', '', '')
|
||||
expect(result.conditions[0]).toEqual({
|
||||
propertyName: 'email',
|
||||
value: '',
|
||||
operator: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates property name and preserves compatible operator', () => {
|
||||
const result = updateNestedPropertyName(sampleFilterGroup, [0], 'email', '=', 'test')
|
||||
expect(result.conditions[0]).toEqual({
|
||||
propertyName: 'email',
|
||||
value: 'test',
|
||||
operator: '=',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify other conditions', () => {
|
||||
const result = updateNestedPropertyName(sampleFilterGroup, [0], 'email')
|
||||
expect(result.conditions[1]).toEqual(sampleFilterGroup.conditions[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePropertyChange', () => {
|
||||
const stringProperty: FilterProperty = {
|
||||
label: 'Email',
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
operators: [
|
||||
{ value: '=', label: 'Equals', group: 'comparison' },
|
||||
{ value: '<>', label: 'Not equal', group: 'comparison' },
|
||||
{ value: '~~', label: 'Like', group: 'pattern' },
|
||||
],
|
||||
}
|
||||
|
||||
const booleanProperty: FilterProperty = {
|
||||
label: 'Active',
|
||||
name: 'active',
|
||||
type: 'boolean',
|
||||
options: [
|
||||
{ label: 'true', value: 'true' },
|
||||
{ label: 'false', value: 'false' },
|
||||
],
|
||||
operators: [
|
||||
{ value: '=', label: 'Equals', group: 'comparison' },
|
||||
{ value: '<>', label: 'Not equal', group: 'comparison' },
|
||||
],
|
||||
}
|
||||
|
||||
const noOperatorsProperty: FilterProperty = {
|
||||
label: 'Raw',
|
||||
name: 'raw',
|
||||
type: 'string',
|
||||
}
|
||||
|
||||
it('preserves compatible operator and free-form value', () => {
|
||||
const result = resolvePropertyChange('=', 'hello', stringProperty)
|
||||
expect(result).toEqual({ operator: '=', value: 'hello', focusTarget: 'value' })
|
||||
})
|
||||
|
||||
it('resets operator when incompatible with new property', () => {
|
||||
const result = resolvePropertyChange('in', 'something', stringProperty)
|
||||
expect(result).toEqual({ operator: '', value: '', focusTarget: 'operator' })
|
||||
})
|
||||
|
||||
it('preserves operator but resets value when fixed options do not match', () => {
|
||||
const result = resolvePropertyChange('=', 'hello', booleanProperty)
|
||||
expect(result).toEqual({ operator: '=', value: '', focusTarget: 'value' })
|
||||
})
|
||||
|
||||
it('preserves operator and value when fixed options match', () => {
|
||||
const result = resolvePropertyChange('=', 'true', booleanProperty)
|
||||
expect(result).toEqual({ operator: '=', value: 'true', focusTarget: 'value' })
|
||||
})
|
||||
|
||||
it('resets everything when current operator is empty', () => {
|
||||
const result = resolvePropertyChange('', 'hello', stringProperty)
|
||||
expect(result).toEqual({ operator: '', value: '', focusTarget: 'operator' })
|
||||
})
|
||||
|
||||
it('resets everything when new property has no operators', () => {
|
||||
const result = resolvePropertyChange('=', 'hello', noOperatorsProperty)
|
||||
expect(result).toEqual({ operator: '', value: '', focusTarget: 'operator' })
|
||||
})
|
||||
|
||||
it('preserves operator with empty value', () => {
|
||||
const result = resolvePropertyChange('=', '', stringProperty)
|
||||
expect(result).toEqual({ operator: '=', value: '', focusTarget: 'value' })
|
||||
})
|
||||
|
||||
it('handles string-based operators (not FilterOperatorObject)', () => {
|
||||
const simpleOpProperty: FilterProperty = {
|
||||
label: 'Simple',
|
||||
name: 'simple',
|
||||
type: 'string',
|
||||
operators: ['=', '!='],
|
||||
}
|
||||
const result = resolvePropertyChange('=', 'test', simpleOpProperty)
|
||||
expect(result).toEqual({ operator: '=', value: 'test', focusTarget: 'value' })
|
||||
})
|
||||
|
||||
it('handles string-based fixed options', () => {
|
||||
const stringOptionsProperty: FilterProperty = {
|
||||
label: 'Status',
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
options: ['active', 'inactive'],
|
||||
operators: ['='],
|
||||
}
|
||||
const result = resolvePropertyChange('=', 'active', stringOptionsProperty)
|
||||
expect(result).toEqual({ operator: '=', value: 'active', focusTarget: 'value' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type guards', () => {
|
||||
it('identifies custom option objects', () => {
|
||||
const customOption = { component: () => React.createElement('div', {}, 'test') }
|
||||
@@ -455,4 +593,61 @@ describe('FilterBar Utils', () => {
|
||||
expect(buildFilterPlaceholder(props, { maxProperties: 2 })).toBe('Filter by Name, Status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPropertyChangeItems', () => {
|
||||
const testProperties: FilterProperty[] = [
|
||||
{ label: 'Name', name: 'name', type: 'string' },
|
||||
{ label: 'Status', name: 'status', type: 'string' },
|
||||
{ label: 'Count', name: 'count', type: 'number' },
|
||||
]
|
||||
|
||||
it('returns all properties except the current one', () => {
|
||||
const items = buildPropertyChangeItems({
|
||||
filterProperties: testProperties,
|
||||
currentPropertyName: 'name',
|
||||
inputValue: '',
|
||||
})
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.map((i) => i.value)).toEqual(['status', 'count'])
|
||||
})
|
||||
|
||||
it('filters by input value', () => {
|
||||
const items = buildPropertyChangeItems({
|
||||
filterProperties: testProperties,
|
||||
currentPropertyName: 'name',
|
||||
inputValue: 'sta',
|
||||
})
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].value).toBe('status')
|
||||
})
|
||||
|
||||
it('returns empty array when no matches', () => {
|
||||
const items = buildPropertyChangeItems({
|
||||
filterProperties: testProperties,
|
||||
currentPropertyName: 'name',
|
||||
inputValue: 'xyz',
|
||||
})
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is case insensitive', () => {
|
||||
const items = buildPropertyChangeItems({
|
||||
filterProperties: testProperties,
|
||||
currentPropertyName: 'name',
|
||||
inputValue: 'STA',
|
||||
})
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].value).toBe('status')
|
||||
})
|
||||
|
||||
it('returns items with label and value', () => {
|
||||
const items = buildPropertyChangeItems({
|
||||
filterProperties: testProperties,
|
||||
currentPropertyName: 'count',
|
||||
inputValue: '',
|
||||
})
|
||||
expect(items[0]).toEqual({ value: 'name', label: 'Name' })
|
||||
expect(items[1]).toEqual({ value: 'status', label: 'Status' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isGroup,
|
||||
MenuItem,
|
||||
MenuItemGroup,
|
||||
ResolvedPropertyChange,
|
||||
SyncOptionsFunction,
|
||||
} from './types'
|
||||
|
||||
@@ -224,6 +225,54 @@ export function updateNestedOperator(
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNestedPropertyName(
|
||||
group: FilterGroup,
|
||||
path: number[],
|
||||
newPropertyName: string,
|
||||
newOperator?: string,
|
||||
newValue?: string
|
||||
): FilterGroup {
|
||||
return updateNestedFilter(group, path, (condition) => ({
|
||||
...condition,
|
||||
propertyName: newPropertyName,
|
||||
...(newOperator !== undefined ? { operator: newOperator } : {}),
|
||||
...(newValue !== undefined ? { value: newValue } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolvePropertyChange(
|
||||
currentOperator: string,
|
||||
currentValue: string,
|
||||
newProperty: FilterProperty
|
||||
): ResolvedPropertyChange {
|
||||
if (!currentOperator || !newProperty.operators) {
|
||||
return { operator: '', value: '', focusTarget: 'operator' }
|
||||
}
|
||||
|
||||
const operatorValues = newProperty.operators.map((op) =>
|
||||
isFilterOperatorObject(op) ? op.value : op
|
||||
)
|
||||
|
||||
if (!operatorValues.includes(currentOperator)) {
|
||||
return { operator: '', value: '', focusTarget: 'operator' }
|
||||
}
|
||||
|
||||
if (!currentValue) {
|
||||
return { operator: currentOperator, value: '', focusTarget: 'value' }
|
||||
}
|
||||
|
||||
if (newProperty.options && Array.isArray(newProperty.options)) {
|
||||
// New property has fixed options — only preserve value if it's in the list
|
||||
const optionValues = newProperty.options.map((opt) =>
|
||||
typeof opt === 'string' ? opt : 'value' in opt ? opt.value : ''
|
||||
)
|
||||
const preservedValue = optionValues.includes(currentValue) ? currentValue : ''
|
||||
return { operator: currentOperator, value: preservedValue, focusTarget: 'value' }
|
||||
}
|
||||
|
||||
return { operator: currentOperator, value: currentValue, focusTarget: 'value' }
|
||||
}
|
||||
|
||||
export function updateNestedLogicalOperator(group: FilterGroup, path: number[]): FilterGroup {
|
||||
if (path.length === 0) {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user