Skip to content

Commit 9b4f484

Browse files
committed
feat: continue implementation
1 parent 08a35d0 commit 9b4f484

26 files changed

Lines changed: 870 additions & 217 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React from 'react';
2+
import { fireEvent, render } from '@testing-library/react-native';
3+
import NotificationsCategory from './NotificationsCategory';
4+
import { NotificationsCategorySelectorsIDs } from './NotificationsCategory.testIds';
5+
6+
let mockIsMetamaskNotificationsEnabled = true;
7+
let mockIsSocialLeaderboardEnabled = false;
8+
let mockIsPriceAlertsEnabled = false;
9+
let mockCategories: Array<{ categoryId: string; ausKeys: string[] }> = [
10+
{ categoryId: 'walletActivity', ausKeys: ['walletActivity'] },
11+
{ categoryId: 'perps', ausKeys: ['perps'] },
12+
{ categoryId: 'socialAI', ausKeys: ['socialAI'] },
13+
{ categoryId: 'marketing', ausKeys: ['marketing'] },
14+
];
15+
let mockIsLoading = false;
16+
17+
jest.mock('react-redux', () => ({
18+
useSelector: (selector: (state: unknown) => unknown) => selector({}),
19+
}));
20+
21+
jest.mock('../../../../selectors/notifications', () => ({
22+
selectIsMetamaskNotificationsEnabled: () =>
23+
mockIsMetamaskNotificationsEnabled,
24+
}));
25+
26+
jest.mock('../../../../selectors/featureFlagController/socialLeaderboard', () => ({
27+
selectSocialLeaderboardEnabled: () => mockIsSocialLeaderboardEnabled,
28+
}));
29+
30+
jest.mock('../../../../selectors/featureFlagController/priceAlerts', () => ({
31+
selectPriceAlertsEnabled: () => mockIsPriceAlertsEnabled,
32+
}));
33+
34+
jest.mock('../../../../util/notifications/categories', () => ({
35+
...jest.requireActual('../../../../util/notifications/categories'),
36+
useNotificationCategories: () => ({
37+
categories: mockCategories,
38+
isLoading: mockIsLoading,
39+
}),
40+
}));
41+
42+
describe('NotificationsCategory', () => {
43+
beforeEach(() => {
44+
mockIsMetamaskNotificationsEnabled = true;
45+
mockIsSocialLeaderboardEnabled = false;
46+
mockIsPriceAlertsEnabled = false;
47+
mockIsLoading = false;
48+
mockCategories = [
49+
{ categoryId: 'walletActivity', ausKeys: ['walletActivity'] },
50+
{ categoryId: 'perps', ausKeys: ['perps'] },
51+
{ categoryId: 'socialAI', ausKeys: ['socialAI'] },
52+
{ categoryId: 'marketing', ausKeys: ['marketing'] },
53+
];
54+
});
55+
56+
it('renders the All tab plus one tab per catalog category', () => {
57+
const { getByTestId, queryByTestId } = render(
58+
<NotificationsCategory onSelect={jest.fn()} />,
59+
);
60+
61+
expect(getByTestId(NotificationsCategorySelectorsIDs.ALL)).toBeTruthy();
62+
expect(getByTestId('notifications-category-walletActivity')).toBeTruthy();
63+
expect(getByTestId('notifications-category-perps')).toBeTruthy();
64+
expect(getByTestId('notifications-category-marketing')).toBeTruthy();
65+
expect(queryByTestId('notifications-category-socialAI')).toBeNull();
66+
});
67+
68+
it('shows the socialAI tab when the social leaderboard flag is enabled', () => {
69+
mockIsSocialLeaderboardEnabled = true;
70+
71+
const { getByTestId } = render(<NotificationsCategory onSelect={jest.fn()} />);
72+
73+
expect(getByTestId('notifications-category-socialAI')).toBeTruthy();
74+
});
75+
76+
it('calls onSelect with the categoryId when a tab is pressed', () => {
77+
const onSelect = jest.fn();
78+
const { getByTestId } = render(
79+
<NotificationsCategory onSelect={onSelect} />,
80+
);
81+
82+
fireEvent(
83+
getByTestId('notifications-category-perps'),
84+
'onPress',
85+
);
86+
87+
expect(onSelect).toHaveBeenCalledWith('perps');
88+
});
89+
90+
it('renders a loading skeleton while categories are loading', () => {
91+
mockIsLoading = true;
92+
93+
const { queryByTestId } = render(
94+
<NotificationsCategory onSelect={jest.fn()} />,
95+
);
96+
97+
expect(queryByTestId(NotificationsCategorySelectorsIDs.CONTAINER)).toBeNull();
98+
expect(
99+
queryByTestId(NotificationsCategorySelectorsIDs.SKELETON),
100+
).toBeTruthy();
101+
});
102+
103+
it('renders neither tabs nor a skeleton while loading if notifications are disabled', () => {
104+
mockIsLoading = true;
105+
mockIsMetamaskNotificationsEnabled = false;
106+
107+
const { queryByTestId } = render(
108+
<NotificationsCategory onSelect={jest.fn()} />,
109+
);
110+
111+
expect(
112+
queryByTestId(NotificationsCategorySelectorsIDs.SKELETON),
113+
).toBeNull();
114+
});
115+
116+
it('renders nothing when MetaMask notifications are disabled', () => {
117+
mockIsMetamaskNotificationsEnabled = false;
118+
119+
const { queryByTestId } = render(
120+
<NotificationsCategory onSelect={jest.fn()} />,
121+
);
122+
123+
expect(queryByTestId(NotificationsCategorySelectorsIDs.CONTAINER)).toBeNull();
124+
});
125+
});
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
export const NotificationsCategorySelectorsIDs = {
22
CONTAINER: 'notifications-category',
33
ALL: 'notifications-category-all',
4-
WALLET_ACTIVITY: 'notifications-category-wallet-activity',
5-
PERPS: 'notifications-category-perps',
6-
SOCIAL_AI: 'notifications-category-social-ai',
7-
MARKETING: 'notifications-category-marketing',
4+
SKELETON: 'notifications-category-skeleton',
85
} as const;
6+
7+
export const categoryTestID = (categoryId: string) =>
8+
`notifications-category-${categoryId}`;

app/components/Views/Notifications/NotificationsCategory/NotificationsCategory.tsx

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,40 @@
11
import React, { useCallback, useMemo, useState } from 'react';
22
import { useSelector } from 'react-redux';
33
import {
4-
SegmentButton,
5-
SegmentGroup,
4+
FilterButton,
5+
FilterButtonGroup,
66
} from '@metamask/design-system-react-native';
77
import { useTailwind } from '@metamask/design-system-twrnc-preset';
88

99
import { strings } from '../../../../../locales/i18n';
1010
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
1111
import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard';
12+
import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts';
13+
import {
14+
ALL_NOTIFICATIONS_CATEGORY_ID,
15+
getCategoryTitle,
16+
getNotificationsSettingsSectionConfigs,
17+
useNotificationCategories,
18+
} from '../../../../util/notifications/categories';
1219

20+
import { NotificationsCategoryProps } from './NotificationsCategory.types';
1321
import {
14-
NotificationCategoryId,
15-
NotificationsCategoryProps,
16-
} from './NotificationsCategory.types';
17-
import { NotificationsCategorySelectorsIDs } from './NotificationsCategory.testIds';
22+
categoryTestID,
23+
NotificationsCategorySelectorsIDs,
24+
} from './NotificationsCategory.testIds';
25+
import NotificationsCategorySkeleton from './NotificationsCategorySkeleton';
1826

1927
interface CategoryTab {
20-
key: NotificationCategoryId;
28+
key: string;
2129
label: string;
2230
testID: string;
2331
}
2432

2533
/**
2634
* Horizontally scrollable category filter for the notifications list, built on
27-
* the design-system `SegmentGroup` / `SegmentButton`. The set of categories is
28-
* gated by feature flags: the activity-related categories only appear when
29-
* MetaMask notifications are enabled, and "Trading Signals" only when the social
30-
* leaderboard flag is on.
35+
* the design-system `SegmentGroup` / `SegmentButton`. Tabs are driven by the
36+
* BE-provided category catalog; the "Trading Signals" category is additionally
37+
* filtered out when the social leaderboard flag is off.
3138
*/
3239
const NotificationsCategory = ({
3340
onSelect,
@@ -40,76 +47,81 @@ const NotificationsCategory = ({
4047
const isSocialLeaderboardEnabled = useSelector(
4148
selectSocialLeaderboardEnabled,
4249
);
50+
const isPriceAlertsEnabled = useSelector(selectPriceAlertsEnabled);
51+
const { categories, isLoading } = useNotificationCategories();
4352

44-
const [selectedCategory, setSelectedCategory] =
45-
useState<NotificationCategoryId>(NotificationCategoryId.All);
53+
const [selectedCategory, setSelectedCategory] = useState<string>(
54+
ALL_NOTIFICATIONS_CATEGORY_ID,
55+
);
4656

4757
const tabs = useMemo<CategoryTab[]>(() => {
58+
if (!isMetamaskNotificationsEnabled) {
59+
return [];
60+
}
61+
4862
const items: CategoryTab[] = [
4963
{
50-
key: NotificationCategoryId.All,
64+
key: ALL_NOTIFICATIONS_CATEGORY_ID,
5165
label: strings('app_settings.notifications_opts.all_title'),
5266
testID: NotificationsCategorySelectorsIDs.ALL,
5367
},
5468
];
5569

56-
if (isMetamaskNotificationsEnabled) {
57-
items.push(
58-
{
59-
key: NotificationCategoryId.WalletActivity,
60-
label: strings(
61-
'app_settings.notifications_opts.wallet_activity_title',
62-
),
63-
testID: NotificationsCategorySelectorsIDs.WALLET_ACTIVITY,
64-
},
65-
{
66-
key: NotificationCategoryId.Perps,
67-
label: strings('app_settings.notifications_opts.perps_title'),
68-
testID: NotificationsCategorySelectorsIDs.PERPS,
69-
},
70-
);
71-
72-
if (isSocialLeaderboardEnabled) {
73-
items.push({
74-
key: NotificationCategoryId.SocialAI,
75-
label: strings('app_settings.notifications_opts.social_ai_title'),
76-
testID: NotificationsCategorySelectorsIDs.SOCIAL_AI,
77-
});
78-
}
70+
const sectionConfigs = getNotificationsSettingsSectionConfigs(categories, {
71+
isSocialLeaderboardEnabled,
72+
isPriceAlertsEnabled,
73+
});
7974

75+
sectionConfigs.forEach((category) => {
8076
items.push({
81-
key: NotificationCategoryId.Marketing,
82-
label: strings('app_settings.notifications_opts.marketing_title'),
83-
testID: NotificationsCategorySelectorsIDs.MARKETING,
77+
key: category.categoryId,
78+
label: getCategoryTitle(category.categoryId),
79+
testID: categoryTestID(category.categoryId),
8480
});
85-
}
81+
});
8682

8783
return items;
88-
}, [isMetamaskNotificationsEnabled, isSocialLeaderboardEnabled]);
84+
}, [
85+
categories,
86+
isMetamaskNotificationsEnabled,
87+
isPriceAlertsEnabled,
88+
isSocialLeaderboardEnabled,
89+
]);
8990

9091
const handleSelect = useCallback(
9192
(key: string) => {
92-
const category = key as NotificationCategoryId;
93-
setSelectedCategory(category);
94-
onSelect(category);
93+
setSelectedCategory(key);
94+
onSelect(key);
9595
},
9696
[onSelect],
9797
);
9898

99+
if (!isMetamaskNotificationsEnabled) {
100+
return null;
101+
}
102+
103+
if (isLoading) {
104+
return <NotificationsCategorySkeleton />;
105+
}
106+
107+
if (tabs.length === 0) {
108+
return null;
109+
}
110+
99111
return (
100-
<SegmentGroup
112+
<FilterButtonGroup
101113
value={selectedCategory}
102114
onChange={handleSelect}
103115
twClassName="gap-2 px-4 py-1"
104116
style={tw.style('flex-grow-0')}
105117
testID={testID ?? NotificationsCategorySelectorsIDs.CONTAINER}
106118
>
107119
{tabs.map((tab) => (
108-
<SegmentButton key={tab.key} value={tab.key} testID={tab.testID}>
120+
<FilterButton key={tab.key} value={tab.key} testID={tab.testID}>
109121
{tab.label}
110-
</SegmentButton>
122+
</FilterButton>
111123
))}
112-
</SegmentGroup>
124+
</FilterButtonGroup>
113125
);
114126
};
115127

app/components/Views/Notifications/NotificationsCategory/NotificationsCategory.types.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
/**
2-
* Identifiers for the notification categories surfaced in the
3-
* {@link NotificationsCategory} tab bar.
4-
*/
5-
export enum NotificationCategoryId {
6-
All = 'all',
7-
WalletActivity = 'wallet-activity',
8-
Perps = 'perps',
9-
SocialAI = 'social-ai',
10-
Marketing = 'marketing',
11-
}
12-
131
/**
142
* NotificationsCategory component props.
153
*/
164
export interface NotificationsCategoryProps {
175
/**
18-
* Called with the selected category whenever the user picks a tab.
6+
* Called with the selected category id whenever the user picks a tab.
197
*/
20-
onSelect: (category: NotificationCategoryId) => void;
8+
onSelect: (categoryId: string) => void;
219
/**
2210
* Optional test identifier applied to the tab bar wrapper.
2311
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
import { View, StyleSheet } from 'react-native';
3+
import { Box, BoxFlexDirection } from '@metamask/design-system-react-native';
4+
import { Skeleton } from '../../../../component-library/components-temp/Skeleton';
5+
import { NotificationsCategorySelectorsIDs } from './NotificationsCategory.testIds';
6+
7+
// Matches FilterButton's Sm size (h-8 / rounded-lg) so the shimmer doesn't
8+
// jump in size once the real tabs replace it.
9+
const PILL_HEIGHT = 32;
10+
const PILL_WIDTHS = [56, 96, 88, 108];
11+
const styles = StyleSheet.create({
12+
pill: { borderRadius: 8 },
13+
});
14+
15+
const NotificationsCategorySkeleton = () => (
16+
<Box
17+
flexDirection={BoxFlexDirection.Row}
18+
twClassName="gap-2 px-4 py-1"
19+
testID={NotificationsCategorySelectorsIDs.SKELETON}
20+
>
21+
{PILL_WIDTHS.map((width, index) => (
22+
<View key={index} style={styles.pill}>
23+
<Skeleton width={width} height={PILL_HEIGHT} />
24+
</View>
25+
))}
26+
</Box>
27+
);
28+
29+
export default NotificationsCategorySkeleton;
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export { default } from './NotificationsCategory';
2-
export { NotificationCategoryId } from './NotificationsCategory.types';
32
export type { NotificationsCategoryProps } from './NotificationsCategory.types';

app/components/Views/Notifications/index.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({
4040
useAnalytics: jest.fn(),
4141
}));
4242

43+
jest.mock('../../../util/notifications/categories', () => ({
44+
...jest.requireActual('../../../util/notifications/categories'),
45+
useNotificationCategories: () => ({
46+
categories: [
47+
{ categoryId: 'walletActivity', ausKeys: ['walletActivity'], icon: 'Clock' },
48+
{ categoryId: 'perps', ausKeys: ['perps'], icon: 'Candlestick' },
49+
{ categoryId: 'marketing', ausKeys: ['marketing'], icon: 'Campaign' },
50+
],
51+
isLoading: false,
52+
}),
53+
}));
54+
4355
const mockMetrics = {
4456
trackEvent: jest.fn(),
4557
createEventBuilder: jest.fn().mockReturnValue({ build: jest.fn() }),

0 commit comments

Comments
 (0)