From 2d971f43aeae1beb671e2217252dffcc12edeb18 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Wed, 31 Jan 2024 20:09:42 +0100 Subject: [PATCH] Display the votes on the network proposal details page --- .changelog/1356.feature.md | 1 + .../Proposals/ProposalVoteIndicator.tsx | 77 +++++++++ .../components/Proposals/VoteTypePills.tsx | 64 ++++++++ .../Validators/DeferredValidatorLink.tsx | 20 +++ .../components/Validators/ValidatorImage.tsx | 12 +- .../components/Validators/ValidatorLink.tsx | 2 +- .../ProposalDetailsPage/ProposalVotesCard.tsx | 111 +++++++++++++ src/app/pages/ProposalDetailsPage/hooks.ts | 154 ++++++++++++++++++ src/app/pages/ProposalDetailsPage/index.tsx | 65 +++++++- src/app/pages/TokenDashboardPage/hook.ts | 3 +- src/locales/en/translation.json | 14 +- src/types/vote.ts | 38 +++++ 12 files changed, 552 insertions(+), 9 deletions(-) create mode 100644 .changelog/1356.feature.md create mode 100644 src/app/components/Proposals/ProposalVoteIndicator.tsx create mode 100644 src/app/components/Proposals/VoteTypePills.tsx create mode 100644 src/app/components/Validators/DeferredValidatorLink.tsx create mode 100644 src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx create mode 100644 src/app/pages/ProposalDetailsPage/hooks.ts create mode 100644 src/types/vote.ts diff --git a/.changelog/1356.feature.md b/.changelog/1356.feature.md new file mode 100644 index 000000000..5484abecc --- /dev/null +++ b/.changelog/1356.feature.md @@ -0,0 +1 @@ +Display the votes on the network proposal details page diff --git a/src/app/components/Proposals/ProposalVoteIndicator.tsx b/src/app/components/Proposals/ProposalVoteIndicator.tsx new file mode 100644 index 000000000..22642cb09 --- /dev/null +++ b/src/app/components/Proposals/ProposalVoteIndicator.tsx @@ -0,0 +1,77 @@ +import { FC } from 'react' +import { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' +import Box from '@mui/material/Box' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' +import RemoveCircleIcon from '@mui/icons-material/RemoveCircle' +import { styled } from '@mui/material/styles' +import { COLORS } from '../../../styles/theme/colors' +import { ProposalVoteValue } from '../../../types/vote' + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 3, + flex: 1, + borderRadius: 10, + padding: theme.spacing(2, 2, 2, '10px'), + fontSize: '12px', + minWidth: '85px', +})) + +const StyledIcon = styled(Box)({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '18px', +}) + +const getStatuses = (t: TFunction) => ({ + [ProposalVoteValue.abstain]: { + backgroundColor: COLORS.lightSilver, + icon: RemoveCircleIcon, + iconColor: COLORS.grayMedium, + label: t('networkProposal.vote.abstain'), + textColor: COLORS.grayExtraDark, + }, + [ProposalVoteValue.yes]: { + backgroundColor: COLORS.honeydew, + icon: CheckCircleIcon, + iconColor: COLORS.eucalyptus, + label: t('networkProposal.vote.yes'), + textColor: COLORS.grayExtraDark, + }, + [ProposalVoteValue.no]: { + backgroundColor: COLORS.linen, + icon: CancelIcon, + iconColor: COLORS.errorIndicatorBackground, + label: t('networkProposal.vote.no'), + textColor: COLORS.grayExtraDark, + }, +}) + +type ProposalVoteIndicatorProps = { + vote: ProposalVoteValue +} + +export const ProposalVoteIndicator: FC = ({ vote }) => { + const { t } = useTranslation() + + if (!ProposalVoteValue[vote]) { + return null + } + + const statusConfig = getStatuses(t)[vote] + const IconComponent = statusConfig.icon + + return ( + + {statusConfig.label} + + + + + ) +} diff --git a/src/app/components/Proposals/VoteTypePills.tsx b/src/app/components/Proposals/VoteTypePills.tsx new file mode 100644 index 000000000..2f7c30600 --- /dev/null +++ b/src/app/components/Proposals/VoteTypePills.tsx @@ -0,0 +1,64 @@ +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import Typography from '@mui/material/Typography' +import { COLORS } from '../../../styles/theme/colors' +import { ProposalVoteValue, VoteType } from '../../../types/vote' + +type VoteTypePillsProps = { + handleChange: (voteType: VoteType) => void + value?: VoteType +} + +export const VoteTypePills: FC = ({ handleChange, value }) => { + const { t } = useTranslation() + const options: { label: string; value: VoteType }[] = [ + { + label: t('networkProposal.vote.all'), + value: 'any', + }, + { + label: t('networkProposal.vote.yes'), + value: ProposalVoteValue.yes, + }, + { + label: t('networkProposal.vote.abstain'), + value: ProposalVoteValue.abstain, + }, + { + label: t('networkProposal.vote.no'), + value: ProposalVoteValue.no, + }, + ] + + return ( + <> + {options.map(option => { + const selected = option.value === value + return ( + handleChange(option.value)} + clickable + color="secondary" + label={ + + + {option.label} + + + } + sx={{ + mr: 3, + borderColor: COLORS.brandMedium, + backgroundColor: selected ? COLORS.brandMedium : COLORS.brandMedium15, + color: selected ? COLORS.white : COLORS.grayExtraDark, + }} + variant={selected ? 'outlined-selected' : 'outlined'} + /> + ) + })} + + ) +} diff --git a/src/app/components/Validators/DeferredValidatorLink.tsx b/src/app/components/Validators/DeferredValidatorLink.tsx new file mode 100644 index 000000000..e703a6b53 --- /dev/null +++ b/src/app/components/Validators/DeferredValidatorLink.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react' +import { Network } from '../../../types/network' +import { Layer, Validator } from '../../../oasis-nexus/api' +import { SearchScope } from '../../../types/searchScope' +import { ValidatorLink } from './ValidatorLink' + +export const DeferredValidatorLink: FC<{ + network: Network + address: string + validator: Validator | undefined + isError: boolean +}> = ({ network, address, validator, isError }) => { + const scope: SearchScope = { network, layer: Layer.consensus } + + if (isError) { + console.log('Warning: failed to look up validators!') + } + + return +} diff --git a/src/app/components/Validators/ValidatorImage.tsx b/src/app/components/Validators/ValidatorImage.tsx index 2dc8eb09f..5aaff806d 100644 --- a/src/app/components/Validators/ValidatorImage.tsx +++ b/src/app/components/Validators/ValidatorImage.tsx @@ -4,6 +4,8 @@ import ImageNotSupportedIcon from '@mui/icons-material/ImageNotSupported' import { hasValidProtocol } from '../../utils/url' import { COLORS } from 'styles/theme/colors' import { Circle } from '../Circle' +import { HighlightedText } from '../HighlightedText' +import Box from '@mui/material/Box' const StyledImage = styled('img')({ width: '28px', @@ -15,9 +17,10 @@ type ValidatorImageProps = { address: string name: string | undefined logotype: string | undefined + highlightedPart?: string | undefined } -export const ValidatorImage: FC = ({ address, name, logotype }) => { +export const ValidatorImage: FC = ({ address, name, logotype, highlightedPart }) => { return ( <> {logotype && hasValidProtocol(logotype) ? ( @@ -27,6 +30,13 @@ export const ValidatorImage: FC = ({ address, name, logotyp )} + {name ? ( + + + + ) : ( + address + )}{' '} ) } diff --git a/src/app/components/Validators/ValidatorLink.tsx b/src/app/components/Validators/ValidatorLink.tsx index afdb9b3f1..f2b3c0102 100644 --- a/src/app/components/Validators/ValidatorLink.tsx +++ b/src/app/components/Validators/ValidatorLink.tsx @@ -61,7 +61,7 @@ const DesktopValidatorLink: FC = ({ address, name, to } return ( - {name || address} + {name ?? address} ) } diff --git a/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx new file mode 100644 index 000000000..0437fd965 --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx @@ -0,0 +1,111 @@ +import { FC } from 'react' +import { useParams } from 'react-router-dom' +import { useRequiredScopeParam } from '../../hooks/useScopeParam' +import { SubPageCard } from '../../components/SubPageCard' +import { TablePaginationProps } from '../../components/Table/TablePagination' +import { useTranslation } from 'react-i18next' +import { Table, TableCellAlign, TableColProps } from '../../components/Table' +import { ExtendedVote, ProposalVoteValue } from '../../../types/vote' +import { PAGE_SIZE, useAllVotes, useDisplayedVotes, useWantedVoteType } from './hooks' +import { ProposalVoteIndicator } from '../../components/Proposals/ProposalVoteIndicator' +import { DeferredValidatorLink } from '../../components/Validators/DeferredValidatorLink' +import { CardHeaderWithResponsiveActions } from '../../components/CardHeaderWithResponsiveActions' +import { VoteTypePills } from '../../components/Proposals/VoteTypePills' +import { AppErrors } from '../../../types/errors' +import { ErrorBoundary } from '../../components/ErrorBoundary' + +type ProposalVotesProps = { + isLoading: boolean + votes: ExtendedVote[] | undefined + limit: number + pagination: TablePaginationProps +} + +const ProposalVotes: FC = ({ isLoading, votes, limit, pagination }) => { + const { t } = useTranslation() + const scope = useRequiredScopeParam() + + const tableColumns: TableColProps[] = [ + { key: 'index', content: <>, width: '50px' }, + { key: 'voter', content: t('common.voter'), align: TableCellAlign.Left }, + { key: 'vote', content: t('common.vote'), align: TableCellAlign.Right }, + ] + const tableRows = votes?.map(vote => { + return { + key: `vote-${vote.index}`, + data: [ + { + key: 'index', + content: `#${vote.index}`, + }, + { + key: 'voter', + content: ( + + ), + }, + { + key: 'vote', + content: , + align: TableCellAlign.Right, + }, + ], + } + }) + return ( + + ) +} + +export const ProposalVotesView: FC = () => { + const { network } = useRequiredScopeParam() + const proposalId = parseInt(useParams().proposalId!, 10) + + const { isLoading } = useAllVotes(network, proposalId) + const displayedVotes = useDisplayedVotes(network, proposalId) + + if (!isLoading && displayedVotes.tablePaginationProps.selectedPage > 1 && !displayedVotes.data?.length) { + throw AppErrors.PageDoesNotExist + } + + return ( + + ) +} + +export const ProposalVotesCard: FC = () => { + const { t } = useTranslation() + + const { wantedVoteType, setWantedVoteType } = useWantedVoteType() + + return ( + + } + disableTypography + component="h3" + title={t('common.votes')} + /> + + + + + ) +} diff --git a/src/app/pages/ProposalDetailsPage/hooks.ts b/src/app/pages/ProposalDetailsPage/hooks.ts new file mode 100644 index 000000000..87b1c33a5 --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/hooks.ts @@ -0,0 +1,154 @@ +import { + List, + useGetConsensusProposalsProposalIdVotes, + useGetConsensusValidators, +} from '../../../oasis-nexus/api' +import { Network } from '../../../types/network' +import { + ExtendedVote, + getFilterForVoteType, + getRandomVote, + ProposalVoteValue, + VoteFilter, + VoteType, +} from '../../../types/vote' +import { useClientSidePagination } from '../../components/Table/useClientSidePagination' +import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' +import { useStringInUrl } from '../../hooks/useStringInUrl' + +export type AllVotesData = List & { + isLoading: boolean + isError: boolean + loadedVotes: ExtendedVote[] +} + +const DEBUG_MODE = true // TODO disable debug mode before merging + +const voteTable: Record = {} + +const getRandomVoteFor = (address: string) => { + const storedVote = voteTable[address] + if (storedVote) return storedVote + const newVote = getRandomVote() + voteTable[address] = newVote + return newVote +} + +const useValidatorMap = (network: Network) => { + const { data, isLoading, isError } = useGetConsensusValidators(network) + return { + isLoading, + isError, + map: (data as any)?.data.map ?? new Map(), + } +} + +export const useAllVotes = (network: Network, proposalId: number): AllVotesData => { + const query = useGetConsensusProposalsProposalIdVotes(network, proposalId) + const { + map: validators, + isLoading: areValidatorsLoading, + isError: haveValidatorsFailed, + } = useValidatorMap(network) + const { isLoading, isError, data } = query + + const extendedVotes = (data?.data.votes || []).map( + (vote, index): ExtendedVote => ({ + ...vote, + index: index + 1, + areValidatorsLoading, + haveValidatorsFailed, + validator: validators.get(vote.address), + }), + ) + + return { + isLoading, + isError, + loadedVotes: DEBUG_MODE + ? extendedVotes.map(v => ({ ...v, vote: getRandomVoteFor(v.address) })) || [] + : extendedVotes, + total_count: data?.data.total_count ?? 0, + is_total_count_clipped: data?.data.is_total_count_clipped ?? false, + } +} + +export type VoteStats = { + isLoading: boolean + isError: boolean + + /** + * Did we manage to get all data from the server? + */ + isComplete: boolean + + /** + * The results of counting the votes + */ + tally: Record + + /** + * The total number of (valid) votes + */ + allVotesCount: number + + /** + * The total amount of valid votes (considering the shares) + */ + allVotesPower: bigint +} + +export const useVoteStats = (network: Network, proposalId: number): VoteStats => { + const { isLoading, isError, loadedVotes, total_count, is_total_count_clipped } = useAllVotes( + network, + proposalId, + ) + const tally: Record = { + yes: 0n, + no: 0n, + abstain: 0n, + } + // TODO: instead of 1n, we should add the power of the vote, but that data is not available yet. + loadedVotes.forEach(vote => (tally[vote.vote as ProposalVoteValue] += 1n)) + const allVotesCount = loadedVotes.length + const allVotesPower = tally.yes + tally.no + tally.abstain + const isComplete = !isError && loadedVotes.length === total_count && !is_total_count_clipped + return { + isLoading, + isError, + isComplete, + tally, + allVotesCount, + allVotesPower, + } +} + +export const useWantedVoteType = () => { + const { value, setValue } = useStringInUrl('vote', 'any') + + return { + wantedVoteType: value as VoteType, + setWantedVoteType: (newType: VoteType) => setValue(newType, { deleteParams: ['page'] }), + } +} + +const useWantedVoteFilter = (): VoteFilter => getFilterForVoteType(useWantedVoteType().wantedVoteType) + +export const PAGE_SIZE = NUMBER_OF_ITEMS_ON_SEPARATE_PAGE + +export const useDisplayedVotes = (network: Network, proposalId: number) => { + const filter = useWantedVoteFilter() + + const pagination = useClientSidePagination({ + paramName: 'page', + clientPageSize: PAGE_SIZE, + serverPageSize: 1000, + filter, + }) + + // Get all the votes + const allVotes = useAllVotes(network, proposalId) + + // Get the section of the votes that we should display in the table + return pagination.getResults(allVotes) +} diff --git a/src/app/pages/ProposalDetailsPage/index.tsx b/src/app/pages/ProposalDetailsPage/index.tsx index cc0ae6667..559958b88 100644 --- a/src/app/pages/ProposalDetailsPage/index.tsx +++ b/src/app/pages/ProposalDetailsPage/index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' import Tooltip from '@mui/material/Tooltip' import InfoIcon from '@mui/icons-material/Info' +import CancelIcon from '@mui/icons-material/Cancel' import { useRequiredScopeParam } from '../../hooks/useScopeParam' import { Layer, Proposal, useGetConsensusProposalsProposalId } from '../../../oasis-nexus/api' import { AppErrors } from '../../../types/errors' @@ -19,6 +20,9 @@ import { AccountLink } from '../../components/Account/AccountLink' import { HighlightedText } from '../../components/HighlightedText' import { ProposalIdLoaderData } from '../../utils/route-utils' import { COLORS } from 'styles/theme/colors' +import { ProposalVotesCard } from './ProposalVotesCard' +import { useVoteStats } from './hooks' +import Skeleton from '@mui/material/Skeleton' import { getTypeNameForProposal } from '../../../types/proposalType' export const ProposalDetailsPage: FC = () => { @@ -29,6 +33,11 @@ export const ProposalDetailsPage: FC = () => { } const { proposalId, searchTerm } = useLoaderData() as ProposalIdLoaderData + const { + isLoading: areStatsLoading, + allVotesCount, + isComplete: areStatsComplete, + } = useVoteStats(scope.network, proposalId) const { isLoading, data } = useGetConsensusProposalsProposalId(scope.network, proposalId) if (!data?.data && !isLoading) { throw AppErrors.NotFoundProposalId @@ -37,19 +46,48 @@ export const ProposalDetailsPage: FC = () => { return ( - + + ) } +const VoteLoadingProblemIndicator: FC = () => { + const { t } = useTranslation() + return ( + + + + ) +} + export const ProposalDetailView: FC<{ proposal: Proposal highlightedPart?: string isLoading?: boolean + totalVotesLoading?: boolean + totalVotesProblematic?: boolean + totalVotes?: number | undefined showLayer?: boolean standalone?: boolean -}> = ({ proposal, highlightedPart, isLoading, showLayer = false, standalone = false }) => { +}> = ({ + proposal, + isLoading, + totalVotesLoading, + totalVotesProblematic, + totalVotes, + showLayer = false, + standalone = false, + highlightedPart, +}) => { const { t } = useTranslation() const { isMobile } = useScreenSize() if (isLoading) return @@ -83,9 +121,26 @@ export const ProposalDetailView: FC<{ - {/*Not enough data*/} - {/*
{t('common.totalVotes')}
*/} - {/*
{proposal.invalid_votes}
*/} + {(totalVotes || totalVotesLoading || totalVotesProblematic) && ( + <> +
{t('common.totalVotes')}
+
+ {totalVotesLoading ? ( + + ) : ( + totalVotes?.toLocaleString() + )} + {totalVotesProblematic && } +
+ + )} + + {proposal.invalid_votes !== '0' && ( + <> +
{t('common.invalidVotes')}
+
{proposal.invalid_votes}
+ + )}
{t('common.status')}
diff --git a/src/app/pages/TokenDashboardPage/hook.ts b/src/app/pages/TokenDashboardPage/hook.ts index b341629d6..bf0dbd726 100644 --- a/src/app/pages/TokenDashboardPage/hook.ts +++ b/src/app/pages/TokenDashboardPage/hook.ts @@ -72,7 +72,8 @@ export const _useTokenTransfers = (scope: SearchScope, params: undefined | GetRu network, layer, // This is OK since consensus has been handled separately { - ...pagination.paramsForQuery, + offset: pagination.offsetForQuery, + limit: pagination.limitForQuery, type: RuntimeEventType.evmlog, // The following is the hex-encoded signature for Transfer(address,address,uint256) evm_log_signature: 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 26a1a32e2..f88454435 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -88,6 +88,7 @@ "hash": "Hash", "height": "Height", "hide": "Hide", + "invalidVotes": "Invalid votes", "loadMore": "Load more", "lessThanAmount": "< {{value, number}} ", "method": "Method", @@ -140,6 +141,9 @@ "valueInTokenWithLink": "{{value, number}} ", "view": "View", "viewAll": "View all", + "vote": "Vote", + "voter": "Voter", + "votes": "Votes", "paraTimeOnline": "ParaTime Online", "paraTimeOutOfDate": "ParaTime Out of date", "mainnet": "Mainnet", @@ -182,6 +186,7 @@ "create": "Created", "createTooltip": "Voting created on epoch shown.", "deposit": "Deposit", + "failedToLoadAllVotes": "Failed to load all the votes, number might be incomplete!", "handler": "Title", "id": "ID", "listTitle": "Network Change Proposals", @@ -195,7 +200,14 @@ "upgrade": "Upgrade", "parameterUpgrade": "Parameter upgrade", "cancellation": "Cancellation" - } + }, + "vote": { + "yes": "Yes", + "no": "No", + "abstain": "Abstained", + "all": "All votes" + }, + "searchForVoters": "Search for voters" }, "nft": { "accountCollection": "ERC-721 Tokens", diff --git a/src/types/vote.ts b/src/types/vote.ts new file mode 100644 index 000000000..fa8eadb7e --- /dev/null +++ b/src/types/vote.ts @@ -0,0 +1,38 @@ +import { ProposalVote, Validator } from '../oasis-nexus/api' + +export type ProposalVoteValue = (typeof ProposalVoteValue)[keyof typeof ProposalVoteValue] + +/** + * Valid vote types for network proposals + * + * Based on https://github.com/oasisprotocol/nexus/blob/main/storage/oasis/nodeapi/api.go#L199 + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ProposalVoteValue = { + yes: 'yes', + no: 'no', + abstain: 'abstain', +} as const + +export const getRandomVote = (): ProposalVoteValue => + [ProposalVoteValue.yes, ProposalVoteValue.no, ProposalVoteValue.abstain][Math.floor(Math.random() * 3)] + +export type VoteType = ProposalVoteValue | 'any' + +export type ExtendedVote = ProposalVote & { + index: number + areValidatorsLoading: boolean + haveValidatorsFailed: boolean + validator?: Validator +} + +export type VoteFilter = (vote: ExtendedVote) => boolean + +const voteFilters: Record = { + any: () => true, + yes: vote => vote.vote === ProposalVoteValue.yes, + no: vote => vote.vote === ProposalVoteValue.no, + abstain: vote => vote.vote === ProposalVoteValue.abstain, +} + +export const getFilterForVoteType = (voteType: VoteType): VoteFilter => voteFilters[voteType]