Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): add useSelectOptions hook [CONCEPT] #1352

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 32 additions & 31 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';

import {KeyCode} from '../../constants';
import {useFocusWithin, useForkRef, useSelect, useUniqId} from '../../hooks';
import type {List} from '../List';
import type {List, ListItemData} from '../List';
import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent';
import {errorPropsMapper} from '../controls/utils';
import {useMobile} from '../mobile';
Expand All @@ -11,16 +11,15 @@ 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';
import type {SelectOption, SelectOptionGroup, SelectProps, SelectRenderPopup} from './types';
import type {SelectFilterRef} from './types-misc';
import {
activateFirstClickableItem,
findItemIndexByQuickSearch,
getActiveItem,
getFilteredFlattenOptions,
getFlattenOptions,
getListItems,
getOptionsFromChildren,
getSelectedOptionsContent,
Expand Down Expand Up @@ -52,14 +51,16 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
onOpenChange,
onFilterChange,
renderControl,
renderFilter,
renderOption,
renderOptionGroup,
renderSelectedOption,
renderEmptyOptions,
renderFilter, //
renderOption, //
renderOptionGroup, //
renderSelectedOption, //
renderEmptyOptions, //
renderPopup = DEFAULT_RENDER_POPUP,
getOptionHeight,
getOptionGroupHeight,
renderDivider, //
getOptionHeight, //
getOptionGroupHeight, //
getDividerHeight, //
filterOption,
name,
className,
Expand Down Expand Up @@ -96,7 +97,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
const controlWrapRef = React.useRef<HTMLDivElement>(null);
const controlRef = React.useRef<HTMLElement>(null);
const filterRef = React.useRef<SelectFilterRef>(null);
const listRef = React.useRef<List<FlattenOption>>(null);
const listRef = React.useRef<List<SelectOption | SelectOptionGroup>>(null);
const handleControlRef = useForkRef(ref, controlRef);

const handleFilterChange = React.useCallback(
Expand All @@ -112,7 +113,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 +142,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 All @@ -169,7 +168,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
const isErrorStateVisible = isErrorMsgVisible || isErrorIconVisible;

const handleOptionClick = React.useCallback(
(option?: FlattenOption) => {
(option?: ListItemData<SelectOption | SelectOptionGroup>) => {
if (!option || option?.disabled || 'label' in option) {
return;
}
Expand Down Expand Up @@ -284,21 +283,23 @@ 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}
renderOption={renderOption}
renderOptionGroup={renderOptionGroup}
renderDivider={renderDivider}
getOptionHeight={getOptionHeight}
getOptionGroupHeight={getOptionGroupHeight}
getDividerHeight={getDividerHeight}
loading={props.loading}
onLoadMore={props.onLoadMore}
selectId={`select-${selectId}`}
Expand Down
11 changes: 11 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 @@ -19,15 +20,25 @@ const DefaultTemplate: StoryFn<SelectProps> = (args) => (
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
<Select.OptionGroup label="asdasd">
<Select.Option value="val5" content="Value5" />
<Select.Option value="val6" content="Value6" />
</Select.OptionGroup>
<Select.Option value="val7" content="Value7" />
<Select.Option value="val8" content="Value8" />
</Select>
);
const ShowcaseTemplate: StoryFn<SelectProps> = (args: SelectProps) => <SelectShowcase {...args} />;
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}
/>
);
};
16 changes: 1 addition & 15 deletions src/components/Select/__tests__/Select.filter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {cleanup} from '../../../../test-utils/utils';
import {TextInput} from '../../controls';
import type {SelectOption, SelectProps, SelectRenderPopup} from '../types';

import {TEST_QA, generateOptions, generateOptionsGroups, setup} from './utils';
import {TEST_QA, generateOptions, setup} from './utils';

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -113,18 +113,4 @@ describe('Select filter', () => {
// 10, 20, 30, 40
expect(queryAllByRole('option').length).toBe(4);
});

test('should not display labels of empty groups during filtering', async () => {
const {getByTestId, queryAllByRole} = setup({
options: generateOptionsGroups(4, 1),
filterable: true,
});
const user = userEvent.setup();
const selectControl = getByTestId(TEST_QA);
await user.click(selectControl);
// 4 group labels + 1 option in each group
expect(queryAllByRole('option').length).toBe(8);
await user.keyboard('definitely not option');
expect(queryAllByRole('option').length).toBe(0);
});
});
36 changes: 1 addition & 35 deletions src/components/Select/__tests__/Select.popup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import userEvent from '@testing-library/user-event';

import {GROUP_ITEM_MARGIN_TOP, SelectQa} from '../constants';
import {SelectQa} from '../constants';
import type {SelectSize} from '../types';

import {DEFAULT_OPTIONS, GROUPED_OPTIONS, TEST_QA, setup} from './utils';
Expand Down Expand Up @@ -64,40 +64,6 @@ describe('Select popup', () => {
},
);

test.each([
['s', {mobile: false, size: 's', height: 28}],
['m', {mobile: false, size: 'm', height: 28}],
['l', {mobile: false, size: 'l', height: 32}],
['xl', {mobile: false, size: 'xl', height: 36}],
['mobile', {mobile: true, size: undefined, height: 32}],
])(
'should return correct height for option group depends on size (%s)',
async (_type, {size, height, mobile}) => {
expect.assertions(2);

const renderOptionGroup = jest.fn();

const {getByTestId} = setup(
{size: size as SelectSize, renderOptionGroup, onUpdate, options: GROUPED_OPTIONS},
mobile,
);

const user = userEvent.setup();
const selectControl = getByTestId(TEST_QA);
// open select popup
await user.click(selectControl);

expect(renderOptionGroup).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({itemHeight: height}),
);
expect(renderOptionGroup).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({itemHeight: height + GROUP_ITEM_MARGIN_TOP}),
);
},
);

test('should use getOptionHeight for render with correct size', async () => {
const getOptionHeight = () => 32;
const renderOption = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ describe('getSelectedOptionsContent', () => {
getSelectedOptionsContent(options, presenceValue, renderSelectedOption);

expect(renderSelectedOption).toBeCalledTimes(1);
expect(renderSelectedOption).toBeCalledWith(options[0], 0);
expect(renderSelectedOption).toBeCalledWith(options[0]);
});
test('option NOT presence. Should be called with generated object', async () => {
renderSelectedOption.mockClear();
getSelectedOptionsContent(options, notPresenceValue, renderSelectedOption);

expect(renderSelectedOption).toBeCalledTimes(1);
expect(renderSelectedOption).toBeCalledWith({value: notPresenceValue[0]}, 0);
expect(renderSelectedOption).toBeCalledWith({value: notPresenceValue[0]});
});
});
});
Loading
Loading