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 10 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` and
`AddressInput` module components
- Implement `DaoDataListItem`, `ProposalDataListItem.Structure`, `TransactionDataListItem.Structure`,
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
`MemberDataListItem.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 @@ -3,3 +3,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 @@
export * from './transactionDataListItemStructure';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TransactionDataListItemStructure } from './transactionDataListItemStructure';

export const TransactionDataListItem = {
Structure: TransactionDataListItemStructure,
};
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved

export type { ITransactionDataListItemProps } from './transactionDataListItemStructure.api';
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { DataList } from '../../../../../core';
import { TransactionDataListItemStructure } from './transactionDataListItemStructure';
import {
TransactionType,
TxStatusCode,
type ITransactionDataListItemProps,
} from './transactionDataListItemStructure.api';

describe('<TransactionDataListItemStructure /> component', () => {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
const createTestComponent = (props?: Partial<ITransactionDataListItemProps>) => {
return (
<DataList.Root entityLabel="Daos">
<DataList.Container>
<TransactionDataListItemStructure {...props} />
</DataList.Container>
</DataList.Root>
);
};

it('renders the transaction type heading', () => {
const txType = TransactionType.ACTION;
render(createTestComponent({ txType }));
const transactionTypeHeading = screen.getByText('Smart contract action');
expect(transactionTypeHeading).toBeInTheDocument();
});

// fails on the local run, but passes on the CI for relative unix timestamps (CI server location == UTC 0)
it('renders the formatted date', () => {
const unixTimestamp = 1628841600;
render(createTestComponent({ unixTimestamp }));
const formattedDate = screen.getByText('January 19, 1970 at 8:27 PM');
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
expect(formattedDate).toBeInTheDocument();
});

it('renders the token value and symbol', () => {
const tokenSymbol = 'ETH';
const tokenValue = 10;
render(createTestComponent({ tokenSymbol, tokenValue }));
const tokenPrintout = screen.getByText('10 ETH');
expect(tokenPrintout).toBeInTheDocument();
});

it('renders the formatted USD estimate', () => {
const usdEstimate = 100;
render(createTestComponent({ usdEstimate }));
const formattedUsdEstimate = screen.getByText('$100.00');
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
expect(formattedUsdEstimate).toBeInTheDocument();
});

it('renders "Unknown" for transactions with an undefined type', () => {
render(createTestComponent({}));
const unknownTransactionTypeHeading = screen.getByText('Unknown');
expect(unknownTransactionTypeHeading).toBeInTheDocument();
});

it('overrides the transaction type display with the transaction status', () => {
render(createTestComponent({ txType: TransactionType.DEPOSIT, txStatus: TxStatusCode.FAILED }));
const failedTransactionText = screen.getByText('Failed transaction');
expect(failedTransactionText).toBeInTheDocument();
const closeIcon = screen.getByTestId('CLOSE');
expect(closeIcon).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { type Hash } from 'viem';
import { IconType, type AvatarIconVariant, type IDataListItemProps } from '../../../../../core';

export enum TxStatusCode {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}

export enum TransactionType {
DEPOSIT = 'DEPOSIT',
WITHDRAW = 'WITHDRAW',
ACTION = 'ACTION',
}

export const txHeadingStringList: Record<TransactionType, string> = {
[TransactionType.DEPOSIT]: 'Deposit',
[TransactionType.WITHDRAW]: 'Withdraw',
[TransactionType.ACTION]: 'Smart contract action',
};

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

export const txVariantList: Record<TransactionType, AvatarIconVariant> = {
[TransactionType.DEPOSIT]: 'success',
[TransactionType.WITHDRAW]: 'warning',
[TransactionType.ACTION]: 'info',
};
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved

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.
*/
tokenValue?: number;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/**
* The type of transaction.
*/
txType?: TransactionType;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/**
* The network state of the transaction.
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
*/
txStatus?: TxStatusCode;
/**
* The Unix timestamp of the transaction.
*/
unixTimestamp?: number;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/**
* The estimated USD value of the transaction.
*/
usdEstimate?: number;
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
/**
* The transaction hash.
*/
txHash?: Hash;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react';
import { DataList } from '../../../../../core';
import { TransactionDataListItemStructure } from './transactionDataListItemStructure';

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/ISSDryshtEpB7SUSdNqAcw/branch/P0GeJKqILL7UXvaqu5Jj7V/Aragon-ODS?type=design&node-id=14385%3A30819&mode=dev',
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
},
},
argTypes: {
txHash: {
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 default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
AvatarIcon,
DataList,
Heading,
IconType,
NumberFormat,
Spinner,
formatterUtils,
type AvatarIconVariant,
} from '../../../../../core';
import { QueryType, createBlockExplorerLink } from '../../../../utils/blockExplorerUtils/blockExplorerUtils';
import { formatDate } from '../../../../utils/timestampUtils';
import {
TransactionType,
TxStatusCode,
txHeadingStringList,
txIconTypeList,
txVariantList,
type ITransactionDataListItemProps,
} from './transactionDataListItemStructure.api';

export const TransactionDataListItemStructure: React.FC<ITransactionDataListItemProps> = (props) => {
const {
chainId,
tokenAddress,
tokenSymbol,
tokenValue,
usdEstimate,
txType,
txStatus = TxStatusCode.PENDING,
unixTimestamp,
txHash,
...otherProps
} = props;

const getEffectiveStatus = () => {
const type = txType;
if (txStatus === TxStatusCode.FAILED) {
return {
icon: IconType.CLOSE,
variant: 'critical' as AvatarIconVariant,
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
heading: 'Failed transaction',
};
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
}

return {
icon: type ? txIconTypeList[type] : IconType.HELP,
variant: type ? txVariantList[type] : ('neutral' as AvatarIconVariant),
heading: type ? txHeadingStringList[type] : 'Unknown',
};
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
};

const { icon, variant, heading } = getEffectiveStatus();

const getTxIcon = () => {
if (txStatus !== TxStatusCode.PENDING) {
return <AvatarIcon className="shrink-0" variant={variant} icon={icon} responsiveSize={{ md: 'md' }} />;
}
return (
<div className="flex size-6 shrink-0 items-center justify-center md:size-8">
<Spinner className="transition" variant="neutral" responsiveSize={{ md: 'lg' }} />
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
};

return (
<DataList.Item
className="min-w-fit !py-0 px-4 md:px-6"
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
{...otherProps}
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
href={createBlockExplorerLink({ chainId, queryType: QueryType.TX, txHash })}
target="_blank"
>
<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">
{getTxIcon()}
<div className="flex w-full flex-col items-start gap-y-0.5">
<Heading size="h5" as="h2">
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
{heading}
</Heading>
<Heading className="!text-neutral-500" size="h5" as="h2">
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
{formatDate(unixTimestamp)}
</Heading>
</div>
</div>

<div className="flex flex-col items-end gap-y-0.5">
<Heading size="h5" as="h2">
{tokenValue && txType !== TransactionType.ACTION ? (
<>
{formatterUtils.formatNumber(tokenValue, { format: NumberFormat.TOKEN_AMOUNT_SHORT })}
{` ${tokenSymbol}`}
</>
) : (
`-`
)}
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
</Heading>
<Heading className="!text-neutral-500" size="h5" as="h2">
{formatterUtils.formatNumber(
usdEstimate && txType !== TransactionType.ACTION ? usdEstimate : 0,
{
format: NumberFormat.FIAT_TOTAL_SHORT,
},
)}
</Heading>
</div>
</div>
</DataList.Item>
);
};
39 changes: 39 additions & 0 deletions src/modules/utils/blockExplorerUtils/blockExplorerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type Hash } from 'viem';

export enum QueryType {
TX = 'TX',
BLOCK = 'BLOCK',
ADDRESS = 'ADDRESS',
}

type ExplorerLinkCriteria = {
chainId?: number;
queryType?: QueryType;
txHash?: Hash;
};

export function createBlockExplorerLink(criteria: ExplorerLinkCriteria): string | undefined {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
const { chainId, queryType, txHash } = criteria;

if (chainId === undefined || queryType === undefined || txHash === undefined) {
return undefined;
}

const baseUrls: { [key: number]: string } = {
1: 'https://etherscan.io/',
11155111: 'https://sepolia.etherscan.io/',
137: 'https://polygonscan.com/',
42161: 'https://arbiscan.io/',
8453: 'https://basescan.org/',
};
const baseUrl = baseUrls[chainId];

const typePaths: { [key in NonNullable<ExplorerLinkCriteria['queryType']>]?: string } = {
TX: 'tx/',
BLOCK: 'block/',
ADDRESS: 'address/',
};
const path = typePaths[queryType];

return `${baseUrl}${path}${txHash}`;
}
1 change: 1 addition & 0 deletions src/modules/utils/blockExplorerUtils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as blockExplorerUtils from './blockExplorerUtils';
1 change: 1 addition & 0 deletions src/modules/utils/timestampUtils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './timestampUtils';
24 changes: 24 additions & 0 deletions src/modules/utils/timestampUtils/timestampUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const formatDate = (unixTimestamp?: number): string => {
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
if (!unixTimestamp) {
return '-';
}

const now = new Date();
const date = new Date(unixTimestamp);
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 3600 * 24));
const isCurrentYear = now.getFullYear() === date.getFullYear();
const timeOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric', hour12: true };
const dateTimeOptions: Intl.DateTimeFormatOptions = isCurrentYear
? { month: 'long', day: 'numeric', ...timeOptions }
: { year: 'numeric', month: 'long', day: 'numeric', ...timeOptions };

if (diffDays < 1 && now.getDate() === date.getDate()) {
return `Today at ${new Intl.DateTimeFormat('en-US', timeOptions).format(date)}`;
} else if (diffDays < 2 && now.getDate() - date.getDate() === 1) {
return `Yesterday at ${new Intl.DateTimeFormat('en-US', timeOptions).format(date)}`;
} else if (diffDays < 7) {
return `${diffDays} days ago at ${new Intl.DateTimeFormat('en-US', timeOptions).format(date)}`;
} else {
return new Intl.DateTimeFormat('en-US', dateTimeOptions).format(date);
}
};
Loading