diff --git a/libs/shared/ui-utils/src/index.ts b/libs/shared/ui-utils/src/index.ts index 52d71ea33..f3c7b9045 100644 --- a/libs/shared/ui-utils/src/index.ts +++ b/libs/shared/ui-utils/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/hooks/useCombinedRefs'; export * from './lib/hooks/useDebounce'; export * from './lib/hooks/useDrivePicker'; export * from './lib/hooks/useFetchPageLayouts'; +export * from './lib/hooks/useFuzzySearchFilter'; export * from './lib/hooks/useGlobalEventHandler'; export * from './lib/hooks/useGoogleApi'; export * from './lib/hooks/useHover'; diff --git a/libs/shared/ui-utils/src/lib/hooks/useFuzzySearchFilter.ts b/libs/shared/ui-utils/src/lib/hooks/useFuzzySearchFilter.ts new file mode 100644 index 000000000..0df62aae5 --- /dev/null +++ b/libs/shared/ui-utils/src/lib/hooks/useFuzzySearchFilter.ts @@ -0,0 +1,29 @@ +import Fuse, { IFuseOptions } from 'fuse.js'; +import { useMemo, useState } from 'react'; +import { useDebounce } from './useDebounce'; + +const DEFAULT_OPTIONS: IFuseOptions = { + includeScore: true, + findAllMatches: true, + ignoreLocation: true, + includeMatches: true, + keys: ['label', 'value', 'secondaryLabel', 'tertiaryLabel'], +}; + +export function useFuzzySearchFilter(items: T[], filter: string, options: IFuseOptions = DEFAULT_OPTIONS) { + const fuse = useMemo(() => new Fuse(items, { ...options }), [items, options]); + + const filterText = useDebounce(filter, 300); + const [visibleItems, setVisibleItems] = useState(items); + + useMemo(() => { + if (filterText) { + const result = fuse.search(filterText); + setVisibleItems(result.map(({ item }) => item)); + } else { + setVisibleItems(items); + } + }, [filterText, fuse, items]); + + return visibleItems; +} diff --git a/libs/ui/src/lib/form/combobox/ComboboxWithDrillInItems.tsx b/libs/ui/src/lib/form/combobox/ComboboxWithDrillInItems.tsx index 84bb4c275..db0198968 100644 --- a/libs/ui/src/lib/form/combobox/ComboboxWithDrillInItems.tsx +++ b/libs/ui/src/lib/form/combobox/ComboboxWithDrillInItems.tsx @@ -10,8 +10,6 @@ export interface ComboboxWithDrillInItemsProps extends Pick (value: unknown, index: number, array: unknown[]) => boolean; onSelected: (item: Maybe) => void; /** Parent component is in charge of loading items and adding items as drillInItems to this item */ onLoadItems?: (item: ListItem) => Promise; @@ -41,7 +39,6 @@ export const ComboboxWithDrillInItems: FunctionComponent - multiWordObjectFilter>(['label', 'value', 'secondaryLabel', 'tertiaryLabel'], filter); const defaultSelectedItemLabelFn = (item: ListItem) => item.label; const defaultSelectedItemTitleFn = (item: ListItem) => item.title; @@ -28,8 +31,6 @@ export interface ComboboxWithItemsProps { }; /** Function called for each item to customize the props of */ itemProps?: (item: ListItem) => Partial; - /** Optional. If not provided, standard multi-word search will be used */ - filterFn?: (filter: string) => (value: unknown, index: number, array: unknown[]) => boolean; /** Used to customize what shows upon selection */ selectedItemLabelFn?: (item: ListItem) => string; selectedItemTitleFn?: (item: ListItem) => Maybe; @@ -51,7 +52,6 @@ export const ComboboxWithItems = forwardRef { const comboboxRef = useRef(null); - const [filterTextNonDebounced, setFilterText] = useState(''); - const filterText = useDebounce(filterTextNonDebounced, 300); + const [filterText, setFilterText] = useState(''); const [selectedItem, setSelectedItem] = useState>(() => selectedItemId ? items.find((item) => item.id === selectedItemId) : null ); - const [visibleItems, setVisibleItems] = useState(items); + const visibleItems = useFuzzySearchFilter(items, filterText); const [selectedItemLabel, setSelectedItemLabel] = useState(() => { if (selectedItem) { const selectedItem = items.find((item) => item.id === selectedItemId); @@ -112,17 +111,6 @@ export const ComboboxWithItems = forwardRef { - if (!filterText) { - setVisibleItems(items); - setFocusedIndex(null); - } else { - const filter = filterText.toLowerCase().trim(); - setVisibleItems(items.filter(filterFn(filter))); - setFocusedIndex(null); - } - }, [items, filterText, filterFn]); - const onInputEnter = useCallback(() => { if (visibleItems.length > 0) { onSelected(visibleItems[0]); diff --git a/package.json b/package.json index c0734bc1e..850907ee5 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "form-data": "^4.0.0", "formulon": "^6.25.2", "fs-extra": "^9.0.1", + "fuse.js": "^7.0.0", "helmet": "^4.1.1", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", diff --git a/yarn.lock b/yarn.lock index cd7f278ea..41df4ccec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16129,6 +16129,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3: resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuse.js@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" + integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== + fx@*: version "33.0.0" resolved "https://registry.yarnpkg.com/fx/-/fx-33.0.0.tgz#1b24260c5cfa08e03abfc034065fd95ab9873493"