Skip to content

Commit

Permalink
feat: APP-2977 - Implement AssetTransfer module component (#134)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekidnamedkd authored Apr 10, 2024
1 parent f291676 commit 9697374
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 4 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Implement `AssetTransfer` module component

### Changed

- Update `README` logo
Expand All @@ -16,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added

- Implement `DaoDataListItem.Structure`, `ProposalDataListItem.Structure`, `TransactionDataListItem.Structure`,
`MemberDataListItem.Structure`, `AssetDataListItem.Structure` and `AddressInput` module components
`MemberDataListItem.Structure`, `AssetDataListItem.Structure`, and `AddressInput` module components
- Implement `StatePingAnimation` core component
- Implement `addressUtils` and `ensUtils` module utilities
- Implement `useDebouncedValue` core hook and `clipboardUtils` core utility
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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>;

/**
* Default usage example of the AssetTransfer component.
*/
export const Default: Story = {
args: {
sender: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', name: 'vitalik.eth' },
recipient: {
address: '0x168dAa4529bf88369ac8c1ABA5A2ad8CF2A61Fb9',
name: 'decentralizedtransactions.eth',
},
assetIconSrc: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
assetSymbol: 'ETH',
assetAmount: 1,
assetName: 'Ethereum',
hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6',
assetFiatPrice: 3850,
chainId: 1,
},
};

/**
* Fallback usage example of the AssetTransfer component with only required props.
*/
export const Fallback: Story = {
args: {
sender: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
recipient: { address: '0x168dAa4529bf88369ac8c1ABA5A2ad8CF2A61Fb9' },
assetName: 'Ethereum',
assetSymbol: 'ETH',
assetAmount: 1,
hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6',
},
};

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

jest.mock('./assetTransferAddress', () => ({
AssetTransferAddress: () => <div data-testid="asset-transfer-address" />,
}));

describe('<AssetTransfer /> component', () => {
const createTestComponent = (props?: Partial<IAssetTransferProps>) => {
const completeProps: IAssetTransferProps = {
sender: { address: '0x1D03D98c0aac1f83860cec5156116FE68725642E' },
recipient: { address: '0x1D03D98c0aac1f83860cec5156116FE687259999' },
assetSymbol: 'ETH',
assetAmount: 1,
assetName: 'Ethereum',
hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6',
...props,
};

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

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

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

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

render(createTestComponent({ assetFiatPrice, assetAmount }));
const formattedUsdEstimate = screen.getByText('$1.00K');
expect(formattedUsdEstimate).toBeInTheDocument();
});

it('renders the asset value and symbol with sign', () => {
const assetSymbol = 'ETH';
const assetAmount = 10;

render(createTestComponent({ assetSymbol, assetAmount }));
const assetPrintout = screen.getByText('+10 ETH');
expect(assetPrintout).toBeInTheDocument();
});

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

expect(screen.getAllByTestId('asset-transfer-address')).toHaveLength(2);
});

it('configures and applies the correct link for transfer tx', () => {
const hash = '0x0ca620e2dd3147658b8a042b3e7b7cd6f5fa043bf3625140c0dbddcabf47dfb9';
render(createTestComponent({ hash }));

const links = screen.getByRole('link');
const expectedTransactionLink = `https://etherscan.io/tx/${hash}`;

expect(links).toHaveAttribute('href', expectedTransactionLink);
});
});
123 changes: 123 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import classNames from 'classnames';
import { useConfig } from 'wagmi';
import { Avatar, AvatarIcon, IconType, NumberFormat, formatterUtils } from '../../../../core';
import { type ICompositeAddress, type IWeb3ComponentProps } from '../../../types';
import { AssetTransferAddress } from './assetTransferAddress';

export interface IAssetTransferProps extends IWeb3ComponentProps {
/**
* Address (& optional ENS Name) of the transaction sender.
*/
sender: ICompositeAddress;
/**
* Address (& optional ENS Name) of the transaction recipient.
*/
recipient: ICompositeAddress;
/**
* Name of the asset transferred.
*/
assetName: string;
/**
* Icon URL of the tranferred asset.
*/
assetIconSrc?: string;
/**
* Asset amount that was transferred.
*/
assetAmount: number | string;
/**
* Symbol of the asset transferred. Example: ETH, DAI, etc.
*/
assetSymbol: string;
/**
* Price per asset in fiat.
*/
assetFiatPrice?: number | string;
/**
* Transaction hash.
*/
hash: string;
/**
* Chain ID of the transaction.
*/
chainId?: number;
}

export const AssetTransfer: React.FC<IAssetTransferProps> = (props) => {
const {
sender,
recipient,
assetName,
assetIconSrc,
assetAmount,
assetSymbol,
assetFiatPrice,
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 ? `${blockExplorerUrl}/tx/${hash}` : undefined;

const formattedTokenValue = formatterUtils.formatNumber(assetAmount, {
format: NumberFormat.TOKEN_AMOUNT_SHORT,
withSign: true,
fallback: '-',
});
const fiatValue = Number(assetAmount) * Number(assetFiatPrice);
const formattedFiatValue = formatterUtils.formatNumber(fiatValue, {
format: NumberFormat.FIAT_TOTAL_SHORT,
fallback: ` `,
});
const formattedTokenAmount = `${formattedTokenValue} ${assetSymbol}`;

const assetTransferClassNames = classNames(
'flex h-16 w-full items-center justify-between rounded-xl border border-neutral-100 bg-neutral-0 px-4', // base
'hover:border-neutral-200 hover:shadow-neutral-md', // hover
'focus:outline-none focus-visible:rounded-xl focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', // focus
'active:border-neutral-300', // active
'md:h-20 md:px-6', // responsive
);

return (
<div className="flex size-full flex-col gap-y-2 md:gap-y-3">
<div className="relative flex h-full flex-col rounded-xl bg-neutral-0 md:flex-row">
<AssetTransferAddress txRole="sender" participant={sender} blockExplorerUrl={blockExplorerUrl} />
<div className="border-t border-neutral-100 md:border-l" />
<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
)}
/>
<AssetTransferAddress txRole="recipient" participant={recipient} blockExplorerUrl={blockExplorerUrl} />
</div>
<a
href={blockExplorerAssembledHref}
target="_blank"
rel="noopener noreferrer"
className={assetTransferClassNames}
>
<div className="flex items-center space-x-3 md:space-x-4">
<Avatar responsiveSize={{ md: 'md' }} src={assetIconSrc} />
<span className="text-sm leading-tight text-neutral-800 md:text-base">{assetName}</span>
</div>
<div className="flex flex-col items-end justify-end">
<span className="text-sm leading-tight text-neutral-800 md:text-base">{formattedTokenAmount}</span>
<span className="text-sm leading-tight text-neutral-500 md:text-base">{formattedFiatValue}</span>
</div>
</a>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { OdsModulesProvider } from '../../../odsModulesProvider';
import { AssetTransferAddress, type IAssetTransferAddressProps } from './assetTransferAddress';

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

describe('<AssetTransferAddress /> component', () => {
const createTestComponent = (props?: Partial<IAssetTransferAddressProps>) => {
const completeProps = {
txRole: 'sender' as const,
participant: { address: '0x1D03D98c0aac1f83860cec5156116FE68725642E' },
// Optional, but setting blockExplorerUrl parameter to retrieve the link element through the getByRole utility
blockExplorerUrl: 'https://etherscan.io',
...props,
};
return (
<OdsModulesProvider>
<AssetTransferAddress {...completeProps} />
</OdsModulesProvider>
);
};

it('renders correctly as a sender', () => {
const txRole = 'sender' as const;
render(createTestComponent({ txRole }));

const parentElement = screen.getByRole('link');
expect(parentElement).toHaveClass('rounded-t-xl md:rounded-l-xl md:rounded-r-none');
expect(screen.getByText('From')).toBeInTheDocument();
});

it('renders correctly as a recipient', () => {
const txRole = 'recipient' as const;
render(createTestComponent({ txRole }));

const parentElement = screen.getByRole('link');
expect(parentElement).toHaveClass('rounded-b-xl md:rounded-r-xl md:rounded-l-none');
expect(screen.getByText('To')).toBeInTheDocument();
});

it('uses truncated address if ensName is undefined', () => {
const participant = { address: '0x028F5Ca0b3A3A14e44AB8af660B53D1e428457e7' };
render(createTestComponent({ participant }));

expect(screen.getByText('0x02…57e7')).toBeInTheDocument();
});

it('renders ENS name over address when available', () => {
const participant = { address: '0x028F5Ca0b3A3A14e44AB8af660B53D1e428457e7', name: 'vitalik.eth' };
render(createTestComponent({ participant }));

const ensName = screen.getByText('vitalik.eth');
expect(ensName).toBeInTheDocument();
const truncatedAddress = screen.queryByText('0x02…57e7');
expect(truncatedAddress).toBeNull();
});

it('does not create a link if blockExplorerUrl is undefined', () => {
const blockExplorerUrl = undefined;
render(createTestComponent({ blockExplorerUrl }));

const possibleLinkElement = screen.queryByRole('link');
expect(possibleLinkElement).toBeNull();
});

it('creates a link if blockExplorerUrl is defined', () => {
const blockExplorerUrl = 'https://etherscan.io';
render(createTestComponent({ blockExplorerUrl }));

const possibleLinkElement = screen.getByRole('link');
expect(possibleLinkElement).toHaveAttribute(
'href',
'https://etherscan.io/address/0x1D03D98c0aac1f83860cec5156116FE68725642E',
);
});
});
Loading

0 comments on commit 9697374

Please sign in to comment.