Skip to content

Commit

Permalink
fix(Select/CustomSelect): Allow to use custom options in renderOption…
Browse files Browse the repository at this point in the history
… prop (#6076)

Мы вроде бы позволяем использовать опции любых типов, которые бы имели у себя обязательные свойста Select,
https://github.com/VKCOM/VKUI/blob/3a844db48dea7a4d83d2b9020c2bf21ba18a3344/packages/vkui/src/components/CustomSelect/CustomSelect.tsx#L107-L112
но сейчас TS ругается если мы пытаемся использовать в `renderOption` опции со свойствами, которых нету в `CustomSelectOptionInterface`.

* Изменения
Добавили дженерик для типа опций с constraint, чтобы можно было использовать расширинный тип опции с обязательными полями.
Юнит-тест для проверки случаев использования CustomSelect с расширенными опциями. В основном, чтобы поймать ошибку типов.
  • Loading branch information
mendrew authored and actions-user committed Nov 7, 2023
1 parent 2649de0 commit f3f0f62
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 35 deletions.
111 changes: 109 additions & 2 deletions packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import * as React from 'react';
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { baselineComponent, waitForFloatingPosition } from '../../testing/utils';
import { CustomSelect, type SelectProps } from './CustomSelect';
import { Avatar } from '../Avatar/Avatar';
import { CustomSelectOption } from '../CustomSelectOption/CustomSelectOption';
import { CustomSelect, type CustomSelectRenderOption, type SelectProps } from './CustomSelect';

const getCustomSelectValue = () => screen.getByTestId('target').textContent;

Expand Down Expand Up @@ -223,7 +225,7 @@ describe('CustomSelect', () => {
{ value: 2, label: 'New York', country: 'USA' },
]}
filterFn={(value, option) =>
(option.label as string).toLowerCase().includes(value.toLowerCase()) ||
option.label.toLowerCase().includes(value.toLowerCase()) ||
option.country.toLowerCase().includes(value.toLowerCase())
}
/>,
Expand Down Expand Up @@ -889,4 +891,109 @@ describe('CustomSelect', () => {
expect(onChange).toBeCalledTimes(3);
expect(onChange).toHaveReturnedWith('0');
});

it('accepts options with extended option type and Typescript does not throw', () => {
const { rerender } = render(
<CustomSelect
data-testid="target"
options={[
{ value: 0, label: 'Mike', avatarUrl: 'some-url' },
{ value: 1, label: 'Josh', avatarUrl: 'some other avatarUrl' },
]}
value={1}
/>,
);

type PublisherFragmentData = {
__typename?: 'Publisher';
id: string;
title: string;
logo: string;
};
type PublisherSelectOption = {
data: PublisherFragmentData;
value: string;
label: string;
};

const complexOptions: PublisherSelectOption[] = [
{
value: '1',
label: 'Mike',
data: {
__typename: 'Publisher',
id: 'some-id-1',
title: 'Some Title 1',
logo: 'Some logo 1',
},
},
{
value: '2',
label: 'Sam',
data: {
__typename: 'Publisher',
id: 'some-id-2',
title: 'Some Title 2',
logo: 'Some logo 2',
},
},
];

rerender(<CustomSelect data-testid="target" options={complexOptions} />);

// типизируем render-функцию
const renderOption = ({
option,
...restProps
}: CustomSelectRenderOption<PublisherSelectOption>) => {
return (
<CustomSelectOption {...restProps} before={<Avatar size={40} src={option.data.logo} />} />
);
};

rerender(
<CustomSelect
placeholder="my place"
disabled={false}
searchable
options={complexOptions}
renderOption={renderOption}
/>,
);

// типизируем render-функцию через SelectProps
const renderOptionViaSelectProp: SelectProps['renderOption'] = ({ option, ...restProps }) => {
return (
<CustomSelectOption {...restProps} before={<Avatar size={40} src={option.data.logo} />} />
);
};

rerender(
<CustomSelect
placeholder="my place"
disabled={false}
searchable
options={complexOptions}
renderOption={renderOptionViaSelectProp}
/>,
);

// используем рендер функцию inline
rerender(
<CustomSelect
placeholder="my place"
disabled={false}
searchable
options={complexOptions}
renderOption={({ option, ...restProps }) => {
return (
<CustomSelectOption
{...restProps}
before={<Avatar size={40} src={option.data.logo} />}
/>
);
}}
/>,
);
});
});
59 changes: 33 additions & 26 deletions packages/vkui/src/components/CustomSelect/CustomSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const findIndexBefore = (

const warn = warnOnce('CustomSelect');

const checkOptionsValueType = (options: CustomSelectOptionInterface[]) => {
const checkOptionsValueType = <T extends CustomSelectOptionInterface>(options: T[]) => {
if (new Set(options.map((item) => typeof item.value)).size > 1) {
warn(
'Некоторые значения ваших опций имеют разные типы. onChange всегда возвращает строковый тип.',
Expand All @@ -66,16 +66,19 @@ const checkOptionsValueType = (options: CustomSelectOptionInterface[]) => {
}
};

function defaultRenderOptionFn({ option, ...props }: CustomSelectOptionProps): React.ReactNode {
function defaultRenderOptionFn<T extends CustomSelectOptionInterface>({
option,
...props
}: CustomSelectRenderOption<T>): React.ReactNode {
return <CustomSelectOption {...props} />;
}

const handleOptionDown: MouseEventHandler = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
};

function findSelectedIndex(
options: CustomSelectOptionInterface[],
function findSelectedIndex<T extends CustomSelectOptionInterface>(
options: T[] = [],
value: SelectValue,
withClear: boolean,
) {
Expand All @@ -90,10 +93,10 @@ function findSelectedIndex(
);
}

const filter = (
options: SelectProps['options'],
const filter = <T extends CustomSelectOptionInterface>(
options: SelectProps<T>['options'],
inputValue: string,
filterFn: SelectProps['filterFn'],
filterFn: SelectProps<T>['filterFn'],
) => {
return typeof filterFn === 'function'
? options.filter((option) => filterFn(inputValue, option))
Expand All @@ -102,6 +105,12 @@ const filter = (

const defaultOptions: CustomSelectOptionInterface[] = [];

type FilterFn<T> = (
value: string,
option: T,
getOptionLabel?: (option: Partial<T>) => string,
) => boolean;

type SelectValue = React.SelectHTMLAttributes<HTMLSelectElement>['value'];

export interface CustomSelectOptionInterface {
Expand All @@ -111,11 +120,16 @@ export interface CustomSelectOptionInterface {
[index: string]: any;
}

interface CustomSelectRenderOption extends CustomSelectOptionProps {
option: CustomSelectOptionInterface;
export interface CustomSelectRenderOption<T extends CustomSelectOptionInterface>
extends CustomSelectOptionProps {
option: T;
}

export interface SelectProps extends NativeSelectProps, FormFieldProps, TrackerOptionsProps {
export interface SelectProps<
OptionInterfaceT extends CustomSelectOptionInterface = CustomSelectOptionInterface,
> extends NativeSelectProps,
FormFieldProps,
TrackerOptionsProps {
/**
* Если `true`, то при клике на селект в нём появится текстовое поле для поиска по `options`. По умолчанию поиск
* производится по `option.label`.
Expand All @@ -129,21 +143,12 @@ export interface SelectProps extends NativeSelectProps, FormFieldProps, TrackerO
* > ⚠️ В **v6** из возвращаемых типов будет удалён `CustomSelectOptionInterface[]`. Для кастомной фильтрации используйте
* > `filterFn`.
*/
onInputChange?: (
e: React.ChangeEvent,
options: CustomSelectOptionInterface[],
) => void | CustomSelectOptionInterface[];
options: CustomSelectOptionInterface[];
onInputChange?: (e: React.ChangeEvent, options: OptionInterfaceT[]) => void | OptionInterfaceT[];
options: OptionInterfaceT[];
/**
* Функция для кастомной фильтрации. По умолчанию поиск производится по `option.label`.
*/
filterFn?:
| false
| ((
value: string,
option: CustomSelectOptionInterface,
getOptionLabel?: (option: Partial<CustomSelectOptionInterface>) => string,
) => boolean);
filterFn?: false | FilterFn<OptionInterfaceT>;
popupDirection?: 'top' | 'bottom';
/**
* Рендер-проп для кастомного рендера опции.
Expand All @@ -153,7 +158,7 @@ export interface SelectProps extends NativeSelectProps, FormFieldProps, TrackerO
* > Запрещается выставлять `disabled` проп опциям в обход `options`, иначе селект не будет знать об актуальном состоянии
* опции.
*/
renderOption?: (props: CustomSelectRenderOption) => React.ReactNode;
renderOption?: (props: CustomSelectRenderOption<OptionInterfaceT>) => React.ReactNode;
/**
* Рендер-проп для кастомного рендера содержимого дропдауна.
* В `defaultDropdownContent` содержится список опций в виде скроллящегося блока.
Expand Down Expand Up @@ -198,7 +203,9 @@ type MouseEventHandler = (event: React.MouseEvent<HTMLElement>) => void;
/**
* @see https://vkcom.github.io/VKUI/#/CustomSelect
*/
export function CustomSelect(props: SelectProps) {
export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterface>(
props: SelectProps<OptionInterfaceT>,
) {
const [opened, setOpened] = React.useState(false);
const {
before,
Expand All @@ -221,7 +228,7 @@ export function CustomSelect(props: SelectProps) {
autoHideScrollbarDelay,
searchable = false,
renderOption: renderOptionProp = defaultRenderOptionFn,
options: optionsProp = defaultOptions,
options: optionsProp = defaultOptions as OptionInterfaceT[],
emptyText = 'Ничего не найдено',
filterFn = defaultFilterFn,
icon: iconProp,
Expand Down Expand Up @@ -644,7 +651,7 @@ export function CustomSelect(props: SelectProps) {
);

const renderOption = React.useCallback(
(option: CustomSelectOptionInterface, index: number) => {
(option: OptionInterfaceT, index: number) => {
const hovered = index === focusedOptionIndex;
const selected = index === selectedOptionIndex;

Expand Down
11 changes: 9 additions & 2 deletions packages/vkui/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import * as React from 'react';
import { useAdaptivityHasPointer } from '../../hooks/useAdaptivityHasPointer';
import { CustomSelect, SelectProps } from '../CustomSelect/CustomSelect';
import {
CustomSelect,
type CustomSelectOptionInterface,
type SelectProps,
} from '../CustomSelect/CustomSelect';
import { NativeSelect } from '../NativeSelect/NativeSelect';

export type SelectType = 'default' | 'plain' | 'accent';

/**
* @see https://vkcom.github.io/VKUI/#/Select
*/
export const Select = ({ children, ...props }: SelectProps) => {
export const Select = <OptionT extends CustomSelectOptionInterface>({
children,
...props
}: SelectProps<OptionT>) => {
const {
options = [],
searchable,
Expand Down
1 change: 1 addition & 0 deletions packages/vkui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export { CustomSelect } from './components/CustomSelect/CustomSelect';
export type {
SelectProps,
CustomSelectOptionInterface,
CustomSelectRenderOption,
} from './components/CustomSelect/CustomSelect';
export { CustomSelectOption } from './components/CustomSelectOption/CustomSelectOption';
export type { CustomSelectOptionProps } from './components/CustomSelectOption/CustomSelectOption';
Expand Down
10 changes: 5 additions & 5 deletions packages/vkui/src/lib/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ try {
letterRegexp = new RegExp('\\p{L}', 'u');
} catch (e) {}

type GetOptionLabel = (option: Option) => string | undefined;
type GetOptionLabel<T> = (option: Partial<T>) => string | undefined;

const _getOptionLabel: GetOptionLabel = (option) => getTitleFromChildren(option.label);
const _getOptionLabel: GetOptionLabel<Option> = (option) => getTitleFromChildren(option.label);

export const defaultFilterFn = (
export const defaultFilterFn = <T>(
query = '',
option: Option,
getOptionLabel: GetOptionLabel = _getOptionLabel,
option: T,
getOptionLabel: GetOptionLabel<T> = _getOptionLabel,
) => {
query = query.toLocaleLowerCase();
let label = getOptionLabel(option)?.toLocaleLowerCase();
Expand Down

0 comments on commit f3f0f62

Please sign in to comment.