From d04439da057cd2b6dcfa855756ce7b848505efa0 Mon Sep 17 00:00:00 2001 From: clesausse-pass <187269096+clesausse-pass@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:03:38 +0100 Subject: [PATCH] (PC-33638) feat(chronicle): add anchor on chronicle when see more button used with InteractionManager --- .../ChronicleCardList.web.tsx | 44 +++++++---- .../ChronicleCardListBase.native.test.tsx | 12 --- .../ChronicleCardListBase.tsx | 52 ++----------- .../Chronicles/Chronicles.native.test.tsx | 73 +++++++++++++++++-- .../chronicle/pages/Chronicles/Chronicles.tsx | 20 ++++- .../pages/Chronicles/Chronicles.web.tsx | 20 ++++- 6 files changed, 132 insertions(+), 89 deletions(-) diff --git a/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx b/src/features/chronicle/components/ChronicleCardList/ChronicleCardList.web.tsx index efb3b435695..bef3dbd7f63 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,24 +17,35 @@ import { SEPARATOR_DEFAULT_VALUE, } from './ChronicleCardListBase' -export const ChronicleCardList: FunctionComponent = ({ - data, - horizontal = true, - cardWidth, - contentContainerStyle, - headerComponent, - separatorSize = SEPARATOR_DEFAULT_VALUE, - onScroll, - style, - offerId, - shouldShowSeeMoreButton, - selectedChronicle, -}) => { +export const ChronicleCardList = forwardRef< + Partial>, + ChronicleCardListProps +>(function ChronicleCardList( + { + data, + horizontal = true, + cardWidth, + contentContainerStyle, + onScroll, + headerComponent, + style, + separatorSize = SEPARATOR_DEFAULT_VALUE, + shouldShowSeeMoreButton, + offerId, + 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, @@ -91,11 +103,11 @@ export const ChronicleCardList: FunctionComponent = ({ snapToInterval={isDesktopViewport ? CHRONICLE_CARD_WIDTH : undefined} offerId={offerId} shouldShowSeeMoreButton={shouldShowSeeMoreButton} - selectedChronicle={selectedChronicle} + 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 7f05678b2ec..361af955835 100644 --- a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx +++ b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.native.test.tsx @@ -34,16 +34,4 @@ describe('ChronicleCardListBase', () => { expect(screen.getByText('La Nature Sauvage')).toBeOnTheScreen() }) - - it('should scroll to the selected chronicle when defined', () => { - render( - - ) - - expect(screen.getByText('La Magie des Étoiles')).toBeOnTheScreen() - }) }) diff --git a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx index 442b885790c..9ef9c7acb98 100644 --- a/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx +++ b/src/features/chronicle/components/ChronicleCardList/ChronicleCardListBase.tsx @@ -5,18 +5,9 @@ import React, { useImperativeHandle, useMemo, useRef, - useState, } from 'react' -import { - FlatList, - FlatListProps, - LayoutChangeEvent, - StyleProp, - View, - ViewStyle, -} from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import styled, { useTheme } from 'styled-components/native' +import { FlatList, FlatListProps, StyleProp, ViewStyle } from 'react-native' +import styled from 'styled-components/native' import { ChronicleCardData } from 'features/chronicle/type' import { getSpacing } from 'ui/theme' @@ -35,6 +26,7 @@ export type ChronicleCardListProps = Pick< | 'snapToInterval' | 'onScroll' | 'onContentSizeChange' + | 'onLayout' > & { offset?: number cardWidth?: number @@ -43,7 +35,6 @@ export type ChronicleCardListProps = Pick< style?: StyleProp shouldShowSeeMoreButton?: boolean offerId?: number - selectedChronicle?: ChronicleCardData } const renderItem = ({ @@ -89,29 +80,15 @@ export const ChronicleCardListBase = forwardRef< separatorSize = SEPARATOR_DEFAULT_VALUE, shouldShowSeeMoreButton, offerId, - selectedChronicle, + onLayout, }, ref ) { const listRef = useRef(null) - const [isFlatListReady, setIsFlatListReady] = useState(false) - const [headerHeight, setHeaderHeight] = useState(0) - const [listHeight, setListHeight] = useState(1) - const { top } = useSafeAreaInsets() - const { appBarHeight } = useTheme() - - const handleHeaderLayout = (event: LayoutChangeEvent) => { - setHeaderHeight(event.nativeEvent.layout.height) - } - - const handleListLayout = (event: LayoutChangeEvent) => { - setListHeight(event.nativeEvent.layout.height) - setIsFlatListReady(true) - } useImperativeHandle(ref, () => ({ scrollToOffset: (params) => listRef.current?.scrollToOffset(params), - scrollToItem: (params) => listRef.current?.scrollToItem(params), + scrollToIndex: (params) => listRef.current?.scrollToIndex(params), })) useEffect(() => { @@ -120,19 +97,6 @@ export const ChronicleCardListBase = forwardRef< } }, [offset]) - useEffect(() => { - if (listRef.current && isFlatListReady && !!selectedChronicle) { - const flatListTop = top + appBarHeight + headerHeight - const viewPosition = flatListTop / listHeight - - listRef.current.scrollToItem({ - item: selectedChronicle, - animated: true, - viewPosition, - }) - } - }, [appBarHeight, headerHeight, selectedChronicle, isFlatListReady, listHeight, top]) - const Separator = useMemo( () => styled.View({ @@ -147,9 +111,7 @@ export const ChronicleCardListBase = forwardRef< ref={listRef} data={data} style={style} - ListHeaderComponent={ - headerComponent ? {headerComponent} : null - } + ListHeaderComponent={headerComponent} renderItem={({ item }) => renderItem({ item, cardWidth, shouldShowSeeMoreButton, offerId })} keyExtractor={keyExtractor} ItemSeparatorComponent={Separator} @@ -162,7 +124,7 @@ export const ChronicleCardListBase = forwardRef< decelerationRate="fast" snapToInterval={snapToInterval} testID="chronicle-list" - onLayout={handleListLayout} + onLayout={onLayout} /> ) }) 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 e0420611a4f..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 { FlatList } 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' @@ -35,7 +35,19 @@ export const Chronicles: FunctionComponent = () => { const chroniclesListRef = useRef>(null) - const selectedChronicle = chronicleCardsData?.find((item) => item.id === chronicleId) + 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 @@ -58,7 +70,7 @@ export const Chronicles: FunctionComponent = () => { marginTop: getSpacing(4), marginHorizontal: contentPage.marginHorizontal, }} - selectedChronicle={selectedChronicle} + onLayout={handleLayout} /> ) diff --git a/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx b/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx index 6c11ab7127a..fe3aee1937f 100644 --- a/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx +++ b/src/features/chronicle/pages/Chronicles/Chronicles.web.tsx @@ -1,6 +1,6 @@ import { useRoute } from '@react-navigation/native' -import React, { FunctionComponent, useRef } from 'react' -import { FlatList } 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' @@ -63,7 +63,19 @@ export const Chronicles: FunctionComponent = () => { { fractionDigits: 2 } ) - const selectedChronicle = chronicleCardsData?.find((item) => item.id === chronicleId) + 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 @@ -76,7 +88,7 @@ export const Chronicles: FunctionComponent = () => { onScroll={onScroll} paddingTop={headerHeight} headerComponent={Tous les avis} - selectedChronicle={selectedChronicle} + onLayout={handleLayout} /> )