Skip to content

Commit

Permalink
STCOM-1340v2 Optimize rendering/filtering of Selection. (#2346)
Browse files Browse the repository at this point in the history
* optimize rendering of 'Selection' options

* just memoize the results

* fix problem with Selection option grouping

* Update CHANGELOG.md
  • Loading branch information
JohnC-80 authored Sep 17, 2024
1 parent be2eacb commit 5f65f9a
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 116 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* Fix visual issue with `<Selection>` where dropdown caret shifts downward when a validation message is present. Refs STCOM-1323.
* Fix MCL Paging bug - back button being incorrectly disabled. This was due to an inaccurate rowcount/rowIndex value. Refs STCOM-1331.
* `Datepicker` - add the `hideCalendarButton` property to hide the calendar icon button. Refs STCOM-1342.
* Optimize rendering of 2k+ option lists in `Selection`. Refs STCOM-1340.

## [12.1.0](https://github.com/folio-org/stripes-components/tree/v12.1.0) (2024-03-12)
[Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.0.0...v12.1.0)
Expand Down
151 changes: 103 additions & 48 deletions lib/Selection/Selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
import { useCombobox } from 'downshift';
import classNames from 'classnames';
import isEqual from 'lodash/isEqual';
import debounce from 'lodash/debounce';
import formField from '../FormField';
import parseMeta from '../FormField/parseMeta';

Expand All @@ -14,12 +15,13 @@ import {
filterOptionList,
getSelectedObject,
flattenOptionList,
reconcileReducedIndex
reconcileReducedIndex,
} from './utils';
import SelectionOverlay from './SelectionOverlay';

import Label from '../Label';
import TextFieldIcon from '../TextField/TextFieldIcon';
import Icon from '../Icon';
import useProvidedRefOrCreate from '../../hooks/useProvidedRefOrCreate'
import formStyles from '../sharedStyles/form.css';
import css from './Selection.css';
Expand All @@ -29,7 +31,7 @@ import useProvidedIdOrCreate from '../../hooks/useProvidedIdOrCreate';
// a rough way to discern if an option is grouped or not - if it finds an index at the top level
// of dataOptions, it's not grouped...
const optionIsGrouped = (item, dataOptions) => {
return dataOptions.findIndex((i) => isEqual(i, item)) === -1;
return dataOptions.findIndex((i) => i === item) === -1;
};

const getControlWidth = (control) => {
Expand All @@ -46,14 +48,13 @@ const getItemClass = (item, i, props) => {
return;
}

const cursored = i === highlightedIndex ? ' ' + css.cursor : '';
const selected = value === selectedItem?.value ? ' ' + css.selected : '';
const grouped = optionIsGrouped(item, dataOptions) ? ' ' + css.groupedOption : '';

// eslint-disable-next-line consistent-return
return classNames(
css.option,
{ [css.cursor]: i === highlightedIndex },
{ [`${css.selected}`]: value === selectedItem?.value },
{ [`${css.groupedOption}`]: optionIsGrouped(item, dataOptions) },
);
};
return `${css.option}${cursored}${selected}${grouped}`;
}

const getClass = ({
dirty,
Expand Down Expand Up @@ -86,6 +87,8 @@ const getClass = ({
);
};

/* eslint-disable prefer-arrow-callback */

const Selection = ({
asyncFilter,
autofocus,
Expand All @@ -104,7 +107,7 @@ const Selection = ({
label,
listMaxHeight = '174px',
loading,
loadingMessage,
loadingMessage = <Icon icon="spinner-ellipsis" />,
marginBottom0,
name,
onFilter = filterOptionList,
Expand All @@ -122,19 +125,45 @@ const Selection = ({
}) => {
const { formatMessage } = useIntl();
const [filterValue, updateFilterValue] = useState('');
const [debouncedFilterValue, updateDebouncedFilter] = useState('');
const dataLength = useRef(dataOptions?.length || 0);
const controlRef = useProvidedRefOrCreate(inputRef);
const awaitingChange = useRef(false);
const options = useMemo(
() => (asyncFilter ? dataOptions :
filterValue ? onFilter(filterValue, dataOptions) : dataOptions),
[asyncFilter, filterValue, dataOptions, onFilter]
);
const dbUpdateFilter = useRef(debounce((filter) => {
updateDebouncedFilter(filter)
}, 200)).current;
const filterUpdateFn = useRef(function filterUpdater(filter) {
updateFilterValue(filter);
// debounce updates to the filter for large data sets...
dbUpdateFilter(filter);
}).current;

const filterFn = useCallback(function filter(data) {
return onFilter(debouncedFilterValue, data);
}, [debouncedFilterValue, onFilter]);

const flattenRef = useRef(function flattener(data) {
return flattenOptionList(data);
}).current;

const reduceOptionsRef = useRef(function dataReducer(data) {
return reduceOptions(data);
}).current;

const options = useMemo(() => {
return (asyncFilter || !debouncedFilterValue) ? dataOptions : filterFn(dataOptions)
},
[dataOptions, debouncedFilterValue]);

const testId = useProvidedIdOrCreate(id, 'selection-');
const hasGroups = dataOptions.some((item) => item.options);

// we need to skip over group headings since those can neither be selectable or cursored over.
const reducedListItems = reduceOptions(options);
const reducedListItems = useMemo(
() => { return hasGroups ? reduceOptionsRef(options) : options },
[options.length, hasGroups]
)

const {
isOpen,
getToggleButtonProps,
Expand All @@ -146,7 +175,7 @@ const Selection = ({
selectedItem,
selectItem: updateSelectedItem,
} = useCombobox({
items: reducedListItems,
items: reducedListItems || [],
itemToString: defaultItemToString,
initialSelectedItem: value ? getSelectedObject(value, dataOptions) : null,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
Expand All @@ -155,6 +184,7 @@ const Selection = ({
if (onChange && newSelectedItem?.value !== value) {
onChange(newSelectedItem.value);
}
updateDebouncedFilter(''); // so that we can unfilter the options for the next time the list opens.
},
isItemDisabled(item) {
return ((readOnly || readonly) && !isEqual(item, selectedItem));
Expand Down Expand Up @@ -208,14 +238,60 @@ const Selection = ({
* This memoized function is passed into the SelectionOverlay & SelectionList
*/

// if the options are grouped, flatten them for rendering.
const flattenedOptions = useMemo(
() => {
return hasGroups && options.length > 0 ? flattenRef(options) : options;
},
[options, hasGroups]
)

const renderedOptionsFn = useCallback(function optionRenderer(data) {
const rendered = [];
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (item.value) {
const reducedIndex = reconcileReducedIndex(item, reducedListItems);
rendered.push(
<li
key={`${item.label}-option-${i}`}
{...getItemProps({
index: reducedIndex,
onMouseUp: e => e.stopPropagation(),
})}
className={getItemClass(item, reducedIndex, { selectedItem, highlightedIndex, dataOptions })}
>
{formatter({ option: item, searchTerm: filterValue })}
</li>
)
} else {
rendered.push(
<li
key={`${item.label}-heading-${i}`}
className={css.groupLabel}
>
{formatter({ option: item, searchTerm: filterValue })}
</li>
);
}
}
return rendered;
}, [filterValue, flattenedOptions]);

const optionsProcessing = false;
const renderedOptions = useMemo(
() => renderedOptionsFn(flattenedOptions),
[flattenedOptions.length, debouncedFilterValue]
);

// It doesn't need to update if *all of the things it uses change...
/* eslint-disable react-hooks/exhaustive-deps */
const renderOptions = useCallback(() => {
if (!isOpen) return null;
// if options are delivered with groupings, we flatten the options for
// a set of selectable indices. Group labels are not selectable.
const flattenedOptions = flattenOptionList(options);
/* loading message */
if (loading) {
if (loading || (filterValue !== debouncedFilterValue)) {
return (
<li
role="option"
Expand All @@ -241,7 +317,7 @@ const Selection = ({
}

/* no options found through filtering */
if (flattenedOptions.length === 0) {
if (options.length === 0) {
return (
<li
role="option"
Expand All @@ -252,39 +328,16 @@ const Selection = ({
</li>
);
}

return flattenedOptions.map((item, i) => {
if (item.value) {
const reducedIndex = reconcileReducedIndex(item, reducedListItems);
return (
<li
key={`${item.label}-option-${i}`}
{...getItemProps({
index: reducedIndex,
onMouseUp: e => e.stopPropagation(),
})}
className={getItemClass(item, reducedIndex, { selectedItem, highlightedIndex, dataOptions })}
>
{formatter({ option: item, searchTerm: filterValue })}
</li>
)
}
return (
<li
key={`${item.label}-heading-${i}`}
className={css.groupLabel}
>
{formatter({ option: item, searchTerm: filterValue })}
</li>
);
})
return renderedOptions;
}, [
loading,
filterValue,
selectedItem,
highlightedIndex,
value,
options,
isOpen,
optionsProcessing,
renderedOptions,
]);

const renderFilterInput = useCallback((filterRef) => (
Expand All @@ -301,7 +354,7 @@ const Selection = ({
onMouseUp: (e) => e.stopPropagation(),
})}
onClick={() => {}}
onChange={(e) => updateFilterValue(e.target.value)}
onChange={(e) => filterUpdateFn(e.target.value)}
aria-label={formatMessage({ id: 'stripes-components.selection.filterOptionsLabel' }, { label })}
className={css.selectionFilter}
placeholder={formatMessage({ id: 'stripes-components.selection.filterOptionsPlaceholder' })}
Expand Down Expand Up @@ -436,3 +489,5 @@ export default formField(
warning: (meta.touched ? parseMeta(meta, 'warning') : ''),
})
);

/* eslint-enable */
4 changes: 3 additions & 1 deletion lib/Selection/SelectionList.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const SelectionList = ({
labelId,
listMaxHeight,
renderOptions,
isOpen,
}) => (
<ul
{...getMenuProps({
Expand All @@ -15,12 +16,13 @@ const SelectionList = ({
style={{ maxHeight: listMaxHeight }}
className={css.selectionList}
>
{renderOptions()}
{ isOpen && renderOptions()}
</ul>
);

SelectionList.propTypes = {
getMenuProps: PropTypes.func,
isOpen: PropTypes.bool,
labelId: PropTypes.string,
listMaxHeight: PropTypes.string,
renderOptions: PropTypes.func,
Expand Down
3 changes: 2 additions & 1 deletion lib/Selection/SelectionOverlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const SelectionOverlay = ({
listMaxHeight={listMaxHeight}
optionAlignment={optionAlignment}
getMenuProps={getMenuProps}
isOpen={isOpen}
{...props}
/>
);
Expand All @@ -71,7 +72,7 @@ const SelectionOverlay = ({
id={`sl-container-${id}`}
>
{renderFilterInput(filterRef)}
{selectList}
{isOpen && selectList}
</div>
</div>
</Portal>
Expand Down
Loading

0 comments on commit 5f65f9a

Please sign in to comment.