Skip to content
Merged
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
1 change: 0 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion web/src/layout/common/SearchRepositories.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('SearchRepositories', () => {
const mockSearch = getMockSearch('1');
vi.mocked(API).searchRepositories.mockResolvedValue(mockSearch);

render(<SearchRepositories {...defaultProps} />);
const { container } = render(<SearchRepositories {...defaultProps} />);

const input = screen.getByRole('textbox', { name: 'Search repositories' });
expect(input).toBeInTheDocument();
Expand All @@ -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 () => {
Expand Down
35 changes: 17 additions & 18 deletions web/src/layout/common/SearchRepositories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<span key={`match_${repositoryName}_${index}`} className="fw-bold highlighted">
{match}
</span>
);
},
input: repositoryName,
});
};

const cleanTimeout = () => {
if (!isNull(dropdownTimeout)) {
clearTimeout(dropdownTimeout);
Expand Down Expand Up @@ -298,23 +313,7 @@ const SearchRepositories = (props: Props) => {
<td className="align-middle">
<div className={styles.truncateWrapper}>
<div className="text-truncate">
{searchName === '' ? (
<>{item.name}</>
) : (
<>
{regexifyString({
pattern: new RegExp(escapeRegExp(searchName), 'gi'),
decorator: (match: string, index: number) => {
return (
<span key={`match_${item.name}_${index}`} className="fw-bold highlighted">
{match}
</span>
);
},
input: item.name,
})}
</>
)}
{searchName === '' ? <>{item.name}</> : <>{getHighlightedRepositoryName(item.name)}</>}
</div>
</div>
</td>
Expand Down
3 changes: 1 addition & 2 deletions web/src/layout/package/chartTemplates/Template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions web/src/utils/regexifyString.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span key={`match_${index}`}>{match}</span>;
},
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 <span>{result[1]}</span>;
},
input: 'repo-1 repo-2',
})}
</>
);

expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions web/src/utils/regexifyString.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 0 additions & 5 deletions web/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading