From 39cd08f2cbdfef33cbf7d608e6d201b5b5488f5b Mon Sep 17 00:00:00 2001 From: Csillag Kristof Date: Sun, 2 Jul 2023 23:38:40 +0200 Subject: [PATCH 1/9] Add a component to highlight parts of text --- src/app/components/HighlightedText/index.tsx | 72 +++++ .../HighlightedText/text-matching.ts | 246 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/app/components/HighlightedText/index.tsx create mode 100644 src/app/components/HighlightedText/text-matching.ts diff --git a/src/app/components/HighlightedText/index.tsx b/src/app/components/HighlightedText/index.tsx new file mode 100644 index 000000000..08d2ed322 --- /dev/null +++ b/src/app/components/HighlightedText/index.tsx @@ -0,0 +1,72 @@ +import * as React from 'react' +import { findTextMatch, NormalizerOptions } from './text-matching' +import { FC } from 'react' +import { SxProps } from '@mui/material/styles' +import Box from '@mui/material/Box' + +export interface HighlightOptions { + /** + * Options for identifying the matches + */ + findOptions?: NormalizerOptions + + /** + * Which class to use for highlighting? + * + * Please don't supply both class and style together. + */ + className?: string + + /** + * Which styles to use for highlighting? + * + * Please don't supply both class and style together. + */ + sx?: SxProps +} + +const defaultHighlight: HighlightOptions = { + sx: { + color: 'red', + }, +} + +interface HighlightedTextProps { + /** + * The text to display + */ + text: string | undefined + + /** + * The pattern to search for (and highlight) + */ + pattern: string | undefined + + /** + * Optional: what class name to put on the highlighter SPAN? + */ + options?: HighlightOptions +} + +/** + * Display a text, with potential pattern matches highlighted with html SPANs + */ +export const HighlightedText: FC = ({ text, pattern, options = defaultHighlight }) => { + const match = findTextMatch(text, [pattern]) + const { + // className, + sx, + } = options + + return text === undefined ? undefined : match ? ( + <> + {text.substring(0, match.startPos)} + + {text.substring(match.startPos, match.startPos + match.searchText.length)} + + {text.substring(match.startPos + match.searchText.length)} + + ) : ( + text + ) +} diff --git a/src/app/components/HighlightedText/text-matching.ts b/src/app/components/HighlightedText/text-matching.ts new file mode 100644 index 000000000..512ccf526 --- /dev/null +++ b/src/app/components/HighlightedText/text-matching.ts @@ -0,0 +1,246 @@ +/** + * Store info about where did we found the pattern inside the corpus + */ +export interface MatchInfo { + searchText: string + startPos: number +} + +/** + * Options for doing text search + */ +export interface NormalizerOptions { + /** + * Should we search in a case-sensitive way? + * + * (Defaults to false.) + */ + caseSensitive?: boolean + + /** + * Should we do a diacritic sensitive search? + * + * (Defaults to false.) + */ + diacriticSensitive?: boolean +} + +/** + * Groups of characters that should be considered equivalent during text searches. + * + * Currently, supports German and Hungarian languages. + */ +export const diacriticEquivalenceGroups: string[][] = [ + ['aáä', 'AÁÄ'], + ['eé', 'EÉ'], + ['ií', 'IÍ'], + ['oóöő', 'OÓÖŐ'], + ['uúüű', 'UÚÜŰ'], +] + +// ====================== Internal data tables + +const caseSensitiveDiacriticNormalizationTable: Map = new Map() +const caseInsensitiveDiacriticNormalizationTable: Map = new Map() +const caseSensitiveDiacriticRegexpTable: Map = new Map() +const caseInsensitiveDiacriticRegexpTable: Map = new Map() + +diacriticEquivalenceGroups.forEach(g => { + const smallReference = g[0][0] + const bigReference = g[1][0] + g[0] + .split('') + .filter(char => char !== smallReference) + .forEach(char => caseInsensitiveDiacriticNormalizationTable.set(char, smallReference)) + g[1] + .split('') + .filter(char => char.toLowerCase() !== smallReference) + .forEach(char => caseInsensitiveDiacriticNormalizationTable.set(char, smallReference)) + g[0] + .split('') + .filter(char => char !== smallReference) + .forEach(char => caseSensitiveDiacriticNormalizationTable.set(char, smallReference)) + g[1] + .split('') + .filter(char => char !== bigReference) + .forEach(char => caseSensitiveDiacriticNormalizationTable.set(char, bigReference)) + g[0].split('').forEach(char => caseInsensitiveDiacriticRegexpTable.set(char, `[${g[0]}${g[1]}]`)) + g[1].split('').forEach(char => caseInsensitiveDiacriticRegexpTable.set(char, `[${g[0]}${g[1]}]`)) + g[0].split('').forEach(char => caseSensitiveDiacriticRegexpTable.set(char, `[${g[0]}]`)) + g[1].split('').forEach(char => caseSensitiveDiacriticRegexpTable.set(char, `[${g[1]}]`)) +}) + +// ----------------------------------------- + +/** + * Determine whether a simple character is alphanumerical or not + */ +export const isCharAlphaNumerical = (char: string): boolean => { + const code = char.charCodeAt(0) + return ( + (code > 47 && code < 58) || // numeric (0-9) + (code > 64 && code < 91) || // upper alpha (A-Z) + (code > 96 && code < 123) + ) // lower alpha (a-z) +} + +/** + * Escape a character to be used in a regexp-based text search, also considering normalization + */ +export const escapeCharForPCRE = (char: string, options: NormalizerOptions) => { + const { diacriticSensitive = false, caseSensitive = false } = options + if (diacriticSensitive) { + if (isCharAlphaNumerical(char) || caseInsensitiveDiacriticNormalizationTable.has(char)) { + // TODO: test if we need to manually do case-insensitive in this case + return char + } else { + return `\\${char}` + } + } else { + const mapping = caseSensitive + ? caseSensitiveDiacriticRegexpTable.get(char) + : caseInsensitiveDiacriticRegexpTable.get(char) + return mapping || (isCharAlphaNumerical(char) ? char : `\\${char}`) + } +} + +/** + * Escape a string so that it becomes a valid PCRE regexp, also considering normalization + * + * (I.e. escape all the non-alphanumerical characters.) + */ +export const escapeTextForPCRE = (input: string, options: NormalizerOptions = {}) => + input + .split('') + .map(char => escapeCharForPCRE(char, options)) + .join('') + +/** + * Prepare a Mongo Query expression for doing a text search, also considering normalization + */ +export const getMongoRegexpSearch = (pattern: string, options: NormalizerOptions = {}) => { + const { caseSensitive = false } = options + return { + $regex: escapeTextForPCRE(pattern, options), + $options: caseSensitive ? 'm' : 'im', + } +} + +/** + * Normalize a character for text search + */ +export const normalizeCharForSearch = (char: string, options: NormalizerOptions = {}) => { + const { caseSensitive = false, diacriticSensitive = false } = options + const stage1 = diacriticSensitive + ? char + : (caseSensitive + ? caseSensitiveDiacriticNormalizationTable.get(char) + : caseInsensitiveDiacriticNormalizationTable.get(char)) || char + const stage2 = caseSensitive ? stage1 : stage1.toLowerCase() + return stage2 +} + +/** + * A basic text normalizer function + */ +export const normalizeTextForSearch = (text: string, options: NormalizerOptions = {}) => + text + .split('') + .map(char => normalizeCharForSearch(char, options)) + .join('') + +/** + * Identify pattern matches within a corpus, also considering normalization + */ +export const findTextMatch = ( + rawCorpus: string | null | undefined, + search: (string | undefined)[], + options: NormalizerOptions = {}, +): MatchInfo => { + let matchStart: number + let normalizedPattern: string + const normalizedCorpus = normalizeTextForSearch(rawCorpus || '', options) + const matches: MatchInfo[] = search + .filter(s => !!s && s.toLowerCase) + .map(rawPattern => { + normalizedPattern = normalizeTextForSearch(rawPattern!, options) + // console.log(`Will search for "${normalizedPattern}" in "${normalizedCorpus}"...`); + matchStart = normalizedCorpus.indexOf(normalizedPattern) + return matchStart !== -1 + ? { + searchText: rawPattern, + startPos: matchStart, + } + : undefined + }) + .filter(m => !!m) + .map(m => m as MatchInfo) // This last line is only here to make TS happy + return matches[0] +} + +export interface CutAroundOptions extends NormalizerOptions { + /** + * What should be the length of the fragment delivered, which + * has the pattern inside it? + * + * The default value is 80. + */ + fragmentLength?: number + + debug?: boolean +} + +/** + * Return a part of the corpus that contains the match to the pattern, if any + * + * If either the corpus or the pattern is undefined or empty, undefined is returned. + * If there is no match, undefined is returned. + * + * If there is a match, but the corpus is at most as long as the desired fragment length, + * the whole corpus is returned. + * + * If there is a match, and the corpus is longer than the desired fragment length, + * then a part of a corpus is returned, so that the match is within the returned part, + * around the middle. + */ +export function cutAroundMatch( + corpus: string | undefined, + pattern: string | undefined, + options: CutAroundOptions = {}, +): string | undefined { + const { fragmentLength = 80, debug, ...matchOptions } = options + + if (!corpus || !pattern) { + // there is nothing to see here + return + } + + // do we have a match? + const match = findTextMatch(corpus, [pattern], matchOptions) + + if (!match) { + // no match, no fragment + return + } + + if (corpus.length <= fragmentLength) { + // the whole corpus fits into the max size, no need to cut. + return corpus + } + + // how much extra space do we have? + const buffer = fragmentLength - pattern.length + + // We will start before the start of the match, by buffer / 2 chars + const startPos = Math.max( + Math.min(match.startPos - Math.floor(buffer / 2), corpus.length - fragmentLength), + 0, + ) + const endPos = Math.min(startPos + fragmentLength, corpus.length) + + // compile the result + const result = + (startPos ? '…' : '') + corpus.substring(startPos, endPos) + (endPos < corpus.length - 1 ? '…' : '') + + return result +} From cd571a9aaaf5b90f8fe2e7ed857c534e5f4b4797 Mon Sep 17 00:00:00 2001 From: Csillag Kristof Date: Sun, 2 Jul 2023 21:30:46 +0200 Subject: [PATCH 2/9] Highlight matching part in token names in search results --- .changelog/646.feature.md | 1 + src/app/components/Search/search-utils.ts | 1 + src/app/components/Tokens/TokenDetails.tsx | 13 ++++++++++--- src/app/components/Tokens/TokenLink.tsx | 14 ++++++++------ .../GlobalSearchResultsView.tsx | 11 +++++++---- .../ScopedSearchResultsView.tsx | 5 ++++- .../SearchResultsPage/SearchResultsList.tsx | 5 +++-- .../SearchResultsPage/SearchResultsView.tsx | 10 ++++++++-- src/app/pages/SearchResultsPage/index.tsx | 1 + src/app/pages/TokensOverviewPage/index.tsx | 17 +++++++++++++++-- 10 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 .changelog/646.feature.md diff --git a/.changelog/646.feature.md b/.changelog/646.feature.md new file mode 100644 index 000000000..aea2745a2 --- /dev/null +++ b/.changelog/646.feature.md @@ -0,0 +1 @@ +Highlight matching part in token names in search results diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index 9bb21e9c7..e00403cae 100644 --- a/src/app/components/Search/search-utils.ts +++ b/src/app/components/Search/search-utils.ts @@ -117,6 +117,7 @@ export const searchParamLoader = async ({ request, params }: LoaderFunctionArgs) Object.entries(validateAndNormalize).map(([key, fn]) => [key, fn(searchTerm)]), ) as { [Key in keyof typeof validateAndNormalize]: string | undefined } return { + searchTerm, ...normalized, // TODO: remove conversion when API supports querying by EVM address // TODO: without async conversion, this won't need to even be a loader diff --git a/src/app/components/Tokens/TokenDetails.tsx b/src/app/components/Tokens/TokenDetails.tsx index 0ba7e6c27..8a071d5dc 100644 --- a/src/app/components/Tokens/TokenDetails.tsx +++ b/src/app/components/Tokens/TokenDetails.tsx @@ -13,13 +13,15 @@ import Box from '@mui/material/Box' import { COLORS } from '../../../styles/theme/colors' import { TokenTypeTag } from './TokenList' import { RoundedBalance } from '../RoundedBalance' +import { HighlightedText } from '../HighlightedText' export const TokenDetails: FC<{ isLoading?: boolean token: EvmToken | undefined showLayer?: boolean standalone?: boolean -}> = ({ isLoading, token, showLayer, standalone = false }) => { + highlightedPartOfName: string | undefined +}> = ({ isLoading, token, showLayer, standalone = false, highlightedPartOfName }) => { const { t } = useTranslation() const { isMobile } = useScreenSize() @@ -38,9 +40,14 @@ export const TokenDetails: FC<{ )}
{t('common.name')}
- + - ({token.symbol}) +
diff --git a/src/app/components/Tokens/TokenLink.tsx b/src/app/components/Tokens/TokenLink.tsx index 3156c1dbc..e78260b10 100644 --- a/src/app/components/Tokens/TokenLink.tsx +++ b/src/app/components/Tokens/TokenLink.tsx @@ -4,15 +4,17 @@ import Link from '@mui/material/Link' import { RouteUtils } from '../../utils/route-utils' import { SearchScope } from '../../../types/searchScope' +import { HighlightedText } from '../HighlightedText' -export const TokenLink: FC<{ scope: SearchScope; address: string; name: string | undefined }> = ({ - scope, - address, - name, -}) => { +export const TokenLink: FC<{ + scope: SearchScope + address: string + name: string | undefined + highlightedPart?: string | undefined +}> = ({ scope, address, name, highlightedPart }) => { return ( - {name || address} + { ?? address} ) } diff --git a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx index b16cfd6ad..de5d11b1c 100644 --- a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx @@ -19,10 +19,11 @@ import { getThemesForNetworks } from '../../../styles/theme' import { orderByLayer } from '../../../types/layers' import { useRedirectIfSingleResult } from './useRedirectIfSingleResult' -export const GlobalSearchResultsView: FC<{ searchResults: SearchResults; tokenPrices: AllTokenPrices }> = ({ - searchResults, - tokenPrices, -}) => { +export const GlobalSearchResultsView: FC<{ + searchTerm: string + searchResults: SearchResults + tokenPrices: AllTokenPrices +}> = ({ searchTerm, searchResults, tokenPrices }) => { const { t } = useTranslation() const [othersOpen, setOthersOpen] = useState(false) useRedirectIfSingleResult(undefined, searchResults) @@ -41,6 +42,7 @@ export const GlobalSearchResultsView: FC<{ searchResults: SearchResults; tokenPr = ({ wantedScope, searchResults, tokenPrices }) => { +}> = ({ wantedScope, searchTerm, searchResults, tokenPrices }) => { const { t } = useTranslation() const [othersOpen, setOthersOpen] = useState(false) const networkNames = getNetworkNames(t) @@ -38,6 +39,7 @@ export const ScopedSearchResultsView: FC<{ {wantedResults.length ? ( diff --git a/src/app/pages/SearchResultsPage/SearchResultsList.tsx b/src/app/pages/SearchResultsPage/SearchResultsList.tsx index 8079f8fc5..dc81b978f 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsList.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsList.tsx @@ -31,7 +31,8 @@ export const SearchResultsList: FC<{ networkForTheme: Network searchResults: SearchResults tokenPrices: AllTokenPrices -}> = ({ title, networkForTheme, searchResults, tokenPrices }) => { + searchTerm?: string +}> = ({ title, networkForTheme, searchResults, tokenPrices, searchTerm = '' }) => { const { t } = useTranslation() const numberOfResults = searchResults.length @@ -110,7 +111,7 @@ export const SearchResultsList: FC<{ item.resultType === 'token')} - resultComponent={item => } + resultComponent={item => } link={token => RouteUtils.getTokenRoute(token, token.eth_contract_addr ?? token.contract_addr)} linkLabel={t('search.results.tokens.viewLink')} /> diff --git a/src/app/pages/SearchResultsPage/SearchResultsView.tsx b/src/app/pages/SearchResultsPage/SearchResultsView.tsx index 85aedef40..e97916a7f 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsView.tsx @@ -13,10 +13,11 @@ import { useScreenSize } from '../../hooks/useScreensize' export const SearchResultsView: FC<{ wantedScope?: SearchScope + searchTerm: string searchResults: SearchResults isLoading: boolean tokenPrices: AllTokenPrices -}> = ({ wantedScope, searchResults, isLoading, tokenPrices }) => { +}> = ({ wantedScope, searchTerm, searchResults, isLoading, tokenPrices }) => { const { isMobile } = useScreenSize() return ( @@ -28,11 +29,16 @@ export const SearchResultsView: FC<{ ) : wantedScope ? ( ) : ( - + )} ) diff --git a/src/app/pages/SearchResultsPage/index.tsx b/src/app/pages/SearchResultsPage/index.tsx index 6ec073d32..23c3c87f4 100644 --- a/src/app/pages/SearchResultsPage/index.tsx +++ b/src/app/pages/SearchResultsPage/index.tsx @@ -15,6 +15,7 @@ export const SearchResultsPage: FC = () => { return ( { {isLoading && [...Array(PAGE_SIZE).keys()].map(key => ( - + ))} {!isLoading && - tokens!.map(token => )} + tokens!.map(token => ( + + ))} )} From c919287cebcc664d603a07e265b6b425fa8e2bcb Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sat, 27 Jan 2024 13:21:57 +0100 Subject: [PATCH 3/9] Change the format of data returned by addressParamLoader --- .../AccountDetailsPage/AccountNFTCollectionCard.tsx | 4 ++-- src/app/pages/AccountDetailsPage/index.tsx | 3 ++- src/app/pages/TokenDashboardPage/index.tsx | 3 ++- src/app/utils/route-utils.ts | 11 ++++++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx b/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx index 449792103..e56514dcc 100644 --- a/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx +++ b/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx @@ -17,7 +17,7 @@ import { LinkableDiv } from '../../components/PageLayout/LinkableDiv' import { AccountDetailsContext } from './index' import { AccountLink } from 'app/components/Account/AccountLink' import { CopyToClipboard } from 'app/components/CopyToClipboard' -import { RouteUtils } from 'app/utils/route-utils' +import { AddressLoaderData, RouteUtils } from 'app/utils/route-utils' import { ImageListItemImage } from '../TokenDashboardPage/ImageListItemImage' import { CardEmptyState } from '../AccountDetailsPage/CardEmptyState' import { TablePagination } from '../../components/Table/TablePagination' @@ -31,7 +31,7 @@ export const accountNFTCollectionContainerId = 'nftCollection' export const AccountNFTCollectionCard: FC = ({ scope, address }) => { const { t } = useTranslation() - const contractAddress = useLoaderData() as string + const { address: contractAddress } = useLoaderData() as AddressLoaderData const { inventory, isFetched, isLoading, isTotalCountClipped, pagination, totalCount } = useAccountTokenInventory(scope, address, contractAddress) const firstToken = inventory?.length ? inventory?.[0].token : undefined diff --git a/src/app/pages/AccountDetailsPage/index.tsx b/src/app/pages/AccountDetailsPage/index.tsx index 23d727aae..098654a92 100644 --- a/src/app/pages/AccountDetailsPage/index.tsx +++ b/src/app/pages/AccountDetailsPage/index.tsx @@ -18,6 +18,7 @@ import { SearchScope } from '../../../types/searchScope' import { AccountDetailsCard } from './AccountDetailsCard' import { AccountEventsCard } from './AccountEventsCard' import { DappBanner } from '../../components/DappBanner' +import { AddressLoaderData } from '../../utils/route-utils' export type AccountDetailsContext = { scope: SearchScope @@ -31,7 +32,7 @@ export const AccountDetailsPage: FC = () => { const { t } = useTranslation() const scope = useRequiredScopeParam() - const address = useLoaderData() as string + const { address } = useLoaderData() as AddressLoaderData const { account, isLoading: isAccountLoading, isError } = useAccount(scope, address) const isContract = !!account?.evm_contract const { token, isLoading: isTokenLoading } = useTokenInfo(scope, address, isContract) diff --git a/src/app/pages/TokenDashboardPage/index.tsx b/src/app/pages/TokenDashboardPage/index.tsx index 2ba4bbe6e..b41c554da 100644 --- a/src/app/pages/TokenDashboardPage/index.tsx +++ b/src/app/pages/TokenDashboardPage/index.tsx @@ -16,6 +16,7 @@ import { tokenHoldersContainerId } from './TokenHoldersCard' import { SearchScope } from '../../../types/searchScope' import { tokenInventoryContainerId } from './TokenInventoryCard' import { DappBanner } from '../../components/DappBanner' +import { AddressLoaderData } from '../../utils/route-utils' export type TokenDashboardContext = { scope: SearchScope @@ -28,7 +29,7 @@ export const TokenDashboardPage: FC = () => { const { t } = useTranslation() const { isMobile } = useScreenSize() const scope = useRequiredScopeParam() - const address = useLoaderData() as string + const { address } = useLoaderData() as AddressLoaderData const { token, isError } = useTokenInfo(scope, address) diff --git a/src/app/utils/route-utils.ts b/src/app/utils/route-utils.ts index 2aedb1221..842b43e49 100644 --- a/src/app/utils/route-utils.ts +++ b/src/app/utils/route-utils.ts @@ -159,13 +159,18 @@ const validateTxHashParam = (hash: string) => { } return true } +export type AddressLoaderData = { + address: string +} export const addressParamLoader = (queryParam: string = 'address') => - ({ params }: LoaderFunctionArgs): string => { + ({ params }: LoaderFunctionArgs): AddressLoaderData => { validateAddressParam(params[queryParam]!) - - return params[queryParam]! + console.log('Returning address data based on ', params) + return { + address: params[queryParam]!, + } } export const blockHeightParamLoader = async ({ params }: LoaderFunctionArgs) => { From 1dd7ce82d1165c4028adddf39247b51b571801e7 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sat, 27 Jan 2024 13:26:38 +0100 Subject: [PATCH 4/9] Add search term to the data returned by addressParamLoader --- src/app/components/Search/search-utils.ts | 5 ++++- src/app/utils/route-utils.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index e00403cae..be2f21a18 100644 --- a/src/app/components/Search/search-utils.ts +++ b/src/app/components/Search/search-utils.ts @@ -106,13 +106,16 @@ export function isSearchValid(searchTerm: string) { return Object.values(validateAndNormalize).some(fn => !!fn(searchTerm)) } +export const getSearchTermFromRequest = (request: Request) => + new URL(request.url).searchParams.get('q')?.trim() ?? '' + export const searchParamLoader = async ({ request, params }: LoaderFunctionArgs) => { const { network } = params if (!!network && !RouteUtils.getEnabledNetworks().includes(network as Network)) { throw new AppError(AppErrors.InvalidUrl) } - const searchTerm = new URL(request.url).searchParams.get('q')?.trim() ?? '' + const searchTerm = getSearchTermFromRequest(request) const normalized = Object.fromEntries( Object.entries(validateAndNormalize).map(([key, fn]) => [key, fn(searchTerm)]), ) as { [Key in keyof typeof validateAndNormalize]: string | undefined } diff --git a/src/app/utils/route-utils.ts b/src/app/utils/route-utils.ts index 842b43e49..ed9a153ca 100644 --- a/src/app/utils/route-utils.ts +++ b/src/app/utils/route-utils.ts @@ -6,6 +6,7 @@ import { EvmTokenType, Layer } from '../../oasis-nexus/api' import { Network } from '../../types/network' import { SearchScope } from '../../types/searchScope' import { isStableDeploy } from '../../config' +import { getSearchTermFromRequest } from '../components/Search/search-utils' export type SpecifiedPerEnabledLayer = { [N in keyof (typeof RouteUtils)['ENABLED_LAYERS_FOR_NETWORK']]: { @@ -161,15 +162,16 @@ const validateTxHashParam = (hash: string) => { } export type AddressLoaderData = { address: string + searchTerm: string } export const addressParamLoader = (queryParam: string = 'address') => - ({ params }: LoaderFunctionArgs): AddressLoaderData => { + ({ params, request }: LoaderFunctionArgs): AddressLoaderData => { validateAddressParam(params[queryParam]!) - console.log('Returning address data based on ', params) return { address: params[queryParam]!, + searchTerm: getSearchTermFromRequest(request), } } From a698c32aeb943245924513c30fbee7b00c775db9 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Tue, 11 Jul 2023 08:33:15 +0200 Subject: [PATCH 5/9] Preserve search term in URL when redirecting to token search result --- .../SearchResultsPage/GlobalSearchResultsView.tsx | 2 +- .../SearchResultsPage/ScopedSearchResultsView.tsx | 2 +- .../SearchResultsPage/useRedirectIfSingleResult.ts | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx index de5d11b1c..94a18fab5 100644 --- a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx @@ -26,7 +26,7 @@ export const GlobalSearchResultsView: FC<{ }> = ({ searchTerm, searchResults, tokenPrices }) => { const { t } = useTranslation() const [othersOpen, setOthersOpen] = useState(false) - useRedirectIfSingleResult(undefined, searchResults) + useRedirectIfSingleResult(undefined, searchTerm, searchResults) const themes = getThemesForNetworks() const networkNames = getNetworkNames(t) diff --git a/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx b/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx index 984b8ed67..755202e7e 100644 --- a/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx @@ -32,7 +32,7 @@ export const ScopedSearchResultsView: FC<{ const otherResults = searchResults.filter(isNotInWantedScope) const notificationTheme = themes[otherResults.some(isOnMainnet) ? Network.mainnet : Network.testnet] - useRedirectIfSingleResult(wantedScope, searchResults) + useRedirectIfSingleResult(wantedScope, searchTerm, searchResults) return ( <> diff --git a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts index 5854f9e45..77cec2da2 100644 --- a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts +++ b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts @@ -7,7 +7,11 @@ import { Network } from '../../../types/network' import { exhaustedTypeWarning } from '../../../types/errors' /** If search only finds one result then redirect to it */ -export function useRedirectIfSingleResult(scope: SearchScope | undefined, results: SearchResults) { +export function useRedirectIfSingleResult( + scope: SearchScope | undefined, + searchTerm: string, + results: SearchResults, +) { const navigate = useNavigate() let shouldRedirect = results.length === 1 @@ -34,7 +38,10 @@ export function useRedirectIfSingleResult(scope: SearchScope | undefined, result redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address) break case 'token': - redirectTo = RouteUtils.getTokenRoute(item, item.eth_contract_addr || item.contract_addr) + redirectTo = `${RouteUtils.getTokenRoute( + item, + item.eth_contract_addr || item.contract_addr, + )}?q=${searchTerm}` break default: exhaustedTypeWarning('Unexpected result type', item) From ce67ff3a0a4fc91f57c273cb1c39c5d654b74997 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sat, 27 Jan 2024 13:40:33 +0100 Subject: [PATCH 6/9] Highlight search match in token dashboard title and details --- .../pages/TokenDashboardPage/TokenDetailsCard.tsx | 11 +++++++++-- .../pages/TokenDashboardPage/TokenTitleCard.tsx | 15 ++++++++++++--- src/app/pages/TokenDashboardPage/index.tsx | 6 +++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx b/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx index 7b3572b4d..84262cbc3 100644 --- a/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx +++ b/src/app/pages/TokenDashboardPage/TokenDetailsCard.tsx @@ -21,8 +21,13 @@ import { RouteUtils } from '../../utils/route-utils' import { tokenTransfersContainerId } from '../../pages/TokenDashboardPage/TokenTransfersCard' import { tokenHoldersContainerId } from '../../pages/TokenDashboardPage/TokenHoldersCard' import { RoundedBalance } from 'app/components/RoundedBalance' +import { HighlightedText } from '../../components/HighlightedText' -export const TokenDetailsCard: FC<{ scope: SearchScope; address: string }> = ({ scope, address }) => { +export const TokenDetailsCard: FC<{ scope: SearchScope; address: string; searchTerm: string }> = ({ + scope, + address, + searchTerm, +}) => { const { t } = useTranslation() const { isMobile } = useScreenSize() @@ -41,7 +46,9 @@ export const TokenDetailsCard: FC<{ scope: SearchScope; address: string }> = ({ {!isLoading && account && token && (
{t('common.token')}
-
{token.name}
+
+ +
{isMobile && ( <> diff --git a/src/app/pages/TokenDashboardPage/TokenTitleCard.tsx b/src/app/pages/TokenDashboardPage/TokenTitleCard.tsx index 06d30e6ab..c9aa28b0e 100644 --- a/src/app/pages/TokenDashboardPage/TokenTitleCard.tsx +++ b/src/app/pages/TokenDashboardPage/TokenTitleCard.tsx @@ -12,6 +12,7 @@ import { styled } from '@mui/material/styles' import { CopyToClipboard } from '../../components/CopyToClipboard' import { useTranslation } from 'react-i18next' import { SearchScope } from '../../../types/searchScope' +import { HighlightedText } from '../../components/HighlightedText' export const StyledCard = styled(Card)(() => ({ '&': { @@ -22,7 +23,11 @@ export const StyledCard = styled(Card)(() => ({ const TitleSkeleton: FC = () => -export const TokenTitleCard: FC<{ scope: SearchScope; address: string }> = ({ scope, address }) => { +export const TokenTitleCard: FC<{ scope: SearchScope; address: string; searchTerm: string }> = ({ + scope, + address, + searchTerm, +}) => { const { t } = useTranslation() const { isLoading, token } = useTokenInfo(scope, address) @@ -49,7 +54,7 @@ export const TokenTitleCard: FC<{ scope: SearchScope; address: string }> = ({ sc flexWrap: 'wrap', }} > - {token?.name ?? t('common.missing')} + {token?.name ? : t('common.missing')}   = ({ sc fontWeight: 700, }} > - ({token?.symbol ?? t('common.missing')}) + {token?.symbol ? ( + + ) : ( + t('common.missing') + )} diff --git a/src/app/pages/TokenDashboardPage/index.tsx b/src/app/pages/TokenDashboardPage/index.tsx index b41c554da..4f5cbe256 100644 --- a/src/app/pages/TokenDashboardPage/index.tsx +++ b/src/app/pages/TokenDashboardPage/index.tsx @@ -29,7 +29,7 @@ export const TokenDashboardPage: FC = () => { const { t } = useTranslation() const { isMobile } = useScreenSize() const scope = useRequiredScopeParam() - const { address } = useLoaderData() as AddressLoaderData + const { address, searchTerm } = useLoaderData() as AddressLoaderData const { token, isError } = useTokenInfo(scope, address) @@ -49,11 +49,11 @@ export const TokenDashboardPage: FC = () => { return ( - + - + Date: Sun, 28 Jan 2024 00:24:19 +0100 Subject: [PATCH 7/9] Add support for searching for network proposal by name --- .../NetworkProposalsList/ProposalDetails.tsx | 51 +++++++++++++++++++ src/app/components/Search/search-utils.ts | 11 ++++ .../SearchResultsPage/ResultsGroupByType.tsx | 27 ++++++---- .../SearchResultsPage/SearchResultsList.tsx | 10 ++++ src/app/pages/SearchResultsPage/hooks.ts | 46 +++++++++++++++-- .../useRedirectIfSingleResult.ts | 3 ++ src/locales/en/translation.json | 4 ++ src/oasis-nexus/api.ts | 5 ++ 8 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 src/app/components/NetworkProposalsList/ProposalDetails.tsx diff --git a/src/app/components/NetworkProposalsList/ProposalDetails.tsx b/src/app/components/NetworkProposalsList/ProposalDetails.tsx new file mode 100644 index 000000000..857681c50 --- /dev/null +++ b/src/app/components/NetworkProposalsList/ProposalDetails.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react' +import { Layer, Proposal } from '../../../oasis-nexus/api' +import { StyledDescriptionList } from '../StyledDescriptionList' +import { useScreenSize } from '../../hooks/useScreensize' +import { DashboardLink } from '../../pages/ParatimeDashboardPage/DashboardLink' +import { useTranslation } from 'react-i18next' +import { RoundedBalance } from '../RoundedBalance' +import { ProposalStatusIcon } from '../ProposalStatusIcon' + +export const ProposalDetails: FC<{ proposal: Proposal; showLayer?: boolean; standalone?: boolean }> = ({ + proposal, + showLayer = false, + standalone = false, +}) => { + const { t } = useTranslation() + const { isMobile } = useScreenSize() + return ( + + {showLayer && ( + <> +
{t('common.network')}
+
+ +
+ + )} + +
{t('networkProposal.id')}
+
{proposal.id}
+ +
{t('networkProposal.handler')}
+
{proposal.handler}
+ +
{t('networkProposal.deposit')}
+
+ +
+ +
{t('networkProposal.create')}
+
{proposal.created_at}
+ +
{t('networkProposal.close')}
+
{proposal.closes_at}
+ +
{t('common.status')}
+
+ +
+
+ ) +} diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index be2f21a18..fc1ba632c 100644 --- a/src/app/components/Search/search-utils.ts +++ b/src/app/components/Search/search-utils.ts @@ -10,6 +10,7 @@ import { import { Network } from '../../../types/network' import { RouteUtils, SpecifiedPerEnabledLayer } from '../../utils/route-utils' import { AppError, AppErrors } from '../../../types/errors' +import { Proposal } from '../../../oasis-nexus/api' type LayerSuggestions = { suggestedBlock: string @@ -100,6 +101,11 @@ export const validateAndNormalize = { return searchTerm.toLowerCase() } }, + networkProposalNameFragment: (searchTerm: string) => { + if (searchTerm?.length >= textSearchMininumLength) { + return searchTerm.toLowerCase() + } + }, } satisfies { [name: string]: (searchTerm: string) => string | undefined } export function isSearchValid(searchTerm: string) { @@ -133,3 +139,8 @@ export const useParamSearch = () => { } export type SearchParams = ReturnType + +export const getProposalTextMatcherFor = (textFragment: string | undefined) => + textFragment + ? (proposal: Proposal): boolean => !!proposal.handler && proposal.handler?.indexOf(textFragment) !== -1 + : () => false diff --git a/src/app/pages/SearchResultsPage/ResultsGroupByType.tsx b/src/app/pages/SearchResultsPage/ResultsGroupByType.tsx index c4804d58b..8ef825515 100644 --- a/src/app/pages/SearchResultsPage/ResultsGroupByType.tsx +++ b/src/app/pages/SearchResultsPage/ResultsGroupByType.tsx @@ -47,17 +47,22 @@ export function ResultsGroupByType({ title, results, resultComponent, link, l {title} - {results.map((item, i) => ( -
- {resultComponent(item)} - - - {linkLabel} - - - {i < results.length - 1 && } -
- ))} + {results.map((item, i) => { + const itemLink = link(item) + return ( +
+ {resultComponent(item)} + {!!itemLink && ( + + + {linkLabel} + + + )} + {i < results.length - 1 && } +
+ ) + })} ) } diff --git a/src/app/pages/SearchResultsPage/SearchResultsList.tsx b/src/app/pages/SearchResultsPage/SearchResultsList.tsx index dc81b978f..17befc7bb 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsList.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsList.tsx @@ -9,6 +9,7 @@ import { AccountResult, BlockResult, ContractResult, + ProposalResult, SearchResults, TokenResult, TransactionResult, @@ -19,6 +20,7 @@ import { SubPageCard } from '../../components/SubPageCard' import { AllTokenPrices } from '../../../coin-gecko/api' import { ResultListFrame } from './ResultListFrame' import { TokenDetails } from '../../components/Tokens/TokenDetails' +import { ProposalDetails } from '../../components/NetworkProposalsList/ProposalDetails' /** * Component for displaying a list of search results @@ -115,6 +117,14 @@ export const SearchResultsList: FC<{ link={token => RouteUtils.getTokenRoute(token, token.eth_contract_addr ?? token.contract_addr)} linkLabel={t('search.results.tokens.viewLink')} /> + + item.resultType === 'proposal')} + resultComponent={item => } + link={() => ''} + linkLabel="" + /> ) diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index 72b12c9ea..a1ce5da84 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -13,9 +13,12 @@ import { EvmTokenList, EvmToken, useGetRuntimeBlockByHash, + Proposal, + useGetConsensusProposals, + ProposalList, } from '../../../oasis-nexus/api' import { RouteUtils } from '../../utils/route-utils' -import { SearchParams } from '../../components/Search/search-utils' +import { getProposalTextMatcherFor, SearchParams } from '../../components/Search/search-utils' function isDefined(item: T): item is NonNullable { return item != null @@ -24,7 +27,7 @@ function isDefined(item: T): item is NonNullable { export type ConditionalResults = { isLoading: boolean; results: T[] } type SearchResultItemCore = HasScope & { - resultType: 'block' | 'transaction' | 'account' | 'contract' | 'token' + resultType: 'block' | 'transaction' | 'account' | 'contract' | 'token' | 'proposal' } export type BlockResult = SearchResultItemCore & RuntimeBlock & { resultType: 'block' } @@ -37,7 +40,15 @@ export type ContractResult = SearchResultItemCore & RuntimeAccount & { resultTyp export type TokenResult = SearchResultItemCore & EvmToken & { resultType: 'token' } -export type SearchResultItem = BlockResult | TransactionResult | AccountResult | ContractResult | TokenResult +export type ProposalResult = SearchResultItemCore & Proposal & { resultType: 'proposal' } + +export type SearchResultItem = + | BlockResult + | TransactionResult + | AccountResult + | ContractResult + | TokenResult + | ProposalResult export type SearchResults = SearchResultItem[] @@ -161,6 +172,27 @@ export function useRuntimeTokenConditionally( } } +export function useNetworkProposalsConditionally( + nameFragment: string | undefined, +): ConditionalResults { + const queries = RouteUtils.getEnabledNetworks().map(network => + // eslint-disable-next-line react-hooks/rules-of-hooks + useGetConsensusProposals( + network, + {}, + { + query: { + enabled: !!nameFragment, + }, + }, + ), + ) + return { + isLoading: queries.some(query => query.isInitialLoading), + results: queries.map(query => query.data?.data).filter(isDefined), + } +} + export const useSearch = (q: SearchParams) => { const queries = { blockHeight: useBlocksByHeightConditionally(q.blockHeight), @@ -170,6 +202,7 @@ export const useSearch = (q: SearchParams) => { // TODO: remove evmBech32Account and use evmAccount when API is ready evmBech32Account: useRuntimeAccountConditionally(q.evmBech32Account), tokens: useRuntimeTokenConditionally(q.evmTokenNameFragment), + proposals: useNetworkProposalsConditionally(q.networkProposalNameFragment), } const isLoading = Object.values(queries).some(query => query.isLoading) const blocks = [...queries.blockHeight.results, ...queries.blockHash.results] @@ -182,6 +215,10 @@ export const useSearch = (q: SearchParams) => { .map(l => l.evm_tokens) .flat() .sort((t1, t2) => t2.num_holders - t1.num_holders) + const proposals = queries.proposals.results + .map(l => l.proposals) + .flat() + .filter(getProposalTextMatcherFor(q.networkProposalNameFragment)) const results: SearchResultItem[] = isLoading ? [] @@ -195,6 +232,9 @@ export const useSearch = (q: SearchParams) => { .filter(account => account.evm_contract) .map((account): ContractResult => ({ ...account, resultType: 'contract' })), ...tokens.map((token): TokenResult => ({ ...token, resultType: 'token' })), + ...proposals.map( + (proposal): ProposalResult => ({ ...proposal, resultType: 'proposal', layer: Layer.consensus }), + ), ] return { isLoading, diff --git a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts index 77cec2da2..c4036efe9 100644 --- a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts +++ b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts @@ -43,6 +43,9 @@ export function useRedirectIfSingleResult( item.eth_contract_addr || item.contract_addr, )}?q=${searchTerm}` break + case 'proposal': + // We don't want to redirect to proposals + break default: exhaustedTypeWarning('Unexpected result type', item) } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 845a9bd72..c3281eefe 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -66,6 +66,7 @@ "lessThanAmount": "< {{value, number}} ", "missing": "n/a", "name": "Name", + "network": "Network", "nft": "NFT", "nfts": "NFTs", "not_defined": "Not defined", @@ -486,6 +487,9 @@ "title": "Transactions", "viewLink": "View Transaction" }, + "proposals": { + "title": "Proposals" + }, "count_one": "1 result", "count_other": "{{ count }} results", "moreCount_one": "1 more result", diff --git a/src/oasis-nexus/api.ts b/src/oasis-nexus/api.ts index d0fe4dfa9..3b822a195 100644 --- a/src/oasis-nexus/api.ts +++ b/src/oasis-nexus/api.ts @@ -83,6 +83,10 @@ declare module './generated/api' { export interface Validator { ticker: NativeTicker } + + export interface Proposal { + network: Network + } } export const isAccountEmpty = (account: RuntimeAccount) => { @@ -800,6 +804,7 @@ export const useGetConsensusProposals: typeof generated.useGetConsensusProposals proposals: data.proposals.map(proposal => { return { ...proposal, + network, deposit: fromBaseUnits(proposal.deposit, consensusDecimals), } }), From 4775a27a08d988e1dc9d015864627131a79b8eba Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sun, 28 Jan 2024 00:25:01 +0100 Subject: [PATCH 8/9] Temporarily switch Nexus to prod (just for demo, since current staging doesn't have any proposals indexed) --- .env | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env b/.env index dbd0b60c2..494daf289 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ REACT_APP_BUILD_DATETIME=0 REACT_APP_BUILD_SHA=sha0000000000000000000000000000000000000 REACT_APP_BUILD_VERSION= # REACT_APP_API=http://localhost:8008/v1/ -REACT_APP_API=https://nexus.stg.oasis.io/v1/ -REACT_APP_TESTNET_API=https://testnet.nexus.stg.oasis.io/v1/ -# REACT_APP_API=https://nexus.oasis.io/v1/ -# REACT_APP_TESTNET_API=https://testnet.nexus.oasis.io/v1/ +# REACT_APP_API=https://nexus.stg.oasis.io/v1/ +# REACT_APP_TESTNET_API=https://testnet.nexus.stg.oasis.io/v1/ +REACT_APP_API=https://nexus.oasis.io/v1/ +REACT_APP_TESTNET_API=https://testnet.nexus.oasis.io/v1/ From fc8be7d5f74d08e2521621c83157fa65712d1ebf Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sun, 28 Jan 2024 00:30:23 +0100 Subject: [PATCH 9/9] Also highlight proposal search results --- .../NetworkProposalsList/ProposalDetails.tsx | 16 ++++++++++------ .../SearchResultsPage/SearchResultsList.tsx | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/components/NetworkProposalsList/ProposalDetails.tsx b/src/app/components/NetworkProposalsList/ProposalDetails.tsx index 857681c50..207aadf47 100644 --- a/src/app/components/NetworkProposalsList/ProposalDetails.tsx +++ b/src/app/components/NetworkProposalsList/ProposalDetails.tsx @@ -6,12 +6,14 @@ import { DashboardLink } from '../../pages/ParatimeDashboardPage/DashboardLink' import { useTranslation } from 'react-i18next' import { RoundedBalance } from '../RoundedBalance' import { ProposalStatusIcon } from '../ProposalStatusIcon' +import { HighlightedText } from '../HighlightedText' -export const ProposalDetails: FC<{ proposal: Proposal; showLayer?: boolean; standalone?: boolean }> = ({ - proposal, - showLayer = false, - standalone = false, -}) => { +export const ProposalDetails: FC<{ + proposal: Proposal + highlightedPart?: string + showLayer?: boolean + standalone?: boolean +}> = ({ proposal, highlightedPart, showLayer = false, standalone = false }) => { const { t } = useTranslation() const { isMobile } = useScreenSize() return ( @@ -29,7 +31,9 @@ export const ProposalDetails: FC<{ proposal: Proposal; showLayer?: boolean; stan
{proposal.id}
{t('networkProposal.handler')}
-
{proposal.handler}
+
+ +
{t('networkProposal.deposit')}
diff --git a/src/app/pages/SearchResultsPage/SearchResultsList.tsx b/src/app/pages/SearchResultsPage/SearchResultsList.tsx index 17befc7bb..53239df2d 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsList.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsList.tsx @@ -121,7 +121,7 @@ export const SearchResultsList: FC<{ item.resultType === 'proposal')} - resultComponent={item => } + resultComponent={item => } link={() => ''} linkLabel="" />