diff --git a/desktop/app/src/ui/components/Highlight.tsx b/desktop/app/src/ui/components/Highlight.tsx new file mode 100644 index 000000000..77ae003c0 --- /dev/null +++ b/desktop/app/src/ui/components/Highlight.tsx @@ -0,0 +1,121 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import styled from '@emotion/styled'; +import {colors} from './colors'; +import React, { + useEffect, + memo, + useState, + useRef, + useMemo, + createContext, + useContext, +} from 'react'; +import {debounce} from 'lodash'; + +const Highlighted = styled.span({ + backgroundColor: colors.lemon, +}); + +export interface HighlightManager { + setFilter(text: string | undefined): void; + render(text: string): React.ReactNode; +} + +function createHighlightManager(initialText: string = ''): HighlightManager { + const callbacks = new Set<(prev: string, next: string) => void>(); + let matches = 0; + let currentFilter = initialText; + + const Highlight: React.FC<{text: string}> = memo(({text}) => { + const [_update, setUpdate] = useState(0); + const elem = useRef(null); + useEffect(() => { + function onChange(prevHighlight: string, newHighlight: string) { + const prevIndex = text.toLowerCase().indexOf(prevHighlight); + const newIndex = text.toLowerCase().indexOf(newHighlight); + if (prevIndex !== newIndex || newIndex !== -1) { + // either we had a result, and we have no longer, + // or we still have a result, but the highlightable text changed + if (newIndex !== -1) { + if (++matches === 1) { + elem.current?.parentElement?.parentElement?.scrollIntoView?.(); + } + } + setUpdate((s) => s + 1); + } + } + callbacks.add(onChange); + return () => { + callbacks.delete(onChange); + }; + }, [text]); + + const index = text.toLowerCase().indexOf(currentFilter); + if (index === -1) { + return {text}; + } + return ( + + {text.substr(0, index)} + {text.substr(index, currentFilter.length)} + {text.substr(index + currentFilter.length)} + + ); + }); + + return { + setFilter: debounce((text: string = '') => { + if (currentFilter !== text) { + matches = 0; + const base = currentFilter; + currentFilter = text.toLowerCase(); + callbacks.forEach((cb) => cb(base, currentFilter)); + } + }, 100), + render(text: string) { + return ; + }, + }; +} + +export const HighlightContext = createContext({ + setFilter(_text: string) { + throw new Error('Cannot set the filter of a stub highlight manager'); + }, + render(text: string) { + // stub implementation in case we render a component without a Highlight context + return text; + }, +}); + +export function HighlightProvider({ + text, + children, +}: { + text: string | undefined; + children: React.ReactElement; +}) { + const highlightManager = useMemo(() => createHighlightManager(text), []); + + useEffect(() => { + highlightManager.setFilter(text); + }, [text]); + + return ( + + {children} + + ); +} + +export function useHighlighter(): HighlightManager { + return useContext(HighlightContext); +} diff --git a/desktop/app/src/ui/components/data-inspector/DataDescription.tsx b/desktop/app/src/ui/components/data-inspector/DataDescription.tsx index 6d9f579a0..fe89c15dc 100644 --- a/desktop/app/src/ui/components/data-inspector/DataDescription.tsx +++ b/desktop/app/src/ui/components/data-inspector/DataDescription.tsx @@ -18,7 +18,7 @@ import {colors} from '../colors'; import Input from '../Input'; import React, {KeyboardEvent} from 'react'; import Glyph from '../Glyph'; -import {Highlight} from './Highlight'; +import {HighlightContext} from '../Highlight'; const NullValue = styled.span({ color: 'rgb(128, 128, 128)', @@ -85,7 +85,6 @@ type DataDescriptionProps = { value: any; extra?: any; setValue: DataInspectorSetValue | null | undefined; - highlight?: string; }; type DescriptionCommitOptions = { @@ -280,14 +279,13 @@ export default class DataDescription extends PureComponent< editable={Boolean(this.props.setValue)} commit={this.commit} onEdit={this.onEditStart} - highlight={this.props.highlight} /> ); } } } -class ColorEditor extends Component<{ +class ColorEditor extends PureComponent<{ value: any; colorSet?: Array; commit: (opts: DescriptionCommitOptions) => void; @@ -449,7 +447,6 @@ class DataDescriptionPreview extends Component<{ editable: boolean; commit: (opts: DescriptionCommitOptions) => void; onEdit?: () => void; - highlight?: string; }> { onClick = () => { const {onEdit} = this.props; @@ -467,7 +464,6 @@ class DataDescriptionPreview extends Component<{ value={value} editable={this.props.editable} commit={this.props.commit} - highlight={this.props.highlight} /> ); @@ -548,8 +544,10 @@ class DataDescriptionContainer extends Component<{ value: any; editable: boolean; commit: (opts: DescriptionCommitOptions) => void; - highlight?: string; }> { + static contextType = HighlightContext; // Replace with useHighlighter + context!: React.ContextType; + onChangeCheckbox = (e: React.ChangeEvent) => { this.props.commit({ clear: true, @@ -561,6 +559,7 @@ class DataDescriptionContainer extends Component<{ render(): any { const {type, editable, value: val} = this.props; + const highlighter = this.context; switch (type) { case 'number': @@ -615,9 +614,7 @@ class DataDescriptionContainer extends Component<{ if (val.startsWith('http://') || val.startsWith('https://')) { return ( <> - - - + {highlighter.render(val)} - - + {highlighter.render(`"${val || ''}"`)} ); } case 'enum': - return ( - - - - ); + return {highlighter.render(val)}; case 'boolean': return editable ? ( diff --git a/desktop/app/src/ui/components/data-inspector/DataInspector.tsx b/desktop/app/src/ui/components/data-inspector/DataInspector.tsx index 38e2c9245..5af227db2 100644 --- a/desktop/app/src/ui/components/data-inspector/DataInspector.tsx +++ b/desktop/app/src/ui/components/data-inspector/DataInspector.tsx @@ -23,7 +23,7 @@ import deepEqual from 'deep-equal'; import React from 'react'; import {TooltipOptions} from '../TooltipProvider'; import {shallowEqual} from 'react-redux'; -import {Highlight} from './Highlight'; +import {HighlightContext} from '../Highlight'; export {DataValueExtractor} from './DataPreview'; @@ -154,10 +154,6 @@ type DataInspectorProps = { * Object of properties that will have tooltips */ tooltips?: any; - /** - * Text to highlight, in case searching is used - */ - highlight?: string; }; const defaultValueExtractor: DataValueExtractor = (value: any) => { @@ -321,6 +317,9 @@ export default class DataInspector extends Component< DataInspectorProps, DataInspectorState > { + static contextType = HighlightContext; // Replace with useHighlighter + context!: React.ContextType; + static defaultProps: { expanded: DataInspectorExpanded; depth: number; @@ -390,8 +389,7 @@ export default class DataInspector extends Component< nextProps.onDelete !== props.onDelete || nextProps.setValue !== props.setValue || nextProps.collapsed !== props.collapsed || - nextProps.expandRoot !== props.expandRoot || - nextProps.highlight !== props.highlight + nextProps.expandRoot !== props.expandRoot ); } @@ -558,10 +556,10 @@ export default class DataInspector extends Component< ancestry, collapsed, tooltips, - highlight, } = this.props; const {resDiff, isExpandable, isExpanded, res} = this.state; + const highlighter = this.context; // useHighlighter(); if (!res) { return null; @@ -619,7 +617,6 @@ export default class DataInspector extends Component< data={metadata.data} diff={metadata.diff} tooltips={tooltips} - highlight={highlight} /> ); @@ -657,9 +654,7 @@ export default class DataInspector extends Component< title={tooltips != null && tooltips[name]} key="name" options={nameTooltipOptions}> - - - + {highlighter.render(name)} , ); nameElems.push(: ); @@ -675,7 +670,6 @@ export default class DataInspector extends Component< type={type} value={value} extra={extra} - highlight={highlight} /> ); } else { diff --git a/desktop/app/src/ui/components/data-inspector/DataPreview.tsx b/desktop/app/src/ui/components/data-inspector/DataPreview.tsx index 67423b693..0755915d4 100755 --- a/desktop/app/src/ui/components/data-inspector/DataPreview.tsx +++ b/desktop/app/src/ui/components/data-inspector/DataPreview.tsx @@ -106,7 +106,7 @@ export default class DataPreview extends PureComponent<{ propertyNodes.push( - Highlight{key} + {key} {ellipsis} , ); diff --git a/desktop/app/src/ui/components/data-inspector/Highlight.tsx b/desktop/app/src/ui/components/data-inspector/Highlight.tsx deleted file mode 100644 index b0afb12aa..000000000 --- a/desktop/app/src/ui/components/data-inspector/Highlight.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - */ - -import styled from '@emotion/styled'; -import {colors} from '../colors'; -import React from 'react'; - -const Highlighted = styled.span({ - backgroundColor: colors.lemon, -}); - -export const Highlight: React.FC<{text: string; highlight?: string}> = ({ - text, - highlight, -}) => { - if (!highlight) { - return {text}; - } - const index = text.toLowerCase().indexOf(highlight.toLowerCase()); - if (index === -1) { - return {text}; - } - return ( - - {text.substr(0, index)} - {text.substr(index, highlight.length)} - {text.substr(index + highlight.length)} - - ); -}; diff --git a/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx b/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx index 19c372771..5a1531849 100644 --- a/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx +++ b/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx @@ -12,6 +12,7 @@ import {PureComponent} from 'react'; import DataInspector from './DataInspector'; import React from 'react'; import {DataValueExtractor} from './DataPreview'; +import {HighlightProvider} from '../Highlight'; type ManagedDataInspectorProps = { /** @@ -147,19 +148,20 @@ export default class ManagedDataInspector extends PureComponent< render() { return ( - + + + ); } } diff --git a/desktop/app/src/ui/components/data-inspector/__tests__/DataInspector.node.tsx b/desktop/app/src/ui/components/data-inspector/__tests__/DataInspector.node.tsx index ca535b20d..e7b472b8f 100644 --- a/desktop/app/src/ui/components/data-inspector/__tests__/DataInspector.node.tsx +++ b/desktop/app/src/ui/components/data-inspector/__tests__/DataInspector.node.tsx @@ -8,10 +8,11 @@ */ import * as React from 'react'; -import {render, fireEvent, waitFor} from '@testing-library/react'; +import {render, fireEvent, waitFor, act} from '@testing-library/react'; jest.mock('../../../../fb/Logger'); import ManagedDataInspector from '../ManagedDataInspector'; +import {sleep} from '../../../../utils'; const mocks = { requestIdleCallback(fn: Function) { @@ -105,14 +106,19 @@ test('can filter for data', async () => { ); await res.findByText(/awesomely/); // everything is shown - res.rerender( - , - ); + // act here is used to make sure the highlight changes have propagated + await act(async () => { + res.rerender( + , + ); + await sleep(200); + }); + const element = await res.findByText(/son/); // N.B. search for 'son', as the text was split up // snapshot to make sure the hilighiting did it's job expect(element.parentElement).toMatchInlineSnapshot(` @@ -132,23 +138,36 @@ test('can filter for data', async () => { }); // find by key - res.rerender( - , - ); + await act(async () => { + res.rerender( + , + ); + await sleep(200); + }); + await res.findByText(/cool/); // hides the other part of the tree await waitFor(() => { expect(res.queryByText(/json/)).toBeNull(); }); - res.rerender( - , - ); + await act(async () => { + res.rerender( + , + ); + await sleep(200); + }); + // everything visible again await res.findByText(/awesomely/); await res.findByText(/json/); diff --git a/desktop/app/src/ui/components/elements-inspector/elements.tsx b/desktop/app/src/ui/components/elements-inspector/elements.tsx index 0c9a3f1dd..3f40ec576 100644 --- a/desktop/app/src/ui/components/elements-inspector/elements.tsx +++ b/desktop/app/src/ui/components/elements-inspector/elements.tsx @@ -131,6 +131,7 @@ const ElementsRowAttributeValue = styled.span({ }); ElementsRowAttributeValue.displayName = 'Elements:ElementsRowAttributeValue'; +// Merge this functionality with components/Highlight class PartialHighlight extends PureComponent<{ selected: boolean; highlighted: string | undefined | null; diff --git a/desktop/app/src/ui/index.tsx b/desktop/app/src/ui/index.tsx index b559f5751..3c458d499 100644 --- a/desktop/app/src/ui/index.tsx +++ b/desktop/app/src/ui/index.tsx @@ -175,3 +175,4 @@ export {default as Info} from './components/Info'; export {default as Bordered} from './components/Bordered'; export {default as AlternatingRows} from './components/AlternatingRows'; export {default as Layout} from './components/Layout'; +export * from './components/Highlight';