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
}