diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3d7fe1b2..66d7b675 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,10 +8,3 @@ updates: open-pull-requests-limit: 10 reviewers: - calintamas - ignore: - - dependency-name: husky - versions: - - '> 3.1.0' - - dependency-name: husky - versions: - - '>= 4.a, < 5' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c2356a0..2ef18fb3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,8 +32,8 @@ jobs: - name: Setup React Native environment run: | - yarn add react-native@0.63.4 - yarn add react@16.13.1 + yarn add react@17.0.1 + yarn add react-native@0.64.2 - name: Run tests run: yarn test --coverage diff --git a/.npmignore b/.npmignore index 737a4e81..1c3ac6b1 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1 @@ -__tests__ \ No newline at end of file +**/__tests__/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..909521ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Calin Tamas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..48181c57 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + preset: 'react-native', + testEnvironment: 'node', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.{ts,tsx}'], + setupFilesAfterEnv: [ + '@testing-library/jest-native/extend-expect', + './jest.setup.js' + ], + testPathIgnorePatterns: ['/__helpers__/'] +}; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 00000000..5c2e434b --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,3 @@ +/* eslint-env jest */ + +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); diff --git a/package.json b/package.json index 1b4fab2a..07f28d9a 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,25 @@ "type": "git", "url": "git+https://github.com/calintamas/react-native-toast-message.git" }, + "keywords": [ + "react-native", + "toast" + ], "scripts": { "prepare": "husky install", "build": "rm -rf ./lib && tsc", "prettier": "./node_modules/.bin/prettier --write", "lint": "./node_modules/.bin/eslint --fix", - "lint-staged": "./node_modules/.bin/lint-staged" + "lint-staged": "./node_modules/.bin/lint-staged", + "test": "./node_modules/.bin/jest" }, "author": "Calin Tamas ", "license": "MIT", "devDependencies": { + "@babel/core": "^7.15.8", "@testing-library/jest-native": "^4.0.2", - "@testing-library/react-native": "^7.2.0", + "@testing-library/react-hooks": "^7.0.2", + "@testing-library/react-native": "^8.0.0", "@types/jest": "^27.0.1", "eslint-config-backpacker-react-ts": "^0.1.0", "husky": "^7.0.2", diff --git a/src/Toast.tsx b/src/Toast.tsx index 88bfb9a3..9e6b575f 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -6,30 +6,9 @@ import { ToastHideParams, ToastProps, ToastShowParams } from './types'; import { useToast } from './useToast'; const ToastRoot = React.forwardRef((props: ToastProps, ref) => { - const { - config, - type, - position, - visibilityTime, - topOffset, - bottomOffset, - keyboardOffset, - onShow, - onHide, - onPress - } = props; + const { config, ...defaultOptions } = props; const { show, hide, isVisible, options, data } = useToast({ - defaultOptions: { - type, - position, - visibilityTime, - topOffset, - bottomOffset, - keyboardOffset, - onShow, - onHide, - onPress - } + defaultOptions }); React.useImperativeHandle(ref, () => ({ @@ -56,33 +35,10 @@ type ToastRef = { const toastRef = React.createRef(); -export function Toast({ - config, - type, - position, - visibilityTime, - topOffset, - bottomOffset, - keyboardOffset, - onShow, - onHide, - onPress -}: ToastProps) { +export function Toast(props: ToastProps) { return ( - + ); } diff --git a/src/__helpers__/PanResponder.ts b/src/__helpers__/PanResponder.ts new file mode 100644 index 00000000..fcb6b68d --- /dev/null +++ b/src/__helpers__/PanResponder.ts @@ -0,0 +1,40 @@ +import { + GestureResponderHandlers, + PanResponder, + PanResponderCallbacks, + PanResponderGestureState +} from 'react-native'; + +export const mockGestureValues: PanResponderGestureState = { + moveY: 0, + moveX: 0, + y0: 0, + x0: 0, + dx: 0, + dy: 10, + stateID: 123, + vx: 0, + vy: 0, + numberActiveTouches: 1, + _accountsForMovesUpTo: 1 +}; + +export function mockPanResponder() { + jest + .spyOn(PanResponder, 'create') + .mockImplementation( + ({ + onMoveShouldSetPanResponder, + onMoveShouldSetPanResponderCapture, + onPanResponderMove, + onPanResponderRelease + }: PanResponderCallbacks) => ({ + panHandlers: { + onMoveShouldSetResponder: onMoveShouldSetPanResponder, + onMoveShouldSetResponderCapture: onMoveShouldSetPanResponderCapture, + onResponderMove: onPanResponderMove, + onResponderRelease: onPanResponderRelease + } as GestureResponderHandlers + }) + ); +} diff --git a/src/components/AnimatedContainer.tsx b/src/components/AnimatedContainer.tsx index 1c4679b7..bddb1b04 100644 --- a/src/components/AnimatedContainer.tsx +++ b/src/components/AnimatedContainer.tsx @@ -8,10 +8,12 @@ import { useViewDimensions } from '../hooks'; import { ReactChildren, ToastPosition } from '../types'; +import { noop } from '../utils/func'; import { bound } from '../utils/number'; +import { getTestId } from '../utils/test-id'; import { styles } from './AnimatedContainer.styles'; -type AnimatedContainerProps = { +export type AnimatedContainerProps = { children: ReactChildren; isVisible: boolean; position: ToastPosition; @@ -19,9 +21,10 @@ type AnimatedContainerProps = { bottomOffset: number; keyboardOffset: number; onHide: () => void; + onRestorePosition?: () => void; }; -function dampingFor( +export function dampingFor( gesture: PanResponderGestureState, position: ToastPosition ) { @@ -39,7 +42,7 @@ function dampingFor( } } -function animatedValueFor( +export function animatedValueFor( gesture: PanResponderGestureState, position: ToastPosition, damping: number @@ -64,7 +67,8 @@ export function AnimatedContainer({ topOffset, bottomOffset, keyboardOffset, - onHide + onHide, + onRestorePosition = noop }: AnimatedContainerProps) { const { log } = useLogger(); @@ -87,7 +91,8 @@ export function AnimatedContainer({ const onRestore = React.useCallback(() => { log('Swipe, restoring to original position'); animate(1); - }, [animate, log]); + onRestorePosition(); + }, [animate, log, onRestorePosition]); const computeNewAnimatedValueForGesture = React.useCallback( (gesture: PanResponderGestureState) => { @@ -111,6 +116,7 @@ export function AnimatedContainer({ return ( diff --git a/src/components/BaseToast.tsx b/src/components/BaseToast.tsx index b2a8a7cd..b9282230 100644 --- a/src/components/BaseToast.tsx +++ b/src/components/BaseToast.tsx @@ -2,13 +2,10 @@ import React from 'react'; import { Text, View } from 'react-native'; import { BaseToastProps } from '../types'; +import { getTestId } from '../utils/test-id'; import { styles } from './BaseToast.styles'; import { Touchable } from './Touchable'; -function getTestId(elementName: string) { - return `toast${elementName}`; -} - export function BaseToast({ text1, text2, diff --git a/src/components/__tests__/AnimatedContainer.test.tsx b/src/components/__tests__/AnimatedContainer.test.tsx new file mode 100644 index 00000000..d74ca048 --- /dev/null +++ b/src/components/__tests__/AnimatedContainer.test.tsx @@ -0,0 +1,184 @@ +/* eslint-env jest */ + +import { render, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { + Dimensions, + PanResponderGestureState, + View, + ViewStyle +} from 'react-native'; + +import { + mockGestureValues, + mockPanResponder +} from '../../__helpers__/PanResponder'; +import { ToastPosition } from '../../types'; +import { + AnimatedContainer, + AnimatedContainerProps, + animatedValueFor, + dampingFor +} from '../AnimatedContainer'; + +const setup = (props?: Omit, 'children'>) => { + const onHide = jest.fn(); + const defaultProps: Omit = { + isVisible: false, + position: 'top', + topOffset: 40, + bottomOffset: 40, + keyboardOffset: 10, + onHide + }; + + const utils = render( + + + + ); + return { + ...utils + }; +}; + +const defaultStyles: ViewStyle = { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + top: 0 +}; + +describe('test AnimatedContainer component', () => { + it('is hidden by default', () => { + const { queryByTestId } = setup(); + expect(queryByTestId('toastAnimatedContainer')).toHaveStyle({ + ...defaultStyles, + opacity: 0 + }); + expect(queryByTestId('childView')).not.toBe(null); + }); + + it('shows when isVisible is true', async () => { + const { queryByTestId } = setup({ + isVisible: true + }); + + await waitFor(() => + expect(queryByTestId('toastAnimatedContainer')).toHaveStyle({ + ...defaultStyles, + opacity: 1 + }) + ); + expect(queryByTestId('childView')).not.toBe(null); + }); + + it('restores toast position on pan (if gesture is higher than threshold)', async () => { + mockPanResponder(); + const onRestorePosition = jest.fn(); + const { queryByTestId } = setup({ + isVisible: true, + onRestorePosition + }); + await waitFor(() => + expect(queryByTestId('toastAnimatedContainer')).toHaveStyle({ + ...defaultStyles, + opacity: 1 + }) + ); + const panHandler = queryByTestId('toastAnimatedContainer'); + const gesture: PanResponderGestureState = { + ...mockGestureValues, + moveY: 100, + dy: 10 + }; + panHandler?.props.onResponderMove(undefined, gesture); + panHandler?.props.onResponderRelease(undefined, gesture); + expect(onRestorePosition).toHaveBeenCalled(); + }); + + it('dismisses toast on pan (if gesture is lower than threshold)', async () => { + mockPanResponder(); + const onHide = jest.fn(); + const { queryByTestId } = setup({ + isVisible: true, + onHide + }); + await waitFor(() => + expect(queryByTestId('toastAnimatedContainer')).toHaveStyle({ + ...defaultStyles, + opacity: 1 + }) + ); + const panHandler = queryByTestId('toastAnimatedContainer'); + const gesture: PanResponderGestureState = { + ...mockGestureValues, + moveY: -100, + dy: 50 + }; + panHandler?.props.onResponderMove(undefined, gesture); + panHandler?.props.onResponderRelease(undefined, gesture); + expect(onHide).toHaveBeenCalled(); + }); +}); + +jest.spyOn(Dimensions, 'get').mockImplementation(() => ({ + height: 600, + width: 200, + scale: 1, + fontScale: 1 +})); + +describe('test dampingFor function', () => { + const gesture: PanResponderGestureState = { + ...mockGestureValues, + moveY: 100 + }; + + it('returns damping for position: bottom', () => { + const position: ToastPosition = 'bottom'; + expect(dampingFor(gesture, position)).toBe(500); + }); + + it('returns damping for position: top', () => { + const position: ToastPosition = 'top'; + expect(dampingFor(gesture, position)).toBe(100); + }); + + it('throws if position is not implemented', () => { + try { + dampingFor(gesture, 'foo' as ToastPosition); + } catch (err) { + expect(err).toBeDefined(); + } + }); +}); + +describe('test animatedValueFor function', () => { + const gesture: PanResponderGestureState = { + ...mockGestureValues, + dy: 10 + }; + it('returns animated value for position: bottom', () => { + const position: ToastPosition = 'bottom'; + const damping = 100; + expect(animatedValueFor(gesture, position, damping)).toBe(0.9); + }); + + it('returns animated value for position: top', () => { + const position: ToastPosition = 'top'; + const damping = 100; + expect(animatedValueFor(gesture, position, damping)).toBe(1.1); + }); + + it('throws if position is not implemented', () => { + try { + const position = 'foo' as ToastPosition; + const damping = 100; + animatedValueFor(gesture, position, damping); + } catch (err) { + expect(err).toBeDefined(); + } + }); +}); diff --git a/src/components/__tests__/BaseToast.test.tsx b/src/components/__tests__/BaseToast.test.tsx new file mode 100644 index 00000000..9bd919cb --- /dev/null +++ b/src/components/__tests__/BaseToast.test.tsx @@ -0,0 +1,99 @@ +/* eslint-env jest */ + +import '@testing-library/jest-native'; + +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { Image } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; + +import { BaseToastProps } from '../../types'; +import { BaseToast } from '../BaseToast'; + +const setup = (props?: BaseToastProps) => { + const utils = render(); + return { + ...utils + }; +}; + +describe('test BaseToast component', () => { + it('renders defaults', () => { + const { queryByTestId } = setup(); + expect(queryByTestId('toastTouchableContainer')).not.toBe(null); + expect(queryByTestId('toastContentContainer')).not.toBe(null); + expect(queryByTestId('toastText1')).toBe(null); + expect(queryByTestId('toastText2')).toBe(null); + }); + + it('renders text1 and text2', () => { + const { queryByTestId, queryByText } = setup({ + text1: 'Hello', + text2: 'World' + }); + expect(queryByTestId('toastText1')).not.toBe(null); + expect(queryByTestId('toastText2')).not.toBe(null); + expect(queryByText('Hello')).not.toBe(null); + expect(queryByText('World')).not.toBe(null); + }); + + it('renders only text1', () => { + const { queryByTestId, queryByText } = setup({ + text1: 'Hello' + }); + expect(queryByTestId('toastText1')).not.toBe(null); + expect(queryByTestId('toastText2')).toBe(null); + expect(queryByText('Hello')).not.toBe(null); + }); + + it('renders only text2', () => { + const { queryByTestId, queryByText } = setup({ + text2: 'World' + }); + expect(queryByTestId('toastText1')).toBe(null); + expect(queryByTestId('toastText2')).not.toBe(null); + expect(queryByText('World')).not.toBe(null); + }); + + it('fires onPress', () => { + const onPress = jest.fn(); + const { queryByTestId } = setup({ + onPress + }); + const touchableContainer = queryByTestId('toastTouchableContainer'); + expect(touchableContainer).not.toBe(null); + fireEvent.press(touchableContainer as ReactTestInstance); + expect(onPress).toHaveBeenCalled(); + }); + + it('adds custom style to toastTouchableContainer', () => { + const { queryByTestId } = setup({ + style: { + borderRadius: 20 + } + }); + const touchableContainer = queryByTestId('toastTouchableContainer'); + expect(touchableContainer).not.toBe(null); + expect(touchableContainer).toHaveStyle({ + borderRadius: 20 + }); + }); + + it('renders leading icon', () => { + const { queryByTestId } = setup({ + renderLeadingIcon: () => ( + + ) + }); + expect(queryByTestId('leadingIcon')).not.toBe(null); + }); + + it('renders trailing icon', () => { + const { queryByTestId } = setup({ + renderTrailingIcon: () => ( + + ) + }); + expect(queryByTestId('trailingIcon')).not.toBe(null); + }); +}); diff --git a/src/components/__tests__/ErrorToast.test.tsx b/src/components/__tests__/ErrorToast.test.tsx new file mode 100644 index 00000000..cc939f4a --- /dev/null +++ b/src/components/__tests__/ErrorToast.test.tsx @@ -0,0 +1,25 @@ +/* eslint-env jest */ + +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { BaseToastProps } from '../../types'; +import { ErrorToast } from '../ErrorToast'; + +const setup = (props?: BaseToastProps) => { + const utils = render(); + return { + ...utils + }; +}; + +describe('test ErrorToast component', () => { + it('renders default style', () => { + const { queryByTestId } = setup(); + const touchableContainer = queryByTestId('toastTouchableContainer'); + expect(touchableContainer).not.toBe(null); + expect(touchableContainer).toHaveStyle({ + borderLeftColor: '#FE6301' + }); + }); +}); diff --git a/src/components/__tests__/InfoToast.test.tsx b/src/components/__tests__/InfoToast.test.tsx new file mode 100644 index 00000000..b89556a6 --- /dev/null +++ b/src/components/__tests__/InfoToast.test.tsx @@ -0,0 +1,25 @@ +/* eslint-env jest */ + +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { BaseToastProps } from '../../types'; +import { InfoToast } from '../InfoToast'; + +const setup = (props?: BaseToastProps) => { + const utils = render(); + return { + ...utils + }; +}; + +describe('test ErrorToast component', () => { + it('renders default style', () => { + const { queryByTestId } = setup(); + const touchableContainer = queryByTestId('toastTouchableContainer'); + expect(touchableContainer).not.toBe(null); + expect(touchableContainer).toHaveStyle({ + borderLeftColor: '#87CEFA' + }); + }); +}); diff --git a/src/components/__tests__/SuccessToast.test.tsx b/src/components/__tests__/SuccessToast.test.tsx new file mode 100644 index 00000000..991c6ce7 --- /dev/null +++ b/src/components/__tests__/SuccessToast.test.tsx @@ -0,0 +1,25 @@ +/* eslint-env jest */ + +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { BaseToastProps } from '../../types'; +import { SuccessToast } from '../SuccessToast'; + +const setup = (props?: BaseToastProps) => { + const utils = render(); + return { + ...utils + }; +}; + +describe('test SuccessToast component', () => { + it('renders default style', () => { + const { queryByTestId } = setup(); + const touchableContainer = queryByTestId('toastTouchableContainer'); + expect(touchableContainer).not.toBe(null); + expect(touchableContainer).toHaveStyle({ + borderLeftColor: '#69C779' + }); + }); +}); diff --git a/src/hooks/__tests__/useKeyboard.test.ts b/src/hooks/__tests__/useKeyboard.test.ts new file mode 100644 index 00000000..160a5b57 --- /dev/null +++ b/src/hooks/__tests__/useKeyboard.test.ts @@ -0,0 +1,69 @@ +/* eslint-env jest */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { Keyboard } from 'react-native'; + +import { isIOS } from '../../utils/platform'; +import { useKeyboard } from '../useKeyboard'; + +jest.mock('../../utils/platform'); + +const mockIsIOS = isIOS as jest.MockedFunction; + +const setup = (platform: 'ios' | 'android' = 'ios') => { + mockIsIOS.mockReturnValue(platform === 'ios'); + const utils = renderHook(useKeyboard); + return { + ...utils + }; +}; + +describe('test useKeyboard hook', () => { + it('returns defaults', () => { + const { result } = setup(); + expect(result.current.isKeyboardVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + }); + + it('updates keyboard height on show', () => { + const { result } = setup(); + expect(result.current.isKeyboardVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + act(() => { + Keyboard.emit('keyboardDidShow', { + endCoordinates: { + height: 425 + } + }); + }); + expect(result.current.isKeyboardVisible).toBe(true); + expect(result.current.keyboardHeight).toBe(425); + }); + + it('updates keyboard height on hide', () => { + const { result } = setup(); + expect(result.current.isKeyboardVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + act(() => { + Keyboard.emit('keyboardDidShow', { + endCoordinates: { + height: 425 + } + }); + }); + expect(result.current.isKeyboardVisible).toBe(true); + expect(result.current.keyboardHeight).toBe(425); + act(() => { + Keyboard.emit('keyboardDidHide'); + }); + expect(result.current.isKeyboardVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + }); + + it('does nothing on Android', () => { + mockIsIOS.mockReturnValue(false); + const { result } = setup('android'); + expect(result.current.isKeyboardVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + }); +}); diff --git a/src/hooks/__tests__/usePanResponder.test.ts b/src/hooks/__tests__/usePanResponder.test.ts new file mode 100644 index 00000000..d44c9bd7 --- /dev/null +++ b/src/hooks/__tests__/usePanResponder.test.ts @@ -0,0 +1,138 @@ +/* eslint-env jest */ + +import { renderHook } from '@testing-library/react-hooks'; +import { Animated, GestureResponderEvent } from 'react-native'; + +import { mockGestureValues } from '../../__helpers__/PanResponder'; +import { usePanResponder } from '../usePanResponder'; +import { shouldSetPanResponder } from '..'; + +const setup = ({ newAnimatedValueForGesture = 0 } = {}) => { + const animatedValue = { + current: new Animated.Value(0) + }; + const computeNewAnimatedValueForGesture = jest.fn( + () => newAnimatedValueForGesture + ); + const onDismiss = jest.fn(); + const onRestore = jest.fn(); + + const utils = renderHook(() => + usePanResponder({ + animatedValue, + computeNewAnimatedValueForGesture, + onDismiss, + onRestore + }) + ); + return { + animatedValue, + computeNewAnimatedValueForGesture, + onDismiss, + onRestore, + ...utils + }; +}; + +describe('test usePanResponder hook', () => { + it('returns defaults', () => { + const { result } = setup(); + expect(result.current.panResponder.panHandlers).toBeDefined(); + expect(result.current.onMove).toBeDefined(); + expect(result.current.onRelease).toBeDefined(); + }); + + it('computes new animated value on move gesture', () => { + const { result, computeNewAnimatedValueForGesture } = setup({ + newAnimatedValueForGesture: 1 + }); + result.current.onMove({} as GestureResponderEvent, mockGestureValues); + expect(computeNewAnimatedValueForGesture).toBeCalledWith(mockGestureValues); + }); + + it('computes new animated value on release gesture', () => { + const { result, computeNewAnimatedValueForGesture } = setup({ + newAnimatedValueForGesture: 1 + }); + result.current.onRelease({} as GestureResponderEvent, mockGestureValues); + expect(computeNewAnimatedValueForGesture).toBeCalledWith(mockGestureValues); + }); + + it('calls onDismiss when swipe gesture value is below dismiss threshold', () => { + const { result, computeNewAnimatedValueForGesture, onDismiss } = setup({ + newAnimatedValueForGesture: 0.65 + }); + result.current.onRelease({} as GestureResponderEvent, mockGestureValues); + expect(computeNewAnimatedValueForGesture).toBeCalledWith(mockGestureValues); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('calls onDismiss when swipe gesture vy (velocity) is over dismiss threshold', () => { + const { result, computeNewAnimatedValueForGesture, onDismiss } = setup({ + newAnimatedValueForGesture: 1 + }); + const gesture = { + ...mockGestureValues, + vy: -0.8, + dy: -1 + }; + result.current.onRelease({} as GestureResponderEvent, gesture); + expect(computeNewAnimatedValueForGesture).toBeCalledWith(gesture); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('calls onRestore when swipe gesture value is not below dismiss threshold', () => { + const { result, computeNewAnimatedValueForGesture, onRestore } = setup({ + newAnimatedValueForGesture: 0.66 + }); + result.current.onRelease({} as GestureResponderEvent, mockGestureValues); + expect(computeNewAnimatedValueForGesture).toBeCalledWith(mockGestureValues); + expect(onRestore).toHaveBeenCalled(); + }); +}); + +describe('test shouldSetPanResponder function', () => { + it('is set when dx > offset', () => { + const gesture = { + ...mockGestureValues, + dx: 2.1, + dy: 0 + }; + expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + true + ); + }); + + it('is set when dy > offset', () => { + const gesture = { + ...mockGestureValues, + dx: 0, + dy: 2.1 + }; + expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + true + ); + }); + + it('is not set when dx <= offset', () => { + const gesture = { + ...mockGestureValues, + dx: 2, + dy: 0 + }; + expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + false + ); + }); + + it('is not set when dy <= offset', () => { + const gesture = { + ...mockGestureValues, + dx: 0, + dy: 2 + }; + expect(shouldSetPanResponder({} as GestureResponderEvent, gesture)).toBe( + false + ); + }); +}); diff --git a/src/hooks/__tests__/useSlideAnimation.test.ts b/src/hooks/__tests__/useSlideAnimation.test.ts new file mode 100644 index 00000000..b24aef10 --- /dev/null +++ b/src/hooks/__tests__/useSlideAnimation.test.ts @@ -0,0 +1,86 @@ +/* eslint-env jest */ + +import { renderHook } from '@testing-library/react-hooks'; +import { Animated } from 'react-native'; + +import { + translateYOutputRangeFor, + useSlideAnimation +} from '../useSlideAnimation'; + +const defaultOffsets = { + topOffset: 40, + bottomOffset: 60, + keyboardOffset: 5 +}; + +const setup = () => { + const utils = renderHook(() => + useSlideAnimation({ + position: 'top', + height: 20, + ...defaultOffsets + }) + ); + return { + ...utils + }; +}; + +describe('test useSlideAnimation hook', () => { + it('returns defaults', () => { + const { result } = setup(); + const { animatedValue, animate, animationStyles } = result.current; + + expect(animatedValue.current).toBeDefined(); + expect(animate).toBeDefined(); + expect(animationStyles.opacity).toBeDefined(); + expect(animationStyles.transform).toBeDefined(); + }); + + it('animates to a new value', async () => { + const spy = jest.spyOn(Animated, 'spring').mockImplementation(() => ({ + start: jest.fn(), + stop: jest.fn(), + reset: jest.fn() + })); + const { result } = setup(); + result.current.animate(1); + expect(spy).toHaveBeenCalled(); + }); +}); + +describe('test translateYOutputRangeFor function', () => { + it('returns output range for position: top', () => { + expect( + translateYOutputRangeFor({ + position: 'top', + height: 20, + keyboardHeight: 0, + ...defaultOffsets + }) + ).toEqual([-40, 40]); + }); + + it('returns output range for position: bottom', () => { + expect( + translateYOutputRangeFor({ + position: 'bottom', + height: 20, + keyboardHeight: 0, + ...defaultOffsets + }) + ).toEqual([40, -60]); + }); + + it('returns output range for position: bottom, with keyboard offset', () => { + expect( + translateYOutputRangeFor({ + position: 'bottom', + height: 20, + keyboardHeight: 400, + ...defaultOffsets + }) + ).toEqual([40, -405]); + }); +}); diff --git a/src/hooks/__tests__/useTimeout.test.ts b/src/hooks/__tests__/useTimeout.test.ts new file mode 100644 index 00000000..d5e6db43 --- /dev/null +++ b/src/hooks/__tests__/useTimeout.test.ts @@ -0,0 +1,68 @@ +/* eslint-env jest */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useTimeout } from '../useTimeout'; + +const setup = () => { + const cb = jest.fn(); + const utils = renderHook(() => useTimeout(cb)); + return { + ...utils, + cb + }; +}; + +describe('test useTimeout hook', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('sets timeout', () => { + const { cb, result } = setup(); + + act(() => { + result.current.startTimer(); + }); + expect(cb).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); + expect(cb).toHaveBeenCalled(); + }); + + it('clears timeout before running', () => { + const { cb, result } = setup(); + + act(() => { + result.current.startTimer(); + }); + expect(cb).not.toHaveBeenCalled(); + + act(() => { + result.current.clearTimer(); + }); + expect(cb).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('clears timeout when unmounting', () => { + const { cb, result, unmount } = setup(); + + act(() => { + result.current.startTimer(); + }); + expect(cb).not.toHaveBeenCalled(); + + act(() => { + unmount(); + jest.runAllTimers(); + }); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useViewDimensions.test.ts b/src/hooks/__tests__/useViewDimensions.test.ts new file mode 100644 index 00000000..b08ba3af --- /dev/null +++ b/src/hooks/__tests__/useViewDimensions.test.ts @@ -0,0 +1,65 @@ +/* eslint-env jest */ + +import { renderHook } from '@testing-library/react-hooks'; +import { LayoutChangeEvent } from 'react-native'; +import { act } from 'react-test-renderer'; + +import { useViewDimensions } from '../useViewDimensions'; +import { UseViewDimensionsParams } from '..'; + +const setup = (offsets: UseViewDimensionsParams) => { + const layoutChangeEventMock = { + nativeEvent: { + layout: { + height: 250, + width: 320 + } + } + } as LayoutChangeEvent; + const utils = renderHook(() => useViewDimensions(offsets)); + return { + ...utils, + layoutChangeEventMock + }; +}; + +describe('test useViewDimensions hook', () => { + it('computes dimensions correctly', () => { + const { result, layoutChangeEventMock } = setup(); + const { computeViewDimensions } = result.current; + + act(() => { + computeViewDimensions(layoutChangeEventMock); + }); + + expect(result.current.height).toBe(250); + expect(result.current.width).toBe(320); + }); + + it('computes dimensions with offsets correctly', () => { + const offsets = { + heightOffset: -40, + widthOffset: 120 + }; + const { result, layoutChangeEventMock } = setup(offsets); + const { computeViewDimensions } = result.current; + + act(() => { + computeViewDimensions(layoutChangeEventMock); + }); + + expect(result.current.height).toBe(250 + offsets.heightOffset); + expect(result.current.width).toBe(320 + offsets.widthOffset); + }); + + it('falls back to 0 when View dimensions are missing', () => { + const { result } = setup(); + + act(() => { + result.current.computeViewDimensions({} as LayoutChangeEvent); + }); + + expect(result.current.height).toBe(0); + expect(result.current.width).toBe(0); + }); +}); diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts index f8e68d16..0a9e9451 100644 --- a/src/hooks/useKeyboard.ts +++ b/src/hooks/useKeyboard.ts @@ -19,7 +19,7 @@ export function useKeyboard() { }, []); React.useEffect(() => { - if (!isIOS) { + if (!isIOS()) { return () => {}; } const didShowListener = Keyboard.addListener('keyboardDidShow', onShow); diff --git a/src/hooks/usePanResponder.ts b/src/hooks/usePanResponder.ts index 96046c04..df93e984 100644 --- a/src/hooks/usePanResponder.ts +++ b/src/hooks/usePanResponder.ts @@ -6,7 +6,7 @@ import { PanResponderGestureState } from 'react-native'; -function shouldSetPanResponder( +export function shouldSetPanResponder( _event: GestureResponderEvent, gesture: PanResponderGestureState ) { @@ -17,7 +17,19 @@ function shouldSetPanResponder( return Math.abs(dx) > offset || Math.abs(dy) > offset; } -type UsePanResponderParams = { +export function shouldDismissView( + newAnimatedValue: number, + gesture: PanResponderGestureState +) { + const dismissThreshold = 0.65; + const { vy, dy } = gesture; + return ( + newAnimatedValue <= dismissThreshold || + (Math.abs(vy) >= dismissThreshold && dy < 0) + ); +} + +export type UsePanResponderParams = { animatedValue: RefObject; computeNewAnimatedValueForGesture: ( gesture: PanResponderGestureState @@ -42,14 +54,8 @@ export function usePanResponder({ const onRelease = React.useCallback( (_event: GestureResponderEvent, gesture: PanResponderGestureState) => { - const { dy, vy } = gesture; const newAnimatedValue = computeNewAnimatedValueForGesture(gesture); - - const dismissThreshold = 0.65; - if ( - newAnimatedValue <= dismissThreshold || - (Math.abs(vy) >= dismissThreshold && dy < 0) - ) { + if (shouldDismissView(newAnimatedValue, gesture)) { onDismiss(); } else { onRestore(); @@ -70,6 +76,8 @@ export function usePanResponder({ ); return { - panResponder + panResponder, + onMove, + onRelease }; } diff --git a/src/hooks/useSlideAnimation.ts b/src/hooks/useSlideAnimation.ts index 212fd280..79139412 100644 --- a/src/hooks/useSlideAnimation.ts +++ b/src/hooks/useSlideAnimation.ts @@ -13,7 +13,7 @@ type UseSlideAnimationParams = { keyboardOffset: number; }; -function translateYOutputRangeFor({ +export function translateYOutputRangeFor({ position, height, topOffset, @@ -63,6 +63,7 @@ export function useSlideAnimation({ keyboardOffset }) }); + const opacity = animatedValue.current.interpolate({ inputRange: [0, 0.7, 1], outputRange: [0, 1, 1] diff --git a/src/hooks/useViewDimensions.ts b/src/hooks/useViewDimensions.ts index b6284b1a..e58870e4 100644 --- a/src/hooks/useViewDimensions.ts +++ b/src/hooks/useViewDimensions.ts @@ -1,6 +1,11 @@ import React from 'react'; import { LayoutChangeEvent } from 'react-native'; +export type UseViewDimensionsParams = { + heightOffset?: number; + widthOffset?: number; +}; + const getLayoutValue = (key: 'height' | 'width') => (event: LayoutChangeEvent) => event?.nativeEvent?.layout?.[key] ?? 0; @@ -8,7 +13,10 @@ const getLayoutValue = /** * Retrieves View dimensions (height, width) from a LayoutChangeEvent and sets them on state */ -export function useViewDimensions({ heightOffset = 0, widthOffset = 0 } = {}) { +export function useViewDimensions({ + heightOffset = 0, + widthOffset = 0 +}: UseViewDimensionsParams = {}) { const [height, setHeight] = React.useState(0); const [width, setWidth] = React.useState(0); diff --git a/src/utils/platform.ts b/src/utils/platform.ts index 5a4f311e..a6dcd186 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -1,3 +1,5 @@ import { Platform } from 'react-native'; -export const isIOS = Platform.OS === 'ios'; +export function isIOS() { + return Platform.OS === 'ios'; +} diff --git a/src/utils/test-id.ts b/src/utils/test-id.ts new file mode 100644 index 00000000..28e67f2e --- /dev/null +++ b/src/utils/test-id.ts @@ -0,0 +1,3 @@ +export function getTestId(elementName: string) { + return `toast${elementName}`; +} diff --git a/tsconfig.json b/tsconfig.json index f5edf12c..2fbd9872 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,9 +60,12 @@ }, "include": ["src", "index.ts"], "exclude": [ + "**/__tests__/*", + "**/__helpers__/*", "node_modules", "babel.config.js", "jest.config.js", + "jest.setup.js", ".eslintrc.js" ] } diff --git a/yarn.lock b/yarn.lock index 9de90748..a078a31d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49,7 +49,7 @@ semver "^6.3.0" source-map "^0.5.0" -"@babel/core@^7.2.2": +"@babel/core@^7.15.8", "@babel/core@^7.2.2": version "7.15.8" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.8.tgz#195b9f2bffe995d2c6c159e72fe525b4114e8c10" integrity sha512-3UG9dsxvYBMYwRv+gS41WKHno4K60/9GPy1CJaH6xy3Elq8CTtvtjT5R5jmNhXfCYLX2mTw+7/aq5ak/gOE0og== @@ -721,7 +721,7 @@ core-js-pure "^3.16.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4": version "7.15.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw== @@ -977,17 +977,6 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^26.6.2": - version "26.6.2" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" - integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^15.0.0" - chalk "^4.0.0" - "@jest/types@^27.1.1": version "27.1.1" resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.1.1.tgz#77a3fc014f906c65752d12123a0134359707c0ad" @@ -1046,12 +1035,23 @@ ramda "^0.26.1" redent "^2.0.0" -"@testing-library/react-native@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-7.2.0.tgz#e5ec5b0974e4e5f525f8057563417d1e9f820d96" - integrity sha512-rDKzJjAAeGgyoJT0gFQiMsIL09chdWcwZyYx6WZHMgm2c5NDqY52hUuyTkzhqddMYWmSRklFphSg7B2HX+246Q== +"@testing-library/react-hooks@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" + integrity sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg== dependencies: - pretty-format "^26.0.1" + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + react-error-boundary "^3.1.0" + +"@testing-library/react-native@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-8.0.0.tgz#f1b8f6bcc9f0ef6026b0ec7d072faf4af0e622fa" + integrity sha512-XwQIv4Amj8AYsPjASo+1XLFWY7qMm+FyV4+QU5j97CpRd+YasCBNnvfyDAZoudm/5Y0Yx55DYjAX36RugxasPQ== + dependencies: + pretty-format "^27.0.0" "@tootallnate/once@1": version "1.1.2" @@ -1158,6 +1158,39 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.3.2.tgz#fc8c2825e4ed2142473b4a81064e6e081463d1b3" integrity sha512-eI5Yrz3Qv4KPUa/nSIAi0h+qX0XyewOliug5F2QAtuRg6Kjg6jfmxe1GIwoIRhZspD1A0RP8ANrPwvEXXtRFog== +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + +"@types/react-dom@>=16.9.0": + version "17.0.10" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.10.tgz#d6972ec018d23cf22b99597f1289343d99ea9d9d" + integrity sha512-8oz3NAUId2z/zQdFI09IMhQPNgIbiP8Lslhv39DIDamr846/0spjZK0vnrMak0iB8EKb9QFTTIdg2Wj2zH5a3g== + dependencies: + "@types/react" "*" + +"@types/react-test-renderer@>=16.9.0": + version "17.0.1" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b" + integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@>=16.9.0": + version "17.0.31" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.31.tgz#fe05ebf91ff3ae35bb6b13f6c1b461db8089dff8" + integrity sha512-MQSR5EL4JZtdWRvqDgz9kXhSDDoy2zMTYyg7UhP+FZ5ttUOocWyxiqFJiI57sUG0BtaEX7WDXYQlkCYkb3X9vQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" @@ -1175,13 +1208,6 @@ dependencies: "@types/yargs-parser" "*" -"@types/yargs@^15.0.0": - version "15.0.14" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.14.tgz#26d821ddb89e70492160b66d10a0eb6df8f6fb06" - integrity sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ== - dependencies: - "@types/yargs-parser" "*" - "@types/yargs@^16.0.0": version "16.0.4" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" @@ -1836,6 +1862,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b" + integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw== + damerau-levenshtein@^1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.7.tgz#64368003512a1a6992593741a09a9d31a836f55d" @@ -4112,16 +4143,6 @@ pretty-format@^24.0.0, pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^26.0.1: - version "26.6.2" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" - integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== - dependencies: - "@jest/types" "^26.6.2" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^17.0.1" - pretty-format@^27.0.0, pretty-format@^27.1.1: version "27.1.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.1.1.tgz#cbaf9ec6cd7cfc3141478b6f6293c0ccdbe968e0" @@ -4174,6 +4195,13 @@ ramda@^0.26.1: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +react-error-boundary@^3.1.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b" + integrity sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA== + dependencies: + "@babel/runtime" "^7.12.5" + "react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"