From 3beab0eb5ea837cfd58c1e66313c75fe16811022 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 11:22:39 -0300 Subject: [PATCH 01/13] feat: creating the get-statusbar-height --- src/utils/index.ts | 1 + .../status-bar-height/get-statusbar-height.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/utils/status-bar-height/get-statusbar-height.ts diff --git a/src/utils/index.ts b/src/utils/index.ts index 681be4f0..a548544a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export { storage } from './storage/storage'; export { renderSVGIconConditionally } from './render-svg-icon-conditionally/render-svg-icon-conditionally'; export { isEqualsOrLargerThanIphoneX } from './is-equals-or-larger-than-iphonex/is-equals-or-larger-than-iphonex'; +export { getStatusBarHeight } from './status-bar-height/get-statusbar-height'; diff --git a/src/utils/status-bar-height/get-statusbar-height.ts b/src/utils/status-bar-height/get-statusbar-height.ts new file mode 100644 index 00000000..28d76d00 --- /dev/null +++ b/src/utils/status-bar-height/get-statusbar-height.ts @@ -0,0 +1,19 @@ +import { Platform, StatusBar } from 'react-native'; + +import { isEqualsOrLargerThanIphoneX } from '../is-equals-or-larger-than-iphonex/is-equals-or-larger-than-iphonex'; + +export const IOS_IPHONE_X_AND_ABOVE = 44; +export const IOS_BELOW_IPHONE_X = 20; + +export const getStatusBarHeight = () => { + if (isEqualsOrLargerThanIphoneX()) { + return IOS_IPHONE_X_AND_ABOVE; + } + if (Platform.OS === 'ios') { + return IOS_BELOW_IPHONE_X; + } + if (Platform.OS === 'android') { + return StatusBar.currentHeight || 0; + } + return 0; +}; From 1affc6a386a69c267010741ef6af9e2c6fb6f2c8 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 11:25:17 -0300 Subject: [PATCH 02/13] test: creating the get-statusbar-height --- .../get-statusbar-height-android.test.ts | 56 +++++++++++++++++++ .../get-statusbar-height-ios.test.ts | 36 ++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/utils/status-bar-height/get-statusbar-height-android.test.ts create mode 100644 src/utils/status-bar-height/get-statusbar-height-ios.test.ts diff --git a/src/utils/status-bar-height/get-statusbar-height-android.test.ts b/src/utils/status-bar-height/get-statusbar-height-android.test.ts new file mode 100644 index 00000000..6e8b678a --- /dev/null +++ b/src/utils/status-bar-height/get-statusbar-height-android.test.ts @@ -0,0 +1,56 @@ +import { + getStatusBarHeight, + IOS_IPHONE_X_AND_ABOVE, + IOS_BELOW_IPHONE_X, +} from './get-statusbar-height'; + +describe('Utils/status-bar-height # Android', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + describe('And "currentHeight" is "defined"', () => { + it('should return correctly', () => { + jest.mock( + '../is-equals-or-larger-than-iphonex/is-equals-or-larger-than-iphonex', + () => ({ + isEqualsOrLargerThanIphoneX: jest.fn().mockReturnValue(false), + }), + ); + jest.mock('react-native/Libraries/Utilities/Platform', () => ({ + OS: 'android', + })); + jest.mock( + 'react-native/Libraries/Components/StatusBar/StatusBar', + () => ({ + currentHeight: 10, + }), + ); + const statusBarHeight = getStatusBarHeight(); + expect(statusBarHeight).toEqual(10); + }); + }); + + describe('And "currentHeight" is "undefined"', () => { + it('should return correctly', () => { + jest.mock( + '../is-equals-or-larger-than-iphonex/is-equals-or-larger-than-iphonex', + () => ({ + isEqualsOrLargerThanIphoneX: jest.fn().mockReturnValue(false), + }), + ); + jest.mock('react-native/Libraries/Utilities/Platform', () => ({ + OS: 'android', + })); + jest.mock( + 'react-native/Libraries/Components/StatusBar/StatusBar', + () => ({ + currentHeight: undefined, + }), + ); + const statusBarHeight = getStatusBarHeight(); + expect(statusBarHeight).toEqual(0); + }); + }); +}); diff --git a/src/utils/status-bar-height/get-statusbar-height-ios.test.ts b/src/utils/status-bar-height/get-statusbar-height-ios.test.ts new file mode 100644 index 00000000..26c49a70 --- /dev/null +++ b/src/utils/status-bar-height/get-statusbar-height-ios.test.ts @@ -0,0 +1,36 @@ +import { + getStatusBarHeight, + IOS_IPHONE_X_AND_ABOVE, + IOS_BELOW_IPHONE_X, +} from './get-statusbar-height'; + +const mockIsEqualsOrLargerThanIphoneX = jest.fn(); + +jest.mock( + '../is-equals-or-larger-than-iphonex/is-equals-or-larger-than-iphonex', + () => ({ + isEqualsOrLargerThanIphoneX: () => mockIsEqualsOrLargerThanIphoneX(), + }), +); + +describe('Utils/status-bar-height # iOS', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('should return correctly when the "device has iPhoneX dimensions or above"', () => { + mockIsEqualsOrLargerThanIphoneX.mockReturnValue(true); + const statusBarHeight = getStatusBarHeight(); + expect(statusBarHeight).toEqual(IOS_IPHONE_X_AND_ABOVE); + }); + + it('should return correctly hen the "device is below iPhoneX dimensions"', () => { + mockIsEqualsOrLargerThanIphoneX.mockReturnValue(false); + jest.mock('react-native/Libraries/Utilities/Platform', () => ({ + OS: 'ios', + })); + const statusBarHeight = getStatusBarHeight(); + expect(statusBarHeight).toEqual(IOS_BELOW_IPHONE_X); + }); +}); From cad2bedafd9e9923e52b3ee2866ed53795fde87b Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 11:27:43 -0300 Subject: [PATCH 03/13] feat: creating the SearchBar --- .../search-bar/SearchBar.styles.tsx | 36 +++++++++++++++ .../components/search-bar/SearchBar.tsx | 46 +++++++++++++++++++ .../components/search-bar/use-search-bar.ts | 16 +++++++ src/styles/styled.d.ts | 1 + src/styles/themes/dark.ts | 1 + src/styles/themes/light.ts | 1 + 6 files changed, 101 insertions(+) create mode 100644 src/components/stacks/common-screens/search/components/search-bar/SearchBar.styles.tsx create mode 100644 src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx create mode 100644 src/components/stacks/common-screens/search/components/search-bar/use-search-bar.ts diff --git a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.styles.tsx b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.styles.tsx new file mode 100644 index 00000000..96a14be1 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.styles.tsx @@ -0,0 +1,36 @@ +import styled from 'styled-components/native'; + +import { getStatusBarHeight } from '@utils'; + +import { dark } from '@styles/themes'; + +const HEADER_HEIGHT = 44; + +export const Wrapper = styled.View` + width: 100%; + height: ${() => getStatusBarHeight() + HEADER_HEIGHT}px; + justify-content: flex-end; + padding-bottom: ${({ theme }) => theme.metrics.md}px; + background-color: ${({ theme }) => theme.colors.contrast}; +`; + +export const ContentWrapper = styled.View` + flex-direction: row; +`; + +export const Input = styled.TextInput.attrs(({ placeholder, theme }) => ({ + placeholderTextColor: dark.colors.subText, + selectionColor: theme.colors.primary, + underlineColorAndroid: 'transparent', + returnKeyLabel: 'search', + returnKeyType: 'search', + numberOfLines: 1, + autoFocus: true, + placeholder, +}))` + width: 85%; + margin-left: ${({ theme }) => theme.metrics.sm}px; + font-family: CircularStd-Book; + font-size: ${({ theme }) => theme.metrics.xl}px; + color: white; +`; diff --git a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx new file mode 100644 index 00000000..fa79f786 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { StatusBar } from 'react-native'; + +import { HeaderIconButton } from '@common-components'; +import { dark as theme } from '@styles/themes'; + +import { useSearchBar } from './use-search-bar'; +import * as Styles from './SearchBar.styles'; + +export type SearchBarProps = { + onTypeSearchQuery: (query: string) => void; + onPressSearch: () => void; + onPressClose: () => void; + placeholder: string; +}; + +export const SearchBar = (props: SearchBarProps) => { + const searchBar = useSearchBar(); + + return ( + <> + + + + + + + + + ); +}; diff --git a/src/components/stacks/common-screens/search/components/search-bar/use-search-bar.ts b/src/components/stacks/common-screens/search/components/search-bar/use-search-bar.ts new file mode 100644 index 00000000..0bf646a1 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/search-bar/use-search-bar.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react'; +import { TextInput } from 'react-native'; + +export const useSearchBar = () => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef && inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return { + inputRef, + }; +}; diff --git a/src/styles/styled.d.ts b/src/styles/styled.d.ts index 596e8ea7..593d52c8 100644 --- a/src/styles/styled.d.ts +++ b/src/styles/styled.d.ts @@ -26,6 +26,7 @@ declare module 'styled-components/native' { inactiveWhite: string; darkLayer: string; inputBackground: string; + searchBar: string; red: string; green: string; white: string; diff --git a/src/styles/themes/dark.ts b/src/styles/themes/dark.ts index 5ce19809..de3e588b 100644 --- a/src/styles/themes/dark.ts +++ b/src/styles/themes/dark.ts @@ -29,6 +29,7 @@ export const dark: DefaultTheme = { fallbackImageIcon: '#4d4d4d', buttonText: '#262626', inputBackground: '#4d4d4d', + searchBar: '#4d4d4d', red: '#D5233B', green: '#32BE70', white: '#FFFFFF', diff --git a/src/styles/themes/light.ts b/src/styles/themes/light.ts index 4d34ef40..b9d70c7d 100644 --- a/src/styles/themes/light.ts +++ b/src/styles/themes/light.ts @@ -27,6 +27,7 @@ export const light: DefaultTheme = { popup: 'rgba(0, 0, 0, 0.9)', fallbackImageBackground: '#cfcfcf', fallbackImageIcon: '#4d4d4d', + searchBar: '#4d4d4d', buttonText: '#262626', inputBackground: '#CCCCCC', red: '#D5233B', From 46db660db6b5003f9999c01c0eaf5739334f7dae Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 11:29:33 -0300 Subject: [PATCH 04/13] test: creating the SearchBar --- .../components/search-bar/SearchBar.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx diff --git a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx new file mode 100644 index 00000000..1f1307ca --- /dev/null +++ b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { ThemeProvider } from 'styled-components/native'; + +import { dark as theme } from '@styles/themes'; + +import { SearchBar } from './SearchBar'; +import { RenderAPI, fireEvent, render } from '@testing-library/react-native'; + +const PLACEHOLDER = 'PLACEHOLDER'; + +type RenderSearchBarParams = { + onTypeSearchQuery?: jest.Mock; + onPressSearch?: jest.Mock; + onPressClose?: jest.Mock; +}; + +const renderSearchBar = (params: RenderSearchBarParams) => ( + + + +); + +describe('Common-screens/Search/SearchBar', () => { + const elements = { + closeButton: (api: RenderAPI) => + api.getByTestId('header-icon-button-wrapper-close'), + input: (api: RenderAPI) => api.getByTestId('search-input'), + }; + + describe('Rendering', () => { + it('should show the "placeholder" correctly', () => { + const component = render(renderSearchBar({})); + expect(elements.input(component).props.placeholder).toEqual(PLACEHOLDER); + }); + }); + + describe('Pressing', () => { + it('should call "onPressClose" when "close the search"', () => { + const onPressClose = jest.fn(); + const component = render( + renderSearchBar({ + onPressClose, + }), + ); + expect(onPressClose).toBeCalledTimes(0); + fireEvent.press(elements.closeButton(component)); + expect(onPressClose).toBeCalledTimes(1); + }); + }); + + describe('Inputing', () => { + it('should call "onTypeSearchQuery" correctly when change the "input-content"', () => { + const onTypeSearchQuery = jest.fn(); + const content = 'SOME_CONTENT_TYPED'; + const component = render( + renderSearchBar({ + onTypeSearchQuery, + }), + ); + expect(onTypeSearchQuery).toBeCalledTimes(0); + fireEvent(elements.input(component), 'onChangeText', content); + expect(onTypeSearchQuery).toBeCalledTimes(1); + expect(onTypeSearchQuery).toBeCalledWith(content); + }); + + it('should call "onSubmitEditing" correctly when press the "keyboard-search-cta"', () => { + const onPressSearch = jest.fn(); + const component = render( + renderSearchBar({ + onPressSearch, + }), + ); + expect(onPressSearch).toBeCalledTimes(0); + fireEvent(elements.input(component), 'onSubmitEditing'); + expect(onPressSearch).toBeCalledTimes(1); + }); + }); +}); From f595edd087eb2adfb9cae9884e8a2c0edffc8bec Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 13:47:06 -0300 Subject: [PATCH 05/13] feat: creating the DefaultTMDBListItem --- __mocks__/trending-famous.ts | 2 +- .../DefaultTMDBListItem.styles.ts | 53 +++++++++++++++++++ .../DefaultTMDBListItem.tsx | 31 +++++++++++ .../DefaultTMDBListItemLoadingPlaceholder.tsx | 24 +++++++++ src/components/common/index.ts | 2 + .../trending-famous/TrendingFamous.styles.ts | 18 ++++++- .../trending-famous/TrendingFamous.tsx | 22 ++++++-- .../LoadingTrendingFamous.styles.ts | 22 -------- .../LoadingTrendingFamous.tsx | 19 ------- .../TrendingFamousListItem.styles.ts | 34 ------------ .../TrendingFamousListItem.tsx | 31 ----------- 11 files changed, 146 insertions(+), 112 deletions(-) create mode 100644 src/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles.ts create mode 100644 src/components/common/default-tmdb-list-item/DefaultTMDBListItem.tsx create mode 100644 src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx delete mode 100644 src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.styles.ts delete mode 100644 src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.tsx delete mode 100644 src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.styles.ts delete mode 100644 src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.tsx diff --git a/__mocks__/trending-famous.ts b/__mocks__/trending-famous.ts index 18c2060a..77cb8bc0 100644 --- a/__mocks__/trending-famous.ts +++ b/__mocks__/trending-famous.ts @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql'; import { QUERY_TRENDING_FAMOUS } from '@/components/stacks/famous/screens/trending-famous/use-trending-famous'; -import { DEFAULT_HEIGHT } from '@/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.styles'; +import { DEFAULT_HEIGHT } from '@/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles'; import { QueryTrendingFamous_trendingFamous_items } from '@/types/schema'; import metrics from '@styles/metrics'; diff --git a/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles.ts b/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles.ts new file mode 100644 index 00000000..9880a6b0 --- /dev/null +++ b/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles.ts @@ -0,0 +1,53 @@ +import { StyleSheet } from 'react-native'; +import styled from 'styled-components/native'; + +import { Typography } from '@/components/common'; +import { borderRadius } from '@/styles/border-radius'; +import metrics from '@styles/metrics'; + +export const DEFAULT_ICON_SIZE = metrics.xl * 3; +export const DEFAULT_WIDTH = metrics.getWidthFromDP('30'); +export const DEFAULT_HEIGHT = metrics.getWidthFromDP('50'); +export const DEFAULT_BORDER_RADIUS = metrics.xs; +export const DEFAULT_MARGIN_BOTTOM = metrics.xl; +export const DEFAULT_MARGIN_LEFT = metrics.getWidthFromDP('2.5'); +const IMAGE_WIDTH = metrics.getWidthFromDP('30'); +const IMAGE_HEIGHT = metrics.getWidthFromDP('40'); +const TEXT_MARGIN_TOP = metrics.sm; + +export const FamousName = styled(Typography.ExtraSmallText).attrs({ + numberOfLines: 2, + bold: true, +})` + margin-top: ${TEXT_MARGIN_TOP}px; +`; + +export const Wrapper = styled.TouchableOpacity` + width: ${DEFAULT_WIDTH}px; + height: ${DEFAULT_HEIGHT}px; + margin-bottom: ${DEFAULT_MARGIN_BOTTOM}px; + margin-left: ${DEFAULT_MARGIN_LEFT}px; +`; + +export const sheet = StyleSheet.create({ + image: { + width: IMAGE_WIDTH, + height: IMAGE_HEIGHT, + borderRadius: DEFAULT_BORDER_RADIUS, + }, + loading: { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }, + loadingImage: { + width: IMAGE_WIDTH, + height: IMAGE_HEIGHT, + borderRadius: DEFAULT_BORDER_RADIUS, + }, + loadingText: { + width: IMAGE_WIDTH, + height: metrics.lg, + borderRadius: borderRadius.xs, + marginTop: TEXT_MARGIN_TOP, + }, +}); diff --git a/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.tsx b/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.tsx new file mode 100644 index 00000000..673fb058 --- /dev/null +++ b/src/components/common/default-tmdb-list-item/DefaultTMDBListItem.tsx @@ -0,0 +1,31 @@ +import React, { memo } from 'react'; + +import { Icons, TMDBImage } from '@common-components'; + +import * as Styles from './DefaultTMDBListItem.styles'; + +type DefaultTMDBListItemProps = { + iconImageLoading: Icons; + iconImageError: Icons; + onPress: () => void; + testID: string; + image: string; + title: string; +}; + +export const DefaultTMDBListItem = memo( + (props: DefaultTMDBListItemProps) => ( + + + {props.title} + + ), + () => true, +); diff --git a/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx b/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx new file mode 100644 index 00000000..78dd4fab --- /dev/null +++ b/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { View } from 'react-native'; + +import * as Styles from './DefaultTMDBListItem.styles'; +import { LoadingPlaceholder } from '..'; + +type DefaultTMDBListItemLoadingPlaceholderProps = { + indexToDelayAnimation: number; +}; + +export const DefaultTMDBListItemLoadingPlaceholder = ( + props: DefaultTMDBListItemLoadingPlaceholderProps, +) => ( + + + + +); diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 1b0267f8..b432e707 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -17,3 +17,5 @@ export { PaginatedListHeader } from './paginated-list-header/PaginatedListHeader export { Advice } from './advice/Advice'; export { RoundedButton } from './rounded-button/RoundedButton'; export { TMDBImage } from './tmdb-image/TMDBImage'; +export { DefaultTMDBListItemLoadingPlaceholder } from './default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder'; +export { DefaultTMDBListItem } from './default-tmdb-list-item/DefaultTMDBListItem'; diff --git a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts index f0f8cff3..91cb10dc 100644 --- a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts +++ b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts @@ -1,11 +1,27 @@ import { StyleSheet } from 'react-native'; +import styled from 'styled-components/native'; +import { + DEFAULT_MARGIN_LEFT, + DEFAULT_HEIGHT, +} from '@/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles'; import metrics from '@styles/metrics'; export const NUMBER_OF_COLUMNS = 3; - +export const NUMBER_OF_LOADING_ITEMS = Math.ceil( + NUMBER_OF_COLUMNS * (metrics.height / DEFAULT_HEIGHT) + 1, +); export const sheet = StyleSheet.create({ contentContainerStyle: { paddingTop: metrics.md, }, }); + +export const LoadingWrapper = styled.View` + flex: 1; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + padding-top: ${({ theme }) => theme.metrics.md}px; + padding-horizontal: ${DEFAULT_MARGIN_LEFT}px; +`; diff --git a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx index 75412caf..3d739bcc 100644 --- a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx +++ b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx @@ -2,16 +2,16 @@ import React, { useCallback, useEffect } from 'react'; import { FlatList, Platform } from 'react-native'; import { + DefaultTMDBListItemLoadingPlaceholder, PaginatedListHeader, PaginatedListFooter, HeaderIconButton, + DefaultTMDBListItem, } from '@/components/common'; -import { TrendingFamousListItem } from './components/trending-famous-list-item/TrendingFamousListItem'; import { useTrendingFamous } from './use-trending-famous'; import { FamousNavigationProp } from '../../routes/route-params-types'; import * as Styles from './TrendingFamous.styles'; -import { LoadingTrendingFamous } from './components/loading-trending-famous/LoadingTrendingFamous'; type FamousProps = { navigation: FamousNavigationProp; @@ -68,7 +68,18 @@ export const TrendingFamous = (props: FamousProps) => { ); if (trendingFamous.isLoading) { - return ; + return ( + + {Array(Styles.NUMBER_OF_LOADING_ITEMS) + .fill({}) + .map((_, index) => ( + + ))} + + ); } return ( @@ -82,10 +93,13 @@ export const TrendingFamous = (props: FamousProps) => { })} numColumns={Styles.NUMBER_OF_COLUMNS} renderItem={({ item }) => ( - trendingFamous.onPressFamous(item)} image={item.profilePath || ''} title={item.name || '-'} + iconImageLoading="account" + iconImageError="image-off" + testID="trending-famous-list-item-button" /> )} onEndReached={trendingFamous.onEndReached} diff --git a/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.styles.ts b/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.styles.ts deleted file mode 100644 index 51031648..00000000 --- a/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { StyleSheet, View } from 'react-native'; -import styled from 'styled-components/native'; - -import * as FamousListItemStyles from '../trending-famous-list-item/TrendingFamousListItem.styles'; - -export const Wrapper = styled(View)` - flex: 1; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - padding-top: ${({ theme }) => theme.metrics.md}px; - padding-horizontal: ${FamousListItemStyles.DEFAULT_MARGIN_LEFT}px; -`; - -export const sheet = StyleSheet.create({ - loading: { - width: FamousListItemStyles.DEFAULT_WIDTH, - height: FamousListItemStyles.DEFAULT_HEIGHT, - borderRadius: FamousListItemStyles.DEFAULT_BORDER_RADIUS, - marginBottom: FamousListItemStyles.DEFAULT_MARGIN_BOTTOM, - }, -}); diff --git a/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.tsx b/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.tsx deleted file mode 100644 index 3648949c..00000000 --- a/src/components/stacks/famous/screens/trending-famous/components/loading-trending-famous/LoadingTrendingFamous.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { LoadingPlaceholder } from '@common-components'; - -import * as Styles from './LoadingTrendingFamous.styles'; - -export const LoadingTrendingFamous = () => ( - - {Array(14) - .fill({}) - .map((_, index) => ( - - ))} - -); diff --git a/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.styles.ts b/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.styles.ts deleted file mode 100644 index bf5c0c2b..00000000 --- a/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.styles.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { StyleSheet, TouchableOpacity } from 'react-native'; -import styled from 'styled-components/native'; - -import metrics from '@styles/metrics'; -import { Typography } from '@/components/common'; - -export const DEFAULT_ICON_SIZE = metrics.getWidthFromDP('14'); -export const DEFAULT_WIDTH = metrics.getWidthFromDP('30'); -export const DEFAULT_HEIGHT = metrics.getWidthFromDP('50'); -export const DEFAULT_BORDER_RADIUS = metrics.xs; -export const DEFAULT_MARGIN_BOTTOM = metrics.xl; -export const DEFAULT_MARGIN_LEFT = metrics.getWidthFromDP('2.5'); - -export const PersonName = styled(Typography.ExtraSmallText).attrs({ - numberOfLines: 2, - bold: true, -})` - margin-top: ${({ theme }) => theme.metrics.sm}px; -`; - -export const Wrapper = styled(TouchableOpacity)` - width: ${DEFAULT_WIDTH}px; - height: ${DEFAULT_HEIGHT}px; - margin-bottom: ${DEFAULT_MARGIN_BOTTOM}px; - margin-left: ${DEFAULT_MARGIN_LEFT}px; -`; - -export const sheet = StyleSheet.create({ - image: { - width: metrics.getWidthFromDP('30'), - height: metrics.getWidthFromDP('40'), - borderRadius: DEFAULT_BORDER_RADIUS, - }, -}); diff --git a/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.tsx b/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.tsx deleted file mode 100644 index c2d5598c..00000000 --- a/src/components/stacks/famous/screens/trending-famous/components/trending-famous-list-item/TrendingFamousListItem.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { memo } from 'react'; - -import { TMDBImage } from '@common-components'; - -import * as Styles from './TrendingFamousListItem.styles'; - -type TrendingFamousListItemProps = { - onPress: () => void; - image: string; - title: string; -}; - -export const TrendingFamousListItem = memo( - (props: TrendingFamousListItemProps) => ( - - - {props.title} - - ), - () => true, -); From eeaef041a162e3ebc87bd0957c7fad9e2ace61dd Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 13:51:03 -0300 Subject: [PATCH 06/13] feat: add schema-types for the search-famous --- src/types/schema.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/types/schema.ts b/src/types/schema.ts index dd374d37..7850470d 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -3,6 +3,36 @@ // @generated // This file was automatically generated and should not be edited. +// ==================================================== +// GraphQL query operation: SearchFamous +// ==================================================== + +export interface SearchFamous_searchFamous_items { + __typename: "SearchFamousItem"; + profilePath: string | null; + name: string | null; + id: number | null; +} + +export interface SearchFamous_searchFamous { + __typename: "SearchFamousResult"; + items: SearchFamous_searchFamous_items[]; + hasMore: boolean; +} + +export interface SearchFamous { + searchFamous: SearchFamous_searchFamous; +} + +export interface SearchFamousVariables { + input: SearchInput; +} + +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + // ==================================================== // GraphQL query operation: QueryTrendingFamous // ==================================================== @@ -98,6 +128,12 @@ export interface QueryQuestionsVariables { // START Enums and Input Objects //============================================================== +export enum ISO6391Language { + en = "en", + es = "es", + pt = "pt", +} + export enum NewsLanguage { AR = "AR", DE = "DE", @@ -140,6 +176,12 @@ export interface QuizInput { numberOfQuestions: number; } +export interface SearchInput { + page: number; + query: string; + language?: ISO6391Language | null; +} + //============================================================== // END Enums and Input Objects //============================================================== From a753a552ef6e7172fe9698fc4d334aae04057225 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 18:51:02 -0300 Subject: [PATCH 07/13] feat: add translations for search --- src/i18n/locale/Locale.ts | 1 + src/i18n/locale/en.ts | 1 + src/i18n/locale/es.ts | 1 + src/i18n/locale/pt.ts | 1 + src/i18n/tags.ts | 13 ++++++++++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/i18n/locale/Locale.ts b/src/i18n/locale/Locale.ts index 47e7cff5..a815a522 100644 --- a/src/i18n/locale/Locale.ts +++ b/src/i18n/locale/Locale.ts @@ -85,4 +85,5 @@ export type Locale = { }; errors: Errors; }; + recentSearches: string; }; diff --git a/src/i18n/locale/en.ts b/src/i18n/locale/en.ts index 88317eb6..c099c37d 100644 --- a/src/i18n/locale/en.ts +++ b/src/i18n/locale/en.ts @@ -110,4 +110,5 @@ export const en: Locale = { entry: "Couldn't load this famous", }, }, + recentSearches: 'Recent', }; diff --git a/src/i18n/locale/es.ts b/src/i18n/locale/es.ts index ff274357..a830c25e 100644 --- a/src/i18n/locale/es.ts +++ b/src/i18n/locale/es.ts @@ -110,4 +110,5 @@ export const es: Locale = { entry: 'No se pudo cargar esta famosos', }, }, + recentSearches: 'Reciente', }; diff --git a/src/i18n/locale/pt.ts b/src/i18n/locale/pt.ts index 2dcf542d..9c177657 100644 --- a/src/i18n/locale/pt.ts +++ b/src/i18n/locale/pt.ts @@ -110,4 +110,5 @@ export const pt: Locale = { entry: 'Não foi possível carregar os famosos', }, }, + recentSearches: 'Recentes', }; diff --git a/src/i18n/tags.ts b/src/i18n/tags.ts index 33096913..2d2d3388 100644 --- a/src/i18n/tags.ts +++ b/src/i18n/tags.ts @@ -88,5 +88,16 @@ export namespace Translations { PAGINATION_ERROR = 'translations:famous:errors:pagination', } - export type Tags = Tabs | News | Time | Quiz | Error | TrendingFamous; + export enum Search { + SEARCH_RECENT = 'translations:recentSearches', + } + + export type Tags = + | Tabs + | News + | Time + | Quiz + | Error + | TrendingFamous + | Search; } From f713c286d76273705a64f55fd1eb634490b71fbf Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 18:54:30 -0300 Subject: [PATCH 08/13] feat: creating the RecentSearches --- .../recent-searches/RecentSearches.styles.ts | 13 +++ .../recent-searches/RecentSearches.tsx | 41 +++++++++ .../RecentSearchesListItem.styles.ts | 60 +++++++++++++ .../RecentSearchesListItem.tsx | 41 +++++++++ .../recent-searches/use-recent-searches.ts | 89 +++++++++++++++++++ .../stacks/common-screens/search/types.ts | 11 +++ 6 files changed, 255 insertions(+) create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.styles.ts create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts create mode 100644 src/components/stacks/common-screens/search/types.ts diff --git a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.styles.ts b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.styles.ts new file mode 100644 index 00000000..e9c106e0 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.styles.ts @@ -0,0 +1,13 @@ +import styled from 'styled-components/native'; + +import { Typography } from '@/components/common'; + +export const Wrapper = styled.View` + flex-direction: column; + padding-top: ${({ theme }) => theme.metrics.xl}px; + padding-horizontal: ${({ theme }) => theme.metrics.lg}px; +`; + +export const RecentText = styled(Typography.ExtraSmallText)` + margin-bottom: ${({ theme }) => theme.metrics.xl}px; +`; diff --git a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx new file mode 100644 index 00000000..0282b21e --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; + +import { RecentSearchesListItem } from './recent-searchers-list-item/RecentSearchesListItem'; +import * as Styles from './RecentSearches.styles'; +import { RecentSearchItem, SearchType } from '../../types'; +import { useRecentSearches } from './use-recent-searches'; + +type RecentSearchesProps = { + onPressItem: (item: RecentSearchItem) => void; + searchType: SearchType; +}; + +export const RecentSearches = (props: RecentSearchesProps) => { + const recentSearches = useRecentSearches({ + searchType: props.searchType, + }); + + useEffect(() => { + recentSearches.load(); + }, []); + + if (!recentSearches.items.length) { + return null; + } + + return ( + + + {recentSearches.texts.recentSearches} + + {recentSearches.items.map(recentSearch => ( + recentSearches.remove(recentSearch.id)} + onPressItem={() => props.onPressItem(recentSearch)} + key={recentSearch.id} + item={recentSearch} + /> + ))} + + ); +}; diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts new file mode 100644 index 00000000..cef00651 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts @@ -0,0 +1,60 @@ +import { StyleSheet } from 'react-native'; +import styled from 'styled-components/native'; +import Animated from 'react-native-reanimated'; + +import { Typography } from '@/components/common'; +import metrics from '@styles/metrics'; + +export const DEFAULT_ICON_SIZE = metrics.xl * 2; +const IMAGE_WIDTH = metrics.xl * 3; +const IMAGE_HEIGHT = metrics.xl * 3.5; + +export const Wrapper = styled.View` + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: ${({ theme }) => theme.metrics.xl}px; +`; + +export const PressableContent = styled.TouchableOpacity` + flex-direction: row; + align-items: center; +`; + +export const FallbackImageWrapper = styled(Animated.View)` + width: ${IMAGE_WIDTH}px; + height: ${IMAGE_HEIGHT}px; + justify-content: center; + align-items: center; + position: absolute; + border-radius: ${({ theme }) => theme.metrics.sm}px; + background-color: ${({ theme }) => theme.colors.fallbackImageBackground}; +`; + +export const ItemText = styled(Typography.ExtraSmallText).attrs({ + numberOfLines: 2, +})` + width: ${({ theme }) => theme.metrics.getWidthFromDP('55%')}px; + margin-left: ${({ theme }) => theme.metrics.md}px; + margin-right: ${({ theme }) => theme.metrics.xl}px; +`; + +export const CloseButtonWrapper = styled.TouchableOpacity.attrs( + ({ theme }) => ({ + hitSlop: { + top: theme.metrics.lg, + bottom: theme.metrics.lg, + left: theme.metrics.lg, + right: theme.metrics.lg, + }, + }), +)``; + +export const sheet = StyleSheet.create({ + image: { + width: IMAGE_WIDTH, + height: IMAGE_HEIGHT, + borderRadius: metrics.xs, + }, +}); diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx new file mode 100644 index 00000000..aa14c46c --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { TMDBImage, SVGIcon } from '@common-components'; +import metrics from '@styles/metrics'; + +import * as Styles from './RecentSearchesListItem.styles'; +import { RecentSearchItem } from '../../../types'; + +type RecentSearchesListItemProps = { + item: RecentSearchItem; + onPressRemove: () => void; + onPressItem: () => void; +}; + +export const RecentSearchesListItem = (props: RecentSearchesListItemProps) => ( + + + <> + + + + {props.item.title} + + + + + + +); diff --git a/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts b/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts new file mode 100644 index 00000000..29f4e986 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts @@ -0,0 +1,89 @@ +import { useCallback, useState, useMemo } from 'react'; + +import { Translations } from '@i18n/tags'; +import { useTranslation } from '@hooks'; +import { storage } from '@utils'; + +import { SearchType, RecentSearchItem } from '../../types'; + +export const BASE_STORAGE_KEY = 'RECENT_SEARCHES'; +export const MAX_RECENT_SEARCHES = 3; + +type UseRecentSearchesProps = { + searchType: SearchType; +}; + +export const useRecentSearches = (props: UseRecentSearchesProps) => { + const [recentSearches, setRecentSearches] = useState([]); + + const translation = useTranslation(); + + const texts = useMemo( + () => ({ + recentSearches: translation.translate(Translations.Search.SEARCH_RECENT), + }), + [translation.translate], + ); + + const storageKey = useMemo( + () => `${BASE_STORAGE_KEY}:${props.searchType.toString()}`, + [props.searchType], + ); + + const add = useCallback( + async (item: RecentSearchItem) => { + const recentSearchesFromStorage = await storage.get( + storageKey, + ); + if (!recentSearchesFromStorage) { + return await storage.set(storageKey, [item]); + } + let recentSearchesUpdated = [item, ...recentSearchesFromStorage]; + const isItemSearchedBefore = recentSearches.some( + recentSearch => recentSearch.id === item.id, + ); + if (isItemSearchedBefore) { + recentSearchesUpdated = [ + item, + ...recentSearches.filter( + persistedItem => persistedItem.id !== item.id, + ), + ]; + } + const recentSearchesUpdatedSliced = recentSearchesUpdated.slice( + 0, + MAX_RECENT_SEARCHES, + ); + await storage.set(storageKey, recentSearchesUpdatedSliced); + }, + [recentSearches, storageKey], + ); + + const load = useCallback(async () => { + const recentSearchesFromStorage = await storage.get( + storageKey, + ); + if (recentSearchesFromStorage) { + setRecentSearches(recentSearchesFromStorage); + } + }, [storageKey]); + + const remove = useCallback( + async (id: number) => { + const recentSearchesUpdated = recentSearches.filter( + recentSearch => recentSearch.id !== id, + ); + setRecentSearches(recentSearchesUpdated); + await storage.set(storageKey, recentSearchesUpdated); + }, + [recentSearches], + ); + + return { + items: recentSearches, + load, + remove, + add, + texts, + }; +}; diff --git a/src/components/stacks/common-screens/search/types.ts b/src/components/stacks/common-screens/search/types.ts new file mode 100644 index 00000000..308d464a --- /dev/null +++ b/src/components/stacks/common-screens/search/types.ts @@ -0,0 +1,11 @@ +export enum SearchType { + MOVIE = 'MOVIE', + FAMOUS = 'FAMOUS', + TV = 'TV', +} + +export type RecentSearchItem = { + title: string; + image: string; + id: number; +}; From d7999a747c4787551bc8695d50fde956ca9d1072 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sat, 4 Nov 2023 18:56:49 -0300 Subject: [PATCH 09/13] test: creating the RecentSearches --- .../recent-searches/RecentSearches.test.tsx | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx diff --git a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx new file mode 100644 index 00000000..acd94ad1 --- /dev/null +++ b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { + RenderAPI, + render, + act, + waitFor, + fireEvent, +} from '@testing-library/react-native'; +import { ThemeProvider } from 'styled-components/native'; + +import { dark as theme } from '@styles/themes'; +import { Translations } from '@/i18n/tags'; + +import { BASE_STORAGE_KEY, MAX_RECENT_SEARCHES } from './use-recent-searches'; +import { randomPositiveNumber } from '../../../../../../../__mocks__/utils'; +import { RecentSearchItem, SearchType } from '../../types'; +import { RecentSearches } from './RecentSearches'; + +const searchTypes = Object.keys(SearchType); + +const persistedItems: RecentSearchItem[] = Array(MAX_RECENT_SEARCHES) + .fill({}) + .map((_, index) => ({ + title: `PERSISTED_ITEM_TITLE_${index}`, + image: `PERSISTED_ITEM_IMAGE_${index}`, + id: index, + })); + +jest.mock('@utils', () => ({ + isEqualsOrLargerThanIphoneX: jest.fn().mockReturnValue(true), + renderSVGIconConditionally: () => <>, + storage: { + set: jest.fn(), + get: jest.fn(), + }, +})); + +const utils = require('@/utils'); + +const renderRecentSearches = (searchType: string, onPressItem = jest.fn()) => ( + + + +); + +describe('Common-screens/Search/RecentSearches', () => { + const elements = { + list: (api: RenderAPI) => api.queryByTestId('recent-searches-list'), + title: (api: RenderAPI) => api.queryByTestId('recent-searches-title'), + items: (api: RenderAPI) => + api.queryAllByTestId('recent-searches-list-item-button'), + itemsTexts: (api: RenderAPI) => + api.queryAllByTestId('recent-searches-list-item-title'), + removeItemButtons: (api: RenderAPI) => + api.queryAllByTestId('recent-searches-list-item-close-button'), + }; + + describe('Loading the items from the storage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the "storage.get" correctly', async () => { + const searchType = + searchTypes[randomPositiveNumber(searchTypes.length - 1)]; + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + render(renderRecentSearches(searchType)); + const storageKey = `${BASE_STORAGE_KEY}:${searchType.toString()}`; + await waitFor(() => { + expect(utils.storage.get).toBeCalledTimes(1); + expect(utils.storage.get).toBeCalledWith(storageKey); + }); + }); + }); + + describe('When has "no previously-searched-items', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render any element', async () => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(undefined); + const searchType = + searchTypes[randomPositiveNumber(searchTypes.length - 1)]; + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.list(component)).toBeNull(); + }); + }); + }); + + describe('When has some previous "recent-searched-items"', () => { + describe('Rendering', () => { + const searchType = + searchTypes[randomPositiveNumber(searchTypes.length - 1)]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show the elements correctly', async () => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.list(component)).not.toBeNull(); + }); + expect(elements.items(component).length).toEqual(persistedItems.length); + expect(elements.removeItemButtons(component).length).toEqual( + persistedItems.length, + ); + }); + + it('should show the "title" correctly', async () => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.title(component)?.children[0]).toEqual( + Translations.Search.SEARCH_RECENT, + ); + }); + }); + + it('should show the "items" in the correct order', async () => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.list(component)).not.toBeNull(); + }); + for (let i = 0; i < elements.itemsTexts(component).length; i++) { + expect(elements.itemsTexts(component)[i].children[0]).toEqual( + persistedItems[i].title, + ); + } + }); + }); + }); + + describe('Removing items', () => { + const searchType = + searchTypes[randomPositiveNumber(searchTypes.length - 1)]; + + beforeEach(() => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + jest.clearAllMocks(); + }); + + it('should "remove" the item "from the list" after removal', async () => { + const indexItemRemoved = randomPositiveNumber(searchTypes.length - 1); + const itemsAfterRemoval = persistedItems.filter( + (_, index) => index !== indexItemRemoved, + ); + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.list(component)).not.toBeNull(); + }); + for (let i = 0; i < elements.itemsTexts(component).length; i++) { + expect(elements.itemsTexts(component)[i].children[0]).toEqual( + persistedItems[i].title, + ); + } + act(() => { + fireEvent.press( + elements.removeItemButtons(component)[indexItemRemoved], + ); + }); + for (let i = 0; i < elements.itemsTexts(component).length; i++) { + expect(elements.itemsTexts(component)[i].children[0]).toEqual( + itemsAfterRemoval[i].title, + ); + } + }); + + it('should "persist" the items correctly after removal', async () => { + const indexItemRemoved = randomPositiveNumber(searchTypes.length - 1); + const storageKey = `${BASE_STORAGE_KEY}:${searchType.toString()}`; + const itemsAfterRemoval = persistedItems.filter( + (_, index) => index !== indexItemRemoved, + ); + const component = render(renderRecentSearches(searchType)); + await waitFor(() => { + expect(elements.list(component)).not.toBeNull(); + }); + expect(utils.storage.set).toBeCalledTimes(0); + fireEvent.press(elements.removeItemButtons(component)[indexItemRemoved]); + expect(utils.storage.set).toBeCalledTimes(1); + expect(utils.storage.set).toBeCalledWith(storageKey, itemsAfterRemoval); + }); + }); + + describe('Pressing items', () => { + it('should call "onPress" correctly when selecting some item', async () => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce(persistedItems); + const searchType = + searchTypes[randomPositiveNumber(searchTypes.length - 1)]; + const onPressItem = jest.fn(); + const indexItemSelected = randomPositiveNumber(searchTypes.length - 1); + const component = render(renderRecentSearches(searchType, onPressItem)); + await waitFor(() => { + expect(elements.list(component)).not.toBeNull(); + }); + expect(onPressItem).toBeCalledTimes(0); + fireEvent.press(elements.items(component)[indexItemSelected]); + expect(onPressItem).toBeCalledTimes(1); + expect(onPressItem).toBeCalledWith(persistedItems[indexItemSelected]); + }); + }); +}); From efa5ad761bdb8b1d090fda94bc9c3d64683f510c Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sun, 5 Nov 2023 15:09:25 -0300 Subject: [PATCH 10/13] feat: add types for all the searches --- src/types/schema.ts | 72 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/src/types/schema.ts b/src/types/schema.ts index 7850470d..30db72e8 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -7,21 +7,21 @@ // GraphQL query operation: SearchFamous // ==================================================== -export interface SearchFamous_searchFamous_items { +export interface SearchFamous_search_items { __typename: "SearchFamousItem"; - profilePath: string | null; - name: string | null; + image: string | null; + title: string | null; id: number | null; } -export interface SearchFamous_searchFamous { +export interface SearchFamous_search { __typename: "SearchFamousResult"; - items: SearchFamous_searchFamous_items[]; + items: SearchFamous_search_items[]; hasMore: boolean; } export interface SearchFamous { - searchFamous: SearchFamous_searchFamous; + search: SearchFamous_search; } export interface SearchFamousVariables { @@ -33,6 +33,66 @@ export interface SearchFamousVariables { // @generated // This file was automatically generated and should not be edited. +// ==================================================== +// GraphQL query operation: SearchMovies +// ==================================================== + +export interface SearchMovies_search_items { + __typename: "SearchMovieItem"; + image: string | null; + title: string | null; + id: number; +} + +export interface SearchMovies_search { + __typename: "SearchMoviesResult"; + items: SearchMovies_search_items[]; + hasMore: boolean; +} + +export interface SearchMovies { + search: SearchMovies_search; +} + +export interface SearchMoviesVariables { + input: SearchInput; +} + +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: SearchTVShows +// ==================================================== + +export interface SearchTVShows_search_items { + __typename: "SearchTVShowItem"; + image: string | null; + title: string | null; + id: number; +} + +export interface SearchTVShows_search { + __typename: "SearchTVShowsResult"; + hasMore: boolean; + items: SearchTVShows_search_items[]; +} + +export interface SearchTVShows { + search: SearchTVShows_search; +} + +export interface SearchTVShowsVariables { + input: SearchInput; +} + +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + // ==================================================== // GraphQL query operation: QueryTrendingFamous // ==================================================== From 87e7ddb7fc5092d4715cec5cd54d90ccc4550469 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sun, 5 Nov 2023 15:22:01 -0300 Subject: [PATCH 11/13] feat: creating the DefaultTMDBListLoading --- .../DefaultTMDBListItemLoadingPlaceholder.tsx | 24 ------------------ .../DefaultTMDBListLoading.styles.ts | 21 ++++++++++++++++ .../DefaultTMDBListLoading.tsx | 25 +++++++++++++++++++ src/components/common/index.ts | 2 +- 4 files changed, 47 insertions(+), 25 deletions(-) delete mode 100644 src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx create mode 100644 src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.styles.ts create mode 100644 src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.tsx diff --git a/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx b/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx deleted file mode 100644 index 78dd4fab..00000000 --- a/src/components/common/default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { View } from 'react-native'; - -import * as Styles from './DefaultTMDBListItem.styles'; -import { LoadingPlaceholder } from '..'; - -type DefaultTMDBListItemLoadingPlaceholderProps = { - indexToDelayAnimation: number; -}; - -export const DefaultTMDBListItemLoadingPlaceholder = ( - props: DefaultTMDBListItemLoadingPlaceholderProps, -) => ( - - - - -); diff --git a/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.styles.ts b/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.styles.ts new file mode 100644 index 00000000..a40b9e42 --- /dev/null +++ b/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.styles.ts @@ -0,0 +1,21 @@ +import styled from 'styled-components/native'; + +import { + DEFAULT_MARGIN_LEFT, + DEFAULT_HEIGHT, +} from '../default-tmdb-list-item/DefaultTMDBListItem.styles'; +import metrics from '@/styles/metrics'; + +const NUMBER_OF_COLUMNS = 3; +export const NUMBER_OF_LOADING_ITEMS = Math.ceil( + NUMBER_OF_COLUMNS * (metrics.height / DEFAULT_HEIGHT) + 1, +); + +export const LoadingWrapper = styled.View` + flex: 1; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + padding-top: ${({ theme }) => theme.metrics.md}px; + padding-horizontal: ${DEFAULT_MARGIN_LEFT}px; +`; diff --git a/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.tsx b/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.tsx new file mode 100644 index 00000000..1a197ae9 --- /dev/null +++ b/src/components/common/default-tmdb-list-loading/DefaultTMDBListLoading.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { View } from 'react-native'; + +import * as DefaultTMDBListItemStyles from '../default-tmdb-list-item/DefaultTMDBListItem.styles'; +import * as Styles from './DefaultTMDBListLoading.styles'; +import { LoadingPlaceholder } from '..'; + +export const DefaultTMDBListLoading = () => ( + + {Array(Styles.NUMBER_OF_LOADING_ITEMS) + .fill({}) + .map((_, index) => ( + + + + + ))} + +); diff --git a/src/components/common/index.ts b/src/components/common/index.ts index b432e707..430a9378 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -17,5 +17,5 @@ export { PaginatedListHeader } from './paginated-list-header/PaginatedListHeader export { Advice } from './advice/Advice'; export { RoundedButton } from './rounded-button/RoundedButton'; export { TMDBImage } from './tmdb-image/TMDBImage'; -export { DefaultTMDBListItemLoadingPlaceholder } from './default-tmdb-list-item/DefaultTMDBListItemLoadingPlaceholder'; export { DefaultTMDBListItem } from './default-tmdb-list-item/DefaultTMDBListItem'; +export { DefaultTMDBListLoading } from './default-tmdb-list-loading/DefaultTMDBListLoading'; From e3ce84edfb1bb71b256f81a64d3017eadfd343f2 Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Sun, 5 Nov 2023 15:25:04 -0300 Subject: [PATCH 12/13] feat: creating the Search --- src/components/stacks/common-screens/index.ts | 1 + .../common-screens/search/Search.styles.ts | 9 ++ .../stacks/common-screens/search/Search.tsx | 101 ++++++++++++ .../recent-searches/RecentSearches.test.tsx | 4 +- .../recent-searches/RecentSearches.tsx | 6 +- .../RecentSearchesListItem.styles.ts | 4 +- .../RecentSearchesListItem.tsx | 4 +- .../recent-searches/use-recent-searches.ts | 10 +- .../components/search-bar/SearchBar.test.tsx | 14 -- .../components/search-bar/SearchBar.tsx | 2 - .../stacks/common-screens/search/debounce.ts | 15 ++ .../search/search-config/search-config.ts | 30 ++++ .../search-config/search-famous-config.ts | 38 +++++ .../search-config/search-movies-config.ts | 34 ++++ .../search-config/search-tv-shows-config.ts | 34 ++++ .../stacks/common-screens/search/types.ts | 36 ++++- .../common-screens/search/use-search.ts | 148 ++++++++++++++++++ .../famous/routes/route-params-types.ts | 11 +- .../stacks/famous/routes/stack-routes.tsx | 10 +- .../trending-famous/TrendingFamous.spec.tsx | 12 +- .../trending-famous/TrendingFamous.styles.ts | 19 +-- .../trending-famous/TrendingFamous.tsx | 17 +- .../trending-famous/use-trending-famous.ts | 5 +- .../stacks/home/routes/route-params-types.ts | 11 ++ src/i18n/tags.ts | 12 +- src/navigation/routes.ts | 2 +- 26 files changed, 509 insertions(+), 80 deletions(-) create mode 100644 src/components/stacks/common-screens/index.ts create mode 100644 src/components/stacks/common-screens/search/Search.styles.ts create mode 100644 src/components/stacks/common-screens/search/Search.tsx create mode 100644 src/components/stacks/common-screens/search/debounce.ts create mode 100644 src/components/stacks/common-screens/search/search-config/search-config.ts create mode 100644 src/components/stacks/common-screens/search/search-config/search-famous-config.ts create mode 100644 src/components/stacks/common-screens/search/search-config/search-movies-config.ts create mode 100644 src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts create mode 100644 src/components/stacks/common-screens/search/use-search.ts create mode 100644 src/components/stacks/home/routes/route-params-types.ts diff --git a/src/components/stacks/common-screens/index.ts b/src/components/stacks/common-screens/index.ts new file mode 100644 index 00000000..3eb2d9c7 --- /dev/null +++ b/src/components/stacks/common-screens/index.ts @@ -0,0 +1 @@ +export { Search } from './search/Search'; diff --git a/src/components/stacks/common-screens/search/Search.styles.ts b/src/components/stacks/common-screens/search/Search.styles.ts new file mode 100644 index 00000000..906478c4 --- /dev/null +++ b/src/components/stacks/common-screens/search/Search.styles.ts @@ -0,0 +1,9 @@ +import { StyleSheet } from 'react-native'; + +import metrics from '@styles/metrics'; + +export const sheet = StyleSheet.create({ + contentContainerStyle: { + paddingTop: metrics.md, + }, +}); diff --git a/src/components/stacks/common-screens/search/Search.tsx b/src/components/stacks/common-screens/search/Search.tsx new file mode 100644 index 00000000..4b84e6a9 --- /dev/null +++ b/src/components/stacks/common-screens/search/Search.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect } from 'react'; +import { FlatList, Platform } from 'react-native'; + +import { + DefaultTMDBListItem, + DefaultTMDBListLoading, + PaginatedListFooter, + PaginatedListHeader, +} from '@/components/common'; + +import { RecentSearches } from './components/recent-searches/RecentSearches'; +import { SearchNavigationProps as SearchProps } from './types'; +import { SearchBar } from './components/search-bar/SearchBar'; +import { useSearch } from './use-search'; +import * as Styles from './Search.styles'; + +export const Search = (props: SearchProps) => { + const search = useSearch(props); + + const ListHeaderComponent = useCallback( + (): React.ReactNode | undefined => + search.shouldShowTopReloadButton && ( + + ), + [search.shouldShowTopReloadButton, search.onPressTopReloadButton], + ); + + const ListFooterComponent = useCallback( + (): React.ReactNode | undefined => + search.shouldShowBottomReloadButton && ( + + ), + [ + search.isPaginating, + search.hasPaginationError, + search.shouldShowBottomReloadButton, + search.onPressBottomReloadButton, + ], + ); + + const Header = useCallback( + () => ( + props.navigation.goBack()} + placeholder={search.placeholder} + /> + ), + [search.onTypeSearchQuery, search.placeholder, props.navigation.goBack], + ); + + useEffect(() => { + props.navigation.setOptions({ + header: Header, + }); + }, [Header]); + + if (search.shouldShowRecentSearches) { + return ( + + ); + } + + if (search.isLoading) { + return ; + } + + return ( + ( + search.onPressItem(item)} + image={item.image || ''} + title={item.title || '-'} + /> + )} + onEndReached={search.onEndReached} + keyExtractor={({ id }) => `${id}`} + testID="famous-list" + data={search.items} + /> + ); +}; diff --git a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx index acd94ad1..a2dc1692 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx +++ b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.test.tsx @@ -13,12 +13,12 @@ import { Translations } from '@/i18n/tags'; import { BASE_STORAGE_KEY, MAX_RECENT_SEARCHES } from './use-recent-searches'; import { randomPositiveNumber } from '../../../../../../../__mocks__/utils'; -import { RecentSearchItem, SearchType } from '../../types'; +import { SearchItem, SearchType } from '../../types'; import { RecentSearches } from './RecentSearches'; const searchTypes = Object.keys(SearchType); -const persistedItems: RecentSearchItem[] = Array(MAX_RECENT_SEARCHES) +const persistedItems: SearchItem[] = Array(MAX_RECENT_SEARCHES) .fill({}) .map((_, index) => ({ title: `PERSISTED_ITEM_TITLE_${index}`, diff --git a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx index 0282b21e..93627441 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx +++ b/src/components/stacks/common-screens/search/components/recent-searches/RecentSearches.tsx @@ -2,11 +2,11 @@ import React, { useEffect } from 'react'; import { RecentSearchesListItem } from './recent-searchers-list-item/RecentSearchesListItem'; import * as Styles from './RecentSearches.styles'; -import { RecentSearchItem, SearchType } from '../../types'; +import { SearchItem, SearchType } from '../../types'; import { useRecentSearches } from './use-recent-searches'; type RecentSearchesProps = { - onPressItem: (item: RecentSearchItem) => void; + onPressItem: (item: SearchItem) => void; searchType: SearchType; }; @@ -30,7 +30,7 @@ export const RecentSearches = (props: RecentSearchesProps) => { {recentSearches.items.map(recentSearch => ( recentSearches.remove(recentSearch.id)} + onPressRemove={() => recentSearches.remove(recentSearch.id ?? -1)} onPressItem={() => props.onPressItem(recentSearch)} key={recentSearch.id} item={recentSearch} diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts index cef00651..f07bdc13 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts @@ -6,8 +6,8 @@ import { Typography } from '@/components/common'; import metrics from '@styles/metrics'; export const DEFAULT_ICON_SIZE = metrics.xl * 2; -const IMAGE_WIDTH = metrics.xl * 3; -const IMAGE_HEIGHT = metrics.xl * 3.5; +const IMAGE_WIDTH = metrics.xl * 2.5; +const IMAGE_HEIGHT = metrics.xl * 2.8; export const Wrapper = styled.View` width: 100%; diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx index aa14c46c..d93abe25 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx @@ -4,12 +4,12 @@ import { TMDBImage, SVGIcon } from '@common-components'; import metrics from '@styles/metrics'; import * as Styles from './RecentSearchesListItem.styles'; -import { RecentSearchItem } from '../../../types'; +import { SearchItem } from '../../../types'; type RecentSearchesListItemProps = { - item: RecentSearchItem; onPressRemove: () => void; onPressItem: () => void; + item: SearchItem; }; export const RecentSearchesListItem = (props: RecentSearchesListItemProps) => ( diff --git a/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts b/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts index 29f4e986..a5393921 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts +++ b/src/components/stacks/common-screens/search/components/recent-searches/use-recent-searches.ts @@ -4,7 +4,7 @@ import { Translations } from '@i18n/tags'; import { useTranslation } from '@hooks'; import { storage } from '@utils'; -import { SearchType, RecentSearchItem } from '../../types'; +import { SearchType, SearchItem } from '../../types'; export const BASE_STORAGE_KEY = 'RECENT_SEARCHES'; export const MAX_RECENT_SEARCHES = 3; @@ -14,7 +14,7 @@ type UseRecentSearchesProps = { }; export const useRecentSearches = (props: UseRecentSearchesProps) => { - const [recentSearches, setRecentSearches] = useState([]); + const [recentSearches, setRecentSearches] = useState([]); const translation = useTranslation(); @@ -31,8 +31,8 @@ export const useRecentSearches = (props: UseRecentSearchesProps) => { ); const add = useCallback( - async (item: RecentSearchItem) => { - const recentSearchesFromStorage = await storage.get( + async (item: SearchItem) => { + const recentSearchesFromStorage = await storage.get( storageKey, ); if (!recentSearchesFromStorage) { @@ -60,7 +60,7 @@ export const useRecentSearches = (props: UseRecentSearchesProps) => { ); const load = useCallback(async () => { - const recentSearchesFromStorage = await storage.get( + const recentSearchesFromStorage = await storage.get( storageKey, ); if (recentSearchesFromStorage) { diff --git a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx index 1f1307ca..30c80264 100644 --- a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx +++ b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.test.tsx @@ -10,7 +10,6 @@ const PLACEHOLDER = 'PLACEHOLDER'; type RenderSearchBarParams = { onTypeSearchQuery?: jest.Mock; - onPressSearch?: jest.Mock; onPressClose?: jest.Mock; }; @@ -19,7 +18,6 @@ const renderSearchBar = (params: RenderSearchBarParams) => ( @@ -67,17 +65,5 @@ describe('Common-screens/Search/SearchBar', () => { expect(onTypeSearchQuery).toBeCalledTimes(1); expect(onTypeSearchQuery).toBeCalledWith(content); }); - - it('should call "onSubmitEditing" correctly when press the "keyboard-search-cta"', () => { - const onPressSearch = jest.fn(); - const component = render( - renderSearchBar({ - onPressSearch, - }), - ); - expect(onPressSearch).toBeCalledTimes(0); - fireEvent(elements.input(component), 'onSubmitEditing'); - expect(onPressSearch).toBeCalledTimes(1); - }); }); }); diff --git a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx index fa79f786..fb203d05 100644 --- a/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx +++ b/src/components/stacks/common-screens/search/components/search-bar/SearchBar.tsx @@ -9,7 +9,6 @@ import * as Styles from './SearchBar.styles'; export type SearchBarProps = { onTypeSearchQuery: (query: string) => void; - onPressSearch: () => void; onPressClose: () => void; placeholder: string; }; @@ -35,7 +34,6 @@ export const SearchBar = (props: SearchBarProps) => { diff --git a/src/components/stacks/common-screens/search/debounce.ts b/src/components/stacks/common-screens/search/debounce.ts new file mode 100644 index 00000000..70bd83de --- /dev/null +++ b/src/components/stacks/common-screens/search/debounce.ts @@ -0,0 +1,15 @@ +export const debounce = any>( + timeoutCallback: T, + delay: number, +) => { + let timeout: ReturnType | null = null; + const debounced = (...args: Parameters) => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + timeout = setTimeout(() => timeoutCallback(...args), delay); + }; + + return debounced as (...args: Parameters) => ReturnType; +}; diff --git a/src/components/stacks/common-screens/search/search-config/search-config.ts b/src/components/stacks/common-screens/search/search-config/search-config.ts new file mode 100644 index 00000000..5ee2a19a --- /dev/null +++ b/src/components/stacks/common-screens/search/search-config/search-config.ts @@ -0,0 +1,30 @@ +import { DocumentNode } from '@apollo/client'; + +import { Icons } from '@/components/common'; +import { Translations } from '@/i18n/tags'; + +import { SearchItem, SearchNavigationProp, SearchType } from '../types'; +import { searchFamousConfig } from './search-famous-config'; +import { searchMoviesConfig } from './search-movies-config'; +import { searchTVShowsConfig } from './search-tv-shows-config'; + +type SearchConfig = { + navigateToDetails: ( + searchItem: SearchItem, + navigation: SearchNavigationProp, + ) => void; + searchPlaceholder: Translations.Tags; + searchByTextError: Translations.Tags; + paginationError: Translations.Tags; + query: DocumentNode; + iconImageLoading: Icons; +}; + +export const getSearchConfig = (searchType: SearchType) => { + const searchTypeConfigMapping: Record = { + MOVIE: searchMoviesConfig(), + TV: searchTVShowsConfig(), + FAMOUS: searchFamousConfig(), + }; + return searchTypeConfigMapping[searchType]; +}; diff --git a/src/components/stacks/common-screens/search/search-config/search-famous-config.ts b/src/components/stacks/common-screens/search/search-config/search-famous-config.ts new file mode 100644 index 00000000..13b6bc1b --- /dev/null +++ b/src/components/stacks/common-screens/search/search-config/search-famous-config.ts @@ -0,0 +1,38 @@ +import { gql } from '@apollo/client'; + +import { Translations } from '@/i18n/tags'; +import { Icons } from '@/components/common'; +import { Routes } from '@/navigation'; + +import { SearchItem, SearchNavigationProp } from '../types'; + +const SEARCH_FAMOUS_QUERY = gql` + query SearchFamous($input: SearchInput!) { + search: searchFamous(input: $input) { + items { + image: profilePath + title: name + id + } + hasMore + } + } +`; + +export const searchFamousConfig = () => ({ + navigateToDetails: ( + searchItem: SearchItem, + navigation: SearchNavigationProp, + ) => { + navigation.navigate(Routes.Famous.DETAILS, { + profilePath: searchItem.image || '', + name: searchItem.title || '-', + id: searchItem.id || -1, + }); + }, + searchPlaceholder: Translations.SearchFamous.SEARCHBAR, + searchByTextError: Translations.SearchFamous.ENTRY_ERROR, + paginationError: Translations.SearchFamous.PAGINATION_ERROR, + query: SEARCH_FAMOUS_QUERY, + iconImageLoading: 'account' as Icons, +}); diff --git a/src/components/stacks/common-screens/search/search-config/search-movies-config.ts b/src/components/stacks/common-screens/search/search-config/search-movies-config.ts new file mode 100644 index 00000000..fb6de99c --- /dev/null +++ b/src/components/stacks/common-screens/search/search-config/search-movies-config.ts @@ -0,0 +1,34 @@ +import { gql } from '@apollo/client'; + +import { Icons } from '@/components/common'; +import { Translations } from '@/i18n/tags'; +import { Routes } from '@/navigation'; + +import { SearchItem, SearchNavigationProp } from '../types'; + +const SEARCH_MOVIES_QUERY = gql` + query SearchMovies($input: SearchInput!) { + search: searchMovies(input: $input) { + items { + image: posterPath + title + id + } + hasMore + } + } +`; + +export const searchMoviesConfig = () => ({ + navigateToDetails: ( + searchItem: SearchItem, + navigation: SearchNavigationProp, + ) => { + navigation.navigate(Routes.Home.MOVIE_DETAILS); + }, + searchPlaceholder: Translations.TrendingFamous.ENTRY_ERROR, + searchByTextError: Translations.TrendingFamous.ENTRY_ERROR, + paginationError: Translations.TrendingFamous.ENTRY_ERROR, + query: SEARCH_MOVIES_QUERY, + iconImageLoading: 'video-vintage' as Icons, +}); diff --git a/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts b/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts new file mode 100644 index 00000000..43c34d43 --- /dev/null +++ b/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts @@ -0,0 +1,34 @@ +import { gql } from '@apollo/client'; + +import { Icons } from '@/components/common'; +import { Translations } from '@/i18n/tags'; +import { Routes } from '@/navigation'; + +import { SearchItem, SearchNavigationProp } from '../types'; + +const SEARCH_TV_SHOWS_QUERY = gql` + query SearchTVShows($input: SearchInput!) { + search: searchTVShows(input: $input) { + hasMore + items { + image: posterPath + title: name + id + } + } + } +`; + +export const searchTVShowsConfig = () => ({ + navigateToDetails: ( + searchItem: SearchItem, + navigation: SearchNavigationProp, + ) => { + navigation.navigate(Routes.Home.TV_SHOW_DETAILS); + }, + searchPlaceholder: Translations.TrendingFamous.ENTRY_ERROR, + searchByTextError: Translations.TrendingFamous.ENTRY_ERROR, + paginationError: Translations.TrendingFamous.ENTRY_ERROR, + query: SEARCH_TV_SHOWS_QUERY, + iconImageLoading: 'video-vintage' as Icons, +}); diff --git a/src/components/stacks/common-screens/search/types.ts b/src/components/stacks/common-screens/search/types.ts index 308d464a..3547bc85 100644 --- a/src/components/stacks/common-screens/search/types.ts +++ b/src/components/stacks/common-screens/search/types.ts @@ -1,11 +1,39 @@ +import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack'; + +import { Routes } from '@navigation'; + +import { FamousStackRoutes } from '../../famous/routes/route-params-types'; +import { HomeStackRoutes } from '../../home/routes/route-params-types'; + export enum SearchType { MOVIE = 'MOVIE', FAMOUS = 'FAMOUS', TV = 'TV', } -export type RecentSearchItem = { - title: string; - image: string; - id: number; +export type SearchItem = { + title?: string | null; + image?: string | null; + id?: number | null; +}; + +type SearchStack = FamousStackRoutes & HomeStackRoutes; + +type SearchEntryRoutes = + | Routes.Famous.SEARCH_FAMOUS + | Routes.Home.SEARCH_MOVIE + | Routes.Home.SEARCH_TV_SHOW; + +export type SearchNavigationProps = StackScreenProps< + SearchStack, + SearchEntryRoutes +>; + +export type SearchNavigationProp = StackNavigationProp< + SearchStack, + SearchEntryRoutes +>; + +export type SearchProps = { + type: SearchType; }; diff --git a/src/components/stacks/common-screens/search/use-search.ts b/src/components/stacks/common-screens/search/use-search.ts new file mode 100644 index 00000000..35a8bcac --- /dev/null +++ b/src/components/stacks/common-screens/search/use-search.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { usePagination, useTranslation } from '@/hooks'; +import { + SearchTVShows_search_items as SearchTVShowsResultItem, + SearchFamous_search_items as SearchFamousResultItem, + SearchMovies_search_items as SearchMoviesResultItem, + SearchFamousVariables, + SearchMoviesVariables, + SearchTVShowsVariables, +} from '@schema-types'; + +import { useRecentSearches } from './components/recent-searches/use-recent-searches'; +import { SearchItem, SearchNavigationProps as UseSearchParams } from './types'; +import { getSearchConfig } from './search-config/search-config'; +import { debounce } from './debounce'; + +export const SEARCH_BY_QUERY_DELAY = 1000; + +type SearchVariables = Omit< + SearchFamousVariables | SearchMoviesVariables | SearchTVShowsVariables, + 'page' +>; + +type SearchResultItem = + | SearchTVShowsResultItem + | SearchFamousResultItem + | SearchMoviesResultItem; + +type SearchResult = { + search: { + items: SearchResultItem[]; + hasMore: boolean; + }; +}; + +export const useSearch = (params: UseSearchParams) => { + const [query, setQuery] = useState(''); + + const translation = useTranslation(); + const recentSearches = useRecentSearches({ + searchType: params.route.params.type, + }); + + const searchConfig = useMemo( + () => getSearchConfig(params.route.params.type), + [params.route.params.type], + ); + + const handleOnGetData = useCallback( + (result: SearchResult) => ({ + hasMore: result.search.hasMore || false, + dataset: result.search.items || [], + }), + [], + ); + + const variables = useMemo( + () => + ({ + input: { + language: translation.currentLanguage, + query, + }, + } as SearchVariables), + [translation.currentLanguage, params.route.params, query], + ); + + const texts = useMemo( + () => ({ + placeholder: translation.translate(searchConfig.searchPlaceholder), + errors: { + entry: translation.translate(searchConfig.searchByTextError), + pagination: translation.translate(searchConfig.paginationError), + }, + }), + [translation.translate, searchConfig], + ); + + const pagination = usePagination< + SearchResult, + SearchResultItem, + SearchVariables + >({ + errorMessageIcon: 'alert-box', + fetchPolicy: 'no-cache', + entryError: texts.errors.entry, + paginationError: texts.errors.pagination, + query: searchConfig.query, + onGetData: handleOnGetData, + skipFirstRun: true, + variables, + }); + + const handleSelectItem = useCallback( + async (item: Partial) => { + await recentSearches.add(item); + searchConfig.navigateToDetails(item, params.navigation); + }, + [recentSearches.add, params.navigation], + ); + + const debouncedSetQueryString = useRef( + debounce(async (queryTyped: string) => { + if (!queryTyped && !query) { + pagination.resetState(); + } + setQuery(queryTyped.trim()); + }, SEARCH_BY_QUERY_DELAY), + ).current; + + const handleTypeSearchQuery = useCallback( + (queryString: string) => { + debouncedSetQueryString(queryString); + }, + [debouncedSetQueryString], + ); + + useEffect(() => { + if (variables.input.query) { + pagination.retryEntryQuery(); + } + }, [variables]); + + return { + shouldShowRecentSearches: + !query && + !pagination.isLoading && + !pagination.error && + !pagination.dataset.length, + onTypeSearchQuery: handleTypeSearchQuery, + placeholder: texts.placeholder, + onPressItem: handleSelectItem, + isLoading: pagination.isLoading, + items: pagination.dataset, + onEndReached: pagination.paginate, + iconImageLoading: searchConfig.iconImageLoading, + isPaginating: pagination.isPaginating, + hasPaginationError: pagination.hasPaginationError, + onPressBottomReloadButton: pagination.retryPagination, + onPressTopReloadButton: pagination.retryEntryQuery, + shouldShowBottomReloadButton: + !!pagination.dataset.length && + (pagination.hasPaginationError || pagination.isPaginating), + shouldShowTopReloadButton: + !pagination.dataset.length && !!pagination.error && !pagination.isLoading, + }; +}; diff --git a/src/components/stacks/famous/routes/route-params-types.ts b/src/components/stacks/famous/routes/route-params-types.ts index 90be3be7..c63a231c 100644 --- a/src/components/stacks/famous/routes/route-params-types.ts +++ b/src/components/stacks/famous/routes/route-params-types.ts @@ -3,10 +3,17 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { QueryTrendingFamous_trendingFamous_items } from '@schema-types'; import { Routes } from '@navigation'; +import { SearchProps } from '../../common-screens/search/types'; + export type FamousStackRoutes = { [Routes.Famous.TRENDING_FAMOUS]: undefined; - [Routes.Famous.DETAILS]: QueryTrendingFamous_trendingFamous_items; - [Routes.Famous.SEARCH]: undefined; + [Routes.Famous.DETAILS]: Omit< + QueryTrendingFamous_trendingFamous_items, + '__typename' + >; + [Routes.Famous.SEARCH_FAMOUS]: SearchProps; + [Routes.Famous.TV_SHOW_DETAILS]: undefined; + [Routes.Famous.MOVIE_DETAILS]: undefined; }; /** Trending-Famous-Props */ diff --git a/src/components/stacks/famous/routes/stack-routes.tsx b/src/components/stacks/famous/routes/stack-routes.tsx index d42d23b5..16d603cd 100644 --- a/src/components/stacks/famous/routes/stack-routes.tsx +++ b/src/components/stacks/famous/routes/stack-routes.tsx @@ -9,6 +9,7 @@ import { import { Translations } from '@i18n/tags'; import { TrendingFamous } from '../screens/trending-famous/TrendingFamous'; +import { Search } from '../../common-screens'; const Stack = createStackNavigator(); @@ -19,7 +20,7 @@ export const FamousStack = () => { ); return ( - + { name={Routes.Famous.TRENDING_FAMOUS} component={TrendingFamous} /> + null, + }} + component={Search} + /> ); }; diff --git a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.spec.tsx b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.spec.tsx index f5987add..98ee2b6d 100644 --- a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.spec.tsx +++ b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.spec.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { MockedResponse, MockedProvider } from '@apollo/client/testing'; - import { RenderAPI, act, @@ -8,8 +7,11 @@ import { render, waitFor, } from '@testing-library/react-native'; + import { AlertMessageProvider } from '@/providers'; import { Translations } from '@/i18n/tags'; +import { SearchType } from '@/components/stacks/common-screens/search/types'; +import { Routes } from '@/navigation'; import { mockTrendingFamousEntryQuerySuccessResponse, @@ -24,7 +26,6 @@ import { import { FamousNavigationProp } from '../../routes/route-params-types'; import { MockedNavigator } from '../../../../../../__mocks__'; import { TrendingFamous } from './TrendingFamous'; -import { Routes } from '@/navigation'; type TrendingFamousComponent = { navigation: FamousNavigationProp; @@ -57,8 +58,7 @@ describe('Stacks/News/Screens/TrendingFamous', () => { component.getByTestId('trending-famous-list'), headerCTA: (component: RenderAPI) => component.getByTestId('header-icon-button-wrapper-magnify'), - loading: (api: RenderAPI) => - api.queryByTestId('trending-famous-loading-list'), + loading: (api: RenderAPI) => api.queryByTestId('default-tmdb-list-loading'), alertMessageText: (api: RenderAPI) => api.queryByTestId('alert-message-text'), alertMessageWrapper: (api: RenderAPI) => @@ -568,7 +568,9 @@ describe('Stacks/News/Screens/TrendingFamous', () => { expect(navigate).toBeCalledTimes(0); fireEvent.press(elements.headerCTA(component)); expect(navigate).toBeCalledTimes(1); - expect(navigate).toBeCalledWith(Routes.Famous.SEARCH); + expect(navigate).toBeCalledWith(Routes.Famous.SEARCH_FAMOUS, { + type: SearchType.FAMOUS, + }); }); it('should navigate to "Famous-Details" when "pressing" the "some famous"', async () => { diff --git a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts index 91cb10dc..fe2f29ef 100644 --- a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts +++ b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.styles.ts @@ -1,27 +1,10 @@ import { StyleSheet } from 'react-native'; -import styled from 'styled-components/native'; -import { - DEFAULT_MARGIN_LEFT, - DEFAULT_HEIGHT, -} from '@/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles'; +import { DEFAULT_HEIGHT } from '@/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles'; import metrics from '@styles/metrics'; -export const NUMBER_OF_COLUMNS = 3; -export const NUMBER_OF_LOADING_ITEMS = Math.ceil( - NUMBER_OF_COLUMNS * (metrics.height / DEFAULT_HEIGHT) + 1, -); export const sheet = StyleSheet.create({ contentContainerStyle: { paddingTop: metrics.md, }, }); - -export const LoadingWrapper = styled.View` - flex: 1; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - padding-top: ${({ theme }) => theme.metrics.md}px; - padding-horizontal: ${DEFAULT_MARGIN_LEFT}px; -`; diff --git a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx index 3d739bcc..7f7c8e47 100644 --- a/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx +++ b/src/components/stacks/famous/screens/trending-famous/TrendingFamous.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react'; import { FlatList, Platform } from 'react-native'; import { - DefaultTMDBListItemLoadingPlaceholder, + DefaultTMDBListLoading, PaginatedListHeader, PaginatedListFooter, HeaderIconButton, @@ -68,18 +68,7 @@ export const TrendingFamous = (props: FamousProps) => { ); if (trendingFamous.isLoading) { - return ( - - {Array(Styles.NUMBER_OF_LOADING_ITEMS) - .fill({}) - .map((_, index) => ( - - ))} - - ); + return ; } return ( @@ -91,7 +80,7 @@ export const TrendingFamous = (props: FamousProps) => { android: 0.5, ios: 0.1, })} - numColumns={Styles.NUMBER_OF_COLUMNS} + numColumns={3} renderItem={({ item }) => ( trendingFamous.onPressFamous(item)} diff --git a/src/components/stacks/famous/screens/trending-famous/use-trending-famous.ts b/src/components/stacks/famous/screens/trending-famous/use-trending-famous.ts index f0f9a778..48b1b866 100644 --- a/src/components/stacks/famous/screens/trending-famous/use-trending-famous.ts +++ b/src/components/stacks/famous/screens/trending-famous/use-trending-famous.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react'; import { gql } from '@apollo/client'; +import { SearchType } from '@/components/stacks/common-screens/search/types'; import { usePagination, useTranslation } from '@/hooks'; import { QueryTrendingFamous, @@ -67,7 +68,9 @@ export const useTrendingFamous = (params: UseTrendingFamous) => { }); const handleNavigateToSearch = useCallback(() => { - params.navigation.navigate(Routes.Famous.SEARCH); + params.navigation.navigate(Routes.Famous.SEARCH_FAMOUS, { + type: SearchType.FAMOUS, + }); }, [params.navigation.navigate]); const handleSelectFamous = useCallback( diff --git a/src/components/stacks/home/routes/route-params-types.ts b/src/components/stacks/home/routes/route-params-types.ts new file mode 100644 index 00000000..9186c7de --- /dev/null +++ b/src/components/stacks/home/routes/route-params-types.ts @@ -0,0 +1,11 @@ +import { Routes } from '@/navigation'; + +import { SearchProps } from '../../common-screens/search/types'; + +export type HomeStackRoutes = { + [Routes.Home.SEARCH_MOVIE]: SearchProps; + [Routes.Home.SEARCH_TV_SHOW]: SearchProps; + [Routes.Home.TV_SHOW_DETAILS]: undefined; + [Routes.Home.FAMOUS_DETAILS]: undefined; + [Routes.Home.MOVIE_DETAILS]: undefined; +}; diff --git a/src/i18n/tags.ts b/src/i18n/tags.ts index 2d2d3388..3c0e98a1 100644 --- a/src/i18n/tags.ts +++ b/src/i18n/tags.ts @@ -81,13 +81,16 @@ export namespace Translations { } export enum TrendingFamous { - SEARCHBAR_PLACEHOLDER = 'translations:famous:search:searchBarPlaceholder', - SEARCH_PAGINATION_ERROR = 'translations:famous:search:errors:pagination', - SEARCH_ENTRY_ERROR = 'translations:famous:search:errors:entry', ENTRY_ERROR = 'translations:famous:errors:entry', PAGINATION_ERROR = 'translations:famous:errors:pagination', } + export enum SearchFamous { + SEARCHBAR = 'translations:famous:search:searchBarPlaceholder', + PAGINATION_ERROR = 'translations:famous:search:errors:pagination', + ENTRY_ERROR = 'translations:famous:search:errors:entry', + } + export enum Search { SEARCH_RECENT = 'translations:recentSearches', } @@ -99,5 +102,6 @@ export namespace Translations { | Quiz | Error | TrendingFamous - | Search; + | Search + | SearchFamous; } diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index 10fdd598..0e53d194 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -33,7 +33,7 @@ export namespace Routes { TV_SHOW_SEASONS = 'APP/FAMOUS/TV_SHOW/SEASONS', MOVIE_DETAILS = 'APP/FAMOUS/MOVIE/DETAILS', MEDIA_REVIEWS = 'APP/FAMOUS/MEDIA_REVIEWS', - SEARCH = 'APP/FAMOUS/SEARCH', + SEARCH_FAMOUS = 'APP/FAMOUS/SEARCH', } export enum Quiz { From b43c008bb743268336ad800b612121014b9e9fef Mon Sep 17 00:00:00 2001 From: Stenio Wagner Date: Mon, 6 Nov 2023 17:31:46 -0300 Subject: [PATCH 13/13] test: creating the Search --- __mocks__/index.ts | 1 + __mocks__/search.ts | 193 +++ .../common-screens/search/Search.spec.tsx | 1336 +++++++++++++++++ .../stacks/common-screens/search/Search.tsx | 2 +- .../RecentSearchesListItem.styles.ts | 2 +- .../RecentSearchesListItem.tsx | 4 +- .../recent-searches/use-recent-searches.ts | 6 +- .../search-config/search-famous-config.ts | 2 +- .../search-config/search-movies-config.ts | 6 +- .../search-config/search-tv-shows-config.ts | 6 +- .../stacks/common-screens/search/types.ts | 2 +- .../tab-navigator/use-tab-navigator.ts | 2 +- 12 files changed, 1546 insertions(+), 16 deletions(-) create mode 100644 __mocks__/search.ts create mode 100644 src/components/stacks/common-screens/search/Search.spec.tsx diff --git a/__mocks__/index.ts b/__mocks__/index.ts index d875713b..58735378 100644 --- a/__mocks__/index.ts +++ b/__mocks__/index.ts @@ -4,3 +4,4 @@ export { MockedNavigator } from './MockedNavigator'; export * from './news'; export * from './quiz-questions'; export * from './trending-famous'; +export * from './search'; diff --git a/__mocks__/search.ts b/__mocks__/search.ts new file mode 100644 index 00000000..c75c69ef --- /dev/null +++ b/__mocks__/search.ts @@ -0,0 +1,193 @@ +import { DocumentNode, GraphQLError } from 'graphql'; + +import { SEARCH_FAMOUS_QUERY } from '@/components/stacks/common-screens/search/search-config/search-famous-config'; +import { SEARCH_MOVIES_QUERY } from '@/components/stacks/common-screens/search/search-config/search-movies-config'; +import { SEARCH_TV_SHOWS_QUERY } from '@/components/stacks/common-screens/search/search-config/search-tv-shows-config'; +import { DEFAULT_HEIGHT } from '@/components/common/default-tmdb-list-item/DefaultTMDBListItem.styles'; +import { + SearchType, + SearchItem, +} from '@/components/stacks/common-screens/search/types'; +import metrics from '@/styles/metrics'; +import { ISO6391Language } from '@/types/schema'; +import { getErrorType } from './utils'; + +type BaseMockSearchQueryResponseParams = { + items: SearchItem[]; + type: SearchType; + page: number; + hasMore: boolean; + query: string; +}; + +export const SEARCH_ITEMS_PER_PAGE = Math.floor( + metrics.height / DEFAULT_HEIGHT, +); + +export const searchItemsList = (page = 1) => + Array(SEARCH_ITEMS_PER_PAGE) + .fill({}) + .map((_, index) => ({ + image: `page${page}-image-${index}`, + title: `page${page}-title-${index}-${page}`, + id: page * SEARCH_ITEMS_PER_PAGE + index, + })) as SearchItem[]; + +const baseMockSearchQueryResponse = ( + params: BaseMockSearchQueryResponseParams, +) => { + const searchTypeQueryMapping: Record = { + [SearchType.FAMOUS]: SEARCH_FAMOUS_QUERY, + [SearchType.MOVIE]: SEARCH_MOVIES_QUERY, + [SearchType.TV]: SEARCH_TV_SHOWS_QUERY, + }; + const request = { + request: { + query: searchTypeQueryMapping[params.type], + variables: { + input: { + language: ISO6391Language.en, + page: params.page, + query: params.query, + }, + }, + }, + }; + const result = { + result: { + data: { + search: { + hasMore: params.hasMore, + items: params.items, + }, + }, + }, + }; + + const responseWithNetworkError = { + ...request, + error: new Error('A Network error occurred'), + }; + + const responseWithGraphQLError = { + ...request, + errors: [new GraphQLError('A GraphQL error occurred')], + }; + + return { + responseWithGraphQLError, + responseWithNetworkError, + request, + result, + }; +}; + +type MockSearchEntryQuerySuccessResponseParams = { + hasMore: boolean; + type: SearchType; + query: string; +}; + +export const mockSearchEntryQuerySuccessResponse = ( + params: MockSearchEntryQuerySuccessResponseParams, +) => { + const entryQueryResult = baseMockSearchQueryResponse({ + items: searchItemsList(), + page: 1, + query: params.query, + type: params.type, + hasMore: params.hasMore, + }); + return [ + { + ...entryQueryResult.request, + ...entryQueryResult.result, + }, + ]; +}; + +export const mockSearchEntryQueryErrorResponse = ( + params: MockSearchEntryQuerySuccessResponseParams, +) => { + const error = getErrorType(); + const entryQueryResult = baseMockSearchQueryResponse({ + items: [], + page: 1, + query: params.query, + type: params.type, + hasMore: false, + }); + const errorResponse = + error === 'network' + ? entryQueryResult.responseWithNetworkError + : entryQueryResult.responseWithGraphQLError; + return [ + { + ...entryQueryResult.request, + ...errorResponse, + }, + ]; +}; + +export const mockSearchPaginationQuerySuccessResponse = ( + params: MockSearchEntryQuerySuccessResponseParams, +) => { + const entryQueryResult = baseMockSearchQueryResponse({ + items: searchItemsList(), + page: 1, + query: params.query, + type: params.type, + hasMore: params.hasMore, + }); + const paginationQueryResult = baseMockSearchQueryResponse({ + items: searchItemsList(2), + page: 2, + query: params.query, + type: params.type, + hasMore: params.hasMore, + }); + return [ + { + ...entryQueryResult.request, + ...entryQueryResult.result, + }, + { + ...paginationQueryResult.request, + ...paginationQueryResult.result, + }, + ]; +}; + +export const mockSearchPaginationQueryErrorResponse = ( + params: MockSearchEntryQuerySuccessResponseParams, +) => { + const error = getErrorType(); + const entryQueryResult = baseMockSearchQueryResponse({ + items: searchItemsList(), + page: 1, + query: params.query, + type: params.type, + hasMore: params.hasMore, + }); + const paginationQueryResult = baseMockSearchQueryResponse({ + items: [], + page: 2, + query: params.query, + type: params.type, + hasMore: false, + }); + const errorResponse = + error === 'network' + ? paginationQueryResult.responseWithNetworkError + : paginationQueryResult.responseWithGraphQLError; + return [ + { + ...entryQueryResult.request, + ...entryQueryResult.result, + }, + { + ...paginationQueryResult.request, + ...errorResponse, + }, + ]; +}; diff --git a/src/components/stacks/common-screens/search/Search.spec.tsx b/src/components/stacks/common-screens/search/Search.spec.tsx new file mode 100644 index 00000000..d27117a9 --- /dev/null +++ b/src/components/stacks/common-screens/search/Search.spec.tsx @@ -0,0 +1,1336 @@ +import React from 'react'; +import { + RenderAPI, + act, + fireEvent, + render, + waitFor, +} from '@testing-library/react-native'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; + +import { AlertMessageProvider, TMDBImageQualitiesProvider } from '@/providers'; +import { Translations } from '@/i18n/tags'; +import { Routes } from '@/navigation'; + +import { BASE_STORAGE_KEY } from './components/recent-searches/use-recent-searches'; +import { + mockSearchEntryQuerySuccessResponse, + mockSearchEntryQueryErrorResponse, + MockedNavigator, + randomPositiveNumber, + SEARCH_ITEMS_PER_PAGE, + searchItemsList, + mockSearchPaginationQuerySuccessResponse, + mockSearchPaginationQueryErrorResponse, +} from '../../../../../__mocks__'; +import { SearchNavigationProps as SearchProps } from './types'; +import { SearchEntryRoutes, SearchType } from './types'; +import { Search } from './Search'; + +const searchTypes = Object.keys(SearchType) as SearchType[]; +const query = 'SOME_QUERY'; + +const searchTypeNavigationParamsMapping = ( + searchType: SearchType, + indexItemSelected: number, +) => { + const mapping: Record = { + [SearchType.FAMOUS]: [ + Routes.Famous.DETAILS, + { + profilePath: searchItemsList()[indexItemSelected].image, + name: searchItemsList()[indexItemSelected].title, + id: searchItemsList()[indexItemSelected].id, + }, + ], + [SearchType.MOVIE]: [Routes.Home.MOVIE_DETAILS], + [SearchType.TV]: [Routes.Home.TV_SHOW_DETAILS], + }; + return mapping[searchType]; +}; + +const searchTypeEntryErrorMapping: Record = { + [SearchType.FAMOUS]: Translations.SearchFamous.ENTRY_ERROR, + [SearchType.MOVIE]: Translations.TrendingFamous.ENTRY_ERROR, + [SearchType.TV]: Translations.TrendingFamous.ENTRY_ERROR, +}; + +jest.mock('@utils', () => ({ + isEqualsOrLargerThanIphoneX: jest.fn().mockReturnValue(true), + getStatusBarHeight: jest.fn().mockReturnValue(10), + renderSVGIconConditionally: () => <>, + storage: { + set: jest.fn(), + get: jest.fn(), + }, +})); + +const utils = require('@/utils'); + +type RenderSearchProps = { + mocks: readonly MockedResponse>[]; + searchType: SearchType; + navigate?: jest.Mock; + goBack?: jest.Mock; +}; + +const renderSearch = (props: RenderSearchProps) => { + const searchTypeRouteMapping: Record = { + [SearchType.FAMOUS]: Routes.Famous.SEARCH_FAMOUS, + [SearchType.MOVIE]: Routes.Home.SEARCH_MOVIE, + [SearchType.TV]: Routes.Home.SEARCH_TV_SHOW, + }; + const route = searchTypeRouteMapping[props.searchType]; + const SearchComponent = (searchComponentProps: SearchProps) => ( + + + + + + + + ); + return ; +}; + +describe('Common-screens/Search', () => { + const elements = { + recentSearchesList: (api: RenderAPI) => + api.queryAllByTestId('recent-searches-list'), + recentSearches: (api: RenderAPI) => + api.queryAllByTestId('recent-searches-list-item-button'), + headerCloseButton: (api: RenderAPI) => + api.getByTestId('header-icon-button-wrapper-close'), + searchItems: (api: RenderAPI) => api.queryAllByTestId('search-item'), + searchInput: (api: RenderAPI) => api.getByTestId('search-input'), + loading: (api: RenderAPI) => api.queryByTestId('default-tmdb-list-loading'), + alertMessageText: (api: RenderAPI) => + api.queryByTestId('alert-message-text'), + alertMessageWrapper: (api: RenderAPI) => + api.queryByTestId('alert-message-wrapper'), + alertMessageIcon: (api: RenderAPI) => + api.queryByTestId('alert-message-icon'), + paginationFooter: (api: RenderAPI) => + api.queryByTestId('pagination-footer-wrapper'), + paginationLoading: (api: RenderAPI) => + api.queryByTestId('pagination-loading-footer-wrapper'), + paginationReload: (api: RenderAPI) => + api.queryByTestId('pagination-footer-reload-button'), + topReloadButton: (api: RenderAPI) => api.queryByTestId('top-reload-button'), + searchList: (api: RenderAPI) => api.queryByTestId('search-list'), + searchItemsTitles: (api: RenderAPI) => api.queryAllByTestId('title-text'), + }; + + describe('Entry-query', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should show the "loading-state" when it is querying for %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.loading(component)).not.toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should reset the "search-list" when change the "query" to "" and it is querying for %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', ''); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual(0); + }); + }, + ); + + describe('When querying successfuly', () => { + test.each(searchTypes)( + 'should show the items %p-items correctly', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + }, + ); + }); + + describe('When querying with some error', () => { + test.each(searchTypes)( + 'should show the "alert-error-message" correctly when "search-type" is %p', + async searchType => { + const mocks = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageText(component)).not.toBeNull(); + expect(elements.alertMessageText(component)!.children[0]).toEqual( + searchTypeEntryErrorMapping[searchType], + ); + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.loading(component)).toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should show the "top-reload" button when "search-type" is %p', + async searchType => { + const mocks = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.topReloadButton(component)).not.toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should not show any "search-list-item" when "search-type" is %p', + async searchType => { + const mocks = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual(0); + }); + }, + ); + }); + + describe('Retrying', () => { + test.each(searchTypes)( + 'should show the "loading-state" when it is retrying to search for %p', + async searchType => { + const mocks = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.topReloadButton(component)).not.toBeNull(); + }); + act(() => { + fireEvent.press(elements.topReloadButton(component)!); + }); + await waitFor(() => { + expect(elements.loading(component)).not.toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should show the "search-items" when it successfuly retry to search for %p', + async searchType => { + const entryQuery = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const retryQuery = mockSearchEntryQuerySuccessResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks: [...entryQuery, ...retryQuery], + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.topReloadButton(component)).not.toBeNull(); + }); + expect(elements.searchItems(component).length).toEqual(0); + act(() => { + fireEvent.press(elements.topReloadButton(component)!); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + }, + ); + + test.each(searchTypes)( + 'should show the "alert-error-message" when the "retry-response" returns "another error" for %p', + async searchType => { + const entryQuery = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const retryQuery = mockSearchEntryQueryErrorResponse({ + hasMore: false, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks: [...entryQuery, ...retryQuery], + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.topReloadButton(component)).not.toBeNull(); + }); + act(() => { + fireEvent.press(elements.topReloadButton(component)!); + }); + await waitFor(() => { + expect(elements.alertMessageText(component)).not.toBeNull(); + expect(elements.alertMessageText(component)!.children[0]).toEqual( + searchTypeEntryErrorMapping[searchType], + ); + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.loading(component)).toBeNull(); + }); + }, + ); + }); + }); + + describe('Pagination-query', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should show the "paginating-state" when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + expect(elements.paginationFooter(component)).not.toBeNull(); + expect(elements.paginationLoading(component)).not.toBeNull(); + await waitFor(() => {}); + }, + ); + + describe('When paginating successfuly', () => { + test.each(searchTypes)( + 'should render correctly when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageWrapper(component)).toBeNull(); + expect(elements.loading(component)).toBeNull(); + expect(elements.topReloadButton(component)).toBeNull(); + expect(elements.paginationFooter(component)).toBeNull(); + expect(elements.searchList(component)).not.toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should render the "news-list" with the correct size when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component)!.length).toEqual( + SEARCH_ITEMS_PER_PAGE * 2, + ); + }); + }, + ); + + test.each(searchTypes)( + 'should render the "news-list items" correctly when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component)!.length).toEqual( + SEARCH_ITEMS_PER_PAGE * 2, + ); + }); + for (let i = 0; i < SEARCH_ITEMS_PER_PAGE * 2; i++) { + if (i < SEARCH_ITEMS_PER_PAGE) { + expect( + ( + elements.searchItemsTitles(component)[i]! + .children[0] as string + ).startsWith('page1'), + ).toEqual(true); + } else { + expect( + ( + elements.searchItemsTitles(component)[i]! + .children[0] as string + ).startsWith('page2'), + ).toEqual(true); + } + } + }, + ); + }); + + describe('When paginating with errors', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should show the "alert-message-error" correctly when it is querying for %p', + async searchType => { + const searchTypePaginationErrorMapping: Record = { + [SearchType.FAMOUS]: Translations.SearchFamous.PAGINATION_ERROR, + [SearchType.MOVIE]: Translations.TrendingFamous.PAGINATION_ERROR, + [SearchType.TV]: Translations.TrendingFamous.PAGINATION_ERROR, + }; + const mocks = mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageText(component)).not.toBeNull(); + expect(elements.alertMessageText(component)!.children[0]).toEqual( + searchTypePaginationErrorMapping[searchType], + ); + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.loading(component)).toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should "keep" the "items" received from the "entry-query" when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + }, + ); + + test.each(searchTypes)( + 'should show the "bottom-reload-pagination" when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.paginationReload(component)).not.toBeNull(); + }); + }, + ); + }); + + describe('Retrying', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should show the "pagination-state" when "retrying" to "paginate" when it is querying for %p', + async searchType => { + const mocks = mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.paginationReload(component)).not.toBeNull(); + }); + act(() => { + fireEvent.press(elements.paginationReload(component)!); + }); + await waitFor(() => { + expect(elements.paginationLoading(component)).not.toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should show the "news-list-items" correctly when the "retry-response" returns "success" when it is querying for %p', + async searchType => { + const mocks = [ + ...mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }), + ...mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }), + ...mockSearchPaginationQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }), + ]; + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.paginationReload(component)).not.toBeNull(); + }); + act(() => { + fireEvent.press(elements.paginationReload(component)!); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE * 2, + ); + }); + for (let i = 0; i < SEARCH_ITEMS_PER_PAGE * 2; i++) { + if (i < SEARCH_ITEMS_PER_PAGE) { + expect( + ( + elements.searchItemsTitles(component)[i]! + .children[0] as string + ).startsWith('page1'), + ).toEqual(true); + } else { + expect( + ( + elements.searchItemsTitles(component)[i]! + .children[0] as string + ).startsWith('page2'), + ).toEqual(true); + } + } + }, + ); + + test.each(searchTypes)( + 'should show the "alert-error-message" when the "retry-response" returns "another error" and it is querying for %p', + async searchType => { + const searchTypePaginationErrorMapping: Record = { + [SearchType.FAMOUS]: Translations.SearchFamous.PAGINATION_ERROR, + [SearchType.MOVIE]: Translations.TrendingFamous.PAGINATION_ERROR, + [SearchType.TV]: Translations.TrendingFamous.PAGINATION_ERROR, + }; + const mocks = [ + ...mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }), + ...mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }), + ...mockSearchPaginationQueryErrorResponse({ + hasMore: true, + type: searchType, + query, + }), + ]; + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + act(() => { + fireEvent(elements.searchList(component)!, 'onEndReached'); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageText(component)).not.toBeNull(); + expect(elements.alertMessageText(component)!.children[0]).toEqual( + searchTypePaginationErrorMapping[searchType], + ); + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.loading(component)).toBeNull(); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageWrapper(component)).toBeNull(); + }); + act(() => { + fireEvent.press(elements.paginationReload(component)!); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.alertMessageText(component)).not.toBeNull(); + expect(elements.alertMessageText(component)!.children[0]).toEqual( + searchTypePaginationErrorMapping[searchType], + ); + expect(elements.alertMessageWrapper(component)).not.toBeNull(); + expect(elements.loading(component)).toBeNull(); + }); + }, + ); + }); + }); + + describe('Recent searches', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(searchTypes)( + 'should show "recent-searches" when has some "previously-searched-items" for %p', + async searchType => { + (utils.storage.get as jest.Mock).mockResolvedValueOnce([ + { + title: 'PERSISTED_ITEM_TITLE', + image: 'PERSISTED_ITEM_IMAGE', + id: 10, + }, + ]); + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query: 'SOME_QUERY', + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + await waitFor(() => { + expect(elements.recentSearchesList(component)).not.toBeNull(); + expect(elements.searchList(component)).toBeNull(); + }); + }, + ); + + test.each(searchTypes)( + 'should not show "recent-searches" when has no "previously-searched-items" for %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query: 'SOME_QUERY', + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + await waitFor(() => { + expect(elements.recentSearchesList(component).length).toEqual(0); + }); + }, + ); + + describe('Persisting items to "recent-searches-list"', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should persist the selected item into the "recent-searched-items" when "recent-searched-list" is empty for %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const component = render( + renderSearch({ + searchType, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + const indexItemSelected = randomPositiveNumber( + elements.searchItems(component).length - 1, + ); + expect(utils.storage.set).toBeCalledTimes(0); + act(() => { + fireEvent.press(elements.searchItems(component)[indexItemSelected]); + }); + await waitFor(() => { + expect(utils.storage.set).toBeCalledTimes(1); + expect(utils.storage.set).toBeCalledWith( + `${BASE_STORAGE_KEY}:${searchType}`, + [ + { + image: searchItemsList()[indexItemSelected].image, + title: searchItemsList()[indexItemSelected].title, + id: searchItemsList()[indexItemSelected].id, + }, + ], + ); + }); + }, + ); + + test.each(searchTypes)( + 'should persist a "selected-item" that was already "previously searched" correctly for %p', + async searchType => { + (utils.storage.get as jest.Mock).mockResolvedValue([ + searchItemsList()[0], + searchItemsList()[1], + ]); + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const component = render( + renderSearch({ + searchType, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + act(() => { + fireEvent.press(elements.searchItems(component)[1]); + }); + await waitFor(() => { + expect(utils.storage.set).toBeCalledTimes(1); + }); + expect(utils.storage.set).lastCalledWith( + `${BASE_STORAGE_KEY}:${searchType}`, + [ + { + image: searchItemsList()[1].image, + title: searchItemsList()[1].title, + id: searchItemsList()[1].id, + }, + { + image: searchItemsList()[0].image, + title: searchItemsList()[0].title, + id: searchItemsList()[0].id, + }, + ], + ); + }, + ); + + test.each(searchTypes)( + 'should persist the selected item into the "recent-searched-items" when the item was never searched before is empty for %p', + async searchType => { + (utils.storage.get as jest.Mock).mockResolvedValue([ + searchItemsList()[0], + searchItemsList()[1], + ]); + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const component = render( + renderSearch({ + searchType, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toEqual( + SEARCH_ITEMS_PER_PAGE, + ); + }); + act(() => { + fireEvent.press(elements.searchItems(component)[2]); + }); + await waitFor(() => { + expect(utils.storage.set).toBeCalledTimes(1); + }); + expect(utils.storage.set).lastCalledWith( + `${BASE_STORAGE_KEY}:${searchType}`, + [ + { + image: searchItemsList()[2].image, + title: searchItemsList()[2].title, + id: searchItemsList()[2].id, + }, + { + image: searchItemsList()[0].image, + title: searchItemsList()[0].title, + id: searchItemsList()[0].id, + }, + { + image: searchItemsList()[1].image, + title: searchItemsList()[1].title, + id: searchItemsList()[1].id, + }, + ], + ); + }, + ); + }); + }); + + describe('Navigating', () => { + describe('To "details-screen"', () => { + describe('From "recent-searches"', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(searchTypes)( + 'should navigate to "details-screen" correctly when "search-type" is %p', + async searchType => { + const persistedItems = [ + { + title: 'PERSISTED_ITEM_TITLE', + image: 'PERSISTED_ITEM_IMAGE', + id: 10, + }, + ]; + (utils.storage.get as jest.Mock).mockResolvedValueOnce( + persistedItems, + ); + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query: 'SOME_QUERY', + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + await waitFor(() => { + expect(elements.recentSearchesList(component)).not.toBeNull(); + }); + expect(navigate).toBeCalledTimes(0); + fireEvent.press(elements.recentSearches(component)[0]); + const mockCallParams: Record = { + [SearchType.FAMOUS]: [ + Routes.Famous.DETAILS, + { + profilePath: persistedItems[0].image, + name: persistedItems[0].title, + id: persistedItems[0].id, + }, + ], + [SearchType.MOVIE]: [Routes.Home.MOVIE_DETAILS], + [SearchType.TV]: [Routes.Home.TV_SHOW_DETAILS], + }; + await waitFor(() => { + expect(navigate).toBeCalledTimes(1); + expect(navigate).toBeCalledWith( + mockCallParams[searchType][0], + mockCallParams[searchType][1], + ); + }); + }, + ); + }); + + describe('From the "search-list"', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test.each(searchTypes)( + 'should navigate to "details-screen" correctly when "search-type" is %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query, + }); + const navigate = jest.fn(); + const component = render( + renderSearch({ + searchType, + navigate, + mocks, + }), + ); + act(() => { + fireEvent(elements.searchInput(component), 'onChangeText', query); + }); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(elements.searchItems(component).length).toBeGreaterThan(0); + }); + const indexItemSelected = randomPositiveNumber( + elements.searchItems(component).length - 1, + ); + expect(navigate).toBeCalledTimes(0); + fireEvent.press(elements.searchItems(component)[indexItemSelected]); + await waitFor(() => { + expect(navigate).toBeCalledTimes(1); + }); + const mockCallParams = searchTypeNavigationParamsMapping( + searchType, + indexItemSelected, + ); + await waitFor(() => { + expect(navigate).toBeCalledTimes(1); + expect(navigate).toBeCalledWith( + mockCallParams[0], + mockCallParams[1], + ); + }); + }, + ); + }); + }); + + describe('Navigating back to the previous screen', () => { + describe('When pressing the "header-icon-close"', () => { + test.each(searchTypes)( + 'should call "goBack" correctly when "search-type" is %p', + async searchType => { + const mocks = mockSearchEntryQuerySuccessResponse({ + hasMore: true, + type: searchType, + query: 'SOME_QUERY', + }); + const goBack = jest.fn(); + const component = render( + renderSearch({ + searchType, + goBack, + mocks, + }), + ); + expect(goBack).toBeCalledTimes(0); + fireEvent.press(elements.headerCloseButton(component)); + expect(goBack).toBeCalledTimes(1); + await waitFor(() => {}); + }, + ); + }); + }); + }); +}); diff --git a/src/components/stacks/common-screens/search/Search.tsx b/src/components/stacks/common-screens/search/Search.tsx index 4b84e6a9..aea40851 100644 --- a/src/components/stacks/common-screens/search/Search.tsx +++ b/src/components/stacks/common-screens/search/Search.tsx @@ -94,7 +94,7 @@ export const Search = (props: SearchProps) => { )} onEndReached={search.onEndReached} keyExtractor={({ id }) => `${id}`} - testID="famous-list" + testID="search-list" data={search.items} /> ); diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts index f07bdc13..a7a23b01 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.styles.ts @@ -28,7 +28,7 @@ export const FallbackImageWrapper = styled(Animated.View)` justify-content: center; align-items: center; position: absolute; - border-radius: ${({ theme }) => theme.metrics.sm}px; + border-radius: ${({ theme }) => theme.borderRadius.sm}px; background-color: ${({ theme }) => theme.colors.fallbackImageBackground}; `; diff --git a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx index d93abe25..6c108e3f 100644 --- a/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx +++ b/src/components/stacks/common-screens/search/components/recent-searches/recent-searchers-list-item/RecentSearchesListItem.tsx @@ -24,12 +24,12 @@ export const RecentSearchesListItem = (props: RecentSearchesListItemProps) => ( imageType="profile" style={Styles.sheet.image} iconSize={Styles.DEFAULT_ICON_SIZE} - image={props.item.image} + image={props.item.image || ''} testID="recent-searches-list-item-image" /> - {props.item.title} + {props.item.title || '-'} { return await storage.set(storageKey, [item]); } let recentSearchesUpdated = [item, ...recentSearchesFromStorage]; - const isItemSearchedBefore = recentSearches.some( + const isItemSearchedBefore = recentSearchesFromStorage.some( recentSearch => recentSearch.id === item.id, ); if (isItemSearchedBefore) { recentSearchesUpdated = [ item, - ...recentSearches.filter( + ...recentSearchesFromStorage.filter( persistedItem => persistedItem.id !== item.id, ), ]; diff --git a/src/components/stacks/common-screens/search/search-config/search-famous-config.ts b/src/components/stacks/common-screens/search/search-config/search-famous-config.ts index 13b6bc1b..eb2a6634 100644 --- a/src/components/stacks/common-screens/search/search-config/search-famous-config.ts +++ b/src/components/stacks/common-screens/search/search-config/search-famous-config.ts @@ -6,7 +6,7 @@ import { Routes } from '@/navigation'; import { SearchItem, SearchNavigationProp } from '../types'; -const SEARCH_FAMOUS_QUERY = gql` +export const SEARCH_FAMOUS_QUERY = gql` query SearchFamous($input: SearchInput!) { search: searchFamous(input: $input) { items { diff --git a/src/components/stacks/common-screens/search/search-config/search-movies-config.ts b/src/components/stacks/common-screens/search/search-config/search-movies-config.ts index fb6de99c..0ca1217c 100644 --- a/src/components/stacks/common-screens/search/search-config/search-movies-config.ts +++ b/src/components/stacks/common-screens/search/search-config/search-movies-config.ts @@ -6,7 +6,7 @@ import { Routes } from '@/navigation'; import { SearchItem, SearchNavigationProp } from '../types'; -const SEARCH_MOVIES_QUERY = gql` +export const SEARCH_MOVIES_QUERY = gql` query SearchMovies($input: SearchInput!) { search: searchMovies(input: $input) { items { @@ -24,11 +24,11 @@ export const searchMoviesConfig = () => ({ searchItem: SearchItem, navigation: SearchNavigationProp, ) => { - navigation.navigate(Routes.Home.MOVIE_DETAILS); + navigation.navigate(Routes.Home.MOVIE_DETAILS, undefined); }, searchPlaceholder: Translations.TrendingFamous.ENTRY_ERROR, searchByTextError: Translations.TrendingFamous.ENTRY_ERROR, - paginationError: Translations.TrendingFamous.ENTRY_ERROR, + paginationError: Translations.TrendingFamous.PAGINATION_ERROR, query: SEARCH_MOVIES_QUERY, iconImageLoading: 'video-vintage' as Icons, }); diff --git a/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts b/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts index 43c34d43..da1d136b 100644 --- a/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts +++ b/src/components/stacks/common-screens/search/search-config/search-tv-shows-config.ts @@ -6,7 +6,7 @@ import { Routes } from '@/navigation'; import { SearchItem, SearchNavigationProp } from '../types'; -const SEARCH_TV_SHOWS_QUERY = gql` +export const SEARCH_TV_SHOWS_QUERY = gql` query SearchTVShows($input: SearchInput!) { search: searchTVShows(input: $input) { hasMore @@ -24,11 +24,11 @@ export const searchTVShowsConfig = () => ({ searchItem: SearchItem, navigation: SearchNavigationProp, ) => { - navigation.navigate(Routes.Home.TV_SHOW_DETAILS); + navigation.navigate(Routes.Home.TV_SHOW_DETAILS, undefined); }, searchPlaceholder: Translations.TrendingFamous.ENTRY_ERROR, searchByTextError: Translations.TrendingFamous.ENTRY_ERROR, - paginationError: Translations.TrendingFamous.ENTRY_ERROR, + paginationError: Translations.TrendingFamous.PAGINATION_ERROR, query: SEARCH_TV_SHOWS_QUERY, iconImageLoading: 'video-vintage' as Icons, }); diff --git a/src/components/stacks/common-screens/search/types.ts b/src/components/stacks/common-screens/search/types.ts index 3547bc85..a0691bd3 100644 --- a/src/components/stacks/common-screens/search/types.ts +++ b/src/components/stacks/common-screens/search/types.ts @@ -19,7 +19,7 @@ export type SearchItem = { type SearchStack = FamousStackRoutes & HomeStackRoutes; -type SearchEntryRoutes = +export type SearchEntryRoutes = | Routes.Famous.SEARCH_FAMOUS | Routes.Home.SEARCH_MOVIE | Routes.Home.SEARCH_TV_SHOW; diff --git a/src/navigation/components/tab-navigator/use-tab-navigator.ts b/src/navigation/components/tab-navigator/use-tab-navigator.ts index 99900dd9..66b417ce 100644 --- a/src/navigation/components/tab-navigator/use-tab-navigator.ts +++ b/src/navigation/components/tab-navigator/use-tab-navigator.ts @@ -37,7 +37,7 @@ export const useTabNavigator = (props: BottomTabBarProps) => { const shouldShowTabNavigator = useMemo(() => { const screensAbleToShowTabNavigator: string[] = [ Routes.Home.HOME, - Routes.Famous.FAMOUS, + Routes.Famous.TRENDING_FAMOUS, Routes.Quiz.QUIZ, Routes.News.NEWS, ];