diff --git a/changelogs/upcoming/7496.md b/changelogs/upcoming/7496.md new file mode 100644 index 00000000000..fcb4b3e8f93 --- /dev/null +++ b/changelogs/upcoming/7496.md @@ -0,0 +1,5 @@ +- Updated `EuiHighlight` to accept an array of `search` strings, which allows highlighting multiple, separate words within its children. This new type and behavior *only* works if `highlightAll` is also set to true. + +**Bug fixes** + +- Fixed `EuiHighlight` to not parse `search` strings as regexes diff --git a/scripts/eslint-plugin/forward_ref_display_name.js b/scripts/eslint-plugin/forward_ref_display_name.js index c7e9b703fde..8b2ff085110 100644 --- a/scripts/eslint-plugin/forward_ref_display_name.js +++ b/scripts/eslint-plugin/forward_ref_display_name.js @@ -2,11 +2,12 @@ module.exports = { meta: { type: 'problem', docs: { - description: 'Enforce display name to forwardRef components', + description: + 'Enforce display name on components wrapped in forwardRef & memo', }, }, - create: function(context) { - const forwardRefUsages = []; + create: function (context) { + const usagesToCheck = []; const displayNameUsages = []; return { VariableDeclarator(node) { @@ -16,14 +17,20 @@ module.exports = { node.init.callee.type === 'MemberExpression' ) { if ( - node.init.callee.property && - node.init.callee.property.name === 'forwardRef' + node.init.callee.property?.name === 'forwardRef' || + node.init.callee.property?.name === 'memo' ) { - forwardRefUsages.push(node.id); + usagesToCheck.push({ + id: node.id, + type: node.init.callee.property.name, + }); } } - if (node.init.callee && node.init.callee.name === 'forwardRef') { - forwardRefUsages.push(node.id); + if ( + node.init.callee?.name === 'forwardRef' || + node.init.callee?.name === 'memo' + ) { + usagesToCheck.push({ id: node.id, type: node.init.callee.name }); } } }, @@ -38,11 +45,11 @@ module.exports = { } }, 'Program:exit'() { - forwardRefUsages.forEach(identifier => { - if (!isDisplayNameUsed(identifier)) { + usagesToCheck.forEach(({ id, type }) => { + if (!isDisplayNameUsed(id)) { context.report({ - node: identifier, - message: 'Forward ref components must use a display name', + node: id, + message: `Components wrapped in React.${type} must set a manual displayName`, }); } }); @@ -50,7 +57,7 @@ module.exports = { }; function isDisplayNameUsed(identifier) { const node = displayNameUsages.find( - displayName => displayName.name === identifier.name + (displayName) => displayName.name === identifier.name ); return !!node; } diff --git a/scripts/eslint-plugin/forward_ref_display_name.test.js b/scripts/eslint-plugin/forward_ref_display_name.test.js index ca8403b263e..c9f1ff8e950 100644 --- a/scripts/eslint-plugin/forward_ref_display_name.test.js +++ b/scripts/eslint-plugin/forward_ref_display_name.test.js @@ -8,6 +8,9 @@ const ruleTester = new RuleTester({ const valid = [ `const Component = React.forwardRef(() => {}) Component.displayName = "EuiBadgeGroup" +`, + `const Component = React.memo(() => {}) + Component.displayName = "EuiHighlight" `, ]; @@ -16,7 +19,17 @@ const invalid = [ code: 'const Component = React.forwardRef(() => {})', errors: [ { - message: 'Forward ref components must use a display name', + message: + 'Components wrapped in React.forwardRef must set a manual displayName', + }, + ], + }, + { + code: 'const Component = React.memo(() => {})', + errors: [ + { + message: + 'Components wrapped in React.memo must set a manual displayName', }, ], }, diff --git a/src-docs/src/views/highlight_and_mark/highlight.js b/src-docs/src/views/highlight_and_mark/highlight.js deleted file mode 100644 index 0e4e4713d0e..00000000000 --- a/src-docs/src/views/highlight_and_mark/highlight.js +++ /dev/null @@ -1,45 +0,0 @@ -import React, { Fragment, useState } from 'react'; - -import { - EuiHighlight, - EuiFieldSearch, - EuiFormRow, - EuiSpacer, - EuiSwitch, -} from '../../../../src/components'; - -export default () => { - const [searchValue, setSearchValue] = useState('jumped over'); - const [isHighlightAll, setHighlightAll] = useState(false); - - const onSearchChange = (e) => { - setSearchValue(e.target.value); - }; - const changeHighlightAll = (e) => { - setHighlightAll(e.target.checked); - }; - - return ( - - - { - onSearchChange(e); - }} - /> - - - - changeHighlightAll(e)} - /> - - - The quick brown fox jumped over the lazy dog - - - ); -}; diff --git a/src-docs/src/views/highlight_and_mark/highlight.tsx b/src-docs/src/views/highlight_and_mark/highlight.tsx new file mode 100644 index 00000000000..50b06f78fcc --- /dev/null +++ b/src-docs/src/views/highlight_and_mark/highlight.tsx @@ -0,0 +1,64 @@ +import React, { useState, useMemo } from 'react'; + +import { + EuiHighlight, + EuiFieldSearch, + EuiFormRow, + EuiSpacer, + EuiSwitch, + EuiFlexGroup, +} from '../../../../src/components'; + +export default () => { + const [searchInput, setSearchInput] = useState('jumped over'); + const [isHighlightAll, setHighlightAll] = useState(false); + const [searchMultiple, setSearchMultiple] = useState(false); + const [caseSensitive, setCaseSensitive] = useState(false); + + const searchValues = useMemo(() => { + return searchMultiple && isHighlightAll + ? searchInput.split(' ') + : searchInput; + }, [searchMultiple, searchInput, isHighlightAll]); + + return ( + <> + + setCaseSensitive(e.target.checked)} + /> + setHighlightAll(e.target.checked)} + /> + {isHighlightAll && ( + setSearchMultiple(e.target.checked)} + /> + )} + + + + + setSearchInput(e.target.value)} + /> + + + + + The quick brown fox jumped over the lazy dog + + + ); +}; diff --git a/src-docs/src/views/highlight_and_mark/mark.js b/src-docs/src/views/highlight_and_mark/mark.tsx similarity index 70% rename from src-docs/src/views/highlight_and_mark/mark.js rename to src-docs/src/views/highlight_and_mark/mark.tsx index 0f3d72a1bd9..9d903a9f049 100644 --- a/src-docs/src/views/highlight_and_mark/mark.js +++ b/src-docs/src/views/highlight_and_mark/mark.tsx @@ -1,11 +1,11 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { EuiMark } from '../../../../src/components'; export default () => { return ( - + <> The quick brown fox jumped over the lazy dog - + ); }; diff --git a/src-docs/src/views/highlight_and_mark/playground.js b/src-docs/src/views/highlight_and_mark/playground.js index 2589f2861e5..0601930da83 100644 --- a/src-docs/src/views/highlight_and_mark/playground.js +++ b/src-docs/src/views/highlight_and_mark/playground.js @@ -15,7 +15,11 @@ export const highlightConfig = () => { value: 'The quick brown fox jumped over the lazy dog', }; - propsToUse.search.value = 'quick'; + propsToUse.search = { + ...propsToUse.search, + type: PropTypes.String, + value: 'quick', + }; return { config: { diff --git a/src/components/datagrid/body/cell/data_grid_cell.tsx b/src/components/datagrid/body/cell/data_grid_cell.tsx index 58e59bcd437..357edb444ff 100644 --- a/src/components/datagrid/body/cell/data_grid_cell.tsx +++ b/src/components/datagrid/body/cell/data_grid_cell.tsx @@ -147,6 +147,7 @@ const EuiDataGridCellContent: FunctionComponent< ); } ); +EuiDataGridCellContent.displayName = 'EuiDataGridCellContent'; export class EuiDataGridCell extends Component< EuiDataGridCellProps, diff --git a/src/components/highlight/__snapshots__/highlight.test.tsx.snap b/src/components/highlight/__snapshots__/highlight.test.tsx.snap index 6c76deac806..dce6cc67640 100644 --- a/src/components/highlight/__snapshots__/highlight.test.tsx.snap +++ b/src/components/highlight/__snapshots__/highlight.test.tsx.snap @@ -1,84 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiHighlight behavior loose matching matches strings with different casing 1`] = ` - - different - - case - - match - -`; - -exports[`EuiHighlight behavior matching applies to all matches 1`] = ` - - - match - - - - match - - - - match - - -`; - -exports[`EuiHighlight behavior matching hasScreenReaderHelpText can be false 1`] = ` - - - match - - - - match - - - - match - - -`; - -exports[`EuiHighlight behavior matching only applies to first match 1`] = ` - - - match - - match match - -`; - -exports[`EuiHighlight behavior strict matching doesn't match strings with different casing 1`] = ` - - different case match - -`; - exports[`EuiHighlight is rendered 1`] = ` - value + + va + + lue `; diff --git a/src/components/highlight/_highlight_all.tsx b/src/components/highlight/_highlight_all.tsx new file mode 100644 index 00000000000..13ae97c601e --- /dev/null +++ b/src/components/highlight/_highlight_all.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, FunctionComponent } from 'react'; +import escapeRegExp from 'lodash/escapeRegExp'; + +import type { _SharedSubcomponentProps } from './highlight'; + +/** + * Internal subcomponent with logic for highlighting all occurrences + * of a search value within a subject + * + * Uses regex rather than indexOf/while loops for easier dev maintainability + */ +export const HighlightAll: FunctionComponent<_SharedSubcomponentProps> = ({ + searchSubject, + searchValue: _searchValue, + isStrict, + highlightComponent: HighlightComponent = 'mark', +}) => { + const searchValue = useMemo(() => { + return Array.isArray(_searchValue) + ? _searchValue.map(escapeRegExp).join('|') + : escapeRegExp(_searchValue); + }, [_searchValue]); + + const chunks = useMemo(() => { + const regex = new RegExp(searchValue, isStrict ? 'g' : 'gi'); + const matches = [...searchSubject.matchAll(regex)].map((match) => ({ + start: match.index || 0, + end: (match.index || 0) + match[0].length, + })); + + return fillInChunks(matches, searchSubject.length); + }, [searchValue, searchSubject, isStrict]); + + return ( + <> + {chunks.map((chunk) => { + const { end, highlight, start } = chunk; + const value = searchSubject.substring(start, end); + + return highlight ? ( + {value} + ) : ( + value + ); + })} + + ); +}; + +/** + * Chunk utility + */ + +interface EuiHighlightChunk { + /** + * Start of the chunk + */ + start: number; + /** + * End of the chunk + */ + end: number; + /** + * Whether to highlight chunk or not + */ + highlight?: boolean; +} +const fillInChunks = ( + chunksToHighlight: EuiHighlightChunk[], + totalLength: number +) => { + const allChunks: EuiHighlightChunk[] = []; + const append = (start: number, end: number, highlight: boolean) => { + if (end - start > 0) { + allChunks.push({ + start, + end, + highlight, + }); + } + }; + if (chunksToHighlight.length === 0) { + append(0, totalLength, false); + } else { + let lastIndex = 0; + chunksToHighlight.forEach((chunk) => { + append(lastIndex, chunk.start, false); + append(chunk.start, chunk.end, true); + lastIndex = chunk.end; + }); + append(lastIndex, totalLength, false); + } + return allChunks; +}; diff --git a/src/components/highlight/_highlight_first.tsx b/src/components/highlight/_highlight_first.tsx new file mode 100644 index 00000000000..2b9bbb06c03 --- /dev/null +++ b/src/components/highlight/_highlight_first.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent } from 'react'; + +import { _SharedSubcomponentProps } from './highlight'; + +/** + * Internal subcomponent with logic for highlighting only the first occurrence + * of a search value within a subject + * + * Uses indexOf for performance (which does matter for, e.g. EuiSelectable searching) + */ +export const HighlightFirst: FunctionComponent<_SharedSubcomponentProps> = ({ + searchSubject, + searchValue, + isStrict, + highlightComponent: HighlightComponent = 'mark', +}) => { + if (Array.isArray(searchValue)) { + throw new Error( + 'Cannot parse multiple search strings without `highlightAll` enabled' + ); + } + + const normalizedSearchSubject = isStrict + ? searchSubject + : searchSubject.toLowerCase(); + const normalizedSearchValue = isStrict + ? searchValue + : searchValue.toLowerCase(); + + const indexOfMatch = normalizedSearchSubject.indexOf(normalizedSearchValue); + if (indexOfMatch === -1) { + return <>{searchSubject}; + } + + const preMatch = searchSubject.substring(0, indexOfMatch); + const match = searchSubject.substring( + indexOfMatch, + indexOfMatch + searchValue.length + ); + const postMatch = searchSubject.substring(indexOfMatch + searchValue.length); + + return ( + // Note: React 16/17 will render empty strings in the DOM. The + // `|| undefined` prevents this & keeps snapshots the same for all versions + <> + {preMatch || undefined} + {match} + {postMatch || undefined} + + ); +}; diff --git a/src/components/highlight/highlight.test.tsx b/src/components/highlight/highlight.test.tsx index 41f8512fc36..8cdc9bd0670 100644 --- a/src/components/highlight/highlight.test.tsx +++ b/src/components/highlight/highlight.test.tsx @@ -15,7 +15,7 @@ import { EuiHighlight } from './highlight'; describe('EuiHighlight', () => { test('is rendered', () => { const { container } = render( - + value ); @@ -30,7 +30,8 @@ describe('EuiHighlight', () => { match match match ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelectorAll('mark')).toHaveLength(1); + expect(container.querySelector('mark')).toHaveTextContent('match'); }); test('applies to all matches', () => { @@ -40,21 +41,32 @@ describe('EuiHighlight', () => { ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelectorAll('mark')).toHaveLength(3); }); - test('hasScreenReaderHelpText can be false', () => { - const { container } = render( - - match match match - - ); + describe('array of search strings', () => { + it('returns results for each word in the array', () => { + const { container } = render( + + The quick brown fox jumped over the lazy dog + + ); - expect(container.firstChild).toMatchSnapshot(); + const results = container.querySelectorAll('mark'); + expect(results).toHaveLength(2); + expect(results[0]).toHaveTextContent('fox'); + expect(results[1]).toHaveTextContent('dog'); + }); + + it('throws an error if `highlightAll` is not set', () => { + expect(() => + render( + + The quick brown fox jumped over the lazy dog + + ) + ).toThrow(); + }); }); }); @@ -64,7 +76,7 @@ describe('EuiHighlight', () => { different case match ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelector('mark')).toBeInTheDocument(); }); }); @@ -76,8 +88,30 @@ describe('EuiHighlight', () => { ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelector('mark')).not.toBeInTheDocument(); }); }); + + it('does not parse regex characters', () => { + const { container } = render( + + match match match + + ); + + expect(container.querySelector('mark')).not.toBeInTheDocument(); + }); + }); + + test('hasScreenReaderHelpText can be false', () => { + const { container } = render( + + match match match + + ); + + expect(container.querySelector('mark')!.className).not.toContain( + 'hasScreenReaderHelpText' + ); }); }); diff --git a/src/components/highlight/highlight.tsx b/src/components/highlight/highlight.tsx index b939deeef68..f8f12e699f0 100644 --- a/src/components/highlight/highlight.tsx +++ b/src/components/highlight/highlight.tsx @@ -6,29 +6,21 @@ * Side Public License, v 1. */ -import React, { Fragment, HTMLAttributes, FunctionComponent } from 'react'; +import React, { + HTMLAttributes, + FunctionComponent, + ElementType, + useMemo, +} from 'react'; + import { CommonProps } from '../common'; import { EuiMark, EuiMarkProps } from '../mark'; -interface EuiHighlightChunk { - /** - * Start of the chunk - */ - start: number; - /** - * End of the chunk - */ - end: number; - /** - * Whether to highlight chunk or not - */ - highlight?: boolean; -} - -type EuiMarkPropHelpText = Pick; +import { HighlightAll } from './_highlight_all'; +import { HighlightFirst } from './_highlight_first'; export type EuiHighlightProps = HTMLAttributes & - EuiMarkPropHelpText & + Pick & CommonProps & { /** * string to highlight as this component's content @@ -36,9 +28,12 @@ export type EuiHighlightProps = HTMLAttributes & children: string; /** - * What to search for + * What to search for. + * + * Allows passing an array of strings (searching by multiple separate + * words or phrases) **only** if `highlightAll` is also set to `true`. */ - search: string; + search: string | string[]; /** * Should the search be strict or not @@ -51,123 +46,6 @@ export type EuiHighlightProps = HTMLAttributes & highlightAll?: boolean; }; -const highlight = ( - searchSubject: string, - searchValue: string, - isStrict: boolean, - highlightAll: boolean, - hasScreenReaderHelpText: boolean -) => { - if (!searchValue) { - return searchSubject; - } - - if (!searchSubject) { - return null; - } - - if (highlightAll) { - const chunks = getHightlightWords(searchSubject, searchValue, isStrict); - return ( - - {chunks.map((chunk) => { - const { end, highlight, start } = chunk; - const value = searchSubject.substring(start, end); - if (highlight) { - return ( - - {value} - - ); - } - return value; - })} - - ); - } - - const normalizedSearchSubject: string = isStrict - ? searchSubject - : searchSubject.toLowerCase(); - const normalizedSearchValue: string = isStrict - ? searchValue - : searchValue.toLowerCase(); - - const indexOfMatch: number = normalizedSearchSubject.indexOf( - normalizedSearchValue - ); - if (indexOfMatch === -1) { - return searchSubject; - } - - const preMatch: string = searchSubject.substring(0, indexOfMatch); - const match: string = searchSubject.substring( - indexOfMatch, - indexOfMatch + searchValue.length - ); - const postMatch: string = searchSubject.substring( - indexOfMatch + searchValue.length - ); - - return ( - - {preMatch || undefined} - - {match} - - {postMatch || undefined} - - ); -}; - -const getHightlightWords = ( - searchSubject: string, - searchValue: string, - isStrict: boolean -) => { - const regex = new RegExp(searchValue, isStrict ? 'g' : 'gi'); - const matches = []; - let match; - while ((match = regex.exec(searchSubject)) !== null) { - matches.push({ - start: match.index, - end: (match.index || 0) + match[0].length, - }); - } - return fillInChunks(matches, searchSubject.length); -}; - -const fillInChunks = ( - chunksToHighlight: EuiHighlightChunk[], - totalLength: number -) => { - const allChunks: EuiHighlightChunk[] = []; - const append = (start: number, end: number, highlight: boolean) => { - if (end - start > 0) { - allChunks.push({ - start, - end, - highlight, - }); - } - }; - if (chunksToHighlight.length === 0) { - append(0, totalLength, false); - } else { - let lastIndex = 0; - chunksToHighlight.forEach((chunk) => { - append(lastIndex, chunk.start, false); - append(chunk.start, chunk.end, true); - lastIndex = chunk.end; - }); - append(lastIndex, totalLength, false); - } - return allChunks; -}; - export const EuiHighlight: FunctionComponent = ({ children, className, @@ -177,15 +55,48 @@ export const EuiHighlight: FunctionComponent = ({ hasScreenReaderHelpText = true, ...rest }) => { + const hasSearch = search && search.length > 0; + + const HighlightComponent = useMemo(() => { + const Component: FunctionComponent<{ children: string }> = ({ + children, + }) => ( + + {children} + + ); + Component.displayName = '_HighlightComponent'; + return Component; + }, [hasScreenReaderHelpText]); + return ( - {highlight( - children, - search, - strict, - highlightAll, - hasScreenReaderHelpText + {children && hasSearch ? ( + highlightAll ? ( + + ) : ( + + ) + ) : ( + children )} ); }; + +export type _SharedSubcomponentProps = { + searchValue: EuiHighlightProps['search']; + searchSubject: EuiHighlightProps['children']; + isStrict: EuiHighlightProps['strict']; + highlightComponent?: ElementType; +};