diff --git a/CHANGELOG.md b/CHANGELOG.md index c965326bc..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 @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Update Eslint rules to align usage of boolean properties - Update default query-client options to set a stale time greater than 0 - Bump `webpack-dev-middleware` from 6.1.1 to 6.1.2 +- Bump `express` from 4.18.2 to 4.19.2 #132 ### Fixed 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} + +
+
+
+ ); +}; diff --git a/yarn.lock b/yarn.lock index c7c7ee6cf..9b59111fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5910,13 +5910,13 @@ bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== dependencies: bytes "3.1.2" - content-type "~1.0.4" + content-type "~1.0.5" debug "2.6.9" depd "2.0.0" destroy "1.2.0" @@ -5924,7 +5924,7 @@ body-parser@1.20.1: iconv-lite "0.4.24" on-finished "2.4.1" qs "6.11.0" - raw-body "2.5.1" + raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -6453,7 +6453,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4: +content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -6478,10 +6478,10 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== core-js-compat@^3.31.0, core-js-compat@^3.34.0: version "3.35.1" @@ -7819,16 +7819,16 @@ expect@^29.0.0, expect@^29.7.0: jest-util "^29.7.0" express@^4.17.3: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.5.0" + cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -12009,10 +12009,10 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== dependencies: bytes "3.1.2" http-errors "2.0.0"