From a94c69672f375b24cd80e11e15d277ae98de8104 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/1214.feature.md | 1 + .../Proposals/ProposalVoteIndicator.tsx | 77 ++++++++ .../components/Proposals/VoteTypePills.tsx | 64 +++++++ .../components/Proposals/VoterSearchBar.tsx | 70 +++++++ src/app/components/Table/PaginationEngine.ts | 35 +++- .../Table/useClientSidePagination.ts | 52 ++++-- .../useComprehensiveSearchParamsPagination.ts | 6 +- .../Validators/DeferredValidatorLink.tsx | 39 ++++ .../components/Validators/ValidatorImage.tsx | 12 +- .../ProposalDetailsPage/ProposalVotesCard.tsx | 149 +++++++++++++++ src/app/pages/ProposalDetailsPage/hooks.ts | 172 ++++++++++++++++++ src/app/pages/ProposalDetailsPage/index.tsx | 65 ++++++- src/app/pages/TokenDashboardPage/hook.ts | 4 +- src/locales/en/translation.json | 14 +- src/types/vote.ts | 38 ++++ 15 files changed, 766 insertions(+), 32 deletions(-) create mode 100644 .changelog/1214.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/Proposals/VoterSearchBar.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/1214.feature.md b/.changelog/1214.feature.md new file mode 100644 index 0000000000..5484abecc7 --- /dev/null +++ b/.changelog/1214.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 0000000000..22642cb09b --- /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 0000000000..2f7c306004 --- /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/Proposals/VoterSearchBar.tsx b/src/app/components/Proposals/VoterSearchBar.tsx new file mode 100644 index 0000000000..7b1781a3d4 --- /dev/null +++ b/src/app/components/Proposals/VoterSearchBar.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react' +import TextField from '@mui/material/TextField' +import SearchIcon from '@mui/icons-material/Search' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import InputAdornment from '@mui/material/InputAdornment' +import { COLORS } from '../../../styles/theme/colors' +import { SearchVariant } from '../Search' +import { useTranslation } from 'react-i18next' +import IconButton from '@mui/material/IconButton' + +export interface SearchBarProps { + variant: SearchVariant + value: string + onChange: (value: string) => void +} + +export const VoterSearchBar: FC = ({ variant, value, onChange }) => { + const { t } = useTranslation() + const startAdornment = variant === 'button' && ( + + + + ) + + const onClearValue = () => onChange('') + + const endAdornment = ( + + <> + {value && ( + + + + )} + + + ) + + return ( + <> + onChange(e.target.value)} + InputProps={{ + inputProps: { + sx: { + p: 0, + marginRight: 2, + }, + }, + startAdornment, + endAdornment, + }} + placeholder={t('networkProposal.searchForVoters')} + // FormHelperTextProps={{ + // component: 'div', // replace p with div tag + // sx: { + // marginTop: 0, + // marginBottom: 0, + // marginLeft: variant === 'button' ? '48px' : '17px', + // marginRight: variant === 'button' ? '48px' : '17px', + // }, + // }} + /> + + ) +} diff --git a/src/app/components/Table/PaginationEngine.ts b/src/app/components/Table/PaginationEngine.ts index c2787b33b5..257c8ea535 100644 --- a/src/app/components/Table/PaginationEngine.ts +++ b/src/app/components/Table/PaginationEngine.ts @@ -7,15 +7,42 @@ export interface SimplePaginationEngine { linkToPage: (pageNumber: number) => To } +/** + * The data returned by a comprehensive pagination engine to the data consumer component + */ export interface PaginatedResults { + /** + * Control interface that can be plugged to a Table's `pagination` prop + */ tablePaginationProps: TablePaginationProps + + /** + * The data provided to the data consumer in the current window + */ data: Item[] | undefined } +/** + * A Comprehensive PaginationEngine sits between the server and the consumer of the data and does transformations + * + * Specifically, the interface for loading the data and the one for the data consumers are decoupled. + */ export interface ComprehensivePaginationEngine { - selectedPage: number - offsetForQuery: number - limitForQuery: number - paramsForQuery: { offset: number; limit: number } + /** + * The currently selected page from the data consumer's POV + */ + selectedPageForClient: number + + /** + * Parameters for data to be loaded from the server + */ + paramsForServer: { offset: number; limit: number } + + /** + * Get the current data/state info for the data consumer component. + * + * @param queryResult the data coming in the server, requested according to this engine's specs, including metadata + * @param key The field where the actual records can be found within queryResults + */ getResults: (queryResult: QueryResult | undefined, key?: keyof QueryResult) => PaginatedResults } diff --git a/src/app/components/Table/useClientSidePagination.ts b/src/app/components/Table/useClientSidePagination.ts index 8337a82932..62b7273303 100644 --- a/src/app/components/Table/useClientSidePagination.ts +++ b/src/app/components/Table/useClientSidePagination.ts @@ -5,9 +5,27 @@ import { List } from '../../../oasis-nexus/api' import { TablePaginationProps } from './TablePagination' type ClientSizePaginationParams = { + /** + * How should we call the query parameter (in the URL)? + */ paramName: string + + /** + * The pagination page size from the POV of the data consumer component + */ clientPageSize: number + + /** + * The pagination page size used for actually loading the data from the server. + * + * Please note that currently this engine doesn't handle when the data consumer requires data which is not + * part of the initial window on the server side. + */ serverPageSize: number + + /** + * Filtering to be applied after loading the data from the server, before presenting it to the data consumer component + */ filter?: (item: Item) => boolean } @@ -27,13 +45,15 @@ function findListIn(data: T): Item[] { } } +/** + * The ClientSidePagination engine loads the data from the server with a big window in one go, for in-memory pagination + */ export function useClientSidePagination({ paramName, clientPageSize, serverPageSize, filter, }: ClientSizePaginationParams): ComprehensivePaginationEngine { - const selectedServerPage = 1 const [searchParams] = useSearchParams() const selectedClientPageString = searchParams.get(paramName) const selectedClientPage = parseInt(selectedClientPageString ?? '1', 10) @@ -57,30 +77,35 @@ export function useClientSidePagination({ return { search: newSearchParams.toString() } } - const limit = serverPageSize - const offset = (selectedServerPage - 1) * clientPageSize + // From the server, we always want to load the first batch of data, with the provided (big) window. + // In theory, we could move this window as required, but currently this is not implemented. + const selectedServerPage = 1 + + // The query parameters that should be used for loading the data from the server const paramsForQuery = { - offset, - limit, + offset: (selectedServerPage - 1) * serverPageSize, + limit: serverPageSize, } return { - selectedPage: selectedClientPage, - offsetForQuery: offset, - limitForQuery: limit, - paramsForQuery, + selectedPageForClient: selectedClientPage, + paramsForServer: paramsForQuery, getResults: (queryResult, key) => { - const data = queryResult - ? key - ? (queryResult[key] as Item[]) - : findListIn(queryResult) + const data = queryResult // we want to get list of items out from the incoming results + ? key // do we know where (in which field) to look? + ? (queryResult[key] as Item[]) // If yes, just get out the data + : findListIn(queryResult) // If no, we will try to guess : undefined + + // Apply the specified client-side filtering const filteredData = !!data && !!filter ? data.filter(filter) : data + // The data window from the POV of the data consumer component const offset = (selectedClientPage - 1) * clientPageSize const limit = clientPageSize const dataWindow = filteredData ? filteredData.slice(offset, offset + limit) : undefined + // The control interface for the data consumer component (i.e. Table) const tableProps: TablePaginationProps = { selectedPage: selectedClientPage, linkToPage, @@ -98,6 +123,5 @@ export function useClientSidePagination({ data: dataWindow, } }, - // tableProps, } } diff --git a/src/app/components/Table/useComprehensiveSearchParamsPagination.ts b/src/app/components/Table/useComprehensiveSearchParamsPagination.ts index 1ad4006a40..0d419164ef 100644 --- a/src/app/components/Table/useComprehensiveSearchParamsPagination.ts +++ b/src/app/components/Table/useComprehensiveSearchParamsPagination.ts @@ -64,10 +64,8 @@ export function useComprehensiveSearchParamsPagination { const data = queryResult ? key diff --git a/src/app/components/Validators/DeferredValidatorLink.tsx b/src/app/components/Validators/DeferredValidatorLink.tsx new file mode 100644 index 0000000000..e354a3f839 --- /dev/null +++ b/src/app/components/Validators/DeferredValidatorLink.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react' +import { AccountLink } from '../Account/AccountLink' +import { Network } from '../../../types/network' +import { Layer, Validator } from '../../../oasis-nexus/api' +import Skeleton from '@mui/material/Skeleton' +import { SearchScope } from '../../../types/searchScope' +import Box from '@mui/material/Box' +import { ValidatorImage } from './ValidatorImage' + +export const DeferredValidatorLink: FC<{ + network: Network + address: string + validator: Validator | undefined + isLoading: boolean + isError: boolean + highlightedPart?: string | undefined +}> = ({ network, address, validator, isError, isLoading, highlightedPart }) => { + const scope: SearchScope = { network, layer: Layer.consensus } + + if (isError) { + console.log('Warning: failed to look up validators!') + } + + const validatorImage = ( + + ) + // TODO: switch to ValidatorLink, when validator detail pages become available + return ( + + + {isLoading && } + + ) +} diff --git a/src/app/components/Validators/ValidatorImage.tsx b/src/app/components/Validators/ValidatorImage.tsx index 769c2d3967..114d8e4392 100644 --- a/src/app/components/Validators/ValidatorImage.tsx +++ b/src/app/components/Validators/ValidatorImage.tsx @@ -5,6 +5,7 @@ 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' const StyledImage = styled('img')({ width: '28px', @@ -16,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) ? ( @@ -28,7 +30,13 @@ export const ValidatorImage: FC = ({ address, name, logotyp )} - {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..6f6f38cda9 --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx @@ -0,0 +1,149 @@ +import { FC } from 'react' +import { useParams } from 'react-router-dom' +import { useRequiredScopeParam } from '../../hooks/useScopeParam' +import { SubPageCard } from '../../components/SubPageCard' +import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' +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 { useClientSidePagination } from '../../components/Table/useClientSidePagination' +import { + AllVotesData, + useAllVotes, + useVoterSearch, + useVoterSearchPattern, + useWantedVoteFilter, + 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' +import { VoterSearchBar } from '../../components/Proposals/VoterSearchBar' + +const PAGE_SIZE = NUMBER_OF_ITEMS_ON_SEPARATE_PAGE + +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 voterNameFragment = useVoterSearchPattern() + + 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 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 + const displayedVotes = pagination.getResults(allVotes) + + if ( + !allVotes.isLoading && + displayedVotes.tablePaginationProps.selectedPage > 1 && + !displayedVotes.data?.length + ) { + throw AppErrors.PageDoesNotExist + } + + return ( + + ) +} + +export const ProposalVotesCard: FC = () => { + const { t } = useTranslation() + + const { wantedVoteType, setWantedVoteType } = useWantedVoteType() + const { voterSearchInput, setVoterSearchPattern } = useVoterSearch() + + 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..8481a6ea4b --- /dev/null +++ b/src/app/pages/ProposalDetailsPage/hooks.ts @@ -0,0 +1,172 @@ +import { + List, + useGetConsensusProposalsProposalIdVotes, + useGetConsensusValidators, +} from '../../../oasis-nexus/api' +import { Network } from '../../../types/network' +import { + ExtendedVote, + getFilterForVoteType, + getRandomVote, + ProposalVoteValue, + VoteFilter, + VoteType, +} from '../../../types/vote' +import { useSearchParams } from 'react-router-dom' + +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, + } +} + +const TYPE_PARAM = 'vote' +export const useWantedVoteType = () => { + const [searchParams, setSearchParams] = useSearchParams() + const wantedVoteType: VoteType = (searchParams.get(TYPE_PARAM) as VoteType) ?? 'any' + const setWantedVoteType = (newType: VoteType) => { + if (newType === 'any') { + searchParams.delete(TYPE_PARAM) + } else { + searchParams.set(TYPE_PARAM, newType) + } + searchParams.delete('page') + setSearchParams(searchParams, { preventScrollReset: true }) + } + return { wantedVoteType, setWantedVoteType } +} + +const SEARCH_PARAM = 'voter' + +export const useVoterSearch = () => { + const [searchParams, setSearchParams] = useSearchParams() + const voterSearchInput = searchParams.get(SEARCH_PARAM) ?? '' + const setVoterSearchPattern = (pattern: string) => { + if (pattern === '') { + searchParams.delete(SEARCH_PARAM) + } else { + searchParams.set(SEARCH_PARAM, pattern) + } + searchParams.delete('page') + setSearchParams(searchParams, { preventScrollReset: true }) + } + return { voterSearchInput, setVoterSearchPattern } +} + +export const useVoterSearchPattern = () => { + const { voterSearchInput } = useVoterSearch() + return voterSearchInput.length < 3 ? undefined : voterSearchInput +} + +export const useWantedVoteFilter = (): VoteFilter => { + const { wantedVoteType } = useWantedVoteType() + const voterSearchPattern = useVoterSearchPattern() + const typeFilter = getFilterForVoteType(wantedVoteType) + if (!voterSearchPattern) { + return typeFilter + } else { + return (vote: ExtendedVote) => + typeFilter(vote) && !!vote.validator?.media?.name?.toLowerCase().includes(voterSearchPattern) + } +} diff --git a/src/app/pages/ProposalDetailsPage/index.tsx b/src/app/pages/ProposalDetailsPage/index.tsx index 1e7c602ff8..496cd86101 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 b341629d60..c372eec4b9 100644 --- a/src/app/pages/TokenDashboardPage/hook.ts +++ b/src/app/pages/TokenDashboardPage/hook.ts @@ -72,7 +72,7 @@ export const _useTokenTransfers = (scope: SearchScope, params: undefined | GetRu network, layer, // This is OK since consensus has been handled separately { - ...pagination.paramsForQuery, + ...pagination.paramsForServer, type: RuntimeEventType.evmlog, // The following is the hex-encoded signature for Transfer(address,address,uint256) evm_log_signature: 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', @@ -89,7 +89,7 @@ export const _useTokenTransfers = (scope: SearchScope, params: undefined | GetRu const results = pagination.getResults(data?.data) - if (isFetched && pagination.selectedPage > 1 && !results.data?.length) { + if (isFetched && pagination.selectedPageForClient > 1 && !results.data?.length) { throw AppErrors.PageDoesNotExist } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 405ad70757..38e0aa3674 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -74,6 +74,7 @@ "hash": "Hash", "height": "Height", "hide": "Hide", + "invalidVotes": "Invalid votes", "loadMore": "Load more", "lessThanAmount": "< {{value, number}} ", "missing": "n/a", @@ -120,6 +121,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", @@ -158,6 +162,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", @@ -171,7 +176,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 0000000000..fa8eadb7e7 --- /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]