mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
FilterBar to disable filter options if already selected (#50331)
## Context Updates FilterBar UI component to prevent selecting a filter option that's already selected + adds a check: <img width="532" height="368" alt="image" src="https://github.com/user-attachments/assets/609f3da2-d627-48d5-9c3e-d6adbc24110b" /> <img width="577" height="373" alt="image" src="https://github.com/user-attachments/assets/5b56eefc-c87b-4254-b1e2-1a5e521b08fe" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Filter menus now identify values already used in another condition for the same property. * Duplicate values are shown as disabled and cannot be selected. * Optional selection indicators can be displayed in filter menus. * Disabled options include clear visual styling and accessibility information. * **Bug Fixes** * Prevented disabled filter values from being selected through mouse interactions or keyboard navigation. * The active condition’s own value remains available for selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
881a7d3151
commit
32b1a0f492
7 files changed
+100
-19
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { OperatorSymbolBadge } from './OperatorSymbolBadge'
|
||||
@@ -8,6 +9,7 @@ export type CommandListItemProps = {
|
||||
item: MenuItem
|
||||
isHighlighted: boolean
|
||||
includeIcon: boolean
|
||||
showSelection?: boolean
|
||||
onSelect: (item: MenuItem) => void
|
||||
setRef: (el: HTMLDivElement | null) => void
|
||||
}
|
||||
@@ -16,6 +18,7 @@ export function CommandListItem({
|
||||
item,
|
||||
isHighlighted,
|
||||
includeIcon,
|
||||
showSelection = false,
|
||||
onSelect,
|
||||
setRef,
|
||||
}: CommandListItemProps) {
|
||||
@@ -23,15 +26,28 @@ export function CommandListItem({
|
||||
<div
|
||||
ref={setRef}
|
||||
role="option"
|
||||
onClick={() => onSelect(item)}
|
||||
aria-disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (!item.disabled) onSelect(item)
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center justify-between gap-2 px-2 h-[28px] text-xs cursor-pointer select-none outline-hidden text-foreground',
|
||||
'relative flex items-center justify-between gap-2 px-2 h-[28px] text-xs select-none outline-hidden',
|
||||
item.disabled ? 'text-muted' : 'text-foreground cursor-pointer',
|
||||
isHighlighted && 'bg-overlay-hover',
|
||||
!isHighlighted && 'hover:bg-surface-200'
|
||||
!isHighlighted && !item.disabled && 'hover:bg-surface-200'
|
||||
)}
|
||||
data-testid={`filter-menu-item-${item.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{showSelection && (
|
||||
<>
|
||||
{item.disabled ? (
|
||||
<Check className="w-3.5 h-3.5 shrink-0 text-foreground-muted" />
|
||||
) : (
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{includeIcon && item.icon}
|
||||
<span className="truncate">{getActionItemLabel(item)}</span>
|
||||
</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ export type DefaultCommandListProps = {
|
||||
highlightedIndex: number
|
||||
onSelect: (item: MenuItem) => void
|
||||
includeIcon?: boolean
|
||||
showSelection?: boolean
|
||||
grouped?: boolean
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export function DefaultCommandList({
|
||||
highlightedIndex,
|
||||
onSelect,
|
||||
includeIcon = true,
|
||||
showSelection = false,
|
||||
grouped = false,
|
||||
}: DefaultCommandListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
@@ -73,6 +75,7 @@ export function DefaultCommandList({
|
||||
)}
|
||||
{groupData.items.map(({ item, index }) => (
|
||||
<CommandListItem
|
||||
showSelection={showSelection}
|
||||
key={`${item.value}-${item.label}`}
|
||||
item={item}
|
||||
isHighlighted={index === highlightedIndex}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function FilterCondition({
|
||||
valueItems.length,
|
||||
(index) => {
|
||||
const item = valueItems[index]
|
||||
if (!item) return
|
||||
if (!item || item.disabled) return
|
||||
if (item.isCustom) {
|
||||
setShowValueCustom(true)
|
||||
} else {
|
||||
@@ -421,11 +421,13 @@ export function FilterCondition({
|
||||
})
|
||||
) : (
|
||||
<DefaultCommandList
|
||||
showSelection
|
||||
items={valueItems}
|
||||
highlightedIndex={valHighlightedIndex}
|
||||
onSelect={(item) =>
|
||||
onSelect={(item) => {
|
||||
if (item.disabled) return
|
||||
item.isCustom ? setShowValueCustom(true) : handleSelectMenuItem(item)
|
||||
}
|
||||
}}
|
||||
includeIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -161,6 +161,31 @@ describe('buildValueItems', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('disables options already used by another condition on the same property', () => {
|
||||
const filters: FilterGroup = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [
|
||||
{ propertyName: 'name', operator: '=', value: 'alice' },
|
||||
{ propertyName: 'name', operator: '=', value: '' },
|
||||
],
|
||||
}
|
||||
|
||||
const items = buildValueItems(
|
||||
{ type: 'value', path: [1] },
|
||||
filters,
|
||||
filterProperties,
|
||||
{},
|
||||
{},
|
||||
'',
|
||||
false
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ value: 'alice', label: 'Alice', disabled: true },
|
||||
{ value: 'bob', label: 'Bob' },
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['~~*', '!~~*'])(
|
||||
'suppresses value suggestions for pattern-group operator `%s`',
|
||||
(operator) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ActiveInputState, FilterBarAction, FilterGroup, FilterProperty, MenuItem } from './types'
|
||||
import {
|
||||
collectConditions,
|
||||
findConditionByPath,
|
||||
isCustomOptionObject,
|
||||
isFilterOperatorObject,
|
||||
isFilterOptionObject,
|
||||
pathsEqual,
|
||||
} from './utils'
|
||||
|
||||
export function buildOperatorItems(
|
||||
@@ -142,8 +144,16 @@ export function buildValueItems(
|
||||
|
||||
if (!property) return items
|
||||
|
||||
// Values already used by other conditions on this same property shouldn't be re-selectable.
|
||||
const usedValues = new Set(
|
||||
collectConditions(activeFilters)
|
||||
.filter(({ path }) => !pathsEqual(path, activeInput.path))
|
||||
.filter(({ condition }) => condition.propertyName === activeCondition?.propertyName)
|
||||
.map(({ condition }) => String(condition.value))
|
||||
)
|
||||
|
||||
if (activeCondition?.operator === 'is') {
|
||||
return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus)
|
||||
return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus, usedValues)
|
||||
}
|
||||
|
||||
// Pattern-matching operators (e.g. iLike) search for a substring, not an exact
|
||||
@@ -174,10 +184,11 @@ export function buildValueItems(
|
||||
inputValue,
|
||||
hasTypedSinceFocus,
|
||||
showCount: activeCondition?.operator === '=',
|
||||
usedValues,
|
||||
})
|
||||
)
|
||||
} else if (propertyOptionsCache[property.name]) {
|
||||
items.push(...getCachedOptionItems(propertyOptionsCache[property.name].options))
|
||||
items.push(...getCachedOptionItems(propertyOptionsCache[property.name].options, usedValues))
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -188,11 +199,13 @@ function getArrayOptionItems({
|
||||
inputValue,
|
||||
hasTypedSinceFocus,
|
||||
showCount,
|
||||
usedValues,
|
||||
}: {
|
||||
options: any[]
|
||||
inputValue: string
|
||||
hasTypedSinceFocus: boolean
|
||||
showCount?: boolean
|
||||
usedValues?: Set<string>
|
||||
}): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
const normalizedInput = inputValue.toLowerCase()
|
||||
@@ -203,7 +216,7 @@ function getArrayOptionItems({
|
||||
for (const option of options) {
|
||||
if (typeof option === 'string') {
|
||||
if (!shouldFilter || option.toLowerCase().includes(normalizedInput)) {
|
||||
items.push({ value: option, label: option })
|
||||
items.push({ value: option, label: option, disabled: usedValues?.has(option) || undefined })
|
||||
}
|
||||
} else if (isFilterOptionObject(option)) {
|
||||
if (!shouldFilter || option.label.toLowerCase().includes(normalizedInput)) {
|
||||
@@ -211,6 +224,7 @@ function getArrayOptionItems({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
count: showCount ? option.count : undefined,
|
||||
disabled: usedValues?.has(option.value) || undefined,
|
||||
})
|
||||
}
|
||||
} else if (isCustomOptionObject(option)) {
|
||||
@@ -227,19 +241,24 @@ function getArrayOptionItems({
|
||||
return items
|
||||
}
|
||||
|
||||
function getCachedOptionItems(options: any[]): MenuItem[] {
|
||||
function getCachedOptionItems(options: any[], usedValues?: Set<string>): MenuItem[] {
|
||||
return options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
return { value: option, label: option, disabled: usedValues?.has(option) || undefined }
|
||||
}
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
disabled: usedValues?.has(option.value) || undefined,
|
||||
}
|
||||
return { value: option.value, label: option.label }
|
||||
})
|
||||
}
|
||||
|
||||
function getIsOperatorValueItems(
|
||||
property: FilterProperty,
|
||||
inputValue: string,
|
||||
hasTypedSinceFocus: boolean
|
||||
hasTypedSinceFocus: boolean,
|
||||
usedValues?: Set<string>
|
||||
): MenuItem[] {
|
||||
const options: { value: string; label: string }[] = [
|
||||
{ value: 'null', label: 'NULL' },
|
||||
@@ -251,11 +270,14 @@ function getIsOperatorValueItems(
|
||||
}
|
||||
|
||||
const shouldFilter = hasTypedSinceFocus && inputValue.length > 0
|
||||
if (!shouldFilter) return options
|
||||
const filtered = shouldFilter
|
||||
? options.filter((opt) => {
|
||||
const normalizedInput = inputValue.toLowerCase()
|
||||
return (
|
||||
opt.label.toLowerCase().includes(normalizedInput) || opt.value.includes(normalizedInput)
|
||||
)
|
||||
})
|
||||
: options
|
||||
|
||||
const normalizedInput = inputValue.toLowerCase()
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(normalizedInput) || opt.value.includes(normalizedInput)
|
||||
)
|
||||
return filtered.map((opt) => ({ ...opt, disabled: usedValues?.has(opt.value) || undefined }))
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export type MenuItem = {
|
||||
isFreeformSearch?: boolean
|
||||
freeformPropertyName?: string
|
||||
freeformValue?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type GroupedMenuItem = {
|
||||
|
||||
@@ -56,6 +56,18 @@ export function findConditionByPath(group: FilterGroup, path: number[]): FilterC
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectConditions(
|
||||
group: FilterGroup,
|
||||
path: number[] = []
|
||||
): { condition: FilterCondition; path: number[] }[] {
|
||||
return group.conditions.flatMap((condition, index) => {
|
||||
const currentPath = [...path, index]
|
||||
return isGroup(condition)
|
||||
? collectConditions(condition, currentPath)
|
||||
: [{ condition, path: currentPath }]
|
||||
})
|
||||
}
|
||||
|
||||
export function isCustomOptionObject(option: any): option is CustomOptionObject {
|
||||
return typeof option === 'object' && option !== null && 'component' in option
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user