Skip to content
Merged
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
Expand Up @@ -24,6 +24,9 @@ const styleSheet = (params: { theme: Theme }) => {
paddingHorizontal: 16,
paddingTop: 8,
},
emptyContentContainer: {
flex: 1,
},
editableRow: {
flexDirection: 'row',
alignItems: 'center',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ jest.mock('react-native-reanimated', () => {
return Reanimated;
});

jest.mock('../../components/WatchlistEmptyCTA', () => {
const { View } = jest.requireActual('react-native');
const ReactActual = jest.requireActual('react');
const Mock = () =>
ReactActual.createElement(View, { testID: 'watchlist-empty-cta' });
Mock.displayName = 'WatchlistEmptyCTA';
return Mock;
});

jest.mock('./WatchlistEditableRow', () => {
const { Text, View } = jest.requireActual('react-native');
const ReactActual = jest.requireActual('react');
Expand Down Expand Up @@ -130,9 +139,10 @@ describe('WatchlistFullScreenView', () => {
expect(getByTestId('editable-row-eip155:1/erc20:0xeth')).toBeDefined();
});

it('omits the token list when the watchlist is empty', () => {
const { queryByTestId } = render(<WatchlistFullScreenView />);
it('renders empty CTA when the watchlist is empty', () => {
const { getByTestId, queryByTestId } = render(<WatchlistFullScreenView />);

expect(getByTestId('watchlist-empty-cta')).toBeDefined();
expect(
queryByTestId(WatchlistFullScreenViewSelectorsIDs.TOKEN_LIST),
).toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import TrendingTokensSkeleton from '../../../../Trending/components/TrendingToke
import { strings } from '../../../../../../../locales/i18n';
import { WatchlistFullScreenViewSelectorsIDs } from './WatchlistFullScreenView.testIds';
import WatchlistEditableRow from './WatchlistEditableRow';
import WatchlistEmptyCTA from '../../components/WatchlistEmptyCTA';
import styleSheet from './WatchlistFullScreenView.styles';

const SKELETON_COUNT = 5;
Expand Down Expand Up @@ -119,7 +120,11 @@ const WatchlistFullScreenView = () => {
}

if (!hasItems) {
return null;
return (
<View style={styles.emptyContentContainer}>
<WatchlistEmptyCTA source="watchlist_fullscreen_empty_cta" />
</View>
);
}

return (
Expand All @@ -145,7 +150,14 @@ const WatchlistFullScreenView = () => {
</Animated.View>
</ScrollView>
);
}, [displayTokens, hasItems, isEditMode, isLoading, styles.listContainer]);
}, [
displayTokens,
hasItems,
isEditMode,
isLoading,
styles.emptyContentContainer,
styles.listContainer,
]);

return (
<View
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { StyleSheet } from 'react-native';
import type { Theme } from '../../../../../../util/theme/models';

const styleSheet = (params: {
theme: Theme;
vars: { isSelected: boolean };
}) => {
const { theme, vars } = params;
const { isSelected } = vars;

return StyleSheet.create({
card: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 12,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: isSelected
? theme.colors.border.default
: theme.colors.border.muted,
backgroundColor: theme.colors.background.section,
},
topRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
logoContainer: {
flexShrink: 0,
},
checkboxContainer: {
flexShrink: 0,
},
});
};

export default styleSheet;
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import WatchlistDefaultTokenCard from './WatchlistDefaultTokenCard';
import {
getWatchlistDefaultTokenCardTestId,
WatchlistDefaultTokenCardTestIds,
} from './WatchlistDefaultTokenCard.testIds';
import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';

jest.mock('../../../../Trending/components/TrendingTokenLogo', () => {
const { View } = jest.requireActual('react-native');
const ReactActual = jest.requireActual('react');
const Mock = ({ testID }: { testID?: string }) =>
ReactActual.createElement(View, { testID: testID ?? 'token-logo' });
Mock.displayName = 'TrendingTokenLogo';
return Mock;
});

const makeToken = (
overrides: Partial<WatchlistTokenWithBalance> = {},
): WatchlistTokenWithBalance => ({
assetId: 'eip155:1/slip44:60',
symbol: 'ETH',
name: 'Ethereum',
decimals: 18,
balance: '0',
isInWallet: false,
marketData: {
price: 3000,
pricePercentChange24h: 4.2,
},
...overrides,
});

describe('WatchlistDefaultTokenCard', () => {
it('renders symbol and price change', () => {
const token = makeToken();
const { getByTestId } = render(
<WatchlistDefaultTokenCard
token={token}
isSelected
onToggle={jest.fn()}
/>,
);

expect(
getByTestId(`${WatchlistDefaultTokenCardTestIds.SYMBOL}-${token.assetId}`)
.props.children,
).toBe('ETH');
expect(
getByTestId(
`${WatchlistDefaultTokenCardTestIds.PRICE_CHANGE}-${token.assetId}`,
).props.children,
).toBe('+4.20%');
});

it('calls onToggle when the card is pressed', () => {
const onToggle = jest.fn();
const token = makeToken();
const { getByTestId } = render(
<WatchlistDefaultTokenCard
token={token}
isSelected={false}
onToggle={onToggle}
/>,
);

fireEvent.press(
getByTestId(getWatchlistDefaultTokenCardTestId(token.assetId)),
);

expect(onToggle).toHaveBeenCalledWith(String(token.assetId));
});

it('calls onToggle when the checkbox is pressed', () => {
const onToggle = jest.fn();
const token = makeToken();
const { getByTestId } = render(
<WatchlistDefaultTokenCard
token={token}
isSelected
onToggle={onToggle}
/>,
);

fireEvent.press(
getByTestId(
`${WatchlistDefaultTokenCardTestIds.CHECKBOX}-${token.assetId}`,
),
);

expect(onToggle).toHaveBeenCalledWith(String(token.assetId));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export enum WatchlistDefaultTokenCardTestIds {
CARD = 'watchlist-default-token-card',
CHECKBOX = 'watchlist-default-token-card-checkbox',
SYMBOL = 'watchlist-default-token-card-symbol',
PRICE_CHANGE = 'watchlist-default-token-card-price-change',
}

export const getWatchlistDefaultTokenCardTestId = (assetId: string): string =>
`${WatchlistDefaultTokenCardTestIds.CARD}-${assetId}`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { useCallback, useMemo } from 'react';
import { Pressable, View } from 'react-native';
import {
BadgeNetwork,
BadgeWrapper,
BadgeWrapperPosition,
Checkbox,
Text,
TextVariant,
FontWeight,
} from '@metamask/design-system-react-native';
import { useStyles } from '../../../../../../component-library/hooks';
import TrendingTokenLogo from '../../../../Trending/components/TrendingTokenLogo';
import {
getCaipChainIdFromAssetId,
getNetworkBadgeSource,
} from '../../../../Trending/components/TrendingTokenRowItem/utils';
import { formatPercentChange } from '../../../../Trending/utils/formatPercentChange';
import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens';
import styleSheet from './WatchlistDefaultTokenCard.styles';
import {
getWatchlistDefaultTokenCardTestId,
WatchlistDefaultTokenCardTestIds,
} from './WatchlistDefaultTokenCard.testIds';

interface WatchlistDefaultTokenCardProps {
token: WatchlistTokenWithBalance;
isSelected: boolean;
onToggle: (assetId: string) => void;
}

const WatchlistDefaultTokenCard: React.FC<WatchlistDefaultTokenCardProps> = ({
token,
isSelected,
onToggle,
}) => {
const assetId = String(token.assetId);
const { styles } = useStyles(styleSheet, { isSelected });

const networkBadgeSource = useMemo(
() => getNetworkBadgeSource(getCaipChainIdFromAssetId(assetId)),
[assetId],
);

const { changeLabel, changeTextColor } = useMemo(
() => formatPercentChange(token.marketData?.pricePercentChange24h),
[token.marketData?.pricePercentChange24h],
);

const handlePress = useCallback(() => {
onToggle(assetId);
}, [assetId, onToggle]);

const handleCheckboxChange = useCallback(() => {
onToggle(assetId);
}, [assetId, onToggle]);

return (
<Pressable
style={styles.card}
onPress={handlePress}
testID={getWatchlistDefaultTokenCardTestId(assetId)}
accessibilityRole="checkbox"
accessibilityState={{ checked: isSelected }}
accessibilityLabel={token.symbol}
>
<View style={styles.topRow}>
<View style={styles.logoContainer}>
<BadgeWrapper
position={BadgeWrapperPosition.BottomRight}
badge={
networkBadgeSource ? (
<BadgeNetwork
src={
networkBadgeSource as React.ComponentProps<
typeof BadgeNetwork
>['src']
}
/>
) : null
}
>
<TrendingTokenLogo
assetId={assetId}
symbol={token.symbol}
size={40}
recyclingKey={assetId}
/>
</BadgeWrapper>
</View>
<View style={styles.checkboxContainer}>
<Checkbox
testID={`${WatchlistDefaultTokenCardTestIds.CHECKBOX}-${assetId}`}
isSelected={isSelected}
onChange={handleCheckboxChange}
/>
</View>
</View>
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Bold}
testID={`${WatchlistDefaultTokenCardTestIds.SYMBOL}-${assetId}`}
>
{token.symbol}
</Text>
{changeLabel ? (
<Text
variant={TextVariant.BodySm}
color={changeTextColor}
testID={`${WatchlistDefaultTokenCardTestIds.PRICE_CHANGE}-${assetId}`}
>
{changeLabel}
</Text>
) : null}
</Pressable>
);
};

export default WatchlistDefaultTokenCard;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './WatchlistDefaultTokenCard';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { StyleSheet } from 'react-native';
import type { Theme } from '../../../../../../util/theme/models';

const styleSheet = (_params: { theme: Theme }) =>
StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 16,
},
scrollView: {
flex: 1,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
gridItem: {
width: '48%',
},
skeletonGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
skeletonCard: {
width: '48%',
height: 140,
borderRadius: 12,
backgroundColor: _params.theme.colors.background.section,
},
footer: {
flexDirection: 'column',
alignItems: 'flex-start',
paddingVertical: 4,
},
button: {
alignSelf: 'stretch',
width: '100%',
},
});

export default styleSheet;
Loading
Loading