Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import NotificationsCategory from './NotificationsCategory';
import { NotificationsCategorySelectorsIDs } from './NotificationsCategory.testIds';

let mockIsMetamaskNotificationsEnabled = true;
let mockIsSocialLeaderboardEnabled = false;
let mockIsPriceAlertsEnabled = false;
let mockCategories: Array<{ categoryId: string; ausKeys: string[] }> = [

Check failure on line 9 in app/components/Views/Notifications/NotificationsCategory/NotificationsCategory.test.tsx

View workflow job for this annotation

GitHub Actions / Run `lint`

Array type using 'Array<T>' is forbidden. Use 'T[]' instead
{ categoryId: 'walletActivity', ausKeys: ['walletActivity'] },
{ categoryId: 'perps', ausKeys: ['perps'] },
{ categoryId: 'socialAI', ausKeys: ['socialAI'] },
{ categoryId: 'marketing', ausKeys: ['marketing'] },
];
let mockIsLoading = false;

jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown) => selector({}),
}));

jest.mock('../../../../selectors/notifications', () => ({
selectIsMetamaskNotificationsEnabled: () =>
mockIsMetamaskNotificationsEnabled,
}));

jest.mock(
'../../../../selectors/featureFlagController/socialLeaderboard',
() => ({
selectSocialLeaderboardEnabled: () => mockIsSocialLeaderboardEnabled,
}),
);

jest.mock('../../../../selectors/featureFlagController/priceAlerts', () => ({
selectPriceAlertsEnabled: () => mockIsPriceAlertsEnabled,
}));

jest.mock('../../../../util/notifications/categories', () => ({
...jest.requireActual('../../../../util/notifications/categories'),
useNotificationCategories: () => ({
categories: mockCategories,
isLoading: mockIsLoading,
}),
}));

describe('NotificationsCategory', () => {
beforeEach(() => {
mockIsMetamaskNotificationsEnabled = true;
mockIsSocialLeaderboardEnabled = false;
mockIsPriceAlertsEnabled = false;
mockIsLoading = false;
mockCategories = [
{ categoryId: 'walletActivity', ausKeys: ['walletActivity'] },
{ categoryId: 'perps', ausKeys: ['perps'] },
{ categoryId: 'socialAI', ausKeys: ['socialAI'] },
{ categoryId: 'marketing', ausKeys: ['marketing'] },
];
});

it('renders the All tab plus one tab per catalog category', () => {
const { getByTestId, queryByTestId } = render(
<NotificationsCategory onSelect={jest.fn()} />,
);

expect(getByTestId(NotificationsCategorySelectorsIDs.ALL)).toBeTruthy();
expect(getByTestId('notifications-category-walletActivity')).toBeTruthy();
expect(getByTestId('notifications-category-perps')).toBeTruthy();
expect(getByTestId('notifications-category-marketing')).toBeTruthy();
expect(queryByTestId('notifications-category-socialAI')).toBeNull();
});

it('shows the socialAI tab when the social leaderboard flag is enabled', () => {
mockIsSocialLeaderboardEnabled = true;

const { getByTestId } = render(
<NotificationsCategory onSelect={jest.fn()} />,
);

expect(getByTestId('notifications-category-socialAI')).toBeTruthy();
});

it('calls onSelect with the categoryId when a tab is pressed', () => {
const onSelect = jest.fn();
const { getByTestId } = render(
<NotificationsCategory onSelect={onSelect} />,
);

fireEvent(getByTestId('notifications-category-perps'), 'onPress');

expect(onSelect).toHaveBeenCalledWith('perps');
});

it('renders a loading skeleton while categories are loading', () => {
mockIsLoading = true;

const { queryByTestId } = render(
<NotificationsCategory onSelect={jest.fn()} />,
);

expect(
queryByTestId(NotificationsCategorySelectorsIDs.CONTAINER),
).toBeNull();
expect(
queryByTestId(NotificationsCategorySelectorsIDs.SKELETON),
).toBeTruthy();
});

it('renders neither tabs nor a skeleton while loading if notifications are disabled', () => {
mockIsLoading = true;
mockIsMetamaskNotificationsEnabled = false;

const { queryByTestId } = render(
<NotificationsCategory onSelect={jest.fn()} />,
);

expect(
queryByTestId(NotificationsCategorySelectorsIDs.SKELETON),
).toBeNull();
});

it('renders nothing when MetaMask notifications are disabled', () => {
mockIsMetamaskNotificationsEnabled = false;

const { queryByTestId } = render(
<NotificationsCategory onSelect={jest.fn()} />,
);

expect(
queryByTestId(NotificationsCategorySelectorsIDs.CONTAINER),
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const NotificationsCategorySelectorsIDs = {
CONTAINER: 'notifications-category',
ALL: 'notifications-category-all',
SKELETON: 'notifications-category-skeleton',
} as const;

export const categoryTestID = (categoryId: string) =>
`notifications-category-${categoryId}`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import {
FilterButton,
FilterButtonGroup,
} from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';

import { strings } from '../../../../../locales/i18n';
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard';
import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts';
import {
ALL_NOTIFICATIONS_CATEGORY_ID,
getCategoryTitle,
getNotificationsSettingsSectionConfigs,
useNotificationCategories,
} from '../../../../util/notifications/categories';

import { NotificationsCategoryProps } from './NotificationsCategory.types';
import {
categoryTestID,
NotificationsCategorySelectorsIDs,
} from './NotificationsCategory.testIds';
import NotificationsCategorySkeleton from './NotificationsCategorySkeleton';

interface CategoryTab {
key: string;
label: string;
testID: string;
}

/**
* Horizontally scrollable category filter for the notifications list, built on
* the design-system `SegmentGroup` / `SegmentButton`. Tabs are driven by the
* BE-provided category catalog; the "Trading Signals" category is additionally
* filtered out when the social leaderboard flag is off.
*/
const NotificationsCategory = ({
onSelect,
testID,
}: NotificationsCategoryProps) => {
const tw = useTailwind();
const isMetamaskNotificationsEnabled = useSelector(
selectIsMetamaskNotificationsEnabled,
);
const isSocialLeaderboardEnabled = useSelector(
selectSocialLeaderboardEnabled,
);
const isPriceAlertsEnabled = useSelector(selectPriceAlertsEnabled);
const { categories, isLoading } = useNotificationCategories();

const [selectedCategory, setSelectedCategory] = useState<string>(
ALL_NOTIFICATIONS_CATEGORY_ID,
);

const tabs = useMemo<CategoryTab[]>(() => {
if (!isMetamaskNotificationsEnabled) {
return [];
}

const items: CategoryTab[] = [
{
key: ALL_NOTIFICATIONS_CATEGORY_ID,
label: strings('app_settings.notifications_opts.all_title'),
testID: NotificationsCategorySelectorsIDs.ALL,
},
];

const sectionConfigs = getNotificationsSettingsSectionConfigs(categories, {
isSocialLeaderboardEnabled,
isPriceAlertsEnabled,
});

sectionConfigs.forEach((category) => {
items.push({
key: category.categoryId,
label: getCategoryTitle(category.categoryId),
testID: categoryTestID(category.categoryId),
});
});

return items;
}, [
categories,
isMetamaskNotificationsEnabled,
isPriceAlertsEnabled,
isSocialLeaderboardEnabled,
]);

const handleSelect = useCallback(
(key: string) => {
setSelectedCategory(key);
onSelect(key);
},
[onSelect],
);

if (!isMetamaskNotificationsEnabled) {
return null;
}

if (isLoading) {
return <NotificationsCategorySkeleton />;
}

if (tabs.length === 0) {
return null;
}

return (
<FilterButtonGroup
value={selectedCategory}
onChange={handleSelect}
twClassName="gap-2 px-4 py-1"
style={tw.style('flex-grow-0')}
testID={testID ?? NotificationsCategorySelectorsIDs.CONTAINER}
>
{tabs.map((tab) => (
<FilterButton key={tab.key} value={tab.key} testID={tab.testID}>
{tab.label}
</FilterButton>
))}
</FilterButtonGroup>
);
};

export default NotificationsCategory;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* NotificationsCategory component props.
*/
export interface NotificationsCategoryProps {
/**
* Called with the selected category id whenever the user picks a tab.
*/
onSelect: (categoryId: string) => void;
/**
* Optional test identifier applied to the tab bar wrapper.
*/
testID?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Box, BoxFlexDirection } from '@metamask/design-system-react-native';
import { Skeleton } from '../../../../component-library/components-temp/Skeleton';
import { NotificationsCategorySelectorsIDs } from './NotificationsCategory.testIds';

// Matches FilterButton's Sm size (h-8 / rounded-lg) so the shimmer doesn't
// jump in size once the real tabs replace it.
const PILL_HEIGHT = 32;
const PILL_WIDTHS = [56, 96, 88, 108];
const styles = StyleSheet.create({
pill: { borderRadius: 8 },
});

const NotificationsCategorySkeleton = () => (
<Box
flexDirection={BoxFlexDirection.Row}
twClassName="gap-2 px-4 py-1"
testID={NotificationsCategorySelectorsIDs.SKELETON}
>
{PILL_WIDTHS.map((width, index) => (
<View key={index} style={styles.pill}>
<Skeleton width={width} height={PILL_HEIGHT} />
</View>
))}
</Box>
);

export default NotificationsCategorySkeleton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './NotificationsCategory';
export type { NotificationsCategoryProps } from './NotificationsCategory.types';
16 changes: 16 additions & 0 deletions app/components/Views/Notifications/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({
useAnalytics: jest.fn(),
}));

jest.mock('../../../util/notifications/categories', () => ({
...jest.requireActual('../../../util/notifications/categories'),
useNotificationCategories: () => ({
categories: [
{
categoryId: 'walletActivity',
ausKeys: ['walletActivity'],
icon: 'Clock',
},
{ categoryId: 'perps', ausKeys: ['perps'], icon: 'Candlestick' },
{ categoryId: 'marketing', ausKeys: ['marketing'], icon: 'Campaign' },
],
isLoading: false,
}),
}));

const mockMetrics = {
trackEvent: jest.fn(),
createEventBuilder: jest.fn().mockReturnValue({ build: jest.fn() }),
Expand Down
Loading
Loading