Skip to content

Commit 8ca110a

Browse files
authored
Merge pull request #2012 from oasisprotocol/csillag/flexible-search
Support flexible searching for multiple words in random order (in all free-text search)
2 parents 3b73bf3 + 16f4fd3 commit 8ca110a

46 files changed

Lines changed: 524 additions & 320 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changelog/2012.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enable flexible multi-word searching in names

src/app/components/Account/AccountLink.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useAccountMetadata } from '../../hooks/useAccountMetadata'
1010
import { trimLongString } from '../../utils/trimLongString'
1111
import { MaybeWithTooltip } from '../Tooltip/MaybeWithTooltip'
1212
import Box from '@mui/material/Box'
13-
import { HighlightedText } from '../HighlightedText'
13+
import { HighlightedText, HighlightPattern } from '../HighlightedText'
1414
import { AdaptiveHighlightedText } from '../HighlightedText/AdaptiveHighlightedText'
1515
import { AdaptiveTrimmer } from '../AdaptiveTrimmer/AdaptiveTrimmer'
1616
import { AccountMetadataSourceIndicator } from './AccountMetadataSourceIndicator'
@@ -62,7 +62,7 @@ interface Props {
6262
/**
6363
* What part of the name should be highlighted (if any)
6464
*/
65-
highlightedPartOfName?: string | undefined
65+
highlightPattern?: HighlightPattern
6666

6767
/**
6868
* Any extra tooltips to display
@@ -85,7 +85,7 @@ export const AccountLink: FC<Props> = ({
8585
address,
8686
alwaysTrim,
8787
alwaysTrimOnTablet,
88-
highlightedPartOfName,
88+
highlightPattern,
8989
extraTooltip,
9090
labelOnly,
9191
}) => {
@@ -158,7 +158,7 @@ export const AccountLink: FC<Props> = ({
158158
sx={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}
159159
>
160160
<AccountMetadataSourceIndicator source={accountMetadata!.source} />
161-
<HighlightedText text={accountName} pattern={highlightedPartOfName} /> ({address})
161+
<HighlightedText text={accountName} pattern={highlightPattern} /> ({address})
162162
</Box>
163163
) : (
164164
address
@@ -183,7 +183,7 @@ export const AccountLink: FC<Props> = ({
183183
<AdaptiveHighlightedText
184184
idPrefix="account-name"
185185
text={accountName}
186-
pattern={highlightedPartOfName}
186+
pattern={highlightPattern}
187187
extraTooltip={tooltipTitle}
188188
minLength={5}
189189
/>

src/app/components/Account/ConsensusAccountDetailsView.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AccountSizeBadge } from '../AccountSizeBadge'
1414
import { ConsensusAccountLink } from './ConsensusAccountLink'
1515
import { CopyToClipboard } from '../CopyToClipboard'
1616
import { getPreciseNumberFormat } from '../../../locales/getPreciseNumberFormat'
17+
import { HighlightPattern } from '../HighlightedText'
1718

1819
export const StyledListTitle = styled('dt')(({ theme }) => ({
1920
marginLeft: theme.spacing(4),
@@ -25,7 +26,7 @@ type ConsensusAccountDetailsViewProps = {
2526
isLoading?: boolean
2627
showLayer?: boolean
2728
standalone?: boolean
28-
highlightedPartOfName?: string
29+
highlightPattern?: HighlightPattern
2930
}
3031

3132
export const ConsensusAccountDetailsView: FC<ConsensusAccountDetailsViewProps> = ({
@@ -34,7 +35,7 @@ export const ConsensusAccountDetailsView: FC<ConsensusAccountDetailsViewProps> =
3435
isLoading,
3536
showLayer,
3637
standalone,
37-
highlightedPartOfName,
38+
highlightPattern,
3839
}) => {
3940
const { t } = useTranslation()
4041
const { isMobile } = useScreenSize()
@@ -64,7 +65,7 @@ export const ConsensusAccountDetailsView: FC<ConsensusAccountDetailsViewProps> =
6465
alwaysTrim={false}
6566
network={account.network}
6667
address={account.address}
67-
highlightedPartOfName={highlightedPartOfName}
68+
highlightPattern={highlightPattern}
6869
/>
6970
<CopyToClipboard value={account.address} />
7071
</dd>

src/app/components/Account/ConsensusAccountLink.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@ import { Layer, useGetConsensusValidatorsAddressNameMap } from './../../../oasis
33
import { Network } from '../../../types/network'
44
import { ValidatorLink } from '../Validators/ValidatorLink'
55
import { AccountLink } from './AccountLink'
6+
import { HighlightPattern } from '../HighlightedText'
67

78
type ConsensusAccountLinkProps = {
89
address: string
910
alwaysTrim?: boolean
1011
labelOnly?: boolean
1112
network: Network
12-
highlightedPartOfName?: string | undefined
13+
highlightPattern?: HighlightPattern
1314
}
1415

1516
export const ConsensusAccountLink: FC<ConsensusAccountLinkProps> = ({
1617
address,
1718
alwaysTrim = true,
1819
labelOnly,
1920
network,
20-
highlightedPartOfName,
21+
highlightPattern,
2122
}) => {
2223
const { data } = useGetConsensusValidatorsAddressNameMap(network)
2324

@@ -27,7 +28,7 @@ export const ConsensusAccountLink: FC<ConsensusAccountLinkProps> = ({
2728
address={address}
2829
network={network}
2930
alwaysTrim={alwaysTrim}
30-
highlightedPartOfName={highlightedPartOfName}
31+
highlightPattern={highlightPattern}
3132
/>
3233
)
3334
}
@@ -38,7 +39,7 @@ export const ConsensusAccountLink: FC<ConsensusAccountLinkProps> = ({
3839
scope={{ network, layer: Layer.consensus }}
3940
address={address}
4041
alwaysTrim={alwaysTrim}
41-
highlightedPartOfName={highlightedPartOfName}
42+
highlightPattern={highlightPattern}
4243
/>
4344
)
4445
}

src/app/components/Account/RuntimeAccountDetailsView.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { extractMinimalProxyERC1167 } from '../ContractVerificationIcon/extractM
2525
import { AbiPlaygroundLink } from '../ContractVerificationIcon/AbiPlaygroundLink'
2626
import Box from '@mui/material/Box'
2727
import { transactionsContainerId } from '../../utils/tabAnchors'
28+
import { HighlightPattern } from '../HighlightedText'
2829

2930
type RuntimeAccountDetailsViewProps = {
3031
isLoading?: boolean
@@ -33,7 +34,7 @@ type RuntimeAccountDetailsViewProps = {
3334
token?: EvmToken
3435
tokenPrices: AllTokenPrices
3536
showLayer?: boolean
36-
highlightedPartOfName?: string
37+
highlightPattern?: HighlightPattern
3738
}
3839

3940
export const RuntimeAccountDetailsView: FC<RuntimeAccountDetailsViewProps> = ({
@@ -43,7 +44,7 @@ export const RuntimeAccountDetailsView: FC<RuntimeAccountDetailsViewProps> = ({
4344
isError,
4445
tokenPrices,
4546
showLayer,
46-
highlightedPartOfName,
47+
highlightPattern,
4748
}) => {
4849
const { t } = useTranslation()
4950
const { isMobile } = useScreenSize()
@@ -81,7 +82,7 @@ export const RuntimeAccountDetailsView: FC<RuntimeAccountDetailsViewProps> = ({
8182
showOnlyAddress={!!token?.name}
8283
scope={account}
8384
address={address!}
84-
highlightedPartOfName={highlightedPartOfName}
85+
highlightPattern={highlightPattern}
8586
/>
8687
<CopyToClipboard value={address!} />
8788
</Box>

src/app/components/HighlightedText/AdaptiveHighlightedText.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FC, ReactNode } from 'react'
22
import InfoIcon from '@mui/icons-material/Info'
3-
import { HighlightedText, HighlightOptions } from './index'
3+
import { HighlightedText, HighlightOptions, HighlightPattern } from './index'
44
import { AdaptiveDynamicTrimmer } from '../AdaptiveTrimmer/AdaptiveDynamicTrimmer'
55
import { HighlightedTrimmedText } from './HighlightedTrimmedText'
66

@@ -15,7 +15,7 @@ type AdaptiveHighlightedTextProps = {
1515
/**
1616
* The pattern to search for (and highlight)
1717
*/
18-
pattern: string | undefined
18+
pattern?: HighlightPattern
1919

2020
/**
2121
* Options for highlighting (case sensitivity, styling, etc.)

src/app/components/HighlightedText/HighlightedTrimmedText.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FC } from 'react'
22

3-
import { HighlightedText, HighlightOptions } from './index'
3+
import { HighlightedText, HighlightOptions, HighlightPattern, NoHighlights } from './index'
44
import { trimAroundMatch } from './text-trimming'
55

66
type HighlightedTrimmedTextProps = {
@@ -12,7 +12,7 @@ type HighlightedTrimmedTextProps = {
1212
/**
1313
* The pattern to search for (and highlight)
1414
*/
15-
pattern: string | undefined
15+
pattern?: HighlightPattern
1616

1717
/**
1818
* Options for highlighting (case sensitivity, styling, etc.)
@@ -32,7 +32,7 @@ type HighlightedTrimmedTextProps = {
3232
* Display a text with a part highlighted, trimmed to a specific length around the highlight
3333
*/
3434
export const HighlightedTrimmedText: FC<HighlightedTrimmedTextProps> = props => {
35-
const { text, pattern, fragmentLength, options } = props
36-
const { part, match } = trimAroundMatch(text, pattern, { fragmentLength })
37-
return <HighlightedText text={part} pattern={pattern} part={match} options={options} />
35+
const { text, pattern = NoHighlights, fragmentLength, options } = props
36+
const { part } = trimAroundMatch(text, pattern, { fragmentLength })
37+
return <HighlightedText text={part} pattern={pattern} options={options} />
3838
}

src/app/components/HighlightedText/index.tsx

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
2-
import { MatchInfo, findTextMatch, NO_MATCH, NormalizerOptions } from './text-matching'
3-
import { FC } from 'react'
2+
import { findTextMatches, NormalizerOptions, PositiveMatchInfo } from './text-matching'
3+
import { FC, ReactNode } from 'react'
44
import { SxProps } from '@mui/material/styles'
55
import Box from '@mui/material/Box'
66

@@ -35,6 +35,10 @@ const defaultHighlight: HighlightOptions = {
3535
sx: defaultHighlightStyle,
3636
}
3737

38+
export type HighlightPattern = string[]
39+
40+
export const NoHighlights: HighlightPattern = []
41+
3842
interface HighlightedTextProps {
3943
/**
4044
* The text to display
@@ -44,15 +48,15 @@ interface HighlightedTextProps {
4448
/**
4549
* The pattern to search for (and highlight)
4650
*/
47-
pattern: string | undefined
51+
pattern?: HighlightPattern
4852

4953
/**
5054
* Instructions about which part to highlight.
5155
*
5256
* If not given, we will just search for the pattern.
5357
* If given, this will be executed, and the pattern will not even be considered.
5458
*/
55-
part?: MatchInfo
59+
partsToHighlight?: PositiveMatchInfo[]
5660

5761
/**
5862
* Options for highlighting (case sensitivity, styling, etc.)
@@ -67,29 +71,51 @@ interface HighlightedTextProps {
6771
*/
6872
export const HighlightedText: FC<HighlightedTextProps> = ({
6973
text,
70-
pattern,
71-
part,
74+
pattern = NoHighlights,
75+
partsToHighlight,
7276
options = defaultHighlight,
7377
}) => {
7478
const { sx = defaultHighlightStyle, findOptions = {} } = options
7579

7680
// Have we been told what to highlight exactly? If not, look for the pattern
77-
const task = part ?? findTextMatch(text, [pattern], findOptions)
81+
const matches = partsToHighlight ?? findTextMatches(text, pattern, findOptions)
7882

7983
if (text === undefined) return undefined // Nothing to display
80-
if (task === NO_MATCH) return <span>{text}</span> // We don't have to highlight anything
81-
82-
const beginning = text.substring(0, task.startPos)
83-
const focus = text.substring(task.startPos, task.endPos)
84-
const end = text.substring(task.endPos)
85-
86-
return (
87-
<span style={{ textWrap: 'nowrap' }}>
88-
{beginning}
89-
<Box component="mark" sx={sx}>
90-
{focus}
91-
</Box>
92-
{end}
93-
</span>
84+
if (!matches.length) return text // We don't have to highlight anything
85+
86+
const sortedMatches = matches.sort((a, b) =>
87+
a.startPos > b.startPos ? 1 : a.startPos < b.startPos ? -1 : 0,
9488
)
89+
90+
const pieces: ReactNode[] = []
91+
let processedChars = 0
92+
let processedMatches = 0
93+
94+
while (processedChars < text.length) {
95+
// Do we still have matches to highlight?
96+
if (processedMatches < sortedMatches.length) {
97+
// Yes, there are more matches
98+
const match = sortedMatches[processedMatches]
99+
if (match.startPos < processedChars) {
100+
// This match would collude with something already highlighted
101+
processedMatches++ // just skip this match
102+
} else {
103+
// We want to highlight this match
104+
pieces.push(text.substring(processedChars, match.startPos))
105+
const focus = text.substring(match.startPos, match.endPos)
106+
pieces.push(
107+
<Box key={processedMatches} component="mark" sx={sx}>
108+
{focus}
109+
</Box>,
110+
)
111+
processedChars = match.endPos
112+
}
113+
} else {
114+
// No more matches, just grab the remaining string
115+
pieces.push(text.substring(processedChars))
116+
processedChars = text.length
117+
}
118+
}
119+
120+
return <span style={{ textWrap: 'nowrap' }}>{pieces}</span>
95121
}

src/app/components/HighlightedText/text-matching.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ export type MatchInfo = PositiveMatchInfo | typeof NO_MATCH
1616
/**
1717
* Identify pattern matches within a corpus, also considering normalization
1818
*
19+
* If there is no match, an empty array is returned.
20+
*
1921
* NOTE: depending on normalization options, the string length can change,
2022
* and in that case, match position can be incorrect.
2123
*/
22-
export const findTextMatch = (
24+
export const findTextMatches = (
2325
rawCorpus: string | null | undefined,
24-
search: (string | undefined)[],
26+
pattern: (string | undefined)[],
2527
options: NormalizerOptions = {},
26-
): MatchInfo => {
28+
): PositiveMatchInfo[] => {
2729
const normalizedCorpus = normalizeTextForSearch(rawCorpus || '', options)
28-
const matches: PositiveMatchInfo[] = search
30+
const matches: PositiveMatchInfo[] = pattern
2931
.filter((s): s is string => !!s)
3032
.map(rawPattern => {
3133
const normalizedPattern = normalizeTextForSearch(rawPattern!, options)
@@ -38,17 +40,40 @@ export const findTextMatch = (
3840
: 'NO_MATCH'
3941
})
4042
.filter((m): m is PositiveMatchInfo => m !== NO_MATCH)
41-
return matches[0] ?? NO_MATCH
43+
return matches
4244
}
4345

4446
/**
45-
* Check if a pattern matches within a corpus, also considering normalization
47+
* Identify the first pattern match within a corpus, also considering normalization
48+
*
49+
* If there is no match, NO_MATCH is returned.
4650
*
4751
* NOTE: depending on normalization options, the string length can change,
4852
* and in that case, match position can be incorrect.
4953
*/
50-
export const hasTextMatch = (
54+
export const findTextMatch = (
5155
rawCorpus: string | null | undefined,
5256
search: (string | undefined)[],
5357
options: NormalizerOptions = {},
54-
): boolean => findTextMatch(rawCorpus, search, options) !== NO_MATCH
58+
): MatchInfo => {
59+
const matches = findTextMatches(rawCorpus, search, options)
60+
return matches[0] ?? NO_MATCH
61+
}
62+
63+
/**
64+
* Check if all patterns match within a corpus, also considering normalization
65+
*
66+
* NOTE: depending on normalization options, the string length can change,
67+
* and in that case, match position can be incorrect.
68+
*
69+
* Also NOTE: if there are no patterns given, the result will be true.
70+
*
71+
*/
72+
export const hasTextMatchesForAll = (
73+
rawCorpus: string | null | undefined,
74+
patterns: (string | undefined)[],
75+
options: NormalizerOptions = {},
76+
): boolean =>
77+
patterns
78+
.filter(pattern => !!pattern)
79+
.every(pattern => findTextMatch(rawCorpus, [pattern], options) !== NO_MATCH)

0 commit comments

Comments
 (0)