Skip to content

Commit

Permalink
feat: implement AssetTransfer module component + tests & stories
Browse files Browse the repository at this point in the history
  • Loading branch information
thekidnamedkd committed Apr 2, 2024
1 parent 1af7dba commit 4f41a71
Show file tree
Hide file tree
Showing 5 changed files with 322 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AssetTransfer } from './assetTransfer';

const meta: Meta<typeof AssetTransfer> = {
title: 'Modules/Components/Asset/AssetTransfer',
component: AssetTransfer,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/P0GeJKqILL7UXvaqu5Jj7V/Aragon-ODS?type=design&node-id=14385%3A24287&mode=dev&t=IX3Fa96hiwUEtcoA-1',
},
},
};

type Story = StoryObj<typeof AssetTransfer>;

export const Default: Story = {};

export const Loaded: Story = {
args: {
senderAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
recipientAddress: '0x1D03D98c0aac1f83860cec5156116FE68725642E',
senderEnsName: 'vitalik.eth',
tokenIconSrc: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
tokenSymbol: 'ETH',
tokenAmount: 1,
tokenName: 'Ethereum',
hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6',
tokenPrice: 3850,
chainId: 1,
},
render: (props) => <AssetTransfer {...props} />,
};

export default meta;
97 changes: 97 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react';
import { NumberFormat, formatterUtils } from '../../../../core';
import { OdsModulesProvider } from '../../odsModulesProvider';
import { AssetTransfer, type IAssetTransferProps } from './assetTransfer';

jest.mock('../../member/memberAvatar', () => ({ MemberAvatar: () => <div data-testid="member-avatar-mock" /> }));

describe('<AssetTransfer /> component', () => {
const createTestComponent = (props?: Partial<IAssetTransferProps>) => {
const minimumProps: IAssetTransferProps = {
recipientAddress: '0x1D03D98c0aac1f83860cec5156116FE68725642E',
senderAddress: '0x1D03D98c0aac1f83860cec5156116FE687259999',
tokenIconSrc: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
tokenSymbol: 'ETH',
tokenAmount: 1,
tokenName: 'Ethereum',
hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6',
tokenPrice: 3850,
chainId: 1,
...props,
};

return (
<OdsModulesProvider>
<AssetTransfer {...minimumProps} />
</OdsModulesProvider>
);
};

it('renders with minimum props', () => {
const tokenName = 'Bitcoin';
render(createTestComponent({ tokenName }));

expect(screen.getByText('Bitcoin')).toBeInTheDocument();
});

it('renders correctly with optional props, preferring ENS over address when available', () => {
const senderEnsName = 'sender.eth';
const recipientEnsName = 'recipient.eth';
render(createTestComponent({ senderEnsName, recipientEnsName }));

expect(screen.getByText('sender.eth')).toBeInTheDocument();
expect(screen.getByText('recipient.eth')).toBeInTheDocument();
});

it('renders the formatted fiat estimate', () => {
const tokenPrice = 100;
const tokenAmount = 10;

const formattedEstimate = formatterUtils.formatNumber(tokenPrice * tokenAmount, {
format: NumberFormat.FIAT_TOTAL_SHORT,
});
render(createTestComponent({ tokenPrice, tokenAmount }));
const formattedUsdEstimate = screen.getByText(formattedEstimate as string);
expect(formattedUsdEstimate).toBeInTheDocument();
});

it('renders the token value and symbol with sign', () => {
const tokenSymbol = 'ETH';
const tokenAmount = 10;

render(createTestComponent({ tokenSymbol, tokenAmount }));
const tokenPrintout = screen.getByText('+10 ETH');
expect(tokenPrintout).toBeInTheDocument();
});

it('renders sender and recipient addresses when ENS names are not provided', () => {
render(createTestComponent());

expect(screen.getByText('0x1D…642E')).toBeInTheDocument();
expect(screen.getByText('0x1D…9999')).toBeInTheDocument();
});

it('renders both avatar elements for the from and to addresses', () => {
render(createTestComponent());

expect(screen.getAllByTestId('member-avatar-mock')).toHaveLength(2);
});

it('configures and applies the correct links for sender, recipient, transaction', () => {
const senderAddress = '0x415c8893D514F9BC5211d36eEDA4183226b84AA7';
const recipientAddress = '0xFf00000000000000000000000000000000081457';
const hash = '0x0ca620e2dd3147658b8a042b3e7b7cd6f5fa043bf3625140c0dbddcabf47dfb9';

render(createTestComponent({ senderAddress, recipientAddress, hash }));

const links = screen.getAllByRole('link');

const expectedSenderLink = `https://etherscan.io/address/${senderAddress}`;
const expectedRecipientLink = `https://etherscan.io/address/${recipientAddress}`;
const expectedTransactionLink = `https://etherscan.io/tx/${hash}`;

expect(links[0]).toHaveAttribute('href', expectedSenderLink);
expect(links[1]).toHaveAttribute('href', expectedRecipientLink);
expect(links[2]).toHaveAttribute('href', expectedTransactionLink);
});
});
187 changes: 187 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import classNames from 'classnames';
import { type Hash } from 'viem';
import { useConfig } from 'wagmi';
import { Avatar, AvatarIcon, Icon, IconType, NumberFormat, formatterUtils } from '../../../../core';
import { type IWeb3ComponentProps } from '../../../types';
import { addressUtils } from '../../../utils';
import { MemberAvatar } from '../../member';

export interface IAssetTransferProps extends IWeb3ComponentProps {
/**
* Address of the transaction sender.
*/
senderAddress: Hash;
/**
* Address of the transaction recipient.
*/
recipientAddress: Hash;
/**
* ENS name of the transaction sender.
*/
senderEnsName?: string;
/**
* ENS name of the transaction recipient.
*/
recipientEnsName?: string;
/**
* Name of the token transferred.
*/
tokenName: string;
/**
* Icon URL of the tranferred token.
*/
tokenIconSrc?: string;
/**
* Amount of tokens transferred.
*/
tokenAmount: number;
/**
* Symbol of the token transferred. Example: ETH, DAI, etc.
*/
tokenSymbol: string;
/**
* Price per token in fiat.
*/
tokenPrice: number | string;
/**
* Transaction hash.
*/
hash: string;
/**
* Chain ID of the transaction.
*/
chainId: number;
}

export const AssetTransfer: React.FC<IAssetTransferProps> = (props) => {
const {
senderAddress,
recipientAddress,
senderEnsName,
recipientEnsName,
tokenName,
tokenIconSrc,
tokenAmount,
tokenSymbol,
tokenPrice,
chainId,
hash,
wagmiConfig: wagmiConfigProps,
} = props;
const wagmiConfigProvider = useConfig();

const wagmiConfig = wagmiConfigProps ?? wagmiConfigProvider;

const processedChainId = chainId ?? wagmiConfig.chains[0].id;

const currentChain = wagmiConfig.chains.find(({ id }) => id === processedChainId);
const blockExplorerUrl = currentChain?.blockExplorers?.default.url;

const blockExplorerAssembledHref = blockExplorerUrl && hash ? `${blockExplorerUrl}/tx/${hash}` : undefined;

const resolvedSenderHandle =
senderEnsName != null && senderEnsName !== '' ? senderEnsName : addressUtils.truncateAddress(senderAddress);

const resolvedRecipientHandle =
recipientEnsName != null && recipientEnsName !== ''
? recipientEnsName
: addressUtils.truncateAddress(recipientAddress);

const resolvedSenderLink =
blockExplorerUrl && senderAddress ? `${blockExplorerUrl}/address/${senderAddress}` : undefined;
const resolvedRecipientLink =
blockExplorerUrl && recipientAddress ? `${blockExplorerUrl}/address/${recipientAddress}` : undefined;

const formattedTokenValue = formatterUtils.formatNumber(tokenAmount && tokenAmount > 0 ? tokenAmount : null, {
format: NumberFormat.TOKEN_AMOUNT_SHORT,
withSign: true,
});
const fiatValue = Number(tokenAmount ?? 0) * Number(tokenPrice ?? 0);
const formattedFiatValue = formatterUtils.formatNumber(fiatValue, {
format: NumberFormat.FIAT_TOTAL_SHORT,
});
const formattedTokenAmount = formattedTokenValue && tokenSymbol ? `${formattedTokenValue} ${tokenSymbol}` : `-`;

return (
<div className="flex h-full w-[320px] flex-col gap-y-2 md:w-[640px] md:gap-y-3">
<div className="relative flex h-full flex-col rounded-xl border-[1px] border-neutral-100 md:flex-row">
<a
href={resolvedSenderLink}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex h-20 w-full items-center space-x-4 rounded-l-xl px-4 py-7', //base
'hover:border-neutral-200 hover:shadow-neutral-md', //hover
'focus:outline-none focus-visible:rounded-l-xl focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', //focus
'active:border-[1px] active:border-neutral-300', //active
'md:w-1/2 md:p-6', //responsive
)}
>
<MemberAvatar responsiveSize={{ md: 'md' }} ensName={senderEnsName} address={senderAddress} />
<div className="flex flex-col">
<p className="text-xs font-normal leading-tight text-neutral-500 md:text-sm">From</p>
<div className="flex items-center space-x-1">
<p className="text-sm font-normal leading-tight text-neutral-800 md:text-base">
{resolvedSenderHandle}
</p>
<Icon icon={IconType.LINK_EXTERNAL} size="sm" className="text-neutral-300" />
</div>
</div>
</a>
<div className="border-t-[1px] border-neutral-100 md:border-l-[1px]" />
<AvatarIcon
icon={IconType.CHEVRON_DOWN}
size="sm"
className={classNames(
'absolute left-4 top-1/2 -translate-y-1/2 bg-neutral-50 text-neutral-300', //base
'md:left-1/2 md:-translate-x-1/2 md:-rotate-90', //responsive
)}
/>
<a
href={resolvedRecipientLink}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex h-20 w-full items-center space-x-4 rounded-r-xl px-4 py-7', //base
'hover:border-neutral-200 hover:shadow-neutral-md', //hover
'focus:outline-none focus-visible:rounded-r-xl focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', //focus
'active:border-[1px] active:border-neutral-300', //active
'md:w-1/2 md:p-6 md:pl-8', //responsive
)}
>
<MemberAvatar responsiveSize={{ md: 'md' }} ensName={recipientEnsName} address={recipientAddress} />
<div className="flex flex-col">
<p className="text-xs font-normal leading-tight text-neutral-500 md:text-sm">To</p>
<div className="flex items-center space-x-1">
<p className="text-sm font-normal leading-tight text-neutral-800 md:text-base">
{resolvedRecipientHandle}
</p>
<Icon icon={IconType.LINK_EXTERNAL} size="sm" className="text-neutral-300" />
</div>
</div>
</a>
</div>
<a
href={blockExplorerAssembledHref}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex h-16 w-full items-center justify-between rounded-xl border-[1px] border-neutral-100 px-4',
'hover:border-neutral-200 hover:shadow-neutral-md',
'focus:outline-none focus-visible:rounded-xl focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset',
'active:border-neutral-300',
'md:h-20 md:px-6',
)}
>
<div className="flex items-center space-x-3 md:space-x-4">
<Avatar responsiveSize={{ md: 'md' }} src={tokenIconSrc} />
<p className="text-sm leading-tight text-neutral-800 md:text-base">{tokenName}</p>
</div>
<div className="flex flex-col items-end justify-end">
<p className="text-sm leading-tight text-neutral-800 md:text-base">{formattedTokenAmount}</p>
<p className="text-sm leading-tight text-neutral-500 md:text-base">{formattedFiatValue}</p>
</div>
</a>
</div>
);
};
1 change: 1 addition & 0 deletions src/modules/components/asset/assetTransfer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './assetTransfer';
1 change: 1 addition & 0 deletions src/modules/components/asset/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './assetDataListItem';
export * from './assetTransfer';

0 comments on commit 4f41a71

Please sign in to comment.