From d506172deeaabec9f219176bdde253b8c69fba71 Mon Sep 17 00:00:00 2001 From: Csillag Kristof Date: Thu, 16 May 2024 10:56:12 +0200 Subject: [PATCH 1/5] Adjust highlight style --- src/app/components/HighlightedText/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/HighlightedText/index.tsx b/src/app/components/HighlightedText/index.tsx index dbf0886dd..b85c73e84 100644 --- a/src/app/components/HighlightedText/index.tsx +++ b/src/app/components/HighlightedText/index.tsx @@ -27,8 +27,8 @@ export interface HighlightOptions { const defaultHighlightStyle: SxProps = { background: '#FFFF5480', - padding: '4px', - margin: '-4px', + padding: '2px', + margin: '-2px', } const defaultHighlight: HighlightOptions = { From ad881077ddcffea7973784f5cbdbe6d91c080ceb Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Wed, 15 May 2024 02:14:20 +0200 Subject: [PATCH 2/5] CardHeaderWithResponsiveActions: tune how things wrap on mobile/tabled --- .../components/CardHeaderWithResponsiveActions/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/components/CardHeaderWithResponsiveActions/index.tsx b/src/app/components/CardHeaderWithResponsiveActions/index.tsx index bdcd3d062..c8b13398d 100644 --- a/src/app/components/CardHeaderWithResponsiveActions/index.tsx +++ b/src/app/components/CardHeaderWithResponsiveActions/index.tsx @@ -2,10 +2,16 @@ import CardHeader, { cardHeaderClasses } from '@mui/material/CardHeader' import { styled } from '@mui/material/styles' export const CardHeaderWithResponsiveActions = styled(CardHeader)(({ theme }) => ({ - [theme.breakpoints.down('sm')]: { + [theme.breakpoints.down('md')]: { + display: 'inline', alignItems: 'flex-start', flexDirection: 'column', + [`.${cardHeaderClasses.content}`]: { + display: 'inline', + marginRight: theme.spacing(4), + }, [`.${cardHeaderClasses.action}`]: { + display: 'inline', marginTop: theme.spacing(4), marginBottom: theme.spacing(4), }, From 6e9281ad55f877936ef594ff4ca15761de77ac23 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sat, 11 May 2024 04:06:09 +0200 Subject: [PATCH 3/5] CardEmptyState: support displaying an action widget besides the label Also tune how de display CardEmptyState, so that the action can cleanly wrap into the next line when needed. --- src/app/components/CardEmptyState/index.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/components/CardEmptyState/index.tsx b/src/app/components/CardEmptyState/index.tsx index 0780c5799..cf9a1cbd7 100644 --- a/src/app/components/CardEmptyState/index.tsx +++ b/src/app/components/CardEmptyState/index.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, ReactNode } from 'react' import Box from '@mui/material/Box' import Typography from '@mui/material/Typography' import { styled } from '@mui/material/styles' @@ -23,11 +23,22 @@ const StyledBox = styled(Box)(({ theme }) => ({ type CardEmptyStateProps = { label: string + action?: ReactNode } -export const CardEmptyState: FC = ({ label }) => ( +export const CardEmptyState: FC = ({ label, action }) => ( - {label} + + {label} + {action} + ) From 3a6660e04387362157235786267b976193b2a346 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Thu, 9 May 2024 15:26:25 +0200 Subject: [PATCH 4/5] Add general purpose search bar component --- src/app/components/Search/TableSearchBar.tsx | 134 +++++++++++++++++++ src/locales/en/translation.json | 7 + src/styles/theme/colors.ts | 1 + 3 files changed, 142 insertions(+) create mode 100644 src/app/components/Search/TableSearchBar.tsx diff --git a/src/app/components/Search/TableSearchBar.tsx b/src/app/components/Search/TableSearchBar.tsx new file mode 100644 index 000000000..670370e7e --- /dev/null +++ b/src/app/components/Search/TableSearchBar.tsx @@ -0,0 +1,134 @@ +import { FC, useEffect, useState } 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 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' +import { useTranslation } from 'react-i18next' +import Button from '@mui/material/Button' +import { CardEmptyState } from '../CardEmptyState' +import { inputBaseClasses } from '@mui/material/InputBase' + +export interface TableSearchBarProps { + placeholder: string + warning?: string + value: string + onChange: (value: string) => void +} + +export const TableSearchBar: FC = ({ value, onChange, placeholder, warning }) => { + const { isTablet } = useScreenSize() + + const [isProblemFresh, setIsProblemFresh] = useState(false) + + useEffect(() => { + if (warning) { + const timeout = setTimeout(() => { + setIsProblemFresh(false) + }, typingDelay) + return () => clearTimeout(timeout) + } else { + setIsProblemFresh(true) + } + }, [warning]) + + const startAdornment = ( + + + + ) + + const onClearValue = () => onChange('') + + const endAdornment = ( + + {value ? ( + + + + ) : ( + + )} + + ) + + const helperText = isProblemFresh ? undefined : ( + + + {warning} + + ) + + return ( + onChange(e.target.value)} + InputProps={{ + inputProps: { + sx: { + p: 0, + width: isTablet ? 110 : 300, + margin: 2, + }, + }, + startAdornment, + endAdornment, + }} + placeholder={placeholder} + helperText={helperText} + /> + ) +} + +export const NoMatchingDataMaybeClearFilters: FC<{ clearFilters: () => void }> = ({ clearFilters }) => { + const { t } = useTranslation() + const clearButton = ( + + ) + return +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 03be0ad93..5cfa55cf9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -613,6 +613,13 @@ "searchSuggestionsForNoResults": "Alternatively, you can view a random Block, Transaction, Address or Token to discover our Explorer.", "wordsOfPower": "I COMMAND THEE TO SEARCH FOR" }, + "tableSearch": { + "error": { + "tooShort": "Please enter at least 3 characters to perform a search." + }, + "noMatchingResults": "There are no results matching your filters.", + "clearFilters": "Clear filters" + }, "validator": { "active": "Active", "amount": "Amount", diff --git a/src/styles/theme/colors.ts b/src/styles/theme/colors.ts index ba2596745..a3f3db69b 100644 --- a/src/styles/theme/colors.ts +++ b/src/styles/theme/colors.ts @@ -41,6 +41,7 @@ export const COLORS = { disabledPagination: '#7575a7', purpleBackground: '#e0e0f4', inactiveTab: '#e5e5f6', + inactiveStroke: '#aaaaadb0', disabledPrimaryBackground: '#acadb0', disabledPrimaryText: '#d5d6d7', errorIndicatorBackground: '#d44c4c', From 9dc098cdaa3ef6b472c421779bd01ebca2304fb6 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sun, 31 Mar 2024 20:59:23 +0200 Subject: [PATCH 5/5] Proposal votes: add search by validator name Also, handle situation when there are 0 votes --- .changelog/1357.trivial.md | 1 + .../components/Proposals/VoteTypeFilter.tsx | 4 +- src/app/components/Search/TableSearchBar.tsx | 9 ++- .../Validators/DeferredValidatorLink.tsx | 12 +++- .../components/Validators/ValidatorLink.tsx | 40 ++++++++--- .../ProposalDetailsPage/ProposalVotesCard.tsx | 53 ++++++++++++--- src/app/pages/ProposalDetailsPage/hooks.ts | 67 +++++++++++++++---- src/app/utils/vote.ts | 10 ++- src/locales/en/translation.json | 2 + 9 files changed, 155 insertions(+), 43 deletions(-) create mode 100644 .changelog/1357.trivial.md diff --git a/.changelog/1357.trivial.md b/.changelog/1357.trivial.md new file mode 100644 index 000000000..ddf2d41ea --- /dev/null +++ b/.changelog/1357.trivial.md @@ -0,0 +1 @@ +Network proposal votes: add search by validator name diff --git a/src/app/components/Proposals/VoteTypeFilter.tsx b/src/app/components/Proposals/VoteTypeFilter.tsx index c2d550883..f684f3090 100644 --- a/src/app/components/Proposals/VoteTypeFilter.tsx +++ b/src/app/components/Proposals/VoteTypeFilter.tsx @@ -33,7 +33,7 @@ export const VoteTypeFilter: FC = ({ onSelect, value }) => ] return ( - <> + {options.map(option => { const selected = option.value === value return ( @@ -59,6 +59,6 @@ export const VoteTypeFilter: FC = ({ onSelect, value }) => /> ) })} - + ) } diff --git a/src/app/components/Search/TableSearchBar.tsx b/src/app/components/Search/TableSearchBar.tsx index 670370e7e..115aad464 100644 --- a/src/app/components/Search/TableSearchBar.tsx +++ b/src/app/components/Search/TableSearchBar.tsx @@ -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' @@ -25,16 +24,16 @@ export interface TableSearchBarProps { export const TableSearchBar: FC = ({ 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]) @@ -61,7 +60,7 @@ export const TableSearchBar: FC = ({ value, onChange, place ) - const helperText = isProblemFresh ? undefined : ( + const helperText = isWarningFresh ? undefined : ( = ({ 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 + return ( + + ) } diff --git a/src/app/components/Validators/ValidatorLink.tsx b/src/app/components/Validators/ValidatorLink.tsx index f2b3c0102..85f11dd30 100644 --- a/src/app/components/Validators/ValidatorLink.tsx +++ b/src/app/components/Validators/ValidatorLink.tsx @@ -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 = ({ address, name, network, alwaysTrim }) => { +export const ValidatorLink: FC = ({ + address, + name, + network, + alwaysTrim, + highlightedPart, +}) => { const { isTablet } = useScreenSize() const to = RouteUtils.getValidatorRoute(network, address) return ( {isTablet ? ( - + ) : ( - + )} ) @@ -32,21 +46,23 @@ export const ValidatorLink: FC = ({ address, name, network, type TrimValidatorEndLinkLabelProps = { name: string to: string + highlightedPart?: string } -const TrimValidatorEndLinkLabel: FC = ({ name, to }) => ( - +const TrimValidatorEndLinkLabel: FC = ({ name, to, highlightedPart }) => ( + ) type TabletValidatorLinkProps = { address: string name?: string to: string + highlightedPart?: string } -const TabletValidatorLink: FC = ({ address, name, to }) => { +const TabletValidatorLink: FC = ({ address, name, to, highlightedPart }) => { if (name) { - return + return } return } @@ -55,13 +71,19 @@ type DesktopValidatorLinkProps = TabletValidatorLinkProps & { alwaysTrim?: boolean } -const DesktopValidatorLink: FC = ({ address, name, to, alwaysTrim }) => { +const DesktopValidatorLink: FC = ({ + address, + name, + to, + alwaysTrim, + highlightedPart, +}) => { if (alwaysTrim) { return } return ( - {name ?? address} + {name ? : address} ) } diff --git a/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx index 7cf5de63f..e7c7d0cb4 100644 --- a/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx +++ b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx @@ -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 @@ -25,6 +28,8 @@ const ProposalVotes: FC = ({ 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 }, @@ -46,6 +51,7 @@ const ProposalVotes: FC = ({ isLoading, votes, rowsNumber, p address={vote.address} isError={vote.haveValidatorsFailed} validator={vote.validator} + highlightedPart={wantedNamePattern} /> ), }, @@ -74,22 +80,35 @@ const ProposalVotes: FC = ({ 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 + } + + if (hasNoResultsWhatsoever) { + return } return ( ) } @@ -97,18 +116,30 @@ export const ProposalVotesView: FC = () => { export const ProposalVotesCard: FC = () => { const { t } = useTranslation() - const [wantedVoteType, setWantedVoteType] = useWantedVoteType() + const { wantedType, setWantedType, wantedNameInput, setWantedNameInput, nameError } = useVoteFiltering() return ( } + action={ + <> + + + + } disableTypography component="h3" title={t('common.votes')} /> - + + + ) diff --git a/src/app/pages/ProposalDetailsPage/hooks.ts b/src/app/pages/ProposalDetailsPage/hooks.ts index a23aa22ad..3fe1ac957 100644 --- a/src/app/pages/ProposalDetailsPage/hooks.ts +++ b/src/app/pages/ProposalDetailsPage/hooks.ts @@ -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 @@ -116,28 +118,67 @@ export const useVoteStats = (network: Network, proposalId: number): VoteStats => } } -export const useWantedVoteType = () => - useTypedSearchParam('vote', 'any', { +export const useVoteFiltering = () => { + const { t } = useTranslation() + const setSearchParams = useSearchParams()[1] + const [wantedType, setWantedType] = useTypedSearchParam('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({ 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, + } } diff --git a/src/app/utils/vote.ts b/src/app/utils/vote.ts index 03e903d2c..7ed042dcb 100644 --- a/src/app/utils/vote.ts +++ b/src/app/utils/vote.ts @@ -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)] @@ -11,3 +12,10 @@ const voteFilters: Record = { } 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]) +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5cfa55cf9..1ff5e0ce7 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -188,6 +188,7 @@ "createTooltip": "Voting created on epoch shown.", "deposit": "Deposit", "failedToLoadAllVotes": "Failed to load all the votes, number might be incomplete!", + "thereAreNoVotes": "No votes have been registered.", "handler": "Title", "id": "ID", "listTitle": "Network Change Proposals", @@ -197,6 +198,7 @@ "passed": "Passed", "rejected": "Rejected" }, + "searchForVoter": "Search for voter", "type": { "upgrade": "Upgrade", "parameterUpgrade": "Parameter upgrade",