From 1b4791660f16517eb4f60f483752127748ac919b Mon Sep 17 00:00:00 2001 From: Adam Tomaszczyk Date: Tue, 26 Aug 2025 11:21:41 +0200 Subject: [PATCH] DRep search improvements #4030 --- govtool/backend/sql/list-dreps.sql | 8 +- govtool/backend/src/VVA/DRep.hs | 7 +- .../components/molecules/DataActionsBar.tsx | 18 ++- .../queries/useGetDRepListInfiniteQuery.ts | 118 ++++++++++++++++ .../src/hooks/queries/useGetDRepListQuery.ts | 131 +++++++++++------- .../hooks/queries/useGetDrepDetailsQuery.ts | 2 +- govtool/frontend/src/i18n/locales/en.json | 1 + .../src/pages/DRepDirectoryContent.tsx | 95 +++++++++---- 8 files changed, 293 insertions(+), 87 deletions(-) create mode 100644 govtool/frontend/src/hooks/queries/useGetDRepListInfiniteQuery.ts diff --git a/govtool/backend/sql/list-dreps.sql b/govtool/backend/sql/list-dreps.sql index 1ae00d6fa..0d7be1697 100644 --- a/govtool/backend/sql/list-dreps.sql +++ b/govtool/backend/sql/list-dreps.sql @@ -317,10 +317,6 @@ WHERE ( COALESCE(?, '') = '' OR (CASE WHEN LENGTH(?) % 2 = 0 AND ? ~ '^[0-9a-fA-F]+$' THEN drep_hash = ? ELSE false END) OR - view ILIKE ? OR - given_name ILIKE ? OR - payment_address ILIKE ? OR - objectives ILIKE ? OR - motivations ILIKE ? OR - qualifications ILIKE ? + (CASE WHEN lower(?) ~ '^drep1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$' THEN view = lower(?) ELSE FALSE END) OR + given_name ILIKE ? ) diff --git a/govtool/backend/src/VVA/DRep.hs b/govtool/backend/src/VVA/DRep.hs index 39342a7eb..8e6f7ceae 100644 --- a/govtool/backend/src/VVA/DRep.hs +++ b/govtool/backend/src/VVA/DRep.hs @@ -86,12 +86,9 @@ listDReps mSearchQuery = withPool $ \conn -> do , searchParam -- LENGTH(?) , searchParam -- AND ? , searchParam -- decode(?, 'hex') - , "%" <> searchParam <> "%" -- dh.view + , searchParam -- lower(?) + , searchParam -- lower(?) , "%" <> searchParam <> "%" -- given_name - , "%" <> searchParam <> "%" -- payment_address - , "%" <> searchParam <> "%" -- objectives - , "%" <> searchParam <> "%" -- motivations - , "%" <> searchParam <> "%" -- qualifications ) :: IO [DRepQueryResult]) timeZone <- liftIO getCurrentTimeZone diff --git a/govtool/frontend/src/components/molecules/DataActionsBar.tsx b/govtool/frontend/src/components/molecules/DataActionsBar.tsx index 223fef97e..17183f3e0 100644 --- a/govtool/frontend/src/components/molecules/DataActionsBar.tsx +++ b/govtool/frontend/src/components/molecules/DataActionsBar.tsx @@ -1,6 +1,7 @@ import { Dispatch, FC, SetStateAction } from "react"; -import { Box, InputBase } from "@mui/material"; +import { Box, InputBase, IconButton } from "@mui/material"; import Search from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; import { DataActionsFilters, DataActionsSorting } from "@molecules"; import { OrderActionsChip } from "./OrderActionsChip"; @@ -20,6 +21,7 @@ type DataActionsBarProps = { filtersTitle?: string; isFiltering?: boolean; searchText: string; + placeholder?: string; setChosenFilters?: Dispatch>; setChosenSorting: Dispatch>; setFiltersOpen?: Dispatch>; @@ -51,6 +53,7 @@ export const DataActionsBar: FC = ({ ...props }) => { setSortOpen, sortOpen, sortOptions = [], + placeholder = "Search...", } = props; const { palette: { boxShadow2 }, @@ -61,7 +64,7 @@ export const DataActionsBar: FC = ({ ...props }) => { setSearchText(e.target.value)} - placeholder="Search..." + placeholder={placeholder} value={searchText} startAdornment={ = ({ ...props }) => { }} /> } + endAdornment={ + searchText && ( + setSearchText("")} + sx={{ ml: 1 }} + > + + + ) + } sx={{ bgcolor: "white", border: 1, diff --git a/govtool/frontend/src/hooks/queries/useGetDRepListInfiniteQuery.ts b/govtool/frontend/src/hooks/queries/useGetDRepListInfiniteQuery.ts new file mode 100644 index 000000000..2465d9926 --- /dev/null +++ b/govtool/frontend/src/hooks/queries/useGetDRepListInfiniteQuery.ts @@ -0,0 +1,118 @@ +import { + UseInfiniteQueryOptions, + useInfiniteQuery, + useQuery, +} from "react-query"; +import { useRef, useMemo } from "react"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { GetDRepListArguments, getDRepList } from "@services"; +import { DRepData, Infinite } from "@/models"; + +const makeStatusKey = (status?: string[] | undefined) => + (status && status.length ? [...status].sort().join("|") : "__EMPTY__"); + +export const useGetDRepListInfiniteQuery = ( + { + filters = [], + pageSize = 10, + searchPhrase, + sorting, + status, + }: GetDRepListArguments, + options?: UseInfiniteQueryOptions>, +) => { + const { pendingTransaction } = useCardano(); + const totalsByStatusRef = useRef>({}); + const statusKey = useMemo(() => makeStatusKey(status), [status]); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isPreviousData, + } = useInfiniteQuery( + [ + QUERY_KEYS.useGetDRepListInfiniteKey, + ( + pendingTransaction.registerAsDirectVoter || + pendingTransaction.registerAsDrep || + pendingTransaction.retireAsDirectVoter || + pendingTransaction.retireAsDrep + )?.transactionHash ?? "noPendingTransaction", + filters.length ? filters : "", + searchPhrase ?? "", + sorting ?? "", + status?.length ? status : "", + ], + async ({ pageParam = 0 }) => + getDRepList({ + page: pageParam, + pageSize, + filters, + searchPhrase, + sorting, + status, + }), + { + getNextPageParam: (lastPage) => { + if (lastPage.elements.length === 0) return undefined; + return lastPage.page + 1; + }, + enabled: options?.enabled, + keepPreviousData: options?.keepPreviousData, + onSuccess: (pagesData) => { + if (!searchPhrase) { + const firstPage = pagesData.pages?.[0]; + if (firstPage && typeof firstPage.total === "number") { + totalsByStatusRef.current[statusKey] = firstPage.total; + } + } + options?.onSuccess?.(pagesData); + }, + }, + ); + + useQuery( + [QUERY_KEYS.useGetDRepListInfiniteKey, "baseline", statusKey], + async () => { + const resp = await getDRepList({ + page: 0, + pageSize: 1, + filters, + searchPhrase: "", + sorting, + status, + }); + return resp; + }, + { + enabled: + options?.enabled && + searchPhrase !== "" && + totalsByStatusRef.current[statusKey] === undefined, + onSuccess: (resp) => { + if (typeof resp.total === "number") { + totalsByStatusRef.current[statusKey] = resp.total; + } + }, + }, + ); + + return { + dRepListFetchNextPage: fetchNextPage, + dRepListHasNextPage: hasNextPage, + isDRepListFetching: isFetching, + isDRepListFetchingNextPage: isFetchingNextPage, + isDRepListLoading: isLoading, + dRepData: data?.pages.flatMap((page) => page.elements), + isPreviousData, + dRepListTotal: data?.pages[0].total, + dRepTotalsByStatus: totalsByStatusRef.current, + dRepBaselineTotalForStatus: totalsByStatusRef.current[statusKey], + }; +}; diff --git a/govtool/frontend/src/hooks/queries/useGetDRepListQuery.ts b/govtool/frontend/src/hooks/queries/useGetDRepListQuery.ts index c6f0f968b..5eedc12df 100644 --- a/govtool/frontend/src/hooks/queries/useGetDRepListQuery.ts +++ b/govtool/frontend/src/hooks/queries/useGetDRepListQuery.ts @@ -1,47 +1,58 @@ -import { UseInfiniteQueryOptions, useInfiniteQuery } from "react-query"; +import { useMemo, useRef } from "react"; +import { useQuery, UseQueryOptions } from "react-query"; import { QUERY_KEYS } from "@consts"; import { useCardano } from "@context"; import { GetDRepListArguments, getDRepList } from "@services"; import { DRepData, Infinite } from "@/models"; -export const useGetDRepListInfiniteQuery = ( - { - filters = [], - pageSize = 10, - searchPhrase, - sorting, - status, - }: GetDRepListArguments, - options?: UseInfiniteQueryOptions>, -) => { +const makeStatusKey = (status?: string[] | undefined) => + (status && status.length ? [...status].sort().join("|") : "__EMPTY__"); + +type PaginatedResult = { + dRepData: DRepData[] | undefined; + isLoading: boolean; + isFetching: boolean; + isPreviousData: boolean; + total: number | undefined; + baselineTotalForStatus: number | undefined; +}; + +type Args = GetDRepListArguments & { + page: number; + pageSize?: number; +}; + +export function useGetDRepListPaginatedQuery( + { page, pageSize = 10, filters = [], searchPhrase, sorting, status }: Args, + options?: UseQueryOptions>, +): PaginatedResult { const { pendingTransaction } = useCardano(); + const totalsByStatusRef = useRef>({}); + const statusKey = useMemo(() => makeStatusKey(status), [status]); - const { - data, - isLoading, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - isPreviousData, - } = useInfiniteQuery( - [ - QUERY_KEYS.useGetDRepListInfiniteKey, - ( - pendingTransaction.registerAsDirectVoter || - pendingTransaction.registerAsDrep || - pendingTransaction.retireAsDirectVoter || - pendingTransaction.retireAsDrep - )?.transactionHash ?? "noPendingTransaction", - filters.length ? filters : "", - searchPhrase ?? "", - sorting ?? "", - status?.length ? status : "", - ], - async ({ pageParam = 0 }) => + const queryKey = [ + QUERY_KEYS.useGetDRepListInfiniteKey, + ( + pendingTransaction.registerAsDirectVoter || + pendingTransaction.registerAsDrep || + pendingTransaction.retireAsDirectVoter || + pendingTransaction.retireAsDrep + )?.transactionHash ?? "noPendingTransaction", + "paged", + page, + pageSize, + filters.length ? filters : "", + searchPhrase ?? "", + sorting ?? "", + status?.length ? status : "", + ]; + + const { data, isLoading, isFetching, isPreviousData } = useQuery( + queryKey, + async () => getDRepList({ - page: pageParam, + page, pageSize, filters, searchPhrase, @@ -49,25 +60,49 @@ export const useGetDRepListInfiniteQuery = ( status, }), { - getNextPageParam: (lastPage) => { - if (lastPage.elements.length === 0) { - return undefined; + keepPreviousData: true, + enabled: options?.enabled, + onSuccess: (resp) => { + if (!searchPhrase && typeof resp?.total === "number") { + totalsByStatusRef.current[statusKey] = resp.total; } + options?.onSuccess?.(resp); + }, + }, + ); - return lastPage.page + 1; + useQuery( + [QUERY_KEYS.useGetDRepListInfiniteKey, "baseline", statusKey], + async () => { + const resp = await getDRepList({ + page: 0, + pageSize: 1, + filters, + searchPhrase: "", + sorting, + status, + }); + return resp; + }, + { + enabled: + options?.enabled && + searchPhrase !== "" && + totalsByStatusRef.current[statusKey] === undefined, + onSuccess: (resp) => { + if (typeof resp.total === "number") { + totalsByStatusRef.current[statusKey] = resp.total; + } }, - enabled: options?.enabled, - keepPreviousData: options?.keepPreviousData, }, ); return { - dRepListFetchNextPage: fetchNextPage, - dRepListHasNextPage: hasNextPage, - isDRepListFetching: isFetching, - isDRepListFetchingNextPage: isFetchingNextPage, - isDRepListLoading: isLoading, - dRepData: data?.pages.flatMap((page) => page.elements), + dRepData: data?.elements, + isLoading, + isFetching, isPreviousData, + total: data?.total, + baselineTotalForStatus: totalsByStatusRef.current[statusKey], }; -}; +} diff --git a/govtool/frontend/src/hooks/queries/useGetDrepDetailsQuery.ts b/govtool/frontend/src/hooks/queries/useGetDrepDetailsQuery.ts index 3c99959ff..d4e39aefa 100644 --- a/govtool/frontend/src/hooks/queries/useGetDrepDetailsQuery.ts +++ b/govtool/frontend/src/hooks/queries/useGetDrepDetailsQuery.ts @@ -1,7 +1,7 @@ import { UseInfiniteQueryOptions } from "react-query"; import { Infinite, DRepData } from "@/models"; -import { useGetDRepListInfiniteQuery } from "./useGetDRepListQuery"; +import { useGetDRepListInfiniteQuery } from "./useGetDRepListInfiniteQuery"; export const useGetDRepDetailsQuery = ( dRepId: string | null | undefined, diff --git a/govtool/frontend/src/i18n/locales/en.json b/govtool/frontend/src/i18n/locales/en.json index cca6a27e3..c4b054471 100644 --- a/govtool/frontend/src/i18n/locales/en.json +++ b/govtool/frontend/src/i18n/locales/en.json @@ -314,6 +314,7 @@ "myDelegationToYourself": "You have delegated ₳ {{ada}} to yourself", "myDRep": "You have delegated ₳ {{ada}} to this DRep", "listTitle": "Find a DRep", + "searchBarPlaceholder": "Search for a DRep name or ID", "noConfidenceDefaultDescription": "Select this to signal no confidence in the current constitutional committee by voting NO on every proposal and voting YES to no confidence proposals", "noConfidenceDefaultTitle": "Signal No Confidence on Every Vote", "noResultsForTheSearchTitle": "No DReps found", diff --git a/govtool/frontend/src/pages/DRepDirectoryContent.tsx b/govtool/frontend/src/pages/DRepDirectoryContent.tsx index 47b1b1006..f95ad447c 100644 --- a/govtool/frontend/src/pages/DRepDirectoryContent.tsx +++ b/govtool/frontend/src/pages/DRepDirectoryContent.tsx @@ -1,8 +1,8 @@ -import { FC, useEffect, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { Box, CircularProgress } from "@mui/material"; +import { Box, CircularProgress, Pagination } from "@mui/material"; -import { Button, Typography } from "@atoms"; +import { Typography } from "@atoms"; import { DREP_DIRECTORY_FILTERS, DREP_DIRECTORY_SORTING } from "@consts"; import { useCardano, useDataActionsBar } from "@context"; import { @@ -10,7 +10,7 @@ import { useGetAdaHolderCurrentDelegationQuery, useGetAdaHolderVotingPowerQuery, useGetDRepDetailsQuery, - useGetDRepListInfiniteQuery, + useGetDRepListPaginatedQuery, } from "@hooks"; import { DataActionsBar, EmptyStateDrepDirectory } from "@molecules"; import { AutomatedVotingOptions, DRepCard } from "@organisms"; @@ -64,8 +64,12 @@ export const DRepDirectoryContent: FC = ({ const [inProgressDelegationDRepData, setInProgressDelegationDRepData] = useState(undefined); + const [page, setPage] = useState(1); + const pageSize = 10; + // Set initial filters and sort useEffect(() => { + // TODO: it should be done only if last page URL WASN'T like /drep_directory/drep1.* setChosenFilters([DRepStatus.Active]); setSearchText(""); // <--- Clear the search field on mount }, []); @@ -74,6 +78,11 @@ export const DRepDirectoryContent: FC = ({ if (!chosenSorting) setChosenSorting(DRepListSort.Random); }, [chosenSorting, setChosenSorting]); + useEffect(() => { + // TODO: it should be done only if last page URL WASN'T like /drep_directory/drep1.* + setPage(1); + }, [debouncedSearchText, chosenSorting, JSON.stringify(chosenFilters)]); + const { delegate, isDelegating } = useDelegateTodRep(); const { votingPower } = useGetAdaHolderVotingPowerQuery(stakeKey); @@ -87,21 +96,26 @@ export const DRepDirectoryContent: FC = ({ const { dRepData: dRepList, - isPreviousData, - dRepListHasNextPage, - dRepListFetchNextPage, - } = useGetDRepListInfiniteQuery( + isFetching, + isPreviousData: isPrev, + total, + baselineTotalForStatus, + } = useGetDRepListPaginatedQuery( { + page: page - 1, // convert 1-based UI -> 0-based API + pageSize, searchPhrase: debouncedSearchText, sorting: chosenSorting as DRepListSort, status: chosenFilters as DRepStatus[], }, - { - enabled: !!chosenSorting, - keepPreviousData: true, - }, + { enabled: !!chosenSorting }, ); + const showSearchSummary = + searchText !== "" && + (!isFetching || !isPrev) && + total !== baselineTotalForStatus; + useEffect(() => { if (!inProgressDelegation && prevInProgressDelegation) { setInProgressDelegationDRepData(undefined); @@ -128,6 +142,9 @@ export const DRepDirectoryContent: FC = ({ "view", ); + const totalForPaging = typeof total === "number" ? total : 0; + const pageCount = Math.max(1, Math.ceil(totalForPaging / pageSize)); + const isAnAutomatedVotingOptionChosen = currentDelegation?.dRepView && (currentDelegation?.dRepView === @@ -212,16 +229,41 @@ export const DRepDirectoryContent: FC = ({ filterOptions={DREP_DIRECTORY_FILTERS} filtersTitle={t("dRepDirectory.filterTitle")} sortOptions={DREP_DIRECTORY_SORTING} + placeholder={t("dRepDirectory.searchBarPlaceholder")} /> + + {showSearchSummary && ( + + + ) : ( + + ), + }} + /> + + )} = ({ ))} + + {pageCount > 1 && ( + + setPage(newPage)} + shape="rounded" + variant="outlined" + siblingCount={1} + boundaryCount={1} + /> + + )} - {dRepListHasNextPage && dRepList.length >= 10 && ( - - - - )} ); };