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:
Joshen Lim authored and GitHub committed 2026-09-16 16:33:36 +08:00
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) => {
+35 -13
View File
@@ -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
}