Skip to content

Commit

Permalink
feat: Weighted voting UI (#10858)
Browse files Browse the repository at this point in the history
<!--
Before opening a pull request, please read the [contributing
guidelines](https://github.com/pancakeswap/pancake-frontend/blob/develop/CONTRIBUTING.md)
first
-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the voting system by introducing new vote
types, updating interfaces, and improving the UI for casting and
displaying votes, particularly for single-choice and weighted voting
proposals.

### Detailed summary
- Added `SingleVoteState` and `WeightedVoteState` interfaces.
- Introduced `ProposalTypeName` enum for vote types.
- Updated `Proposal` interface to include `type`, `scores`, and
`scores_total`.
- Modified `CastVoteModal` to handle different vote types.
- Created `SingleVote` and `WeightedVote` components for respective
voting methods.
- Improved `VoteRow` to display vote percentages for weighted votes.
- Enhanced UI components for better styling and responsiveness.
- Updated localization strings for new voting-related texts.
- Refactored voting logic to accommodate new features and improve
clarity.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your
question}`

<!-- end pr-codex -->
  • Loading branch information
ChefMomota authored Oct 28, 2024
1 parent 680d3f1 commit 1b8a6d4
Show file tree
Hide file tree
Showing 22 changed files with 789 additions and 196 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/web/src/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,11 @@ export enum ProposalState {
CLOSED = 'closed',
}

export enum ProposalTypeName {
SINGLE_CHOICE = 'single-choice',
WEIGHTED = 'weighted',
}

export interface Proposal {
author: string
body: string
Expand All @@ -403,6 +408,9 @@ export interface Proposal {
state: ProposalState
title: string
ipfs: string
type: ProposalTypeName
scores: number[]
scores_total: number
}

export interface Vote {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/state/voting/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const getProposal = async (id: string): Promise<Proposal> => {
author
votes
ipfs
type
scores
scores_total
}
}
`,
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/views/Voting/CreateProposal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from '@pancakeswap/localization'
import {
AutoRenewIcon,
Box,
Expand All @@ -15,19 +16,18 @@ import {
useModal,
useToast,
} from '@pancakeswap/uikit'
import snapshot from '@snapshot-labs/snapshot.js'
import isEmpty from 'lodash/isEmpty'
import times from 'lodash/times'
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react'
import { useInitialBlock } from 'state/block/hooks'

import { useTranslation } from '@pancakeswap/localization'
import truncateHash from '@pancakeswap/utils/truncateHash'
import snapshot from '@snapshot-labs/snapshot.js'
import ConnectWalletButton from 'components/ConnectWalletButton'
import Container from 'components/Layout/Container'
import isEmpty from 'lodash/isEmpty'
import times from 'lodash/times'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react'
import { useInitialBlock } from 'state/block/hooks'
import { ProposalTypeName } from 'state/types'
import { getBlockExploreLink } from 'utils'
import { DatePicker, DatePickerPortal, TimePicker } from 'views/Voting/components/DatePicker'
import { useAccount, useWalletClient } from 'wagmi'
Expand Down Expand Up @@ -96,7 +96,7 @@ const CreateProposal = () => {

const data: any = await client.proposal(web3 as any, account, {
space: PANCAKE_SPACE,
type: 'single-choice',
type: ProposalTypeName.SINGLE_CHOICE, // TODO
title: name,
body,
start: combineDateAndTime(startDate, startTime) || 0,
Expand Down
59 changes: 31 additions & 28 deletions apps/web/src/views/Voting/Proposal/Details.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,72 @@
import { useTranslation } from '@pancakeswap/localization'
import { Box, Card, CardBody, CardHeader, Flex, Heading, LinkExternal, ScanLink, Text } from '@pancakeswap/uikit'
import { styled } from 'styled-components'
import truncateHash from '@pancakeswap/utils/truncateHash'
import dayjs from 'dayjs'
import { Proposal } from 'state/types'
import { Proposal, ProposalTypeName } from 'state/types'
import { getBlockExploreLink } from 'utils'
import { useTranslation } from '@pancakeswap/localization'
import truncateHash from '@pancakeswap/utils/truncateHash'
import { IPFS_GATEWAY } from '../config'
import { ProposalStateTag } from '../components/Proposals/tags'

interface DetailsProps {
proposal: Proposal
}

const DetailBox = styled(Box)`
background-color: ${({ theme }) => theme.colors.background};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 16px;
`

const Details: React.FC<React.PropsWithChildren<DetailsProps>> = ({ proposal }) => {
const { t } = useTranslation()
const startDate = new Date(proposal.start * 1000)
const endDate = new Date(proposal.end * 1000)

return (
<Card mb="16px">
<CardHeader>
<CardHeader style={{ background: 'transparent' }}>
<Heading as="h3" scale="md">
{t('Details')}
</Heading>
</CardHeader>
<CardBody>
<Flex alignItems="center" mb="8px">
<Text color="textSubtle">{t('Identifier')}</Text>
<LinkExternal href={`${IPFS_GATEWAY}/${proposal.ipfs}`} ml="8px">
{proposal.ipfs.slice(0, 8)}
</LinkExternal>
</Flex>
<Flex alignItems="center" mb="8px">
<Text color="textSubtle">{t('Creator')}</Text>
<Text color="textSubtle" mr="auto">
{t('Creator')}
</Text>
<ScanLink useBscCoinFallback href={getBlockExploreLink(proposal.author, 'address')} ml="8px">
{truncateHash(proposal.author)}
</ScanLink>
</Flex>
<Flex alignItems="center" mb="16px">
<Text color="textSubtle">{t('Snapshot')}</Text>
<Flex mb="24px">
<Text color="textSubtle" mr="auto">
{t('Voting system')}
</Text>
<Text ml="8px">{proposal.type === ProposalTypeName.SINGLE_CHOICE ? t('Binary') : t('Weighted')}</Text>
</Flex>
<Flex alignItems="center" mb="8px">
<Text color="textSubtle" mr="auto">
{t('Identifier')}
</Text>
<LinkExternal href={`${IPFS_GATEWAY}/${proposal.ipfs}`} ml="8px">
{proposal.ipfs.slice(0, 8)}
</LinkExternal>
</Flex>
<Flex alignItems="center" mb="24px">
<Text color="textSubtle" mr="auto">
{t('Snapshot')}
</Text>
<ScanLink useBscCoinFallback href={getBlockExploreLink(proposal.snapshot, 'block')} ml="8px">
{proposal.snapshot}
</ScanLink>
</Flex>
<DetailBox p="16px">
<ProposalStateTag proposalState={proposal.state} mb="8px" />
<Flex alignItems="center">
<Text color="textSubtle" fontSize="14px">
<Box>
<Flex>
<Text color="textSubtle" mr="auto">
{t('Start Date')}
</Text>
<Text ml="8px">{dayjs(startDate).format('YYYY-MM-DD HH:mm')}</Text>
</Flex>
<Flex alignItems="center">
<Text color="textSubtle" fontSize="14px">
<Flex>
<Text color="textSubtle" mr="auto">
{t('End Date')}
</Text>
<Text ml="8px">{dayjs(endDate).format('YYYY-MM-DD HH:mm')}</Text>
</Flex>
</DetailBox>
</Box>
</CardBody>
</Card>
)
Expand Down
57 changes: 48 additions & 9 deletions apps/web/src/views/Voting/Proposal/Overview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { useTranslation } from '@pancakeswap/localization'
import { ArrowBackIcon, Box, Button, Flex, Heading, NotFound, ReactMarkdown } from '@pancakeswap/uikit'
import {
ArrowBackIcon,
Box,
Button,
Flex,
Heading,
NotFound,
ReactMarkdown,
useMatchBreakpoints,
} from '@pancakeswap/uikit'
import { useQuery } from '@tanstack/react-query'
import Container from 'components/Layout/Container'
import PageLoader from 'components/Loader/PageLoader'
Expand All @@ -23,6 +32,7 @@ const Overview = () => {
const id = query.id as string
const { t } = useTranslation()
const { address: account } = useAccount()
const { isDesktop } = useMatchBreakpoints()

const {
status: proposalLoadingStatus,
Expand Down Expand Up @@ -56,8 +66,12 @@ const Overview = () => {
})

const votes = useMemo(() => data || [], [data])

const hasAccountVoted = account && votes && votes.some((vote) => vote.voter.toLowerCase() === account.toLowerCase())
const hasAccountVoted =
account &&
votes &&
proposal &&
proposal.state === ProposalState.ACTIVE &&
votes.some((vote) => vote.voter.toLowerCase() === account.toLowerCase())

const isPageLoading = votesLoadingStatus === 'pending' || proposalLoadingStatus === 'pending'

Expand Down Expand Up @@ -96,19 +110,44 @@ const Overview = () => {
<ReactMarkdown>{proposal.body}</ReactMarkdown>
</Box>
</Box>
{!isPageLoading && !hasAccountVoted && proposal.state === ProposalState.ACTIVE && (
<Vote proposal={proposal} onSuccess={refetch} mb="16px" />
{!isPageLoading && (
<Vote
mb="16px"
proposal={proposal}
votes={votes}
hasAccountVoted={Boolean(hasAccountVoted)}
onSuccess={refetch}
/>
)}
{!isDesktop && (
<Box mb="16px">
<Details proposal={proposal} />
<Results
proposal={proposal}
choices={proposal.choices}
votes={votes || []}
votesLoadingStatus={votesLoadingStatus}
/>
</Box>
)}
<Votes
votes={votes || []}
proposal={proposal}
totalVotes={votes?.length ?? proposal.votes}
votesLoadingStatus={votesLoadingStatus}
/>
</Box>
<Box position="sticky" top="60px">
<Details proposal={proposal} />
<Results choices={proposal.choices} votes={votes || []} votesLoadingStatus={votesLoadingStatus} />
</Box>
{isDesktop && (
<Box position="sticky" top="60px">
<Details proposal={proposal} />
<Results
proposal={proposal}
choices={proposal.choices}
votes={votes || []}
votesLoadingStatus={votesLoadingStatus}
/>
</Box>
)}
</Layout>
</Container>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useTranslation } from '@pancakeswap/localization'
import { Box, Flex, Progress, Text } from '@pancakeswap/uikit'
import { formatNumber } from '@pancakeswap/utils/formatBalance'
import { Vote } from 'state/types'
import TextEllipsis from '../../components/TextEllipsis'
import { calculateVoteResults, getTotalFromVotes } from '../../helpers'

interface SingleVoteResultsProps {
choices: string[]
votes: Vote[]
}

export const SingleVoteResults: React.FC<SingleVoteResultsProps> = ({ votes, choices }) => {
const { t } = useTranslation()
const results = calculateVoteResults(votes)
const totalVotes = getTotalFromVotes(votes)

return (
<>
{choices.map((choice, index) => {
const choiceVotes = results[choice] || []
const totalChoiceVote = getTotalFromVotes(choiceVotes)
const progress = totalVotes === 0 ? 0 : (totalChoiceVote / totalVotes) * 100

return (
<Box key={choice} mt={index > 0 ? '24px' : '0px'}>
<Flex alignItems="center" mb="8px">
<TextEllipsis mb="4px" title={choice}>
{choice}
</TextEllipsis>
</Flex>
<Box mb="4px">
<Progress primaryStep={progress} scale="sm" />
</Box>
<Flex alignItems="center" justifyContent="space-between">
<Text color="textSubtle">{t('%total% Votes', { total: formatNumber(totalChoiceVote, 0, 2) })}</Text>
<Text>{progress.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Text>
</Flex>
</Box>
)
})}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useTranslation } from '@pancakeswap/localization'
import { Box, Flex, Progress, Text } from '@pancakeswap/uikit'
import { formatNumber } from '@pancakeswap/utils/formatBalance'
import { useMemo } from 'react'
import { WeightedVoteState } from 'views/Voting/Proposal/VoteType/types'
import TextEllipsis from '../../components/TextEllipsis'

interface WeightedVoteResultsProps {
choices: string[]
sortData?: boolean
choicesVotes: WeightedVoteState[]
}

export const WeightedVoteResults: React.FC<WeightedVoteResultsProps> = ({ choices, sortData, choicesVotes }) => {
const { t } = useTranslation()

const totalSum = useMemo(
() => choicesVotes.reduce((sum, item) => sum + Object.values(item).reduce((a, b) => a + b, 0), 0),
[choicesVotes],
)

const percentageResults = useMemo(
() =>
choicesVotes.reduce((acc, item) => {
Object.entries(item).forEach(([key, value]) => {
// eslint-disable-next-line no-param-reassign
acc[key] = (acc[key] || 0) + value
})
return acc
}, {}),
[choicesVotes],
)

const sortedChoices = useMemo(() => {
const list = choices.map((choice, index) => {
const totalChoiceVote = percentageResults[index + 1] ?? 0
const progress = (totalChoiceVote / totalSum) * 100
return { choice, totalChoiceVote, progress }
})

return sortData ? list.sort((a, b) => b.progress - a.progress) : list
}, [choices, percentageResults, sortData, totalSum])

return (
<>
{sortedChoices.map(({ choice, totalChoiceVote, progress }, index) => (
<Box key={choice} mt={index > 0 ? '24px' : '0px'}>
<Flex alignItems="center" mb="8px">
<TextEllipsis mb="4px" title={choice}>
{choice}
</TextEllipsis>
</Flex>
<Box mb="4px">
<Progress primaryStep={progress} scale="sm" />
</Box>
<Flex alignItems="center" justifyContent="space-between">
<Text color="textSubtle">{t('%total% Votes', { total: formatNumber(totalChoiceVote, 0, 2) })}</Text>
<Text>
{totalChoiceVote === 0 && totalSum === 0
? '0.00%'
: `${progress.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
%`}
</Text>
</Flex>
</Box>
))}
</>
)
}
Loading

0 comments on commit 1b8a6d4

Please sign in to comment.