Skip to content

Commit

Permalink
feat(Select): add useSelectOptions hook (#1356)
Browse files Browse the repository at this point in the history
  • Loading branch information
korvin89 authored Feb 21, 2024
1 parent f8b52b9 commit 456ffaa
Show file tree
Hide file tree
Showing 11 changed files with 300 additions and 39 deletions.
37 changes: 17 additions & 20 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {CnMods} from '../utils/cn';
import {EmptyOptions, SelectControl, SelectFilter, SelectList, SelectPopup} from './components';
import {DEFAULT_VIRTUALIZATION_THRESHOLD, selectBlock} from './constants';
import {useQuickSearch} from './hooks';
import {getSelectFilteredOptions, useSelectOptions} from './hooks-public';
import {initialState, reducer} from './store';
import {Option, OptionGroup} from './tech-components';
import type {SelectProps, SelectRenderPopup} from './types';
Expand All @@ -19,8 +20,6 @@ import {
activateFirstClickableItem,
findItemIndexByQuickSearch,
getActiveItem,
getFilteredFlattenOptions,
getFlattenOptions,
getListItems,
getOptionsFromChildren,
getSelectedOptionsContent,
Expand Down Expand Up @@ -112,7 +111,10 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
onOpenChange?.(open);

if (!open && filterable) {
handleFilterChange('');
// FIXME: rework after https://github.com/gravity-ui/uikit/issues/1354
setTimeout(() => {
handleFilterChange('');
}, 100);
}
},
[filterable, onOpenChange, handleFilterChange],
Expand All @@ -138,21 +140,16 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
});
const uniqId = useUniqId();
const selectId = id ?? uniqId;
const options = props.options || getOptionsFromChildren(props.children);
const flattenOptions = getFlattenOptions(options);
const filteredFlattenOptions = filterable
? getFilteredFlattenOptions({
options: flattenOptions,
filter,
filterOption,
})
: flattenOptions;
const selectedOptionsContent = getSelectedOptionsContent(
flattenOptions,
value,
renderSelectedOption,
);
const virtualized = filteredFlattenOptions.length >= virtualizationThreshold;
const propsOptions = props.options || getOptionsFromChildren(props.children);
const options = useSelectOptions({
options: propsOptions,
filter,
filterable,
filterOption,
});
const filteredOptions = getSelectFilteredOptions(options) as FlattenOption[];
const selectedOptionsContent = getSelectedOptionsContent(options, value, renderSelectedOption);
const virtualized = filteredOptions.length >= virtualizationThreshold;

const {errorMessage, errorPlacement, validationState} = errorPropsMapper({
error,
Expand Down Expand Up @@ -284,14 +281,14 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
};

const _renderList = () => {
if (filteredFlattenOptions.length || props.loading) {
if (filteredOptions.length || props.loading) {
return (
<SelectList
ref={listRef}
size={size}
value={value}
mobile={mobile}
flattenOptions={filteredFlattenOptions}
flattenOptions={filteredOptions}
multiple={multiple}
virtualized={virtualized}
onOptionClick={handleOptionClick}
Expand Down
5 changes: 5 additions & 0 deletions src/components/Select/__stories__/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {SelectProps} from '..';

import {SelectPopupWidthShowcase} from './SelectPopupWidthShowcase';
import {SelectShowcase} from './SelectShowcase';
import {UseSelectOptionsShowcase} from './UseSelectOptionsShowcase';

export default {
title: 'Components/Inputs/Select',
Expand All @@ -25,9 +26,13 @@ const ShowcaseTemplate: StoryFn<SelectProps> = (args: SelectProps) => <SelectSho
const SelectPopupWidthShowcaseTemplate: StoryFn<SelectProps> = (args) => (
<SelectPopupWidthShowcase {...args} />
);
const UseSelectOptionsShowcaseTemplate = () => {
return <UseSelectOptionsShowcase />;
};
export const Default = DefaultTemplate.bind({});
export const Showcase = ShowcaseTemplate.bind({});
export const PopupWidth = SelectPopupWidthShowcaseTemplate.bind({});
export const UseSelectOptions = UseSelectOptionsShowcaseTemplate.bind({});

Showcase.args = {
view: 'normal',
Expand Down
115 changes: 115 additions & 0 deletions src/components/Select/__stories__/UseSelectOptionsShowcase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';

import {Button} from '../../Button';
import {TextInput} from '../../controls';
import {Select, getSelectFilteredOptions, isSelectGroupTitle, useSelectOptions} from '../index';
import type {SelectOption, SelectProps} from '../index';

export const UseSelectOptionsShowcase = () => {
const [value, setValue] = React.useState<string[]>([]);
const [filter, setFilter] = React.useState('');
const filterable = true;
const options = useSelectOptions({
options: [
{
label: 'Group 1',
options: [
{value: 'val1', content: 'Value 1'},
{value: 'val2', content: 'Value 2'},
{value: 'val3', content: 'Value 3'},
{value: 'val4', content: 'Value 4'},
],
},
{
label: 'Group 2',
options: [
{value: 'val5', content: 'Value 5'},
{value: 'val6', content: 'Value 6'},
{value: 'val7', content: 'Value 7'},
{value: 'val8', content: 'Value 8'},
],
},
],
filter,
filterable,
});
const filteredOptions = getSelectFilteredOptions(options);

const renderFilter: SelectProps['renderFilter'] = ({
value: filterValue,
ref,
onChange,
onKeyDown,
}) => {
const optionsWithoutGroupLabels = options.filter(
(option) => !isSelectGroupTitle(option),
) as SelectOption[];
const filteredOptionsWithoutGroupLabels = filteredOptions.filter(
(option) => !isSelectGroupTitle(option),
) as SelectOption[];
const allOptionsSelected = Boolean(
value.length && optionsWithoutGroupLabels.length === value.length,
);
const allVisibleOptionsSelected = Boolean(
value.length &&
filteredOptionsWithoutGroupLabels
.map((o) => o.value)
.every((o) => value.includes(o)),
);

const handleAllOptionsButtonClick = () => {
const nextValue = allOptionsSelected
? []
: optionsWithoutGroupLabels.map((option) => option.value);
setValue(nextValue);
};

const handleAllVisibleOptionsButtonClick = () => {
const filteredValue = filteredOptionsWithoutGroupLabels.map((o) => o.value);
const nextValue = allVisibleOptionsSelected
? value.filter((v) => !filteredValue.includes(v))
: filteredOptionsWithoutGroupLabels.map((o) => o.value);
setValue(nextValue);
};

return (
<div
style={{
display: 'flex',
flexDirection: 'column',
rowGap: 4,
padding: '4px 4px 0 4px',
}}
>
<TextInput
controlRef={ref}
controlProps={{size: 1}}
value={filterValue}
onUpdate={onChange}
onKeyDown={onKeyDown}
/>
<Button
disabled={!filteredOptionsWithoutGroupLabels.length}
onClick={handleAllVisibleOptionsButtonClick}
>
{allVisibleOptionsSelected ? 'Deselect all visible' : 'Select all visible'}
</Button>
<Button onClick={handleAllOptionsButtonClick}>
{allOptionsSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
);
};

return (
<Select
value={value}
options={options}
filterable={filterable}
multiple={true}
renderFilter={renderFilter}
onFilterChange={setFilter}
onUpdate={setValue}
/>
);
};
38 changes: 38 additions & 0 deletions src/components/Select/__tests__/useSelectOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {renderHook} from '../../../../test-utils/utils';
import {getSelectFilteredOptions, useSelectOptions} from '../hooks-public';

describe('Select useSelectOptions hook', function () {
it('should properly manage result options', () => {
const {result: r1} = renderHook(() => {
return useSelectOptions({options: [{value: '1'}, {value: '2'}]});
});
expect(r1.current.map((o) => 'value' in o && o.value)).toEqual(['1', '2']);
const {result: r2} = renderHook(() => {
return useSelectOptions({options: r1.current});
});
expect(r2.current === r1.current).toBeTruthy();
});
it('should properly manage filter & filterable properties', () => {
const {result: r1} = renderHook(() => {
return useSelectOptions({
options: [{value: '1'}, {value: '2'}],
filterable: false,
filter: '1',
});
});
const filteredOptions1 = getSelectFilteredOptions(r1.current);
expect(filteredOptions1.map((o) => 'value' in o && o.value)).toEqual(['1', '2']);
const {result: r2} = renderHook(() => {
return useSelectOptions({
options: [{value: '1'}, {value: '2'}],
filterable: true,
filter: '1',
});
});
const filteredOptions2 = getSelectFilteredOptions(r2.current);
expect(filteredOptions2.map((o) => 'value' in o && o.value)).toEqual(['1']);
});
it('getSelectFilteredOptions should emit an error in case of using unprepared options', () => {
expect(() => getSelectFilteredOptions([{value: '1'}])).toThrow();
});
});
2 changes: 2 additions & 0 deletions src/components/Select/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ export const SelectQa = {
CLEAR: 'select-clear',
FILTER_INPUT: 'select-filter-input',
};

export const FLATTEN_KEY = Symbol('flatten');
1 change: 1 addition & 0 deletions src/components/Select/hooks-public/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useSelectOptions';
14 changes: 14 additions & 0 deletions src/components/Select/hooks-public/useSelectOptions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
The `useSelectOptions` hook that helps to manage options data before passing into `Select` component. It could be handy in case of options management outside the component.

## Properties

| Name | Description | Type | Default |
| :----------- | :----------------------------------------------------------------------- | :-------------------------------------: | :-----: |
| options | [Select options](https://gravity-ui.com/components/uikit/select#options) | `(SelectOption \| SelectOptionGroup)[]` | |
| filter | Value to filter options. Used with `filterable: true` only | `string \| undefined` | |
| filterable | Indicates that `filter` and `filterOption` properties can be used | `boolean \| undefined` | |
| filterOption | Used to compare option with filter | `function \| undefined` | |

## Result

`useSelectOptions` returns prepared `Select` options.
63 changes: 63 additions & 0 deletions src/components/Select/hooks-public/useSelectOptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';

import get from 'lodash/get';

import {FLATTEN_KEY} from '../../constants';
import type {SelectOptions, SelectProps} from '../../types';
import {getFilteredFlattenOptions, getFlattenOptions} from '../../utils';
import type {FlattenOptions} from '../../utils';

export interface UseSelectOptionsProps<T = any> {
/** [Select options](https://gravity-ui.com/components/uikit/select#options). */
options: SelectOptions<T>;
/** Value to filter options. Used with `filterable: true` only. */
filter?: string;
/** Indicates that `filter` and `filterOption` properties can be used. */
filterable?: boolean;
/** Used to compare option with filter. Used with `filterable: true` only. */
filterOption?: SelectProps['filterOption'];
}

function isFlattenOptions(options: UseSelectOptionsProps['options']): options is FlattenOptions {
return get(options, [FLATTEN_KEY]);
}

/**
* Helps to manage options data before passing it into `Select` component.
*
* @param {SelectOptions} options
* @returns preprared options for `Select` component.
*
* @example
*
* import {Select, getSelectFilteredOptions, useSelectOptions} from '@gravity-ui/uikit';
*
* function App() {
* const options = useSelectOptions({options: [{value: '1'}, {value: '2'}]});
* const filteredOptions = getSelectFilteredOptions(options);
* // Do some staff with prepared options
* return <Select options={options} />
* }
*/
export function getSelectFilteredOptions<T>(options: SelectOptions<T>): SelectOptions<T> {
if (!isFlattenOptions(options)) {
throw Error('You should use options generated by useSelectOptions hook');
}

return get(options, [FLATTEN_KEY, 'filteredOptions']);
}

export function useSelectOptions<T extends any>(props: UseSelectOptionsProps<T>): SelectOptions<T> {
const {filter = '', filterable, filterOption} = props;
const options = React.useMemo(() => {
return isFlattenOptions(props.options)
? props.options
: (getFlattenOptions(props.options) as FlattenOptions);
}, [props.options]);
const filteredOptions = React.useMemo(() => {
return filterable ? getFilteredFlattenOptions({options, filter, filterOption}) : options;
}, [filter, filterable, filterOption, options]);
options[FLATTEN_KEY]['filteredOptions'] = filteredOptions;

return options;
}
2 changes: 2 additions & 0 deletions src/components/Select/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './Select';
export * from './types';
export {SelectQa} from './constants';
export * from './hooks-public';
export {isSelectGroupTitle} from './utils';
2 changes: 2 additions & 0 deletions src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@ export type SelectClearProps = SelectClearIconProps & {
onMouseEnter: (e: React.MouseEvent) => void;
onMouseLeave: (e: React.MouseEvent) => void;
};

export type SelectOptions<T = any> = NonNullable<SelectProps<T>['options']>;
Loading

0 comments on commit 456ffaa

Please sign in to comment.