From 04f66c87595600d6d273d11078bf65b8d7252610 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sun, 31 Mar 2024 21:05:52 +0200 Subject: [PATCH 1/7] Improve the comprehensive pagination engine - Add documentation - Rename things for clarity - Add support for transforming the data, besides filtering - Add support for also specifying filters as an array - Add a bunch of more status fields --- .changelog/1385.internal.md | 1 + src/app/components/Table/PaginationEngine.ts | 94 ++++++++++++- .../Table/useClientSidePagination.ts | 129 +++++++++++++++--- .../useComprehensiveSearchParamsPagination.ts | 50 +++++-- src/app/pages/TokenDashboardPage/hook.ts | 8 +- 5 files changed, 242 insertions(+), 40 deletions(-) create mode 100644 .changelog/1385.internal.md diff --git a/.changelog/1385.internal.md b/.changelog/1385.internal.md new file mode 100644 index 000000000..b150073d2 --- /dev/null +++ b/.changelog/1385.internal.md @@ -0,0 +1 @@ +Improve the comprehensive pagination engine diff --git a/src/app/components/Table/PaginationEngine.ts b/src/app/components/Table/PaginationEngine.ts index c2787b33b..f4394ea4b 100644 --- a/src/app/components/Table/PaginationEngine.ts +++ b/src/app/components/Table/PaginationEngine.ts @@ -7,15 +7,95 @@ export interface SimplePaginationEngine { linkToPage: (pageNumber: number) => To } -export interface PaginatedResults { +/** + * The data returned by a comprehensive pagination engine to the data consumer component + */ +export interface ComprehensivePaginatedResults { + /** + * 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 + + /** + * Any extra data produced by the transformer function (besides the array of items) + */ + extractedData?: ExtractedData | undefined + + /** + * Is the data set still loading from the server? + */ + isLoading: boolean + + /** + * Has the data been loaded from the server? + */ + isFetched: boolean + + /** + * Are we on the first page of the pagination? + */ + isOnFirstPage: boolean + + /** + * Do we have any data on the client page? + */ + hasData: boolean + + /** + * Can we say that there are no results at all? + * + * This is determined before any filtering or transformation. + */ + hasNoResultsWhatsoever: boolean + + /** + * Can we say that there are no results on the selected page + * + * This will only be marked as true if + * - we are not the first page + * - loading has finished + */ + hasNoResultsOnSelectedPage: boolean + + hasNoResultsBecauseOfFilters: boolean } -export interface ComprehensivePaginationEngine { - selectedPage: number - offsetForQuery: number - limitForQuery: number - paramsForQuery: { offset: number; limit: number } - getResults: (queryResult: QueryResult | undefined, key?: keyof QueryResult) => PaginatedResults +/** + * 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< + Item, + QueryResult extends List, + ExtractedData = typeof undefined, +> { + /** + * 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 isLoading Is the data still being loaded from the server? + * @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: ( + isLoading: boolean, + isFetched: boolean, + queryResult: QueryResult | undefined, + key?: keyof QueryResult, + ) => ComprehensivePaginatedResults } diff --git a/src/app/components/Table/useClientSidePagination.ts b/src/app/components/Table/useClientSidePagination.ts index 8337a8293..c93ba9cae 100644 --- a/src/app/components/Table/useClientSidePagination.ts +++ b/src/app/components/Table/useClientSidePagination.ts @@ -1,14 +1,58 @@ import { To, useSearchParams } from 'react-router-dom' import { AppErrors } from '../../../types/errors' -import { ComprehensivePaginationEngine } from './PaginationEngine' +import { ComprehensivePaginatedResults, ComprehensivePaginationEngine } from './PaginationEngine' import { List } from '../../../oasis-nexus/api' import { TablePaginationProps } from './TablePagination' -type ClientSizePaginationParams = { +type Filter = (item: Item) => boolean + +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 - filter?: (item: Item) => boolean + + /** + * Filter to be applied to the loaded data. + * + * This is the order of processing: + * - transform() + * - filter + * - filters + * - order */ + filter?: Filter | undefined + + /** + * Filter to be applied to the loaded data. + * + * This is the order of processing: + * - transform() + * - filter + * - filters + * - order + */ + filters?: (Filter | undefined)[] + + /** + * Transformation to be applied after loading the data from the server, before presenting it to the data consumer component + * + * Can be used for ordering, aggregation, etc.D + * If both transform and filter is set, transform will run first. + */ + transform?: (input: Item[], results: QueryResult) => [Item[], ExtractedData] } const knownListKeys: string[] = ['total_count', 'is_total_count_clipped'] @@ -27,13 +71,21 @@ function findListIn(data: T): Item[] { } } -export function useClientSidePagination({ +/** + * 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 + filters, + transform, +}: ClientSizePaginationParams): ComprehensivePaginationEngine< + Item, + QueryResult, + ExtractedData +> { const [searchParams] = useSearchParams() const selectedClientPageString = searchParams.get(paramName) const selectedClientPage = parseInt(selectedClientPageString ?? '1', 10) @@ -57,30 +109,51 @@ 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, - getResults: (queryResult, key) => { - const data = queryResult - ? key - ? (queryResult[key] as Item[]) - : findListIn(queryResult) + selectedPageForClient: selectedClientPage, + paramsForServer: paramsForQuery, + getResults: ( + isLoading, + isFetched, + queryResult, + key, + ): ComprehensivePaginatedResults => { + 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 - const filteredData = !!data && !!filter ? data.filter(filter) : data + // Apply the specified client-side transformation + const [transformedData, extractedData] = !!data && !!transform ? transform(data, queryResult!) : [data] + + // Select the filters to use. (filter field, filters field, drop undefined ones) + const filtersToApply = [filter, ...(filters ?? [])].filter(f => !!f) as Filter[] + + // Apply the specified filtering + const filteredData = transformedData + ? filtersToApply.reduce( + (partiallyFiltered, nextFilter) => partiallyFiltered.filter(nextFilter), + transformedData, + ) + : transformedData + + // 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, @@ -93,11 +166,25 @@ export function useClientSidePagination({ isTotalCountClipped: queryResult?.is_total_count_clipped, // TODO rowsPerPage: clientPageSize, } + + const isOnFirstPage = tableProps.selectedPage === 1 + const hasData = !!dataWindow?.length + const hasNoResultsOnSelectedPage = !isLoading && !isOnFirstPage && !hasData + const hasNoResultsWhatsoever = !isLoading && !queryResult?.total_count + const hasNoResultsBecauseOfFilters = !isLoading && !!transformedData?.length && !filteredData?.length + return { tablePaginationProps: tableProps, data: dataWindow, + extractedData, + isLoading, + isFetched, + hasData, + isOnFirstPage, + hasNoResultsWhatsoever, + hasNoResultsOnSelectedPage, + hasNoResultsBecauseOfFilters, } }, - // tableProps, } } diff --git a/src/app/components/Table/useComprehensiveSearchParamsPagination.ts b/src/app/components/Table/useComprehensiveSearchParamsPagination.ts index 1ad4006a4..ecb1c019d 100644 --- a/src/app/components/Table/useComprehensiveSearchParamsPagination.ts +++ b/src/app/components/Table/useComprehensiveSearchParamsPagination.ts @@ -1,18 +1,28 @@ import { To, useSearchParams } from 'react-router-dom' import { AppErrors } from '../../../types/errors' -import { ComprehensivePaginationEngine } from './PaginationEngine' +import { ComprehensivePaginatedResults, ComprehensivePaginationEngine } from './PaginationEngine' import { List } from '../../../oasis-nexus/api' import { TablePaginationProps } from './TablePagination' +type Filter = (item: Item) => boolean + type ComprehensiveSearchParamsPaginationParams = { paramName: string pageSize: number + /** * @deprecated this will mess up page size. * * Consider using client-side pagination instead. */ - filter?: (item: Item) => boolean + filter?: Filter | undefined + + /** + * @deprecated this will mess up page size. + * + * Consider using client-side pagination instead. + */ + filters?: (Filter | undefined)[] } const knownListKeys: string[] = ['total_count', 'is_total_count_clipped'] @@ -35,6 +45,7 @@ export function useComprehensiveSearchParamsPagination): ComprehensivePaginationEngine { const [searchParams] = useSearchParams() const selectedPageString = searchParams.get(paramName) @@ -64,17 +75,26 @@ export function useComprehensiveSearchParamsPagination { + selectedPageForClient: selectedPage, + paramsForServer: paramsForQuery, + getResults: (isLoading, isFetched, queryResult, key): ComprehensivePaginatedResults => { const data = queryResult ? key ? (queryResult[key] as Item[]) : findListIn(queryResult) : undefined - const filteredData = !!data && !!filter ? data.filter(filter) : data + + // Select the filters to use. (filter field, filters field, drop undefined ones) + const filtersToApply = [filter, ...(filters ?? [])].filter(f => !!f) as Filter[] + + // Apply the specified filtering + const filteredData = data + ? filtersToApply.reduce( + (partiallyFiltered, nextFilter) => partiallyFiltered.filter(nextFilter), + data, + ) + : data + const tableProps: TablePaginationProps = { selectedPage, linkToPage, @@ -82,9 +102,23 @@ export function useComprehensiveSearchParamsPagination 1 && !results.data?.length) { + if (isFetched && pagination.selectedPageForClient > 1 && !results.data?.length) { throw AppErrors.PageDoesNotExist } return { isLoading, isFetched, - results: pagination.getResults(data?.data), + results, } } From 5b3b56219088fc778b0ff0e77f51b8d8be52a03c Mon Sep 17 00:00:00 2001 From: Csillag Kristof Date: Thu, 16 May 2024 19:08:37 +0200 Subject: [PATCH 2/7] Simplify code and follow API changes on pagination engines --- .../ProposalDetailsPage/ProposalVotesCard.tsx | 14 +++++----- src/app/pages/ProposalDetailsPage/hooks.ts | 27 +++++-------------- src/app/utils/vote.ts | 8 +++--- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx index e7c7d0cb4..9a0f5abcd 100644 --- a/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx +++ b/src/app/pages/ProposalDetailsPage/ProposalVotesCard.tsx @@ -85,13 +85,15 @@ export const ProposalVotesView: FC = () => { const proposalId = parseInt(useParams().proposalId!, 10) const { clearFilters } = useVoteFiltering() + const results = useVotes(network, proposalId) const { - results, isLoading, + tablePaginationProps, + data: votes, hasNoResultsOnSelectedPage, - hasNoResultsBecauseOfFilters, hasNoResultsWhatsoever, - } = useVotes(network, proposalId) + hasNoResultsBecauseOfFilters, + } = results if (hasNoResultsOnSelectedPage) throw AppErrors.PageDoesNotExist @@ -106,9 +108,9 @@ export const ProposalVotesView: FC = () => { return ( ) } diff --git a/src/app/pages/ProposalDetailsPage/hooks.ts b/src/app/pages/ProposalDetailsPage/hooks.ts index 3fe1ac957..f94024bc9 100644 --- a/src/app/pages/ProposalDetailsPage/hooks.ts +++ b/src/app/pages/ProposalDetailsPage/hooks.ts @@ -17,6 +17,7 @@ import { useSearchParams } from 'react-router-dom' export type AllVotesData = List & { isLoading: boolean isError: boolean + isFetched: boolean loadedVotes: ExtendedVote[] } @@ -48,7 +49,7 @@ export const useAllVotes = (network: Network, proposalId: number): AllVotesData isLoading: areValidatorsLoading, isError: haveValidatorsFailed, } = useValidatorMap(network) - const { isLoading, isError, data } = query + const { isLoading, isFetched, isError, data } = query const extendedVotes = (data?.data.votes || []).map( (vote, index): ExtendedVote => ({ @@ -62,6 +63,7 @@ export const useAllVotes = (network: Network, proposalId: number): AllVotesData return { isLoading, + isFetched, isError, loadedVotes: DEBUG_MODE ? extendedVotes.map(v => ({ ...v, vote: getRandomVoteFor(v.address) })) || [] @@ -149,7 +151,7 @@ export const useVoteFiltering = () => { } export const useVotes = (network: Network, proposalId: number) => { - const { hasFilters, wantedType, wantedNamePattern } = useVoteFiltering() + const { wantedType, wantedNamePattern } = useVoteFiltering() const typeFilter = getFilterForVoteType(wantedType) const nameFilter = getFilterForVoterNameFragment(wantedNamePattern) @@ -157,28 +159,13 @@ export const useVotes = (network: Network, proposalId: number) => { paramName: 'page', clientPageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, serverPageSize: 1000, - filter: (vote: ExtendedVote) => typeFilter(vote) && nameFilter(vote), + filters: [typeFilter, nameFilter], }) // Get all the votes const allVotes = useAllVotes(network, proposalId) + const { isLoading, isFetched } = allVotes // Get the section of the votes that we should display in the table - 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, - } + return pagination.getResults(isLoading, isFetched, allVotes) } diff --git a/src/app/utils/vote.ts b/src/app/utils/vote.ts index 7ed042dcb..982a0d4ab 100644 --- a/src/app/utils/vote.ts +++ b/src/app/utils/vote.ts @@ -4,18 +4,18 @@ import { hasTextMatch } from '../components/HighlightedText/text-matching' export const getRandomVote = (): ProposalVoteValue => [ProposalVoteValue.yes, ProposalVoteValue.no, ProposalVoteValue.abstain][Math.floor(Math.random() * 3)] -const voteFilters: Record = { - any: () => true, +const voteFilters: Record = { + any: undefined, 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] +export const getFilterForVoteType = (voteType: VoteType) => voteFilters[voteType] export const getFilterForVoterNameFragment = (fragment: string | undefined) => { if (!fragment) { - return () => true + return } return (vote: ExtendedVote) => hasTextMatch(vote.validator?.media?.name, [fragment]) } From 06a62ccf1129f16a75dd4d17d8c0044f16db8b5b Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Fri, 10 May 2024 01:26:17 +0200 Subject: [PATCH 3/7] Validators list: fix displaying rank vs pagination --- src/app/components/Validators/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/Validators/index.tsx b/src/app/components/Validators/index.tsx index 26825be41..d63413eb7 100644 --- a/src/app/components/Validators/index.tsx +++ b/src/app/components/Validators/index.tsx @@ -35,13 +35,14 @@ export const Validators: FC = ({ isLoading, limit, pagination, { key: 'status', content: t('common.status') }, { key: 'uptime', content: t('validator.uptime') }, ] + const offset = typeof pagination === 'boolean' ? 0 : (pagination.selectedPage - 1) * pagination.rowsPerPage const tableRows = validators?.map((validator, index) => ({ key: validator.entity_address, data: [ { align: TableCellAlign.Center, // TODO: replace index when rank is implemented in the API - content:
{index + 1}
, + content:
{offset + index + 1}
, key: 'rank', }, { From ab7cd5cd503da667bc3b5b545c02b58cd4162dd5 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Fri, 10 May 2024 01:28:00 +0200 Subject: [PATCH 4/7] Validators list: factor out data loading into a hook --- src/app/pages/ValidatorsPage/hooks.ts | 17 +++++++++++++ src/app/pages/ValidatorsPage/index.tsx | 35 ++++++++++++-------------- 2 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 src/app/pages/ValidatorsPage/hooks.ts diff --git a/src/app/pages/ValidatorsPage/hooks.ts b/src/app/pages/ValidatorsPage/hooks.ts new file mode 100644 index 000000000..390c91582 --- /dev/null +++ b/src/app/pages/ValidatorsPage/hooks.ts @@ -0,0 +1,17 @@ +import { TableLayout } from '../../components/TableLayoutButton' +import { useGetConsensusValidators } from '../../../oasis-nexus/api' +import { useSearchParamsPagination } from '../../components/Table/useSearchParamsPagination' +import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE as pageSize } from '../../config' +import { Network } from '../../../types/network' + +export const useLoadedValidators = (network: Network, tableView: TableLayout) => { + const pagination = useSearchParamsPagination('page') + const offset = (pagination.selectedPage - 1) * pageSize + const validatorsQuery = useGetConsensusValidators(network, { + limit: tableView === TableLayout.Vertical ? offset + pageSize : pageSize, + offset: tableView === TableLayout.Vertical ? 0 : offset, + }) + const { isLoading, isFetched, data } = validatorsQuery + const validatorsData = data?.data + return { pagination, pageSize, isLoading, isFetched, validatorsData } +} diff --git a/src/app/pages/ValidatorsPage/index.tsx b/src/app/pages/ValidatorsPage/index.tsx index 9a7403fa0..f974dfc1b 100644 --- a/src/app/pages/ValidatorsPage/index.tsx +++ b/src/app/pages/ValidatorsPage/index.tsx @@ -4,9 +4,7 @@ import Divider from '@mui/material/Divider' import { useScreenSize } from '../../hooks/useScreensize' import { PageLayout } from '../../components/PageLayout' import { SubPageCard } from '../../components/SubPageCard' -import { useGetConsensusValidators } from '../../../oasis-nexus/api' -import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE as PAGE_SIZE } from '../../config' -import { useSearchParamsPagination } from '../../components/Table/useSearchParamsPagination' + import { AppErrors } from '../../../types/errors' import { TableLayout, TableLayoutButton } from '../../components/TableLayoutButton' import { LoadMoreButton } from '../../components/LoadMoreButton' @@ -15,13 +13,13 @@ import { Validators } from '../../components/Validators' import { CardHeaderWithCounter } from '../../components/CardHeaderWithCounter' import { ValidatorDetailsView } from '../ValidatorDetailsPage' import { VerticalList } from '../../components/VerticalList' +import { useLoadedValidators } from './hooks' export const ValidatorsPage: FC = () => { const [tableView, setTableView] = useState(TableLayout.Horizontal) const { isMobile } = useScreenSize() const { t } = useTranslation() - const pagination = useSearchParamsPagination('page') - const offset = (pagination.selectedPage - 1) * PAGE_SIZE + const scope = useRequiredScopeParam() const { network } = scope @@ -31,13 +29,12 @@ export const ValidatorsPage: FC = () => { } }, [isMobile, setTableView]) - const validatorsQuery = useGetConsensusValidators(network, { - limit: tableView === TableLayout.Vertical ? offset + PAGE_SIZE : PAGE_SIZE, - offset: tableView === TableLayout.Vertical ? 0 : offset, - }) - const { isLoading, isFetched, data } = validatorsQuery - const validatorsData = data?.data - if (isFetched && offset && !validatorsData?.validators?.length) { + const { pagination, pageSize, isLoading, isFetched, validatorsData } = useLoadedValidators( + network, + tableView, + ) + + if (isFetched && pagination.selectedPage > 1 && !validatorsData?.validators?.length) { throw AppErrors.PageDoesNotExist } @@ -63,22 +60,22 @@ export const ValidatorsPage: FC = () => { > {tableView === TableLayout.Horizontal && ( )} {tableView === TableLayout.Vertical && ( {isLoading && - [...Array(PAGE_SIZE).keys()].map(key => ( + [...Array(pageSize).keys()].map(key => ( ))} {!isLoading && From c6d278958e19dac2228d0a07d57972d195f3b6f8 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Fri, 10 May 2024 02:01:31 +0200 Subject: [PATCH 5/7] Validators list: switch to comprehensive pagination --- src/app/pages/ValidatorsPage/hooks.ts | 23 ++++++++++----- src/app/pages/ValidatorsPage/index.tsx | 41 +++++++++++++------------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/app/pages/ValidatorsPage/hooks.ts b/src/app/pages/ValidatorsPage/hooks.ts index 390c91582..b0595ec19 100644 --- a/src/app/pages/ValidatorsPage/hooks.ts +++ b/src/app/pages/ValidatorsPage/hooks.ts @@ -1,17 +1,24 @@ import { TableLayout } from '../../components/TableLayoutButton' -import { useGetConsensusValidators } from '../../../oasis-nexus/api' -import { useSearchParamsPagination } from '../../components/Table/useSearchParamsPagination' -import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE as pageSize } from '../../config' +import { useGetConsensusValidators, Validator, ValidatorList } from '../../../oasis-nexus/api' +import { useComprehensiveSearchParamsPagination } from '../../components/Table/useComprehensiveSearchParamsPagination' +import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' import { Network } from '../../../types/network' export const useLoadedValidators = (network: Network, tableView: TableLayout) => { - const pagination = useSearchParamsPagination('page') - const offset = (pagination.selectedPage - 1) * pageSize + const pagination = useComprehensiveSearchParamsPagination({ + paramName: 'page', + pageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, + }) + const offset = pagination.offsetForQuery const validatorsQuery = useGetConsensusValidators(network, { - limit: tableView === TableLayout.Vertical ? offset + pageSize : pageSize, + limit: tableView === TableLayout.Vertical ? offset + pagination.limitForQuery : pagination.limitForQuery, offset: tableView === TableLayout.Vertical ? 0 : offset, }) const { isLoading, isFetched, data } = validatorsQuery - const validatorsData = data?.data - return { pagination, pageSize, isLoading, isFetched, validatorsData } + const paginatedResults = pagination.getResults(data?.data) + return { + isLoading, + isFetched, + paginatedResults, + } } diff --git a/src/app/pages/ValidatorsPage/index.tsx b/src/app/pages/ValidatorsPage/index.tsx index f974dfc1b..cdf001085 100644 --- a/src/app/pages/ValidatorsPage/index.tsx +++ b/src/app/pages/ValidatorsPage/index.tsx @@ -29,20 +29,25 @@ export const ValidatorsPage: FC = () => { } }, [isMobile, setTableView]) - const { pagination, pageSize, isLoading, isFetched, validatorsData } = useLoadedValidators( - network, - tableView, - ) + const { + isLoading, + isFetched, + paginatedResults, + // pagination, pageSize, isLoading, isFetched, validatorsData + } = useLoadedValidators(network, tableView) + + const { tablePaginationProps, data: validators } = paginatedResults + const { selectedPage, totalCount, isTotalCountClipped, rowsPerPage } = tablePaginationProps - if (isFetched && pagination.selectedPage > 1 && !validatorsData?.validators?.length) { + if (isFetched && selectedPage > 1 && !validators?.length) { throw AppErrors.PageDoesNotExist } return ( - } + // mobileFooterAction={ + // tableView === TableLayout.Vertical && + // } > {!isMobile && } { } action={isMobile && } @@ -60,26 +65,20 @@ export const ValidatorsPage: FC = () => { > {tableView === TableLayout.Horizontal && ( )} {tableView === TableLayout.Vertical && ( {isLoading && - [...Array(pageSize).keys()].map(key => ( + [...Array(rowsPerPage).keys()].map(key => ( ))} {!isLoading && - validatorsData?.validators.map(validator => ( + validators?.map(validator => ( ))} From 4fcae674ceec1db620e011fb8242b1a1fffc9f5b Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Fri, 10 May 2024 02:03:51 +0200 Subject: [PATCH 6/7] Validators list: switch to client-side pagination --- src/app/pages/ValidatorsPage/hooks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/pages/ValidatorsPage/hooks.ts b/src/app/pages/ValidatorsPage/hooks.ts index b0595ec19..5b6128562 100644 --- a/src/app/pages/ValidatorsPage/hooks.ts +++ b/src/app/pages/ValidatorsPage/hooks.ts @@ -1,13 +1,14 @@ import { TableLayout } from '../../components/TableLayoutButton' import { useGetConsensusValidators, Validator, ValidatorList } from '../../../oasis-nexus/api' -import { useComprehensiveSearchParamsPagination } from '../../components/Table/useComprehensiveSearchParamsPagination' +import { useClientSidePagination } from '../../components/Table/useClientSidePagination' import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' import { Network } from '../../../types/network' export const useLoadedValidators = (network: Network, tableView: TableLayout) => { - const pagination = useComprehensiveSearchParamsPagination({ + const pagination = useClientSidePagination({ paramName: 'page', - pageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, + serverPageSize: 1000, + clientPageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, }) const offset = pagination.offsetForQuery const validatorsQuery = useGetConsensusValidators(network, { From a992108ee932cfe2699381c963c65db10c926df6 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Fri, 10 May 2024 02:45:31 +0200 Subject: [PATCH 7/7] Validators list: support search --- src/app/components/Validators/index.tsx | 16 +++++++---- src/app/pages/ValidatorsPage/hooks.ts | 37 ++++++++++++++++++------- src/app/pages/ValidatorsPage/index.tsx | 34 ++++++++++++++++------- src/locales/en/translation.json | 1 + src/oasis-nexus/api.ts | 4 ++- 5 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/app/components/Validators/index.tsx b/src/app/components/Validators/index.tsx index d63413eb7..cd0278fdf 100644 --- a/src/app/components/Validators/index.tsx +++ b/src/app/components/Validators/index.tsx @@ -17,9 +17,16 @@ type ValidatorsProps = { isLoading: boolean limit: number pagination: false | TablePaginationProps + highlightedPart?: string } -export const Validators: FC = ({ isLoading, limit, pagination, validators }) => { +export const Validators: FC = ({ + isLoading, + limit, + pagination, + validators, + highlightedPart, +}) => { const { t } = useTranslation() const { network } = useRequiredScopeParam() @@ -35,14 +42,12 @@ export const Validators: FC = ({ isLoading, limit, pagination, { key: 'status', content: t('common.status') }, { key: 'uptime', content: t('validator.uptime') }, ] - const offset = typeof pagination === 'boolean' ? 0 : (pagination.selectedPage - 1) * pagination.rowsPerPage - const tableRows = validators?.map((validator, index) => ({ + const tableRows = validators?.map(validator => ({ key: validator.entity_address, data: [ { align: TableCellAlign.Center, - // TODO: replace index when rank is implemented in the API - content:
{offset + index + 1}
, + content:
{validator.rank + 1}
, key: 'rank', }, { @@ -57,6 +62,7 @@ export const Validators: FC = ({ isLoading, limit, pagination, address={validator.entity_address} name={validator.media?.name} network={network} + highlightedPart={highlightedPart} /> ), diff --git a/src/app/pages/ValidatorsPage/hooks.ts b/src/app/pages/ValidatorsPage/hooks.ts index 5b6128562..310c9ba7d 100644 --- a/src/app/pages/ValidatorsPage/hooks.ts +++ b/src/app/pages/ValidatorsPage/hooks.ts @@ -3,23 +3,40 @@ import { useGetConsensusValidators, Validator, ValidatorList } from '../../../oa import { useClientSidePagination } from '../../components/Table/useClientSidePagination' import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE } from '../../config' import { Network } from '../../../types/network' +import { useTypedSearchParam } from '../../hooks/useTypedSearchParam' +import { hasTextMatch } from '../../components/HighlightedText/text-matching' +import { useTranslation } from 'react-i18next' -export const useLoadedValidators = (network: Network, tableView: TableLayout) => { - const pagination = useClientSidePagination({ +const useValidatorNameSearch = () => useTypedSearchParam('name', '', { deleteParams: ['page'] }) + +export const useValidatorFiltering = () => { + const { t } = useTranslation() + const [nameSearchInput, setNameSearchInput] = useValidatorNameSearch() + const namePattern = nameSearchInput.length < 3 ? undefined : nameSearchInput + const nameWarning = !!nameSearchInput && !namePattern ? t('tableSearch.error.tooShort') : undefined + return { nameSearchInput, setNameSearchInput, namePattern, nameWarning } +} + +export const useValidatorData = (network: Network, tableView: TableLayout) => { + const { namePattern } = useValidatorFiltering() + const nameFilter = namePattern + ? (validator: Validator) => hasTextMatch(validator.media?.name, [namePattern]) + : undefined + const pagination = useClientSidePagination({ paramName: 'page', serverPageSize: 1000, clientPageSize: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, + transform: (data, r) => [ + data, + [r.total_count, r.is_total_count_clipped], // Extract the real number of validators + ], + filter: nameFilter, }) - const offset = pagination.offsetForQuery + const { offset, limit } = pagination.paramsForServer const validatorsQuery = useGetConsensusValidators(network, { - limit: tableView === TableLayout.Vertical ? offset + pagination.limitForQuery : pagination.limitForQuery, + limit: tableView === TableLayout.Vertical ? offset + limit : limit, offset: tableView === TableLayout.Vertical ? 0 : offset, }) const { isLoading, isFetched, data } = validatorsQuery - const paginatedResults = pagination.getResults(data?.data) - return { - isLoading, - isFetched, - paginatedResults, - } + return pagination.getResults(isLoading, isFetched, data?.data) } diff --git a/src/app/pages/ValidatorsPage/index.tsx b/src/app/pages/ValidatorsPage/index.tsx index cdf001085..df93dd61f 100644 --- a/src/app/pages/ValidatorsPage/index.tsx +++ b/src/app/pages/ValidatorsPage/index.tsx @@ -7,13 +7,14 @@ import { SubPageCard } from '../../components/SubPageCard' import { AppErrors } from '../../../types/errors' import { TableLayout, TableLayoutButton } from '../../components/TableLayoutButton' -import { LoadMoreButton } from '../../components/LoadMoreButton' import { useRequiredScopeParam } from '../../hooks/useScopeParam' import { Validators } from '../../components/Validators' import { CardHeaderWithCounter } from '../../components/CardHeaderWithCounter' import { ValidatorDetailsView } from '../ValidatorDetailsPage' import { VerticalList } from '../../components/VerticalList' -import { useLoadedValidators } from './hooks' +import { useValidatorFiltering, useValidatorData } from './hooks' +import Box from '@mui/material/Box' +import { TableSearchBar } from '../../components/Search/TableSearchBar' export const ValidatorsPage: FC = () => { const [tableView, setTableView] = useState(TableLayout.Horizontal) @@ -22,6 +23,7 @@ export const ValidatorsPage: FC = () => { const scope = useRequiredScopeParam() const { network } = scope + const { nameSearchInput, setNameSearchInput, nameWarning, namePattern } = useValidatorFiltering() useEffect(() => { if (!isMobile) { @@ -31,15 +33,16 @@ export const ValidatorsPage: FC = () => { const { isLoading, - isFetched, - paginatedResults, - // pagination, pageSize, isLoading, isFetched, validatorsData - } = useLoadedValidators(network, tableView) + tablePaginationProps, + data: validators, + extractedData, + hasNoResultsOnSelectedPage, + } = useValidatorData(network, tableView) - const { tablePaginationProps, data: validators } = paginatedResults - const { selectedPage, totalCount, isTotalCountClipped, rowsPerPage } = tablePaginationProps + const { rowsPerPage } = tablePaginationProps + const [totalCount, isTotalCountClipped] = extractedData ?? [0, false] - if (isFetched && selectedPage > 1 && !validators?.length) { + if (hasNoResultsOnSelectedPage) { throw AppErrors.PageDoesNotExist } @@ -59,7 +62,17 @@ export const ValidatorsPage: FC = () => { isTotalCountClipped={isTotalCountClipped} /> } - action={isMobile && } + action={ + + + {isMobile && } + + } noPadding={tableView === TableLayout.Vertical} mainTitle > @@ -69,6 +82,7 @@ export const ValidatorsPage: FC = () => { isLoading={isLoading} limit={rowsPerPage} pagination={tablePaginationProps} + highlightedPart={namePattern} /> )} {tableView === TableLayout.Vertical && ( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 14ee0d887..c60fd0837 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -651,6 +651,7 @@ "signedBlocks": "Signed Blocks", "signedBlocksDescription": "Last 100 blocks", "proposedBlocks": "Proposed Blocks", + "search": "Search by name", "snapshot": "Validator Snapshot", "shares": "Shares", "sharesDocs": "How shares are calculated", diff --git a/src/oasis-nexus/api.ts b/src/oasis-nexus/api.ts index 48a26b745..fdc10bfed 100644 --- a/src/oasis-nexus/api.ts +++ b/src/oasis-nexus/api.ts @@ -89,6 +89,7 @@ declare module './generated/api' { export interface Validator { ticker: Ticker + rank: number } export interface Proposal { @@ -965,10 +966,11 @@ export const useGetConsensusValidators: typeof generated.useGetConsensusValidato ...arrayify(axios.defaults.transformResponse), (data: generated.ValidatorList, headers, status): ExtendedValidatorList => { if (status !== 200) return data - const validators = data.validators.map((validator): generated.Validator => { + const validators = data.validators.map((validator, index): generated.Validator => { return { ...validator, escrow: fromBaseUnits(validator.escrow, consensusDecimals), + rank: index + (params?.offset ?? 0), // TODO: remove this when rank is added to API ticker, } })