Skip to content

Commit af2c2d5

Browse files
authored
feat(APP-4405): Implement SmartContractFunctionDataListItem module component (#530)
1 parent e476181 commit af2c2d5

14 files changed

Lines changed: 393 additions & 99 deletions

File tree

.changeset/witty-berries-move.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aragon/gov-ui-kit': patch
3+
---
4+
5+
Implement SmartContractFunctionDataListItem module component

src/modules/assets/copy/modulesCopy.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ export const modulesCopy = {
3030
nativeSendAlert: 'Proceed with caution',
3131
nativeSendDescription: (amount: string, symbol: string) =>
3232
`This action attempts to send ${amount} ${symbol}. This could cause the action to fail or result in a loss of funds.`,
33-
notVerified: {
34-
function: 'Unknown',
35-
contract: 'Unverified contract',
36-
},
3733
menu: {
3834
BASIC: 'Basic',
3935
dropdownLabel: 'View action as',
@@ -181,6 +177,13 @@ export const modulesCopy = {
181177
wallet: {
182178
connect: 'Connect',
183179
},
180+
smartContractFunctionDataListItemStructure: {
181+
remove: 'Remove',
182+
notVerified: {
183+
function: 'Unknown',
184+
contract: 'Unverified contract',
185+
},
186+
},
184187
};
185188

186189
export type ModulesCopy = typeof modulesCopy;

src/modules/components/proposal/proposalActions/proposalActionsItem/proposalActionsItem.test.tsx

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { polygon } from 'viem/chains';
43
import { Accordion, IconType } from '../../../../../core';
54
import { testLogger } from '../../../../../core/test';
65
import { modulesCopy } from '../../../../assets';
@@ -54,50 +53,6 @@ describe('<ProposalActionsItem /> component', () => {
5453
expect(() => render(createTestComponent({ index }))).toThrow();
5554
});
5655

57-
it('renders the function name when smart contract is verified', () => {
58-
const functionName = 'mintTokens';
59-
const action = generateProposalAction({ inputData: { function: functionName, contract: '', parameters: [] } });
60-
render(createTestComponent({ action }));
61-
expect(screen.getByText(functionName)).toBeInTheDocument();
62-
});
63-
64-
it('renders a not-verified label for function name when smart contract is not verified', () => {
65-
const action = generateProposalAction({ inputData: undefined });
66-
render(createTestComponent({ action }));
67-
expect(screen.getByText(modulesCopy.proposalActionsItem.notVerified.function)).toBeInTheDocument();
68-
});
69-
70-
it('renders the contract name when smart contract is verified', () => {
71-
const contractName = 'Uniswap';
72-
const action = generateProposalAction({ inputData: { function: '', contract: contractName, parameters: [] } });
73-
render(createTestComponent({ action }));
74-
expect(screen.getByText(contractName)).toBeInTheDocument();
75-
});
76-
77-
it('renders a not-verified label for contract name when smart contract is not verified', () => {
78-
const action = generateProposalAction({ inputData: undefined });
79-
render(createTestComponent({ action }));
80-
expect(screen.getByText(modulesCopy.proposalActionsItem.notVerified.contract)).toBeInTheDocument();
81-
});
82-
83-
it('renders the truncated address of the action target as link', () => {
84-
const to = '0xF26a23f3E7B88e93A16970B74Ae6599d2993690F';
85-
const action = generateProposalAction({ to });
86-
const chainId = polygon.id;
87-
render(createTestComponent({ action, chainId }));
88-
const link = screen.getByRole<HTMLAnchorElement>('link', { name: '0xF26a…690F' });
89-
expect(link).toBeInTheDocument();
90-
expect(link.href).toEqual(`https://polygonscan.com/address/${to}`);
91-
});
92-
93-
it('defaults chain-id to ethereum mainnet when not provided', () => {
94-
const to = '0x87D18Ee84e8f4f5709CBf3500179a4C601DA12cE';
95-
const action = generateProposalAction({ to });
96-
render(createTestComponent({ action }));
97-
const link = screen.getByRole<HTMLAnchorElement>('link', { name: '0x87D1…12cE' });
98-
expect(link.href).toEqual(`https://etherscan.io/address/${to}`);
99-
});
100-
10156
it('renders the action on an accordion and expands it on click', async () => {
10257
render(createTestComponent());
10358
const actionButton = screen.getByRole('button');

src/modules/components/proposal/proposalActions/proposalActionsItem/proposalActionsItem.tsx

Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import classNames from 'classnames';
21
import { useRef, useState } from 'react';
32
import { formatUnits } from 'viem';
43
import { mainnet } from 'viem/chains';
54
import { useChains } from 'wagmi';
6-
import { Accordion, AlertCard, Button, Dropdown, Icon, IconType, invariant, Link, LinkBase } from '../../../../../core';
7-
import { ChainEntityType, useBlockExplorer } from '../../../../hooks';
8-
import { addressUtils } from '../../../../utils';
5+
import { Accordion, AlertCard, Button, Dropdown, IconType, invariant } from '../../../../../core';
96
import { useGukModulesContext } from '../../../gukModulesProvider';
7+
import { SmartContractFunctionDataListItem } from '../../../smartContract/smartContractFunctionDataListItem';
108
import { ProposalActionsDecoder, ProposalActionsDecoderView } from '../proposalActionsDecoder';
119
import { ProposalActionsDecoderMode } from '../proposalActionsDecoder/proposalActionsDecoder.api';
1210
import type { IProposalAction } from '../proposalActionsDefinitions';
@@ -40,7 +38,6 @@ export const ProposalActionsItem = <TAction extends IProposalAction = IProposalA
4038
);
4139

4240
const { copy } = useGukModulesContext();
43-
const { buildEntityUrl } = useBlockExplorer({ chainId });
4441

4542
const chains = useChains();
4643
const chain = chains.find((chain) => chain.id === chainId);
@@ -70,11 +67,6 @@ export const ProposalActionsItem = <TAction extends IProposalAction = IProposalA
7067
const displayValueWarning = action.value !== '0' && action.data !== '0x';
7168
const formattedValue = formatUnits(BigInt(action.value), 18);
7269

73-
const displayWarningFeedback = displayValueWarning || !isAbiAvailable;
74-
const functionNameStyle = displayWarningFeedback ? 'text-warning-800' : 'text-neutral-800';
75-
76-
const targetAddressUrl = buildEntityUrl({ type: ChainEntityType.ADDRESS, id: action.to });
77-
7870
const viewModes = [
7971
{ mode: 'BASIC' as const, disabled: !supportsBasicView },
8072
{ mode: ProposalActionsDecoderView.DECODED, disabled: !supportsDecodedView },
@@ -88,36 +80,16 @@ export const ProposalActionsItem = <TAction extends IProposalAction = IProposalA
8880
return (
8981
<Accordion.Item value={value ?? index.toString()} ref={itemRef}>
9082
<Accordion.ItemHeader className="min-w-0">
91-
<div className="flex min-w-0 flex-col items-start gap-1 md:gap-1.5">
92-
<div className="flex w-full flex-row items-center gap-2">
93-
<p
94-
className={classNames(
95-
'truncate text-base leading-tight font-normal md:text-lg',
96-
functionNameStyle,
97-
)}
98-
>
99-
{action.inputData?.function ?? copy.proposalActionsItem.notVerified.function}
100-
</p>
101-
{displayWarningFeedback && (
102-
<Icon icon={IconType.WARNING} size="md" className="text-warning-500" />
103-
)}
104-
</div>
105-
<LinkBase
106-
className="flex w-full items-center gap-2 md:gap-3"
107-
href={targetAddressUrl}
108-
target="_blank"
109-
>
110-
<p className="truncate text-sm leading-tight font-normal text-neutral-500 md:text-base">
111-
{action.inputData?.contract ?? copy.proposalActionsItem.notVerified.contract}
112-
</p>
113-
{/* Using solution from https://kizu.dev/nested-links/ to nest anchor tags */}
114-
<object type="unknown">
115-
<Link className="shrink-0" href={targetAddressUrl} isExternal={true}>
116-
{addressUtils.truncateAddress(action.to)}
117-
</Link>
118-
</object>
119-
</LinkBase>
120-
</div>
83+
<SmartContractFunctionDataListItem.Structure
84+
functionName={action.inputData?.function}
85+
contractName={action.inputData?.contract}
86+
contractAddress={action.to}
87+
functionParameters={action.inputData?.parameters}
88+
chainId={chainId}
89+
className="w-full bg-transparent"
90+
asChild={true}
91+
displayWarning={displayValueWarning}
92+
/>
12193
</Accordion.ItemHeader>
12294
<Accordion.ItemContent forceMount={editMode ? true : undefined}>
12395
<div className="flex flex-col items-start gap-y-6 self-start md:gap-y-8">
Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
import { Accordion, StateSkeletonBar } from '../../../../../core';
1+
import { Accordion } from '../../../../../core';
2+
import { SmartContractFunctionDataListItem } from '../../../smartContract/smartContractFunctionDataListItem';
23

34
export const ProposalActionsItemSkeleton = () => (
45
<Accordion.Item value="skeleton" className="pointer-events-none" tabIndex={0} aria-busy="true" aria-label="loading">
56
<Accordion.ItemHeader>
6-
<div className="flex size-full h-[48px] flex-col justify-center gap-y-1 md:h-[52.5px] md:gap-y-1.5">
7-
<div className="flex w-1/2 md:w-1/3">
8-
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="100%" />
9-
</div>
10-
<div className="flex w-3/4 gap-x-2 md:w-1/2">
11-
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
12-
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
13-
</div>
14-
</div>
7+
<SmartContractFunctionDataListItem.Skeleton
8+
asChild={true}
9+
className="flex size-full h-[48px] flex-col justify-center gap-3 md:h-[52.5px]"
10+
/>
1511
</Accordion.ItemHeader>
1612
</Accordion.Item>
1713
);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { SmartContractFunctionDataListItemSkeleton } from './smartContractFunctionDataListItemSkeleton';
2+
import { SmartContractFunctionDataListItemStructure } from './smartContractFunctionDataListItemStructure';
3+
4+
export const SmartContractFunctionDataListItem = {
5+
Skeleton: SmartContractFunctionDataListItemSkeleton,
6+
Structure: SmartContractFunctionDataListItemStructure,
7+
};
8+
9+
export * from './smartContractFunctionDataListItemSkeleton';
10+
export * from './smartContractFunctionDataListItemStructure';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {
2+
SmartContractFunctionDataListItemSkeleton,
3+
type ISmartContractFunctionDataListItemSkeletonProps,
4+
} from './smartContractFunctionDataListItemSkeleton';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { SmartContractFunctionDataListItem } from '../../smartContractFunctionDataListItem';
3+
4+
const meta: Meta<typeof SmartContractFunctionDataListItem.Skeleton> = {
5+
title: 'Modules/Components/SmartContract/SmartContractFunctionDataListItem/SmartContractFunctionDataListItem.Skeleton',
6+
component: SmartContractFunctionDataListItem.Skeleton,
7+
parameters: {
8+
design: {
9+
type: 'figma',
10+
url: 'https://www.figma.com/design/mnH992k1U8hHBzTHFC2W3U/Aragon-App?node-id=13248-149568',
11+
},
12+
},
13+
};
14+
15+
type Story = StoryObj<typeof SmartContractFunctionDataListItem.Skeleton>;
16+
17+
/**
18+
* Default usage example of the SmartContractFunctionDataListItem.Skeleton component.
19+
*/
20+
export const Default: Story = {};
21+
22+
export default meta;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { render, screen } from '@testing-library/react';
2+
import {
3+
SmartContractFunctionDataListItem,
4+
type ISmartContractFunctionDataListItemSkeletonProps,
5+
} from '../../smartContractFunctionDataListItem';
6+
7+
describe('<SmartContractFunctionDataListItem.Skeleton /> component', () => {
8+
const createTestComponent = (props?: Partial<ISmartContractFunctionDataListItemSkeletonProps>) => {
9+
const completeProps: ISmartContractFunctionDataListItemSkeletonProps = { ...props };
10+
11+
return <SmartContractFunctionDataListItem.Skeleton {...completeProps} />;
12+
};
13+
14+
it('has correct accessibility attributes', () => {
15+
render(createTestComponent());
16+
const listItem = screen.getByLabelText('loading');
17+
expect(listItem).toHaveAttribute('aria-busy', 'true');
18+
expect(listItem).toHaveAttribute('tabIndex', '0');
19+
});
20+
21+
it('does not have accessibility attributes when asChild is true', () => {
22+
render(createTestComponent({ asChild: true }));
23+
const listItems = screen.queryAllByLabelText('loading');
24+
expect(listItems.length).toBe(0);
25+
});
26+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import classNames from 'classnames';
2+
import { DataList, StateSkeletonBar, type IDataListItemProps } from '../../../../../core';
3+
4+
export type ISmartContractFunctionDataListItemSkeletonProps = IDataListItemProps & {
5+
/**
6+
* Flag to determine whether or not the item is a child of another component so we can apply the correct styles and remove accessibility attributes.
7+
*/
8+
asChild?: boolean;
9+
};
10+
11+
export const SmartContractFunctionDataListItemSkeleton: React.FC<ISmartContractFunctionDataListItemSkeletonProps> = (
12+
props,
13+
) => {
14+
const { className, asChild, ...otherProps } = props;
15+
16+
const containerClasses = asChild
17+
? ' border-none shadow-none'
18+
: 'border-neutral-0 flex min-h-[67.5px] w-full flex-col gap-3 py-4 md:min-h-[88.5px]';
19+
20+
return (
21+
<DataList.Item
22+
tabIndex={!asChild ? 0 : undefined}
23+
aria-busy={!asChild ? true : undefined}
24+
aria-label={!asChild ? 'loading' : undefined}
25+
className={classNames(containerClasses, className)}
26+
{...otherProps}
27+
>
28+
<div className="flex w-1/2 md:w-1/3">
29+
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="100%" />
30+
</div>
31+
<div className="flex w-3/4 gap-x-2 md:w-1/2">
32+
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
33+
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
34+
</div>
35+
</DataList.Item>
36+
);
37+
};

0 commit comments

Comments
 (0)