Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
5 changes: 5 additions & 0 deletions .changeset/witty-berries-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/gov-ui-kit': patch
---

Implement SmartContractFunctionDataListItem module component
11 changes: 7 additions & 4 deletions src/modules/assets/copy/modulesCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ export const modulesCopy = {
nativeSendAlert: 'Proceed with caution',
nativeSendDescription: (amount: string, symbol: string) =>
`This action attempts to send ${amount} ${symbol}. This could cause the action to fail or result in a loss of funds.`,
notVerified: {
function: 'Unknown',
contract: 'Unverified contract',
},
menu: {
BASIC: 'Basic',
dropdownLabel: 'View action as',
Expand Down Expand Up @@ -181,6 +177,13 @@ export const modulesCopy = {
wallet: {
connect: 'Connect',
},
smartContractFunctionDataListItemStructure: {
remove: 'Remove',
notVerified: {
function: 'Unknown',
contract: 'Unverified contract',
},
},
};

export type ModulesCopy = typeof modulesCopy;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { polygon } from 'viem/chains';
import { Accordion, IconType } from '../../../../../core';
import { testLogger } from '../../../../../core/test';
import { modulesCopy } from '../../../../assets';
Expand Down Expand Up @@ -54,50 +53,6 @@ describe('<ProposalActionsItem /> component', () => {
expect(() => render(createTestComponent({ index }))).toThrow();
});

it('renders the function name when smart contract is verified', () => {
const functionName = 'mintTokens';
const action = generateProposalAction({ inputData: { function: functionName, contract: '', parameters: [] } });
render(createTestComponent({ action }));
expect(screen.getByText(functionName)).toBeInTheDocument();
});

it('renders a not-verified label for function name when smart contract is not verified', () => {
const action = generateProposalAction({ inputData: undefined });
render(createTestComponent({ action }));
expect(screen.getByText(modulesCopy.proposalActionsItem.notVerified.function)).toBeInTheDocument();
});

it('renders the contract name when smart contract is verified', () => {
const contractName = 'Uniswap';
const action = generateProposalAction({ inputData: { function: '', contract: contractName, parameters: [] } });
render(createTestComponent({ action }));
expect(screen.getByText(contractName)).toBeInTheDocument();
});

it('renders a not-verified label for contract name when smart contract is not verified', () => {
const action = generateProposalAction({ inputData: undefined });
render(createTestComponent({ action }));
expect(screen.getByText(modulesCopy.proposalActionsItem.notVerified.contract)).toBeInTheDocument();
});

it('renders the truncated address of the action target as link', () => {
const to = '0xF26a23f3E7B88e93A16970B74Ae6599d2993690F';
const action = generateProposalAction({ to });
const chainId = polygon.id;
render(createTestComponent({ action, chainId }));
const link = screen.getByRole<HTMLAnchorElement>('link', { name: '0xF26a…690F' });
expect(link).toBeInTheDocument();
expect(link.href).toEqual(`https://polygonscan.com/address/${to}`);
});

it('defaults chain-id to ethereum mainnet when not provided', () => {
const to = '0x87D18Ee84e8f4f5709CBf3500179a4C601DA12cE';
const action = generateProposalAction({ to });
render(createTestComponent({ action }));
const link = screen.getByRole<HTMLAnchorElement>('link', { name: '0x87D1…12cE' });
expect(link.href).toEqual(`https://etherscan.io/address/${to}`);
});

it('renders the action on an accordion and expands it on click', async () => {
render(createTestComponent());
const actionButton = screen.getByRole('button');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import classNames from 'classnames';
import { useRef, useState } from 'react';
import { formatUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { useChains } from 'wagmi';
import { Accordion, AlertCard, Button, Dropdown, Icon, IconType, invariant, Link, LinkBase } from '../../../../../core';
import { ChainEntityType, useBlockExplorer } from '../../../../hooks';
import { addressUtils } from '../../../../utils';
import { Accordion, AlertCard, Button, Dropdown, IconType, invariant } from '../../../../../core';
import { useGukModulesContext } from '../../../gukModulesProvider';
import { SmartContractFunctionDataListItem } from '../../../smartContract/smartContractFunctionDataListItem';
import { ProposalActionsDecoder, ProposalActionsDecoderView } from '../proposalActionsDecoder';
import { ProposalActionsDecoderMode } from '../proposalActionsDecoder/proposalActionsDecoder.api';
import type { IProposalAction } from '../proposalActionsDefinitions';
Expand Down Expand Up @@ -40,7 +38,6 @@ export const ProposalActionsItem = <TAction extends IProposalAction = IProposalA
);

const { copy } = useGukModulesContext();
const { buildEntityUrl } = useBlockExplorer({ chainId });

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

const displayWarningFeedback = displayValueWarning || !isAbiAvailable;
const functionNameStyle = displayWarningFeedback ? 'text-warning-800' : 'text-neutral-800';
Comment thread
shan8851 marked this conversation as resolved.

const targetAddressUrl = buildEntityUrl({ type: ChainEntityType.ADDRESS, id: action.to });

const viewModes = [
{ mode: 'BASIC' as const, disabled: !supportsBasicView },
{ mode: ProposalActionsDecoderView.DECODED, disabled: !supportsDecodedView },
Expand All @@ -88,36 +80,16 @@ export const ProposalActionsItem = <TAction extends IProposalAction = IProposalA
return (
<Accordion.Item value={value ?? index.toString()} ref={itemRef}>
<Accordion.ItemHeader className="min-w-0">
<div className="flex min-w-0 flex-col items-start gap-1 md:gap-1.5">
<div className="flex w-full flex-row items-center gap-2">
<p
className={classNames(
'truncate text-base leading-tight font-normal md:text-lg',
functionNameStyle,
)}
>
{action.inputData?.function ?? copy.proposalActionsItem.notVerified.function}
</p>
{displayWarningFeedback && (
<Icon icon={IconType.WARNING} size="md" className="text-warning-500" />
)}
</div>
<LinkBase
className="flex w-full items-center gap-2 md:gap-3"
href={targetAddressUrl}
target="_blank"
>
<p className="truncate text-sm leading-tight font-normal text-neutral-500 md:text-base">
{action.inputData?.contract ?? copy.proposalActionsItem.notVerified.contract}
</p>
{/* Using solution from https://kizu.dev/nested-links/ to nest anchor tags */}
<object type="unknown">
<Link className="shrink-0" href={targetAddressUrl} isExternal={true}>
{addressUtils.truncateAddress(action.to)}
</Link>
</object>
</LinkBase>
</div>
<SmartContractFunctionDataListItem.Structure
functionName={action.inputData?.function}
contractName={action.inputData?.contract}
contractAddress={action.to}
functionParameters={action.inputData?.parameters}
chainId={chainId}
className="w-full bg-transparent"
asChild={true}
displayWarning={displayValueWarning}
/>
</Accordion.ItemHeader>
<Accordion.ItemContent forceMount={editMode ? true : undefined}>
<div className="flex flex-col items-start gap-y-6 self-start md:gap-y-8">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { Accordion, StateSkeletonBar } from '../../../../../core';
import { Accordion } from '../../../../../core';
import { SmartContractFunctionDataListItemSkeletonContent } from '../../../smartContract/smartContractFunctionDataListItem';

export const ProposalActionsItemSkeleton = () => (
<Accordion.Item value="skeleton" className="pointer-events-none" tabIndex={0} aria-busy="true" aria-label="loading">
<Accordion.ItemHeader>
<div className="flex size-full h-[48px] flex-col justify-center gap-y-1 md:h-[52.5px] md:gap-y-1.5">
<div className="flex w-1/2 md:w-1/3">
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="100%" />
</div>
<div className="flex w-3/4 gap-x-2 md:w-1/2">
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
</div>
<SmartContractFunctionDataListItemSkeletonContent />
Comment thread
shan8851 marked this conversation as resolved.
Outdated
</div>
</Accordion.ItemHeader>
</Accordion.Item>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SmartContractFunctionDataListItemSkeleton } from './smartContractFunctionDataListItemSkeleton';
import { SmartContractFunctionDataListItemStructure } from './smartContractFunctionDataListItemStructure';

export const SmartContractFunctionDataListItem = {
Skeleton: SmartContractFunctionDataListItemSkeleton,
Structure: SmartContractFunctionDataListItemStructure,
};

export * from './smartContractFunctionDataListItemSkeleton';
export * from './smartContractFunctionDataListItemStructure';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
SmartContractFunctionDataListItemSkeleton,
type ISmartContractFunctionDataListItemSkeletonProps,
} from './smartContractFunctionDataListItemSkeleton';

export { SmartContractFunctionDataListItemSkeletonContent } from './smartContractFunctionDataListItemSkeletonContent';
Comment thread
shan8851 marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SmartContractFunctionDataListItem } from '../../smartContractFunctionDataListItem';

const meta: Meta<typeof SmartContractFunctionDataListItem.Skeleton> = {
title: 'Modules/Components/SmartContract/SmartContractFunctionDataListItem/SmartContractFunctionDataListItem.Skeleton',
component: SmartContractFunctionDataListItem.Skeleton,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mnH992k1U8hHBzTHFC2W3U/Aragon-App?node-id=13248-149568',
},
},
};

type Story = StoryObj<typeof SmartContractFunctionDataListItem.Skeleton>;

/**
* Default usage example of the SmartContractFunctionDataListItem.Skeleton component.
*/
export const Default: Story = {};

export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import {
SmartContractFunctionDataListItem,
type ISmartContractFunctionDataListItemSkeletonProps,
} from '../../smartContractFunctionDataListItem';

describe('<SmartContractFunctionDataListItem.Skeleton /> component', () => {
const createTestComponent = (props?: Partial<ISmartContractFunctionDataListItemSkeletonProps>) => {
const completeProps: ISmartContractFunctionDataListItemSkeletonProps = { ...props };

return <SmartContractFunctionDataListItem.Skeleton {...completeProps} />;
};

it('has correct accessibility attributes', () => {
render(createTestComponent());
const listItem = screen.getByLabelText('loading');
expect(listItem).toHaveAttribute('aria-busy', 'true');
expect(listItem).toHaveAttribute('tabIndex', '0');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import classNames from 'classnames';
import { DataList, type IDataListItemProps } from '../../../../../core';
import { SmartContractFunctionDataListItemSkeletonContent } from './smartContractFunctionDataListItemSkeletonContent';

export type ISmartContractFunctionDataListItemSkeletonProps = IDataListItemProps;

export const SmartContractFunctionDataListItemSkeleton: React.FC<ISmartContractFunctionDataListItemSkeletonProps> = (
props,
) => {
const { className, ...otherProps } = props;

return (
<DataList.Item
Comment thread
shan8851 marked this conversation as resolved.
tabIndex={0}
aria-busy="true"
aria-label="loading"
className={classNames(
'border-neutral-0 flex min-h-[67.5px] w-full flex-col gap-3 py-4 md:min-h-[88.5px]',
className,
)}
{...otherProps}
>
<SmartContractFunctionDataListItemSkeletonContent />
</DataList.Item>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { StateSkeletonBar } from '../../../../../core';

export interface ISmartContractFunctionDataListItemSkeletonContentProps {}

export const SmartContractFunctionDataListItemSkeletonContent: React.FC<
ISmartContractFunctionDataListItemSkeletonContentProps
> = () => {
return (
<>
<div className="flex w-1/2 md:w-1/3">
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="100%" />
</div>
<div className="flex w-3/4 gap-x-2 md:w-1/2">
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
<StateSkeletonBar responsiveSize={{ md: 'lg' }} width="50%" />
</div>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
SmartContractFunctionDataListItemStructure,
type ISmartContractFunctionDataListItemProps,
} from './smartContractFunctionDataListItemStructure';
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SmartContractFunctionDataListItem } from '../../smartContractFunctionDataListItem';
import { type SmartContractFunctionDataListItemStructure } from './smartContractFunctionDataListItemStructure';

const meta: Meta<typeof SmartContractFunctionDataListItem.Structure> = {
title: 'Modules/Components/SmartContract/SmartContractFunctionDataListItem/SmartContractFunctionDataListItem.Structure',
component: SmartContractFunctionDataListItem.Structure,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mnH992k1U8hHBzTHFC2W3U/Aragon-App?node-id=13248-149568',
},
},
};

type Story = StoryObj<typeof SmartContractFunctionDataListItemStructure>;

/**
* Usage example of the SmartContractFunctionDataListItem.Structure component with verified function and contract names.
*/
export const Verified: Story = {
args: {
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
contractName: 'Uniswap V2 Router',
functionName: 'addLiquidity',
},
};

/**
* Usage example of the SmartContractFunctionDataListItem.Structure component with unverified function and contract names.
*/
export const Unverified: Story = {
args: {
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
},
};

/**
* Usage example of the SmartContractFunctionDataListItem.Structure component with a function signature.
*/
export const WithFunctionSignature: Story = {
args: {
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
contractName: 'Uniswap V2 Router',
functionName: 'addLiquidity',
functionParameters: [
{ name: 'tokenA', type: 'address', value: 0 },
{ name: 'tokenB', type: 'address', value: 0 },
],
},
};

/**
* Usage example of the SmartContractFunctionDataListItem.Structure component with a remove button.
*/
export const WithRemoveButton: Story = {
args: {
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
contractName: 'Uniswap V2 Router',
functionName: 'addLiquidity',
onRemove: () => alert('Function removed'),
},
};

export default meta;
Loading