diff --git a/CHANGELOG.md b/CHANGELOG.md index 530d0faa0..41879d45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/src/modules/components/asset/assetTransfer/assetTransfer.stories.tsx b/src/modules/components/asset/assetTransfer/assetTransfer.stories.tsx new file mode 100644 index 000000000..e0c893eb0 --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransfer.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AssetTransfer } from './assetTransfer'; + +const meta: Meta = { + 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; + +/** + * 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; diff --git a/src/modules/components/asset/assetTransfer/assetTransfer.test.tsx b/src/modules/components/asset/assetTransfer/assetTransfer.test.tsx new file mode 100644 index 000000000..8b68f6d4f --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransfer.test.tsx @@ -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: () =>
, +})); + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IAssetTransferProps = { + sender: { address: '0x1D03D98c0aac1f83860cec5156116FE68725642E' }, + recipient: { address: '0x1D03D98c0aac1f83860cec5156116FE687259999' }, + assetSymbol: 'ETH', + assetAmount: 1, + assetName: 'Ethereum', + hash: '0xf006e9454ad77c5e8e6f54106c6939d3d8b68ae16fc216d67c752f54adb21fc6', + ...props, + }; + + return ( + + + + ); + }; + + 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); + }); +}); diff --git a/src/modules/components/asset/assetTransfer/assetTransfer.tsx b/src/modules/components/asset/assetTransfer/assetTransfer.tsx new file mode 100644 index 000000000..b6e6e0faf --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransfer.tsx @@ -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 = (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 ( +
+ + ); +}; diff --git a/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.test.tsx b/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.test.tsx new file mode 100644 index 000000000..853ae3a3a --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.test.tsx @@ -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: () =>
})); + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + 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 ( + + + + ); + }; + + 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', + ); + }); +}); diff --git a/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.tsx b/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.tsx new file mode 100644 index 000000000..7c1f7d446 --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransferAddress/assetTransferAddress.tsx @@ -0,0 +1,79 @@ +import classNames from 'classnames'; +import { Icon, IconType } from '../../../../../core'; +import { type ICompositeAddress } from '../../../../types'; +import { addressUtils } from '../../../../utils'; +import { MemberAvatar } from '../../../member'; + +export type TxRole = 'sender' | 'recipient'; + +export interface IAssetTransferAddressProps { + /** + * Role of the transaction participant. + */ + txRole: TxRole; + /** + * Address (& optional ENS Name) of the transaction participant. + */ + participant: ICompositeAddress; + /** + * URL of the block explorer. + */ + blockExplorerUrl?: string; +} + +export const AssetTransferAddress: React.FC = (props) => { + const { participant, blockExplorerUrl, txRole } = props; + + const assembledHref = blockExplorerUrl != null ? `${blockExplorerUrl}/address/${participant.address}` : undefined; + const resolvedUserHandle = + participant.name != null && participant.name !== '' + ? participant.name + : addressUtils.truncateAddress(participant.address); + + return ( + + +
+ + {txRole === 'sender' ? 'From' : 'To'} + +
+ + {resolvedUserHandle} + + +
+
+
+ ); +}; diff --git a/src/modules/components/asset/assetTransfer/assetTransferAddress/index.ts b/src/modules/components/asset/assetTransfer/assetTransferAddress/index.ts new file mode 100644 index 000000000..3a3dd8ab9 --- /dev/null +++ b/src/modules/components/asset/assetTransfer/assetTransferAddress/index.ts @@ -0,0 +1 @@ +export { AssetTransferAddress, type IAssetTransferAddressProps } from './assetTransferAddress'; diff --git a/src/modules/components/asset/assetTransfer/index.ts b/src/modules/components/asset/assetTransfer/index.ts new file mode 100644 index 000000000..6b326fd57 --- /dev/null +++ b/src/modules/components/asset/assetTransfer/index.ts @@ -0,0 +1 @@ +export { AssetTransfer, type IAssetTransferProps } from './assetTransfer'; diff --git a/src/modules/components/asset/index.ts b/src/modules/components/asset/index.ts index e64cc0259..91240561f 100644 --- a/src/modules/components/asset/index.ts +++ b/src/modules/components/asset/index.ts @@ -1 +1,2 @@ export * from './assetDataListItem'; +export * from './assetTransfer'; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts index d255d011a..97dda5ace 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts @@ -28,7 +28,7 @@ export interface IProposalDataListItemStructureBaseProps