diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dacca09 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog 📝 + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.2] - 2022-09-26 + +### Added + +- Adicionado componente alternativo ao Alert para mostrar erros de forma mais sutil (Snackbar) +- Adicionado retornos original de http status e http response data para erros de request +- Adicionado exemplo de testes usando FireEvent +- Adicionado Mock para RN Reanimated +- Adicionado Mock para RN Safe Area Context + +### Changed + +- Definindo versão fixa de instalação do React Native Reanimated +- Primeira letra do campo de erro é mostrado com letra maiúscula +- Novo componente de input com suporte melhor a dark mode, eventos de troca de texto e mostrar label personalizada. + +### Fixed + +- Usando estilo compatível com VSCode Styled +- Alinhando texto de erro no input +- Status 422 agora mostrar o erro processado +- Tirando opacidade da cores em componentes desabilitados +- Adicionado opção para configurar cor do `placeholder` +- Mock de navegação incorporar o tema +- Evitar problemas de sobreposição do teclado no iOS onde há scroll +- Suporte a DarkMode para o Select +- FormData não funcionar no Jest +- Falhas na importação de funções do RN Platform +- Corrigido problema do Safe Area Context não funcionar no Jest + +## [1.0.1] - 2022-06-20 + +### Fixed + +- Aplicando cor no fundo que faltava + +## [1.0.0] - 2022-06-13 + +### Added + +- Criado boilerplate diff --git a/README.md b/README.md index d444bda..08b7cfb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Há duas maneiras de usar esse boilerplate. - [react-native-vector-icons](https://github.com/oblador/react-native-vector-icons#installation) - [react-native-bootsplash](https://github.com/zoontek/react-native-bootsplash#setup-1) - [react-native-screens](https://github.com/software-mansion/react-native-screens#installation) - +- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation) ## Configurações ### Variável de Ambiente @@ -226,6 +226,34 @@ const [isFocus, setIsFocus] = useState(false); const [currentValue, setNewValue] = useState(''); ``` +- Passando atributos no Styled. Atributos que utilizam as `props`, devem ter `return` em seu corpo pois a extensão do VSCode do styled buga sem esse padrão de estilo de código. + +❌ Não fazer + +```ts +export const ContainerTextInput = styled(TextInputComponent).attrs((props) => ({ + mode: props.mode || 'outlined', + autoCapitalize: props.autoCapitalize || 'words', + placeholderTextColor: + props.placeholderTextColor || props.theme.colors.placeholderText, + dense: true, +}))``; +``` + +✅ Fazer isso + +```ts +export const ContainerTextInput = styled(TextInputComponent).attrs((props) => { + return { + mode: props.mode || 'outlined', + autoCapitalize: props.autoCapitalize || 'words', + placeholderTextColor: + props.placeholderTextColor || props.theme.colors.placeholderText, + dense: true, + }; +})``; +``` + ## Testes Para testes unitários está sendo utilizado biblioteca [Jest](https://jestjs.io/docs/getting-started) e para escrever os testes é necessário criar arquivos `.spec.ts`/ `.spec.tsx` dentro do mesmo diretório em que se encontra o fragmento de código. @@ -359,3 +387,4 @@ Os mocks a serem consumido no projeto podem ser criados em [`src/tests/mocks/`]( - [react-native-svg-transformer](https://github.com/kristerkari/react-native-svg-transformer): Permitir importar arquivos .SVG - [@react-native-firebase/remote-config](https://rnfirebase.io/remote-config/usage): Integração com Firebase Remote Config - [react-native-camera](https://github.com/react-native-camera/react-native-camera): Suporte para usar câmera nativamente do dispositivo +- [react-native-keyboard-aware-scroll-view](https://github.com/APSL/react-native-keyboard-aware-scroll-view): Evitar problemas de sobreposição do teclado diff --git a/package.json b/package.json index 14925bd..24b42d5 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,12 @@ "react-hook-form": "^7.29.0", "react-native": "0.68.2", "react-native-device-info": "^8.7.1", - "react-native-element-dropdown": "^1.8.10", - "react-native-gesture-handler": "^2.3.2", + "react-native-element-dropdown": "^2.3.0", + "react-native-gesture-handler": "^2.5.0", + "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-mask-text": "^0.7.0", "react-native-paper": "^4.12.0", + "react-native-reanimated": "^2.9.1", "react-native-safe-area-context": "^4.2.4", "react-native-screens": "^3.13.1", "react-native-svg": "^12.3.0", diff --git a/patches/react-native-element-dropdown+1.8.11.patch b/patches/react-native-element-dropdown+1.8.11.patch deleted file mode 100644 index 8c59ecf..0000000 --- a/patches/react-native-element-dropdown+1.8.11.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx b/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx -index 81493c8..70454cb 100644 ---- a/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx -+++ b/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx -@@ -272,7 +272,6 @@ const DropdownComponent = React.forwardRef((props, currentRe - ref={refList} - onScrollToIndexFailed={scrollIndex} - data={listData} -- inverted - renderItem={_renderItem} - keyExtractor={(item, index) => index.toString()} - showsVerticalScrollIndicator={showsVerticalScrollIndicator} diff --git a/patches/react-native-element-dropdown+2.3.0.patch b/patches/react-native-element-dropdown+2.3.0.patch new file mode 100644 index 0000000..39b22a8 --- /dev/null +++ b/patches/react-native-element-dropdown+2.3.0.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx b/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx +index a8feae8..94edebf 100644 +--- a/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx ++++ b/node_modules/react-native-element-dropdown/src/components/Dropdown/index.tsx +@@ -449,7 +449,6 @@ const DropdownComponent = React.forwardRef( + ref={refList} + onScrollToIndexFailed={scrollIndex} + data={listData} +- inverted + renderItem={_renderItem} + keyExtractor={(_item, index) => index.toString()} + showsVerticalScrollIndicator={showsVerticalScrollIndicator} +diff --git a/node_modules/react-native-element-dropdown/src/components/MultiSelect/index.tsx b/node_modules/react-native-element-dropdown/src/components/MultiSelect/index.tsx +index ad33b78..2c306e8 100644 +--- a/node_modules/react-native-element-dropdown/src/components/MultiSelect/index.tsx ++++ b/node_modules/react-native-element-dropdown/src/components/MultiSelect/index.tsx +@@ -420,7 +420,6 @@ const MultiSelectComponent = React.forwardRef( + {...flatListProps} + keyboardShouldPersistTaps="handled" + data={listData} +- inverted + renderItem={_renderItem} + keyExtractor={(_item, index) => index.toString()} + showsVerticalScrollIndicator={showsVerticalScrollIndicator} diff --git a/patches/react-native-paper+4.12.1.patch b/patches/react-native-paper+4.12.1.patch index 65ca6f7..b9a9e89 100644 --- a/patches/react-native-paper+4.12.1.patch +++ b/patches/react-native-paper+4.12.1.patch @@ -1,3 +1,20 @@ +diff --git a/node_modules/react-native-paper/src/components/TextInput/Label/InputLabel.tsx b/node_modules/react-native-paper/src/components/TextInput/Label/InputLabel.tsx +index 07e6d3c..75dc406 100644 +--- a/node_modules/react-native-paper/src/components/TextInput/Label/InputLabel.tsx ++++ b/node_modules/react-native-paper/src/components/TextInput/Label/InputLabel.tsx +@@ -91,12 +91,6 @@ const InputLabel = (props: InputLabelProps) => { + labelTranslationX, + ]} + > +- {labelBackground?.({ +- parentState, +- labelStyle, +- labelProps: props.labelProps, +- maxFontSizeMultiplier: maxFontSizeMultiplier, +- })} + { text: theme.colors[colorSchemeName].primaryText, error: theme.colors[colorSchemeName].attention, disabled: theme.colors[colorSchemeName].disabled, + placeholder: theme.colors[colorSchemeName].placeholder, }, }; diff --git a/src/components/Input/index.spec.tsx b/src/components/Input/index.spec.tsx index 5b31a0d..db36674 100644 --- a/src/components/Input/index.spec.tsx +++ b/src/components/Input/index.spec.tsx @@ -1,5 +1,14 @@ -import React from 'react'; -import { cleanup, render } from '@testing-library/react-native'; +import React, { useRef } from 'react'; + +import { useForm } from 'react-hook-form'; + +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from '@testing-library/react-native'; import '@testing-library/jest-native/extend-expect'; import theme from '@theme/index'; @@ -8,7 +17,7 @@ import { shadowTheme } from '@tests/actions/styledTheme'; import Input from './index'; -jest.useFakeTimers(); +jest.useFakeTimers('legacy'); describe('Input component', () => { beforeEach(cleanup); @@ -16,6 +25,137 @@ describe('Input component', () => { it('should render Input component correctly', () => { render(shadowTheme()); + render( + shadowTheme( + , + ), + ); + }); + + it('should render Input component correctly with control', () => { + const Component = () => { + const { control } = useForm<{ + test: string; + }>(); + + const testParam = ''; + + return ( + + ); + }; + + render(shadowTheme()); + }); + + it('should render Input component correctly and use onFocus and onBlur', async () => { + const Component = () => { + return ( + + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await waitFor(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + + await fireEvent(InputTextField, 'onFocus'); + await fireEvent.changeText(InputTextField, 'test password'); + await fireEvent(InputTextField, 'onBlur'); + }); + }); + + it('should render Input component correctly with control and use onFocus and onBlur', async () => { + const Component = () => { + const { control } = useForm<{ + test: string; + }>(); + + const testParam = ''; + + return ( + + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await waitFor(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + + await fireEvent(InputTextField, 'onFocus'); + await fireEvent.changeText(InputTextField, 'test password'); + await fireEvent(InputTextField, 'onBlur'); + }); + }); + + it('should render Input component correctly and use onFocus and onBlur with inputRef', async () => { + const Component = () => { + const inputRef = useRef(null); + + return ( + + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await waitFor(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + + await fireEvent(InputTextField, 'onFocus'); + await fireEvent.changeText(InputTextField, 'test password'); + await fireEvent(InputTextField, 'onBlur'); + }); + }); + + it('should render Input component correctly with control and use onFocus and onBlur with InputRef', async () => { + const Component = () => { + const inputRef = useRef(null); + + const { control } = useForm<{ + test: string; + }>(); + + const testParam = ''; + + return ( + + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await waitFor(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + + await fireEvent(InputTextField, 'onFocus'); + await fireEvent.changeText(InputTextField, 'test password'); + await fireEvent(InputTextField, 'onBlur'); + }); }); it('should render Input component with icon', () => { @@ -69,19 +209,15 @@ describe('Input component', () => { const IconComponent = getByA11yRole('button') as any; - const IconComponentColor = - IconComponent.parent.children[0].props.children[0].props.children.props - .color; - expect(IconComponent).toBeDisabled(); expect(IconComponent).not.toBeUndefined(); - expect(IconComponentColor).toEqual('rgba(235, 235, 228, 0.3)'); }); - it('should render Input component with enabled icon', () => { + it('should render Input component with enabled icon', async () => { const { getByA11yRole } = render( shadowTheme( { expect(IconComponent).not.toBeDisabled(); expect(IconComponent).not.toBeUndefined(); - expect(IconComponentColor).toEqual('#ffba00'); + expect(IconComponentColor).toEqual(theme.colors.light.primary); + }); + + it('should render Input component with password eye icon', async () => { + const Component = () => { + const inputRef = useRef(null); + + return ( + + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await act(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + + await fireEvent.changeText(InputTextField, 'test password'); + }); + const InputTextField = getByTestId('Input:textInput:input'); + + expect(InputTextField.props.secureTextEntry).toBeTruthy(); + }); + + it('should render Input component with password eye icon and show password', async () => { + const Component = () => { + const inputRef = useRef(null); + + return ( + + ); + }; + const { getByA11yRole, getByTestId } = render(shadowTheme()); + + await act(async () => { + const IconComponent = getByA11yRole('button') as any; + + await fireEvent.press(IconComponent); + }); + const InputTextField = getByTestId('Input:textInput:input'); + + expect(InputTextField.props.secureTextEntry).toBeFalsy(); + }); + + it('should render Input component with label and * required', async () => { + const Component = () => { + const inputRef = useRef(null); + + return ( + + ); + }; + + render(shadowTheme()); + }); + + it('should render Input component correctly with control and use onChangeCustom', async () => { + let testCustomValue = ''; + + const Component = () => { + const inputRef = useRef(null); + + const { control } = useForm<{ + test: string; + }>(); + + const testParam = ''; + + return ( + { + testCustomValue = text; + }} + /> + ); + }; + + const { getByTestId } = render(shadowTheme()); + + await waitFor(async () => { + const InputTextField = getByTestId('Input:textInput:input'); + await fireEvent.changeText(InputTextField, 'test password'); + }); + + expect(testCustomValue).toBe('test password'); }); }); diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 6f6197c..8a311e5 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,23 +1,29 @@ import React from 'react'; -import { TextInputProps } from 'react-native'; +import { TextInput as TextInputRN, TextInputProps } from 'react-native'; import { Control } from 'react-hook-form/dist/types'; import { RenderProps } from 'react-native-paper/lib/typescript/components/TextInput/types'; +import { Callback } from '@typings/common'; + import { FormController, ErrorMessageText, ContainerTextInput, getColorIcon, + AlertText, + LabelText, } from './styles'; -interface Props extends Omit { +export interface Props extends Omit { + inputRef?: React.RefObject; control?: Control; name?: string; param?: string; errorMessage?: string; icon?: string; - onPressIcon?: () => void; + iconForceColor?: string; + onPressIcon?: Callback; selectionColor?: string; isDisabled?: boolean; render?: (props: RenderProps) => React.ReactNode; @@ -25,53 +31,139 @@ interface Props extends Omit { mode?: 'flat' | 'outlined' | undefined; label?: string; dense?: boolean; + isPasswordInput?: boolean; + isShowRequired?: boolean; + onChangeCustom?: (text: string) => void; } const TextInput = ({ + inputRef, render, control, name, param, errorMessage, icon, + iconForceColor, onPressIcon, - isDisabled, - hasError, + isDisabled = false, + hasError = false, + isPasswordInput = false, + isShowRequired = false, + onChangeCustom, ...props }: Props) => { + const [isShowPassword, setPasswordShow] = React.useState( + !isPasswordInput, + ); + const [isFocus, setIsFocus] = React.useState(false); + const RightIcon = (): JSX.Element | null => { + if (isPasswordInput) { + return ( + setPasswordShow(!isShowPassword)} + disabled={isDisabled} + size={25} + /> + ); + } + return icon ? ( ) : null; }; + const textInputOptions: Props = { ...props }; + if (isPasswordInput) { + textInputOptions.autoComplete = 'password'; + textInputOptions.spellCheck = false; + textInputOptions.autoCorrect = false; + textInputOptions.secureTextEntry = !isShowPassword; + textInputOptions.autoCapitalize = undefined; + } else if ( + !textInputOptions.autoCapitalize && + textInputOptions.autoCapitalize !== undefined + ) { + textInputOptions.autoCapitalize = 'words'; + } + + if (textInputOptions.label) { + // @ts-ignore + textInputOptions.label = ( + + {textInputOptions.label} + {isShowRequired && *} + + ); + } + return ( <> {control && name ? ( <> { - return ( - - ); - }} + render={ + /* istanbul ignore next */ ({ + field: { onChange, onBlur, value }, + }) => { + return inputRef ? ( + { + onChange(text); + onChangeCustom && onChangeCustom(text); + }} + onBlur={() => { + setIsFocus(false); + onBlur(); + }} + onFocus={() => { + setIsFocus(true); + }} + right={RightIcon()} + error={hasError} + disabled={isDisabled} + {...textInputOptions} + /> + ) : ( + { + onChange(text); + onChangeCustom && onChangeCustom(text); + }} + onBlur={() => { + setIsFocus(false); + onBlur(); + }} + onFocus={() => { + setIsFocus(true); + }} + right={RightIcon()} + error={hasError} + disabled={isDisabled} + {...textInputOptions} + /> + ); + } + } name={name} defaultValue={param} /> @@ -81,12 +173,34 @@ const TextInput = ({ ) : ( <> - + {inputRef ? ( + { + setIsFocus(false); + }} + onFocus={() => { + setIsFocus(true); + }} + right={RightIcon()} + error={hasError} + ref={inputRef} + {...textInputOptions} + /> + ) : ( + { + setIsFocus(false); + }} + onFocus={() => { + setIsFocus(true); + }} + right={RightIcon()} + error={hasError} + {...textInputOptions} + /> + )} {!!errorMessage && hasError && ( {errorMessage} )} diff --git a/src/components/Input/styles.ts b/src/components/Input/styles.ts index aca0959..361f6a7 100644 --- a/src/components/Input/styles.ts +++ b/src/components/Input/styles.ts @@ -1,9 +1,39 @@ import { useContext } from 'react'; -import styled, { ThemeContext } from 'styled-components/native'; -import { TextInput as TextInputComponent } from 'react-native-paper'; +import styled, { ThemeContext, DefaultTheme } from 'styled-components/native'; +import { TextInput as TextInputComponent, Text } from 'react-native-paper'; import { Controller as ControllerComponent } from 'react-hook-form'; +interface CommonProps { + theme: DefaultTheme; + isFocus: boolean; + isDisabled: boolean; + hasError: boolean; + placeholderTextColor?: string; +} + +const colorVerification = ( + theme: DefaultTheme, + isFocus: boolean, + isDisabled: boolean, + hasError: boolean, + color: string, +) => { + if (hasError) { + return theme.colors.attention; + } + + if (isFocus) { + return theme.colors.primary; + } + + if (isDisabled) { + return theme.colors.disabled; + } + + return color; +}; + export const getColorIcon = ( hasError: boolean = false, isDisabled: boolean = false, @@ -21,21 +51,40 @@ export const getColorIcon = ( return themeContext.colors.primary; }; -export const ContainerTextInput = styled(TextInputComponent).attrs((props) => ({ - mode: props.mode || 'outlined', - autoCapitalize: props.autoCapitalize || 'words', - placeholderTextColor: - props.placeholderTextColor || props.theme.colors.placeholderText, - dense: true, -}))` +export const ContainerTextInput = styled(TextInputComponent).attrs((props) => { + return { + mode: props.mode || 'outlined', + placeholderTextColor: + props.placeholderTextColor || props.theme.colors.placeholderText, + dense: true, + }; +})` font-size: 18px; padding-bottom: 0px; - background-color: white; + background-color: ${(props) => props.theme.colors.backgroundLight}; ` as unknown as typeof TextInputComponent; +export const LabelText = styled(Text)` + background-color: ${(props) => props.theme.colors.backgroundLight}; + color: ${(props: CommonProps) => + colorVerification( + props.theme, + props.isFocus, + props.isDisabled, + props.hasError, + '#626466', + )}; +`; + +export const AlertText = styled(Text)` + color: ${(props) => props.theme.colors.attention}; ; +`; + export const FormController = styled(ControllerComponent)``; export const ErrorMessageText = styled.Text` margin-top: 5px; + margin-right: 8px; + margin-left: 8px; color: ${(props) => props.theme.colors.attentionLight}; `; diff --git a/src/components/Select/index.spec.tsx b/src/components/Select/index.spec.tsx new file mode 100644 index 0000000..fe015f4 --- /dev/null +++ b/src/components/Select/index.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { act, cleanup, fireEvent, render } from '@testing-library/react-native'; +import '@testing-library/jest-native/extend-expect'; + +import { shadowTheme } from '@tests/actions/styledTheme'; + +import Select from './index'; +import ListEmptyLabel from './ListEmptyLabel'; + +jest.useFakeTimers('legacy'); + +describe('Select component', () => { + beforeEach(cleanup); + afterEach(cleanup); + + it('should render component correctly', () => { + render(shadowTheme(, + ), + ); + + await act(async () => { + const SelectView = await getByTestId('select'); + await fireEvent(SelectView, 'onChange', options[0]); + }); + + expect(onValueChangeMock).toHaveBeenCalledTimes(1); + expect(onValueChangeMock).toHaveBeenLastCalledWith(options[0]); + }); +}); diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index 71d2cf6..5fdc936 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -9,9 +9,11 @@ import { ErrorMessage, Dropdown, LabelText, + AlertText, ContainerView, FixLabelBackgroundView, LabelBackgroundView, + PlaceholderText, } from './styles'; interface Props { @@ -36,6 +38,8 @@ interface Props { isDisabled?: boolean; maxHeight?: number; placeholderTextColor?: string; + testID?: string; + isShowRequired?: boolean; } export default ({ @@ -52,11 +56,23 @@ export default ({ keyItem, onValueChange, isDisabled, + testID, + isShowRequired = false, ...rest }: Props) => { const [isFocus, setIsFocus] = useState(false); const [currentValue, setNewValue] = useState(''); + if (rest.placeholder) { + // @ts-ignore + rest.placeholder = ( + + {rest.placeholder} + {isShowRequired && *} + + ); + } + return ( {control && name ? ( @@ -69,45 +85,57 @@ export default ({ isDisabled={isDisabled ? true : false} hasError={hasError ? true : false}> {label} + {isShowRequired && *} )} { - return ( - <> - { - if (onValueChange) { - onValueChange(option); - } + render={ + /* istanbul ignore next */ ({ field: { onChange, value } }) => { + return ( + <> + ; + }) => { + if (onValueChange) { + onValueChange(option); + } - setNewValue(keyItem ? option[keyItem] : option.value); - keyItem - ? onChange(option[keyItem]) - : onChange(option.value); - setIsFocus(false); + setNewValue(keyItem ? option[keyItem] : option.value); + keyItem + ? onChange(option[keyItem]) + : onChange(option.value); + setIsFocus(false); + } } - } - onFocus={/* istanbul ignore next */ () => setIsFocus(true)} - onBlur={/* istanbul ignore next */ () => setIsFocus(false)} - /> - - ); - }} + onFocus={ + /* istanbul ignore next */ () => setIsFocus(true) + } + onBlur={ + /* istanbul ignore next */ () => setIsFocus(false) + } + /> + + ); + } + } name={name} rules={rules} defaultValue={param} @@ -115,7 +143,35 @@ export default ({ {hasError && {errorMessage}} ) : ( - <> + ; + }) => { + if (onValueChange) { + onValueChange(option); + } + + setNewValue(keyItem ? option[keyItem] : option.value); + setIsFocus(false); + } + } + onFocus={/* istanbul ignore next */ () => setIsFocus(true)} + onBlur={/* istanbul ignore next */ () => setIsFocus(false)} + /> )} ); diff --git a/src/components/Select/styles.ts b/src/components/Select/styles.ts index a261414..b4cf834 100644 --- a/src/components/Select/styles.ts +++ b/src/components/Select/styles.ts @@ -47,17 +47,25 @@ export const Dropdown = styled(DropdownComponent).attrs( disabled: props.isDisabled, dropdownPosition: 'auto', keyboardAvoiding: true, + activeColor: props.theme.colors.backgroundSecondaryLight, + itemTextStyle: { + color: props.theme.colors.primaryText, + }, placeholderStyle: { fontSize: 16, color: props.hasError ? props.theme.colors.attention : props.placeholderTextColor || '#6f6f6f', }, + containerStyle: { + backgroundColor: props.theme.colors.backgroundLight, + }, selectedTextStyle: { fontSize: 16, color: props.theme.colors.primaryText, }, inputSearchStyle: { + backgroundColor: props.theme.colors.backgroundLight, height: 40, fontSize: 16, }, @@ -71,7 +79,7 @@ export const Dropdown = styled(DropdownComponent).attrs( }), )` height: 45px; - background-color: white; + background-color: ${(props) => props.theme.colors.backgroundLight}; padding-left: 12px; padding-right: 12px; border-color: ${(props) => @@ -82,12 +90,12 @@ export const Dropdown = styled(DropdownComponent).attrs( props.hasError, '#626466', )}; - border-radius: 6px; + border-radius: 20px; border-width: ${(props) => (props.isFocus ? 1.75 : 1)}px; `; export const LabelText = styled(Text)` - opacity: ${(props) => (props.isFocus ? 1 : 0.5)}; + opacity: ${(props) => (props.isFocus ? 1 : 0.75)}; padding-left: 4px; padding-right: 4px; font-size: 12px; @@ -101,12 +109,21 @@ export const LabelText = styled(Text)` )}; `; +export const PlaceholderText = styled(Text)` + font-size: 20px; + color: #626466; +`; + +export const AlertText = styled(Text)` + color: ${(props) => props.theme.colors.attention}; ; +`; + export const ContainerView = styled.View` margin-top: 6px; `; export const LabelBackgroundView = styled.View` - background-color: #ffffff; + background-color: ${(props) => props.theme.colors.backgroundLight}; z-index: 999; position: absolute; left: 10px; diff --git a/src/components/SnackbarError/index.spec.tsx b/src/components/SnackbarError/index.spec.tsx new file mode 100644 index 0000000..63150c0 --- /dev/null +++ b/src/components/SnackbarError/index.spec.tsx @@ -0,0 +1,50 @@ +import React, { useRef } from 'react'; +import { act, cleanup, render } from '@testing-library/react-native'; +import '@testing-library/jest-native/extend-expect'; + +import { shadowTheme } from '@tests/actions/styledTheme'; + +import SnackbarError, { Handle } from './index'; + +jest.useFakeTimers('legacy'); + +describe('Snackbar Error component', () => { + beforeEach(() => { + cleanup(); + jest.clearAllTimers(); + }); + + afterEach(() => { + cleanup(); + jest.clearAllTimers(); + }); + + it('should render component correctly', () => { + render(shadowTheme()); + render(shadowTheme()); + }); + + it('should show error message', async () => { + let snackbarErrorRef: React.RefObject | undefined; + const callback = jest.fn(); + + const Component = () => { + snackbarErrorRef = useRef(null); + return ; + }; + + const { getByText, queryByText } = render(shadowTheme()); + + expect(queryByText('Title error')).toBeNull(); + + await act(async () => { + snackbarErrorRef?.current?.show({ + title: 'Title error', + callback: callback, + }); + }); + + expect(callback).toBeCalledTimes(0); + getByText('Title error'); + }); +}); diff --git a/src/components/SnackbarError/index.tsx b/src/components/SnackbarError/index.tsx new file mode 100644 index 0000000..9baba2e --- /dev/null +++ b/src/components/SnackbarError/index.tsx @@ -0,0 +1,63 @@ +import React, { useImperativeHandle, useState } from 'react'; + +import { Callback } from '@typings/common'; + +import { Snackbar } from './styles'; + +export interface Handle { + show(data: Data): void; +} + +export interface Data { + title: string; + callback: Callback; +} + +export interface Props { + currentPosition?: 'top' | 'bottom'; +} + +const SnackbarError: React.ForwardRefRenderFunction = ( + { currentPosition = 'top' }, + forwardedRef, +) => { + const [isVisible, setVisible] = useState(false); + const [data, setData] = useState({ + title: '', + /* istanbul ignore next */ + callback: () => {}, + }); + + /* istanbul ignore next */ + const onDismissSnackbar = () => { + setVisible(false); + }; + + const show = (newDate: Data) => { + setData(newDate); + setVisible(true); + }; + + const handleRefCallback = () => { + return { + show, + }; + }; + + useImperativeHandle(forwardedRef, handleRefCallback); + + return ( + + {data.title} + + ); +}; + +export default React.forwardRef(SnackbarError); diff --git a/src/components/SnackbarError/styles.ts b/src/components/SnackbarError/styles.ts new file mode 100644 index 0000000..fd121df --- /dev/null +++ b/src/components/SnackbarError/styles.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components/native'; + +import { second } from '@utils/units'; + +import { Snackbar as SnackbarComponent } from 'react-native-paper'; + +export interface Props { + currentPosition: 'top' | 'bottom'; +} + +export const Snackbar = styled(SnackbarComponent).attrs((props: Props) => ({ + duration: 3 * second, + wrapperStyle: props.currentPosition === 'top' ? { top: 0 } : { bottom: 0 }, +}))``; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index c72946f..13e13f9 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -5,13 +5,13 @@ import { User } from '@typings/common'; import { setAccessToken } from '@services/api'; -export type AuthContextData = { +export interface AuthContextData { user: User; isAuthenticated: boolean; isLoading: boolean; saveUser: (user: User, isUsedCached?: boolean) => Promise; deleteUser: () => Promise; -}; +} export const AuthContext = createContext( {} as AuthContextData, diff --git a/src/routes/MockedNavigator.tsx b/src/routes/MockedNavigator.tsx index 53d34bf..31964c7 100644 --- a/src/routes/MockedNavigator.tsx +++ b/src/routes/MockedNavigator.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { withThemeProvider } from '@tests/actions/styledTheme'; +import { shadowTheme } from '@tests/actions/styledTheme'; const AppStack = createNativeStackNavigator(); @@ -13,18 +13,16 @@ interface Props { } const MockedNavigator = ({ component, params = {} }: Props) => { - const Providers: React.FC = (props) => withThemeProvider(component, props); - - return ( + return shadowTheme( - + , ); }; diff --git a/src/screens/Auth/Login/index.spec.tsx b/src/screens/Auth/Login/index.spec.tsx index d98f65f..d098374 100644 --- a/src/screens/Auth/Login/index.spec.tsx +++ b/src/screens/Auth/Login/index.spec.tsx @@ -1,12 +1,24 @@ import React from 'react'; -import { act, cleanup, fireEvent, render } from '@testing-library/react-native'; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from '@testing-library/react-native'; import { alertSpy } from '@tests/actions/alertSpy'; + import { mockedNavigate } from '@tests/mocks/rnNavigation'; +import { mockedUpdateStateUser } from '@tests/mocks/authContext'; + +import exampleMethodName from '@tests/responses/exampleMethodName'; import MockedNavigator from '@routes/MockedNavigator'; +import { states } from '@utils/lists'; + import Login from './index'; jest.mock('@services/api'); @@ -16,6 +28,7 @@ describe('Login Screen', () => { cleanup(); alertSpy.mockClear(); mockedNavigate.mockClear(); + mockedUpdateStateUser.mockClear(); }); beforeEach(() => { @@ -28,6 +41,8 @@ describe('Login Screen', () => { }); it('should show error for empty fields', async () => { + const exampleMethodNameMocked = exampleMethodName.withSuccess(); + const { getByText } = render(); await act(async () => { @@ -37,5 +52,82 @@ describe('Login Screen', () => { getByText('Telefone é obrigatório'); getByText('Estado é obrigatório'); + + expect(mockedUpdateStateUser).toBeCalledTimes(0); + expect(exampleMethodNameMocked.mock).toBeCalledTimes(0); + }); + + it('should open page of login with request success', async () => { + const exampleMethodNameMocked = exampleMethodName.withSuccess(); + + const { getByText, queryByText, getByTestId } = render( + , + ); + + await act(async () => { + await fireEvent.changeText( + await getByTestId('textInput:phoneNumber'), + '12345678910', + ); + + await fireEvent(await getByTestId('select:state'), 'onChange', states[0]); + }); + + await act(async () => { + const RegisterButton = await getByText('Entrar'); + await fireEvent.press(RegisterButton); + }); + + expect(queryByText('Telefone é obrigatório')).toBeNull(); + expect(queryByText('Estado é obrigatório')).toBeNull(); + + expect(exampleMethodNameMocked.mock).toBeCalledTimes(1); + expect(mockedUpdateStateUser).toBeCalledTimes(1); + expect(mockedUpdateStateUser).toHaveBeenLastCalledWith({ + accessToken: '4c0393ae35e1.4fb787d.564f2d02eba0e.3c3237aba0944c0', + idToken: '924af6cd-3e53-40dc-a89d-054cd90307a3', + }); + }); + + it('should open page of login with request failed', async () => { + const exampleMethodNameMocked = exampleMethodName.withFailed(); + + const { getByText, queryByText, getByTestId } = render( + , + ); + + await act(async () => { + await fireEvent.changeText( + await getByTestId('textInput:phoneNumber'), + '12345678910', + ); + + await fireEvent(await getByTestId('select:state'), 'onChange', states[0]); + }); + + await act(async () => { + const RegisterButton = await getByText('Entrar'); + await fireEvent.press(RegisterButton); + }); + + expect(queryByText('Telefone é obrigatório')).toBeNull(); + expect(queryByText('Estado é obrigatório')).toBeNull(); + + expect(exampleMethodNameMocked.mock).toBeCalledTimes(1); + + await act(async () => { + await waitFor(() => alertSpy); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(alertSpy).toHaveBeenLastCalledWith( + 'Não foi possível realizar o login', + 'Não foi possível obter dados', + expect.anything(), + expect.anything(), + ); + }); + + // TODO: change this to 0 + expect(mockedUpdateStateUser).toBeCalledTimes(1); }); }); diff --git a/src/screens/Auth/Login/index.tsx b/src/screens/Auth/Login/index.tsx index 7086e35..7b2cc03 100644 --- a/src/screens/Auth/Login/index.tsx +++ b/src/screens/Auth/Login/index.tsx @@ -5,7 +5,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { ParamsExampleMethodName } from '@typings/requests'; -import { states } from '@utils/statesList'; +import { states } from '@utils/lists'; import { exampleMethodName } from '@services/api'; @@ -77,7 +77,7 @@ const Login = () => { {