-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(predict): implement featured carousel #28102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
f92000f
feat: feature carousel prediction progress
ghgoodreau 9fad147
feat: feature carousel progress
ghgoodreau 9a74932
fix: button overflow
ghgoodreau 721740a
feat(predict): featured carousel with sport cards, feature flag, and …
ghgoodreau 832919d
test(predict): fix failing test and improve carousel coverage for Son…
ghgoodreau 58e5394
fix: align featured carousel cards with Figma designs
ghgoodreau 20bf090
fix: code deduplication
ghgoodreau 5cff415
chore: add feat flag to registry
ghgoodreau 27b199c
fix: fix carousel outcome count
ghgoodreau ab84013
fix: stale remaining time
ghgoodreau 71a3708
fix: minute parsing for carousel
ghgoodreau b439e4b
fix: misc bugbot
ghgoodreau f265001
fix: misc cursor bugbot suggestions
ghgoodreau 829d3c7
fix: CI
ghgoodreau 25c5ff6
fix: bugbot, more testing
ghgoodreau 23fca11
fix: replace StyleSheet with Tailwind, migrate carousel hook to React…
ghgoodreau 2f90a58
fix: bugbot
ghgoodreau 269d36c
fix: polymarket provider refactor
ghgoodreau 78620cf
update predictfeed test
ghgoodreau a15d264
fix: readd jsdoc
ghgoodreau File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
232 changes: 232 additions & 0 deletions
232
app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarousel.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import React from 'react'; | ||
| import { View } from 'react-native'; | ||
| import { backgroundState } from '../../../../../util/test/initial-root-state'; | ||
| import renderWithProvider from '../../../../../util/test/renderWithProvider'; | ||
| import { PredictMarket, PredictOutcome, Recurrence } from '../../types'; | ||
| import FeaturedCarousel from './FeaturedCarousel'; | ||
| import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds'; | ||
|
|
||
| const mockUseFeaturedCarouselData = jest.fn(); | ||
|
|
||
| jest.mock('@metamask/design-system-twrnc-preset', () => ({ | ||
| useTailwind: () => ({ | ||
| style: jest.fn(() => ({})), | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('../../hooks/useFeaturedCarouselData', () => ({ | ||
| useFeaturedCarouselData: () => mockUseFeaturedCarouselData(), | ||
| })); | ||
|
|
||
| jest.mock('@shopify/flash-list', () => { | ||
| const MockReact = jest.requireActual('react'); | ||
| const { View: MockView, ScrollView: MockScrollView } = | ||
| jest.requireActual('react-native'); | ||
|
|
||
| const MockFlashList = MockReact.forwardRef( | ||
| ( | ||
| { | ||
| data, | ||
| renderItem, | ||
| keyExtractor, | ||
| testID, | ||
| }: { | ||
| data: { id: string }[]; | ||
| renderItem: (info: { item: unknown; index: number }) => React.ReactNode; | ||
| keyExtractor: (item: { id: string }) => string; | ||
| testID?: string; | ||
| }, | ||
| ref: React.Ref<unknown>, | ||
| ) => { | ||
| MockReact.useImperativeHandle(ref, () => ({})); | ||
|
|
||
| return ( | ||
| <MockScrollView testID={testID}> | ||
| {data?.map((item, index) => ( | ||
| <MockView key={keyExtractor?.(item) ?? item.id}> | ||
| {renderItem({ item, index })} | ||
| </MockView> | ||
| ))} | ||
| </MockScrollView> | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| return { | ||
| FlashList: MockFlashList, | ||
| FlashListRef: {}, | ||
| }; | ||
| }); | ||
|
|
||
| jest.mock('./FeaturedCarouselCard', () => { | ||
| const { View: MockView } = jest.requireActual('react-native'); | ||
| const mockTestIds = jest.requireActual< | ||
| typeof import('./FeaturedCarousel.testIds') | ||
| >('./FeaturedCarousel.testIds'); | ||
|
|
||
| return ({ index }: { index: number }) => ( | ||
| <MockView testID={mockTestIds.FEATURED_CAROUSEL_TEST_IDS.CARD(index)} /> | ||
| ); | ||
| }); | ||
|
|
||
| const mockOutcome: PredictOutcome = { | ||
| id: 'outcome-1', | ||
| providerId: 'polymarket', | ||
| marketId: 'market-1', | ||
| title: 'Will BTC hit $200k?', | ||
| description: 'BTC prediction', | ||
| image: 'https://example.com/btc.png', | ||
| status: 'open', | ||
| tokens: [ | ||
| { id: 'token-yes', title: 'Yes', price: 0.65 }, | ||
| { id: 'token-no', title: 'No', price: 0.35 }, | ||
| ], | ||
| volume: 1500000, | ||
| groupItemTitle: 'Bitcoin', | ||
| negRisk: false, | ||
| tickSize: '0.01', | ||
| }; | ||
|
|
||
| const mockMarket: PredictMarket = { | ||
| id: 'market-1', | ||
| providerId: 'polymarket', | ||
| slug: 'btc-200k', | ||
| title: 'Will BTC hit $200k?', | ||
| description: 'BTC prediction', | ||
| image: 'https://example.com/btc.png', | ||
| status: 'open', | ||
| recurrence: Recurrence.NONE, | ||
| category: 'crypto', | ||
| tags: [], | ||
| outcomes: [mockOutcome], | ||
| liquidity: 1500000, | ||
| volume: 1500000, | ||
| }; | ||
|
|
||
| const initialState = { | ||
| engine: { | ||
| backgroundState, | ||
| }, | ||
| }; | ||
|
|
||
| describe('FeaturedCarousel', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('renders skeleton when loading', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [], | ||
| isLoading: true, | ||
| error: null, | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { getByTestId } = renderWithProvider(<FeaturedCarousel />, { | ||
| state: initialState, | ||
| }); | ||
|
|
||
| expect(getByTestId(FEATURED_CAROUSEL_TEST_IDS.SKELETON)).toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('renders nothing when error is returned', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [], | ||
| isLoading: false, | ||
| error: 'Request failed', | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { queryByTestId } = renderWithProvider(<FeaturedCarousel />, { | ||
| state: initialState, | ||
| }); | ||
|
|
||
| expect( | ||
| queryByTestId(FEATURED_CAROUSEL_TEST_IDS.CONTAINER), | ||
| ).not.toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('renders nothing when markets are empty', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [], | ||
| isLoading: false, | ||
| error: null, | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { queryByTestId } = renderWithProvider(<FeaturedCarousel />, { | ||
| state: initialState, | ||
| }); | ||
|
|
||
| expect( | ||
| queryByTestId(FEATURED_CAROUSEL_TEST_IDS.CONTAINER), | ||
| ).not.toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('renders carousel container with market data', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [ | ||
| mockMarket, | ||
| { ...mockMarket, id: 'market-2', slug: 'btc-210k' }, | ||
| ], | ||
| isLoading: false, | ||
| error: null, | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { getByTestId } = renderWithProvider(<FeaturedCarousel />, { | ||
| state: initialState, | ||
| }); | ||
|
|
||
| expect(getByTestId(FEATURED_CAROUSEL_TEST_IDS.CONTAINER)).toBeOnTheScreen(); | ||
| expect( | ||
| getByTestId(FEATURED_CAROUSEL_TEST_IDS.FLASH_LIST), | ||
| ).toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('renders pagination dots matching market count', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [ | ||
| mockMarket, | ||
| { ...mockMarket, id: 'market-2', slug: 'btc-210k' }, | ||
| { ...mockMarket, id: 'market-3', slug: 'btc-220k' }, | ||
| ], | ||
| isLoading: false, | ||
| error: null, | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { getByTestId } = renderWithProvider(<FeaturedCarousel />, { | ||
| state: initialState, | ||
| }); | ||
|
|
||
| expect( | ||
| getByTestId(FEATURED_CAROUSEL_TEST_IDS.PAGINATION_DOTS), | ||
| ).toBeOnTheScreen(); | ||
| }); | ||
|
|
||
| it('renders the expected number of carousel cards', () => { | ||
| mockUseFeaturedCarouselData.mockReturnValue({ | ||
| markets: [ | ||
| mockMarket, | ||
| { ...mockMarket, id: 'market-2', slug: 'btc-210k' }, | ||
| ], | ||
| isLoading: false, | ||
| error: null, | ||
| refetch: jest.fn(), | ||
| }); | ||
|
|
||
| const { getByTestId, queryByTestId } = renderWithProvider( | ||
| <FeaturedCarousel />, | ||
| { | ||
| state: initialState, | ||
| }, | ||
| ); | ||
|
|
||
| expect(getByTestId(FEATURED_CAROUSEL_TEST_IDS.CARD(0))).toBeOnTheScreen(); | ||
| expect(getByTestId(FEATURED_CAROUSEL_TEST_IDS.CARD(1))).toBeOnTheScreen(); | ||
| expect( | ||
| queryByTestId(FEATURED_CAROUSEL_TEST_IDS.CARD(2)), | ||
| ).not.toBeOnTheScreen(); | ||
| }); | ||
| }); |
13 changes: 13 additions & 0 deletions
13
app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarousel.testIds.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| export const FEATURED_CAROUSEL_TEST_IDS = { | ||
| CONTAINER: 'featured-carousel-container', | ||
| FLASH_LIST: 'featured-carousel-flash-list', | ||
| PAGINATION_DOTS: 'featured-carousel-pagination-dots', | ||
| CARD: (index: number) => `featured-carousel-card-${index}`, | ||
| CARD_TITLE: (index: number) => `featured-carousel-card-title-${index}`, | ||
| CARD_OUTCOME: (index: number, outcomeIndex: number) => | ||
| `featured-carousel-card-${index}-outcome-${outcomeIndex}`, | ||
| CARD_BUY_BUTTON: (index: number, outcomeIndex: number) => | ||
| `featured-carousel-card-${index}-buy-${outcomeIndex}`, | ||
| CARD_FOOTER: (index: number) => `featured-carousel-card-footer-${index}`, | ||
| SKELETON: 'featured-carousel-skeleton', | ||
| } as const; |
141 changes: 141 additions & 0 deletions
141
app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarousel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import React, { useCallback, useRef, useState } from 'react'; | ||
| import { | ||
| Dimensions, | ||
| NativeScrollEvent, | ||
| NativeSyntheticEvent, | ||
| View, | ||
| } from 'react-native'; | ||
| import { useTailwind } from '@metamask/design-system-twrnc-preset'; | ||
| import { Box } from '@metamask/design-system-react-native'; | ||
| import { FlashList, FlashListRef } from '@shopify/flash-list'; | ||
| import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; | ||
| import { PredictMarket } from '../../types'; | ||
| import { PredictEventValues } from '../../constants/eventNames'; | ||
| import { useFeaturedCarouselData } from '../../hooks/useFeaturedCarouselData'; | ||
| import FeaturedCarouselCard from './FeaturedCarouselCard'; | ||
| import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds'; | ||
|
|
||
| const { width: SCREEN_WIDTH } = Dimensions.get('window'); | ||
| export const HORIZONTAL_PADDING = 16; | ||
| export const CARD_GAP = 12; | ||
| export const CARD_WIDTH = SCREEN_WIDTH - HORIZONTAL_PADDING * 2; | ||
| export const CARD_HEIGHT = 280; | ||
| export const SNAP_INTERVAL = CARD_WIDTH + CARD_GAP; | ||
|
|
||
| const FeaturedCarouselSkeleton: React.FC = () => { | ||
| const tw = useTailwind(); | ||
| return ( | ||
| <Box testID={FEATURED_CAROUSEL_TEST_IDS.SKELETON} twClassName="mx-4"> | ||
| <Skeleton | ||
| width={CARD_WIDTH} | ||
| height={CARD_HEIGHT} | ||
| style={tw.style('rounded-2xl')} | ||
| /> | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| interface PaginationDotsProps { | ||
| count: number; | ||
| activeIndex: number; | ||
| } | ||
|
|
||
| export const PaginationDots: React.FC<PaginationDotsProps> = ({ | ||
| count, | ||
| activeIndex, | ||
| }) => { | ||
| const tw = useTailwind(); | ||
|
|
||
| if (count <= 1) return null; | ||
|
|
||
| return ( | ||
| <Box | ||
| testID={FEATURED_CAROUSEL_TEST_IDS.PAGINATION_DOTS} | ||
| twClassName="flex-row justify-center items-center gap-2 mt-3" | ||
| > | ||
| {Array.from({ length: count }).map((_, dotPosition) => ( | ||
| <View | ||
| key={`pagination-dot-${dotPosition}`} | ||
| style={tw.style( | ||
| 'h-2 rounded-full', | ||
| dotPosition === activeIndex | ||
| ? 'bg-icon-alternative' | ||
| : 'bg-icon-muted w-2', | ||
| dotPosition === activeIndex && { width: 35 }, | ||
| )} | ||
| /> | ||
| ))} | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| const FeaturedCarousel: React.FC = () => { | ||
| const tw = useTailwind(); | ||
| const flashListRef = useRef<FlashListRef<PredictMarket>>(null); | ||
| const [activeIndex, setActiveIndex] = useState(0); | ||
|
|
||
| const { markets, isLoading, error } = useFeaturedCarouselData(); | ||
|
|
||
| const handleScroll = useCallback( | ||
| (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| const offsetX = event.nativeEvent.contentOffset.x; | ||
| const newIndex = Math.round(offsetX / SNAP_INTERVAL); | ||
| setActiveIndex(newIndex); | ||
| }, | ||
| [], | ||
| ); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const renderItem = useCallback( | ||
| ({ item: market, index: idx }: { item: PredictMarket; index: number }) => ( | ||
| <Box | ||
| style={tw.style( | ||
| { width: CARD_WIDTH, height: CARD_HEIGHT }, | ||
| idx < markets.length - 1 && { marginRight: CARD_GAP }, | ||
| )} | ||
| > | ||
| <FeaturedCarouselCard | ||
| market={market} | ||
| index={idx} | ||
| entryPoint={PredictEventValues.ENTRY_POINT.PREDICT_FEED} | ||
| /> | ||
| </Box> | ||
| ), | ||
| [markets.length, tw], | ||
| ); | ||
|
|
||
| const keyExtractor = useCallback( | ||
| (item: PredictMarket) => `carousel-${item.id}`, | ||
| [], | ||
| ); | ||
|
|
||
| if (isLoading) { | ||
| return <FeaturedCarouselSkeleton />; | ||
| } | ||
|
|
||
| if (error || markets.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <Box testID={FEATURED_CAROUSEL_TEST_IDS.CONTAINER}> | ||
| <FlashList | ||
| ref={flashListRef} | ||
| testID={FEATURED_CAROUSEL_TEST_IDS.FLASH_LIST} | ||
| data={markets} | ||
| renderItem={renderItem} | ||
| keyExtractor={keyExtractor} | ||
| horizontal | ||
| pagingEnabled={false} | ||
| showsHorizontalScrollIndicator={false} | ||
| snapToInterval={SNAP_INTERVAL} | ||
| decelerationRate="fast" | ||
| onScroll={handleScroll} | ||
| scrollEventThrottle={16} | ||
| contentContainerStyle={tw.style(`px-[${HORIZONTAL_PADDING}px]`)} | ||
ghgoodreau marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /> | ||
ghgoodreau marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <PaginationDots count={markets.length} activeIndex={activeIndex} /> | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| export default FeaturedCarousel; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.