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-2797 - Implement TransactionDataListItem.Structure module component #135

Merged
merged 2 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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>
);
};
Loading