From cebf92db5732757b3b98f7c526c8473ec7660ea2 Mon Sep 17 00:00:00 2001 From: Sepehr Sanaei <46657145+sepehr2github@users.noreply.github.com> Date: Wed, 13 Mar 2024 20:59:13 +0330 Subject: [PATCH 1/3] feat: APP-2789 - Implement DaoDataListItem component (#121) Co-authored-by: cgero.eth --- CHANGELOG.md | 1 + .../daoDataListItemStructure.stories.tsx | 62 ++++++++++++++++ .../daoDataListItemStructure.test.tsx | 53 ++++++++++++++ .../daoDataListItemStructure.tsx | 70 +++++++++++++++++++ .../daoDataListItemStructure/index.ts | 7 ++ .../components/dao/daoDataListItem/index.ts | 1 + src/modules/components/dao/index.ts | 1 + 7 files changed, 195 insertions(+) create mode 100644 src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.stories.tsx create mode 100644 src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.test.tsx create mode 100644 src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.tsx create mode 100644 src/modules/components/dao/daoDataListItem/daoDataListItemStructure/index.ts create mode 100644 src/modules/components/dao/daoDataListItem/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cbe74924..ca84d2d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Implement `DaoDataListItem` module component - Implement `StatePingAnimation` core component ## [1.0.20] - 2024-03-13 diff --git a/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.stories.tsx b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.stories.tsx new file mode 100644 index 000000000..5dd7ec7a6 --- /dev/null +++ b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DataList } from '../../../../../core'; +import { DaoDataListItemStructure } from './daoDataListItemStructure'; + +const meta: Meta = { + title: 'Modules/Components/Dao/DaoDataListItem/DaoDataListItem.Structure', + component: DaoDataListItemStructure, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/P0GeJKqILL7UXvaqu5Jj7V/v1.1.0?type=design&node-id=3259-11363&mode=dev', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the DaoDataListItem component. + */ +export const Default: Story = { + args: { + name: 'Patito DAO', + logoSrc: 'https://cdn.discordapp.com/icons/672466989217873929/acffa3e9e09ac5962ff803a5f8649040.webp?size=240', + description: + 'Papito DAO is responsible for maximizing effective coordination and collaboration between different Patito teams and enabling them to perform at their best ability with the highest velocity they can achieve. Our main focus is on managing the day-to-day tasks of the Patito Guilds, such as enabling contractual relationships, legal operations, accounting, finance, and HR. We are also responsible for addressing any issues that may arise within the teams and deploying new tools, and infrastructure to ensure smooth operations.', + plugin: 'token-based', + network: 'Ethereum Mainnet', + ens: 'patito.dao.eth', + }, + render: (props) => ( + + + + + + ), +}; + +/** + * Usage of the DaoDataListItem without an image src. + */ +export const Fallback: Story = { + args: { + name: 'Patito DAO', + description: + 'Papito DAO is responsible for maximizing effective coordination and collaboration between different Patito teams and enabling them to perform at their best ability with the highest velocity they can achieve. Our main focus is on managing the day-to-day tasks of the Patito Guilds, such as enabling contractual relationships, legal operations, accounting, finance, and HR. We are also responsible for addressing any issues that may arise within the teams and deploying new tools, and infrastructure to ensure smooth operations.', + plugin: 'token-based', + network: 'Ethereum Mainnet', + ens: 'patito.dao.eth', + }, + render: (props) => ( + + + + + + ), +}; + +export default meta; diff --git a/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.test.tsx b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.test.tsx new file mode 100644 index 000000000..7d4091290 --- /dev/null +++ b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { DataList } from '../../../../../core'; +import { DaoDataListItemStructure, type IDaoDataListItemStructureProps } from './daoDataListItemStructure'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + return ( + + + + + + ); + }; + + it('renders ensName and the daoName (in uppercase) as the avatar fallback', () => { + const name = 'a'; + const ens = 'a.eth'; + render(createTestComponent({ name, ens })); + expect(screen.getByText(name.toUpperCase())).toBeInTheDocument(); + expect(screen.getByText(ens)).toBeInTheDocument(); + }); + + it('renders name and the address', () => { + const name = 'ab'; + const address = '0x123'; + render(createTestComponent({ name, address })); + expect(screen.getByText(name.toUpperCase())).toBeInTheDocument(); + expect(screen.getByText(address)).toBeInTheDocument(); + }); + + it('does not render the dao ENS name if it is not provided', () => { + const name = 'a'; + render(createTestComponent({ name })); + expect(screen.queryByText(/.eth/)).not.toBeInTheDocument(); + }); + + it('renders the description with an ellipsis if it is more than two lines', () => { + const description = + 'This is a very long description that should be more than two lines. It should end with an ellipsis.'; + render(createTestComponent({ description })); + const descriptionElement = screen.getByText(/This is a very long description/); + expect(descriptionElement).toHaveClass('line-clamp-2'); + }); + + it('renders the network and plugin information correctly', () => { + const network = 'ethereum'; + const plugin = 'token-based'; + render(createTestComponent({ network, plugin })); + expect(screen.getByText(network)).toBeInTheDocument(); + expect(screen.getByText(plugin)).toBeInTheDocument(); + }); +}); diff --git a/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.tsx b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.tsx new file mode 100644 index 000000000..5dfe202f2 --- /dev/null +++ b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/daoDataListItemStructure.tsx @@ -0,0 +1,70 @@ +import type React from 'react'; +import { DataList, Heading, Icon, IconType, type IDataListItemProps } from '../../../../../core'; +import { DaoAvatar } from '../../daoAvatar'; + +export interface IDaoDataListItemStructureProps extends IDataListItemProps { + /** + * The name of the DAO. + */ + name?: string; + /** + * The source of the logo for the DAO. + */ + logoSrc?: string; + /** + * The description of the DAO. + */ + description?: string; + /** + * The address of the DAO. + */ + address?: string; + /** + * The ENS (Ethereum Name Service) address of the DAO. + */ + ens?: string; + /** + * The plugin used by the DAO. + * @default token-based + */ + plugin?: string; + /** + * The network on which the DAO operates. + */ + network?: string; +} + +export const DaoDataListItemStructure: React.FC = (props) => { + const { name, logoSrc, description, network, plugin = 'token-based', address, ens, ...otherProps } = props; + + return ( + +
+
+
+ + {name} + + + {ens ?? address} + +
+ +
+

+ {description} +

+
+
+ {network} + +
+
+ {plugin} + +
+
+
+
+ ); +}; diff --git a/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/index.ts b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/index.ts new file mode 100644 index 000000000..9de8fc4b7 --- /dev/null +++ b/src/modules/components/dao/daoDataListItem/daoDataListItemStructure/index.ts @@ -0,0 +1,7 @@ +import { DaoDataListItemStructure } from './daoDataListItemStructure'; + +export const DaoDataListItem = { + Structure: DaoDataListItemStructure, +}; + +export type { IDaoDataListItemStructureProps } from './daoDataListItemStructure'; diff --git a/src/modules/components/dao/daoDataListItem/index.ts b/src/modules/components/dao/daoDataListItem/index.ts new file mode 100644 index 000000000..b6fd5ed01 --- /dev/null +++ b/src/modules/components/dao/daoDataListItem/index.ts @@ -0,0 +1 @@ +export * from './daoDataListItemStructure'; diff --git a/src/modules/components/dao/index.ts b/src/modules/components/dao/index.ts index 88b95efc6..ed744f04f 100644 --- a/src/modules/components/dao/index.ts +++ b/src/modules/components/dao/index.ts @@ -1 +1,2 @@ export * from './daoAvatar'; +export * from './daoDataListItem'; From f91a4832fbd5aa05f2d81a7f57444d5c286430b6 Mon Sep 17 00:00:00 2001 From: Fabrice Francois Date: Thu, 14 Mar 2024 07:12:06 -0400 Subject: [PATCH 2/3] feat: APP-2941 - Implement ProposalDataListItem module component (#122) --- CHANGELOG.md | 6 +- src/core/components/tag/tag.tsx | 2 +- src/modules/components/index.ts | 1 + src/modules/components/proposal/index.ts | 1 + .../approvalThresholdResult.test.tsx | 41 ++++ .../approvalThresholdResult.tsx | 30 +++ .../approvalThresholdResult/index.ts | 1 + .../proposal/proposalDataListItem/index.ts | 13 ++ .../majorityVotingResult/index.ts | 1 + .../majorityVotingResult.test.tsx | 30 +++ .../majorityVotingResult.tsx | 26 +++ .../proposalDataListItemStatus/index.ts | 1 + .../proposalDataListItemStatus.test.tsx | 62 ++++++ .../proposalDataListItemStatus.tsx | 69 ++++++ .../proposalDataListItemStructure/index.ts | 2 + .../proposalDataListItemStructure.api.ts | 102 +++++++++ .../proposalDataListItemStructure.stories.tsx | 80 +++++++ .../proposalDataListItemStructure.test.tsx | 197 ++++++++++++++++++ .../proposalDataListItemStructure.tsx | 67 ++++++ src/modules/types/compositeAddress.ts | 4 + src/modules/types/index.ts | 1 + .../utils/addressUtils/addressUtils.ts | 17 ++ src/modules/utils/addressUtils/index.ts | 1 + src/modules/utils/ensUtils/ensUtils.ts | 13 ++ src/modules/utils/ensUtils/index.ts | 1 + 25 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 src/modules/components/proposal/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/majorityVotingResult/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/index.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.stories.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx create mode 100644 src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx create mode 100644 src/modules/types/compositeAddress.ts create mode 100644 src/modules/utils/addressUtils/addressUtils.ts create mode 100644 src/modules/utils/addressUtils/index.ts create mode 100644 src/modules/utils/ensUtils/ensUtils.ts create mode 100644 src/modules/utils/ensUtils/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca84d2d31..9805f4568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Implement `DaoDataListItem` module component +- Implement `DaoDataListItem` and `ProposalDataListItem.Structure` module components - Implement `StatePingAnimation` core component +### Changed + +- Update `Tag` component primary variant styling + ## [1.0.20] - 2024-03-13 ### Fixed diff --git a/src/core/components/tag/tag.tsx b/src/core/components/tag/tag.tsx index a0a3012fd..156c8fe41 100644 --- a/src/core/components/tag/tag.tsx +++ b/src/core/components/tag/tag.tsx @@ -24,7 +24,7 @@ const variantToClassName: Record = { warning: 'bg-warning-200 text-warning-800', critical: 'bg-critical-200 text-critical-800', success: 'bg-success-200 text-success-800', - primary: 'bg-primary-100 text-primary-800', + primary: 'bg-primary-50 text-primary-400', }; export const Tag: React.FC = (props) => { diff --git a/src/modules/components/index.ts b/src/modules/components/index.ts index 1e0744bcf..0da2bb7ef 100644 --- a/src/modules/components/index.ts +++ b/src/modules/components/index.ts @@ -1,3 +1,4 @@ export * from './dao'; export * from './member'; export * from './odsModulesProvider'; +export * from './proposal'; diff --git a/src/modules/components/proposal/index.ts b/src/modules/components/proposal/index.ts new file mode 100644 index 000000000..9148c34f7 --- /dev/null +++ b/src/modules/components/proposal/index.ts @@ -0,0 +1 @@ +export * from './proposalDataListItem'; diff --git a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx new file mode 100644 index 000000000..df66ddad8 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { NumberFormat, formatterUtils } from '../../../../../core'; +import { ApprovalThresholdResult, type IApprovalThresholdResultProps } from './approvalThresholdResult'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IApprovalThresholdResultProps = { + approvalAmount: 1, + approvalThreshold: 2, + ...props, + }; + + return ; + }; + + it('renders the formatted approval threshold and approval amount and the corresponding progressbar', () => { + const mockProps: IApprovalThresholdResultProps = { + approvalAmount: 15000000, + approvalThreshold: 20000000, + }; + + render(createTestComponent(mockProps)); + + const expectedApproval = formatterUtils.formatNumber(mockProps.approvalAmount, { + format: NumberFormat.GENERIC_SHORT, + }) as string; + + const expectedThreshold = formatterUtils.formatNumber(mockProps.approvalThreshold, { + format: NumberFormat.GENERIC_SHORT, + }) as string; + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + + const expectedPercentage = (mockProps.approvalAmount / mockProps.approvalThreshold) * 100; + expect(progressbar.getAttribute('data-value')).toEqual(expectedPercentage.toString()); + + expect(screen.getByText(expectedApproval)).toBeInTheDocument(); + expect(screen.getByText(expectedThreshold)).toBeInTheDocument(); + }); +}); diff --git a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx new file mode 100644 index 000000000..0f3d55e12 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx @@ -0,0 +1,30 @@ +import { NumberFormat, Progress, formatterUtils } from '../../../../../core'; +import { type IApprovalThresholdResult } from '../proposalDataListItemStructure'; + +export interface IApprovalThresholdResultProps extends IApprovalThresholdResult {} + +/** + * `ApprovalThresholdResult` component + */ +export const ApprovalThresholdResult: React.FC = (props) => { + const { approvalAmount, approvalThreshold } = props; + const percentage = approvalThreshold !== 0 ? (approvalAmount / approvalThreshold) * 100 : 100; + + return ( + // TODO: apply internationalization to Approved By, of, and Members [APP-2627] +
+
+ Approved By +
+ +
+ + {formatterUtils.formatNumber(approvalAmount, { format: NumberFormat.GENERIC_SHORT })} + + of + {formatterUtils.formatNumber(approvalThreshold, { format: NumberFormat.GENERIC_SHORT })} + Members +
+
+ ); +}; diff --git a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/index.ts b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/index.ts new file mode 100644 index 000000000..8f6eb5c24 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/index.ts @@ -0,0 +1 @@ +export { ApprovalThresholdResult, type IApprovalThresholdResultProps } from './approvalThresholdResult'; diff --git a/src/modules/components/proposal/proposalDataListItem/index.ts b/src/modules/components/proposal/proposalDataListItem/index.ts new file mode 100644 index 000000000..f1d6e97a6 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/index.ts @@ -0,0 +1,13 @@ +import { ProposalDataListItemStructure as Structure } from './proposalDataListItemStructure'; + +export const ProposalDataListItem = { + Structure, +}; + +export type { + IApprovalThresholdResult, + IMajorityVotingResult, + IProposalDataListItemStructureProps, + ProposalStatus, + ProposalType, +} from './proposalDataListItemStructure'; diff --git a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/index.ts b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/index.ts new file mode 100644 index 000000000..418f0bf66 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/index.ts @@ -0,0 +1 @@ +export { MajorityVotingResult, type IMajorityVotingResultProps } from './majorityVotingResult'; diff --git a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx new file mode 100644 index 000000000..8da12f06a --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { MajorityVotingResult, type IMajorityVotingResultProps } from './majorityVotingResult'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IMajorityVotingResultProps = { + option: 'yes', + voteAmount: '100 wAnt', + votePercentage: 10, + ...props, + }; + + return ; + }; + + it('renders the given winning option, vote amount, vote percentage and a corresponding progressbar', () => { + const mockProps: IMajorityVotingResultProps = { + option: 'yes', + voteAmount: '100k wAnt', + votePercentage: 15, + }; + + render(createTestComponent(mockProps)); + + expect(screen.getByText(mockProps.option)).toBeInTheDocument(); + expect(screen.getByText(mockProps.voteAmount)).toBeInTheDocument(); + expect(screen.getByText(mockProps.votePercentage, { exact: false })).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); +}); diff --git a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx new file mode 100644 index 000000000..2757034ad --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx @@ -0,0 +1,26 @@ +import { Progress } from '../../../../../core'; +import { type IMajorityVotingResult } from '../proposalDataListItemStructure'; + +export interface IMajorityVotingResultProps extends IMajorityVotingResult {} + +/** + * `MajorityVotingResult` component + */ +export const MajorityVotingResult: React.FC = (props) => { + const { option, voteAmount, votePercentage } = props; + + return ( + // TODO: apply internationalization to Winning Option [APP-2627] +
+
+ Winning Option + {`${votePercentage}%`} +
+ +
+ {option} + {voteAmount} +
+
+ ); +}; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/index.ts b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/index.ts new file mode 100644 index 000000000..2d6bd9888 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/index.ts @@ -0,0 +1 @@ +export { ProposalDataListItemStatus, type IProposalDataListItemStatusProps } from './proposalDataListItemStatus'; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx new file mode 100644 index 000000000..0f65473e3 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; +import { IconType } from '../../../../../core'; +import { type ProposalStatus } from '../proposalDataListItemStructure'; +import { ProposalDataListItemStatus, type IProposalDataListItemStatusProps } from './proposalDataListItemStatus'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IProposalDataListItemStatusProps = { date: 'test date', status: 'accepted', ...props }; + + return ; + }; + + const ongoingStatuses = ['active', 'challenged', 'vetoed']; + + it('displays the date, calendar icon and status', () => { + const date = 'test date'; + const status = 'accepted'; + + render(createTestComponent({ date, status })); + + expect(screen.getByText(date)).toBeInTheDocument(); + expect(screen.getByText(status)).toBeInTheDocument(); + expect(screen.getByTestId(IconType.CALENDAR)).toBeInTheDocument(); + }); + + it("only displays the date for proposals with a status that is not 'draft'", () => { + const date = 'test date'; + const status = 'draft'; + + render(createTestComponent({ date, status })); + + expect(screen.getByText(status)).toBeInTheDocument(); + expect(screen.queryByText(date)).not.toBeInTheDocument(); + expect(screen.queryByTestId(IconType.CALENDAR)).not.toBeInTheDocument(); + }); + + ongoingStatuses.forEach((status) => { + it(`displays the date and a pinging indicator when the status is '${status}' and voted is false`, () => { + const date = 'test date'; + render(createTestComponent({ date, status: status as ProposalStatus, voted: false })); + + expect(screen.getByText(date)).toBeInTheDocument(); + expect(screen.getByTestId('statePingAnimation')).toBeInTheDocument(); + }); + }); + + ongoingStatuses.forEach((status) => { + it(`displays 'You've voted' with an icon checkmark when the status is '${status}' and voted is true`, () => { + render(createTestComponent({ status: status as ProposalStatus, voted: true })); + + expect(screen.getByText(/You've voted/i)).toBeInTheDocument(); + expect(screen.getByTestId(IconType.CHECKMARK)).toBeInTheDocument(); + }); + }); + + it("does not display 'You've voted' when the status is not an ongoing one and the voted is true", () => { + render(createTestComponent({ status: 'executed', voted: true })); + + expect(screen.queryByText(/You've voted/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId(IconType.CHECKMARK)).not.toBeInTheDocument(); + }); +}); diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx new file mode 100644 index 000000000..3a6681e11 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { + AvatarIcon, + IconType, + StatePingAnimation, + Tag, + type StatePingAnimationVariant, + type TagVariant, +} from '../../../../../core'; +import { type IProposalDataListItemStructureProps, type ProposalStatus } from '../proposalDataListItemStructure'; + +export interface IProposalDataListItemStatusProps + extends Pick {} + +const statusToTagVariant: Record = { + accepted: 'success', + active: 'info', + challenged: 'warning', + draft: 'neutral', + executed: 'success', + expired: 'critical', + failed: 'critical', + partiallyExecuted: 'warning', + pending: 'neutral', + queued: 'success', + rejected: 'critical', + vetoed: 'warning', +}; + +type OngoingProposalStatus = 'active' | 'challenged' | 'vetoed'; +const ongoingStatusToPingVariant: Record = { + active: 'info', + challenged: 'warning', + vetoed: 'warning', +}; + +/** + * `ProposalDataListItemStatus` local component + */ +export const ProposalDataListItemStatus: React.FC = (props) => { + const { date, status, voted } = props; + + const ongoing = status === 'active' || status === 'challenged' || status === 'vetoed'; + const ongoingAndVoted = ongoing && voted; + const showStatusMetadata = status !== 'draft'; + + return ( +
+ + {showStatusMetadata && ( +
+ + {/* TODO: apply internationalization [APP-2627]; apply relative date formatter [APP-2944] */} + {ongoingAndVoted ? "You've voted" : date} + + {ongoingAndVoted && } + {ongoing && !voted && } + {!ongoing && !voted && } +
+ )} +
+ ); +}; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/index.ts b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/index.ts new file mode 100644 index 000000000..5675427c2 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/index.ts @@ -0,0 +1,2 @@ +export { ProposalDataListItemStructure } from './proposalDataListItemStructure'; +export * from './proposalDataListItemStructure.api'; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts new file mode 100644 index 000000000..d255d011a --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts @@ -0,0 +1,102 @@ +import { type IDataListItemProps } from '../../../../../core'; +import { type ICompositeAddress, type IWeb3ComponentProps } from '../../../../types'; + +export type ProposalType = 'majorityVoting' | 'approvalThreshold'; +export type ProposalStatus = + | 'accepted' + | 'active' + | 'challenged' + | 'draft' + | 'executed' + | 'expired' + | 'failed' + | 'partiallyExecuted' + | 'pending' + | 'queued' + | 'rejected' + | 'vetoed'; + +export interface IProposalDataListItemStructureBaseProps + extends IDataListItemProps, + IWeb3ComponentProps { + /** + * Indicates date relative to the proposal status + */ + date: string; + /** + * Indicates whether the proposal is a protocol update + */ + protocolUpdate?: boolean; + /** + * Publisher address and/or ENS name + */ + publisher: ICompositeAddress; + /** + * Link to the publisher's profile + */ + publisherProfileLink: string; + /** + * Result of the proposal shown only when it is active, challenged or vetoed. + */ + result: TType extends 'majorityVoting' ? IMajorityVotingResult : IApprovalThresholdResult; + /** + * Proposal status + */ + status: ProposalStatus; + /** + * Proposal description + */ + summary: string; + /** + * Proposal title + */ + title: string; + /** + * Type of the ProposalDataListItem + */ + type: TType; + /** + * Indicates whether the connected wallet has voted + */ + voted?: boolean; +} + +export interface IApprovalThresholdResult { + /** + * Number of approvals for the proposal + */ + approvalAmount: number; + /** + * Proposal approval threshold + */ + approvalThreshold: number; +} + +export interface IMajorityVotingResult { + /** + * Winning option + */ + option: string; + /** + * Number of votes for the winning option + */ + voteAmount: string; + /** + * Percentage of votes for the winning option + */ + votePercentage: number; +} + +export interface IProposalDataListItemStructureMajorityVotingProps + extends IProposalDataListItemStructureBaseProps<'majorityVoting'> { + result: IMajorityVotingResult; +} + +export interface IProposalDataListItemStructureApprovalThresholdProps + extends IProposalDataListItemStructureBaseProps<'approvalThreshold'> { + result: IApprovalThresholdResult; +} + +export type IProposalDataListItemStructureProps = + | IProposalDataListItemStructureMajorityVotingProps + | IProposalDataListItemStructureApprovalThresholdProps; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.stories.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.stories.tsx new file mode 100644 index 000000000..da83cb3c6 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DataList } from '../../../../../core'; +import { ProposalDataListItem } from '../../index'; +import { type IProposalDataListItemStructureProps } from './proposalDataListItemStructure.api'; + +const meta: Meta = { + title: 'Modules/Components/Proposal/ProposalDataListItem/ProposalDataListItem.Structure', + component: ProposalDataListItem.Structure, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/P0GeJKqILL7UXvaqu5Jj7V/v1.1.0?type=design&node-id=13724-27671&mode=dev', + }, + }, +}; + +type Story = StoryObj; + +const baseArgs: Omit = { + date: '5 days left', + protocolUpdate: false, + publisher: { address: '0xd5fb864ACfD6BB2f72939f122e89fF7F475924f5' }, + publisherProfileLink: + 'https://app.aragon.org/#/daos/base/0xd2705c56aa4edb98271cb8cea2b0df3288ad4585/members/0xd5fb864ACfD6BB2f72939f122e89fF7F475924f5', + status: 'draft', + title: 'This is a very serious proposal to send funds to a wallet address', + summary: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris vel eleifend neque, in mattis eros. + Integer ornare dapibus sem sit amet viverra. Sed blandit ipsum quis erat elementum lacinia. + Sed eu nisi urna. Ut quis urna ac mi vulputate suscipit. Aenean lacinia, libero sit amet laoreet vulputate, + magna magna sollicitudin tellus, ut volutpat nulla arcu nec neque. Phasellus vulputate tincidunt orci vitae eleifend.`, + type: 'majorityVoting', + voted: false, +}; + +/** + * Example of the `ProposalDataListItem.Structure` module component for a MajorityVoting type proposal. + */ +export const MajorityVoting: Story = { + args: { + ...baseArgs, + type: 'majorityVoting', + result: { + option: 'yes', + voteAmount: '100k wAnt', + votePercentage: 15, + }, + }, + render: (props) => ( + + + + + + ), +}; + +/** + * Example of the `ProposalDataListItem.Structure` module component for an ApprovalThreshold type proposal. + */ +export const ApprovalThreshold: Story = { + args: { + ...baseArgs, + publisher: { name: 'sio.eth', address: baseArgs.publisher.address }, + type: 'approvalThreshold', + result: { + approvalAmount: 4, + approvalThreshold: 6, + }, + }, + render: (props) => ( + + + + + + ), +}; + +export default meta; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx new file mode 100644 index 000000000..42f93c721 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx @@ -0,0 +1,197 @@ +import { render, screen } from '@testing-library/react'; +import * as wagmi from 'wagmi'; +import { DataList } from '../../../../../core'; +import { addressUtils } from '../../../../utils/addressUtils'; +import { ProposalDataListItemStructure } from './proposalDataListItemStructure'; +import { + type IApprovalThresholdResult, + type IMajorityVotingResult, + type IProposalDataListItemStructureProps, + type ProposalStatus, +} from './proposalDataListItemStructure.api'; + +jest.mock('wagmi', () => ({ useAccount: jest.fn() })); +jest.mock('viem/utils', () => ({ isAddress: jest.fn().mockReturnValue(true) })); + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const { result, ...baseInputProps } = props ?? {}; + + const baseProps: Omit = { + date: new Date().toISOString(), + protocolUpdate: false, + publisher: { address: '0x123' }, + status: 'active', + summary: 'Example Summary', + title: 'Example Title', + voted: false, + publisherProfileLink: '#', + type: 'approvalThreshold', + ...baseInputProps, + }; + + const approvalThresholdProps: IApprovalThresholdResult = { + approvalAmount: 1, + approvalThreshold: 2, + ...result, + }; + + const majorityVotingProps: IMajorityVotingResult = { + option: 'yes', + voteAmount: '100 wAnt', + votePercentage: 10, + ...result, + }; + + return ( + + {baseProps.type === 'approvalThreshold' && ( + + )} + + {baseProps.type === 'majorityVoting' && ( + + )} + + ); + }; + + const useAccountMock = jest.spyOn(wagmi, 'useAccount'); + + beforeEach(() => { + useAccountMock.mockImplementation(jest.fn().mockReturnValue({ address: '0x456', isConnected: true })); + }); + + afterEach(() => { + useAccountMock.mockReset(); + }); + + const ongoingStatuses: ProposalStatus[] = ['active', 'challenged', 'vetoed']; + + it("renders 'You' as the publisher if the connected address is the publisher address", () => { + const publisher = { address: '0x123' }; + + useAccountMock.mockImplementation(jest.fn().mockReturnValue({ address: publisher.address, isConnected: true })); + + render(createTestComponent({ publisher })); + + expect(screen.getByRole('link', { name: 'You' })).toBeInTheDocument(); + }); + + describe("'approvalThreshold type'", () => { + it('renders without crashing', () => { + const testProps: IProposalDataListItemStructureProps = { + date: new Date().toISOString(), + publisher: { address: '0x123' }, + publisherProfileLink: '#', + status: 'active', + summary: 'Example Summary', + title: 'Example Title', + type: 'approvalThreshold', + result: { + approvalAmount: 1, + approvalThreshold: 2, + }, + }; + + render(createTestComponent(testProps)); + + expect(screen.getByText(testProps.title)).toBeInTheDocument(); + expect(screen.getByText(testProps.summary)).toBeInTheDocument(); + expect(screen.getByText(testProps.status)).toBeInTheDocument(); + expect(screen.getByText(testProps.date)).toBeInTheDocument(); + expect( + screen.getByText(addressUtils.shortenAddress(testProps.publisher.address ?? '')), + ).toBeInTheDocument(); + }); + + ongoingStatuses.forEach((status) => { + it(`renders the results when status is '${status}'`, () => { + const testProps = { + approvalAmount: 10, + approvalThreshold: 11, + }; + + render(createTestComponent({ result: testProps, type: 'approvalThreshold', status })); + + expect(screen.getByText(testProps.approvalAmount)).toBeInTheDocument(); + expect(screen.getByText(testProps.approvalThreshold)).toBeInTheDocument(); + }); + }); + + it('does not render the results when status is not of an ongoing type', () => { + const testProps = { + approvalAmount: 10, + approvalThreshold: 11, + }; + + render(createTestComponent({ result: testProps, type: 'approvalThreshold', status: 'expired' })); + + expect(screen.queryByText(testProps.approvalAmount)).not.toBeInTheDocument(); + expect(screen.queryByText(testProps.approvalThreshold)).not.toBeInTheDocument(); + }); + }); + + describe("'majorityVoting' type", () => { + it('renders without crashing', () => { + const testProps: IProposalDataListItemStructureProps = { + date: new Date().toISOString(), + publisher: { address: '0x123' }, + publisherProfileLink: '#', + status: 'active', + summary: 'Example Summary', + title: 'Example Title', + type: 'majorityVoting', + result: { + option: 'Yes', + voteAmount: '100 wAnt', + votePercentage: 10, + }, + }; + + render(createTestComponent(testProps)); + + expect(screen.getByText(testProps.title)).toBeInTheDocument(); + expect(screen.getByText(testProps.summary)).toBeInTheDocument(); + expect(screen.getByText(testProps.status)).toBeInTheDocument(); + expect(screen.getByText(testProps.date)).toBeInTheDocument(); + expect( + screen.getByText(addressUtils.shortenAddress(testProps.publisher.address ?? '')), + ).toBeInTheDocument(); + }); + + ongoingStatuses.forEach((status) => { + it(`renders the results when status is '${status}'`, () => { + const testProps = { + option: 'Yes', + voteAmount: '100 wAnt', + votePercentage: 10, + }; + + render(createTestComponent({ result: testProps, type: 'majorityVoting', status })); + + expect(screen.getByText(testProps.option)).toBeInTheDocument(); + expect(screen.getByText(testProps.voteAmount)).toBeInTheDocument(); + expect(screen.getByText(`${testProps.votePercentage}%`)).toBeInTheDocument(); + }); + }); + + it('does not render the results when status is not of an ongoing type', () => { + const testProps = { + option: 'Yes', + voteAmount: '100 wAnt', + votePercentage: 10, + }; + + render(createTestComponent({ result: testProps, type: 'majorityVoting', status: 'pending' })); + + expect(screen.queryByText(testProps.option)).not.toBeInTheDocument(); + expect(screen.queryByText(testProps.voteAmount)).not.toBeInTheDocument(); + expect(screen.queryByText(`${testProps.votePercentage}%`)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx new file mode 100644 index 000000000..ec4005886 --- /dev/null +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import { useAccount } from 'wagmi'; +import { DataList, Link, Tag } from '../../../../../core'; +import { addressUtils } from '../../../../utils/addressUtils'; +import { ApprovalThresholdResult } from '../approvalThresholdResult'; +import { MajorityVotingResult } from '../majorityVotingResult'; +import { ProposalDataListItemStatus } from '../proposalDataListItemStatus'; +import { type IProposalDataListItemStructureProps } from './proposalDataListItemStructure.api'; + +/** + * `ProposalDataListItemStructure` module component + */ +export const ProposalDataListItemStructure: React.FC = (props) => { + const { + wagmiConfig: config, + chainId, + className, + type, + result, + date, + protocolUpdate, + publisher, + publisherProfileLink, + status, + summary, + title, + voted, + ...otherProps + } = props; + + const { address: connectedAddress, isConnected } = useAccount({ config }); + + const ongoing = status === 'active' || status === 'challenged' || status === 'vetoed'; + + const publisherIsConnected = isConnected && connectedAddress?.toLowerCase() === publisher.address?.toLowerCase(); + const publisherLabel = publisherIsConnected + ? 'You' + : publisher.name ?? addressUtils.shortenAddress(publisher.address as string); + + return ( + + +
+

{title}

+

{summary}

+
+ + {ongoing && type === 'approvalThreshold' && } + + {ongoing && type === 'majorityVoting' && } + +
+
+ {/* TODO: apply internationalization [APP-2627] */} + By + {/* using solution from https://kizu.dev/nested-links/ to nest anchor tags */} + + {publisherLabel} + +
+ + {/* TODO: apply internationalization [APP-2627] */} + {protocolUpdate && } +
+
+ ); +}; diff --git a/src/modules/types/compositeAddress.ts b/src/modules/types/compositeAddress.ts new file mode 100644 index 000000000..38e50f363 --- /dev/null +++ b/src/modules/types/compositeAddress.ts @@ -0,0 +1,4 @@ +export interface ICompositeAddress { + address?: string; + name?: string; +} diff --git a/src/modules/types/index.ts b/src/modules/types/index.ts index 14431bd94..e67ba9055 100644 --- a/src/modules/types/index.ts +++ b/src/modules/types/index.ts @@ -1 +1,2 @@ +export * from './compositeAddress'; export * from './web3ComponentConfig'; diff --git a/src/modules/utils/addressUtils/addressUtils.ts b/src/modules/utils/addressUtils/addressUtils.ts new file mode 100644 index 000000000..930552ee7 --- /dev/null +++ b/src/modules/utils/addressUtils/addressUtils.ts @@ -0,0 +1,17 @@ +import { isAddress } from 'viem/utils'; + +class AddressUtils { + isAddress(address: string): boolean { + return isAddress(address, { strict: false }); + } + + shortenAddress(address: string): string { + if (this.isAddress(address)) { + return `${address.substring(0, 5)}…${address.substring(address.length - 4, address.length)}`; + } + + return address; + } +} + +export const addressUtils = new AddressUtils(); diff --git a/src/modules/utils/addressUtils/index.ts b/src/modules/utils/addressUtils/index.ts new file mode 100644 index 000000000..d1960c580 --- /dev/null +++ b/src/modules/utils/addressUtils/index.ts @@ -0,0 +1 @@ +export { addressUtils } from './addressUtils'; diff --git a/src/modules/utils/ensUtils/ensUtils.ts b/src/modules/utils/ensUtils/ensUtils.ts new file mode 100644 index 000000000..95185ad90 --- /dev/null +++ b/src/modules/utils/ensUtils/ensUtils.ts @@ -0,0 +1,13 @@ +class EnsUtils { + // The pattern for a valid ENS domain: + // - starts with an alphanumeric character (case-insensitive) + // - followed by zero or more alphanumeric characters, hyphens, or underscores (case-insensitive) + // - ends with '.eth' + private ensPattern = /^(?:[a-z0-9]+(?:[-_][a-z0-9]+)*\.)*[a-z0-9]+(?:[-_][a-z0-9]+)*\.eth$/; + + isEnsName(ensName: string): boolean { + return this.ensPattern.test(ensName); + } +} + +export const ensUtils = new EnsUtils(); diff --git a/src/modules/utils/ensUtils/index.ts b/src/modules/utils/ensUtils/index.ts new file mode 100644 index 000000000..5726e4952 --- /dev/null +++ b/src/modules/utils/ensUtils/index.ts @@ -0,0 +1 @@ +export { ensUtils } from './ensUtils'; From b9db5ee174fe79993a93d7f993b64e54126f3578 Mon Sep 17 00:00:00 2001 From: Kevin Davis <65736142+thekidnamedkd@users.noreply.github.com> Date: Thu, 14 Mar 2024 12:16:29 +0100 Subject: [PATCH 3/3] feat: APP-2788 - Implement MemberDataListItem component (#119) --- CHANGELOG.md | 2 +- .../components/dao/daoAvatar/daoAvatar.tsx | 7 +- src/modules/components/member/index.ts | 1 + .../member/memberDataListItem/index.ts | 1 + .../memberDataListItemStructure/index.ts | 7 ++ .../memberDataListItemStructure.stories.tsx | 55 +++++++++ .../memberDataListItemStructure.test.tsx | 105 ++++++++++++++++++ .../memberDataListItemStructure.tsx | 82 ++++++++++++++ src/modules/utils/truncateEthereumAddress.ts | 9 ++ 9 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 src/modules/components/member/memberDataListItem/index.ts create mode 100644 src/modules/components/member/memberDataListItem/memberDataListItemStructure/index.ts create mode 100644 src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.stories.tsx create mode 100644 src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx create mode 100644 src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx create mode 100644 src/modules/utils/truncateEthereumAddress.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9805f4568..d355f2aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Implement `DaoDataListItem` and `ProposalDataListItem.Structure` module components +- Implement `DaoDataListItem`, `ProposalDataListItem.Structure`, and `MemberDataListItem.Structure` module components - Implement `StatePingAnimation` core component ### Changed diff --git a/src/modules/components/dao/daoAvatar/daoAvatar.tsx b/src/modules/components/dao/daoAvatar/daoAvatar.tsx index e021f38d3..4d772f65a 100644 --- a/src/modules/components/dao/daoAvatar/daoAvatar.tsx +++ b/src/modules/components/dao/daoAvatar/daoAvatar.tsx @@ -1,12 +1,17 @@ import classNames from 'classnames'; import type React from 'react'; -import { Avatar, type IAvatarProps } from '../../../../core'; +import { Avatar, type AvatarSize, type IAvatarProps } from '../../../../core'; export interface IDaoAvatarProps extends Omit { /** * Name of the DAO */ name?: string; + /** + * The size of the avatar. + * @default lg + */ + size?: AvatarSize; } export const DaoAvatar: React.FC = (props) => { diff --git a/src/modules/components/member/index.ts b/src/modules/components/member/index.ts index d167d3a2c..c637a5ef8 100644 --- a/src/modules/components/member/index.ts +++ b/src/modules/components/member/index.ts @@ -1 +1,2 @@ export * from './memberAvatar'; +export * from './memberDataListItem'; diff --git a/src/modules/components/member/memberDataListItem/index.ts b/src/modules/components/member/memberDataListItem/index.ts new file mode 100644 index 000000000..3ebb05790 --- /dev/null +++ b/src/modules/components/member/memberDataListItem/index.ts @@ -0,0 +1 @@ +export * from './memberDataListItemStructure'; diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/index.ts b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/index.ts new file mode 100644 index 000000000..8bdc5bc5c --- /dev/null +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/index.ts @@ -0,0 +1,7 @@ +import { MemberDataListItemStructure } from './memberDataListItemStructure'; + +export const MemberDataListItem = { + Structure: MemberDataListItemStructure, +}; + +export type { IMemberDataListItemProps } from './memberDataListItemStructure'; diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.stories.tsx b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.stories.tsx new file mode 100644 index 000000000..2288abc6c --- /dev/null +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DataList } from '../../../../../core'; +import { MemberDataListItemStructure } from './memberDataListItemStructure'; + +const meta: Meta = { + title: 'Modules/Components/Member/MemberDataListItem.Structure', + component: MemberDataListItemStructure, + 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', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the MemberDataList module component. + */ +export const Default: Story = { + args: { + address: '0x1234567890123456789012345678901234567890', + }, + render: (args) => ( + + + + {' '} + + ), +}; + +/** + * Example of the MemberDataList module component with fully loaded props and token voting. + */ +export const Loaded: Story = { + args: { + isDelegate: true, + ensName: 'vitalik.eth', + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + delegationCount: 9, + votingPower: 13370, + }, + render: (args) => ( + + + + {' '} + + ), +}; + +export default meta; diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx new file mode 100644 index 000000000..aad485c30 --- /dev/null +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx @@ -0,0 +1,105 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { getAddress, isAddress } from 'viem'; +import { useAccount } from 'wagmi'; +import { DataList } from '../../../../../core'; +import { MemberDataListItemStructure, type IMemberDataListItemProps } from './memberDataListItemStructure'; + +jest.mock('viem', () => ({ + isAddress: jest.fn(), + getAddress: jest.fn(), +})); + +jest.mock('viem/ens', () => ({ + normalize: jest.fn(), +})); + +jest.mock('wagmi', () => ({ + useAccount: jest.fn(), +})); + +jest.mock('../../memberAvatar', () => ({ MemberAvatar: () =>
})); + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IMemberDataListItemProps = { + address: '0x1234567890123456789012345678901234567890', + ...props, + }; + + return ( + + + + + + ); + }; + + beforeEach(() => { + (isAddress as unknown as jest.Mock).mockImplementation(() => true); + (getAddress as jest.Mock).mockImplementation((address: string) => address); + + (useAccount as jest.Mock).mockReturnValue({ + address: '0x1234567890123456789012345678901234567890', + isConnected: true, + }); + }); + + it('renders the avatar', async () => { + render(createTestComponent()); + const avatar = screen.getByTestId('member-avatar-mock'); + + expect(avatar).toBeInTheDocument(); + }); + + it('conditionally renders the "Your Delegate" tag', async () => { + const address = '0x0987654321098765432109876543210987654321'; + render(createTestComponent({ isDelegate: true, address })); + + expect(screen.getByText('Your Delegate')).toBeInTheDocument(); + }); + + it('renders the ENS user handle instead of address if provided', async () => { + const ensName = 'testUserHandle'; + render(createTestComponent({ ensName })); + + expect(screen.getByRole('heading', { name: ensName })).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: '0x1234567890123456789012345678901234567890' }), + ).not.toBeInTheDocument(); + }); + + it('conditionally renders the delegation count and formats it', async () => { + const { rerender } = render(createTestComponent({ delegationCount: 340 })); + const delegationText = await screen.findByText(/340/); + // eslint-disable-next-line testing-library/no-node-access + const parentElement = delegationText.closest('h2'); + + expect(parentElement).toHaveTextContent('340 Delegation'); + + rerender(createTestComponent({ delegationCount: 2959 })); + const delegationsText = await screen.findByText(/2.96K/); + // eslint-disable-next-line testing-library/no-node-access + const parentElementForTwo = delegationsText.closest('h2'); + + expect(parentElementForTwo).toHaveTextContent('2.96K Delegations'); + }); + + it('renders the voting power, correctly formatting large numbers', async () => { + const votingPower = 420689; + render(createTestComponent({ votingPower })); + const formattedNumberElement = await screen.findByText(/420\.69K/); + // eslint-disable-next-line testing-library/no-node-access + const parentElement = formattedNumberElement.closest('h2'); + + expect(parentElement).toHaveTextContent('420.69K Voting Power'); + }); + + it('renders the "You" tag when the user is the current account', async () => { + const address = '0x1234567890123456789012345678901234567890'; + render(createTestComponent({ address })); + + expect(screen.getByText('You')).toBeInTheDocument(); + }); +}); diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx new file mode 100644 index 000000000..0d346e611 --- /dev/null +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx @@ -0,0 +1,82 @@ +import { getAddress } from 'viem'; +import { useAccount } from 'wagmi'; +import { DataList, Heading, NumberFormat, Tag, formatterUtils, type IDataListItemProps } from '../../../../../core'; +import { truncateEthAddress } from '../../../../utils/truncateEthereumAddress'; +import { MemberAvatar } from '../../memberAvatar'; + +export interface IMemberDataListItemProps extends IDataListItemProps { + /** + * Whether the member is a delegate of current user or not. + */ + isDelegate?: boolean; + /** + * The number of delegations the member has from other members. + */ + delegationCount?: number; + /** + * The total voting power of the member. + */ + votingPower?: number; + /** + * ENS name of the user. + */ + ensName?: string; + /** + * 0x address of the user. + */ + address: string; + /** + * Direct URL src of the user avatar image to be rendered. + */ + avatarSrc?: string; +} + +export const MemberDataListItemStructure: React.FC = (props) => { + const { isDelegate, delegationCount = 0, votingPower = 0, avatarSrc, ensName, address, ...otherProps } = props; + + const { address: currentUserAddress, isConnected } = useAccount(); + + const isCurrentUser = isConnected && address && currentUserAddress === getAddress(address); + + const resolvedUserHandle = ensName != null && ensName !== '' ? ensName : address; + + const hasDelegationOrVotingPower = delegationCount > 0 || votingPower > 0; + + return ( + +
+
+ + {isDelegate && !isCurrentUser && } + {isCurrentUser && } +
+ + + {truncateEthAddress(resolvedUserHandle)} + + + {hasDelegationOrVotingPower && ( +
+ {delegationCount > 0 && ( + + {formatterUtils.formatNumber(delegationCount, { format: NumberFormat.GENERIC_SHORT })} + {` Delegation${delegationCount === 1 ? '' : 's'}`} + + )} + {votingPower > 0 && ( + + {formatterUtils.formatNumber(votingPower, { format: NumberFormat.GENERIC_SHORT })} + Voting Power + + )} +
+ )} +
+
+ ); +}; diff --git a/src/modules/utils/truncateEthereumAddress.ts b/src/modules/utils/truncateEthereumAddress.ts new file mode 100644 index 000000000..831ca2df3 --- /dev/null +++ b/src/modules/utils/truncateEthereumAddress.ts @@ -0,0 +1,9 @@ +const truncateRegex = /^(0x[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})$/; + +export const truncateEthAddress = (address?: string) => { + const match = address?.match(truncateRegex); + if (!match) { + return address; + } + return `${match[1]}\u2026${match[2]}`; +};