diff --git a/src/features/chronicle/components/ChronicleCard/ChronicleCard.stories.tsx b/src/features/chronicle/components/ChronicleCard/ChronicleCard.stories.tsx index 5e078552250..9e306d8ef45 100644 --- a/src/features/chronicle/components/ChronicleCard/ChronicleCard.stories.tsx +++ b/src/features/chronicle/components/ChronicleCard/ChronicleCard.stories.tsx @@ -1,7 +1,9 @@ import { ComponentMeta } from '@storybook/react' import React from 'react' +import { View } from 'react-native' import { ChronicleCard } from 'features/chronicle/components/ChronicleCard/ChronicleCard' +import { ButtonTertiaryBlack } from 'ui/components/buttons/ButtonTertiaryBlack' import { VariantsTemplate, type Variants, type VariantsStory } from 'ui/storybook/VariantsTemplate' const meta: ComponentMeta = { @@ -24,6 +26,17 @@ const variantConfig: Variants = [ label: 'ChronicleCard default', props: { ...baseProps }, }, + { + label: 'ChronicleCard with see more button', + props: { + ...baseProps, + children: ( + + + + ), + }, + }, ] const Template: VariantsStory = (args) => ( diff --git a/src/features/chronicle/components/ChronicleCard/ChronicleCard.tsx b/src/features/chronicle/components/ChronicleCard/ChronicleCard.tsx index 8d8c1200c1f..2e9a98e67f8 100644 --- a/src/features/chronicle/components/ChronicleCard/ChronicleCard.tsx +++ b/src/features/chronicle/components/ChronicleCard/ChronicleCard.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react' +import React, { FunctionComponent, PropsWithChildren } from 'react' import styled from 'styled-components/native' import { ChronicleCardData } from 'features/chronicle/type' @@ -10,9 +10,11 @@ import { TypoDS, getShadow, getSpacing } from 'ui/theme' const CHRONICLE_THUMBNAIL_SIZE = getSpacing(14) -type Props = ChronicleCardData & { - cardWidth?: number -} +type Props = PropsWithChildren< + ChronicleCardData & { + cardWidth?: number + } +> export const ChronicleCard: FunctionComponent = ({ id, @@ -21,6 +23,7 @@ export const ChronicleCard: FunctionComponent = ({ description, date, cardWidth, + children, }) => { return ( @@ -32,7 +35,10 @@ export const ChronicleCard: FunctionComponent = ({ /> {description} - {date} + + {date} + {children} + ) } @@ -58,6 +64,12 @@ const Description = styled(TypoDS.BodyAccentS)(({ theme }) => ({ flexGrow: 1, })) +const BottomCardContainer = styled.View({ + flexDirection: 'row', + justifyContent: 'space-between', +}) + const PublicationDate = styled(TypoDS.BodyAccentXs)(({ theme }) => ({ color: theme.colors.greyDark, + alignSelf: 'center', })) diff --git a/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx b/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx index 93c0395c976..a6bc158eb10 100644 --- a/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx +++ b/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx @@ -1,5 +1,5 @@ import colorAlpha from 'color-alpha' -import React, { FunctionComponent, useRef } from 'react' +import React, { forwardRef, useImperativeHandle, useRef } from 'react' import { NativeScrollEvent, NativeSyntheticEvent, useWindowDimensions, View } from 'react-native' import { FlatList } from 'react-native-gesture-handler' import LinearGradient from 'react-native-linear-gradient' @@ -7,6 +7,7 @@ import { useTheme } from 'styled-components' import styled from 'styled-components/native' import { CHRONICLE_CARD_WIDTH } from 'features/chronicle/constant' +import { ChronicleCardData } from 'features/chronicle/type' import { useHorizontalFlatListScroll } from 'ui/hooks/useHorizontalFlatListScroll' import { PlaylistArrowButton } from 'ui/Playlist/PlaylistArrowButton' @@ -16,21 +17,34 @@ import { SEPARATOR_DEFAULT_VALUE, } from './ChronicleCardListBase' -export const ChronicleCardList: FunctionComponent = ({ - data, - horizontal = true, - cardWidth, - contentContainerStyle, - headerComponent, - separatorSize = SEPARATOR_DEFAULT_VALUE, - onScroll, - style, -}) => { +export const ChronicleCardList = forwardRef< + Partial>, + ChronicleCardListProps +>(function ChronicleCardList( + { + data, + horizontal = true, + cardWidth, + contentContainerStyle, + onScroll, + headerComponent, + style, + separatorSize = SEPARATOR_DEFAULT_VALUE, + onSeeMoreButtonPress, + onLayout, + }, + ref +) { const { isDesktopViewport } = useTheme() const { width: windowWidth } = useWindowDimensions() const listRef = useRef(null) + useImperativeHandle(ref, () => ({ + scrollToOffset: (params) => listRef.current?.scrollToOffset(params), + scrollToIndex: (params) => listRef.current?.scrollToIndex(params), + })) + const { onScroll: internalScrollHandler, handleScrollNext, @@ -86,10 +100,12 @@ export const ChronicleCardList: FunctionComponent = ({ separatorSize={separatorSize} contentContainerStyle={contentContainerStyle} snapToInterval={isDesktopViewport ? CHRONICLE_CARD_WIDTH : undefined} + onSeeMoreButtonPress={onSeeMoreButtonPress} + onLayout={onLayout} /> ) -} +}) const ArrowWrapper = styled.View.attrs({ pointerEvents: 'box-none', diff --git a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx index 361af955835..12f61448344 100644 --- a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx +++ b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx @@ -1,12 +1,17 @@ import React, { createRef } from 'react' import { FlatList } from 'react-native-gesture-handler' +import { ReactTestInstance } from 'react-test-renderer' import { CHRONICLE_CARD_WIDTH } from 'features/chronicle/constant' import { chroniclesSnap } from 'features/chronicle/fixtures/chroniclesSnap' -import { render, screen } from 'tests/utils' +import { render, screen, userEvent } from 'tests/utils' import { ChronicleCardListBase } from './ChronicleCardListBase' +const user = userEvent.setup() + +jest.useFakeTimers() + describe('ChronicleCardListBase', () => { const ref = createRef() @@ -34,4 +39,51 @@ describe('ChronicleCardListBase', () => { expect(screen.getByText('La Nature Sauvage')).toBeOnTheScreen() }) + + it('should display "Voir plus" button on all cards when onPressSeeMoreButton defined', () => { + render( + + ) + + expect(screen.getAllByText('Voir plus')).toHaveLength(10) + }) + + it('should not display "Voir plus" button on all cards when onSeeMoreButtonPress not defined', () => { + render( + + ) + + expect(screen.queryByText('Voir plus')).not.toBeOnTheScreen() + }) + + it('should handle onSeeMoreButtonPress when pressing "Voir plus" button', async () => { + const mockOnSeeMoreButtonPress = jest.fn() + render( + + ) + + const seeMoreButtons = screen.getAllByText('Voir plus') + + // Using as because links is never undefined and the typing is not correct + await user.press(seeMoreButtons[2] as ReactTestInstance) + + expect(mockOnSeeMoreButtonPress).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx index 4a8576629ec..52e91bb14d1 100644 --- a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx +++ b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx @@ -1,15 +1,19 @@ import React, { ReactElement, forwardRef, + useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react' -import { FlatList, FlatListProps, StyleProp, ViewStyle } from 'react-native' +import { FlatList, FlatListProps, ListRenderItem, StyleProp, View, ViewStyle } from 'react-native' import styled from 'styled-components/native' import { ChronicleCardData } from 'features/chronicle/type' +import { ButtonTertiaryBlack } from 'ui/components/buttons/ButtonTertiaryBlack' +import { styledButton } from 'ui/components/buttons/styledButton' +import { PlainMore } from 'ui/svg/icons/PlainMore' import { getSpacing } from 'ui/theme' import { ChronicleCard } from '../ChronicleCard/ChronicleCard' @@ -26,25 +30,14 @@ export type ChronicleCardListProps = Pick< | 'snapToInterval' | 'onScroll' | 'onContentSizeChange' + | 'onLayout' > & { offset?: number cardWidth?: number separatorSize?: number headerComponent?: ReactElement style?: StyleProp -} - -const renderItem = ({ item, cardWidth }: { item: ChronicleCardData; cardWidth?: number }) => { - return ( - - ) + onSeeMoreButtonPress?: (chronicleId: number) => void } export const ChronicleCardListBase = forwardRef< @@ -63,6 +56,8 @@ export const ChronicleCardListBase = forwardRef< onContentSizeChange, style, separatorSize = SEPARATOR_DEFAULT_VALUE, + onSeeMoreButtonPress, + onLayout, }, ref ) { @@ -70,6 +65,7 @@ export const ChronicleCardListBase = forwardRef< useImperativeHandle(ref, () => ({ scrollToOffset: (params) => listRef.current?.scrollToOffset(params), + scrollToIndex: (params) => listRef.current?.scrollToIndex(params), })) useEffect(() => { @@ -87,13 +83,37 @@ export const ChronicleCardListBase = forwardRef< [separatorSize, horizontal] ) + const renderItem = useCallback>( + ({ item }) => { + return ( + + {onSeeMoreButtonPress ? ( + + onSeeMoreButtonPress(item.id)} + /> + + ) : null} + + ) + }, + [cardWidth, onSeeMoreButtonPress] + ) + return ( renderItem({ item, cardWidth })} + renderItem={renderItem} keyExtractor={keyExtractor} ItemSeparatorComponent={Separator} contentContainerStyle={contentContainerStyle} @@ -105,6 +125,17 @@ export const ChronicleCardListBase = forwardRef< decelerationRate="fast" snapToInterval={snapToInterval} testID="chronicle-list" + onLayout={onLayout} /> ) }) + +const StyledPlainMore = styled(PlainMore).attrs(({ theme }) => ({ + size: theme.icons.sizes.extraSmall, +}))`` + +const StyledButtonTertiaryBlack = styledButton(ButtonTertiaryBlack).attrs({ + icon: StyledPlainMore, + iconPosition: 'right', + buttonHeight: 'extraSmall', +})`` diff --git a/src/features/chronicle/pages/Chronicles/Chronicles.native.test.tsx b/src/features/chronicle/pages/Chronicles/Chronicles.native.test.tsx index bd900f38928..d6849cf116b 100644 --- a/src/features/chronicle/pages/Chronicles/Chronicles.native.test.tsx +++ b/src/features/chronicle/pages/Chronicles/Chronicles.native.test.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { FlatList } from 'react-native' import { useRoute } from '__mocks__/@react-navigation/native' import { offerChroniclesFixture } from 'features/chronicle/fixtures/offerChronicles.fixture' @@ -6,13 +7,24 @@ import { Chronicles } from 'features/chronicle/pages/Chronicles/Chronicles' import { offerResponseSnap } from 'features/offer/fixtures/offerResponse' import { mockServer } from 'tests/mswServer' import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC' -import { render, screen } from 'tests/utils' +import { act, fireEvent, render, screen } from 'tests/utils' -useRoute.mockReturnValue({ - params: { - offerId: offerResponseSnap.id, +const mockOnLayout = { + nativeEvent: { + layout: { + width: 375, + height: 2100, + }, }, -}) +} + +const mockScrollToIndex = jest.fn() +jest.spyOn(FlatList.prototype, 'scrollToIndex').mockImplementation(mockScrollToIndex) + +const mockChronicles = offerChroniclesFixture.chronicles +jest.mock('features/chronicle/api/useChronicles/useChronicles', () => ({ + useChronicles: () => ({ data: mockChronicles, isLoading: false }), +})) describe('Chronicles', () => { beforeEach(() => { @@ -20,9 +32,54 @@ describe('Chronicles', () => { mockServer.getApi(`/v1/offer/${offerResponseSnap.id}/chronicles`, offerChroniclesFixture) }) - it('should render correctly', async () => { - render(reactQueryProviderHOC()) + describe('When chronicle id not defined', () => { + beforeAll(() => { + useRoute.mockReturnValue({ + params: { + offerId: offerResponseSnap.id, + }, + }) + }) + + it('should render correctly', async () => { + render(reactQueryProviderHOC()) + + expect(await screen.findByText('Tous les avis')).toBeOnTheScreen() + }) + + it('should not scroll to selected chronicle on layout', async () => { + render(reactQueryProviderHOC()) + + await screen.findByText('Tous les avis') + + await act(async () => { + fireEvent(screen.getByTestId('chronicle-list'), 'onLayout', mockOnLayout) + }) + + expect(mockScrollToIndex).not.toHaveBeenCalled() + }) + }) + + describe('When chronicle id defined', () => { + beforeAll(() => { + useRoute.mockReturnValue({ + params: { + offerId: offerResponseSnap.id, + chronicleId: 1, + }, + }) + }) + + it('should scroll to selected chronicle on layout', async () => { + render(reactQueryProviderHOC()) + + await screen.findByText('Tous les avis') + + await act(async () => { + fireEvent(screen.getByTestId('chronicle-list'), 'onLayout', mockOnLayout) + }) - expect(await screen.findByText('Tous les avis')).toBeOnTheScreen() + expect(mockScrollToIndex).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/src/features/chronicle/pages/Chronicles/Chronicles.tsx b/src/features/chronicle/pages/Chronicles/Chronicles.tsx index 925f0650b1f..bc2aa158c11 100644 --- a/src/features/chronicle/pages/Chronicles/Chronicles.tsx +++ b/src/features/chronicle/pages/Chronicles/Chronicles.tsx @@ -1,6 +1,6 @@ import { useRoute } from '@react-navigation/native' -import React, { FunctionComponent, useRef } from 'react' -import { ScrollView } from 'react-native' +import React, { FunctionComponent, useCallback, useRef } from 'react' +import { FlatList, InteractionManager } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import styled, { useTheme } from 'styled-components/native' @@ -19,6 +19,7 @@ import { TypoDS, getSpacing } from 'ui/theme' export const Chronicles: FunctionComponent = () => { const route = useRoute>() const offerId = route.params?.offerId + const chronicleId = route.params?.chronicleId const { goBack } = useGoBack('Offer', { id: offerId }) const { data: offer } = useOffer({ offerId }) const { data: chronicleCardsData } = useChronicles({ @@ -29,9 +30,24 @@ export const Chronicles: FunctionComponent = () => { const { headerTransition, onScroll } = useOpacityTransition() const { appBarHeight } = useTheme() const { top } = useSafeAreaInsets() + const { contentPage } = useTheme() const headerHeight = appBarHeight + top - const scrollViewRef = useRef(null) + const chroniclesListRef = useRef>(null) + + const selectedChronicle = chronicleCardsData?.findIndex((item) => item.id === chronicleId) ?? -1 + + const handleLayout = useCallback(() => { + if (selectedChronicle !== -1) { + InteractionManager.runAfterInteractions(() => { + chroniclesListRef.current?.scrollToIndex({ + index: selectedChronicle, + animated: true, + viewOffset: headerHeight, + }) + }) + } + }, [selectedChronicle, headerHeight]) if (!offer || !chronicleCardsData) return null @@ -41,21 +57,21 @@ export const Chronicles: FunctionComponent = () => { - Tous les avis} + ref={chroniclesListRef} onScroll={onScroll} - contentContainerStyle={{ paddingTop: headerHeight, paddingBottom: getSpacing(10) }}> - - Tous les avis} - /> - - + contentContainerStyle={{ + paddingTop: headerHeight, + paddingBottom: getSpacing(10), + marginTop: getSpacing(4), + marginHorizontal: contentPage.marginHorizontal, + }} + onLayout={handleLayout} + /> ) } @@ -63,8 +79,3 @@ export const Chronicles: FunctionComponent = () => { const StyledTitle2 = styled(TypoDS.Title2)({ marginBottom: getSpacing(6), }) - -const ChroniclesContainer = styled.View(({ theme }) => ({ - marginTop: getSpacing(4), - marginHorizontal: theme.contentPage.marginHorizontal, -})) diff --git a/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx b/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx index ff93989afb6..fe3aee1937f 100644 --- a/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx +++ b/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx @@ -1,5 +1,6 @@ import { useRoute } from '@react-navigation/native' -import React, { FunctionComponent } from 'react' +import React, { FunctionComponent, useCallback, useRef } from 'react' +import { FlatList, InteractionManager } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import styled, { useTheme } from 'styled-components/native' @@ -28,6 +29,7 @@ import { TypoDS, getSpacing } from 'ui/theme' export const Chronicles: FunctionComponent = () => { const route = useRoute>() const offerId = route.params?.offerId + const chronicleId = route.params?.chronicleId const { goBack } = useGoBack('Offer', { id: offerId }) const { data: offer } = useOffer({ offerId }) const subcategoriesMapping = useSubcategoriesMapping() @@ -51,6 +53,8 @@ export const Chronicles: FunctionComponent = () => { const currency = useGetCurrencyToDisplay() const euroToPacificFrancRate = useGetPacificFrancToEuroRate() + const chroniclesListRef = useRef>(null) + const displayedPrice = getDisplayedPrice( prices, currency, @@ -59,16 +63,32 @@ export const Chronicles: FunctionComponent = () => { { fractionDigits: 2 } ) + const selectedChronicle = chronicleCardsData?.findIndex((item) => item.id === chronicleId) ?? -1 + + const handleLayout = useCallback(() => { + if (selectedChronicle !== -1) { + InteractionManager.runAfterInteractions(() => { + chroniclesListRef.current?.scrollToIndex({ + index: selectedChronicle, + animated: true, + viewOffset: headerHeight, + }) + }) + } + }, [selectedChronicle, headerHeight]) + if (!offer || !chronicleCardsData) return null const title = `Tous les avis sur "${offer.name}"` const listComponent = ( Tous les avis} + onLayout={handleLayout} /> ) diff --git a/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSection.web.tsx b/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSection.web.tsx index 11fdd4d9dc4..45e76262748 100644 --- a/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSection.web.tsx +++ b/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSection.web.tsx @@ -16,7 +16,7 @@ import { ChronicleSectionProps } from './types' export const ChronicleSection = (props: ChronicleSectionProps) => { const { isDesktopViewport } = useTheme() - const { data, title, subtitle, ctaLabel, navigateTo, style } = props + const { data, title, subtitle, ctaLabel, navigateTo, style, onSeeMoreButtonPress } = props return isDesktopViewport ? ( @@ -35,7 +35,7 @@ export const ChronicleSection = (props: ChronicleSectionProps) => { {subtitle ? {subtitle} : null} - + ) : ( diff --git a/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSectionBase.tsx b/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSectionBase.tsx index e4e824098db..892eb51d16e 100644 --- a/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSectionBase.tsx +++ b/src/features/offer/components/OfferContent/ChronicleSection/ChronicleSectionBase.tsx @@ -17,6 +17,7 @@ export const ChronicleSectionBase = ({ subtitle, ctaLabel, navigateTo, + onSeeMoreButtonPress, style, }: ChronicleSectionProps) => { return ( @@ -25,7 +26,7 @@ export const ChronicleSectionBase = ({ {title} {subtitle ? {subtitle} : null} - + void style?: StyleProp } diff --git a/src/features/offer/components/OfferContent/OfferContent.native.test.tsx b/src/features/offer/components/OfferContent/OfferContent.native.test.tsx index c1a743e7b74..d3904185fa5 100644 --- a/src/features/offer/components/OfferContent/OfferContent.native.test.tsx +++ b/src/features/offer/components/OfferContent/OfferContent.native.test.tsx @@ -1,5 +1,6 @@ import { NavigationContainer } from '@react-navigation/native' import React, { ComponentProps } from 'react' +import { ReactTestInstance } from 'react-test-renderer' import { OfferResponseV2, @@ -646,6 +647,22 @@ describe('', () => { expect(mockNavigate).toHaveBeenNthCalledWith(1, 'Chronicles', { offerId: 116656 }) }) + + it('should navigate to chronicles page with anchor on the selected chronicle when pressing "Voir plus" button on a card', async () => { + renderOfferContent({ + offer: { ...offerResponseSnap, subcategoryId: SubcategoryIdEnum.LIVRE_PAPIER }, + }) + + const seeMoreButtons = screen.getAllByText('Voir plus') + + // Using as because links is never undefined and the typing is not correct + await user.press(seeMoreButtons[2] as ReactTestInstance) + + expect(mockNavigate).toHaveBeenNthCalledWith(1, 'Chronicles', { + offerId: 116656, + chronicleId: 3, + }) + }) }) it('should display social network section', async () => { diff --git a/src/features/offer/components/OfferContent/OfferContentBase.tsx b/src/features/offer/components/OfferContent/OfferContentBase.tsx index 6bf2136fca1..93470b9a0e4 100644 --- a/src/features/offer/components/OfferContent/OfferContentBase.tsx +++ b/src/features/offer/components/OfferContent/OfferContentBase.tsx @@ -1,3 +1,4 @@ +import { useNavigation } from '@react-navigation/native' import React, { FunctionComponent, ReactElement, @@ -19,6 +20,7 @@ import styled from 'styled-components/native' import { OfferImageResponse, OfferResponseV2 } from 'api/gen' import { ChronicleCardData } from 'features/chronicle/type' +import { UseNavigationType } from 'features/navigation/RootNavigator/types' import { OfferBody } from 'features/offer/components/OfferBody/OfferBody' import { CineContentCTA } from 'features/offer/components/OfferCine/CineContentCTA' import { ChronicleSection } from 'features/offer/components/OfferContent/ChronicleSection/ChronicleSection' @@ -62,6 +64,7 @@ export const OfferContentBase: FunctionComponent = ({ contentContainerStyle, BodyWrapper = React.Fragment, }) => { + const { navigate } = useNavigation() const { sameCategorySimilarOffers, apiRecoParamsSameCategory, @@ -131,6 +134,10 @@ export const OfferContentBase: FunctionComponent = ({ [onScroll] ) + const onSeeMoreButtonPress = (chronicleId: number) => { + navigate('Chronicles', { offerId: offer.id, chronicleId }) + } + return ( @@ -165,6 +172,7 @@ export const OfferContentBase: FunctionComponent = ({ subtitle="Des avis de jeunes passionnés sélectionnés par le pass Culture !" data={chronicles} navigateTo={{ screen: 'Chronicles', params: { offerId: offer.id } }} + onSeeMoreButtonPress={onSeeMoreButtonPress} /> ) : null}