diff --git a/web/package.json b/web/package.json index 41409019e..278245aa4 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,6 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "react-syntax-highlighter": "^16.1.1", - "regexify-string": "^1.0.17", "rehype-github-alerts": "^4.2.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", diff --git a/web/src/layout/common/SearchRepositories.test.tsx b/web/src/layout/common/SearchRepositories.test.tsx index 76b2aaf7a..2e5742f78 100644 --- a/web/src/layout/common/SearchRepositories.test.tsx +++ b/web/src/layout/common/SearchRepositories.test.tsx @@ -42,7 +42,7 @@ describe('SearchRepositories', () => { const mockSearch = getMockSearch('1'); vi.mocked(API).searchRepositories.mockResolvedValue(mockSearch); - render(); + const { container } = render(); const input = screen.getByRole('textbox', { name: 'Search repositories' }); expect(input).toBeInTheDocument(); @@ -55,6 +55,9 @@ describe('SearchRepositories', () => { }); expect(await screen.findAllByRole('button')).toHaveLength(3); + const highlightedMatches = container.querySelectorAll('.highlighted'); + expect(highlightedMatches).toHaveLength(1); + expect(highlightedMatches[0]).toHaveTextContent('sec'); }); it('selects repo', async () => { diff --git a/web/src/layout/common/SearchRepositories.tsx b/web/src/layout/common/SearchRepositories.tsx index cd644800b..0d890d9d9 100644 --- a/web/src/layout/common/SearchRepositories.tsx +++ b/web/src/layout/common/SearchRepositories.tsx @@ -6,12 +6,12 @@ import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { FaUser } from 'react-icons/fa'; import { FiSearch } from 'react-icons/fi'; import { MdBusiness } from 'react-icons/md'; -import regexifyString from 'regexify-string'; import API from '../../api'; import useOutsideClick from '../../hooks/useOutsideClick'; import { ErrorKind, Repository, SearchQuery } from '../../types'; import alertDispatcher from '../../utils/alertDispatcher'; +import regexifyString from '../../utils/regexifyString'; import Loading from './Loading'; import RepositoryIcon from './RepositoryIcon'; import styles from './SearchRepositories.module.css'; @@ -162,6 +162,21 @@ const SearchRepositories = (props: Props) => { } }; + // Highlights the typed search term in repository names. + const getHighlightedRepositoryName = (repositoryName: string) => { + return regexifyString({ + pattern: new RegExp(escapeRegExp(searchName), 'gi'), + decorator: (match: string, index: number) => { + return ( + + {match} + + ); + }, + input: repositoryName, + }); + }; + const cleanTimeout = () => { if (!isNull(dropdownTimeout)) { clearTimeout(dropdownTimeout); @@ -298,23 +313,7 @@ const SearchRepositories = (props: Props) => {
- {searchName === '' ? ( - <>{item.name} - ) : ( - <> - {regexifyString({ - pattern: new RegExp(escapeRegExp(searchName), 'gi'), - decorator: (match: string, index: number) => { - return ( - - {match} - - ); - }, - input: item.name, - })} - - )} + {searchName === '' ? <>{item.name} : <>{getHighlightedRepositoryName(item.name)}}
diff --git a/web/src/layout/package/chartTemplates/Template.tsx b/web/src/layout/package/chartTemplates/Template.tsx index 5294bb8a0..50b9bda68 100644 --- a/web/src/layout/package/chartTemplates/Template.tsx +++ b/web/src/layout/package/chartTemplates/Template.tsx @@ -6,11 +6,11 @@ import isObject from 'lodash/isObject'; import isString from 'lodash/isString'; import isUndefined from 'lodash/isUndefined'; import { Dispatch, Fragment, memo, SetStateAction, useContext, useEffect, useState } from 'react'; -import * as regexifyStringModule from 'regexify-string'; import { AppCtx } from '../../../context/AppCtx'; import { ChartTemplate, ChartTemplateSpecialType, DefinedTemplate, DefinedTemplatesList } from '../../../types'; import processHelmTemplate from '../../../utils/processHelmTemplate'; +import regexifyString from '../../../utils/regexifyString'; import AutoresizeTextarea from '../../common/AutoresizeTextarea'; import builtInDefinitions from './builtIn.json'; import functionsDefinitions from './functions.json'; @@ -32,7 +32,6 @@ const SPECIAL_CHARACTERS = /[^|({})-]+/; const TOKENIZE_RE = /[^\s"']+|"([^"]*)"|'([^']*)/g; const INITIAL_HELPER_COMMENT = /{{\/\*|{{- \/\*/; const FINAL_HELPER_COMMENT = /\*\/}}|\*\/ -}}$/; -const regexifyString = regexifyStringModule.default; const Template = (props: Props) => { const { ctx } = useContext(AppCtx); diff --git a/web/src/utils/regexifyString.test.tsx b/web/src/utils/regexifyString.test.tsx new file mode 100644 index 000000000..9dd76867f --- /dev/null +++ b/web/src/utils/regexifyString.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; + +import regexifyString from './regexifyString'; + +describe('regexifyString', () => { + it('decorates matches', () => { + render( + <> + {regexifyString({ + pattern: /hub/gi, + decorator: (match, index) => { + return {match}; + }, + input: 'artifact-hub security-hub', + })} + + ); + + expect(screen.getAllByText('hub')).toHaveLength(2); + }); + + it('passes the match result to the decorator', () => { + render( + <> + {regexifyString({ + pattern: /repo-(\d+)/, + decorator: (_match, _index, result) => { + return {result[1]}; + }, + input: 'repo-1 repo-2', + })} + + ); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); +}); diff --git a/web/src/utils/regexifyString.ts b/web/src/utils/regexifyString.ts new file mode 100644 index 000000000..bf76befa7 --- /dev/null +++ b/web/src/utils/regexifyString.ts @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; + +interface RegexifyStringProps { + pattern: RegExp; + decorator: (match: string, index: number, result: RegExpExecArray) => ReactNode; + input: string; +} + +/** + * Splits text by a regex and decorates each match for React rendering. + */ +const regexifyString = (props: RegexifyStringProps): ReactNode[] => { + const { pattern, decorator, input } = props; + // Work on a global copy so callers can pass reusable regex constants. + const patternFlags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`; + const globalPattern = new RegExp(pattern.source, patternFlags); + const output: ReactNode[] = []; + let match = globalPattern.exec(input); + let matchIndex = 0; + let previousMatchEnd = 0; + + while (match !== null) { + const matchStart = match.index; + const matchValue = match[0]; + // Preserve text between matches so React can render the original input. + output.push(input.substring(previousMatchEnd, matchStart)); + output.push(decorator(matchValue, matchIndex, match)); + + // Avoid infinite loops when a regex can match an empty string. + if (matchValue.length === 0) { + globalPattern.lastIndex += 1; + } + previousMatchEnd = globalPattern.lastIndex; + matchIndex += 1; + match = globalPattern.exec(input); + } + + if (previousMatchEnd < input.length) { + output.push(input.substring(previousMatchEnd)); + } + + return output; +}; + +export default regexifyString; diff --git a/web/yarn.lock b/web/yarn.lock index 231bfdb4d..c7019baea 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4591,11 +4591,6 @@ refractor@^5.0.0: hastscript "^9.0.0" parse-entities "^4.0.0" -regexify-string@^1.0.17: - version "1.0.19" - resolved "https://registry.yarnpkg.com/regexify-string/-/regexify-string-1.0.19.tgz#74fab49b348b8faffbd948218ea3a09e25303010" - integrity sha512-EREOggl31J6v2Hk3ksPuOof0DMq5QhFfVQ7iDaGQ6BeA1QcrV4rhGvwCES5a72ITMmLBDAOb6cOWbn8/Ja82Ig== - rehype-github-alerts@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/rehype-github-alerts/-/rehype-github-alerts-4.2.0.tgz#876f2a5aefcb5f80637077490dc65b14183b1397"