Skip to content

Commit

Permalink
feat: APP-2797 - Implement TransactionDataListItem.Structure module c…
Browse files Browse the repository at this point in the history
…omponent (#135)
  • Loading branch information
thekidnamedkd authored Apr 3, 2024
1 parent 613663c commit 9eaf8aa
Show file tree
Hide file tree
Showing 9 changed files with 371 additions and 2 deletions.
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/modules/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './dao';
export * from './member';
export * from './odsModulesProvider';
export * from './proposal';
export * from './transaction';
1 change: 1 addition & 0 deletions src/modules/components/transaction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './transactionDataListItem';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { TransactionDataListItemStructure as Structure } from './transactionDataListItemStructure/transactionDataListItemStructure';

export const TransactionDataListItem = {
Structure,
};
export {
ITransactionDataListItemProps,
TransactionStatus,
TransactionType,
} from './transactionDataListItemStructure/transactionDataListItemStructure.api';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { TransactionDataListItemStructure } from './transactionDataListItemStructure';
export {
ITransactionDataListItemProps,
TransactionStatus,
TransactionType,
} from './transactionDataListItemStructure.api';
Original file line number Diff line number Diff line change
@@ -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('<TransactionDataListItem.Structure /> 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<ITransactionDataListItemProps>) => {
const defaultProps: ITransactionDataListItemProps = {
chainId: 1,
hash: '0x123',
date: '2023-01-01T00:00:00Z',
...props,
};
return (
<DataList.Root entityLabel="Daos">
<DataList.Container>
<TransactionDataListItemStructure {...defaultProps} />
</DataList.Container>
</DataList.Root>
);
};

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<HTMLAnchorElement>('link');
expect(linkElement).toHaveAttribute('href', 'https://etherscan.io/tx/0x123');
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<typeof TransactionDataListItemStructure> = {
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<typeof TransactionDataListItemStructure>;

/**
* Default usage example of the TransactionDataList module component.
*/
export const Default: Story = {
render: (args) => (
<DataList.Root entityLabel="Transactions">
<DataList.Container>
<TransactionDataListItemStructure {...args} />
</DataList.Container>
</DataList.Root>
),
};

export const Withdraw: Story = {
args: {
status: TransactionStatus.SUCCESS,
type: TransactionType.WITHDRAW,
tokenAmount: 10,
tokenSymbol: 'ETH',
},
render: (args) => (
<DataList.Root entityLabel="Transactions">
<DataList.Container>
<TransactionDataListItemStructure {...args} />
</DataList.Container>
</DataList.Root>
),
};

export const Failed: Story = {
args: {
status: TransactionStatus.FAILED,
type: TransactionType.DEPOSIT,
tokenSymbol: 'ETH',
tokenAmount: 10,
tokenPrice: 100,
},
render: (args) => (
<DataList.Root entityLabel="Transactions">
<DataList.Container>
<TransactionDataListItemStructure {...args} />
</DataList.Container>
</DataList.Root>
),
};

export default meta;
Original file line number Diff line number Diff line change
@@ -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, string> = {
[TransactionType.DEPOSIT]: 'Deposit',
[TransactionType.WITHDRAW]: 'Withdraw',
[TransactionType.ACTION]: 'Smart contract action',
};

const txIconTypeList: Record<TransactionType, IconType> = {
[TransactionType.DEPOSIT]: IconType.DEPOSIT,
[TransactionType.WITHDRAW]: IconType.WITHDRAW,
[TransactionType.ACTION]: IconType.BLOCKCHAIN_SMARTCONTRACT,
};

const txVariantList: Record<TransactionType, AvatarIconVariant> = {
[TransactionType.DEPOSIT]: 'success',
[TransactionType.WITHDRAW]: 'warning',
[TransactionType.ACTION]: 'info',
};

export const TransactionDataListItemStructure: React.FC<ITransactionDataListItemProps> = (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 (
<DataList.Item
className={classNames('px-4 py-0 md:px-6', className)}
href={parsedHref}
target="_blank"
{...otherProps}
>
<div className="flex w-full justify-between py-3 md:py-4">
<div className="flex items-center gap-x-3 md:gap-x-4">
{status === TransactionStatus.SUCCESS && (
<AvatarIcon
className="shrink-0"
variant={txVariantList[type]}
icon={txIconTypeList[type]}
responsiveSize={{ md: 'md' }}
/>
)}
{status === TransactionStatus.FAILED && (
<AvatarIcon
className="shrink-0"
variant="critical"
icon={IconType.CLOSE}
responsiveSize={{ md: 'md' }}
/>
)}
{status === TransactionStatus.PENDING && (
<div className="flex size-6 shrink-0 items-center justify-center md:size-8">
<Spinner className="transition" variant="neutral" responsiveSize={{ md: 'lg' }} />
</div>
)}
<div className="flex w-full flex-col items-start gap-y-0.5">
<span className="text-sm font-normal leading-tight text-neutral-800 md:text-base">
{txHeadingStringList[type]}
</span>
<p className="text-sm font-normal leading-tight text-neutral-500 md:text-base">{date}</p>
</div>
</div>

<div className="flex flex-col items-end gap-y-0.5">
<span className="text-sm font-normal leading-tight text-neutral-800 md:text-base">
{formattedTokenAmount}
</span>
<span className="text-sm font-normal leading-tight text-neutral-500 md:text-base">
{formattedTokenPrice}
</span>
</div>
</div>
</DataList.Item>
);
};

0 comments on commit 9eaf8aa

Please sign in to comment.