From f8c84ec5888cde091afa94bb21e8a44e1e03ffae Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Mon, 14 Sep 2026 17:11:35 +0200 Subject: [PATCH 1/3] feat(SF): implement lazy loading for filter option values in SearchFilter --- src/SearchFilter/SearchFilter.stories.tsx | 29 +++ src/SearchFilter/SearchFilter.tsx | 187 +++++++++++++----- .../SearchFilterDropdown.styled.ts | 5 + .../SearchFilterDropdown.tsx | 16 +- src/SearchFilter/types.ts | 1 + 5 files changed, 186 insertions(+), 52 deletions(-) diff --git a/src/SearchFilter/SearchFilter.stories.tsx b/src/SearchFilter/SearchFilter.stories.tsx index 7f43fe5..08f8d6f 100644 --- a/src/SearchFilter/SearchFilter.stories.tsx +++ b/src/SearchFilter/SearchFilter.stories.tsx @@ -624,3 +624,32 @@ export const CustomRangeDebug: Story = { }, render: (args) => , } + +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([]) + + 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 + }, +} diff --git a/src/SearchFilter/SearchFilter.tsx b/src/SearchFilter/SearchFilter.tsx index 208b2d9..a0f1206 100644 --- a/src/SearchFilter/SearchFilter.tsx +++ b/src/SearchFilter/SearchFilter.tsx @@ -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, { @@ -73,7 +73,7 @@ export const SearchFilter = forwardRef( filters = [], onChange, onFinish, - options = [], + options: rawOptions = [], groupOptions = [], quickActions, onQuickAction, @@ -109,6 +109,28 @@ export const SearchFilter = forwardRef( const { enableMultiple: enableGlobalSearchMultiple } = globalSearchConfig || {} + const [lazyValues, setLazyValues] = useState>({}) + const lazyRequestsRef = useRef(new Set()) + + 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 } })), + ) + .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) const [dropdownOptions, setOptions] = useState(null) const [search, setSearch] = useState('') @@ -327,20 +349,12 @@ export const SearchFilter = forwardRef( // 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) @@ -356,11 +370,10 @@ export const SearchFilter = forwardRef( 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) @@ -433,26 +446,22 @@ export const SearchFilter = forwardRef( } } } 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) @@ -507,29 +516,57 @@ export const SearchFilter = forwardRef( 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 @@ -857,11 +894,10 @@ export const SearchFilter = forwardRef( 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] @@ -948,7 +984,7 @@ export const SearchFilter = forwardRef( label={filter.label} inverted={filter.inverted} operator={filter.operator} - values={filter.values} + values={getDisplayValues(filter, option)} icon={filter.icon} isCustom={filter.isCustom} index={index} @@ -1051,6 +1087,7 @@ export const SearchFilter = forwardRef( isCustomAllowed={ !!parentOption?.allowsCustomValues || (!parentOption && !!enableGlobalSearch) } + valuesStatus={parentOption ? lazyValues[parentOption.id]?.status : undefined} isHasValueAllowed={!!parentOption?.allowHasValue} isNoValueAllowed={!!parentOption?.allowNoValue} isInvertedAllowed={!!parentOption?.allowExcludes} @@ -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) => { + 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 => ({ + 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, +}) diff --git a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts index 223a425..1fa300b 100644 --- a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts +++ b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts @@ -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; diff --git a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx index c20cea1..3999e3d 100644 --- a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx +++ b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx @@ -35,6 +35,7 @@ export interface SearchFilterDropdownProps { searchInputRef?: React.RefObject listRef?: React.RefObject isCustomAllowed: boolean + valuesStatus?: 'loading' | 'loaded' | 'error' // state of the parent filter's lazily loaded values isHasValueAllowed?: boolean isNoValueAllowed?: boolean isInvertedAllowed?: boolean @@ -70,6 +71,7 @@ const SearchFilterDropdown = forwardRef {hasLevelDivider &&