From 550e7573527a314293cd44ffb318b081856982ec Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 19 Jan 2026 14:47:44 -0500 Subject: [PATCH 1/8] Allow filters by activity type Add test coverage Fix bug prettier issue fix bugbot fix f Address comments and update design Update OriginSpamModal.test.tsx.snap Fix bug Fix bug Fix bug Update NFTAutoDetectionModal.test.tsx.snap Revert snapshot Update index.test.tsx.snap Update OptionSheet.test.tsx.snap --- .../components/RewardItem/RewardItem.tsx | 2 +- .../Tabs/ActivityTab/ActivityEventRow.tsx | 4 +- .../Tabs/ActivityTab/ActivityTab.test.tsx | 357 +++- .../Tabs/ActivityTab/ActivityTab.tsx | 195 +- .../UI/Rewards/hooks/usePointsEvents.test.ts | 1694 +++++++++++++---- .../UI/Rewards/hooks/usePointsEvents.ts | 112 +- .../UI/SelectOptionSheet/OptionSheet.test.tsx | 7 +- .../UI/SelectOptionSheet/OptionsSheet.tsx | 46 +- .../SelectOptionSheet/SelectOptionSheet.tsx | 36 +- .../__snapshots__/OptionSheet.test.tsx.snap | 70 +- .../SelectOptionSheet.test.tsx.snap | 94 +- app/components/UI/SelectOptionSheet/styles.ts | 29 +- .../__snapshots__/index.test.tsx.snap | 94 +- .../RewardsController.test.ts | 379 ++++ .../rewards-controller/RewardsController.ts | 30 +- .../services/rewards-data-service.test.ts | 69 + .../services/rewards-data-service.ts | 8 +- .../controllers/rewards-controller/types.ts | 7 +- locales/languages/en.json | 9 + 19 files changed, 2513 insertions(+), 729 deletions(-) diff --git a/app/components/UI/Rewards/components/RewardItem/RewardItem.tsx b/app/components/UI/Rewards/components/RewardItem/RewardItem.tsx index dd86b4400ce..08edf6bf416 100644 --- a/app/components/UI/Rewards/components/RewardItem/RewardItem.tsx +++ b/app/components/UI/Rewards/components/RewardItem/RewardItem.tsx @@ -377,7 +377,7 @@ const RewardItem: React.FC = ({ > {/* Reward Icon */} diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx index 9e3cf6ee4e7..d041bf658d6 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { ActivityTab } from './ActivityTab'; import type { @@ -20,6 +20,7 @@ jest.mock('../../../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); jest.mock('../../../../../../reducers/rewards/selectors', () => ({ + selectActiveTab: jest.fn(), selectSeasonId: jest.fn(), selectSeasonStartDate: jest.fn(), selectSeasonStatusLoading: jest.fn(), @@ -27,6 +28,7 @@ jest.mock('../../../../../../reducers/rewards/selectors', () => ({ import { selectRewardsSubscriptionId } from '../../../../../../selectors/rewards'; import { + selectActiveTab, selectSeasonId, selectSeasonStartDate, selectSeasonStatusLoading, @@ -36,6 +38,9 @@ const mockSelectSubscriptionId = selectRewardsSubscriptionId as jest.MockedFunction< typeof selectRewardsSubscriptionId >; +const mockSelectActiveTab = selectActiveTab as jest.MockedFunction< + typeof selectActiveTab +>; const mockSelectSeasonId = selectSeasonId as jest.MockedFunction< typeof selectSeasonId >; @@ -56,6 +61,15 @@ jest.mock('../../../../../../../locales/i18n', () => ({ const t: Record = { 'rewards.loading_activity': 'Loading activity', 'rewards.error_loading_activity': 'Error loading activity', + 'rewards.filter_title': 'Filter by', + 'rewards.filter_all': 'All', + 'rewards.filter_swap': 'Swap', + 'rewards.filter_perps': 'Perps', + 'rewards.filter_predict': 'Predict', + 'rewards.filter_referral': 'Referral', + 'rewards.filter_card': 'Card', + 'rewards.filter_musd_deposit': 'MUSD Deposit', + 'rewards.filter_shield': 'Shield', }; return t[key] || key; }), @@ -77,6 +91,52 @@ jest.mock('../../../../../hooks/DisplayName/useAccountNames', () => ({ useAccountNames: jest.fn(() => []), })); +// Mock SelectOptionSheet component +jest.mock('../../../../SelectOptionSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + label, + options, + selectedValue, + onValueChange, + }: { + label: string; + options: { key?: string; value?: string; label?: string }[]; + selectedValue?: string; + onValueChange: (val: string) => void; + }) => + ReactActual.createElement( + View, + { testID: 'select-option-sheet' }, + ReactActual.createElement( + Text, + { testID: 'select-option-sheet-label' }, + label, + ), + ReactActual.createElement( + Text, + { testID: 'select-option-sheet-selected' }, + selectedValue, + ), + options.map( + (option: { key?: string; value?: string; label?: string }) => + ReactActual.createElement( + TouchableOpacity, + { + key: option.key, + onPress: () => option.value && onValueChange(option.value), + testID: `activity-filter-${option.value}`, + }, + ReactActual.createElement(Text, null, option.label), + ), + ), + ), + }; +}); + // Mock ActivityEventRow to simplify assertions jest.mock('./ActivityEventRow', () => ({ ActivityEventRow: ({ event }: { event: { id: string } }) => { @@ -296,6 +356,7 @@ describe('ActivityTab', () => { // Mock state structure with rewards property for selectors const mockState = { rewards: { + activeTab: 'activity', seasonId: defaultSeasonStatus.season.id, seasonStatusLoading: false, seasonStartDate: new Date('2024-01-01'), @@ -307,6 +368,7 @@ describe('ActivityTab', () => { (selector: (state: unknown) => unknown) => selector(mockState as unknown), ); mockSelectSubscriptionId.mockReturnValue(mockSubscriptionId); + mockSelectActiveTab.mockReturnValue('activity'); mockSelectSeasonId.mockReturnValue(defaultSeasonStatus.season.id); mockSelectSeasonStartDate.mockReturnValue(new Date('2024-01-01')); mockSelectSeasonStatusLoading.mockReturnValue(false); @@ -402,6 +464,36 @@ describe('ActivityTab', () => { expect(lastCall[0]).toEqual({ seasonId: defaultSeasonStatus.season.id, subscriptionId: '', + type: undefined, + enabled: true, + }); + }); + + it('passes undefined type to usePointsEvents initially', () => { + render(); + + const lastCall = + mockUsePointsEvents.mock.calls[mockUsePointsEvents.mock.calls.length - 1]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: undefined, + enabled: true, + }); + }); + + it('passes enabled false to usePointsEvents when not on activity tab', () => { + mockSelectActiveTab.mockReturnValue('overview'); + + render(); + + const lastCall = + mockUsePointsEvents.mock.calls[mockUsePointsEvents.mock.calls.length - 1]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: undefined, + enabled: false, }); }); @@ -417,6 +509,7 @@ describe('ActivityTab', () => { ); const { getByTestId } = render(); + const flatList = getByTestId('flatlist'); flatList.props.onRefresh(); @@ -615,4 +708,266 @@ describe('ActivityTab', () => { expect(mockRefresh).toBeDefined(); }); }); + + describe('Filter functionality', () => { + it('renders SelectOptionSheet with filter label', () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId('select-option-sheet')).toBeOnTheScreen(); + expect(getByText('Filter by')).toBeOnTheScreen(); + }); + + it('renders all filter options', () => { + const { getByText } = render(); + + expect(getByText('All')).toBeOnTheScreen(); + expect(getByText('Swap')).toBeOnTheScreen(); + expect(getByText('Perps')).toBeOnTheScreen(); + expect(getByText('Predict')).toBeOnTheScreen(); + expect(getByText('Referral')).toBeOnTheScreen(); + expect(getByText('Card')).toBeOnTheScreen(); + expect(getByText('MUSD Deposit')).toBeOnTheScreen(); + expect(getByText('Shield')).toBeOnTheScreen(); + }); + + it('renders filter options with correct testIDs', () => { + const { getByTestId } = render(); + + expect(getByTestId('activity-filter-ALL')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-SWAP')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-PERPS')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-PREDICT')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-REFERRAL')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-CARD')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-MUSD_DEPOSIT')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-SHIELD')).toBeOnTheScreen(); + }); + + it('passes SWAP type to usePointsEvents when Swap filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-SWAP')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'SWAP', + enabled: true, + }); + }); + + it('passes PERPS type to usePointsEvents when Perps filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-PERPS')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'PERPS', + enabled: true, + }); + }); + + it('passes PREDICT type to usePointsEvents when Predict filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-PREDICT')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'PREDICT', + enabled: true, + }); + }); + + it('passes REFERRAL type to usePointsEvents when Referral filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-REFERRAL')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'REFERRAL', + enabled: true, + }); + }); + + it('passes CARD type to usePointsEvents when Card filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-CARD')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'CARD', + enabled: true, + }); + }); + + it('passes MUSD_DEPOSIT type to usePointsEvents when MUSD Deposit filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-MUSD_DEPOSIT')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'MUSD_DEPOSIT', + enabled: true, + }); + }); + + it('passes SHIELD type to usePointsEvents when Shield filter is selected', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('activity-filter-SHIELD')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: 'SHIELD', + enabled: true, + }); + }); + + it('passes undefined type when All filter is selected after selecting another filter', () => { + const { getByTestId } = render(); + + // First select SWAP filter + fireEvent.press(getByTestId('activity-filter-SWAP')); + + // Then select All filter + fireEvent.press(getByTestId('activity-filter-ALL')); + + const lastCall = + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual({ + seasonId: defaultSeasonStatus.season.id, + subscriptionId: mockSubscriptionId, + type: undefined, + enabled: true, + }); + }); + + it('allows switching between different filter types', () => { + const { getByTestId } = render(); + + // Select SWAP + fireEvent.press(getByTestId('activity-filter-SWAP')); + expect( + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ][0].type, + ).toBe('SWAP'); + + // Switch to PERPS + fireEvent.press(getByTestId('activity-filter-PERPS')); + expect( + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ][0].type, + ).toBe('PERPS'); + + // Switch to REFERRAL + fireEvent.press(getByTestId('activity-filter-REFERRAL')); + expect( + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ][0].type, + ).toBe('REFERRAL'); + + // Switch back to All + fireEvent.press(getByTestId('activity-filter-ALL')); + expect( + mockUsePointsEvents.mock.calls[ + mockUsePointsEvents.mock.calls.length - 1 + ][0].type, + ).toBeUndefined(); + }); + + it('shows empty state when filter returns no events', () => { + mockUsePointsEvents.mockReturnValue( + makePointsEventsResult({ + pointsEvents: [], + isLoading: false, + }), + ); + + const { getByTestId, getByText } = render(); + + // Select a filter + fireEvent.press(getByTestId('activity-filter-SWAP')); + + // Filter still shows empty state + expect(getByText('rewards.activity_empty_title')).toBeOnTheScreen(); + }); + + it('shows loading state when filter changes and isLoading is true', () => { + mockUsePointsEvents.mockReturnValue( + makePointsEventsResult({ + pointsEvents: null, + isLoading: true, + }), + ); + + const { getByTestId, queryByTestId } = render(); + + // Select a filter + fireEvent.press(getByTestId('activity-filter-SWAP')); + + // Still shows skeleton (no flatlist) + expect(queryByTestId('flatlist')).toBeNull(); + }); + + it('shows selected value in SelectOptionSheet', () => { + const { getByTestId } = render(); + + // Initially shows "ALL" as selected value + expect(getByTestId('select-option-sheet-selected')).toHaveTextContent( + 'ALL', + ); + + // Select SWAP + fireEvent.press(getByTestId('activity-filter-SWAP')); + + // Selected value updates to "SWAP" + expect(getByTestId('select-option-sheet-selected')).toHaveTextContent( + 'SWAP', + ); + }); + }); }); diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index 89f8f3f3161..a1f6be03c83 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { FlatList, ListRenderItem, ActivityIndicator } from 'react-native'; import { @@ -9,11 +9,15 @@ import { ButtonVariant, } from '@metamask/design-system-react-native'; import { usePointsEvents } from '../../../hooks/usePointsEvents'; -import { PointsEventDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; +import { + PointsEventDto, + PointsEventEarnType, +} from '../../../../../../core/Engine/controllers/rewards-controller/types'; import { selectRewardsSubscriptionId } from '../../../../../../selectors/rewards'; import { strings } from '../../../../../../../locales/i18n'; import { ActivityEventRow } from './ActivityEventRow'; import { + selectActiveTab, selectSeasonId, selectSeasonStartDate, selectSeasonStatusLoading, @@ -26,6 +30,67 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useAccountNames } from '../../../../../hooks/DisplayName/useAccountNames'; import { NameType } from '../../../../Name/Name.types'; import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; +import SelectOptionSheet from '../../../../SelectOptionSheet'; +import type { ISelectOption } from '../../../../SelectOptionSheet/types'; + +const ALL_FILTER_VALUE = 'ALL'; + +const FILTER_OPTIONS: ISelectOption[] = [ + { key: 'all', value: ALL_FILTER_VALUE, label: strings('rewards.filter_all') }, + { key: 'swap', value: 'SWAP', label: strings('rewards.filter_swap') }, + { key: 'perps', value: 'PERPS', label: strings('rewards.filter_perps') }, + { + key: 'predict', + value: 'PREDICT', + label: strings('rewards.filter_predict'), + }, + { + key: 'referral', + value: 'REFERRAL', + label: strings('rewards.filter_referral'), + }, + { key: 'card', value: 'CARD', label: strings('rewards.filter_card') }, + { + key: 'musd_deposit', + value: 'MUSD_DEPOSIT', + label: strings('rewards.filter_musd_deposit'), + }, + { key: 'shield', value: 'SHIELD', label: strings('rewards.filter_shield') }, +]; + +interface ActivityFilterProps { + selectedType: PointsEventEarnType | undefined; + onSelectType: (type: PointsEventEarnType | undefined) => void; +} + +const ActivityFilter: React.FC = ({ + selectedType, + onSelectType, +}) => { + const selectedValue = selectedType ?? ALL_FILTER_VALUE; + + const handleValueChange = useCallback( + (value: string) => { + onSelectType( + value === ALL_FILTER_VALUE ? undefined : (value as PointsEventEarnType), + ); + }, + [onSelectType], + ); + + return ( + + + + + + ); +}; const LoadingFooter: React.FC = () => ( @@ -81,6 +146,18 @@ export const ActivityTab: React.FC = () => { const seasonId = useSelector(selectSeasonId); const seasonStatusLoading = useSelector(selectSeasonStatusLoading); const seasonStartDate = useSelector(selectSeasonStartDate); + const activeTab = useSelector(selectActiveTab); + const [selectedType, setSelectedType] = useState< + PointsEventEarnType | undefined + >(undefined); + + const handleSelectType = useCallback( + (type: PointsEventEarnType | undefined) => { + setSelectedType(type); + }, + [], + ); + const { pointsEvents, isLoading, @@ -92,7 +169,10 @@ export const ActivityTab: React.FC = () => { } = usePointsEvents({ seasonId: seasonId ?? undefined, subscriptionId: subscriptionId || '', + type: selectedType, + enabled: activeTab === 'activity', }); + const accountNameRequests = useMemo( () => pointsEvents?.map((event) => ({ @@ -122,65 +202,64 @@ export const ActivityTab: React.FC = () => { return ; }; - if ( - (isLoading || (seasonStatusLoading && !!seasonStartDate)) && - !isRefreshing - ) { - return ( - - ); - } else if ( - !isLoading && - !error && - pointsEvents && - pointsEvents.length === 0 - ) { - return null; - } - - if (error && !pointsEvents?.length) { - return ( - - ); - } - - // Determine what to render based on loading state and data + // Determine loading and content states + const isInitialLoading = + (isLoading || (seasonStatusLoading && !!seasonStartDate)) && !isRefreshing; const shouldShowLoadingSkeleton = (isLoading || pointsEvents === null) && !pointsEvents?.length && !error; - + const hasError = error && !pointsEvents?.length; const hasPointsEvents = pointsEvents?.length; - if (shouldShowLoadingSkeleton) { - return ; - } - - if (hasPointsEvents) { - return ( - item.id} - showsVerticalScrollIndicator={false} - onEndReached={loadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={renderFooter} - ItemSeparatorComponent={ItemSeparator} - onRefresh={refresh} - refreshing={isRefreshing} - horizontal={false} - /> - ); - } + // Render content based on state + const renderContent = () => { + if (isInitialLoading || shouldShowLoadingSkeleton) { + return ; + } + + if (hasError) { + return ( + + ); + } - return ; + if (hasPointsEvents) { + return ( + item.id} + showsVerticalScrollIndicator={false} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + ItemSeparatorComponent={ItemSeparator} + onRefresh={refresh} + refreshing={isRefreshing} + horizontal={false} + /> + ); + } + + return ; + }; + + return ( + + + {renderContent()} + + ); }; diff --git a/app/components/UI/Rewards/hooks/usePointsEvents.test.ts b/app/components/UI/Rewards/hooks/usePointsEvents.test.ts index 429b937b2d0..20c06e4c9e6 100644 --- a/app/components/UI/Rewards/hooks/usePointsEvents.test.ts +++ b/app/components/UI/Rewards/hooks/usePointsEvents.test.ts @@ -90,11 +90,8 @@ describe('usePointsEvents', () => { // Mock implementation }); - // Default mock for useSelector to return 'activity' tab and null pointsEvents + // Default mock for useSelector to return null pointsEvents mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } if (selector.toString().includes('selectPointsEvents')) { return null; } @@ -103,11 +100,12 @@ describe('usePointsEvents', () => { }); describe('initialization', () => { - it('should return refresh and loadMore functions', () => { + it('returns refresh and loadMore functions', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); @@ -117,7 +115,7 @@ describe('usePointsEvents', () => { expect(typeof result.current.loadMore).toBe('function'); }); - it('should initialize with empty data and not fetch when seasonId is undefined', async () => { + it('initializes with null data and skips fetch when seasonId is undefined', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: undefined, @@ -132,7 +130,7 @@ describe('usePointsEvents', () => { expect(mockCall).not.toHaveBeenCalled(); }); - it('should initialize with empty data and not fetch when subscriptionId is empty', async () => { + it('initializes with null data and skips fetch when subscriptionId is empty', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', @@ -147,19 +145,31 @@ describe('usePointsEvents', () => { expect(mockCall).not.toHaveBeenCalled(); }); - it('should fetch data when both seasonId and subscriptionId are provided', async () => { + it('initializes with null data and skips fetch when enabled is false', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Initial state should show loading - expect(result.current.isLoading).toBe(true); + expect(result.current.pointsEvents).toEqual(null); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasMore).toBe(true); expect(result.current.error).toBeNull(); + expect(mockCall).not.toHaveBeenCalled(); + }); - // Wait for the data to load + it('auto-fetches data when enabled with seasonId and subscriptionId', async () => { + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + }), + ); + + // Wait for auto-fetch to complete await waitFor( () => expect(result.current.isLoading).toBe(false), waitForOptions, @@ -173,6 +183,7 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: null, forceFresh: false, + type: undefined, }, ); @@ -180,34 +191,79 @@ describe('usePointsEvents', () => { expect(result.current.pointsEvents).toEqual([mockPointsEvent]); expect(result.current.hasMore).toBe(true); expect(result.current.error).toBeNull(); - expect(result.current.isLoading).toBe(false); }); - it('should set loading to true at start and false after successful completion', async () => { + it('fetches data when enabled becomes true', async () => { + const { result, rerender } = renderHook( + ({ enabled }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled, + }), + { initialProps: { enabled: false } }, + ); + + // No fetch when disabled + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.pointsEvents).toBeNull(); + + // Enable the hook + rerender({ enabled: true }); + + // Wait for fetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Verify the API was called + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: false, + type: undefined, + }, + ); + + // Verify the data was loaded + expect(result.current.pointsEvents).toEqual([mockPointsEvent]); + }); + + it('sets isRefreshing to true at start and false after successful refresh', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Should start with loading true - expect(result.current.isLoading).toBe(true); + // Call refresh + act(() => { + result.current.refresh(); + }); + + // Starts with isRefreshing true + expect(result.current.isRefreshing).toBe(true); // Wait for the data to load await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); - // Should end with loading false and no error - expect(result.current.isLoading).toBe(false); + // Ends with isRefreshing false and no error + expect(result.current.isRefreshing).toBe(false); expect(result.current.error).toBeNull(); }); }); describe('error handling', () => { - it('should handle fetch errors and manage loading state', async () => { + it('sets error message and stops refreshing on fetch failure', async () => { const fetchError = new Error('Failed to fetch'); mockCall.mockRejectedValueOnce(fetchError); @@ -215,12 +271,14 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Initial state should show loading - expect(result.current.isLoading).toBe(true); - expect(result.current.error).toBeNull(); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); // Wait for the error to be set await waitFor( @@ -228,24 +286,26 @@ describe('usePointsEvents', () => { waitForOptions, ); - expect(result.current.isLoading).toBe(false); + expect(result.current.isRefreshing).toBe(false); expect(result.current.error).toBe('Failed to fetch'); expect(result.current.pointsEvents).toEqual(null); }); - it('should handle unknown errors and manage loading state', async () => { + it('sets generic error message for non-Error objects', async () => { mockCall.mockRejectedValueOnce({}); const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Initial state should show loading - expect(result.current.isLoading).toBe(true); - expect(result.current.error).toBeNull(); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); // Wait for the error to be set await waitFor( @@ -253,12 +313,12 @@ describe('usePointsEvents', () => { waitForOptions, ); - expect(result.current.isLoading).toBe(false); + expect(result.current.isRefreshing).toBe(false); expect(result.current.error).toBe('Unknown error occurred'); expect(result.current.pointsEvents).toEqual(null); }); - it('should set loading to true at start and false after error', async () => { + it('sets isRefreshing to false after error occurs', async () => { const fetchError = new Error('Network error'); mockCall.mockRejectedValueOnce(fetchError); @@ -266,11 +326,14 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Should start with loading true - expect(result.current.isLoading).toBe(true); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); // Wait for the error to be set await waitFor( @@ -278,23 +341,29 @@ describe('usePointsEvents', () => { waitForOptions, ); - // Should end with loading false - expect(result.current.isLoading).toBe(false); + // Ends with isRefreshing false + expect(result.current.isRefreshing).toBe(false); }); }); describe('loadMore functionality', () => { - it('should load more data when loadMore is called', async () => { + it('appends data and uses cursor when loadMore is called', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + // Wait for initial load await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -323,6 +392,7 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: 'next-cursor', forceFresh: false, + type: undefined, }, ); @@ -333,17 +403,23 @@ describe('usePointsEvents', () => { ]); }); - it('should not load more if already loading', async () => { + it('blocks duplicate loadMore calls while already loading', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + // Wait for initial load await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -370,7 +446,7 @@ describe('usePointsEvents', () => { expect(mockCall).toHaveBeenCalledTimes(1); }); - it('should not load more if there are no more results', async () => { + it('skips API call when hasMore is false', async () => { // Mock response with no more results mockCall.mockResolvedValueOnce({ ...mockPaginatedResponse, @@ -381,63 +457,47 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Reset mock to track the next calls - mockCall.mockClear(); - - // Call loadMore + // Call refresh to get initial data act(() => { - result.current.loadMore(); + result.current.refresh(); }); - // Wait for loadMore to complete + // Wait for initial load await waitFor( - () => expect(result.current.isLoadingMore).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); // Verify hasMore is false expect(result.current.hasMore).toBe(false); - // Reset mock again + // Reset mock to track the next calls mockCall.mockClear(); - // Call loadMore again + // Call loadMore act(() => { result.current.loadMore(); }); - // Verify API was not called + // Verify API was not called since hasMore is false expect(mockCall).not.toHaveBeenCalled(); }); }); describe('refresh functionality', () => { - it('should refresh data when refresh is called and manage refreshing state', async () => { + it('fetches data with forceFresh=true and manages refreshing state', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Reset mock to track the next call - mockCall.mockClear(); - // Call refresh act(() => { result.current.refresh(); @@ -461,30 +521,27 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: null, forceFresh: true, + type: undefined, }, ); // Verify refreshing state is false after completion expect(result.current.isRefreshing).toBe(false); + expect(result.current.pointsEvents).toEqual([mockPointsEvent]); }); - it('should handle refresh errors and manage refreshing state', async () => { + it('sets error and stops refreshing on refresh failure', async () => { + // Mock error for refresh + mockCall.mockRejectedValueOnce(new Error('Failed to refresh')); + const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Mock error for refresh - mockCall.mockRejectedValueOnce(new Error('Failed to refresh')); - // Call refresh act(() => { result.current.refresh(); @@ -504,25 +561,21 @@ describe('usePointsEvents', () => { expect(result.current.isRefreshing).toBe(false); }); - it('should set isRefreshing to true at start and false after completion', async () => { + it('transitions isRefreshing from true to false during refresh cycle', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - // Call refresh act(() => { result.current.refresh(); }); - // Should start with isRefreshing true + // Starts with isRefreshing true expect(result.current.isRefreshing).toBe(true); // Wait for refresh to complete @@ -531,208 +584,13 @@ describe('usePointsEvents', () => { waitForOptions, ); - // Should end with isRefreshing false + // Ends with isRefreshing false expect(result.current.isRefreshing).toBe(false); }); }); - describe('activeTab functionality', () => { - it('should fetch data when activeTab changes to activity', async () => { - // Arrange - Start with a different tab - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'overview'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - - const { rerender } = renderHook(() => - usePointsEvents({ - seasonId: 'season-1', - subscriptionId: 'sub-1', - }), - ); - - // Initial load should not happen since we're on 'overview' tab - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Reset mock to track the next call - mockCall.mockClear(); - - // Act - Change activeTab to 'activity' - act(() => { - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - rerender(); - }); - - // Assert - Should trigger fetchPointsEvents - await waitFor( - () => - expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getPointsEvents', - { - seasonId: 'season-1', - subscriptionId: 'sub-1', - cursor: null, - forceFresh: false, - }, - ), - waitForOptions, - ); - }); - - it('should not fetch data when activeTab changes to non-activity tab', async () => { - // Arrange - Start with overview tab (important: set BEFORE rendering) - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'overview'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - - const { rerender } = renderHook(() => - usePointsEvents({ - seasonId: 'season-1', - subscriptionId: 'sub-1', - }), - ); - - // Wait for initial state to settle (no fetch should happen on overview tab) - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Reset mock to track the next call - mockCall.mockClear(); - - // Act - Change activeTab to 'levels' (non-activity) - act(() => { - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'levels'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - rerender(); - }); - - // Wait a bit to ensure no API call is made - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Assert - Should not trigger fetchPointsEvents - expect(mockCall).not.toHaveBeenCalled(); - }); - - it('should not fetch data when activeTab changes to activity but seasonId is missing', async () => { - // Arrange - Start with overview tab - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'overview'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - - const { rerender } = renderHook(() => - usePointsEvents({ - seasonId: undefined, - subscriptionId: 'sub-1', - }), - ); - - // Wait for initial state to settle - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Reset mock to track the next call - mockCall.mockClear(); - - // Act - Change activeTab to 'activity' - act(() => { - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - rerender(); - }); - - // Wait a bit to ensure no API call is made - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Assert - Should not trigger fetchPointsEvents due to missing seasonId - expect(mockCall).not.toHaveBeenCalled(); - }); - - it('should not fetch data when activeTab changes to activity but subscriptionId is empty', async () => { - // Arrange - Start with overview tab - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'overview'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - - const { rerender } = renderHook(() => - usePointsEvents({ - seasonId: 'season-1', - subscriptionId: '', - }), - ); - - // Wait for initial state to settle - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Reset mock to track the next call - mockCall.mockClear(); - - // Act - Change activeTab to 'activity' - act(() => { - mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } - if (selector.toString().includes('selectPointsEvents')) { - return null; - } - return null; - }); - rerender(); - }); - - // Wait a bit to ensure no API call is made - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Assert - Should not trigger fetchPointsEvents due to empty subscriptionId - expect(mockCall).not.toHaveBeenCalled(); - }); - }); - describe('UI store integration', () => { - it('should not load from UI store when pointsEvents is already set', async () => { + it('uses API data over UI store data when refresh is called', async () => { const mockStoreEvents = [ mockPointsEvent, { ...mockPointsEvent, id: 'event-2' }, @@ -741,9 +599,6 @@ describe('usePointsEvents', () => { // Mock useSelector to return data from store mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } if (selector.toString().includes('selectPointsEvents')) { return mockStoreEvents; } @@ -760,25 +615,28 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger API call + act(() => { + result.current.refresh(); + }); + // Wait for API call to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); - // Should use API data, not store data + // Uses API data, not store data expect(result.current.pointsEvents).toEqual(mockApiEvents); }); - it('should not load from UI store when uiStorePointsEvents is null', async () => { + it('uses API data when uiStorePointsEvents is null', async () => { // Mock useSelector to return null from store mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } if (selector.toString().includes('selectPointsEvents')) { return null; } @@ -789,25 +647,28 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger API call + act(() => { + result.current.refresh(); + }); + // Wait for API call to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); - // Should use API data + // Uses API data expect(result.current.pointsEvents).toEqual([mockPointsEvent]); }); - it('should not load from UI store when uiStorePointsEvents is empty array', async () => { + it('uses API data when uiStorePointsEvents is empty array', async () => { // Mock useSelector to return empty array from store mockUseSelector.mockImplementation((selector) => { - if (selector.toString().includes('selectActiveTab')) { - return 'activity'; - } if (selector.toString().includes('selectPointsEvents')) { return []; } @@ -818,32 +679,44 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger API call + act(() => { + result.current.refresh(); + }); + // Wait for API call to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); - // Should use API data, not empty store data + // Uses API data, not empty store data expect(result.current.pointsEvents).toEqual([mockPointsEvent]); }); }); describe('Redux dispatch integration', () => { - it('should dispatch setPointsEventsAction on successful initial fetch', async () => { + it('dispatches setPointsEventsAction on successful fetch', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -854,17 +727,23 @@ describe('usePointsEvents', () => { }); }); - it('should dispatch setPointsEventsAction on successful loadMore', async () => { + it('dispatches setPointsEventsAction with appended data on loadMore', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -889,7 +768,7 @@ describe('usePointsEvents', () => { }); }); - it('should not dispatch setPointsEventsAction on initial fetch error to preserve stale state', async () => { + it('skips dispatch on fetch error to preserve stale state', async () => { const fetchError = new Error('Failed to fetch'); mockCall.mockRejectedValueOnce(fetchError); @@ -897,9 +776,15 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); + // Wait for error to be set await waitFor( () => expect(result.current.error).not.toBeNull(), @@ -910,17 +795,23 @@ describe('usePointsEvents', () => { expect(mockDispatch).not.toHaveBeenCalled(); }); - it('should preserve stale data when error occurs during refresh', async () => { + it('preserves stale data when error occurs during refresh', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load to complete + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -935,7 +826,7 @@ describe('usePointsEvents', () => { const refreshError = new Error('Failed to refresh'); mockCall.mockRejectedValueOnce(refreshError); - // Call refresh + // Call refresh again act(() => { result.current.refresh(); }); @@ -954,17 +845,23 @@ describe('usePointsEvents', () => { expect(mockDispatch).not.toHaveBeenCalled(); }); - it('should not dispatch setPointsEventsAction on pagination error', async () => { + it('skips dispatch on pagination error to preserve existing data', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -986,26 +883,21 @@ describe('usePointsEvents', () => { waitForOptions, ); - // Verify dispatch was NOT called (should not clear existing data on pagination error) + // Verify dispatch was NOT called (preserves existing data on pagination error) expect(mockDispatch).not.toHaveBeenCalled(); }); }); describe('refreshWithoutForceFresh functionality', () => { - it('should call refresh with forceFresh=false when refreshWithoutForceFresh is called', async () => { - const { result } = renderHook(() => + it('calls API with forceFresh=false when pointsEventsUpdated event fires', async () => { + renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - // Reset mock to track the next call mockCall.mockClear(); @@ -1032,30 +924,23 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: null, forceFresh: false, + type: undefined, }, ); }); }); describe('refresh forceFresh parameter handling', () => { - it('should call API with forceFresh=true when refresh is called without parameter', async () => { + it('calls API with forceFresh=true when refresh is called without parameter', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Reset mock to track the next call - mockCall.mockClear(); - - // Call refresh without parameter (should default to forceFresh=true) + // Call refresh without parameter (defaults to forceFresh=true) act(() => { result.current.refresh(); }); @@ -1074,27 +959,20 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: null, forceFresh: true, + type: undefined, }, ); }); - it('should call API with forceFresh=false when refresh is called with false parameter', async () => { + it('calls API with forceFresh=false when refresh is called with false parameter', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Reset mock to track the next call - mockCall.mockClear(); - // Call refresh with forceFresh=false act(() => { result.current.refresh(false); @@ -1114,13 +992,14 @@ describe('usePointsEvents', () => { subscriptionId: 'sub-1', cursor: null, forceFresh: false, + type: undefined, }, ); }); }); describe('edge cases and additional error handling', () => { - it('should handle empty results from API', async () => { + it('handles empty results from API', async () => { // Mock response with empty results mockCall.mockResolvedValueOnce({ results: [], @@ -1132,12 +1011,18 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); + // Wait for the data to load await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1147,7 +1032,7 @@ describe('usePointsEvents', () => { expect(result.current.error).toBeNull(); }); - it('should handle null cursor in API response', async () => { + it('handles null cursor in API response', async () => { // Mock response with null cursor mockCall.mockResolvedValueOnce({ results: [mockPointsEvent], @@ -1159,12 +1044,18 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); + // Wait for the data to load await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1174,7 +1065,7 @@ describe('usePointsEvents', () => { expect(result.current.error).toBeNull(); }); - it('should handle fetchPointsEvents early return when seasonId is missing', async () => { + it('returns early from fetchPointsEvents when seasonId is missing', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: undefined, @@ -1182,13 +1073,13 @@ describe('usePointsEvents', () => { }), ); - // Should not be loading since seasonId is undefined + // Not loading since seasonId is undefined expect(result.current.isLoading).toBe(false); expect(result.current.pointsEvents).toEqual(null); expect(mockCall).not.toHaveBeenCalled(); }); - it('should handle fetchPointsEvents early return when subscriptionId is empty', async () => { + it('returns early from fetchPointsEvents when subscriptionId is empty', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', @@ -1196,80 +1087,94 @@ describe('usePointsEvents', () => { }), ); - // Should not be loading since subscriptionId is empty + // Not loading since subscriptionId is empty expect(result.current.isLoading).toBe(false); expect(result.current.pointsEvents).toEqual(null); expect(mockCall).not.toHaveBeenCalled(); }); - it('should handle concurrent fetchPointsEvents calls by using isLoadingRef', async () => { - // Mock a slow API response - let resolvePromise: (value: PaginatedPointsEventsDto) => void = () => { + it('allows concurrent first-page requests but discards stale results via activeRequestRef', async () => { + // Mock a slow API response for the first request + let resolveFirstPromise: ( + value: PaginatedPointsEventsDto, + ) => void = () => { // do nothing }; - const slowPromise = new Promise((resolve) => { - resolvePromise = resolve; + const slowFirstPromise = new Promise( + (resolve) => { + resolveFirstPromise = resolve; + }, + ); + + // First call returns slow promise, second returns immediately + mockCall.mockReturnValueOnce(slowFirstPromise).mockResolvedValueOnce({ + results: [{ ...mockPointsEvent, id: 'second-request-event' }], + has_more: false, + cursor: null, }); - mockCall.mockReturnValueOnce(slowPromise); const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Should be loading initially - expect(result.current.isLoading).toBe(true); + // Start first request via refresh + act(() => { + result.current.refresh(); + }); - // Try to trigger another fetch while the first one is still pending - // This should be prevented by isLoadingRef - const subscriptionCalls = mockUseInvalidateByRewardEvents.mock.calls; - const accountLinkedCallback = subscriptionCalls.find((call) => - call[0].includes('RewardsController:accountLinked'), - )?.[1]; + expect(result.current.isRefreshing).toBe(true); - if (accountLinkedCallback) { - await act(async () => { - await accountLinkedCallback(); - }); - } + // Trigger second first-page request via refresh (cancels the first) + act(() => { + result.current.refresh(); + }); - // Resolve the slow promise - if (resolvePromise) { - resolvePromise( - mockPaginatedResponse as unknown as PaginatedPointsEventsDto, - ); - } + // Both requests are made (first-page requests are not blocked) + await waitFor( + () => expect(mockCall).toHaveBeenCalledTimes(2), + waitForOptions, + ); - // Wait for completion + // Wait for second request to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); - // Should only have been called once due to isLoadingRef protection - expect(mockCall).toHaveBeenCalledTimes(1); + // Resolve the first (stale) request + resolveFirstPromise({ + results: [{ ...mockPointsEvent, id: 'first-request-event' }], + has_more: true, + cursor: 'stale-cursor', + } as unknown as PaginatedPointsEventsDto); + + // Wait for stale response to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Result is from second request (stale first request was discarded) + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'second-request-event' }, + ]); + expect(result.current.hasMore).toBe(false); }); - it('should handle refresh error and maintain refreshing state correctly', async () => { + it('sets error and stops refreshing on refresh failure', async () => { + // Mock error for refresh + const refreshError = new Error('Refresh failed'); + mockCall.mockRejectedValueOnce(refreshError); + const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load - await waitFor( - () => expect(result.current.isLoading).toBe(false), - waitForOptions, - ); - - // Mock error for refresh - const refreshError = new Error('Refresh failed'); - mockCall.mockRejectedValueOnce(refreshError); - // Call refresh act(() => { result.current.refresh(); @@ -1289,17 +1194,23 @@ describe('usePointsEvents', () => { expect(result.current.isRefreshing).toBe(false); }); - it('should handle loadMore error and maintain loadingMore state correctly', async () => { + it('sets error and stops loadingMore on loadMore failure', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1326,7 +1237,7 @@ describe('usePointsEvents', () => { expect(result.current.isLoadingMore).toBe(false); }); - it('should handle loadMore when cursor is null', async () => { + it('skips loadMore API call when cursor is null', async () => { // Mock response with null cursor mockCall.mockResolvedValueOnce({ results: [mockPointsEvent], @@ -1338,12 +1249,18 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1355,11 +1272,11 @@ describe('usePointsEvents', () => { result.current.loadMore(); }); - // Should not make API call since cursor is null + // Does not make API call since cursor is null expect(mockCall).not.toHaveBeenCalled(); }); - it('should handle loadMore when hasMore is false', async () => { + it('skips loadMore API call when hasMore is false', async () => { // Mock response with has_more: false mockCall.mockResolvedValueOnce({ results: [mockPointsEvent], @@ -1371,12 +1288,18 @@ describe('usePointsEvents', () => { usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1388,21 +1311,27 @@ describe('usePointsEvents', () => { result.current.loadMore(); }); - // Should not make API call since hasMore is false + // Does not make API call since hasMore is false expect(mockCall).not.toHaveBeenCalled(); }); - it('should handle loadMore when already loading more', async () => { + it('blocks duplicate loadMore calls via isLoadingRef', async () => { const { result } = renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); - // Wait for initial load + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete await waitFor( - () => expect(result.current.isLoading).toBe(false), + () => expect(result.current.isRefreshing).toBe(false), waitForOptions, ); @@ -1425,17 +1354,18 @@ describe('usePointsEvents', () => { waitForOptions, ); - // Should only have been called once + // Only called once (duplicate blocked by isLoadingRef) expect(mockCall).toHaveBeenCalledTimes(1); }); }); describe('event subscriptions', () => { - it('should subscribe to reward events', () => { + it('subscribes to accountLinked and rewardClaimed events', () => { renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); @@ -1446,11 +1376,12 @@ describe('usePointsEvents', () => { ); }); - it('should subscribe to pointsEventsUpdated event separately', () => { + it('subscribes to pointsEventsUpdated event separately', () => { renderHook(() => usePointsEvents({ seasonId: 'season-1', subscriptionId: 'sub-1', + enabled: false, }), ); @@ -1461,4 +1392,959 @@ describe('usePointsEvents', () => { ); }); }); + + describe('type filtering functionality', () => { + it('passes type parameter to API call when provided', async () => { + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type: 'SWAP', + enabled: false, + }), + ); + + // Call refresh to trigger fetch + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Verify the API was called with type parameter + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: true, + type: 'SWAP', + }, + ); + }); + + it('passes type parameter in loadMore call', async () => { + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type: 'PERPS', + enabled: false, + }), + ); + + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Call loadMore + act(() => { + result.current.loadMore(); + }); + + // Wait for loadMore to complete + await waitFor( + () => expect(result.current.isLoadingMore).toBe(false), + waitForOptions, + ); + + // Verify API call includes type parameter + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: 'next-cursor', + forceFresh: false, + type: 'PERPS', + }, + ); + }); + + it('passes type parameter in refresh call', async () => { + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type: 'CARD', + enabled: false, + }), + ); + + // Call refresh + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Verify API call includes type parameter + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: true, + type: 'CARD', + }, + ); + }); + + it('refetches when type changes while enabled', async () => { + // Arrange - Start enabled with no type + const { result, rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type, + enabled: true, + }), + { initialProps: { type: undefined as 'SWAP' | undefined } }, + ); + + // Wait for initial auto-fetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Verify initial call without type + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: false, + type: undefined, + }, + ); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Act - Change type + rerender({ type: 'SWAP' }); + + // Wait for refetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Assert - called API with new type + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: false, + type: 'SWAP', + }, + ); + }); + + it('resets cursor and hasMore when type changes', async () => { + // Arrange - Start with some type, disabled + const { result, rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type, + enabled: false, + }), + { initialProps: { type: 'SWAP' as 'SWAP' | 'PERPS' } }, + ); + + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Load more to set cursor + act(() => { + result.current.loadMore(); + }); + + await waitFor( + () => expect(result.current.isLoadingMore).toBe(false), + waitForOptions, + ); + + // Verify we have more data loaded + expect(result.current.pointsEvents?.length).toBeGreaterThan(1); + + // Reset mock completely and set new response for type change + mockCall.mockReset(); + mockCall.mockResolvedValue({ + results: [{ ...mockPointsEvent, id: 'perps-event' }], + has_more: true, + cursor: 'new-cursor', + }); + + // Act - Change type (this won't auto-fetch since enabled is false) + rerender({ type: 'PERPS' }); + + // Manually call refresh to trigger fetch with new type + act(() => { + result.current.refresh(); + }); + + // Wait for refetch to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Assert - fetched with new type + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: true, + type: 'PERPS', + }, + ); + + // Data is from the new fetch only + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'perps-event' }, + ]); + }); + + it('skips refetch when type stays the same and enabled is false', async () => { + // Arrange - Start with a type, disabled + const { result, rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type, + enabled: false, + }), + { initialProps: { type: 'SWAP' as const } }, + ); + + // Call refresh to get initial data + act(() => { + result.current.refresh(); + }); + + // Wait for refresh to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Act - Rerender with same type + rerender({ type: 'SWAP' }); + + // Wait a bit to ensure no API call is made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - does not call API again + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('auto-fetches on initial mount when enabled with type', async () => { + // Hook auto-fetches when enabled and has valid params + mockCall.mockClear(); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type: 'SWAP', + enabled: true, + }), + ); + + // Wait for auto-fetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // API was called with type + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-1', + cursor: null, + forceFresh: false, + type: 'SWAP', + }, + ); + }); + + it('does not fetch on initial mount when enabled is false', async () => { + mockCall.mockClear(); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type: 'SWAP', + enabled: false, + }), + ); + + // Wait for initial state to settle + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Not loading since enabled is false + expect(result.current.isLoading).toBe(false); + + // No API call since enabled is false + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('sets pointsEvents to null when type changes while enabled', async () => { + // Arrange - Start enabled with some data + const { result, rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + type, + enabled: true, + }), + { initialProps: { type: 'SWAP' as 'SWAP' | 'PERPS' } }, + ); + + // Wait for auto-fetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Verify we have data + expect(result.current.pointsEvents).not.toBeNull(); + + // Act - Change type (this sets pointsEvents to null before fetching) + rerender({ type: 'PERPS' }); + + // Wait for refetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // After fetch completes, we have new data + expect(result.current.pointsEvents).toEqual([mockPointsEvent]); + }); + + it('skips refetch when type changes but seasonId is missing', async () => { + // Arrange - Start without seasonId + const { rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: undefined, + subscriptionId: 'sub-1', + type, + enabled: true, + }), + { initialProps: { type: undefined as 'SWAP' | undefined } }, + ); + + // Wait for initial state to settle (no fetch happens) + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Act - Change type + rerender({ type: 'SWAP' }); + + // Wait a bit to ensure no API call is made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - does not call API due to missing seasonId + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('skips refetch when type changes but subscriptionId is empty', async () => { + // Arrange - Start without subscriptionId + const { rerender } = renderHook( + ({ type }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: '', + type, + enabled: true, + }), + { initialProps: { type: undefined as 'SWAP' | undefined } }, + ); + + // Wait for initial state to settle (no fetch happens) + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Act - Change type + rerender({ type: 'SWAP' }); + + // Wait a bit to ensure no API call is made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - does not call API due to empty subscriptionId + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('discards stale results when refresh is called during in-flight request', async () => { + // Arrange - Create controllable promises for requests + const requestPromises: { + resolve: (value: { + results: (typeof mockPointsEvent)[]; + has_more: boolean; + cursor: string | null; + }) => void; + }[] = []; + + mockCall.mockImplementation( + () => + new Promise((resolve) => { + requestPromises.push({ resolve }); + }), + ); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled: false, + }), + ); + + // Start first request via refresh + act(() => { + result.current.refresh(); + }); + + expect(result.current.isRefreshing).toBe(true); + await waitFor( + () => expect(requestPromises.length).toBe(1), + waitForOptions, + ); + + // Call refresh while first request is still in-flight + act(() => { + result.current.refresh(); + }); + + // Wait for second request to be made + await waitFor( + () => expect(requestPromises.length).toBe(2), + waitForOptions, + ); + + // Resolve the second (refresh) request first + requestPromises[1].resolve({ + results: [ + { ...mockPointsEvent, id: 'refresh-event', title: 'Refresh Event' }, + ], + has_more: false, + cursor: null, + }); + + // Wait for loading to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Now resolve the first (stale) request + requestPromises[0].resolve({ + results: [ + { ...mockPointsEvent, id: 'stale-event', title: 'Stale Event' }, + ], + has_more: true, + cursor: 'old-cursor', + }); + + // Wait a bit to ensure the stale response is processed (but discarded) + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - Result is refresh data, not the stale data + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'refresh-event', title: 'Refresh Event' }, + ]); + expect(result.current.hasMore).toBe(false); + }); + + it('discards stale results when multiple refreshes are called', async () => { + // Arrange - Create controllable promises for each request + const requestPromises: { + resolve: (value: { + results: (typeof mockPointsEvent)[]; + has_more: boolean; + cursor: string | null; + }) => void; + }[] = []; + + mockCall.mockImplementation( + () => + new Promise((resolve) => { + requestPromises.push({ resolve }); + }), + ); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled: false, + }), + ); + + // Start first request via refresh + act(() => { + result.current.refresh(); + }); + + expect(result.current.isRefreshing).toBe(true); + await waitFor( + () => expect(requestPromises.length).toBe(1), + waitForOptions, + ); + + // Call refresh twice while requests are in-flight + act(() => { + result.current.refresh(); + }); + await waitFor( + () => expect(requestPromises.length).toBe(2), + waitForOptions, + ); + + act(() => { + result.current.refresh(); + }); + await waitFor( + () => expect(requestPromises.length).toBe(3), + waitForOptions, + ); + + // Resolve requests out of order: first the oldest, then newest, then middle + // Resolve first request (stale) + requestPromises[0].resolve({ + results: [{ ...mockPointsEvent, id: 'first-event' }], + has_more: false, + cursor: null, + }); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Resolve third request (current) + requestPromises[2].resolve({ + results: [{ ...mockPointsEvent, id: 'third-event' }], + has_more: false, + cursor: null, + }); + + // Wait for loading to complete + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Now resolve the middle request (stale) + requestPromises[1].resolve({ + results: [{ ...mockPointsEvent, id: 'second-event' }], + has_more: true, + cursor: 'middle-cursor', + }); + + // Wait a bit to ensure the stale response is processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - Result is third (latest) request data + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'third-event' }, + ]); + expect(result.current.hasMore).toBe(false); + }); + }); + + describe('enabled option', () => { + it('refetches when subscriptionId changes while enabled', async () => { + const { result, rerender } = renderHook( + ({ subscriptionId }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId, + enabled: true, + }), + { initialProps: { subscriptionId: 'sub-1' } }, + ); + + // Wait for initial auto-fetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Reset mock to track the next call + mockCall.mockClear(); + + // Change subscriptionId (e.g., account switch) + rerender({ subscriptionId: 'sub-2' }); + + // Wait for refetch to complete + await waitFor( + () => expect(result.current.isLoading).toBe(false), + waitForOptions, + ); + + // Assert - called API with new subscriptionId + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + { + seasonId: 'season-1', + subscriptionId: 'sub-2', + cursor: null, + forceFresh: false, + type: undefined, + }, + ); + }); + + it('does not refetch when subscriptionId changes while disabled', async () => { + const { result, rerender } = renderHook( + ({ subscriptionId }) => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId, + enabled: false, + }), + { initialProps: { subscriptionId: 'sub-1' } }, + ); + + // No fetch since disabled + expect(mockCall).not.toHaveBeenCalled(); + + // Change subscriptionId + rerender({ subscriptionId: 'sub-2' }); + + // Wait a bit to ensure no API call is made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - still no API call since disabled + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.pointsEvents).toBeNull(); + }); + + it('keeps isRefreshing true when refresh is cancelled by newer request', async () => { + // Arrange - Create controllable promises for requests + const requestPromises: { + resolve: (value: { + results: (typeof mockPointsEvent)[]; + has_more: boolean; + cursor: string | null; + }) => void; + }[] = []; + + mockCall.mockImplementation( + () => + new Promise((resolve) => { + requestPromises.push({ resolve }); + }), + ); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled: false, + }), + ); + + // Start first refresh + act(() => { + result.current.refresh(); + }); + + expect(result.current.isRefreshing).toBe(true); + await waitFor( + () => expect(requestPromises.length).toBe(1), + waitForOptions, + ); + + // Start second refresh (cancels first) + act(() => { + result.current.refresh(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(2), + waitForOptions, + ); + + // Resolve first request (cancelled) + requestPromises[0].resolve({ + results: [{ ...mockPointsEvent, id: 'cancelled-event' }], + has_more: false, + cursor: null, + }); + + // isRefreshing should still be true because second request is still in flight + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(result.current.isRefreshing).toBe(true); + + // Resolve second request + requestPromises[1].resolve({ + results: [{ ...mockPointsEvent, id: 'final-event' }], + has_more: false, + cursor: null, + }); + + // Now isRefreshing should be false + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'final-event' }, + ]); + }); + + it('discards stale pagination results when first-page request starts', async () => { + // Arrange - Create controllable promises for requests + const requestPromises: { + resolve: (value: { + results: (typeof mockPointsEvent)[]; + has_more: boolean; + cursor: string | null; + }) => void; + }[] = []; + + mockCall.mockImplementation( + () => + new Promise((resolve) => { + requestPromises.push({ resolve }); + }), + ); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled: false, + }), + ); + + // Get initial data via refresh + act(() => { + result.current.refresh(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(1), + waitForOptions, + ); + + // Resolve initial request with has_more: true and cursor + requestPromises[0].resolve({ + results: [{ ...mockPointsEvent, id: 'initial-event' }], + has_more: true, + cursor: 'page-2-cursor', + }); + + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'initial-event' }, + ]); + + // Start pagination request (loadMore) + act(() => { + result.current.loadMore(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(2), + waitForOptions, + ); + + expect(result.current.isLoadingMore).toBe(true); + + // While pagination is in flight, start a new first-page request (e.g., filter change) + act(() => { + result.current.refresh(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(3), + waitForOptions, + ); + + // Resolve the pagination request (stale - should be discarded) + requestPromises[1].resolve({ + results: [{ ...mockPointsEvent, id: 'stale-pagination-event' }], + has_more: false, + cursor: null, + }); + + // Wait a bit for stale response to be processed + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Resolve the new first-page request + requestPromises[2].resolve({ + results: [{ ...mockPointsEvent, id: 'new-filtered-event' }], + has_more: false, + cursor: null, + }); + + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Assert - only new filtered data, no stale pagination data appended + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'new-filtered-event' }, + ]); + }); + + it('resets isLoadingMore when pagination is cancelled by first-page request', async () => { + // Arrange - Create controllable promises for requests + const requestPromises: { + resolve: (value: { + results: (typeof mockPointsEvent)[]; + has_more: boolean; + cursor: string | null; + }) => void; + }[] = []; + + mockCall.mockImplementation( + () => + new Promise((resolve) => { + requestPromises.push({ resolve }); + }), + ); + + const { result } = renderHook(() => + usePointsEvents({ + seasonId: 'season-1', + subscriptionId: 'sub-1', + enabled: false, + }), + ); + + // Get initial data via refresh + act(() => { + result.current.refresh(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(1), + waitForOptions, + ); + + requestPromises[0].resolve({ + results: [{ ...mockPointsEvent, id: 'initial-event' }], + has_more: true, + cursor: 'page-2-cursor', + }); + + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // Start pagination request + act(() => { + result.current.loadMore(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(2), + waitForOptions, + ); + + expect(result.current.isLoadingMore).toBe(true); + + // Start first-page request while pagination is in flight + act(() => { + result.current.refresh(); + }); + + await waitFor( + () => expect(requestPromises.length).toBe(3), + waitForOptions, + ); + + // Resolve cancelled pagination request + requestPromises[1].resolve({ + results: [{ ...mockPointsEvent, id: 'cancelled-pagination' }], + has_more: false, + cursor: null, + }); + + // Wait for cancelled response to be processed + await new Promise((resolve) => setTimeout(resolve, 50)); + + // isLoadingMore should be reset since pagination was cancelled + // (the finally block doesn't run setIsLoadingMore(false) for cancelled requests, + // but the cancellation check returns early before the finally block resets it) + // Actually, let's verify the state after the first-page request completes + requestPromises[2].resolve({ + results: [{ ...mockPointsEvent, id: 'new-data' }], + has_more: false, + cursor: null, + }); + + await waitFor( + () => expect(result.current.isRefreshing).toBe(false), + waitForOptions, + ); + + // isLoadingMore should be false after everything completes + expect(result.current.isLoadingMore).toBe(false); + expect(result.current.pointsEvents).toEqual([ + { ...mockPointsEvent, id: 'new-data' }, + ]); + }); + }); }); diff --git a/app/components/UI/Rewards/hooks/usePointsEvents.ts b/app/components/UI/Rewards/hooks/usePointsEvents.ts index 5075d741b42..27563f7ea64 100644 --- a/app/components/UI/Rewards/hooks/usePointsEvents.ts +++ b/app/components/UI/Rewards/hooks/usePointsEvents.ts @@ -1,17 +1,19 @@ import { useCallback, useRef, useState, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import Engine from '../../../../core/Engine/Engine'; -import { PointsEventDto } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import { - selectActiveTab, - selectPointsEvents, -} from '../../../../reducers/rewards/selectors'; + PointsEventDto, + PointsEventEarnType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import { selectPointsEvents } from '../../../../reducers/rewards/selectors'; import { strings } from '../../../../../locales/i18n'; import { setPointsEvents as setPointsEventsAction } from '../../../../reducers/rewards'; export interface UsePointsEventsOptions { seasonId: string | undefined; subscriptionId: string; + type?: PointsEventEarnType; + enabled?: boolean; } export interface UsePointsEventsResult { @@ -28,44 +30,70 @@ export interface UsePointsEventsResult { export const usePointsEvents = ( options: UsePointsEventsOptions, ): UsePointsEventsResult => { - const { seasonId, subscriptionId } = options; + const { seasonId, subscriptionId, type, enabled = true } = options; const dispatch = useDispatch(); - const activeTab = useSelector(selectActiveTab); const uiStorePointsEvents = useSelector(selectPointsEvents); const [pointsEvents, setPointsEvents] = useState( null, ); - const [isLoading, setIsLoading] = useState(!!seasonId && !!subscriptionId); + const [isLoading, setIsLoading] = useState( + enabled && !!seasonId && !!subscriptionId, + ); const [isLoadingMore, setIsLoadingMore] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [hasMore, setHasMore] = useState(true); const [cursor, setCursor] = useState(null); const isLoadingRef = useRef(false); const [error, setError] = useState(null); + // Tracks the active first-page request for cancellation when dependencies change + const activeRequestRef = useRef<{ cancelled: boolean } | null>(null); + // Tracks the active pagination request for cancellation when a first-page request starts + const activePaginationRequestRef = useRef<{ cancelled: boolean } | null>( + null, + ); const fetchPointsEvents = useCallback( async ({ - isInitial, + isFirstPage, currentCursor = null, forceFresh = false, }: { - isInitial: boolean; + isFirstPage: boolean; currentCursor?: string | null; forceFresh?: boolean; - }) => { - if (isLoadingRef.current) { - return; + }): Promise<{ cancelled: boolean }> => { + // For pagination, prevent concurrent requests + if (!isFirstPage && isLoadingRef.current) { + return { cancelled: false }; } isLoadingRef.current = true; - if (isInitial) { + + // Track request for cancellation + let request: { cancelled: boolean } | null = null; + if (isFirstPage) { + // Cancel any in-flight first-page request + if (activeRequestRef.current) { + activeRequestRef.current.cancelled = true; + } + // Also cancel any in-flight pagination request + if (activePaginationRequestRef.current) { + activePaginationRequestRef.current.cancelled = true; + // Reset isLoadingMore since we're cancelling the pagination request + setIsLoadingMore(false); + } + request = { cancelled: false }; + activeRequestRef.current = request; setIsLoading(true); setError(null); } else { + // Track pagination request for cancellation + request = { cancelled: false }; + activePaginationRequestRef.current = request; setIsLoadingMore(true); } try { - if (!seasonId || !subscriptionId) return; + if (!seasonId || !subscriptionId) return { cancelled: false }; const pointsEventsData = await Engine.controllerMessenger.call( 'RewardsController:getPointsEvents', { @@ -73,10 +101,16 @@ export const usePointsEvents = ( subscriptionId, cursor: currentCursor, forceFresh, + type, }, ); - if (isInitial) { + // Discard results if this request was superseded + if (request?.cancelled) { + return { cancelled: true }; + } + + if (isFirstPage) { setPointsEvents(pointsEventsData.results); dispatch(setPointsEventsAction(pointsEventsData.results)); } else { @@ -92,27 +126,33 @@ export const usePointsEvents = ( setCursor(pointsEventsData.cursor); setHasMore(pointsEventsData.has_more); } catch (err) { + if (request?.cancelled) { + return { cancelled: true }; + } const errorMessage = err instanceof Error ? err.message : strings('rewards.error_messages.unknown_error'); setError(errorMessage); } finally { - isLoadingRef.current = false; - if (isInitial) { - setIsLoading(false); - } else { - setIsLoadingMore(false); + if (!request?.cancelled) { + isLoadingRef.current = false; + if (isFirstPage) { + setIsLoading(false); + } else { + setIsLoadingMore(false); + } } } + return { cancelled: false }; }, - [seasonId, subscriptionId, dispatch], + [seasonId, subscriptionId, type, dispatch], ); const loadMore = useCallback(() => { if (!isLoadingMore && hasMore && cursor) { fetchPointsEvents({ - isInitial: false, + isFirstPage: false, currentCursor: cursor, }); } @@ -120,6 +160,7 @@ export const usePointsEvents = ( useEffect(() => { if ( + !isLoading && pointsEvents === null && uiStorePointsEvents !== null && uiStorePointsEvents?.length > 0 @@ -127,6 +168,7 @@ export const usePointsEvents = ( setPointsEvents(uiStorePointsEvents); } }, [ + isLoading, pointsEvents, seasonId, subscriptionId, @@ -139,11 +181,14 @@ export const usePointsEvents = ( setIsRefreshing(true); setCursor(null); setHasMore(true); - await fetchPointsEvents({ - isInitial: true, + const result = await fetchPointsEvents({ + isFirstPage: true, forceFresh, }); - setIsRefreshing(false); + // Only dismiss refresh indicator if this request wasn't cancelled + if (!result.cancelled) { + setIsRefreshing(false); + } }, [fetchPointsEvents], ); @@ -152,12 +197,19 @@ export const usePointsEvents = ( refresh(false); }, [refresh]); - // Listen for activeTab changes to refresh when switching to activity tab + // Fetch data when enabled becomes true or when dependencies change while enabled useEffect(() => { - if (activeTab === 'activity') { - fetchPointsEvents({ isInitial: true }); + // Only fetch if enabled and we have required params + if (!enabled || !seasonId || !subscriptionId) { + return; } - }, [activeTab, fetchPointsEvents]); + + setIsRefreshing(false); + setCursor(null); + setHasMore(true); + setPointsEvents(null); + fetchPointsEvents({ isFirstPage: true }); + }, [enabled, fetchPointsEvents, seasonId, subscriptionId]); // Listen for reward claimed events to trigger refetch useInvalidateByRewardEvents( diff --git a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx index ed827014587..0cb46b8df69 100644 --- a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx +++ b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx @@ -2,7 +2,7 @@ import { renderScreen } from '../../../util/test/renderWithProvider'; import React from 'react'; import OptionsSheet from './OptionsSheet'; import Routes from '../../../constants/navigation/Routes'; -import { SELECT_OPTION_PREFIX, SELECT_VALUE_TICK_PREFIX } from './constants'; +import { SELECT_OPTION_PREFIX } from './constants'; import { fireEvent } from '@testing-library/react-native'; import { ISelectOptionSheet } from './types'; @@ -43,11 +43,6 @@ describe('OptionSheet', () => { expect(getByText('option 1')).toBeDefined(); }); - it('shows the selected option with tick', () => { - const { getByTestId } = render(OptionsSheet); - expect(getByTestId(SELECT_VALUE_TICK_PREFIX + 'key2')).toBeDefined(); - }); - it('calls onValueChange when an option is selected', () => { const { getByTestId } = render(OptionsSheet); const option1 = getByTestId(SELECT_OPTION_PREFIX + 'key1'); diff --git a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx index 54e294e8c4a..e8fb7039033 100644 --- a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx +++ b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx @@ -3,7 +3,6 @@ import BottomSheet, { } from '../../../component-library/components/BottomSheets/BottomSheet'; import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; -import IconCheck from 'react-native-vector-icons/MaterialCommunityIcons'; import React, { useRef } from 'react'; import { createNavigationDetails, @@ -13,7 +12,7 @@ import { OptionsSheetParams } from './types'; import { useTheme } from '../../../util/theme'; import createStyles from './styles'; import Routes from '../../../constants/navigation/Routes'; -import { SELECT_OPTION_PREFIX, SELECT_VALUE_TICK_PREFIX } from './constants'; +import { SELECT_OPTION_PREFIX } from './constants'; export const createOptionsSheetNavDetails = (params: OptionsSheetParams) => createNavigationDetails(Routes.OPTIONS_SHEET)({ @@ -41,29 +40,26 @@ const OptionsSheet = () => { - {options.map((option) => ( - - option.value && onSelectedValueChange(option.value) - } - style={styles.optionButton} - key={option.key} - testID={SELECT_OPTION_PREFIX + option.key} - > - - {option.label} - - {params.selectedValue === option.value ? ( - - ) : null} - - ))} + {options.map((option) => { + const isSelected = option.value === params.selectedValue; + return ( + + option.value && onSelectedValueChange(option.value) + } + style={[ + styles.optionButton, + isSelected && styles.optionButtonSelected, + ]} + key={option.key} + testID={SELECT_OPTION_PREFIX + option.key} + > + + {option.label} + + + ); + })} diff --git a/app/components/UI/SelectOptionSheet/SelectOptionSheet.tsx b/app/components/UI/SelectOptionSheet/SelectOptionSheet.tsx index b52382655b0..62c5bdcda9b 100644 --- a/app/components/UI/SelectOptionSheet/SelectOptionSheet.tsx +++ b/app/components/UI/SelectOptionSheet/SelectOptionSheet.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; -import { baseStyles } from '../../../styles/common'; -import Icon from 'react-native-vector-icons/MaterialIcons'; +import { + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; import { useTheme } from '../../../util/theme'; import createStyles from './styles'; import { ISelectOptionSheet } from './types'; @@ -43,21 +47,19 @@ const SelectOptionSheet = ({ }; return ( - - - - - {renderDisplayValue()} - - - - - + + + + {renderDisplayValue()} + + + + ); }; diff --git a/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap b/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap index d173ba5f163..c52f4ff981e 100644 --- a/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap +++ b/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap @@ -490,14 +490,17 @@ exports[`OptionSheet render matches snapshot 1`] = ` @@ -508,7 +511,8 @@ exports[`OptionSheet render matches snapshot 1`] = ` "color": "#121314", "flex": 1, "fontFamily": "Geist-Regular", - "fontSize": 14, + "fontSize": 16, + "fontWeight": 500, } } > @@ -518,14 +522,19 @@ exports[`OptionSheet render matches snapshot 1`] = ` @@ -536,36 +545,13 @@ exports[`OptionSheet render matches snapshot 1`] = ` "color": "#121314", "flex": 1, "fontFamily": "Geist-Regular", - "fontSize": 14, + "fontSize": 16, + "fontWeight": 500, } } > option 2 - - 󰄬 - diff --git a/app/components/UI/SelectOptionSheet/__snapshots__/SelectOptionSheet.test.tsx.snap b/app/components/UI/SelectOptionSheet/__snapshots__/SelectOptionSheet.test.tsx.snap index 9dff6b043e1..4b76eece02e 100644 --- a/app/components/UI/SelectOptionSheet/__snapshots__/SelectOptionSheet.test.tsx.snap +++ b/app/components/UI/SelectOptionSheet/__snapshots__/SelectOptionSheet.test.tsx.snap @@ -1,69 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SelectOptionSheet render matches the snapshot 1`] = ` - - - - + - option 2 - - -  - - - - + "color": "#686e7d", + "height": 12, + "marginRight": 8, + "width": 12, + }, + undefined, + ] + } + /> + + `; diff --git a/app/components/UI/SelectOptionSheet/styles.ts b/app/components/UI/SelectOptionSheet/styles.ts index eb69d461bf2..7385a5577a8 100644 --- a/app/components/UI/SelectOptionSheet/styles.ts +++ b/app/components/UI/SelectOptionSheet/styles.ts @@ -3,28 +3,21 @@ import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../styles/common'; import Device from '../../../util/device'; -export const ROW_HEIGHT = 35; +export const ROW_HEIGHT = 56; const createStyles = (colors: ThemeColors) => StyleSheet.create({ dropdown: { flexDirection: 'row', - }, - iconDropdown: { - marginTop: 7, - height: 25, - justifyContent: 'flex-end', - textAlign: 'right', - marginRight: 10, + alignItems: 'center', }, selectedOption: { - flex: 1, - alignSelf: 'flex-start', color: colors.text.default, fontSize: 14, - paddingHorizontal: 15, - paddingTop: 10, - paddingBottom: 10, + paddingHorizontal: 10, + paddingTop: 8, + paddingBottom: 8, ...fontStyles.normal, + fontWeight: 600, }, label: { textAlign: 'center', @@ -38,16 +31,20 @@ const createStyles = (colors: ThemeColors) => width: '100%', }, optionButton: { - paddingHorizontal: 15, - paddingVertical: 5, + paddingHorizontal: 16, + paddingVertical: 16, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', height: Device.isIos() ? ROW_HEIGHT : undefined, }, + optionButtonSelected: { + backgroundColor: colors.background.muted, + }, optionLabel: { flex: 1, - fontSize: 14, + fontSize: 16, + fontWeight: 500, ...fontStyles.normal, color: colors.text.default, }, diff --git a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap index 4b4e002d1e5..468e9c6d42b 100644 --- a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap @@ -511,71 +511,51 @@ exports[`LedgerConnect render matches latest snapshot 1`] = ` } } > - - - - + - Ledger device - - -  - - - - + "color": "#686e7d", + "height": 12, + "marginRight": 8, + "width": 12, + }, + undefined, + ] + } + /> + + { ); }); }); + + describe('type parameter caching behavior', () => { + let testableController: TestableRewardsController; + + beforeEach(() => { + testableController = new TestableRewardsController({ + messenger: mockMessenger, + isDisabled: () => false, + }); + mockMessenger.publish.mockClear(); + }); + + it('uses cache key with type suffix when type is provided', async () => { + // Arrange + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + type: 'SWAP' as const, + }; + + const cachedPointsEvents = { + results: [ + { + id: 'cached-event-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), // Fresh cache + }; + + // Set up cached points events with type suffix in the key + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123:SWAP'] = cachedPointsEvents; + }); + + // Act + const result = await testableController.getPointsEvents(mockRequest); + + // Assert - should return cached data without calling API + expect(result.results[0].id).toEqual('cached-event-1'); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + expect.anything(), + ); + }); + + it('stores results with type suffix in cache key when type is provided', async () => { + // Arrange + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + type: 'PERPS' as const, + }; + + const freshPointsEvents = { + has_more: false, + cursor: null, + results: [ + { + id: 'fresh-event-1', + type: 'PERPS' as const, + timestamp: new Date(), + value: 200, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: new Date(), + payload: null, + }, + ], + }; + + // When no cache exists for the typed key, hasPointsEventsChanged returns true immediately + // without calling getPointsEventsLastUpdated. So only getPointsEvents is called. + mockMessenger.call.mockResolvedValueOnce(freshPointsEvents); + + // Act + await testableController.getPointsEvents(mockRequest); + + // Assert - verify cache key includes type suffix + const cachedData = + testableController.state.pointsEvents['current:sub-123:PERPS']; + expect(cachedData).toBeDefined(); + expect(cachedData.results[0].id).toEqual('fresh-event-1'); + }); + + it('has separate cache entries for different types', async () => { + // Arrange + const swapCachedData = { + results: [ + { + id: 'swap-event-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), // Fresh cache + }; + + const perpsCachedData = { + results: [ + { + id: 'perps-event-1', + type: 'PERPS' as const, + timestamp: Date.now(), + value: 200, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), // Fresh cache + }; + + // Set up separate cache entries for different types + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123:SWAP'] = swapCachedData; + state.pointsEvents['current:sub-123:PERPS'] = perpsCachedData; + }); + + // Act - request SWAP type + const swapResult = await testableController.getPointsEvents({ + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + type: 'SWAP' as const, + }); + + // Act - request PERPS type + const perpsResult = await testableController.getPointsEvents({ + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + type: 'PERPS' as const, + }); + + // Assert - each request should return its own cached data + expect(swapResult.results[0].id).toEqual('swap-event-1'); + expect(perpsResult.results[0].id).toEqual('perps-event-1'); + }); + + it('uses cache key without type suffix when type is not provided', async () => { + // Arrange + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + const cachedPointsEvents = { + results: [ + { + id: 'all-events-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), // Fresh cache + }; + + // Set up cached points events WITHOUT type suffix + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123'] = cachedPointsEvents; + }); + + // Act + const result = await testableController.getPointsEvents(mockRequest); + + // Assert - should return cached data from key without type suffix + expect(result.results[0].id).toEqual('all-events-1'); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + expect.anything(), + ); + }); + + it('does not use untyped cache when type is provided', async () => { + // Arrange + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + type: 'SWAP' as const, + }; + + const untypedCachedData = { + results: [ + { + id: 'untyped-event-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), // Fresh cache + }; + + const freshTypedData = { + has_more: false, + cursor: null, + results: [ + { + id: 'typed-event-1', + type: 'SWAP' as const, + timestamp: new Date(), + value: 200, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: new Date(), + payload: null, + }, + ], + }; + + // Set up cached data only in untyped key (should NOT be used) + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123'] = untypedCachedData; + }); + + // When no cache exists for the typed key (current:sub-123:SWAP), hasPointsEventsChanged + // returns true immediately without calling getPointsEventsLastUpdated. + // So only getPointsEvents is called. + mockMessenger.call.mockResolvedValueOnce(freshTypedData); + + // Act + const result = await testableController.getPointsEvents(mockRequest); + + // Assert - should NOT return untyped cached data + expect(result.results[0].id).toEqual('typed-event-1'); + }); + }); + }); + + describe('hasPointsEventsChanged', () => { + let testableController: TestableRewardsController; + + beforeEach(() => { + testableController = new TestableRewardsController({ + messenger: mockMessenger, + isDisabled: () => false, + }); + }); + + it('uses cache key with type suffix when type is provided', async () => { + // Arrange + const params = { + seasonId: 'current', + subscriptionId: 'sub-123', + type: 'SWAP' as const, + }; + + const cachedData = { + results: [ + { + id: 'event-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), + }; + + // Set up cached data with type suffix + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123:SWAP'] = cachedData; + }); + + const cachedUpdatedAt = new Date(cachedData.results[0].updatedAt); + mockMessenger.call.mockResolvedValue(cachedUpdatedAt); + + // Act + const result = await testableController.hasPointsEventsChanged(params); + + // Assert - should return false because lastUpdated matches cached updatedAt + expect(result).toBe(false); + }); + + it('returns true when no cached data exists for type-filtered request', async () => { + // Arrange + const params = { + seasonId: 'current', + subscriptionId: 'sub-123', + type: 'PERPS' as const, + }; + + // No cached data set up for PERPS type + + // Act + const result = await testableController.hasPointsEventsChanged(params); + + // Assert - should return true because no cached data exists + expect(result).toBe(true); + }); + + it('uses separate cache entries for different types', async () => { + // Arrange + const swapCachedData = { + results: [ + { + id: 'swap-event-1', + type: 'SWAP' as const, + timestamp: Date.now(), + value: 100, + bonus: { bips: 0, bonuses: [] }, + accountAddress: '0x123', + updatedAt: Date.now(), + payload: null, + }, + ], + has_more: false, + cursor: null, + lastFetched: Date.now(), + }; + + testableController.testUpdate((state) => { + state.pointsEvents['current:sub-123:SWAP'] = swapCachedData; + // No cache for PERPS type + }); + + // SWAP should find cache + const swapUpdatedAt = new Date(swapCachedData.results[0].updatedAt); + mockMessenger.call.mockResolvedValue(swapUpdatedAt); + + const swapResult = await testableController.hasPointsEventsChanged({ + seasonId: 'current', + subscriptionId: 'sub-123', + type: 'SWAP' as const, + }); + + // PERPS should NOT find cache + const perpsResult = await testableController.hasPointsEventsChanged({ + seasonId: 'current', + subscriptionId: 'sub-123', + type: 'PERPS' as const, + }); + + // Assert + expect(swapResult).toBe(false); // Has cache, same timestamp + expect(perpsResult).toBe(true); // No cache + }); }); describe('getPointsEventsLastUpdated', () => { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 57c4739c575..105c07cf55a 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -1486,10 +1486,14 @@ export class RewardsController extends BaseController< } // First page: use cached data with SWR background refresh - const cacheKey = this.#createSeasonSubscriptionCompositeKey( + // Include type in cache key so different filters have separate cache entries + const baseCacheKey = this.#createSeasonSubscriptionCompositeKey( params.seasonId, params.subscriptionId, ); + const cacheKey = params.type + ? `${baseCacheKey}:${params.type}` + : baseCacheKey; const result = await wrapWithCache({ key: cacheKey, @@ -1506,10 +1510,11 @@ export class RewardsController extends BaseController< fetchFresh: async () => { try { Logger.log( - 'RewardsController: Fetching fresh points events data via API call for seasonId & subscriptionId & page cursor', + 'RewardsController: Fetching fresh points events data via API call for seasonId & subscriptionId & type & page cursor', { seasonId: params.seasonId, subscriptionId: params.subscriptionId, + type: params.type, cursor: params.cursor, }, ); @@ -1556,10 +1561,13 @@ export class RewardsController extends BaseController< async getPointsEventsIfChanged( params: GetPointsEventsDto, ): Promise { - const cacheKey = this.#createSeasonSubscriptionCompositeKey( + const baseCacheKey = this.#createSeasonSubscriptionCompositeKey( params.seasonId, params.subscriptionId, ); + const cacheKey = params.type + ? `${baseCacheKey}:${params.type}` + : baseCacheKey; const hasPointsEventsChanged = await this.hasPointsEventsChanged(params); @@ -1612,13 +1620,15 @@ export class RewardsController extends BaseController< const rewardsEnabled = this.isRewardsFeatureEnabled(); if (!rewardsEnabled) return false; - const cached = - this.state.pointsEvents[ - this.#createSeasonSubscriptionCompositeKey( - params.seasonId, - params.subscriptionId, - ) - ]; + const baseCacheKey = this.#createSeasonSubscriptionCompositeKey( + params.seasonId, + params.subscriptionId, + ); + const cacheKey = params.type + ? `${baseCacheKey}:${params.type}` + : baseCacheKey; + + const cached = this.state.pointsEvents[cacheKey]; const cachedLatestUpdatedAt = cached?.results?.[0]?.updatedAt; // If the cache is empty, we need to fetch fresh data diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index d442e2c9876..152738b1f7a 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -517,6 +517,75 @@ describe('RewardsDataService', () => { ); }); + it('successfully gets points events with type filter', async () => { + const requestWithType = { + ...mockGetPointsEventsRequest, + type: 'SWAP' as const, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPointsEvents(requestWithType); + + expect(result).toEqual(mockPointsEventsResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events?type=SWAP', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + }), + ); + }); + + it('successfully gets points events with both cursor and type', async () => { + const requestWithCursorAndType = { + ...mockGetPointsEventsRequest, + cursor: 'cursor-abc123', + type: 'PREDICT' as const, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPointsEvents(requestWithCursorAndType); + + expect(result).toEqual(mockPointsEventsResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events?cursor=cursor-abc123&type=PREDICT', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + }), + ); + }); + + it('properly encodes type parameter in URL', async () => { + const requestWithType = { + ...mockGetPointsEventsRequest, + type: 'SIGN_UP_BONUS' as const, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.getPointsEvents(requestWithType); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events?type=SIGN_UP_BONUS', + expect.any(Object), + ); + }); + it('should include authentication headers with subscription token', async () => { const mockResponse = { ok: true, diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 65c07a64d11..2c422fab2db 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -481,10 +481,14 @@ export class RewardsDataService { async getPointsEvents( params: GetPointsEventsDto, ): Promise { - const { seasonId, subscriptionId, cursor } = params; + const { seasonId, subscriptionId, cursor, type } = params; + + const queryParams: string[] = []; + if (cursor) queryParams.push(`cursor=${encodeURIComponent(cursor)}`); + if (type) queryParams.push(`type=${encodeURIComponent(type)}`); let url = `/seasons/${seasonId}/points-events`; - if (cursor) url += `?cursor=${encodeURIComponent(cursor)}`; + if (queryParams.length > 0) url += `?${queryParams.join('&')}`; const response = await this.makeRequest( url, diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 87fb80ba8c2..13606740ff0 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -176,18 +176,23 @@ export type PointsEventEarnType = | 'REFERRAL' | 'SIGN_UP_BONUS' | 'LOYALTY_BONUS' - | 'ONE_TIME_BONUS'; + | 'ONE_TIME_BONUS' + | 'CARD' + | 'MUSD_DEPOSIT' + | 'SHIELD'; export interface GetPointsEventsDto { seasonId: string; subscriptionId: string; cursor: string | null; forceFresh?: boolean; + type?: PointsEventEarnType; } export interface GetPointsEventsLastUpdatedDto { seasonId: string; subscriptionId: string; + type?: PointsEventEarnType; } /** diff --git a/locales/languages/en.json b/locales/languages/en.json index f6b2c7e4c8f..b81d4d47e59 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7065,6 +7065,15 @@ "activity_empty_title": "No recent activity.", "activity_empty_description": "Use MetaMask to earn points, level up, and unlock rewards.", "activity_empty_link": "See ways to earn", + "filter_title": "Filter by activity type", + "filter_all": "All", + "filter_swap": "Swap", + "filter_perps": "Perps", + "filter_predict": "Predict", + "filter_referral": "Referral", + "filter_card": "Card", + "filter_musd_deposit": "mUSD deposit", + "filter_shield": "Shield", "events": { "to": "to", "musd_deposit_for": "For {{date}}", From ad72e6755f000e642bfd6d04654488f5f4316c27 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 26 Jan 2026 17:27:43 -0500 Subject: [PATCH 2/8] Update skeleton --- .../UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index a1f6be03c83..5994d833e01 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -213,7 +213,7 @@ export const ActivityTab: React.FC = () => { // Render content based on state const renderContent = () => { if (isInitialLoading || shouldShowLoadingSkeleton) { - return ; + return ; } if (hasError) { From c45f0e24b566a4412de2ad35633a3291b775e5ce Mon Sep 17 00:00:00 2001 From: Rik Van Gulck Date: Tue, 27 Jan 2026 11:16:25 +0100 Subject: [PATCH 3/8] pr review --- .../UI/SelectOptionSheet/OptionSheet.test.tsx | 111 ++++++++++++++++++ .../UI/SelectOptionSheet/OptionsSheet.tsx | 21 +++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx index 0cb46b8df69..007cdbba11d 100644 --- a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx +++ b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx @@ -12,6 +12,8 @@ function render(Component: React.ComponentType) { }); } +const mockOnCloseBottomSheet = jest.fn(); + const mockUseParamsValues: ISelectOptionSheet = { options: [ { key: 'key1', value: 'val 1', label: 'option 1' }, @@ -27,7 +29,70 @@ jest.mock('../../../util/navigation/navUtils', () => ({ useParams: jest.fn(() => mockUseParamsValues), })); +// Mock BottomSheet +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return ReactActual.forwardRef( + ( + { children }: { children?: React.ReactNode }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: () => { + mockOnCloseBottomSheet(); + }, + })); + + return ReactActual.createElement( + View, + { testID: 'bottom-sheet' }, + children, + ); + }, + ); + }, +); + +// Mock BottomSheetHeader +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const ReactActual = jest.requireActual('react'); + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ({ + children, + onClose, + }: { + children?: React.ReactNode; + onClose?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'bottom-sheet-header' }, + ReactActual.createElement(Text, { testID: 'header-title' }, children), + onClose && + ReactActual.createElement( + TouchableOpacity, + { testID: 'close-button', onPress: onClose }, + ReactActual.createElement(Text, {}, 'Close'), + ), + ), + }; + }, +); + describe('OptionSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -49,4 +114,50 @@ describe('OptionSheet', () => { fireEvent.press(option1); expect(mockUseParamsValues.onValueChange).toHaveBeenCalledWith('val 1'); }); + + it('sorts options alphabetically by label', () => { + // Update mock to have unsorted options + mockUseParamsValues.options = [ + { key: 'key3', value: 'val 3', label: 'Zebra' }, + { key: 'key1', value: 'val 1', label: 'Apple' }, + { key: 'key2', value: 'val 2', label: 'Banana' }, + ]; + + const { getByText, getAllByTestId } = render(OptionsSheet); + + // Verify all options are present + expect(getByText('Apple')).toBeDefined(); + expect(getByText('Banana')).toBeDefined(); + expect(getByText('Zebra')).toBeDefined(); + + // Get all option buttons in the order they appear + const optionButtons = getAllByTestId( + new RegExp(`^${SELECT_OPTION_PREFIX}`), + ); + + // Verify we have 3 options + expect(optionButtons).toHaveLength(3); + + // Verify options are sorted alphabetically by checking testID order + // testIDs contain the key, so sorted order should be key1 (Apple), key2 (Banana), key3 (Zebra) + const testIds = optionButtons.map((button) => button.props.testID); + expect(testIds).toEqual([ + `${SELECT_OPTION_PREFIX}key1`, + `${SELECT_OPTION_PREFIX}key2`, + `${SELECT_OPTION_PREFIX}key3`, + ]); + }); + + it('renders close button in header', () => { + const { getByTestId } = render(OptionsSheet); + const closeButton = getByTestId('close-button'); + expect(closeButton).toBeDefined(); + }); + + it('calls onCloseBottomSheet when close button is pressed', () => { + const { getByTestId } = render(OptionsSheet); + const closeButton = getByTestId('close-button'); + fireEvent.press(closeButton); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx index e8fb7039033..f635048a7e7 100644 --- a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx +++ b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx @@ -1,9 +1,9 @@ import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; -import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import { createNavigationDetails, useParams, @@ -25,7 +25,12 @@ const OptionsSheet = () => { const { colors } = useTheme(); const styles = createStyles(colors); - const options = params.options; + // Sort options alphabetically by label + const sortedOptions = useMemo(() => [...params.options].sort((a, b) => { + const labelA = a.label || ''; + const labelB = b.label || ''; + return labelA.localeCompare(labelB); + }), [params.options]); const onSelectedValueChange = (val?: string) => { if (!val) { @@ -35,12 +40,18 @@ const OptionsSheet = () => { bottomSheetRef.current?.onCloseBottomSheet(); }; + const handleClose = () => { + bottomSheetRef.current?.onCloseBottomSheet(); + }; + return ( - + + {params.label} + - {options.map((option) => { + {sortedOptions.map((option) => { const isSelected = option.value === params.selectedValue; return ( Date: Tue, 27 Jan 2026 13:06:34 -0500 Subject: [PATCH 4/8] Update OptionSheet.test.tsx.snap --- .../__snapshots__/OptionSheet.test.tsx.snap | 293 +++++------------- 1 file changed, 76 insertions(+), 217 deletions(-) diff --git a/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap b/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap index c52f4ff981e..7455fe05c77 100644 --- a/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap +++ b/app/components/UI/SelectOptionSheet/__snapshots__/OptionSheet.test.tsx.snap @@ -313,251 +313,110 @@ exports[`OptionSheet render matches snapshot 1`] = ` } > + + Select a Account + + testID="close-button" + > + + Close + + - - + - - - - + - + option 1 + + + - Select a Account - - - - - - - - - option 1 - - - - - option 2 - - - - - + option 2 + + + - + From cde6cb238fc2b1282aaf0f41243b40977366fbd4 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 27 Jan 2026 14:11:45 -0500 Subject: [PATCH 5/8] Empty commit From 9ca13733e6b37c65983175e5d993889157c9999d Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 27 Jan 2026 18:29:07 -0500 Subject: [PATCH 6/8] use activityTypes from contentful for filter values --- .../Tabs/ActivityTab/ActivityTab.test.tsx | 41 ++++++++++++++++- .../Tabs/ActivityTab/ActivityTab.tsx | 45 +++++++++---------- .../UI/SelectOptionSheet/OptionsSheet.tsx | 14 +++--- locales/languages/en.json | 7 --- 4 files changed, 70 insertions(+), 37 deletions(-) diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx index d041bf658d6..f3706472093 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx @@ -24,6 +24,7 @@ jest.mock('../../../../../../reducers/rewards/selectors', () => ({ selectSeasonId: jest.fn(), selectSeasonStartDate: jest.fn(), selectSeasonStatusLoading: jest.fn(), + selectSeasonActivityTypes: jest.fn(), })); import { selectRewardsSubscriptionId } from '../../../../../../selectors/rewards'; @@ -32,6 +33,7 @@ import { selectSeasonId, selectSeasonStartDate, selectSeasonStatusLoading, + selectSeasonActivityTypes, } from '../../../../../../reducers/rewards/selectors'; import { UsePointsEventsResult } from '../../../hooks/usePointsEvents'; const mockSelectSubscriptionId = @@ -51,10 +53,45 @@ const mockSelectSeasonStatusLoading = selectSeasonStatusLoading as jest.MockedFunction< typeof selectSeasonStatusLoading >; +const mockSelectSeasonActivityTypes = + selectSeasonActivityTypes as jest.MockedFunction< + typeof selectSeasonActivityTypes + >; // Mock data const mockSubscriptionId: string = 'sub-12345678'; +// Mock activity types that match what the API returns +const mockSeasonActivityTypes = [ + { type: 'SWAP', title: 'Swap', description: 'Swap tokens', icon: 'Swap' }, + { type: 'PERPS', title: 'Perps', description: 'Trade perps', icon: 'Perps' }, + { + type: 'PREDICT', + title: 'Predict', + description: 'Predict outcomes', + icon: 'Predict', + }, + { + type: 'REFERRAL', + title: 'Referral', + description: 'Refer friends', + icon: 'Referral', + }, + { type: 'CARD', title: 'Card', description: 'Use card', icon: 'Card' }, + { + type: 'MUSD_DEPOSIT', + title: 'MUSD Deposit', + description: 'Deposit MUSD', + icon: 'Deposit', + }, + { + type: 'SHIELD', + title: 'Shield', + description: 'Shield assets', + icon: 'Shield', + }, +]; + // Mock i18n strings jest.mock('../../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { @@ -313,7 +350,7 @@ describe('ActivityTab', () => { startDate: Date.now(), endDate: Date.now() + 1000, tiers: [], - activityTypes: [], + activityTypes: mockSeasonActivityTypes, }, balance: { total: 0, @@ -360,6 +397,7 @@ describe('ActivityTab', () => { seasonId: defaultSeasonStatus.season.id, seasonStatusLoading: false, seasonStartDate: new Date('2024-01-01'), + seasonActivityTypes: mockSeasonActivityTypes, }, }; @@ -372,6 +410,7 @@ describe('ActivityTab', () => { mockSelectSeasonId.mockReturnValue(defaultSeasonStatus.season.id); mockSelectSeasonStartDate.mockReturnValue(new Date('2024-01-01')); mockSelectSeasonStatusLoading.mockReturnValue(false); + mockSelectSeasonActivityTypes.mockReturnValue(mockSeasonActivityTypes); mockUseDispatch.mockReturnValue(jest.fn()); mockUseSeasonStatus.mockReturnValue({ diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index 5994d833e01..76bf65da876 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -21,6 +21,7 @@ import { selectSeasonId, selectSeasonStartDate, selectSeasonStatusLoading, + selectSeasonActivityTypes, } from '../../../../../../reducers/rewards/selectors'; import { Skeleton } from '../../../../../../component-library/components/Skeleton'; import MetamaskRewardsActivityEmptyImage from '../../../../../../images/rewards/metamask-rewards-activity-empty.svg'; @@ -35,29 +36,6 @@ import type { ISelectOption } from '../../../../SelectOptionSheet/types'; const ALL_FILTER_VALUE = 'ALL'; -const FILTER_OPTIONS: ISelectOption[] = [ - { key: 'all', value: ALL_FILTER_VALUE, label: strings('rewards.filter_all') }, - { key: 'swap', value: 'SWAP', label: strings('rewards.filter_swap') }, - { key: 'perps', value: 'PERPS', label: strings('rewards.filter_perps') }, - { - key: 'predict', - value: 'PREDICT', - label: strings('rewards.filter_predict'), - }, - { - key: 'referral', - value: 'REFERRAL', - label: strings('rewards.filter_referral'), - }, - { key: 'card', value: 'CARD', label: strings('rewards.filter_card') }, - { - key: 'musd_deposit', - value: 'MUSD_DEPOSIT', - label: strings('rewards.filter_musd_deposit'), - }, - { key: 'shield', value: 'SHIELD', label: strings('rewards.filter_shield') }, -]; - interface ActivityFilterProps { selectedType: PointsEventEarnType | undefined; onSelectType: (type: PointsEventEarnType | undefined) => void; @@ -67,8 +45,27 @@ const ActivityFilter: React.FC = ({ selectedType, onSelectType, }) => { + const seasonActivityTypes = useSelector(selectSeasonActivityTypes); const selectedValue = selectedType ?? ALL_FILTER_VALUE; + const filterOptions: ISelectOption[] = useMemo(() => { + const allOption: ISelectOption = { + key: 'all', + value: ALL_FILTER_VALUE, + label: strings('rewards.filter_all'), + }; + + const activityOptions: ISelectOption[] = seasonActivityTypes.map( + (activityType) => ({ + key: activityType.type.toLowerCase(), + value: activityType.type, + label: activityType.title, + }), + ); + + return [allOption, ...activityOptions]; + }, [seasonActivityTypes]); + const handleValueChange = useCallback( (value: string) => { onSelectType( @@ -83,7 +80,7 @@ const ActivityFilter: React.FC = ({ diff --git a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx index f635048a7e7..74a40eac8ec 100644 --- a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx +++ b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx @@ -26,11 +26,15 @@ const OptionsSheet = () => { const styles = createStyles(colors); // Sort options alphabetically by label - const sortedOptions = useMemo(() => [...params.options].sort((a, b) => { - const labelA = a.label || ''; - const labelB = b.label || ''; - return labelA.localeCompare(labelB); - }), [params.options]); + const sortedOptions = useMemo( + () => + [...params.options].sort((a, b) => { + const labelA = a.label || ''; + const labelB = b.label || ''; + return labelA.localeCompare(labelB); + }), + [params.options], + ); const onSelectedValueChange = (val?: string) => { if (!val) { diff --git a/locales/languages/en.json b/locales/languages/en.json index b81d4d47e59..4c8fb566944 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7067,13 +7067,6 @@ "activity_empty_link": "See ways to earn", "filter_title": "Filter by activity type", "filter_all": "All", - "filter_swap": "Swap", - "filter_perps": "Perps", - "filter_predict": "Predict", - "filter_referral": "Referral", - "filter_card": "Card", - "filter_musd_deposit": "mUSD deposit", - "filter_shield": "Shield", "events": { "to": "to", "musd_deposit_for": "For {{date}}", From 29df849d7586a3f5fa5d0ddd68e785155365c71f Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 27 Jan 2026 18:35:28 -0500 Subject: [PATCH 7/8] Keep All at top --- .../UI/SelectOptionSheet/OptionSheet.test.tsx | 29 +++++++++++++++++++ .../UI/SelectOptionSheet/OptionsSheet.tsx | 17 ++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx index 007cdbba11d..c17eda75c1d 100644 --- a/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx +++ b/app/components/UI/SelectOptionSheet/OptionSheet.test.tsx @@ -148,6 +148,35 @@ describe('OptionSheet', () => { ]); }); + it('keeps "all" option at the top while sorting other options alphabetically', () => { + // Update mock to include 'all' option mixed with other options + mockUseParamsValues.options = [ + { key: 'key3', value: 'val 3', label: 'Zebra' }, + { key: 'all', value: 'ALL', label: 'All' }, + { key: 'key1', value: 'val 1', label: 'Apple' }, + { key: 'key2', value: 'val 2', label: 'Banana' }, + ]; + + const { getAllByTestId } = render(OptionsSheet); + + // Get all option buttons in the order they appear + const optionButtons = getAllByTestId( + new RegExp(`^${SELECT_OPTION_PREFIX}`), + ); + + // Verify we have 4 options + expect(optionButtons).toHaveLength(4); + + // Verify 'all' is first, then remaining options are sorted alphabetically + const testIds = optionButtons.map((button) => button.props.testID); + expect(testIds).toEqual([ + `${SELECT_OPTION_PREFIX}all`, + `${SELECT_OPTION_PREFIX}key1`, + `${SELECT_OPTION_PREFIX}key2`, + `${SELECT_OPTION_PREFIX}key3`, + ]); + }); + it('renders close button in header', () => { const { getByTestId } = render(OptionsSheet); const closeButton = getByTestId('close-button'); diff --git a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx index 74a40eac8ec..fad64f9a38b 100644 --- a/app/components/UI/SelectOptionSheet/OptionsSheet.tsx +++ b/app/components/UI/SelectOptionSheet/OptionsSheet.tsx @@ -25,16 +25,19 @@ const OptionsSheet = () => { const { colors } = useTheme(); const styles = createStyles(colors); - // Sort options alphabetically by label - const sortedOptions = useMemo( - () => - [...params.options].sort((a, b) => { + // Sort options alphabetically by label, keeping 'all' at the top + const sortedOptions = useMemo(() => { + const allOption = params.options.find((opt) => opt.key === 'all'); + const otherOptions = params.options + .filter((opt) => opt.key !== 'all') + .sort((a, b) => { const labelA = a.label || ''; const labelB = b.label || ''; return labelA.localeCompare(labelB); - }), - [params.options], - ); + }); + + return allOption ? [allOption, ...otherOptions] : otherOptions; + }, [params.options]); const onSelectedValueChange = (val?: string) => { if (!val) { From 049b910473355e4738d768a15f871c4b58e5f75b Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Wed, 28 Jan 2026 12:17:12 -0500 Subject: [PATCH 8/8] Fix bug --- .../Tabs/ActivityTab/ActivityTab.test.tsx | 14 ++++++++++++++ .../components/Tabs/ActivityTab/ActivityTab.tsx | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx index f3706472093..407df12e408 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx @@ -1008,5 +1008,19 @@ describe('ActivityTab', () => { 'SWAP', ); }); + + it('handles undefined seasonActivityTypes gracefully (upgrade scenario)', () => { + // Simulate upgrade scenario where seasonActivityTypes is undefined from persisted state + mockSelectSeasonActivityTypes.mockReturnValueOnce( + undefined as unknown as typeof mockSeasonActivityTypes, + ); + + const { getByTestId, getByText } = render(); + + // Should render without crashing and show only the "All" option + expect(getByTestId('select-option-sheet')).toBeOnTheScreen(); + expect(getByText('All')).toBeOnTheScreen(); + expect(getByTestId('activity-filter-ALL')).toBeOnTheScreen(); + }); }); }); diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index 76bf65da876..c3b227f3afa 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -55,7 +55,7 @@ const ActivityFilter: React.FC = ({ label: strings('rewards.filter_all'), }; - const activityOptions: ISelectOption[] = seasonActivityTypes.map( + const activityOptions: ISelectOption[] = (seasonActivityTypes ?? []).map( (activityType) => ({ key: activityType.type.toLowerCase(), value: activityType.type,