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 { 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/pages/TokenDashboardPage/hook.ts b/src/app/pages/TokenDashboardPage/hook.ts index 84af94f76..4226ce207 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', @@ -87,16 +87,16 @@ export const _useTokenTransfers = (scope: SearchScope, params: undefined | GetRu const { isFetched, isLoading, data } = query - const results = pagination.getResults(data?.data) + const results = pagination.getResults(isLoading, isFetched, data?.data) - if (isFetched && pagination.selectedPage > 1 && !results.data?.length) { + if (isFetched && pagination.selectedPageForClient > 1 && !results.data?.length) { throw AppErrors.PageDoesNotExist } return { isLoading, isFetched, - results: pagination.getResults(data?.data), + results, } } 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]) }