diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ab8b6be..5bbe2be38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ 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 +- Implement `DaoDataListItem.Structure`, `ProposalDataListItem.Structure`, `TransactionDataListItem.Structure`, + `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/index.ts b/src/modules/components/index.ts index 00f45ebbd..922aa4a53 100644 --- a/src/modules/components/index.ts +++ b/src/modules/components/index.ts @@ -4,3 +4,4 @@ export * from './dao'; export * from './member'; export * from './odsModulesProvider'; export * from './proposal'; +export * from './transaction'; diff --git a/src/modules/components/transaction/index.ts b/src/modules/components/transaction/index.ts new file mode 100644 index 000000000..1c841b92e --- /dev/null +++ b/src/modules/components/transaction/index.ts @@ -0,0 +1 @@ +export * from './transactionDataListItem'; diff --git a/src/modules/components/transaction/transactionDataListItem/index.ts b/src/modules/components/transaction/transactionDataListItem/index.ts new file mode 100644 index 000000000..03b613d59 --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/index.ts @@ -0,0 +1,10 @@ +import { TransactionDataListItemStructure as Structure } from './transactionDataListItemStructure/transactionDataListItemStructure'; + +export const TransactionDataListItem = { + Structure, +}; +export { + ITransactionDataListItemProps, + TransactionStatus, + TransactionType, +} from './transactionDataListItemStructure/transactionDataListItemStructure.api'; diff --git a/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/index.ts b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/index.ts new file mode 100644 index 000000000..28cf62e9a --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/index.ts @@ -0,0 +1,6 @@ +export { TransactionDataListItemStructure } from './transactionDataListItemStructure'; +export { + ITransactionDataListItemProps, + TransactionStatus, + TransactionType, +} from './transactionDataListItemStructure.api'; diff --git a/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItem.test.tsx b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItem.test.tsx new file mode 100644 index 000000000..235e96be5 --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItem.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import * as wagmi from 'wagmi'; +import { DataList, NumberFormat, formatterUtils } from '../../../../../core'; +import { TransactionDataListItemStructure } from './transactionDataListItemStructure'; +import { + TransactionStatus, + TransactionType, + type ITransactionDataListItemProps, +} from './transactionDataListItemStructure.api'; + +describe(' component', () => { + const useChainsMock = jest.spyOn(wagmi, 'useChains'); + + beforeEach(() => { + useChainsMock.mockReturnValue([ + { + id: 1, + blockExplorers: { + default: { name: 'Etherscan', url: 'https://etherscan.io', apiUrl: 'https://api.etherscan.io/api' }, + }, + name: 'Chain Name', + nativeCurrency: { + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + rpcUrls: { default: { http: ['https://cloudflare-eth.com'] } }, + }, + ]); + }); + + afterEach(() => { + useChainsMock.mockReset(); + }); + + const createTestComponent = (props?: Partial) => { + const defaultProps: ITransactionDataListItemProps = { + chainId: 1, + hash: '0x123', + date: '2023-01-01T00:00:00Z', + ...props, + }; + return ( + + + + + + ); + }; + + it('renders the transaction type heading', () => { + const type = TransactionType.ACTION; + render(createTestComponent({ type })); + const transactionTypeHeading = screen.getByText('Smart contract action'); + expect(transactionTypeHeading).toBeInTheDocument(); + }); + + it('renders the token value and symbol in a deposit', () => { + const tokenSymbol = 'ETH'; + const tokenAmount = 10; + const type = TransactionType.DEPOSIT; + render(createTestComponent({ tokenSymbol, tokenAmount, type })); + const tokenPrintout = screen.getByText('10 ETH'); + expect(tokenPrintout).toBeInTheDocument(); + }); + + it('renders the formatted USD estimate', () => { + const tokenPrice = 100; + const tokenAmount = 10; + const type = TransactionType.DEPOSIT; + const formattedEstimate = formatterUtils.formatNumber(tokenPrice * tokenAmount, { + format: NumberFormat.FIAT_TOTAL_SHORT, + }); + render(createTestComponent({ tokenPrice, tokenAmount, type })); + const formattedUsdEstimate = screen.getByText(formattedEstimate as string); + expect(formattedUsdEstimate).toBeInTheDocument(); + }); + + it('renders a failed transaction indicator alongside the transaction type', () => { + render(createTestComponent({ type: TransactionType.DEPOSIT, status: TransactionStatus.FAILED })); + const failedTransactionText = screen.getByText('Deposit'); + expect(failedTransactionText).toBeInTheDocument(); + const closeIcon = screen.getByTestId('CLOSE'); + expect(closeIcon).toBeInTheDocument(); + }); + + it('renders the provided timestamp correctly', () => { + const date = '2000-01-01T00:00:00Z'; + render(createTestComponent({ date })); + expect(screen.getByText(date)).toBeInTheDocument(); + }); + + it('renders with the correct block explorer URL', async () => { + const chainId = 1; + const hash = '0x123'; + render(createTestComponent({ chainId, hash })); + + await waitFor(() => { + const linkElement = screen.getByRole('link'); + expect(linkElement).toHaveAttribute('href', 'https://etherscan.io/tx/0x123'); + }); + }); +}); diff --git a/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.api.ts b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.api.ts new file mode 100644 index 000000000..baf8f889f --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.api.ts @@ -0,0 +1,55 @@ +import { type Hash } from 'viem'; +import { type IDataListItemProps } from '../../../../../core'; + +export enum TransactionStatus { + PENDING = 'PENDING', + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', +} + +export enum TransactionType { + DEPOSIT = 'DEPOSIT', + WITHDRAW = 'WITHDRAW', + ACTION = 'ACTION', +} + +export interface ITransactionDataListItemProps extends IDataListItemProps { + /** + * The chain ID of the transaction. + */ + chainId: number; + /** + * The address of the token. + */ + tokenAddress?: string; + /** + * The symbol of the token, e.g. 'ETH' as a string + */ + tokenSymbol?: string; + /** + * The token value in the transaction. + */ + tokenAmount?: number | string; + /** + * The estimated fiat value of the transaction. + */ + tokenPrice?: number | string; + /** + * The type of transaction. + * @default TransactionType.ACTION + */ + type?: TransactionType; + /** + * The current status of a blockchain transaction on the network. + * @default TransactionStatus.PENDING + */ + status?: TransactionStatus; + /** + * The Unix timestamp of the transaction. + */ + date: string; + /** + * The transaction hash. + */ + hash: Hash; +} diff --git a/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.stories.tsx b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.stories.tsx new file mode 100644 index 000000000..934261f78 --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DataList } from '../../../../../core'; +import { TransactionDataListItemStructure } from './transactionDataListItemStructure'; +import { TransactionStatus, TransactionType } from './transactionDataListItemStructure.api'; + +const meta: Meta = { + title: 'Modules/Components/Transaction/TransactionDataListItem.Structure', + component: TransactionDataListItemStructure, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/P0GeJKqILL7UXvaqu5Jj7V/v1.1.0?type=design&node-id=445-5113&mode=design&t=qzF3muTU7z33q8EX-4', + }, + }, + argTypes: { + hash: { + control: 'text', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the TransactionDataList module component. + */ +export const Default: Story = { + render: (args) => ( + + + + + + ), +}; + +export const Withdraw: Story = { + args: { + status: TransactionStatus.SUCCESS, + type: TransactionType.WITHDRAW, + tokenAmount: 10, + tokenSymbol: 'ETH', + }, + render: (args) => ( + + + + + + ), +}; + +export const Failed: Story = { + args: { + status: TransactionStatus.FAILED, + type: TransactionType.DEPOSIT, + tokenSymbol: 'ETH', + tokenAmount: 10, + tokenPrice: 100, + }, + render: (args) => ( + + + + + + ), +}; + +export default meta; diff --git a/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.tsx b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.tsx new file mode 100644 index 000000000..9292f9b5a --- /dev/null +++ b/src/modules/components/transaction/transactionDataListItem/transactionDataListItemStructure/transactionDataListItemStructure.tsx @@ -0,0 +1,121 @@ +import classNames from 'classnames'; +import { useChains } from 'wagmi'; +import { + AvatarIcon, + DataList, + IconType, + NumberFormat, + Spinner, + formatterUtils, + type AvatarIconVariant, +} from '../../../../../core'; +import { + TransactionStatus, + TransactionType, + type ITransactionDataListItemProps, +} from './transactionDataListItemStructure.api'; + +const txHeadingStringList: Record = { + [TransactionType.DEPOSIT]: 'Deposit', + [TransactionType.WITHDRAW]: 'Withdraw', + [TransactionType.ACTION]: 'Smart contract action', +}; + +const txIconTypeList: Record = { + [TransactionType.DEPOSIT]: IconType.DEPOSIT, + [TransactionType.WITHDRAW]: IconType.WITHDRAW, + [TransactionType.ACTION]: IconType.BLOCKCHAIN_SMARTCONTRACT, +}; + +const txVariantList: Record = { + [TransactionType.DEPOSIT]: 'success', + [TransactionType.WITHDRAW]: 'warning', + [TransactionType.ACTION]: 'info', +}; + +export const TransactionDataListItemStructure: React.FC = (props) => { + const { + chainId, + tokenAddress, + tokenSymbol, + tokenAmount, + tokenPrice, + type = TransactionType.ACTION, + status = TransactionStatus.PENDING, + // TO-DO: implement formatter decision + date, + hash, + href, + className, + ...otherProps + } = props; + const chains = useChains(); + + const matchingChain = chains?.find((chain) => chain.id === chainId); + const blockExplorerBaseUrl = matchingChain?.blockExplorers?.default?.url; + const blockExplorerAssembledHref = blockExplorerBaseUrl ? `${blockExplorerBaseUrl}/tx/${hash}` : undefined; + + const parsedHref = blockExplorerAssembledHref ?? href; + + const formattedTokenValue = formatterUtils.formatNumber(tokenAmount, { + format: NumberFormat.TOKEN_AMOUNT_SHORT, + }); + + const fiatValue = Number(tokenAmount ?? 0) * Number(tokenPrice ?? 0); + const formattedTokenPrice = formatterUtils.formatNumber(fiatValue, { + format: NumberFormat.FIAT_TOTAL_SHORT, + }); + + const formattedTokenAmount = + type === TransactionType.ACTION || tokenAmount == null ? '-' : `${formattedTokenValue} ${tokenSymbol}`; + + return ( + +
+
+ {status === TransactionStatus.SUCCESS && ( + + )} + {status === TransactionStatus.FAILED && ( + + )} + {status === TransactionStatus.PENDING && ( +
+ +
+ )} +
+ + {txHeadingStringList[type]} + +

{date}

+
+
+ +
+ + {formattedTokenAmount} + + + {formattedTokenPrice} + +
+
+
+ ); +};