Skip to content

Commit

Permalink
feat(APP-3347): Implement ProposalVoting module component (#245)
Browse files Browse the repository at this point in the history
  • Loading branch information
cgero-eth authored Jul 24, 2024
1 parent 9a90fe6 commit 4b896ff
Show file tree
Hide file tree
Showing 51 changed files with 2,005 additions and 3 deletions.
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `ProposalAction` module component
- Implement `ProposalAction` and `ProposalVoting` module components
- Handle `WithdrawToken`, `ChangeMembers`, `UpdateMetadata` and `TokenMint` actions on `ProposalActions` module
component.
- Add optional `hideLabelTokenVoting` and `tokenSymbol` props to the `MemberDataListItemStructure` module component
- Implement `invariant` core utility

### Changed

- Renamed `votingPower` prop to `tokenAmount` in the `MemberDataListItemStructure` module component
- Update interface for `Accordion.Container` to expose value prop
- Update styles on `Tabs.List` for latest spec
- Rename `indicator` property of `<Progress />` core component to `thresholdIndicator`
- Rename `indicator` property of `<Progress />` core component to `thresholdIndicator` and set `data-value` property
to indicator component to easier test its value
- Bump `softprops/action-gh-release` from 2.0.6 to 2.0.8
- Bump `ws` from 7.5.9 to 7.5.10
- Update minor and patch NPM dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const AccordionItem = forwardRef<HTMLDivElement, IAccordionItemProps>((pr
disabled={disabled}
value={value}
className={classNames(
'border-t border-neutral-100 first:border-t-0 hover:border-neutral-200 active:border-neutral-400',
'border-t border-neutral-100 hover:border-neutral-200 active:border-neutral-400',
className,
)}
ref={ref}
Expand Down
1 change: 1 addition & 0 deletions src/core/components/progress/progress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ describe('<Progress /> component', () => {
render(createTestComponent({ thresholdIndicator: indicatorValue }));
const progressIndicator = screen.getByTestId('progress-indicator');
expect(progressIndicator).toHaveStyle(`left: ${indicatorValue}%`);
expect(progressIndicator.dataset.value).toEqual(indicatorValue.toString());
});
});
1 change: 1 addition & 0 deletions src/core/components/progress/progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const Progress: React.FC<IProgressProps> = (props) => {
{processedIndicator && (
<div
data-testid="progress-indicator"
data-value={processedIndicator}
className={indicatorClassNames}
style={{ left: `${processedIndicator}%`, transform: 'translateX(-50%)' }}
>
Expand Down
1 change: 1 addition & 0 deletions src/core/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './clipboardUtils';
export * from './formatterUtils';
export * from './invariant';
export * from './mergeRefs';
export * from './responsiveUtils';
export * from './ssrUtils';
1 change: 1 addition & 0 deletions src/core/utils/invariant/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { invariant, invariantError } from './invariant';
14 changes: 14 additions & 0 deletions src/core/utils/invariant/invariant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { invariant, invariantError } from './invariant';

describe('invariant utils', () => {
it('does not throw error on condition success', () => {
expect(() => invariant(5 < 10, 'error')).not.toThrow();
});

it('throws invariant error on condition error', () => {
const error = 'oops';
const expectedError = new Error(error);
expectedError.name = invariantError;
expect(() => invariant(5 > 10, error)).toThrow(new Error(error));
});
});
12 changes: 12 additions & 0 deletions src/core/utils/invariant/invariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const invariantError = 'Invariant';

export function invariant(condition: boolean, message: string): asserts condition {
if (!condition) {
const error = new Error(message);
error.name = invariantError;

throw error;
}

return;
}
48 changes: 48 additions & 0 deletions src/modules/assets/copy/modulesCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,54 @@ export const modulesCopy = {
by: 'By',
creators: 'creators',
},
proposalVotingTabs: {
breakdown: 'Breakdown',
votes: 'Votes',
details: 'Details',
},
proposalVotingBreakdownMultisig: {
name: 'Minimum Approval',
description: (count: string | null) => `of ${count} members`,
},
proposalVotingBreakdownToken: {
option: {
yes: 'Yes',
no: 'No',
abstain: 'Abstain',
},
support: {
name: 'Support',
description: (value: string) => `of ${value}`,
},
minParticipation: {
name: 'Minimum participation',
description: (value: string) => `of ${value}`,
},
},
proposalVotingStageStatus: {
main: {
proposal: 'Proposal',
stage: 'Stage',
},
secondary: {
pending: 'is pending',
active: 'left to vote',
accepted: 'has been',
rejected: 'has been',
unreached: 'not reached',
},
status: {
accepted: 'accepted',
rejected: 'rejected',
},
},
proposalVotingDetails: {
voting: 'Voting',
governance: 'Governance',
},
proposalVotingStage: {
stage: (index: number) => `Stage ${index}`,
},
voteDataListItemStructure: {
yourDelegate: 'Your delegate',
you: 'You',
Expand Down
1 change: 1 addition & 0 deletions src/modules/components/proposal/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './proposalActions';
export * from './proposalDataListItem';
export * from './proposalVoting';
23 changes: 23 additions & 0 deletions src/modules/components/proposal/proposalVoting/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ProposalVotingBreakdownMultisig } from './proposalVotingBreakdownMultisig';
import { ProposalVotingBreakdownToken } from './proposalVotingBreakdownToken';
import { ProposalVotingContainer } from './proposalVotingContainer';
import { ProposalVotingDetails } from './proposalVotingDetails';
import { ProposalVotingStage } from './proposalVotingStage';
import { ProposalVotingVotes } from './proposalVotingVotes';

export const ProposalVoting = {
BreakdownMultisig: ProposalVotingBreakdownMultisig,
BreakdownToken: ProposalVotingBreakdownToken,
Container: ProposalVotingContainer,
Details: ProposalVotingDetails,
Stage: ProposalVotingStage,
Votes: ProposalVotingVotes,
};

export * from './proposalVotingBreakdownMultisig';
export * from './proposalVotingBreakdownToken';
export * from './proposalVotingContainer';
export * from './proposalVotingDefinitions';
export * from './proposalVotingDetails';
export * from './proposalVotingStage';
export * from './proposalVotingVotes';
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import type { Meta, StoryObj } from '@storybook/react';
import { DateTime } from 'luxon';
import { useState } from 'react';
import { Button, DataList } from '../../../../core';
import { type IVoteDataListItemStructureProps, VoteDataListItem } from '../../vote';
import { ProposalVoting, ProposalVotingStatus } from '../index';

const meta: Meta<typeof ProposalVoting.Container> = {
title: 'Modules/Components/Proposal/ProposalVoting/ProposalVoting',
component: ProposalVoting.Container,
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/ISSDryshtEpB7SUSdNqAcw/Aragon-ODS?node-id=16752-20193&m=dev',
},
},
};

type Story = StoryObj<typeof ProposalVoting.Container>;

const getTotalVotes = (votes: IVoteDataListItemStructureProps[], indicator: string) =>
votes.reduce(
(accumulator, { voteIndicator, votingPower }) =>
accumulator + (voteIndicator === indicator ? Number(votingPower) : 0),
0,
);

const filterVotes = (votes: IVoteDataListItemStructureProps[], search?: string) =>
votes.filter(
(vote) =>
search == null ||
search.length === 0 ||
vote.voter.address.includes(search) ||
vote.voter.name?.includes(search),
);

const tokenVotes: IVoteDataListItemStructureProps[] = [
{
voter: { address: '0x17366cae2b9c6C3055e9e3C78936a69006BE5409', name: 'cgero.eth' },
isDelegate: true,
voteIndicator: 'yes',
votingPower: 47289374,
tokenSymbol: 'ARA',
},
{
voter: { address: '0xd5fb864ACfD6BB2f72939f122e89fF7F475924f5', name: 'sio.eth' },
voteIndicator: 'yes',
votingPower: 1238948,
tokenSymbol: 'ARA',
},
{
voter: { address: '0xF6ad40D5D477ade0C640eaD49944bdD0AA1fBF05' },
voteIndicator: 'no',
votingPower: 8495,
tokenSymbol: 'ARA',
},
{
voter: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', name: 'vitalik.eth' },
voteIndicator: 'abstain',
votingPower: 69420000,
tokenSymbol: 'ARA',
},
{
voter: { address: '0xe11BFCBDd43745d4Aa6f4f18E24aD24f4623af04', name: 'cdixon.eth' },
voteIndicator: 'yes',
votingPower: 66749851,
tokenSymbol: 'ARA',
},
];

const multisigVotes: IVoteDataListItemStructureProps[] = [
{
voter: { address: '0xFe89cc7aBB2C4183683ab71653C4cdc9B02D44b7', name: 'ens.eth' },
voteIndicator: 'approve',
},
{
voter: { address: '0x650235a0889CAe912673AAD13Ff75d1F1A175487' },
voteIndicator: 'approve',
},
{
voter: { address: '0xDCFfFFA68464A4AFC96EEf885844631A439cE625' },
voteIndicator: 'approve',
},
{
voter: { address: '0x2536c09E5F5691498805884fa37811Be3b2BDdb4' },
voteIndicator: 'approve',
},
];

const tokenSettings = [
{ term: 'Strategy', definition: '1 Token → 1 Vote' },
{ term: 'Voting options', definition: 'Yes, Abstain, or No' },
{ term: 'Minimum support', definition: '>50%' },
{ term: 'Minimum participation (Quorum)', definition: '≥62.42K of 1M DEGEN (≥6.942)' },
{ term: 'Early execution', definition: 'Yes' },
{ term: 'Vote replacement', definition: 'No' },
{ term: 'Minimum duration', definition: '7 days' },
];

const getMultisigSettings = (minApprovals: number) => [
{ term: 'Strategy', definition: '1 Address → 1 Vote' },
{ term: 'Voting options', definition: 'Approve' },
{ term: 'Minimum approval', definition: `${minApprovals} of 5` },
];

/**
* Usage example of the ProposalVoting module component for multi-stage proposals
*/
export const MultiStage: Story = {
args: {
title: 'Voting',
description:
'The proposal must pass all governance stages to be accepted and potential onchain actions to execute.',
activeStage: '0',
className: 'max-w-[560px]',
},
render: (args) => {
const [tokenSearch, setTokenSearch] = useState<string | undefined>('');
const [multisigSearch, setMultisigSearch] = useState<string | undefined>('');

const filteredTokenVotes = filterVotes(tokenVotes, tokenSearch);
const minApprovals = 4;

return (
<ProposalVoting.Container {...args}>
<ProposalVoting.Stage
name="Token holder voting"
status={ProposalVotingStatus.ACTIVE}
startDate={DateTime.now().toMillis()}
endDate={DateTime.now().plus({ days: 5 }).toMillis()}
>
<ProposalVoting.BreakdownToken
tokenSymbol="ARA"
totalYes={getTotalVotes(tokenVotes, 'yes')}
totalNo={getTotalVotes(tokenVotes, 'no')}
totalAbstain={getTotalVotes(tokenVotes, 'abstain')}
supportThreshold={50}
minParticipation={15}
tokenTotalSupply={9451231259}
>
<Button variant="primary" size="md" className="md:self-start">
Vote on proposal
</Button>
</ProposalVoting.BreakdownToken>
<ProposalVoting.Votes>
<DataList.Root itemsCount={filteredTokenVotes.length} entityLabel="Votes">
<DataList.Filter searchValue={tokenSearch} onSearchValueChange={setTokenSearch} />
<DataList.Container>
{filteredTokenVotes.map((vote) => (
<VoteDataListItem.Structure key={vote.voter.address} {...vote} />
))}
</DataList.Container>
<DataList.Pagination />
</DataList.Root>
</ProposalVoting.Votes>
<ProposalVoting.Details settings={tokenSettings} />
</ProposalVoting.Stage>
<ProposalVoting.Stage
name="Founders approval"
status={ProposalVotingStatus.PENDING}
startDate={DateTime.now().plus({ days: 7 }).toMillis()}
endDate={DateTime.now().plus({ days: 10 }).toMillis()}
>
<ProposalVoting.BreakdownMultisig approvalsAmount={0} minApprovals={minApprovals} />
<ProposalVoting.Votes>
<DataList.Root itemsCount={0} entityLabel="Votes">
<DataList.Filter searchValue={multisigSearch} onSearchValueChange={setMultisigSearch} />
<DataList.Container
emptyState={{ heading: 'No votes', description: 'Stage has no votes' }}
/>
<DataList.Pagination />
</DataList.Root>
</ProposalVoting.Votes>
<ProposalVoting.Details settings={getMultisigSettings(minApprovals)} />
</ProposalVoting.Stage>
</ProposalVoting.Container>
);
},
};

/**
* Usage example of the ProposalVoting module component for single-stage proposals
*/
export const SingleStage: Story = {
args: {
title: 'Voting',
description: 'The proposal must pass the voting to be accepted and potential onchain actions to execute.',
className: 'max-w-[560px]',
},
render: (args) => {
const [search, setSearch] = useState<string | undefined>('');

const filteredVotes = filterVotes(multisigVotes, search);
const minApprovals = 5;

return (
<ProposalVoting.Container {...args}>
<ProposalVoting.Stage
name="Token holder voting"
status={ProposalVotingStatus.ACTIVE}
startDate={DateTime.now().toMillis()}
endDate={DateTime.now().plus({ hours: 7 }).toMillis()}
>
<ProposalVoting.BreakdownMultisig
approvalsAmount={multisigVotes.length}
minApprovals={minApprovals}
/>
<ProposalVoting.Votes>
<DataList.Root itemsCount={filteredVotes.length} entityLabel="Votes">
<DataList.Filter searchValue={search} onSearchValueChange={setSearch} />
<DataList.Container>
{filteredVotes.map((vote) => (
<VoteDataListItem.Structure key={vote.voter.address} {...vote} />
))}
</DataList.Container>
<DataList.Pagination />
</DataList.Root>
</ProposalVoting.Votes>
<ProposalVoting.Details settings={getMultisigSettings(minApprovals)} />
</ProposalVoting.Stage>
</ProposalVoting.Container>
);
},
};

export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
ProposalVotingBreakdownMultisig,
type IProposalVotingBreakdownMultisigProps,
} from './proposalVotingBreakdownMultisig';
Loading

0 comments on commit 4b896ff

Please sign in to comment.