Skip to content

Commit

Permalink
Use fuzzy search for comboboxes
Browse files Browse the repository at this point in the history
Implement fuzzy matching for combobox dropdowns to sort based on best match instead of alphabetical.

resolves #904
  • Loading branch information
paustint committed May 29, 2024
1 parent 4b770ad commit 0f84788
Show file tree
Hide file tree
Showing 6 changed files with 45 additions and 25 deletions.
1 change: 1 addition & 0 deletions libs/shared/ui-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
29 changes: 29 additions & 0 deletions libs/shared/ui-utils/src/lib/hooks/useFuzzySearchFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Fuse, { IFuseOptions } from 'fuse.js';
import { useMemo, useState } from 'react';
import { useDebounce } from './useDebounce';

const DEFAULT_OPTIONS: IFuseOptions<unknown> = {
includeScore: true,
findAllMatches: true,
ignoreLocation: true,
includeMatches: true,
keys: ['label', 'value', 'secondaryLabel', 'tertiaryLabel'],
};

export function useFuzzySearchFilter<T extends object>(items: T[], filter: string, options: IFuseOptions<unknown> = DEFAULT_OPTIONS) {
const fuse = useMemo(() => new Fuse<T>(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;
}
4 changes: 0 additions & 4 deletions libs/ui/src/lib/form/combobox/ComboboxWithDrillInItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ export interface ComboboxWithDrillInItemsProps extends Pick<ComboboxWithItemsPro
selectedItemId?: string | null;
/** Used as the heading in the dropdown when no items are selected. Will be pre-pended to child item labels */
rootHeadingLabel?: string;
/** Optional. If not provided, standard multi-word search will be used */
filterFn?: (filter: string) => (value: unknown, index: number, array: unknown[]) => boolean;
onSelected: (item: Maybe<ListItem>) => void;
/** Parent component is in charge of loading items and adding items as drillInItems to this item */
onLoadItems?: (item: ListItem) => Promise<ListItem[]>;
Expand Down Expand Up @@ -41,7 +39,6 @@ export const ComboboxWithDrillInItems: FunctionComponent<ComboboxWithDrillInItem
items,
selectedItemId,
rootHeadingLabel,
filterFn,
onSelected,
onLoadItems,
...rest
Expand Down Expand Up @@ -149,7 +146,6 @@ export const ComboboxWithDrillInItems: FunctionComponent<ComboboxWithDrillInItem
heading={heading}
items={currentItems}
selectedItemId={selectedItemId}
filterFn={filterFn}
onSelected={handleSelection}
onClose={handleClose}
{...rest}
Expand Down
30 changes: 9 additions & 21 deletions libs/ui/src/lib/form/combobox/ComboboxWithItems.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { focusElementFromRefWhenAvailable, getFlattenedListItemsById, menuItemSelectScroll, useDebounce } from '@jetstream/shared/ui-utils';
import { multiWordObjectFilter, NOOP } from '@jetstream/shared/utils';
import {
focusElementFromRefWhenAvailable,
getFlattenedListItemsById,
menuItemSelectScroll,
useFuzzySearchFilter,
} from '@jetstream/shared/ui-utils';
import { NOOP } from '@jetstream/shared/utils';
import { ListItem, Maybe } from '@jetstream/types';
import isNumber from 'lodash/isNumber';
import { createRef, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Combobox, ComboboxPropsRef, ComboboxSharedProps } from './Combobox';
import { ComboboxListItem, ComboboxListItemProps } from './ComboboxListItem';
import { ComboboxListItemHeading } from './ComboboxListItemHeading';

const defaultFilterFn = (filter) =>
multiWordObjectFilter<ListItem<string, any>>(['label', 'value', 'secondaryLabel', 'tertiaryLabel'], filter);
const defaultSelectedItemLabelFn = (item: ListItem) => item.label;
const defaultSelectedItemTitleFn = (item: ListItem) => item.title;

Expand All @@ -28,8 +31,6 @@ export interface ComboboxWithItemsProps {
};
/** Function called for each item to customize the props of <ComboboxListItem /> */
itemProps?: (item: ListItem) => Partial<ComboboxListItemProps>;
/** 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<string>;
Expand All @@ -51,7 +52,6 @@ export const ComboboxWithItems = forwardRef<ComboboxWithItemsRef, ComboboxWithIt
selectedItemId,
heading,
itemProps = NOOP,
filterFn = defaultFilterFn,
selectedItemLabelFn = defaultSelectedItemLabelFn,
selectedItemTitleFn = defaultSelectedItemTitleFn,
onSelected,
Expand All @@ -60,12 +60,11 @@ export const ComboboxWithItems = forwardRef<ComboboxWithItemsRef, ComboboxWithIt
ref
) => {
const comboboxRef = useRef<ComboboxPropsRef>(null);
const [filterTextNonDebounced, setFilterText] = useState<string>('');
const filterText = useDebounce(filterTextNonDebounced, 300);
const [filterText, setFilterText] = useState<string>('');
const [selectedItem, setSelectedItem] = useState<Maybe<ListItem>>(() =>
selectedItemId ? items.find((item) => item.id === selectedItemId) : null
);
const [visibleItems, setVisibleItems] = useState(items);
const visibleItems = useFuzzySearchFilter(items, filterText);
const [selectedItemLabel, setSelectedItemLabel] = useState<string | null>(() => {
if (selectedItem) {
const selectedItem = items.find((item) => item.id === selectedItemId);
Expand Down Expand Up @@ -112,17 +111,6 @@ export const ComboboxWithItems = forwardRef<ComboboxWithItemsRef, ComboboxWithIt
}
}, [selectedItem, selectedItemLabelFn, selectedItemTitleFn]);

useEffect(() => {
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]);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 0f84788

Please sign in to comment.