From de531b96329bb4227a6fceef38c5c91c7e0e07b8 Mon Sep 17 00:00:00 2001 From: Artur Yorsh <10753921+artyorsh@users.noreply.github.com> Date: Tue, 21 Jan 2020 10:58:26 +0300 Subject: [PATCH] refactor(components): select - fix select strategies and add tests --- src/components/ui/select/select.component.tsx | 147 +++++++------ src/components/ui/select/select.service.ts | 169 ++++++++++++++ src/components/ui/select/select.spec.tsx | 69 +++++- .../ui/select/selectGroupOption.component.tsx | 135 +++++------- .../ui/select/selectOption.component.tsx | 47 ++-- .../ui/select/selectOptionsList.component.tsx | 52 ++--- .../ui/select/selection.strategy.ts | 206 ------------------ src/components/ui/support/typings/index.ts | 1 - src/components/ui/support/typings/type.ts | 7 - 9 files changed, 403 insertions(+), 430 deletions(-) create mode 100644 src/components/ui/select/select.service.ts delete mode 100644 src/components/ui/select/selection.strategy.ts diff --git a/src/components/ui/select/select.component.tsx b/src/components/ui/select/select.component.tsx index daeb4a63b..eacbce804 100644 --- a/src/components/ui/select/select.component.tsx +++ b/src/components/ui/select/select.component.tsx @@ -25,36 +25,33 @@ import { StyledComponentProps, StyleType, } from '@kitten/theme'; -import { - SelectOptionsList, - SelectOptionsListElement, -} from './selectOptionsList.component'; import { SelectOption, SelectOptionType, } from './selectOption.component'; import { - MultiSelectStrategy, - SelectionStrategy, - SingleSelectStrategy, -} from './selection.strategy'; + SelectOptionsList, + SelectOptionsListElement, +} from './selectOptionsList.component'; import { Text, TextElement, } from '../text/text.component'; import { IconElement } from '../icon/icon.component'; import { Popover } from '../popover/popover.component'; +import { + ChevronDown, + ChevronDownElement, + ChevronDownProps, +} from '../support/components/chevronDown.component'; import { allWithPrefix, isValidString, WebEventResponder, + WebEventResponderCallbacks, WebEventResponderInstance, } from '../support/services'; -import { - ChevronDown, - ChevronDownElement, - ChevronDownProps, -} from '../support/components/chevronDown.component'; +import { SelectService } from './select.service'; type ControlElement = React.ReactElement; type IconProp = (style: ImageStyle, visible: boolean) => IconElement; @@ -83,7 +80,7 @@ export interface SelectProps extends StyledComponentProps, TouchableOpacityProps export type SelectElement = React.ReactElement; interface State { - visible: boolean; + optionsVisible: boolean; } /** @@ -124,7 +121,7 @@ interface State { * * @property {SelectOptionType[]} data - Determines items of the Select component. * - * @property {(option: SelectOption, event?: GestureResponderEvent) => void} onSelect - Fires on option selection. + * @property {(option: SelectOption, event?: GestureResponderEvent) => void} onSelect - Fires on option select. * Returns selected option/options. * * @property {StyleProp} label - Determines the `label` of the component. @@ -170,7 +167,7 @@ interface State { * * @example SelectInlineStyling */ -class SelectComponent extends React.Component { +class SelectComponent extends React.Component implements WebEventResponderCallbacks { static styledComponentName: string = 'Select'; @@ -180,24 +177,17 @@ class SelectComponent extends React.Component { }; public state: State = { - visible: false, + optionsVisible: false, }; private popoverRef: React.RefObject = React.createRef(); - private webEventResponder: WebEventResponderInstance = WebEventResponder.create(this); - - private selectionStrategy: SelectionStrategy; private iconAnimation: Animated.Value = new Animated.Value(0); - constructor(props: SelectProps) { - super(props); - const { multiSelect, selectedOption, keyExtractor, data } = this.props; - - this.selectionStrategy = multiSelect ? - new MultiSelectStrategy(selectedOption, data, keyExtractor) : - new SingleSelectStrategy(selectedOption, data, keyExtractor); - } + private selectService: SelectService = new SelectService({ + multiSelect: this.props.multiSelect, + keyExtractor: this.props.keyExtractor, + }); public show = (): void => { this.popoverRef.current.show(); @@ -208,32 +198,33 @@ class SelectComponent extends React.Component { }; public focus = (): void => { - this.setState({ visible: true }, this.dispatchActive); + this.setState({ optionsVisible: true }, this.onOptionsListVisible); }; public blur = (): void => { - this.setState({ visible: true }, this.dispatchActive); + this.setState({ optionsVisible: false }, this.onOptionsListInvisible); }; public isFocused = (): boolean => { - return this.state.visible; + return this.state.optionsVisible; }; public clear = (): void => { if (this.props.onSelect) { - this.selectionStrategy.select(null); this.props.onSelect(null); } }; + // WebEventResponderCallbacks + public onMouseEnter = (): void => { - if (!this.state.visible) { + if (!this.state.optionsVisible) { this.props.dispatch([Interaction.HOVER]); } }; public onMouseLeave = (): void => { - if (!this.state.visible) { + if (!this.state.optionsVisible) { this.props.dispatch([]); } }; @@ -247,7 +238,7 @@ class SelectComponent extends React.Component { }; private onPress = (event: GestureResponderEvent): void => { - this.toggleVisibility(); + this.setOptionsListVisible(); if (this.props.onPress) { this.props.onPress(event); @@ -272,38 +263,44 @@ class SelectComponent extends React.Component { private onSelect = (option: SelectOptionType, event: GestureResponderEvent): void => { if (this.props.onSelect) { - const selection: SelectOption = this.selectionStrategy.select(option, this.toggleVisibility); - this.props.onSelect(selection, event); - // FIXME: looks like a bug in selection strategy - this.forceUpdate(); + const options: SelectOption = this.selectService.select(option, this.props.selectedOption); + !this.props.multiSelect && this.setOptionsListInvisible(); + + this.props.onSelect(options, event); } }; - private toggleVisibility = (): void => { - const visible: boolean = !this.state.visible; - this.setState({ visible }, this.handleVisibleChange); + private onOptionsListVisible = (): void => { + this.props.dispatch([Interaction.ACTIVE]); + this.createIconAnimation(-180).start(); + }; + + private onOptionsListInvisible = (): void => { + this.props.dispatch([]); + this.createIconAnimation(0).start(); + }; + + private setOptionsListVisible = (): void => { + this.setState({ optionsVisible: true }, this.onOptionsListVisible); }; - private handleVisibleChange = (): void => { - this.dispatchActive(); - this.startIconAnimation(); + private setOptionsListInvisible = (): void => { + this.setState({ optionsVisible: false }, this.onOptionsListInvisible); }; - private dispatchActive = (): void => { - const interactions: Interaction[] = this.state.visible ? [Interaction.ACTIVE] : []; - this.props.dispatch(interactions); + private isOptionSelected = (option: SelectOptionType): boolean => { + return this.selectService.isSelected(option, this.props.selectedOption); }; - private startIconAnimation = (): void => { - const deg: number = this.state.visible ? -180 : 0; - this.animateIcon(deg); + private isOptionGroup = (option: SelectOptionType): boolean => { + return SelectService.isGroup(option); }; - private animateIcon = (toValue: number): void => { - Animated.timing(this.iconAnimation, { + private createIconAnimation = (toValue: number): Animated.CompositeAnimation => { + return Animated.timing(this.iconAnimation, { toValue: toValue, duration: 200, - }).start(); + }); }; private getComponentStyle = (source: StyleType): StyleType => { @@ -391,45 +388,47 @@ class SelectComponent extends React.Component { return ( - + ); }; private renderIconElement = (style: ImageStyle): IconElement => { - const iconElement = this.props.icon(style, this.state.visible); + const iconElement: React.ReactElement = this.props.icon(style, this.state.optionsVisible); return React.cloneElement(iconElement, { style: [style, iconElement.props.style], }); }; - private renderTextElement = (valueStyle: TextStyle, placeholderStyle: TextStyle): TextElement => { - const { placeholder, textStyle } = this.props; - const value: string = this.selectionStrategy.getPlaceholder(placeholder); - const style: TextStyle = placeholder === value ? placeholderStyle : valueStyle; + private renderTextElement = (style: StyleType): TextElement => { + const value: string = this.selectService.toStringOptions(this.props.selectedOption); + const textStyle: TextStyle = value && style.text; return ( - {value} + {value || this.props.placeholder} ); }; private renderOptionsListElement = (style: StyleType): SelectOptionsListElement => { - const { appearance, selectedOption, ...restProps } = this.props; - return ( ); }; @@ -439,7 +438,7 @@ class SelectComponent extends React.Component { return [ iconElement || this.renderDefaultIconElement(style.icon), - this.renderTextElement(style.text, style.placeholder), + this.renderTextElement(style), ]; }; @@ -474,7 +473,11 @@ class SelectComponent extends React.Component { const { themedStyle, style } = this.props; const { popover, ...componentStyle }: StyleType = this.getComponentStyle(themedStyle); - const [optionsListElement, labelElement, controlElement] = this.renderComponentChildren(componentStyle); + const [ + optionsListElement, + labelElement, + controlElement, + ] = this.renderComponentChildren(componentStyle); return ( @@ -483,9 +486,9 @@ class SelectComponent extends React.Component { ref={this.popoverRef} style={[popover, styles.popover]} fullWidth={true} - visible={this.state.visible} + visible={this.state.optionsVisible} content={optionsListElement} - onBackdropPress={this.toggleVisibility}> + onBackdropPress={this.setOptionsListInvisible}> {controlElement} diff --git a/src/components/ui/select/select.service.ts b/src/components/ui/select/select.service.ts new file mode 100644 index 000000000..843cf8183 --- /dev/null +++ b/src/components/ui/select/select.service.ts @@ -0,0 +1,169 @@ +import { KeyExtractorType } from './select.component'; +import { SelectOptionType } from './selectOption.component'; + +type Options = SelectOptionType | SelectOptionType[]; + +interface SelectStrategy { + select: (option: SelectOptionType, selectedOptions: T) => T; + isSelected: (option: SelectOptionType, selectedOptions: T) => boolean; + toStringOptions: (options: T) => string; +} + +interface SelectServiceOptions { + multiSelect?: boolean; + keyExtractor?: (option: SelectOptionType) => string; +} + +export class SelectService { + + private strategy: SelectStrategy; + + constructor(options: SelectServiceOptions = {}) { + const { multiSelect, keyExtractor } = options; + this.strategy = multiSelect ? new MultiSelectStrategy(keyExtractor) : new SingleSelectStrategy( + keyExtractor); + } + + public select = (option: SelectOptionType, selectedOptions: Options): Options => { + return this.strategy.select(option, selectedOptions); + }; + + public isSelected = (option: SelectOptionType, options: Options): boolean => { + return this.strategy.isSelected(option, options); + }; + + public toStringOptions = (options: Options): string => { + return options && this.strategy.toStringOptions(options); + }; + + static isGroup = (option: SelectOptionType): boolean => { + return option.items && option.items.length > 0; + }; + + static toStringOption = (option: SelectOptionType): string => { + return option.text; + }; + + static isEqualOptions = (lhs: SelectOptionType, + rhs: SelectOptionType, + keyExtractor?: KeyExtractorType): boolean => { + + if (keyExtractor) { + return (lhs && rhs) && keyExtractor(lhs) === keyExtractor(rhs); + } + + return lhs === rhs; + }; +} + +class SingleSelectStrategy implements SelectStrategy { + + constructor(private keyExtractor: KeyExtractorType) { + } + + public select = (option: SelectOptionType): SelectOptionType => { + return option; + }; + + public isSelected = (option: SelectOptionType, selectedOption: SelectOptionType): boolean => { + if (SelectService.isGroup(option)) { + return option.items.some(groupOption => this.isSelected(groupOption, selectedOption)); + } + return SelectService.isEqualOptions(selectedOption, option, this.keyExtractor); + }; + + public toStringOptions = (option: SelectOptionType): string => { + return SelectService.toStringOption(option); + }; +} + +class MultiSelectStrategy implements SelectStrategy { + + constructor(private keyExtractor: KeyExtractorType) { + } + + public select = (option: SelectOptionType, + selectedOptions: SelectOptionType[] = []): SelectOptionType[] => { + + if (SelectService.isGroup(option)) { + return this.selectOptionGroup(option, selectedOptions); + } else { + return this.selectOption(option, selectedOptions); + } + }; + + public isSelected = (option: SelectOptionType, + selectedOptions: SelectOptionType[] = []): boolean => { + + return this.isOptionSelected(option, selectedOptions); + }; + + public toStringOptions = (options: SelectOptionType[] = []): string => { + return options.map(SelectService.toStringOption).join(', '); + }; + + private selectOptionGroup = (option: SelectOptionType, + selectedOptions: SelectOptionType[]): SelectOptionType[] => { + + if (this.isGroupSelected(option, selectedOptions)) { + return this.removeOptionGroup(option, selectedOptions); + } else { + return this.addOptionGroup(option, selectedOptions); + } + }; + + private selectOption = (option: SelectOptionType, + selectedOptions: SelectOptionType[]): SelectOptionType[] => { + + if (this.isOptionSelected(option, selectedOptions)) { + return this.removeOption(selectedOptions, option); + } else { + return this.addOption(option, selectedOptions); + } + }; + + private isGroupSelected = (group: SelectOptionType, + selectedOptions: SelectOptionType[]): boolean => { + + return selectedOptions.some((selectedOption: SelectOptionType): boolean => { + return this.isOptionSelected(selectedOption, group.items); + }); + }; + + private isOptionSelected = (option: SelectOptionType, + selectedOptions: SelectOptionType[]): boolean => { + + return selectedOptions.some((selectedOption: SelectOptionType): boolean => { + return SelectService.isEqualOptions(selectedOption, option, this.keyExtractor); + }); + }; + + private addOptionGroup = (option: SelectOptionType, + selectedOptions: SelectOptionType[]): SelectOptionType[] => { + + const options: SelectOptionType[] = option.items.filter(groupOption => !groupOption.disabled); + + return selectedOptions.concat(options); + }; + + private addOption = (option: SelectOptionType, + selectedOptions: SelectOptionType[]) => { + + return selectedOptions.concat(option); + }; + + private removeOptionGroup = (option: SelectOptionType, + selectedOptions: SelectOptionType[]): SelectOptionType[] => { + + return option.items.reduce(this.removeOption, selectedOptions); + }; + + private removeOption = (selectedOptions: SelectOptionType[], + option: SelectOptionType): SelectOptionType[] => { + + return selectedOptions.filter((selectedOption: SelectOptionType): boolean => { + return !SelectService.isEqualOptions(selectedOption, option, this.keyExtractor); + }); + }; +} + diff --git a/src/components/ui/select/select.spec.tsx b/src/components/ui/select/select.spec.tsx index b69a5c529..6c6bcfefe 100644 --- a/src/components/ui/select/select.spec.tsx +++ b/src/components/ui/select/select.spec.tsx @@ -5,8 +5,8 @@ import { TouchableOpacity, } from 'react-native'; import { - render, fireEvent, + render, RenderAPI, } from 'react-native-testing-library'; import { @@ -19,6 +19,7 @@ import { mapping, theme, } from '../support/tests'; +import { SelectService } from './select.service'; jest.useFakeTimers(); @@ -91,7 +92,8 @@ class TestApplication extends React.Component { onPressIn={onSelectPressIn} onPressOut={onSelectPressOut} onLongPress={onSelectLongPress} - onSelect={() => {}} + onSelect={() => { + }} />