diff --git a/.changelog/1356.feature.md b/.changelog/1356.feature.md new file mode 100644 index 0000000000..5484abecc7 --- /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/ErrorDisplay/index.tsx b/src/app/components/ErrorDisplay/index.tsx index d2a8592993..56163ba52e 100644 --- a/src/app/components/ErrorDisplay/index.tsx +++ b/src/app/components/ErrorDisplay/index.tsx @@ -66,6 +66,7 @@ const errorMap: Record Formatt title: t('errors.error'), message: t('errors.storage'), }), + [AppErrors.InvalidVote]: t => ({ title: t('errors.invalidVote'), message: null }), } export const errorFormatter = (t: TFunction, error: ErrorPayload) => { diff --git a/src/app/components/Proposals/ProposalVoteIndicator.tsx b/src/app/components/Proposals/ProposalVoteIndicator.tsx new file mode 100644 index 0000000000..a6b920dc8b --- /dev/null +++ b/src/app/components/Proposals/ProposalVoteIndicator.tsx @@ -0,0 +1,78 @@ +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' +import { AppErrors } from '../../../types/errors' + +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]) { + throw AppErrors.InvalidVote + } + + const statusConfig = getStatuses(t)[vote] + const IconComponent = statusConfig.icon + + return ( + + {statusConfig.label} + + + + + ) +} diff --git a/src/app/components/Proposals/VoteTypeFilter.tsx b/src/app/components/Proposals/VoteTypeFilter.tsx new file mode 100644 index 0000000000..c2d5508837 --- /dev/null +++ b/src/app/components/Proposals/VoteTypeFilter.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 VoteTypeFilterProps = { + onSelect: (voteType: VoteType) => void + value?: VoteType +} + +export const VoteTypeFilter: FC = ({ onSelect, 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 ( + onSelect(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 0000000000..e703a6b53a --- /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/ValidatorLink.tsx b/src/app/components/Validators/ValidatorLink.tsx index afdb9b3f1f..f2b3c0102d 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 0000000000..7cf5de63f2 --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx @@ -0,0 +1,115 @@ +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 { VoteTypeFilter } from '../../components/Proposals/VoteTypeFilter' +import { AppErrors } from '../../../types/errors' +import { ErrorBoundary } from '../../components/ErrorBoundary' + +type ProposalVotesProps = { + isLoading: boolean + votes: ExtendedVote[] | undefined + rowsNumber: number + pagination: TablePaginationProps +} + +const ProposalVotes: FC = ({ isLoading, votes, rowsNumber, 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 + 1}`, + }, + { + 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 0000000000..d60119cc4c --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/hooks.ts @@ -0,0 +1,143 @@ +import { + Validator, + ExtendedValidatorList, + List, + useGetConsensusProposalsProposalIdVotes, + useGetConsensusValidators, +} from '../../../oasis-nexus/api' +import { Network } from '../../../types/network' +import { ExtendedVote, ProposalVoteValue, VoteFilter, VoteType } from '../../../types/vote' +import { getFilterForVoteType, getRandomVote } from '../../utils/vote' +import { useClientSidePagination } from '../../components/Table/useClientSidePagination' +import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' +import { useTypedSearchParam } from '../../hooks/useTypedSearchParam' + +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?.data as ExtendedValidatorList)?.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, + 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, // We need fallbacks here so that we always satisfy the List interface + 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, 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 && !is_total_count_clipped + return { + isLoading, + isError, + isComplete, + tally, + allVotesCount, + allVotesPower, + } +} + +export const useWantedVoteType = () => + useTypedSearchParam('vote', 'any', { + deleteParams: ['page'], + }) + +const useWantedVoteFilter = (): VoteFilter => getFilterForVoteType(useWantedVoteType()[0]) + +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 cc0ae66672..559958b886 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/utils/vote.ts b/src/app/utils/vote.ts new file mode 100644 index 0000000000..03e903d2cc --- /dev/null +++ b/src/app/utils/vote.ts @@ -0,0 +1,13 @@ +import { ProposalVoteValue, VoteFilter, VoteType } from '../../types/vote' + +export const getRandomVote = (): ProposalVoteValue => + [ProposalVoteValue.yes, ProposalVoteValue.no, ProposalVoteValue.abstain][Math.floor(Math.random() * 3)] + +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] diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 96edfe37ea..03be0ad937 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -89,6 +89,7 @@ "hash": "Hash", "height": "Height", "hide": "Hide", + "invalidVotes": "Invalid votes", "loadMore": "Load more", "lessThanAmount": "< {{value, number}} ", "method": "Method", @@ -141,6 +142,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", @@ -183,6 +187,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", @@ -196,6 +201,12 @@ "upgrade": "Upgrade", "parameterUpgrade": "Parameter upgrade", "cancellation": "Cancellation" + }, + "vote": { + "yes": "Yes", + "no": "No", + "abstain": "Abstained", + "all": "All votes" } }, "nft": { @@ -240,6 +251,7 @@ "validateURL": "Please validate provided URL", "validateURLOrGoToFirstPage": "Please check the URL or .", "invalidUrl": "Invalid URL", + "invalidVote": "Invalid vote", "revertedWithoutMessage": "reverted without a message", "storage": "Access to browser storage denied" }, diff --git a/src/types/errors.ts b/src/types/errors.ts index fda487b1d6..4b7a82626b 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -22,6 +22,7 @@ export enum AppErrors { NotFoundTxHash = 'not_found_tx_hash', NotFoundProposalId = 'not_found_proposal_id', InvalidUrl = 'invalid_url', + InvalidVote = 'invalid_vote', Storage = 'storage', } diff --git a/src/types/vote.ts b/src/types/vote.ts new file mode 100644 index 0000000000..0e4e49ea15 --- /dev/null +++ b/src/types/vote.ts @@ -0,0 +1,26 @@ +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 type VoteType = ProposalVoteValue | 'any' + +export type ExtendedVote = ProposalVote & { + index: number + areValidatorsLoading: boolean + haveValidatorsFailed: boolean + validator?: Validator +} + +export type VoteFilter = (vote: ExtendedVote) => boolean