From deb6161533278c459db3a8ae36153205fb7c372c Mon Sep 17 00:00:00 2001 From: Beast Date: Wed, 23 Sep 2026 11:46:48 +0800 Subject: [PATCH 1/2] feat: update search UX --- src/components/features/landing/hero/Hero.tsx | 4 +- src/components/layout/header/hook.tsx | 69 +-------- .../search-preview/SearchPreview.tsx | 24 +-- .../hero/hook.tsx => hooks/useChainSearch.ts} | 35 ++++- src/utils/get-top-search-result-path.test.ts | 142 ++++++++++++++++++ src/utils/get-top-search-result-path.ts | 93 ++++++++++++ 6 files changed, 285 insertions(+), 82 deletions(-) rename src/{components/features/landing/hero/hook.tsx => hooks/useChainSearch.ts} (59%) create mode 100644 src/utils/get-top-search-result-path.test.ts create mode 100644 src/utils/get-top-search-result-path.ts diff --git a/src/components/features/landing/hero/Hero.tsx b/src/components/features/landing/hero/Hero.tsx index 31540d5..2438596 100644 --- a/src/components/features/landing/hero/Hero.tsx +++ b/src/components/features/landing/hero/Hero.tsx @@ -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 { @@ -18,7 +18,7 @@ export const Hero = () => { searchLoading, searchResult, handleClosePreview - } = useHero(); + } = useChainSearch(); return ( diff --git a/src/components/layout/header/hook.tsx b/src/components/layout/header/hook.tsx index 9595e1e..f0f3328 100644 --- a/src/components/layout/header/hook.tsx +++ b/src/components/layout/header/hook.tsx @@ -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); @@ -16,67 +12,10 @@ export const useHeader = () => { setIsOpen(false); }, [location]); - const [searchResult, setSearchResult] = useState(); - const [searchLoading, setSearchLoading] = useState(false); - const [searchError, setSearchError] = useState(); - - const [isResultVisible, setIsResultVisible] = useState(false); - - const inputRef = useRef(null); - const resultRef = useRef(null); - useOnClickOutside([resultRef, inputRef] as any, () => - setIsResultVisible(false) - ); - - const handleClosePreview = () => { - setIsResultVisible(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - 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 }; diff --git a/src/components/ui/composites/search-preview/SearchPreview.tsx b/src/components/ui/composites/search-preview/SearchPreview.tsx index 5bc874b..980f283 100644 --- a/src/components/ui/composites/search-preview/SearchPreview.tsx +++ b/src/components/ui/composites/search-preview/SearchPreview.tsx @@ -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({ @@ -124,12 +129,7 @@ export const SearchPreview = forwardRef( 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 ( @@ -147,7 +147,7 @@ export const SearchPreview = forwardRef( items: accounts, renderItem: (acc: any) => ( @@ -159,7 +159,7 @@ export const SearchPreview = forwardRef( items: blocks, renderItem: (block: any) => ( @@ -171,7 +171,7 @@ export const SearchPreview = forwardRef( items: highSecuritySets, renderItem: (highSecuritySet: any) => ( @@ -183,7 +183,7 @@ export const SearchPreview = forwardRef( items: errorEvents, renderItem: (errorEvent: any) => ( diff --git a/src/components/features/landing/hero/hook.tsx b/src/hooks/useChainSearch.ts similarity index 59% rename from src/components/features/landing/hero/hook.tsx rename to src/hooks/useChainSearch.ts index a2f2fb1..280bedc 100644 --- a/src/components/features/landing/hero/hook.tsx +++ b/src/hooks/useChainSearch.ts @@ -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(); const [searchLoading, setSearchLoading] = useState(false); const [searchError, setSearchError] = useState(); + const [resultKeyword, setResultKeyword] = useState(); const [isResultVisible, setIsResultVisible] = useState(false); const inputRef = useRef(null); @@ -23,11 +27,33 @@ export const useHero = () => { setIsResultVisible(false); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { 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 = () => { @@ -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); diff --git a/src/utils/get-top-search-result-path.test.ts b/src/utils/get-top-search-result-path.test.ts new file mode 100644 index 0000000..9b36854 --- /dev/null +++ b/src/utils/get-top-search-result-path.test.ts @@ -0,0 +1,142 @@ +import type { SearchAllResponse } from '@/schemas/searchs'; + +import { + getTopSearchResultPath, + topSearchResultPathOnEnter +} from './get-top-search-result-path'; + +const block = { height: 10, hash: '0xblock' }; + +const emptyResult = (): SearchAllResponse => ({ + transactions: [], + accounts: [], + blocks: [], + highSecuritySets: [], + errorEvents: [] +}); + +const immediateTx = { + id: 'tx-1', + type: 'IMMEDIATE' as const, + hash: '0xabc', + detail_id: 'detail-1', + block +}; + +describe('getTopSearchResultPath', () => { + it('returns undefined when there is no result', () => { + expect(getTopSearchResultPath(undefined)).toBeUndefined(); + expect(getTopSearchResultPath(emptyResult())).toBeUndefined(); + }); + + it('picks the first transaction before later sections', () => { + const result = emptyResult(); + result.transactions = [immediateTx]; + result.accounts = [{ id: 'acc-1' }]; + + expect(getTopSearchResultPath(result)).toBe('/transactions/0xabc'); + }); + + it('falls through empty sections in preview order', () => { + const accountsOnly = emptyResult(); + accountsOnly.accounts = [{ id: 'acc-1' }]; + expect(getTopSearchResultPath(accountsOnly)).toBe('/accounts/acc-1'); + + const blocksOnly = emptyResult(); + blocksOnly.blocks = [{ height: 42 }]; + expect(getTopSearchResultPath(blocksOnly)).toBe('/blocks/42'); + + const highSecurityOnly = emptyResult(); + highSecurityOnly.highSecuritySets = [ + { extrinsic: { id: 'hs-1' } } + ] as SearchAllResponse['highSecuritySets']; + expect(getTopSearchResultPath(highSecurityOnly)).toBe( + '/high-security-sets/hs-1' + ); + + const errorsOnly = emptyResult(); + errorsOnly.errorEvents = [ + { extrinsic: { id: 'err-1' } } + ] as SearchAllResponse['errorEvents']; + expect(getTopSearchResultPath(errorsOnly)).toBe('/errors/err-1'); + }); + + it('uses the transaction detail route for non-immediate types', () => { + const result = emptyResult(); + result.transactions = [ + { + ...immediateTx, + type: 'SCHEDULED_REVERSIBLE', + hash: null, + detail_id: 'sched-1' + } + ]; + + expect(getTopSearchResultPath(result)).toBe( + '/transactions/scheduled-reversible/sched-1' + ); + }); +}); + +describe('topSearchResultPathOnEnter', () => { + const ready = { + key: 'Enter', + isComposing: false, + targetIsKeywordInput: true, + isResultVisible: true, + isLoading: false, + hasError: false, + inputValue: 'acc-1', + resultKeyword: 'acc-1', + result: { + ...emptyResult(), + accounts: [{ id: 'acc-1' }] + } + }; + + it('returns the top result path when results are shown', () => { + expect(topSearchResultPathOnEnter(ready)).toBe('/accounts/acc-1'); + }); + + it('ignores Enter when results are hidden, loading, failed, or empty', () => { + expect( + topSearchResultPathOnEnter({ ...ready, isResultVisible: false }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, isLoading: true }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, hasError: true }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, result: emptyResult() }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, result: undefined }) + ).toBeUndefined(); + }); + + it('ignores Enter when the field no longer matches the shown query', () => { + expect( + topSearchResultPathOnEnter({ ...ready, inputValue: 'acc-1x' }) + ).toBeUndefined(); + }); + + it('ignores keys other than Enter, IME composition, and non-search targets', () => { + expect( + topSearchResultPathOnEnter({ ...ready, key: 'Escape' }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, isComposing: true }) + ).toBeUndefined(); + expect( + topSearchResultPathOnEnter({ ...ready, targetIsKeywordInput: false }) + ).toBeUndefined(); + }); + + it('treats surrounding whitespace as the same query', () => { + expect( + topSearchResultPathOnEnter({ ...ready, inputValue: ' acc-1 ' }) + ).toBe('/accounts/acc-1'); + }); +}); diff --git a/src/utils/get-top-search-result-path.ts b/src/utils/get-top-search-result-path.ts new file mode 100644 index 0000000..7e30b11 --- /dev/null +++ b/src/utils/get-top-search-result-path.ts @@ -0,0 +1,93 @@ +import { RESOURCES } from '@/constants/resources'; +import type { SearchAllResponse } from '@/schemas/searchs'; + +import { getUnifiedTransactionDetailPath } from './get-unified-transaction-detail-path'; + +type SearchTransaction = SearchAllResponse['transactions'][number]; + +/** Same section order as SearchPreview: transactions, accounts, blocks, high security sets, error events. */ +export function getTransactionSearchPath(tx: SearchTransaction): string { + return getUnifiedTransactionDetailPath({ + type: tx.type, + hash: tx.hash, + detailId: tx.detail_id, + block: tx.block + }); +} + +export function getAccountSearchPath(id: string): string { + return `${RESOURCES.accounts}/${id}`; +} + +export function getBlockSearchPath(height: number): string { + return `${RESOURCES.blocks}/${height}`; +} + +export function getHighSecuritySetSearchPath( + extrinsicId?: string | null +): string { + return `${RESOURCES.highSecuritySets}/${extrinsicId}`; +} + +export function getErrorEventSearchPath(extrinsicId?: string | null): string { + return `${RESOURCES.errors}/${extrinsicId}`; +} + +export function getTopSearchResultPath( + result: SearchAllResponse | undefined +): string | undefined { + if (!result) return undefined; + + const transaction = result.transactions?.[0]; + if (transaction) return getTransactionSearchPath(transaction); + + const account = result.accounts?.[0]; + if (account) return getAccountSearchPath(account.id); + + const block = result.blocks?.[0]; + if (block) return getBlockSearchPath(block.height); + + const highSecuritySet = result.highSecuritySets?.[0]; + if (highSecuritySet) { + return getHighSecuritySetSearchPath(highSecuritySet.extrinsic?.id); + } + + const errorEvent = result.errorEvents?.[0]; + if (errorEvent) return getErrorEventSearchPath(errorEvent.extrinsic?.id); + + return undefined; +} + +export interface TopSearchResultEnterInput { + key: string; + isComposing: boolean; + targetIsKeywordInput: boolean; + isResultVisible: boolean; + isLoading: boolean; + hasError: boolean; + inputValue: string; + resultKeyword: string | undefined; + result: SearchAllResponse | undefined; +} + +export function topSearchResultPathOnEnter( + input: TopSearchResultEnterInput +): string | undefined { + if ( + input.key !== 'Enter' || + input.isComposing || + !input.targetIsKeywordInput + ) { + return undefined; + } + + if (!input.isResultVisible || input.isLoading || input.hasError) { + return undefined; + } + + if (input.inputValue.trim() !== input.resultKeyword) { + return undefined; + } + + return getTopSearchResultPath(input.result); +} From ead9d24a46ed64dba83f4833369a7f3bcef9507f Mon Sep 17 00:00:00 2001 From: Beast Date: Wed, 23 Sep 2026 12:21:32 +0800 Subject: [PATCH 2/2] fix: duplicate search result for batch transfers --- src/api/search.tsx | 3 +- .../collapse-search-transactions.test.ts | 114 ++++++++++++++++++ src/utils/collapse-search-transactions.ts | 22 ++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/utils/collapse-search-transactions.test.ts create mode 100644 src/utils/collapse-search-transactions.ts diff --git a/src/api/search.tsx b/src/api/search.tsx index 15a217a..ce3d93a 100644 --- a/src/api/search.tsx +++ b/src/api/search.tsx @@ -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'; @@ -155,7 +156,7 @@ function normalizeSearchResponse( data: Partial | null | undefined ): SearchAllResponse { return { - transactions: data?.transactions ?? [], + transactions: collapseSearchTransactions(data?.transactions ?? []), accounts: data?.accounts ?? [], blocks: data?.blocks ?? [], highSecuritySets: data?.highSecuritySets ?? [], diff --git a/src/utils/collapse-search-transactions.test.ts b/src/utils/collapse-search-transactions.test.ts new file mode 100644 index 0000000..113bb01 --- /dev/null +++ b/src/utils/collapse-search-transactions.test.ts @@ -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 & Pick +): 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 + ]); + }); +}); diff --git a/src/utils/collapse-search-transactions.ts b/src/utils/collapse-search-transactions.ts new file mode 100644 index 0000000..c1b1cf1 --- /dev/null +++ b/src/utils/collapse-search-transactions.ts @@ -0,0 +1,22 @@ +import type { SearchAllResponse } from '@/schemas/searchs'; + +import { getTransactionSearchPath } from './get-top-search-result-path'; + +type SearchTransaction = SearchAllResponse['transactions'][number]; + +/** + * A batch is indexed as one unified_transaction row per transfer. Those rows + * open the same detail page, so search should list the batch once. + */ +export function collapseSearchTransactions( + transactions: SearchTransaction[] +): SearchTransaction[] { + const seen = new Set(); + + return transactions.filter((tx) => { + const path = getTransactionSearchPath(tx); + if (seen.has(path)) return false; + seen.add(path); + return true; + }); +}