diff --git a/src/components/search/SearchFilterBar.tsx b/src/components/search/SearchFilterBar.tsx index b638726..cf577d8 100644 --- a/src/components/search/SearchFilterBar.tsx +++ b/src/components/search/SearchFilterBar.tsx @@ -71,7 +71,7 @@ function ChainSelector({ const multiProvider = useMultiProvider(); - const chainName = value ? multiProvider.getChainName(value) : undefined; + const chainName = value ? multiProvider.tryGetChainName(value) : undefined; const chainDisplayName = chainName ? trimToLength(getChainDisplayName(multiProvider, chainName, true), 12) : undefined; diff --git a/src/components/search/SearchStates.tsx b/src/components/search/SearchStates.tsx index 2da819d..765de27 100644 --- a/src/components/search/SearchStates.tsx +++ b/src/components/search/SearchStates.tsx @@ -118,3 +118,14 @@ export function SearchUnknownError({ show }: { show: boolean }) { /> ); } + +export function SearchChainError({ show }: { show: boolean }) { + return ( + + ); +} diff --git a/src/features/messages/MessageSearch.tsx b/src/features/messages/MessageSearch.tsx index 4b47353..2ff4ec0 100644 --- a/src/features/messages/MessageSearch.tsx +++ b/src/features/messages/MessageSearch.tsx @@ -6,47 +6,85 @@ import { Card } from '../../components/layout/Card'; import { SearchBar } from '../../components/search/SearchBar'; import { SearchFilterBar } from '../../components/search/SearchFilterBar'; import { + SearchChainError, SearchEmptyError, SearchFetching, SearchInvalidError, SearchUnknownError, } from '../../components/search/SearchStates'; import { useReadyMultiProvider } from '../../store'; -import { useQueryParam, useSyncQueryParam } from '../../utils/queryParams'; +import { useMultipleQueryParams, useSyncQueryParam } from '../../utils/queryParams'; import { sanitizeString } from '../../utils/string'; +import { tryToDecimalNumber } from '../../utils/number'; import { MessageTable } from './MessageTable'; import { usePiChainMessageSearchQuery } from './pi-queries/usePiChainMessageQuery'; import { useMessageSearchQuery } from './queries/useMessageQuery'; -const QUERY_SEARCH_PARAM = 'search'; +enum MESSAGE_QUERY_PARAMS { + SEARCH = 'search', + ORIGIN = 'origin', + DESTINATION = 'destination', + START_TIME = 'startTime', + END_TIME = 'endTime', +} export function MessageSearch() { // Chain metadata const multiProvider = useReadyMultiProvider(); + // query params + const [ + defaultSearchQuery, + defaultOriginQuery, + defaultDestinationQuery, + defaultStartTime, + defaultEndTime, + ] = useMultipleQueryParams([ + MESSAGE_QUERY_PARAMS.SEARCH, + MESSAGE_QUERY_PARAMS.ORIGIN, + MESSAGE_QUERY_PARAMS.DESTINATION, + MESSAGE_QUERY_PARAMS.START_TIME, + MESSAGE_QUERY_PARAMS.END_TIME, + ]); + // Search text input - const defaultSearchQuery = useQueryParam(QUERY_SEARCH_PARAM); const [searchInput, setSearchInput] = useState(defaultSearchQuery); const debouncedSearchInput = useDebounce(searchInput, 750); const hasInput = !!debouncedSearchInput; const sanitizedInput = sanitizeString(debouncedSearchInput); // Filter state - const [originChainFilter, setOriginChainFilter] = useState(null); - const [destinationChainFilter, setDestinationChainFilter] = useState(null); - const [startTimeFilter, setStartTimeFilter] = useState(null); - const [endTimeFilter, setEndTimeFilter] = useState(null); + const [originChainFilter, setOriginChainFilter] = useState( + defaultOriginQuery || null, + ); + const [destinationChainFilter, setDestinationChainFilter] = useState( + defaultDestinationQuery || null, + ); + const [startTimeFilter, setStartTimeFilter] = useState( + tryToDecimalNumber(defaultStartTime), + ); + const [endTimeFilter, setEndTimeFilter] = useState( + tryToDecimalNumber(defaultEndTime), + ); // GraphQL query and results - const { isValidInput, isError, isFetching, hasRun, messageList, isMessagesFound } = - useMessageSearchQuery( - sanitizedInput, - originChainFilter, - destinationChainFilter, - startTimeFilter, - endTimeFilter, - ); + const { + isValidInput, + isValidOrigin, + isValidDestination, + isError, + isFetching, + hasRun, + messageList, + isMessagesFound, + } = useMessageSearchQuery( + sanitizedInput, + originChainFilter, + destinationChainFilter, + startTimeFilter, + endTimeFilter, + ); // Run permissionless interop chains query if needed const { @@ -69,8 +107,23 @@ export function MessageSearch() { const isAnyMessageFound = isMessagesFound || isPiMessagesFound; const messageListResult = isMessagesFound ? messageList : piMessageList; + // Show message list if there are no errors and filters are valid + const showMessageTable = + !isAnyError && + isValidInput && + isValidOrigin && + isValidDestination && + isAnyMessageFound && + !!multiProvider; + // Keep url in sync - useSyncQueryParam(QUERY_SEARCH_PARAM, isValidInput ? sanitizedInput : ''); + useSyncQueryParam({ + [MESSAGE_QUERY_PARAMS.SEARCH]: isValidInput ? sanitizedInput : '', + [MESSAGE_QUERY_PARAMS.ORIGIN]: (isValidOrigin && originChainFilter) || '', + [MESSAGE_QUERY_PARAMS.DESTINATION]: (isValidDestination && destinationChainFilter) || '', + [MESSAGE_QUERY_PARAMS.START_TIME]: startTimeFilter !== null ? String(startTimeFilter) : '', + [MESSAGE_QUERY_PARAMS.END_TIME]: endTimeFilter !== null ? String(endTimeFilter) : '', + }); return ( <> @@ -96,7 +149,7 @@ export function MessageSearch() { onChangeEndTimestamp={setEndTimeFilter} /> - + + ); diff --git a/src/features/messages/queries/useMessageQuery.ts b/src/features/messages/queries/useMessageQuery.ts index 62be9d6..e0d4431 100644 --- a/src/features/messages/queries/useMessageQuery.ts +++ b/src/features/messages/queries/useMessageQuery.ts @@ -5,6 +5,7 @@ import { useMultiProvider } from '../../../store'; import { MessageStatus } from '../../../types'; import { useScrapedDomains } from '../../chains/queries/useScrapedChains'; +import { MultiProvider } from '@hyperlane-xyz/sdk'; import { useInterval } from '@hyperlane-xyz/widgets'; import { MessageIdentifierType, buildMessageQuery, buildMessageSearchQuery } from './build'; import { searchValueToPostgresBytea } from './encoding'; @@ -21,6 +22,11 @@ export function isValidSearchQuery(input: string) { return !!searchValueToPostgresBytea(input); } +export function isValidDomainId(domainId: string | null, multiProvider: MultiProvider) { + if (!domainId) return false; + return multiProvider.hasChain(domainId); +} + export function useMessageSearchQuery( sanitizedInput: string, originChainFilter: string | null, @@ -29,15 +35,21 @@ export function useMessageSearchQuery( endTimeFilter: number | null, ) { const { scrapedDomains: scrapedChains } = useScrapedDomains(); + const multiProvider = useMultiProvider(); const hasInput = !!sanitizedInput; const isValidInput = !hasInput || isValidSearchQuery(sanitizedInput); + // validating filters + const isValidOrigin = !originChainFilter || isValidDomainId(originChainFilter, multiProvider); + const isValidDestination = + !destinationChainFilter || isValidDomainId(destinationChainFilter, multiProvider); + // Assemble GraphQL query const { query, variables } = buildMessageSearchQuery( sanitizedInput, - originChainFilter, - destinationChainFilter, + isValidOrigin ? originChainFilter : null, + isValidDestination ? destinationChainFilter : null, startTimeFilter, endTimeFilter, hasInput ? SEARCH_QUERY_LIMIT : LATEST_QUERY_LIMIT, @@ -53,7 +65,6 @@ export function useMessageSearchQuery( const { data, fetching: isFetching, error } = result; // Parse results - const multiProvider = useMultiProvider(); const unfilteredMessageList = useMemo( () => parseMessageStubResult(multiProvider, scrapedChains, data), [multiProvider, scrapedChains, data], @@ -90,6 +101,8 @@ export function useMessageSearchQuery( hasRun: !!data, isMessagesFound, messageList, + isValidOrigin, + isValidDestination, }; } diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index aee9ee1..190edd2 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -15,28 +15,47 @@ export function getQueryParamString(query: ParsedUrlQuery, key: string, defaultV // Use query param form URL export function useQueryParam(key: string, defaultVal = '') { const router = useRouter(); + return getQueryParamString(router.query, key, defaultVal); } +export function useMultipleQueryParams(keys: string[]) { + const router = useRouter(); + + return keys.map((key) => { + return getQueryParamString(router.query, key); + }); +} + // Keep value in sync with query param in URL -export function useSyncQueryParam(key: string, value = '') { +export function useSyncQueryParam(params: Record) { const router = useRouter(); const { pathname, query } = router; useEffect(() => { + let hasChanged = false; const newQuery = new URLSearchParams( Object.fromEntries( Object.entries(query).filter((kv): kv is [string, string] => typeof kv[0] === 'string'), ), ); - if (value) newQuery.set(key, value); - else newQuery.delete(key); - const path = `${pathname}?${newQuery.toString()}`; - router - .replace(path, undefined, { shallow: true }) - .catch((e) => logger.error('Error shallow updating url', e)); + Object.entries(params).forEach(([key, value]) => { + if (value && newQuery.get(key) !== value) { + newQuery.set(key, value); + hasChanged = true; + } else if (!value && newQuery.has(key)) { + newQuery.delete(key); + hasChanged = true; + } + }); + if (hasChanged) { + const path = `${pathname}?${newQuery.toString()}`; + router + .replace(path, undefined, { shallow: true }) + .catch((e) => logger.error('Error shallow updating URL', e)); + } // Must exclude router for next.js shallow routing, otherwise links break: // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, value]); + }, [params]); } // Circumventing Next's router.replace method here because