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 18 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
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',
fiatPrice: 3850,
chainId: 1,
},
render: (props) => <AssetTransfer {...props} />,
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* 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',
chainId: 1,
},
render: (props) => <AssetTransfer {...props} />,
};

export default meta;
69 changes: 69 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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',
chainId: 1,
...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 fiatPrice = 100;
const assetAmount = 10;

render(createTestComponent({ fiatPrice, 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);
});
});
130 changes: 130 additions & 0 deletions src/modules/components/asset/assetTransfer/assetTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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 IRequiredCompositeAddress extends ICompositeAddress {
/**
* Required address the transfer participants.
*/
address: string;
}
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved

export interface IAssetTransferProps extends IWeb3ComponentProps {
/**
* Address of the transaction sender.
*/
sender: IRequiredCompositeAddress;
/**
* Address of the transaction recipient.
*/
recipient: IRequiredCompositeAddress;
/**
* Name of the token transferred.
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
*/
assetName: string;
/**
* Icon URL of the tranferred token.
*/
assetIconSrc?: string;
/**
* Amount of tokens transferred.
*/
assetAmount: number | string;
/**
* Symbol of the token transferred. Example: ETH, DAI, etc.
*/
assetSymbol: string;
/**
* Price per token in fiat.
*/
fiatPrice?: number | string;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/**
* Transaction hash.
*/
hash: string;
/**
* Chain ID of the transaction.
*/
chainId: number;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
}

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

const formattedTokenValue = formatterUtils.formatNumber(assetAmount, {
format: NumberFormat.TOKEN_AMOUNT_SHORT,
withSign: true,
fallback: '-',
});
const fiatValue = Number(assetAmount) * Number(fiatPrice);
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,69 @@
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', () => ({ MemberAvatar: () => <div data-testid="member-avatar-mock" /> }));
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved

describe('<AssetTransferAddress /> component', () => {
const createTestComponent = (props?: Partial<IAssetTransferAddressProps>) => {
const completeProps = {
txRole: 'sender' as const,
participant: { address: '0x1D03D98c0aac1f83860cec5156116FE68725642E' },
...props,
};
return (
<OdsModulesProvider>
<AssetTransferAddress {...completeProps} />
</OdsModulesProvider>
);
};

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

expect(screen.getByText('From')).toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access
const parentElement = screen.getByText('From').closest('a');
expect(parentElement).toHaveClass('rounded-t-xl md:rounded-l-xl md:rounded-r-none');
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
});

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

expect(screen.getByText('To')).toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access
const parentElement = screen.getByText('To').closest('a');
expect(parentElement).toHaveClass('rounded-b-xl md:rounded-r-xl md:rounded-l-none');
});

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

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

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

// eslint-disable-next-line testing-library/no-node-access
const possibleLinkElement = screen.getByText('From').closest('a');
expect(possibleLinkElement).not.toHaveAttribute('href');
});

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

// eslint-disable-next-line testing-library/no-node-access
const possibleLinkElement = screen.getByText('From').closest('a');
expect(possibleLinkElement).toHaveAttribute(
'href',
'https://etherscan.io/address/0x1D03D98c0aac1f83860cec5156116FE68725642E',
);
});
});
Loading
Loading