Skip to content

Commit

Permalink
Proposal votes: add search by validator name
Browse files Browse the repository at this point in the history
Also, handle situation when there are 0 votes
  • Loading branch information
csillag committed May 16, 2024
1 parent 3a6660e commit 9dc098c
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 43 deletions.
1 change: 1 addition & 0 deletions .changelog/1357.trivial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Network proposal votes: add search by validator name
4 changes: 2 additions & 2 deletions src/app/components/Proposals/VoteTypeFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const VoteTypeFilter: FC<VoteTypeFilterProps> = ({ onSelect, value }) =>
]

return (
<>
<Box sx={{ display: 'inline-flex' }}>
{options.map(option => {
const selected = option.value === value
return (
Expand All @@ -59,6 +59,6 @@ export const VoteTypeFilter: FC<VoteTypeFilterProps> = ({ onSelect, value }) =>
/>
)
})}
</>
</Box>
)
}
9 changes: 4 additions & 5 deletions src/app/components/Search/TableSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import InputAdornment from '@mui/material/InputAdornment'
import { COLORS } from '../../../styles/theme/colors'
import IconButton from '@mui/material/IconButton'
import { useScreenSize } from '../../hooks/useScreensize'
import Box from '@mui/material/Box'
import WarningIcon from '@mui/icons-material/WarningAmber'
import { typingDelay } from '../../../styles/theme'
import Typography from '@mui/material/Typography'
Expand All @@ -25,16 +24,16 @@ export interface TableSearchBarProps {
export const TableSearchBar: FC<TableSearchBarProps> = ({ value, onChange, placeholder, warning }) => {
const { isTablet } = useScreenSize()

const [isProblemFresh, setIsProblemFresh] = useState(false)
const [isWarningFresh, setIsWarningFresh] = useState(false)

useEffect(() => {
if (warning) {
const timeout = setTimeout(() => {
setIsProblemFresh(false)
setIsWarningFresh(false)
}, typingDelay)
return () => clearTimeout(timeout)
} else {
setIsProblemFresh(true)
setIsWarningFresh(true)
}
}, [warning])

Expand All @@ -61,7 +60,7 @@ export const TableSearchBar: FC<TableSearchBarProps> = ({ value, onChange, place
</InputAdornment>
)

const helperText = isProblemFresh ? undefined : (
const helperText = isWarningFresh ? undefined : (
<Typography
component="span"
sx={{
Expand Down
12 changes: 10 additions & 2 deletions src/app/components/Validators/DeferredValidatorLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ export const DeferredValidatorLink: FC<{
address: string
validator: Validator | undefined
isError: boolean
}> = ({ network, address, validator, isError }) => {
highlightedPart?: string | undefined
}> = ({ network, address, validator, isError, highlightedPart }) => {
const scope: SearchScope = { network, layer: Layer.consensus }

if (isError) {
console.log('Warning: failed to look up validators!')
}

return <ValidatorLink address={address} network={scope.network} name={validator?.media?.name} />
return (
<ValidatorLink
address={address}
network={scope.network}
name={validator?.media?.name}
highlightedPart={highlightedPart}
/>
)
}
40 changes: 31 additions & 9 deletions src/app/components/Validators/ValidatorLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,37 @@ import { RouteUtils } from '../../utils/route-utils'
import Typography from '@mui/material/Typography'
import { COLORS } from '../../../styles/theme/colors'
import { Network } from '../../../types/network'
import { HighlightedText } from '../HighlightedText'

type ValidatorLinkProps = {
address: string
name?: string
network: Network
alwaysTrim?: boolean
highlightedPart?: string
}

export const ValidatorLink: FC<ValidatorLinkProps> = ({ address, name, network, alwaysTrim }) => {
export const ValidatorLink: FC<ValidatorLinkProps> = ({
address,
name,
network,
alwaysTrim,
highlightedPart,
}) => {
const { isTablet } = useScreenSize()
const to = RouteUtils.getValidatorRoute(network, address)
return (
<Typography variant="mono" component="span" sx={{ color: COLORS.brandDark, fontWeight: 700 }}>
{isTablet ? (
<TabletValidatorLink address={address} name={name} to={to} />
<TabletValidatorLink address={address} name={name} to={to} highlightedPart={highlightedPart} />
) : (
<DesktopValidatorLink address={address} alwaysTrim={alwaysTrim} name={name} to={to} />
<DesktopValidatorLink
address={address}
alwaysTrim={alwaysTrim}
name={name}
to={to}
highlightedPart={highlightedPart}
/>
)}
</Typography>
)
Expand All @@ -32,21 +46,23 @@ export const ValidatorLink: FC<ValidatorLinkProps> = ({ address, name, network,
type TrimValidatorEndLinkLabelProps = {
name: string
to: string
highlightedPart?: string
}

const TrimValidatorEndLinkLabel: FC<TrimValidatorEndLinkLabelProps> = ({ name, to }) => (
<TrimEndLinkLabel label={name} to={to} trimStart={14} />
const TrimValidatorEndLinkLabel: FC<TrimValidatorEndLinkLabelProps> = ({ name, to, highlightedPart }) => (
<TrimEndLinkLabel label={name} to={to} trimStart={14} highlightedPart={highlightedPart} />
)

type TabletValidatorLinkProps = {
address: string
name?: string
to: string
highlightedPart?: string
}

const TabletValidatorLink: FC<TabletValidatorLinkProps> = ({ address, name, to }) => {
const TabletValidatorLink: FC<TabletValidatorLinkProps> = ({ address, name, to, highlightedPart }) => {
if (name) {
return <TrimValidatorEndLinkLabel name={name} to={to} />
return <TrimValidatorEndLinkLabel name={name} to={to} highlightedPart={highlightedPart} />
}
return <TrimLinkLabel label={address} to={to} />
}
Expand All @@ -55,13 +71,19 @@ type DesktopValidatorLinkProps = TabletValidatorLinkProps & {
alwaysTrim?: boolean
}

const DesktopValidatorLink: FC<DesktopValidatorLinkProps> = ({ address, name, to, alwaysTrim }) => {
const DesktopValidatorLink: FC<DesktopValidatorLinkProps> = ({
address,
name,
to,
alwaysTrim,
highlightedPart,
}) => {
if (alwaysTrim) {
return <TrimLinkLabel label={address} to={to} />
}
return (
<Link component={RouterLink} to={to}>
{name ?? address}
{name ? <HighlightedText text={name} pattern={highlightedPart} /> : address}
</Link>
)
}
53 changes: 42 additions & 11 deletions src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ 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 { useVotes, useVoteFiltering } 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'
import Box from '@mui/material/Box'
import { NoMatchingDataMaybeClearFilters, TableSearchBar } from '../../components/Search/TableSearchBar'
import { CardEmptyState } from '../../components/CardEmptyState'

type ProposalVotesProps = {
isLoading: boolean
Expand All @@ -25,6 +28,8 @@ const ProposalVotes: FC<ProposalVotesProps> = ({ isLoading, votes, rowsNumber, p
const { t } = useTranslation()
const scope = useRequiredScopeParam()

const { wantedNamePattern } = useVoteFiltering()

const tableColumns: TableColProps[] = [
{ key: 'index', content: <></>, width: '50px' },
{ key: 'voter', content: t('common.voter'), align: TableCellAlign.Left },
Expand All @@ -46,6 +51,7 @@ const ProposalVotes: FC<ProposalVotesProps> = ({ isLoading, votes, rowsNumber, p
address={vote.address}
isError={vote.haveValidatorsFailed}
validator={vote.validator}
highlightedPart={wantedNamePattern}
/>
),
},
Expand Down Expand Up @@ -74,41 +80,66 @@ const ProposalVotes: FC<ProposalVotesProps> = ({ isLoading, votes, rowsNumber, p
}

export const ProposalVotesView: FC = () => {
const { t } = useTranslation()
const { network } = useRequiredScopeParam()
const proposalId = parseInt(useParams().proposalId!, 10)

const { isLoading } = useAllVotes(network, proposalId)
const displayedVotes = useDisplayedVotes(network, proposalId)
const { clearFilters } = useVoteFiltering()
const {
results,
isLoading,
hasNoResultsOnSelectedPage,
hasNoResultsBecauseOfFilters,
hasNoResultsWhatsoever,
} = useVotes(network, proposalId)

if (hasNoResultsOnSelectedPage) throw AppErrors.PageDoesNotExist

if (!isLoading && displayedVotes.tablePaginationProps.selectedPage > 1 && !displayedVotes.data?.length) {
throw AppErrors.PageDoesNotExist
if (hasNoResultsBecauseOfFilters) {
return <NoMatchingDataMaybeClearFilters clearFilters={clearFilters} />
}

if (hasNoResultsWhatsoever) {
return <CardEmptyState label={t('networkProposal.thereAreNoVotes')} />
}

return (
<ProposalVotes
isLoading={isLoading}
votes={displayedVotes.data}
rowsNumber={PAGE_SIZE}
pagination={displayedVotes.tablePaginationProps}
votes={results.data}
rowsNumber={results.tablePaginationProps.rowsPerPage}
pagination={results.tablePaginationProps}
/>
)
}

export const ProposalVotesCard: FC = () => {
const { t } = useTranslation()

const [wantedVoteType, setWantedVoteType] = useWantedVoteType()
const { wantedType, setWantedType, wantedNameInput, setWantedNameInput, nameError } = useVoteFiltering()

return (
<SubPageCard>
<CardHeaderWithResponsiveActions
action={<VoteTypeFilter onSelect={setWantedVoteType} value={wantedVoteType} />}
action={
<>
<TableSearchBar
value={wantedNameInput}
onChange={setWantedNameInput}
placeholder={t('networkProposal.searchForVoter')}
warning={nameError}
/>
<VoteTypeFilter onSelect={setWantedType} value={wantedType} />
</>
}
disableTypography
component="h3"
title={t('common.votes')}
/>
<ErrorBoundary light={true}>
<ProposalVotesView />
<Box sx={{ height: '704px' }}>
<ProposalVotesView />
</Box>
</ErrorBoundary>
</SubPageCard>
)
Expand Down
67 changes: 54 additions & 13 deletions src/app/pages/ProposalDetailsPage/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
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 { ExtendedVote, ProposalVoteValue, VoteType } from '../../../types/vote'
import { getFilterForVoterNameFragment, 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'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'

export type AllVotesData = List & {
isLoading: boolean
Expand Down Expand Up @@ -116,28 +118,67 @@ export const useVoteStats = (network: Network, proposalId: number): VoteStats =>
}
}

export const useWantedVoteType = () =>
useTypedSearchParam<VoteType>('vote', 'any', {
export const useVoteFiltering = () => {
const { t } = useTranslation()
const setSearchParams = useSearchParams()[1]
const [wantedType, setWantedType] = useTypedSearchParam<VoteType>('vote', 'any', {
deleteParams: ['page'],
})
const [wantedNameInput, setWantedNameInput] = useTypedSearchParam('voter', '', { deleteParams: ['page'] })
const wantedNamePattern = wantedNameInput.length < 3 ? undefined : wantedNameInput
const nameError = !!wantedNameInput && !wantedNamePattern ? t('tableSearch.error.tooShort') : undefined
const hasFilters = wantedType !== 'any' || !!wantedNamePattern
const clearFilters = () => {
setSearchParams(searchParams => {
searchParams.delete('vote')
searchParams.delete('voter')
searchParams.delete('page')
return searchParams
})
}
return {
wantedType,
setWantedType,
wantedNameInput,
setWantedNameInput,
wantedNamePattern,
nameError,
hasFilters,
clearFilters,
}
}

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()
export const useVotes = (network: Network, proposalId: number) => {
const { hasFilters, wantedType, wantedNamePattern } = useVoteFiltering()
const typeFilter = getFilterForVoteType(wantedType)
const nameFilter = getFilterForVoterNameFragment(wantedNamePattern)

const pagination = useClientSidePagination<ExtendedVote, AllVotesData>({
paramName: 'page',
clientPageSize: PAGE_SIZE,
clientPageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE,
serverPageSize: 1000,
filter,
filter: (vote: ExtendedVote) => typeFilter(vote) && nameFilter(vote),
})

// 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)
const results = pagination.getResults(allVotes)

const { isLoading } = allVotes
const isOnFirstPage = results.tablePaginationProps.selectedPage === 1
const hasData = !!results.data?.length
const hasNoResultsOnSelectedPage = !isLoading && !isOnFirstPage && !hasData
const hasNoResultsWhatsoever = !isLoading && !allVotes.total_count
const hasNoResultsBecauseOfFilters =
!isLoading && !hasData && isOnFirstPage && hasFilters && !hasNoResultsWhatsoever

return {
results,
isLoading,
hasNoResultsOnSelectedPage,
hasNoResultsBecauseOfFilters,
hasNoResultsWhatsoever,
}
}
10 changes: 9 additions & 1 deletion src/app/utils/vote.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ProposalVoteValue, VoteFilter, VoteType } from '../../types/vote'
import { ExtendedVote, ProposalVoteValue, VoteFilter, VoteType } from '../../types/vote'
import { hasTextMatch } from '../components/HighlightedText/text-matching'

export const getRandomVote = (): ProposalVoteValue =>
[ProposalVoteValue.yes, ProposalVoteValue.no, ProposalVoteValue.abstain][Math.floor(Math.random() * 3)]
Expand All @@ -11,3 +12,10 @@ const voteFilters: Record<VoteType, VoteFilter> = {
}

export const getFilterForVoteType = (voteType: VoteType): VoteFilter => voteFilters[voteType]

export const getFilterForVoterNameFragment = (fragment: string | undefined) => {
if (!fragment) {
return () => true
}
return (vote: ExtendedVote) => hasTextMatch(vote.validator?.media?.name, [fragment])
}
Loading

0 comments on commit 9dc098c

Please sign in to comment.