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 #130

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a358d8e
feat: implement transactionDataListItemStructure component
thekidnamedkd Mar 21, 2024
2919e21
chore: update index exports
thekidnamedkd Mar 21, 2024
57f4eee
chore: update CHANGELOG
thekidnamedkd Mar 21, 2024
915d27d
feat: handle tx status and relationship with tx type
thekidnamedkd Mar 25, 2024
e230d1c
chore: update CHANGELOG
thekidnamedkd Mar 25, 2024
b50ea0c
chore: improve test coverage for failed cases
thekidnamedkd Mar 25, 2024
64e5894
fix: update test for unix timestamp to pass on remote CI timezone
thekidnamedkd Mar 25, 2024
b214cff
fix: prevent layout shift with spinner wrapper, extra cleanup
thekidnamedkd Mar 25, 2024
18c445d
chore: prop cleanup + naming
thekidnamedkd Mar 25, 2024
a18eb16
chore: clean up tests
thekidnamedkd Mar 26, 2024
5ed869c
index on feat/APP-2797: a18eb16 chore: clean up tests
thekidnamedkd Mar 27, 2024
2d7f6c0
On feat/APP-2797: pending PR work
thekidnamedkd Mar 27, 2024
a8962c0
feat: implement PR fixes from convos -- logic cleanup, props, index b…
thekidnamedkd Mar 27, 2024
680a2b9
chore: clean up tests
thekidnamedkd Mar 27, 2024
f9c96de
chore: rename effective function
thekidnamedkd Mar 27, 2024
cb5edff
chore: resolve PR conversations - improved readability, block explore…
thekidnamedkd Mar 31, 2024
bcce4a4
chore: resolve merge conflict - update CHANGELOG
thekidnamedkd Mar 31, 2024
6d1979e
fix: TransactionStatus export
thekidnamedkd Apr 1, 2024
b02ab99
chore: update failed state, revise tests
thekidnamedkd Apr 3, 2024
8e1993e
fix: resolve PR convos - spy test, move consts, remove unnecessary ch…
thekidnamedkd Apr 3, 2024
d215d6e
Merge branch 'main' of github.com:aragon/ods into feat/APP-2797
thekidnamedkd Apr 3, 2024
2582f01
fix: remove console.log
thekidnamedkd Apr 3, 2024
d27d8ea
fix: swap out for additonal span tags
thekidnamedkd Apr 3, 2024
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 = {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
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 = {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
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">
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
<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