Skip to content

Commit

Permalink
feat: sync url with filters (#148)
Browse files Browse the repository at this point in the history
fixes #145 
- Chain picker and time picker will now sync with the URL. 
- Navigation to this URL will make it so that these filters will remain
and be sent to the message query
- For the chains, the keys are `origin` and `destination` and their
value will be a `domainId`. Inputting an invalid `domainId` will prompt
the `SearchChainError` component to show
- For the time, the keys are `startTime` and `endTime` and their value
is a number. The initial load of the values are validated by the
function `tryToDecimalNumber()`
- Refactor functions to allow multiple query params.
  • Loading branch information
Xaroz authored Dec 12, 2024
1 parent 0585bff commit 1f9e154
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 29 deletions.
2 changes: 1 addition & 1 deletion src/components/search/SearchFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/components/search/SearchStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,14 @@ export function SearchUnknownError({ show }: { show: boolean }) {
/>
);
}

export function SearchChainError({ show }: { show: boolean }) {
return (
<SearchError
show={show}
imgSrc={ErrorIcon}
text="Sorry, either the origin or the destination chain is invalid. Please choose a different chain or clear your search filters."
imgWidth={70}
/>
);
}
88 changes: 71 additions & 17 deletions src/features/messages/MessageSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [destinationChainFilter, setDestinationChainFilter] = useState<string | null>(null);
const [startTimeFilter, setStartTimeFilter] = useState<number | null>(null);
const [endTimeFilter, setEndTimeFilter] = useState<number | null>(null);
const [originChainFilter, setOriginChainFilter] = useState<string | null>(
defaultOriginQuery || null,
);
const [destinationChainFilter, setDestinationChainFilter] = useState<string | null>(
defaultDestinationQuery || null,
);
const [startTimeFilter, setStartTimeFilter] = useState<number | null>(
tryToDecimalNumber(defaultStartTime),
);
const [endTimeFilter, setEndTimeFilter] = useState<number | null>(
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 {
Expand All @@ -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 (
<>
Expand All @@ -96,7 +149,7 @@ export function MessageSearch() {
onChangeEndTimestamp={setEndTimeFilter}
/>
</div>
<Fade show={!isAnyError && isValidInput && isAnyMessageFound && !!multiProvider}>
<Fade show={showMessageTable}>
<MessageTable messageList={messageListResult} isFetching={isAnyFetching} />
</Fade>
<SearchFetching
Expand All @@ -110,6 +163,7 @@ export function MessageSearch() {
/>
<SearchUnknownError show={isAnyError && isValidInput} />
<SearchInvalidError show={!isValidInput} allowAddress={true} />
<SearchChainError show={(!isValidOrigin || !isValidDestination) && isValidInput} />
</Card>
</>
);
Expand Down
19 changes: 16 additions & 3 deletions src/features/messages/queries/useMessageQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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],
Expand Down Expand Up @@ -90,6 +101,8 @@ export function useMessageSearchQuery(
hasRun: !!data,
isMessagesFound,
messageList,
isValidOrigin,
isValidDestination,
};
}

Expand Down
35 changes: 27 additions & 8 deletions src/utils/queryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) {
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
Expand Down

0 comments on commit 1f9e154

Please sign in to comment.