Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/api/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { gql, useQuery } from '@apollo/client';

import { SEARCH_PREVIEW_RESULTS_LIMIT } from '@/constants/search-preview-results-limit';
import type { SearchAllResponse } from '@/schemas';
import { collapseSearchTransactions } from '@/utils/collapse-search-transactions';
import type DataFetcher from '@/utils/fetcher';
import { getGqlString } from '@/utils/get-gql-string';

Expand Down Expand Up @@ -155,7 +156,7 @@ function normalizeSearchResponse(
data: Partial<SearchAllResponse> | null | undefined
): SearchAllResponse {
return {
transactions: data?.transactions ?? [],
transactions: collapseSearchTransactions(data?.transactions ?? []),
accounts: data?.accounts ?? [],
blocks: data?.blocks ?? [],
highSecuritySets: data?.highSecuritySets ?? [],
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/landing/hero/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { SearchBox } from '@/components/ui/composites/search-box/SearchBox';
import { ContentContainer } from '@/components/ui/content-container';
import { SectionContainer } from '@/components/ui/section-container';
import { useChainSearch } from '@/hooks/useChainSearch';

import { SearchPreview } from '../../../ui/composites/search-preview/SearchPreview';
import { ChainStats } from './chain-stats/ChainStats';
import { useHero } from './hook';

export const Hero = () => {
const {
Expand All @@ -18,7 +18,7 @@ export const Hero = () => {
searchLoading,
searchResult,
handleClosePreview
} = useHero();
} = useChainSearch();

return (
<SectionContainer>
Expand Down
69 changes: 4 additions & 65 deletions src/components/layout/header/hook.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { useLocation } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useOnClickOutside } from 'usehooks-ts';
import { useEffect, useState } from 'react';

import useApiClient from '@/api';
import type { SearchAllResponse } from '@/schemas/searchs';
import { useChainSearch } from '@/hooks/useChainSearch';

export const useHeader = () => {
const api = useApiClient();
const location = useLocation().pathname;
const [isOpen, setIsOpen] = useState(false);
const toggleMenu = () => setIsOpen((open) => !open);
Expand All @@ -16,67 +12,10 @@ export const useHeader = () => {
setIsOpen(false);
}, [location]);

const [searchResult, setSearchResult] = useState<SearchAllResponse>();
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string>();

const [isResultVisible, setIsResultVisible] = useState(false);

const inputRef = useRef<HTMLDivElement>(null);
const resultRef = useRef<HTMLDivElement>(null);
useOnClickOutside([resultRef, inputRef] as any, () =>
setIsResultVisible(false)
);

const handleClosePreview = () => {
setIsResultVisible(false);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.currentTarget.blur();
setIsResultVisible(false);
}
};

const handleInputFocus = () => {
setIsResultVisible(true);
};

const handleKeywordChange = async (val: string) => {
const keyword = val.trim();

if (!keyword) {
setSearchResult(undefined);
return;
}

try {
setSearchError(undefined);
setSearchLoading(true);

const { data } = await api.search.all().query(keyword);

setSearchResult(data);
setSearchLoading(false);
} catch (err: any) {
toast.error(err.message);
setSearchError(err.message);
setSearchLoading(false);
}
};
const search = useChainSearch();

return {
isResultVisible,
resultRef,
inputRef,
handleClosePreview,
handleKeywordChange,
handleKeyDown,
handleInputFocus,
searchResult,
searchLoading,
searchError,
...search,
toggleMenu,
isOpen
};
Expand Down
24 changes: 12 additions & 12 deletions src/components/ui/composites/search-preview/SearchPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import React, { forwardRef } from 'react';

import { InlineFetchError } from '@/components/ui/composites/fetch-error/FetchError';
import { Skeleton } from '@/components/ui/skeleton';
import { RESOURCES } from '@/constants/resources';
import type { SearchAllResponse } from '@/schemas/searchs';
import { formatBlockHeight } from '@/utils/formatter';
import { getUnifiedTransactionDetailPath } from '@/utils/get-unified-transaction-detail-path';
import {
getAccountSearchPath,
getBlockSearchPath,
getErrorEventSearchPath,
getHighSecuritySetSearchPath,
getTransactionSearchPath
} from '@/utils/get-top-search-result-path';

// Helper: Preview link
function PreviewLink({
Expand Down Expand Up @@ -124,12 +129,7 @@ export const SearchPreview = forwardRef<HTMLDivElement, SearchPreviewProps>(
emptyMsg: 'No transactions found.',
items: transactions,
renderItem: (tx: SearchAllResponse['transactions'][number]) => {
const href = getUnifiedTransactionDetailPath({
type: tx.type,
hash: tx.hash,
detailId: tx.detail_id,
block: tx.block
});
const href = getTransactionSearchPath(tx);
const label = tx.hash ?? tx.detail_id ?? tx.id;

return (
Expand All @@ -147,7 +147,7 @@ export const SearchPreview = forwardRef<HTMLDivElement, SearchPreviewProps>(
items: accounts,
renderItem: (acc: any) => (
<PreviewLink
href={`${RESOURCES.accounts}/${acc.id}`}
href={getAccountSearchPath(acc.id)}
label={`${acc.id}`}
onSelect={handleClosePreview}
/>
Expand All @@ -159,7 +159,7 @@ export const SearchPreview = forwardRef<HTMLDivElement, SearchPreviewProps>(
items: blocks,
renderItem: (block: any) => (
<PreviewLink
href={`${RESOURCES.blocks}/${block.height}`}
href={getBlockSearchPath(block.height)}
label={formatBlockHeight(block.height)}
onSelect={handleClosePreview}
/>
Expand All @@ -171,7 +171,7 @@ export const SearchPreview = forwardRef<HTMLDivElement, SearchPreviewProps>(
items: highSecuritySets,
renderItem: (highSecuritySet: any) => (
<PreviewLink
href={`${RESOURCES.highSecuritySets}/${highSecuritySet.extrinsic?.id}`}
href={getHighSecuritySetSearchPath(highSecuritySet.extrinsic?.id)}
label={`${highSecuritySet.extrinsic?.id}`}
onSelect={handleClosePreview}
/>
Expand All @@ -183,7 +183,7 @@ export const SearchPreview = forwardRef<HTMLDivElement, SearchPreviewProps>(
items: errorEvents,
renderItem: (errorEvent: any) => (
<PreviewLink
href={`${RESOURCES.errors}/${errorEvent.extrinsic?.id}`}
href={getErrorEventSearchPath(errorEvent.extrinsic?.id)}
label={`${errorEvent.extrinsic?.id}`}
onSelect={handleClosePreview}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { useRef, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { type KeyboardEvent, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useOnClickOutside } from 'usehooks-ts';

import useApiClient from '@/api';
import type { SearchAllResponse } from '@/schemas/searchs';
import { topSearchResultPathOnEnter } from '@/utils/get-top-search-result-path';

export const useHero = () => {
export const useChainSearch = () => {
const api = useApiClient();
const navigate = useNavigate();

const [searchResult, setSearchResult] = useState<SearchAllResponse>();
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string>();
const [resultKeyword, setResultKeyword] = useState<string>();
const [isResultVisible, setIsResultVisible] = useState(false);

const inputRef = useRef<HTMLDivElement>(null);
Expand All @@ -23,11 +27,33 @@ export const useHero = () => {
setIsResultVisible(false);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Escape') {
e.currentTarget.blur();
setIsResultVisible(false);
return;
}

const inputValue =
e.target instanceof HTMLInputElement ? e.target.value : '';
const href = topSearchResultPathOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
targetIsKeywordInput:
e.target instanceof HTMLInputElement && e.target.name === 'keyword',
isResultVisible,
isLoading: searchLoading,
hasError: Boolean(searchError),
inputValue,
resultKeyword,
result: searchResult
});

if (!href) return;

e.preventDefault();
setIsResultVisible(false);
navigate({ href });
};

const handleInputFocus = () => {
Expand All @@ -39,15 +65,18 @@ export const useHero = () => {

if (!keyword) {
setSearchResult(undefined);
setResultKeyword(undefined);
return;
}

try {
setSearchError(undefined);
setSearchLoading(true);

const { data } = await api.search.all().query(keyword);

setSearchResult(data);
setResultKeyword(keyword);
setSearchLoading(false);
} catch (err: any) {
toast.error(err.message);
Expand Down
114 changes: 114 additions & 0 deletions src/utils/collapse-search-transactions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { SearchAllResponse } from '@/schemas/searchs';

import { collapseSearchTransactions } from './collapse-search-transactions';

type SearchTransaction = SearchAllResponse['transactions'][number];

const block = { height: 1146141, hash: '0xblock' };

function tx(
overrides: Partial<SearchTransaction> & Pick<SearchTransaction, 'id'>
): SearchTransaction {
return {
type: 'IMMEDIATE',
hash: '0xbatch',
detail_id: 'detail',
block,
...overrides
};
}

describe('collapseSearchTransactions', () => {
it('collapses a batch transfer that shares one hash into a single result', () => {
const first = tx({
id: 'immediate:0001146141-7dac3-000024',
detail_id: '0001146141-7dac3-000024'
});
const transactions = [
first,
tx({
id: 'immediate:0001146141-7dac3-000026',
detail_id: '0001146141-7dac3-000026'
}),
tx({
id: 'immediate:0001146141-7dac3-000028',
detail_id: '0001146141-7dac3-000028'
})
];

expect(collapseSearchTransactions(transactions)).toEqual([first]);
});

it('collapses wormhole batch rows that share a hash and detail id', () => {
const hash =
'0xa5e88ba79e61cd47275834b02f8cb1417299043ba608035218407c6fde9a68ef';
const first = tx({
id: 'wormhole:0001146151-ac4cc-000004',
type: 'WORMHOLE',
hash,
detail_id: hash
});

expect(
collapseSearchTransactions([
first,
tx({
id: 'wormhole:0001146151-ac4cc-000008',
type: 'WORMHOLE',
hash,
detail_id: hash
})
])
).toEqual([first]);
});

it('keeps transactions that open different pages', () => {
const batch = tx({
id: 'immediate:batch-a',
hash: '0xaaa',
detail_id: 'batch-a-1'
});
const other = tx({
id: 'immediate:other',
hash: '0xbbb',
detail_id: 'other'
});
const reversible = tx({
id: 'scheduled:1',
type: 'SCHEDULED_REVERSIBLE',
hash: '0xccc',
detail_id: 'sched-1'
});
const transferWithoutHash = tx({
id: 'immediate:no-hash-1',
hash: null,
detail_id: '0001151210-f703d-000004'
});
const anotherWithoutHash = tx({
id: 'immediate:no-hash-2',
hash: null,
detail_id: '0001151209-d3fae-000004'
});

expect(
collapseSearchTransactions([
batch,
tx({
id: 'immediate:batch-a-2',
hash: '0xaaa',
detail_id: 'batch-a-2'
}),
other,
reversible,
transferWithoutHash,
anotherWithoutHash
])
).toEqual([
batch,
other,
reversible,
transferWithoutHash,
anotherWithoutHash
]);
});
});
Loading
Loading