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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions web/client/src/library/components/search/SearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,31 @@ export default function SearchList<
)
}

function HighlightedText({
source,
search,
}: {
source: string
search: string
}): JSX.Element {
return (
<>
{highlightMatch(source, search).map(({ text, isMatch }, idx) =>
isMatch ? (
<b
key={idx}
className="inline-block text-brand-500 bg-brand-10"
>
{text}
</b>
) : (
<Fragment key={idx}>{text}</Fragment>
),
)}
</>
)
}

function SearchResult<T extends Record<string, any> = Record<string, any>>({
item,
index,
Expand Down Expand Up @@ -322,21 +347,23 @@ function SearchResult<T extends Record<string, any> = Record<string, any>>({
<TBKModelName text={item[displayBy]}></TBKModelName>
)}
</span>
<small
className="block text-neutral-600 italic overflow-hidden whitespace-nowrap overflow-ellipsis"
dangerouslySetInnerHTML={{
__html: highlightMatch(index, search),
}}
></small>
<small className="block text-neutral-600 italic overflow-hidden whitespace-nowrap overflow-ellipsis">
<HighlightedText
source={index}
search={search}
/>
</small>
</>
) : (
<span
title={item[displayBy]}
className="font-bold"
dangerouslySetInnerHTML={{
__html: highlightMatch(item[displayBy], search),
}}
></span>
>
<HighlightedText
source={item[displayBy]}
search={search}
/>
</span>
)}
{isNotNil(descriptionBy) && (
<small className="block text-neutral-600 italic overflow-hidden whitespace-nowrap overflow-ellipsis">
Expand Down
25 changes: 20 additions & 5 deletions web/client/src/library/components/search/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@ export { filterListBy, highlightMatch }
export const EMPTY_STRING = ''
const MATCHED_LENGTH_TO_DISPLAY = 40

function highlightMatch(source: string, match: string): string {
return source.replaceAll(
match,
`<b class="inline-block text-brand-500 bg-brand-10">${match}</b>`,
)
export interface HighlightPart {
text: string
isMatch: boolean
}

// NOTE: Returns plain text parts so callers can render them as React text nodes
function highlightMatch(source: string, match: string): HighlightPart[] {
if (match === EMPTY_STRING) return [{ text: source, isMatch: false }]

return source
.split(match)
.flatMap((text, idx) =>
idx === 0
? [{ text, isMatch: false }]
: [
{ text: match, isMatch: true },
{ text, isMatch: false },
],
)
.filter(part => part.text !== EMPTY_STRING)
}

// NOTE: This function is used to filter the list of models in the search bar
Expand Down
66 changes: 66 additions & 0 deletions web/client/src/library/components/search/tests/SearchList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, vi } from 'vitest'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need the # SPDX-License-Identifier: Apache-2.0 header on new files

import { MemoryRouter } from 'react-router'
import { render, fireEvent } from '../../../../tests/utils'
import SearchList from '../SearchList'

// jsdom has no ResizeObserver, which TBKResizeObserver needs to mount
vi.stubGlobal(
'ResizeObserver',
class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
},
)

const PAYLOAD = '<img src=x onerror="alert(1)">'

function renderSearch(
name: string,
search: string,
showIndex: boolean,
): HTMLElement {
const { container } = render(
<MemoryRouter>
<SearchList<{ name: string }>
list={[{ name }]}
searchBy="name"
displayBy="name"
showIndex={showIndex}
/>
</MemoryRouter>,
)

fireEvent.input(container.querySelector('input')!, {
target: { value: search },
})

return container.querySelector('[role="menuitem"]')!
}

describe.each([true, false])('SearchList with showIndex=%s', showIndex => {
it('should render markup in project text as text', () => {
const name = `${PAYLOAD}model`
const result = renderSearch(name, 'model', showIndex)

expect(result.querySelector('img')).toBeNull()
expect(result.textContent).toContain(name)
expect(result.querySelector('b')?.textContent).toBe('model')
})

it('should render markup in the search string as text', () => {
const result = renderSearch(`a${PAYLOAD}b`, PAYLOAD, showIndex)

expect(result.querySelector('img')).toBeNull()
expect(result.querySelector('b')?.textContent).toBe(PAYLOAD)
})

it('should highlight every match, including adjacent and trailing ones', () => {
const result = renderSearch('ababxab', 'ab', showIndex)

expect(
Array.from(result.querySelectorAll('b'), b => b.textContent),
).toEqual(['ab', 'ab', 'ab'])
expect(result.textContent).toContain('ababxab')
})
})
Loading