Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: APP-2977 - Implement AssetTransfer module component #134

Merged
merged 23 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4f41a71
feat: implement AssetTransfer module component + tests & stories
thekidnamedkd Apr 2, 2024
94dc40b
chore: update CHANGELOG
thekidnamedkd Apr 2, 2024
004112a
fix: resolve PR convo - refactor AssetTransferAddress, dry up code
thekidnamedkd Apr 3, 2024
13d1a4c
Merge branch 'main' of github.com:aragon/ods into feat/APP-2977
thekidnamedkd Apr 3, 2024
de24a1a
fix: move to span tags, adjust from/to for sender
thekidnamedkd Apr 3, 2024
cc13a7b
fix: swap out for additonal span tags
thekidnamedkd Apr 3, 2024
8508c62
chore: abstract length classNames blocks from return for readability
thekidnamedkd Apr 3, 2024
664191c
chore: reindex AssetTransferAddress and write tests, update tests for…
thekidnamedkd Apr 4, 2024
6813da1
chore: update CHANGELOG
thekidnamedkd Apr 4, 2024
cfb07bd
chore: update test for simpler formatter mock
thekidnamedkd Apr 4, 2024
0dce9bb
chore: clean up props, truncate ens, default/fallback story, remove f…
thekidnamedkd Apr 5, 2024
d884c2b
chore: resolve PR conversations - borders to spec, removed format fun…
thekidnamedkd Apr 8, 2024
0c7d749
fix: implement correct truncate usage to full width parent with grid
thekidnamedkd Apr 8, 2024
4d10abd
chore: move and update comments on styling for better readability
thekidnamedkd Apr 8, 2024
de7d15f
fix: remove unnecessary padding from AssetAddressTransfer
thekidnamedkd Apr 8, 2024
09ab549
chore: remove mocked number formatter
thekidnamedkd Apr 8, 2024
d4e96f2
Merge branch 'main' of github.com:aragon/ods into feat/APP-2977
thekidnamedkd Apr 8, 2024
5872ad1
chore: update CHANGELOG
thekidnamedkd Apr 8, 2024
b12bf2e
chore: resolve PR conversations - prop naming, comments, optional cha…
thekidnamedkd Apr 9, 2024
a0cd696
chore: min-w-0 to parent col div on transfer address
thekidnamedkd Apr 9, 2024
f4b1a21
chore: remove extra truncate
thekidnamedkd Apr 9, 2024
509a479
chore: update test for AssetTransferAddress for URL exception, includ…
thekidnamedkd Apr 10, 2024
f4ab7c1
chore: reflect ICompositeAddress changes in ProposalDataListItem stru…
thekidnamedkd Apr 10, 2024
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added

- Implement `DaoDataListItem.Structure`, `ProposalDataListItem.Structure`, `MemberDataListItem.Structure`,
`AssetDataListItem.Structure` and `AddressInput` module components
`AssetDataListItem.Structure`, `AssetTransfer` 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,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 = {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
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);
});
});
153 changes: 153 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import classNames from 'classnames';
import { type Hash } from 'viem';
import { useConfig } from 'wagmi';
import { Avatar, AvatarIcon, IconType, NumberFormat, formatterUtils } from '../../../../core';
import { type IWeb3ComponentProps } from '../../../types';
import { addressUtils } from '../../../utils';
import { AssetTransferAddress } from './assetTransferAddress';

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

// could be moved to a utils file for export? reusable in TranactionDataListItem.Structure for example
const formatValue = (tokenAmount: number, tokenSymbol: string, tokenPrice: number | string) => {
const formattedTokenValue = formatterUtils.formatNumber(tokenAmount, {
format: NumberFormat.TOKEN_AMOUNT_SHORT,
withSign: true,
});
const fiatValue = Number(tokenAmount) * Number(tokenPrice);
const formattedFiatValue = formatterUtils.formatNumber(fiatValue, { format: NumberFormat.FIAT_TOTAL_SHORT });
const formattedTokenAmount = `${formattedTokenValue} ${tokenSymbol}`;

return { formattedTokenAmount, formattedFiatValue };
};

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;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved

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

const createLink = (address: Hash) => blockExplorerUrl && `${blockExplorerUrl}/address/${address}`;
const resolveHandle = (ensName: string | undefined, address: Hash) =>
ensName ?? addressUtils.truncateAddress(address);

const { formattedTokenAmount, formattedFiatValue } = formatValue(tokenAmount, tokenSymbol, tokenPrice);

const tokenTransferClassNames = classNames(
'flex h-16 w-full items-center justify-between rounded-xl border-[1px] border-neutral-100 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 h-full w-[320px] flex-col gap-y-2 md:w-[640px] md:gap-y-3">
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
<div className="relative flex h-full flex-col rounded-xl border-[1px] border-neutral-100 md:flex-row">
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
<AssetTransferAddress
txRole="sender"
ensName={senderEnsName}
address={senderAddress}
link={createLink(senderAddress)}
handle={resolveHandle(senderEnsName, senderAddress)}
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/>
<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
)}
/>
<AssetTransferAddress
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
txRole="recipient"
ensName={recipientEnsName}
address={recipientAddress}
link={createLink(recipientAddress)}
handle={resolveHandle(recipientEnsName, recipientAddress)}
/>
</div>
<a
href={blockExplorerAssembledHref}
target="_blank"
rel="noopener noreferrer"
className={tokenTransferClassNames}
>
<div className="flex items-center space-x-3 md:space-x-4">
<Avatar responsiveSize={{ md: 'md' }} src={tokenIconSrc} />
<span className="text-sm leading-tight text-neutral-800 md:text-base">{tokenName}</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,63 @@
import classNames from 'classnames';
import { type Hash } from 'viem';
import { Icon, IconType } from '../../../../core';
import { MemberAvatar } from '../../member';

interface IAssetTransferAddressProps {
txRole: 'sender' | 'recipient';
/**
* Address of the transaction user.
*/
address: Hash;
/**
* Resolved handle of the user.
*/
handle: string;
/**
* ENS name of the transaction user.
*/
ensName?: string;
/**
* Link to the block explorer page of the user.
*/
link?: string;
}

export const AssetTransferAddress: React.FC<IAssetTransferAddressProps> = (props) => {
const { address, ensName, link, handle, txRole } = props;
return (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className={classNames(
'flex h-20 w-full items-center space-x-4 px-4 py-7', //base
'hover:border-neutral-200 hover:shadow-neutral-md', //hover
'focus:outline-none 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
{
'rounded-t-xl md:rounded-l-xl md:rounded-r-none ': txRole === 'sender', // sender base
'rounded-b-xl md:rounded-l-none md:rounded-r-xl md:pl-8': txRole === 'recipient', // recipient base
},
{
'focus-visible:rounded-t-xl md:focus-visible:rounded-l-xl md:focus-visible:rounded-r-none':
txRole === 'sender', // sender focus
'focus-visible:rounded-b-xl md:focus-visible:rounded-l-none md:focus-visible:rounded-r-xl':
txRole === 'recipient', // recipient focus
},
)}
>
<MemberAvatar responsiveSize={{ md: 'md' }} ensName={ensName} address={address} />
<div className="flex flex-col">
<span className="text-xs font-normal leading-tight text-neutral-500 md:text-sm">
{txRole === 'sender' ? 'From' : 'To'}
</span>
<div className="flex items-center space-x-1">
<span className="text-sm font-normal leading-tight text-neutral-800 md:text-base">{handle}</span>
<Icon icon={IconType.LINK_EXTERNAL} size="sm" className="text-neutral-300" />
</div>
</div>
</a>
);
};
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';
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
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';
Loading