Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/SearchFilter/SearchFilter.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,32 @@ export const CustomRangeDebug: Story = {
},
render: (args) => <SearchFilterCustomRangeDebug {...args} baseOptions={options} />,
}

const lazyPaletteValues = [
{ id: 'red', label: 'Red', color: '#F44336', icon: 'circle' },
{ id: 'green', label: 'Green', color: '#4CAF50', icon: 'circle' },
{ id: 'blue', label: 'Blue', color: '#2196F3', icon: 'circle' },
]

export const LazyOptions: Story = {
args: {},
render: (args) => {
const [filters, setFilters] = useState<Filter[]>([])

const lazyOptions: Option[] = [
...options,
{
id: 'palette',
label: 'Palette (loads on open)',
icon: 'palette',
operator: 'OR',
allowExcludes: true,
values: [],
loadValues: () =>
new Promise((resolve) => setTimeout(() => resolve(lazyPaletteValues), 1500)),
},
]

return <SearchFilter {...args} options={lazyOptions} filters={filters} onChange={setFilters} />
},
}
187 changes: 137 additions & 50 deletions src/SearchFilter/SearchFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, useImperativeHandle, forwardRef } from 'react'

import { Filter, FilterOperator, Option, SearchFilterGroupOption } from './types'
import { Filter, FilterOperator, FilterValue, Option, SearchFilterGroupOption } from './types'
import * as Styled from './SearchFilter.styled'
import { SearchFilterItem, SearchFilterItemProps } from './SearchFilterItem/SearchFilterItem'
import SearchFilterDropdown, {
Expand Down Expand Up @@ -73,7 +73,7 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
filters = [],
onChange,
onFinish,
options = [],
options: rawOptions = [],
groupOptions = [],
quickActions,
onQuickAction,
Expand Down Expand Up @@ -109,6 +109,28 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(

const { enableMultiple: enableGlobalSearchMultiple } = globalSearchConfig || {}

const [lazyValues, setLazyValues] = useState<Record<string, LazyValuesState>>({})
const lazyRequestsRef = useRef(new Set<string>())

const options = useMemo(() => withLazyValues(rawOptions, lazyValues), [rawOptions, lazyValues])

const loadOptionValues = (option?: Option) => {
if (!option?.loadValues || lazyRequestsRef.current.has(option.id)) return
const { id } = option
lazyRequestsRef.current.add(id)
setLazyValues((current) => ({ ...current, [id]: { status: 'loading', values: [] } }))
option
.loadValues()
.then((values) =>
setLazyValues((current) => ({ ...current, [id]: { status: 'loaded', values } })),
)
Comment on lines +122 to +126
.catch(() => {
// a failed load is retried the next time the filter opens
lazyRequestsRef.current.delete(id)
setLazyValues((current) => ({ ...current, [id]: { status: 'error', values: [] } }))
})
}

const [dropdownParentId, setDropdownParentId] = useState<null | string>(null)
const [dropdownOptions, setOptions] = useState<Option[] | null>(null)
const [search, setSearch] = useState('')
Expand Down Expand Up @@ -327,20 +349,12 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
// boolean options without explicit values are one-click toggles: add
// immediately with an "on" value and close, instead of opening a values panel
if (!parentId && option.type === 'boolean' && !option.values?.length) {
const {
group: _group,
search: _search,
tooltip: _tooltip,
...filterOptionData
} = filterOption
const addFilter: Filter = {
...filterOptionData,
const addFilter = createFilterState(filterOption, {
id: newId,
// value label = filter name so the compact chip (label hidden) reads the
// filter name instead of "Yes"
values: [{ id: 'true', label: option.label }],
}
delete (addFilter as Option).allowsCustomValues
})
const updatedFilters = [...filters, addFilter]
onChange(updatedFilters)
handleClose(updatedFilters)
Expand All @@ -356,11 +370,10 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
if (option.searchOnly) {
const parentOption = findOption(options, option.parentId)
if (parentOption) {
parentFilter = {
...parentOption,
parentFilter = createFilterState(parentOption, {
id: buildFilterId(option.parentId || ''),
values: [],
}
})
}
} else {
parentFilter = filters.find((filter) => filter.id === parentId)
Expand Down Expand Up @@ -433,26 +446,22 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
}
}
} else {
const {
group: _group,
search: _search,
tooltip: _tooltip,
...filterOptionData
} = filterOption
const addFilter = { ...filterOptionData, id: newId, values: [] }
// remove not required fields
delete addFilter.allowsCustomValues
const addFilter = createFilterState(filterOption, { id: newId, values: [] })

// add to filters top level
onChange([...filters, addFilter])
}

// if there are values set the next dropdownOptions
// or the option allows custom values (text)
if (!parentId && ((values && values.length > 0) || option.allowsCustomValues)) {
// or the option allows custom values (text), or it loads its values lazily
if (
!parentId &&
((values && values.length > 0) || option.allowsCustomValues || option.loadValues)
) {
const newOptions = values?.map((value) => ({ ...value, parentId: newId })) || []

openOptions(newOptions, newId)
loadOptionValues(filterOption)
// enter inline chip editing mode so the chip's search input drives the value selection
setEditingSearchChipId(newId)
setIsEditingExisting(false)
Expand Down Expand Up @@ -507,29 +516,57 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
onFinish && onFinish(updatedFilters)
}

const getEditValueOptions = (id: string, filter?: Filter): Option[] => {
if (!filter?.values?.length) return options

// Merge options with filter values to include custom values
const newOptions = mergeOptionsWithFilterValues(filter, options).map((value) => ({
...value,
parentId: id,
isSelected: getIsValueSelected(value.id, id, filters),
}))

const filterName = getFilterFromId(id)
if (sortSelectedToTopFields.includes(filterName)) {
// sort selected to top
newOptions.sort((a, b) => {
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
return 0
})
}
return newOptions
}

const handleEditFilterValues = (id: string, filter?: Filter) => {
if (filter && filter.values && filter.values.length > 0) {
// Merge options with filter values to include custom values
const newOptions = mergeOptionsWithFilterValues(filter, options).map((value) => ({
...value,
parentId: id,
isSelected: getIsValueSelected(value.id, id, filters),
}))

const filterName = getFilterFromId(id)
if (sortSelectedToTopFields.includes(filterName)) {
// sort selected to top
newOptions.sort((a, b) => {
if (a.isSelected && !b.isSelected) return -1
if (!a.isSelected && b.isSelected) return 1
return 0
})
}
openOptions(newOptions, id)
openOptions(getEditValueOptions(id, filter), id)
const option = findOption(options, getFilterFromId(id))
loadOptionValues(option)
}

// Values loaded after the panel opened (see Option.loadValues) replace the snapshot taken on open
const parentValues = parentOption?.values
const parentValuesRef = useRef(parentValues)
useEffect(() => {
if (parentValuesRef.current === parentValues) return
parentValuesRef.current = parentValues
if (!dropdownOptions || !dropdownParentId || !parentOption || isGroupMenu) return

if (isEditingExisting) {
const filter = filters.find((candidate) => candidate.id === dropdownParentId)
setOptions(getEditValueOptions(dropdownParentId, filter))
} else {
openOptions(options, id)
setOptions(parentValues?.map((value) => ({ ...value, parentId: dropdownParentId })) || [])
}
}
}, [parentValues])

// Active filters need their values too, so chips show labels instead of raw ids
const activeFilterNames = filters.map((filter) => getFilterFromId(filter.id)).join(',')
useEffect(() => {
filters.forEach((filter) =>
loadOptionValues(rawOptions.find((option) => option.id === getFilterFromId(filter.id))),
)
}, [activeFilterNames])

const handleRemoveFilter = (id: string) => {
// remove a filter by id
Expand Down Expand Up @@ -857,11 +894,10 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(

if (!selectedValues.length) return

const updatedFilter = {
...(existing || option),
const updatedFilter = createFilterState(existing || option, {
id: existing?.id || buildFilterId(optionId),
values: selectedValues,
}
})
const updatedFilters = existing
? filters.map((filter) => (filter.id === existing.id ? updatedFilter : filter))
: [...filters, updatedFilter]
Expand Down Expand Up @@ -948,7 +984,7 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
label={filter.label}
inverted={filter.inverted}
operator={filter.operator}
values={filter.values}
values={getDisplayValues(filter, option)}
icon={filter.icon}
isCustom={filter.isCustom}
index={index}
Expand Down Expand Up @@ -1051,6 +1087,7 @@ export const SearchFilter = forwardRef<SearchFilterRef, SearchFilterProps>(
isCustomAllowed={
!!parentOption?.allowsCustomValues || (!parentOption && !!enableGlobalSearch)
}
valuesStatus={parentOption ? lazyValues[parentOption.id]?.status : undefined}
isHasValueAllowed={!!parentOption?.allowHasValue}
isNoValueAllowed={!!parentOption?.allowNoValue}
isInvertedAllowed={!!parentOption?.allowExcludes}
Expand Down Expand Up @@ -1201,3 +1238,53 @@ const mergeOptionsWithFilterValues = (filter: Filter, options: Option[]): Option

return mergedOptions
}

type LazyValuesState = {
status: 'loading' | 'loaded' | 'error'
values: FilterValue[]
}

const withLazyValues = (options: Option[], lazyValues: Record<string, LazyValuesState>) => {
if (!Object.keys(lazyValues).length) return options
return options.map((option) => {
const loaded = lazyValues[option.id]
if (loaded?.status !== 'loaded') return option
const existing = option.values || []
const extra = existing.filter((value) => !loaded.values.some((l) => l.id === value.id))
return { ...option, values: [...loaded.values, ...extra] }
})
}

// Chips built before lazy values arrived carry the raw id as label
const getDisplayValues = (filter: Filter, option?: Option): FilterValue[] | undefined => {
if (!option?.loadValues || !filter.values) return filter.values
return filter.values.map((value) => {
if (value.label !== value.id) return value
const loaded = option.values?.find((candidate) => candidate.id === value.id)
if (!loaded) return value
return {
...value,
label: loaded.label,
icon: value.icon ?? loaded.icon,
img: value.img ?? loaded.img,
color: value.color ?? loaded.color,
}
})
}

// Only filter fields are kept: option-only data (loadValues, loaded values, React content) must not leak into filters
const createFilterState = (option: Option | Filter, overrides: Partial<Filter> = {}): Filter => ({
id: option.id,
label: option.label,
type: option.type,
inverted: option.inverted,
operator: option.operator,
icon: option.icon,
img: option.img,
values: option.values,
isCustom: option.isCustom,
isReadonly: option.isReadonly,
singleSelect: option.singleSelect,
fieldType: option.fieldType,
...overrides,
})
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export const OptionsList = styled.ul`
}
`

export const Loading = styled.span`
padding: 6px 8px;
color: var(--md-sys-color-outline);
`

export const Item = styled.li`
margin: 0;
list-style: none;
Expand Down
16 changes: 14 additions & 2 deletions src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface SearchFilterDropdownProps {
searchInputRef?: React.RefObject<HTMLInputElement>
listRef?: React.RefObject<HTMLUListElement>
isCustomAllowed: boolean
valuesStatus?: 'loading' | 'loaded' | 'error' // state of the parent filter's lazily loaded values
isHasValueAllowed?: boolean
isNoValueAllowed?: boolean
isInvertedAllowed?: boolean
Expand Down Expand Up @@ -70,6 +71,7 @@ const SearchFilterDropdown = forwardRef<SearchFilterDropdownRef, SearchFilterDro
searchInputRef,
listRef,
isCustomAllowed,
valuesStatus,
isHasValueAllowed,
isNoValueAllowed,
isInvertedAllowed,
Expand Down Expand Up @@ -373,6 +375,7 @@ const SearchFilterDropdown = forwardRef<SearchFilterDropdownRef, SearchFilterDro
groupItems,
values: optionValues,
allowsCustomValues,
loadValues,
label,
searchLabel,
icon,
Expand Down Expand Up @@ -407,7 +410,10 @@ const SearchFilterDropdown = forwardRef<SearchFilterDropdownRef, SearchFilterDro
: undefined
const opensSubmenu =
!parentId &&
(Boolean(groupItems) || Boolean(optionValues?.length) || !!allowsCustomValues)
(Boolean(groupItems) ||
Boolean(optionValues?.length) ||
!!allowsCustomValues ||
!!loadValues)
return (
<Fragment key={id + '-' + parentId}>
{hasLevelDivider && <Styled.Divider aria-hidden="true" />}
Expand Down Expand Up @@ -449,7 +455,13 @@ const SearchFilterDropdown = forwardRef<SearchFilterDropdownRef, SearchFilterDro
)
},
)}
{filteredOptions.length === 0 && !isCustomAllowed && <span>No filters found</span>}
{valuesStatus === 'loading' && <Styled.Loading>Loading...</Styled.Loading>}
{valuesStatus === 'error' && <Styled.Loading>Could not load values</Styled.Loading>}
Comment on lines +458 to +459
{filteredOptions.length === 0 &&
!isCustomAllowed &&
(!valuesStatus || valuesStatus === 'loaded') && (
<span>No filters found</span>
)}
{parentId && !!parentFilter?.values?.length && (
<Styled.Toolbar className="toolbar">
<Spacer />
Expand Down
1 change: 1 addition & 0 deletions src/SearchFilter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface Option extends Filter {
allowNoValue?: boolean // allows the filter to have "no value"
allowHasValue?: boolean // allows the filter to have "has a value"
allowsCustomValues?: boolean // allows the filter to have custom values
loadValues?: () => Promise<FilterValue[]> // fetched on first open (and for active filters), merged into values
allowExcludes?: boolean // allows the filter to be inverted
operatorChangeable?: boolean // allows the operator to be changed
color?: string | null // color of the filter (not used for root options)
Expand Down