From f3f0f627f2e9edf80f96507f65ae5cc7aaf1a8d9 Mon Sep 17 00:00:00 2001 From: Andrey Medvedev Date: Tue, 7 Nov 2023 15:06:53 +0300 Subject: [PATCH] fix(Select/CustomSelect): Allow to use custom options in renderOption prop (#6076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мы вроде бы позволяем использовать опции любых типов, которые бы имели у себя обязательные свойста Select, https://github.com/VKCOM/VKUI/blob/3a844db48dea7a4d83d2b9020c2bf21ba18a3344/packages/vkui/src/components/CustomSelect/CustomSelect.tsx#L107-L112 но сейчас TS ругается если мы пытаемся использовать в `renderOption` опции со свойствами, которых нету в `CustomSelectOptionInterface`. * Изменения Добавили дженерик для типа опций с constraint, чтобы можно было использовать расширинный тип опции с обязательными полями. Юнит-тест для проверки случаев использования CustomSelect с расширенными опциями. В основном, чтобы поймать ошибку типов. --- .../CustomSelect/CustomSelect.test.tsx | 111 +++++++++++++++++- .../components/CustomSelect/CustomSelect.tsx | 59 ++++++---- .../vkui/src/components/Select/Select.tsx | 11 +- packages/vkui/src/index.ts | 1 + packages/vkui/src/lib/select.ts | 10 +- 5 files changed, 157 insertions(+), 35 deletions(-) diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx index 6987ae4405..3db11483a7 100644 --- a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx +++ b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx @@ -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; @@ -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()) } />, @@ -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( + , + ); + + 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(); + + // типизируем render-функцию + const renderOption = ({ + option, + ...restProps + }: CustomSelectRenderOption) => { + return ( + } /> + ); + }; + + rerender( + , + ); + + // типизируем render-функцию через SelectProps + const renderOptionViaSelectProp: SelectProps['renderOption'] = ({ option, ...restProps }) => { + return ( + } /> + ); + }; + + rerender( + , + ); + + // используем рендер функцию inline + rerender( + { + return ( + } + /> + ); + }} + />, + ); + }); }); diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx index 82365948d3..8760502867 100644 --- a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx +++ b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx @@ -57,7 +57,7 @@ const findIndexBefore = ( const warn = warnOnce('CustomSelect'); -const checkOptionsValueType = (options: CustomSelectOptionInterface[]) => { +const checkOptionsValueType = (options: T[]) => { if (new Set(options.map((item) => typeof item.value)).size > 1) { warn( 'Некоторые значения ваших опций имеют разные типы. onChange всегда возвращает строковый тип.', @@ -66,7 +66,10 @@ const checkOptionsValueType = (options: CustomSelectOptionInterface[]) => { } }; -function defaultRenderOptionFn({ option, ...props }: CustomSelectOptionProps): React.ReactNode { +function defaultRenderOptionFn({ + option, + ...props +}: CustomSelectRenderOption): React.ReactNode { return ; } @@ -74,8 +77,8 @@ const handleOptionDown: MouseEventHandler = (e: React.MouseEvent) = e.preventDefault(); }; -function findSelectedIndex( - options: CustomSelectOptionInterface[], +function findSelectedIndex( + options: T[] = [], value: SelectValue, withClear: boolean, ) { @@ -90,10 +93,10 @@ function findSelectedIndex( ); } -const filter = ( - options: SelectProps['options'], +const filter = ( + options: SelectProps['options'], inputValue: string, - filterFn: SelectProps['filterFn'], + filterFn: SelectProps['filterFn'], ) => { return typeof filterFn === 'function' ? options.filter((option) => filterFn(inputValue, option)) @@ -102,6 +105,12 @@ const filter = ( const defaultOptions: CustomSelectOptionInterface[] = []; +type FilterFn = ( + value: string, + option: T, + getOptionLabel?: (option: Partial) => string, +) => boolean; + type SelectValue = React.SelectHTMLAttributes['value']; export interface CustomSelectOptionInterface { @@ -111,11 +120,16 @@ export interface CustomSelectOptionInterface { [index: string]: any; } -interface CustomSelectRenderOption extends CustomSelectOptionProps { - option: CustomSelectOptionInterface; +export interface CustomSelectRenderOption + 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`. @@ -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) => string, - ) => boolean); + filterFn?: false | FilterFn; popupDirection?: 'top' | 'bottom'; /** * Рендер-проп для кастомного рендера опции. @@ -153,7 +158,7 @@ export interface SelectProps extends NativeSelectProps, FormFieldProps, TrackerO * > Запрещается выставлять `disabled` проп опциям в обход `options`, иначе селект не будет знать об актуальном состоянии * опции. */ - renderOption?: (props: CustomSelectRenderOption) => React.ReactNode; + renderOption?: (props: CustomSelectRenderOption) => React.ReactNode; /** * Рендер-проп для кастомного рендера содержимого дропдауна. * В `defaultDropdownContent` содержится список опций в виде скроллящегося блока. @@ -198,7 +203,9 @@ type MouseEventHandler = (event: React.MouseEvent) => void; /** * @see https://vkcom.github.io/VKUI/#/CustomSelect */ -export function CustomSelect(props: SelectProps) { +export function CustomSelect( + props: SelectProps, +) { const [opened, setOpened] = React.useState(false); const { before, @@ -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, @@ -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; diff --git a/packages/vkui/src/components/Select/Select.tsx b/packages/vkui/src/components/Select/Select.tsx index 94ba8210ad..4aa0262ec9 100644 --- a/packages/vkui/src/components/Select/Select.tsx +++ b/packages/vkui/src/components/Select/Select.tsx @@ -1,6 +1,10 @@ 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'; @@ -8,7 +12,10 @@ export type SelectType = 'default' | 'plain' | 'accent'; /** * @see https://vkcom.github.io/VKUI/#/Select */ -export const Select = ({ children, ...props }: SelectProps) => { +export const Select = ({ + children, + ...props +}: SelectProps) => { const { options = [], searchable, diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 19c89410e8..b14b9b79fd 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -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'; diff --git a/packages/vkui/src/lib/select.ts b/packages/vkui/src/lib/select.ts index ffdc2b8c29..b87a7a8bcb 100644 --- a/packages/vkui/src/lib/select.ts +++ b/packages/vkui/src/lib/select.ts @@ -26,14 +26,14 @@ try { letterRegexp = new RegExp('\\p{L}', 'u'); } catch (e) {} -type GetOptionLabel = (option: Option) => string | undefined; +type GetOptionLabel = (option: Partial) => string | undefined; -const _getOptionLabel: GetOptionLabel = (option) => getTitleFromChildren(option.label); +const _getOptionLabel: GetOptionLabel