diff --git a/CHANGELOG.md b/CHANGELOG.md index 5882bb153..dad911463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Implement `ProposalDataListItemSkeleton` module component +- Extend `addressUtils` with `isAddressEqual` method ### Changed - Remove padding from `DataListContainer`, `DataListFilterStatus`, `DataListPagination` and `DataListRoot` +- Add `stageId` and `stageTitle` properties to `IApprovalThresholdResult` & `IMajorityVotingResult` interfaces +- Add `id` and optional `tag` properties to `ProposalDataListItemStructure` +- Remove `publisherProfileLink` and `protocolUpdate` properties from `ProposalDataListItemStructure` +- Update `date` and `result` properties of `ProposalDataListItemStructure` to be optional and `publisher` to allow for + multiple publishers + +### Fixed + +- `Link` core component to truncate on overflow ## [1.0.23] - 2024-04-18 diff --git a/src/core/components/link/link.tsx b/src/core/components/link/link.tsx index 73934f6ca..b004e24ba 100644 --- a/src/core/components/link/link.tsx +++ b/src/core/components/link/link.tsx @@ -55,8 +55,8 @@ export const Link = React.forwardRef((props, ref) {...(disabled && { tabIndex: -1, 'aria-disabled': 'true' })} {...otherProps} > -
- {children} +
+ {children} {iconRight && }
{description &&

{description}

} diff --git a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx index df66ddad8..ab16367c1 100644 --- a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx +++ b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.test.tsx @@ -38,4 +38,22 @@ describe(' component', () => { expect(screen.getByText(expectedApproval)).toBeInTheDocument(); expect(screen.getByText(expectedThreshold)).toBeInTheDocument(); }); + + it('renders the stage title and stage id when provided', () => { + const stage = { + title: 'Test Stage', + id: '3', + }; + + render(createTestComponent({ stage })); + + expect(screen.getByText(stage.title)).toBeInTheDocument(); + expect(screen.getByText(stage.id)).toBeInTheDocument(); + }); + + it('renders the default stage title when not provided', () => { + render(createTestComponent()); + + expect(screen.getByText(/approved by/i)).toBeInTheDocument(); + }); }); diff --git a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx index 0f3d55e12..9317f634a 100644 --- a/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx +++ b/src/modules/components/proposal/proposalDataListItem/approvalThresholdResult/approvalThresholdResult.tsx @@ -7,14 +7,20 @@ export interface IApprovalThresholdResultProps extends IApprovalThresholdResult * `ApprovalThresholdResult` component */ export const ApprovalThresholdResult: React.FC = (props) => { - const { approvalAmount, approvalThreshold } = props; + const { approvalAmount, approvalThreshold, stage } = props; const percentage = approvalThreshold !== 0 ? (approvalAmount / approvalThreshold) * 100 : 100; return ( - // TODO: apply internationalization to Approved By, of, and Members [APP-2627] + // TODO: apply internationalization to Approved By, of, Stage, and Members [APP-2627]
-
- Approved By +
+ {stage?.title ?? 'Approved By'} + {stage?.id != null && ( + + Stage + {stage.id} + + )}
diff --git a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx index 8da12f06a..043539de4 100644 --- a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx +++ b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.test.tsx @@ -27,4 +27,22 @@ describe(' component', () => { expect(screen.getByText(mockProps.votePercentage, { exact: false })).toBeInTheDocument(); expect(screen.getByRole('progressbar')).toBeInTheDocument(); }); + + it('renders the stage title and stage id when provided', () => { + const stage = { + title: 'Test Stage', + id: '3', + }; + + render(createTestComponent({ stage })); + + expect(screen.getByText(stage.title)).toBeInTheDocument(); + expect(screen.getByText(stage.id)).toBeInTheDocument(); + }); + + it('renders the default stage title when not provided', () => { + render(createTestComponent()); + + expect(screen.getByText(/winning option/i)).toBeInTheDocument(); + }); }); diff --git a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx index 2757034ad..c58837773 100644 --- a/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx +++ b/src/modules/components/proposal/proposalDataListItem/majorityVotingResult/majorityVotingResult.tsx @@ -7,14 +7,20 @@ export interface IMajorityVotingResultProps extends IMajorityVotingResult {} * `MajorityVotingResult` component */ export const MajorityVotingResult: React.FC = (props) => { - const { option, voteAmount, votePercentage } = props; + const { option, stage, voteAmount, votePercentage } = props; return ( - // TODO: apply internationalization to Winning Option [APP-2627] + // TODO: apply internationalization to Winning Option and Stage [APP-2627]
- Winning Option - {`${votePercentage}%`} + {stage?.title ?? 'Winning Option'} + {stage?.id == null && {`${votePercentage}%`}} + {stage?.id != null && ( + + Stage + {stage.id} + + )}
diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx index 0f65473e3..d763c5104 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.test.tsx @@ -23,6 +23,14 @@ describe(' component', () => { expect(screen.getByTestId(IconType.CALENDAR)).toBeInTheDocument(); }); + it('does not render the calendar icon when date property is not defined', () => { + const status = 'accepted'; + + render(createTestComponent({ status, date: undefined })); + + expect(screen.queryByTestId(IconType.CALENDAR)).not.toBeInTheDocument(); + }); + it("only displays the date for proposals with a status that is not 'draft'", () => { const date = 'test date'; const status = 'draft'; diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx index 3a6681e11..aa9a03813 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStatus/proposalDataListItemStatus.tsx @@ -61,7 +61,9 @@ export const ProposalDataListItemStatus: React.FC {ongoingAndVoted && } {ongoing && !voted && } - {!ongoing && !voted && } + {!ongoing && !voted && date && ( + + )}
)}
diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts index 97dda5ace..5e9f432a2 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.api.ts @@ -20,25 +20,25 @@ export interface IProposalDataListItemStructureBaseProps = { type Story = StoryObj; -const baseArgs: Omit = { +const basePublisher = { + address: '0xd5fb864ACfD6BB2f72939f122e89fF7F475924f5', + link: 'https://app.aragon.org/#/daos/base/0xd2705c56aa4edb98271cb8cea2b0df3288ad4585/members/0xd5fb864ACfD6BB2f72939f122e89fF7F475924f5', +}; + +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. @@ -39,6 +40,7 @@ const baseArgs: Omit = { export const MajorityVoting: Story = { args: { ...baseArgs, + publisher: { ...basePublisher }, type: 'majorityVoting', result: { option: 'yes', @@ -61,7 +63,7 @@ export const MajorityVoting: Story = { export const ApprovalThreshold: Story = { args: { ...baseArgs, - publisher: { name: 'sio.eth', address: baseArgs.publisher.address }, + publisher: { ...basePublisher, name: 'sio.eth' }, type: 'approvalThreshold', result: { approvalAmount: 4, @@ -77,4 +79,33 @@ export const ApprovalThreshold: Story = { ), }; +/** + * Example of the `ProposalDataListItem.Structure` module component for a multi-body proposal. + */ +export const MultiBody: Story = { + args: { + ...baseArgs, + id: 'PIP-1', + publisher: [ + { ...basePublisher, name: '0xRugg', link: undefined }, + { ...basePublisher, name: 'Bob the Builder', link: undefined }, + { ...basePublisher, name: 'sio.eth' }, + { ...basePublisher }, + ], + type: 'approvalThreshold', + result: { + stage: { title: 'Founders Approval Council', id: '1' }, + 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 index ee7809550..0fc2fe777 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx @@ -2,7 +2,7 @@ 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 { ProposalDataListItemStructure, maxPublishersDisplayed } from './proposalDataListItemStructure'; import { type IApprovalThresholdResult, type IMajorityVotingResult, @@ -18,14 +18,10 @@ describe(' component', () => { const { result, ...baseInputProps } = props ?? {}; const baseProps: Omit = { - date: new Date().toISOString(), - protocolUpdate: false, - publisher: { address: '0x123' }, + publisher: { address: '0x0000000000000000000000000000000000000000', link: '#' }, status: 'active', summary: 'Example Summary', title: 'Example Title', - voted: false, - publisherProfileLink: '#', type: 'approvalThreshold', ...baseInputProps, }; @@ -73,7 +69,7 @@ describe(' component', () => { const ongoingStatuses: ProposalStatus[] = ['active', 'challenged', 'vetoed']; it("renders 'You' as the publisher if the connected address is the publisher address", () => { - const publisher = { address: '0x123' }; + const publisher = { address: '0x0000000000000000000000000000000000000000', link: '#' }; useAccountMock.mockImplementation(jest.fn().mockReturnValue({ address: publisher.address, isConnected: true })); @@ -82,35 +78,58 @@ describe(' component', () => { 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, - }, - }; + it('renders multiple publishers', () => { + const publishers = [ + { name: 'abc', link: '#', address: '0x0000000000000000000000000000000000000000' }, + { name: 'def', link: '#', address: '0x0000000000000000000000000000000000000000' }, + ]; - render(createTestComponent(testProps)); + render(createTestComponent({ publisher: publishers })); - 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.truncateAddress(testProps.publisher.address ?? '')), - ).toBeInTheDocument(); + publishers.forEach((publisher) => { + expect(screen.getByText(publisher.name)).toBeInTheDocument(); }); + }); + + it(`renders '${maxPublishersDisplayed}+ creators' when the publishers are more than ${maxPublishersDisplayed}`, () => { + const publishers = [ + { name: 'abc', link: '#', address: '0x0000000000000000000000000000000000000000' }, + { name: 'def', link: '#', address: '0x0000000000000000000000000000000000000000' }, + { name: 'ghi', link: '#', address: '0x0000000000000000000000000000000000000000' }, + { name: 'jkl', link: '#', address: '0x0000000000000000000000000000000000000000' }, + ]; + + render(createTestComponent({ publisher: publishers })); + + expect(screen.getByText(`${maxPublishersDisplayed}+ creators`)).toBeInTheDocument(); + }); + + it('renders with the given properties', () => { + const testProps = { + tag: 'OSx updates', + date: new Date().toISOString(), + publisher: { address: '0x0000000000000000000000000000000000000000', link: '#' }, + status: 'active', + summary: 'Example Summary', + title: 'Example Title', + type: 'approvalThreshold', + id: '0x1', + }; + + render(createTestComponent(testProps as IProposalDataListItemStructureProps)); + + 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(testProps.id)).toBeInTheDocument(); + expect(screen.getByText(testProps.tag)).toBeInTheDocument(); + expect(screen.getByText(addressUtils.truncateAddress(testProps.publisher.address))).toBeInTheDocument(); + }); + describe("'approvalThreshold' type", () => { ongoingStatuses.forEach((status) => { - it(`renders the results when status is '${status}'`, () => { + it.each(ongoingStatuses)(`renders the results when status is '${status}'`, () => { const testProps = { approvalAmount: 10, approvalThreshold: 11, @@ -137,33 +156,6 @@ describe(' component', () => { }); 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.truncateAddress(testProps.publisher.address ?? '')), - ).toBeInTheDocument(); - }); - ongoingStatuses.forEach((status) => { it(`renders the results when status is '${status}'`, () => { const testProps = { diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx index f97a942d8..e37b550f0 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx @@ -5,7 +5,18 @@ import { addressUtils } from '../../../../utils/addressUtils'; import { ApprovalThresholdResult } from '../approvalThresholdResult'; import { MajorityVotingResult } from '../majorityVotingResult'; import { ProposalDataListItemStatus } from '../proposalDataListItemStatus'; -import { type IProposalDataListItemStructureProps } from './proposalDataListItemStructure.api'; +import { type IProposalDataListItemStructureProps, type IPublisher } from './proposalDataListItemStructure.api'; + +export const maxPublishersDisplayed = 3; + +function parsePublisher(publisher: IPublisher, isConnected: boolean, connectedAddress: string | undefined) { + const publisherIsConnected = isConnected && addressUtils.isAddressEqual(publisher.address, connectedAddress); + const publisherLabel = publisherIsConnected + ? 'You' + : publisher.name ?? addressUtils.truncateAddress(publisher.address); + + return { label: publisherLabel, link: publisher.link }; +} /** * `ProposalDataListItemStructure` module component @@ -14,13 +25,13 @@ export const ProposalDataListItemStructure: React.FC parsePublisher(p, isConnected, connectedAddress)) + : [parsePublisher(publisher, isConnected, connectedAddress)]; + + const showParsedPublisher = parsedPublisher.length <= maxPublishersDisplayed; return (
-

{title}

+

+ {id && {id}} + {title} +

{summary}

- {ongoing && type === 'approvalThreshold' && } + {ongoing && type === 'approvalThreshold' && result && } - {ongoing && type === 'majorityVoting' && } + {ongoing && type === 'majorityVoting' && result && } -
-
- {/* TODO: apply internationalization [APP-2627] */} +
+
By - {/* using solution from https://kizu.dev/nested-links/ to nest anchor tags */} - - {publisherLabel} - + {showParsedPublisher === false && 3+ creators} + {showParsedPublisher && + parsedPublisher.map(({ label, link }, index) => ( + + + {link != null && ( + // using solution from https://kizu.dev/nested-links/ to nest anchor tags + {label} + )} + {link == null && {label}} + {index < parsedPublisher.length - 1 && ','} + + + ))}
- - {/* TODO: apply internationalization [APP-2627] */} - {protocolUpdate && } + {tag && }
); diff --git a/src/modules/utils/addressUtils/addressUtils.test.tsx b/src/modules/utils/addressUtils/addressUtils.test.tsx index e4a185ec7..4bf79e6e3 100644 --- a/src/modules/utils/addressUtils/addressUtils.test.tsx +++ b/src/modules/utils/addressUtils/addressUtils.test.tsx @@ -60,4 +60,29 @@ describe('address utils', () => { expect(() => addressUtils.getChecksum(value)).toThrow(); }); }); + + describe('isAddressEqual', () => { + it('returns true when both addresses are the same regardless of checksum', () => { + const addressOne = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + const addressTwo = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + expect(addressUtils.isAddressEqual(addressOne, addressTwo)).toBeTruthy(); + }); + + it('returns false when the addresses are not the same', () => { + const addressOne = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + const addressTwo = '0x0000000000000000000000000000000000000000'; + + expect(addressUtils.isAddressEqual(addressOne, addressTwo)).toBeFalsy(); + }); + + it('returns false when either of the inputs is an invalid address', () => { + const invalidAddress = 'test'; + const validAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + + expect(addressUtils.isAddressEqual(validAddress, invalidAddress)).toBeFalsy(); + expect(addressUtils.isAddressEqual(invalidAddress, validAddress)).toBeFalsy(); + expect(addressUtils.isAddressEqual(invalidAddress, invalidAddress)).toBeFalsy(); + }); + }); }); diff --git a/src/modules/utils/addressUtils/addressUtils.ts b/src/modules/utils/addressUtils/addressUtils.ts index 72e22fd24..638d323ba 100644 --- a/src/modules/utils/addressUtils/addressUtils.ts +++ b/src/modules/utils/addressUtils/addressUtils.ts @@ -33,6 +33,17 @@ class AddressUtils { * @returns The address in checksum format */ getChecksum = (address = ''): Address => getAddress(address); + + /** + * Compares two addresses (ignoring checksum) to see if they are the same + * @param addressOne The first address + * @param addressTwo The second address + * @returns true if the addresses are the same, false otherwise + */ + isAddressEqual = (addressOne = '', addressTwo = ''): boolean => + this.isAddress(addressOne) && + this.isAddress(addressTwo) && + addressOne?.toLowerCase() === addressTwo?.toLowerCase(); } export const addressUtils = new AddressUtils();