Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal name search with highlight demo #1193

Closed
1 change: 1 addition & 0 deletions .changelog/646.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Highlight matching part in token names in search results
8 changes: 4 additions & 4 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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/
72 changes: 72 additions & 0 deletions src/app/components/HighlightedText/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HighlightedTextProps> = ({ text, pattern, options = defaultHighlight }) => {
const match = findTextMatch(text, [pattern])
const {
// className,
sx,
} = options

return text === undefined ? undefined : match ? (
<>
{text.substring(0, match.startPos)}
<Box display={'inline'} sx={sx}>
{text.substring(match.startPos, match.startPos + match.searchText.length)}
</Box>
{text.substring(match.startPos + match.searchText.length)}
</>
) : (
text
)
}
246 changes: 246 additions & 0 deletions src/app/components/HighlightedText/text-matching.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = new Map<string, string>()
const caseInsensitiveDiacriticNormalizationTable: Map<string, string> = new Map<string, string>()
const caseSensitiveDiacriticRegexpTable: Map<string, string> = new Map<string, string>()
const caseInsensitiveDiacriticRegexpTable: Map<string, string> = new Map<string, string>()

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
}
55 changes: 55 additions & 0 deletions src/app/components/NetworkProposalsList/ProposalDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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'
import { HighlightedText } from '../HighlightedText'

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 (
<StyledDescriptionList titleWidth={isMobile ? '100px' : '200px'} standalone={standalone}>
{showLayer && (
<>
<dt>{t('common.network')}</dt>
<dd>
<DashboardLink scope={{ network: proposal.network, layer: Layer.consensus }} />
</dd>
</>
)}

<dt>{t('networkProposal.id')}</dt>
<dd>{proposal.id}</dd>

<dt>{t('networkProposal.handler')}</dt>
<dd>
<HighlightedText text={proposal.handler} pattern={highlightedPart} />
</dd>

<dt>{t('networkProposal.deposit')}</dt>
<dd>
<RoundedBalance value={proposal.deposit} />
</dd>

<dt>{t('networkProposal.create')}</dt>
<dd>{proposal.created_at}</dd>

<dt>{t('networkProposal.close')}</dt>
<dd>{proposal.closes_at}</dd>

<dt>{t('common.status')}</dt>
<dd>
<ProposalStatusIcon status={proposal.state} />
</dd>
</StyledDescriptionList>
)
}
Loading
Loading