From 32b1a0f4920ec3472a5bb991dcf130dc950e331d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 16 Sep 2026 16:33:36 +0800 Subject: [PATCH] FilterBar to disable filter options if already selected (#50331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Updates FilterBar UI component to prevent selecting a filter option that's already selected + adds a check: image image ## 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. --- .../src/FilterBar/CommandListItem.tsx | 22 +++++++-- .../src/FilterBar/DefaultCommandList.tsx | 3 ++ .../src/FilterBar/FilterCondition.tsx | 8 ++-- .../src/FilterBar/menuItems.test.ts | 25 ++++++++++ .../ui-patterns/src/FilterBar/menuItems.ts | 48 ++++++++++++++----- packages/ui-patterns/src/FilterBar/types.ts | 1 + packages/ui-patterns/src/FilterBar/utils.ts | 12 +++++ 7 files changed, 100 insertions(+), 19 deletions(-) diff --git a/packages/ui-patterns/src/FilterBar/CommandListItem.tsx b/packages/ui-patterns/src/FilterBar/CommandListItem.tsx index 774d92e5ae4..e0fd1199a5b 100644 --- a/packages/ui-patterns/src/FilterBar/CommandListItem.tsx +++ b/packages/ui-patterns/src/FilterBar/CommandListItem.tsx @@ -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({
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}`} > + {showSelection && ( + <> + {item.disabled ? ( + + ) : ( +
+ )} + + )} {includeIcon && item.icon} {getActionItemLabel(item)} diff --git a/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx b/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx index 761019dfc25..a84e996a17a 100644 --- a/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx +++ b/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx @@ -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(null) @@ -73,6 +75,7 @@ export function DefaultCommandList({ )} {groupData.items.map(({ item, 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({ }) ) : ( + onSelect={(item) => { + if (item.disabled) return item.isCustom ? setShowValueCustom(true) : handleSelectMenuItem(item) - } + }} includeIcon /> )} diff --git a/packages/ui-patterns/src/FilterBar/menuItems.test.ts b/packages/ui-patterns/src/FilterBar/menuItems.test.ts index 52c1a95b984..ba5358c7052 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.test.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.test.ts @@ -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) => { diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts index 8ed83c386d2..3031b95e00a 100644 --- a/packages/ui-patterns/src/FilterBar/menuItems.ts +++ b/packages/ui-patterns/src/FilterBar/menuItems.ts @@ -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 }): 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): 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 ): 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 })) } diff --git a/packages/ui-patterns/src/FilterBar/types.ts b/packages/ui-patterns/src/FilterBar/types.ts index 5a1a29b3216..af18893e0eb 100644 --- a/packages/ui-patterns/src/FilterBar/types.ts +++ b/packages/ui-patterns/src/FilterBar/types.ts @@ -104,6 +104,7 @@ export type MenuItem = { isFreeformSearch?: boolean freeformPropertyName?: string freeformValue?: string + disabled?: boolean } export type GroupedMenuItem = { diff --git a/packages/ui-patterns/src/FilterBar/utils.ts b/packages/ui-patterns/src/FilterBar/utils.ts index c89a39e846b..be7e9769396 100644 --- a/packages/ui-patterns/src/FilterBar/utils.ts +++ b/packages/ui-patterns/src/FilterBar/utils.ts @@ -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 }