Introduce highlight manager, add scroll on highlight

Summary:
Introduced a highlight manager, which prevents drilling the highlight through the entire component tree and causing too many re-renders.

Also smartly optimizes that non-matched highlighted text doesn't render unnecessarily, and debounces the updates.

Finally, automatically scroll to the first highlight.

Reviewed By: jknoxville

Differential Revision: D21348575

fbshipit-source-id: 71f7ba2e981ad3fc1ea7f5e7043645e6b6811fb7
This commit is contained in:
Michel Weststrate
2020-05-04 04:14:29 -07:00
committed by Facebook GitHub Bot
parent fd84820ee5
commit fdff6aeae0
9 changed files with 194 additions and 104 deletions

View File

@@ -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<HTMLSpanElement | null>(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 <span>{text}</span>;
}
return (
<span ref={elem}>
{text.substr(0, index)}
<Highlighted>{text.substr(index, currentFilter.length)}</Highlighted>
{text.substr(index + currentFilter.length)}
</span>
);
});
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 <Highlight text={text} />;
},
};
}
export const HighlightContext = createContext<HighlightManager>({
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 (
<HighlightContext.Provider value={highlightManager}>
{children}
</HighlightContext.Provider>
);
}
export function useHighlighter(): HighlightManager {
return useContext(HighlightContext);
}

View File

@@ -18,7 +18,7 @@ import {colors} from '../colors';
import Input from '../Input'; import Input from '../Input';
import React, {KeyboardEvent} from 'react'; import React, {KeyboardEvent} from 'react';
import Glyph from '../Glyph'; import Glyph from '../Glyph';
import {Highlight} from './Highlight'; import {HighlightContext} from '../Highlight';
const NullValue = styled.span({ const NullValue = styled.span({
color: 'rgb(128, 128, 128)', color: 'rgb(128, 128, 128)',
@@ -85,7 +85,6 @@ type DataDescriptionProps = {
value: any; value: any;
extra?: any; extra?: any;
setValue: DataInspectorSetValue | null | undefined; setValue: DataInspectorSetValue | null | undefined;
highlight?: string;
}; };
type DescriptionCommitOptions = { type DescriptionCommitOptions = {
@@ -280,14 +279,13 @@ export default class DataDescription extends PureComponent<
editable={Boolean(this.props.setValue)} editable={Boolean(this.props.setValue)}
commit={this.commit} commit={this.commit}
onEdit={this.onEditStart} onEdit={this.onEditStart}
highlight={this.props.highlight}
/> />
); );
} }
} }
} }
class ColorEditor extends Component<{ class ColorEditor extends PureComponent<{
value: any; value: any;
colorSet?: Array<string | number>; colorSet?: Array<string | number>;
commit: (opts: DescriptionCommitOptions) => void; commit: (opts: DescriptionCommitOptions) => void;
@@ -449,7 +447,6 @@ class DataDescriptionPreview extends Component<{
editable: boolean; editable: boolean;
commit: (opts: DescriptionCommitOptions) => void; commit: (opts: DescriptionCommitOptions) => void;
onEdit?: () => void; onEdit?: () => void;
highlight?: string;
}> { }> {
onClick = () => { onClick = () => {
const {onEdit} = this.props; const {onEdit} = this.props;
@@ -467,7 +464,6 @@ class DataDescriptionPreview extends Component<{
value={value} value={value}
editable={this.props.editable} editable={this.props.editable}
commit={this.props.commit} commit={this.props.commit}
highlight={this.props.highlight}
/> />
); );
@@ -548,8 +544,10 @@ class DataDescriptionContainer extends Component<{
value: any; value: any;
editable: boolean; editable: boolean;
commit: (opts: DescriptionCommitOptions) => void; commit: (opts: DescriptionCommitOptions) => void;
highlight?: string;
}> { }> {
static contextType = HighlightContext; // Replace with useHighlighter
context!: React.ContextType<typeof HighlightContext>;
onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => { onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.commit({ this.props.commit({
clear: true, clear: true,
@@ -561,6 +559,7 @@ class DataDescriptionContainer extends Component<{
render(): any { render(): any {
const {type, editable, value: val} = this.props; const {type, editable, value: val} = this.props;
const highlighter = this.context;
switch (type) { switch (type) {
case 'number': case 'number':
@@ -615,9 +614,7 @@ class DataDescriptionContainer extends Component<{
if (val.startsWith('http://') || val.startsWith('https://')) { if (val.startsWith('http://') || val.startsWith('https://')) {
return ( return (
<> <>
<Link href={val}> <Link href={val}>{highlighter.render(val)}</Link>
<Highlight text={val} highlight={this.props.highlight} />
</Link>
<Glyph <Glyph
name="pencil" name="pencil"
variant="outline" variant="outline"
@@ -629,21 +626,12 @@ class DataDescriptionContainer extends Component<{
); );
} else { } else {
return ( return (
<StringValue> <StringValue>{highlighter.render(`"${val || ''}"`)}</StringValue>
<Highlight
text={`"${val || ''}"`}
highlight={this.props.highlight}
/>
</StringValue>
); );
} }
case 'enum': case 'enum':
return ( return <StringValue>{highlighter.render(val)}</StringValue>;
<StringValue>
<Highlight text={val} highlight={this.props.highlight} />
</StringValue>
);
case 'boolean': case 'boolean':
return editable ? ( return editable ? (

View File

@@ -23,7 +23,7 @@ import deepEqual from 'deep-equal';
import React from 'react'; import React from 'react';
import {TooltipOptions} from '../TooltipProvider'; import {TooltipOptions} from '../TooltipProvider';
import {shallowEqual} from 'react-redux'; import {shallowEqual} from 'react-redux';
import {Highlight} from './Highlight'; import {HighlightContext} from '../Highlight';
export {DataValueExtractor} from './DataPreview'; export {DataValueExtractor} from './DataPreview';
@@ -154,10 +154,6 @@ type DataInspectorProps = {
* Object of properties that will have tooltips * Object of properties that will have tooltips
*/ */
tooltips?: any; tooltips?: any;
/**
* Text to highlight, in case searching is used
*/
highlight?: string;
}; };
const defaultValueExtractor: DataValueExtractor = (value: any) => { const defaultValueExtractor: DataValueExtractor = (value: any) => {
@@ -321,6 +317,9 @@ export default class DataInspector extends Component<
DataInspectorProps, DataInspectorProps,
DataInspectorState DataInspectorState
> { > {
static contextType = HighlightContext; // Replace with useHighlighter
context!: React.ContextType<typeof HighlightContext>;
static defaultProps: { static defaultProps: {
expanded: DataInspectorExpanded; expanded: DataInspectorExpanded;
depth: number; depth: number;
@@ -390,8 +389,7 @@ export default class DataInspector extends Component<
nextProps.onDelete !== props.onDelete || nextProps.onDelete !== props.onDelete ||
nextProps.setValue !== props.setValue || nextProps.setValue !== props.setValue ||
nextProps.collapsed !== props.collapsed || nextProps.collapsed !== props.collapsed ||
nextProps.expandRoot !== props.expandRoot || nextProps.expandRoot !== props.expandRoot
nextProps.highlight !== props.highlight
); );
} }
@@ -558,10 +556,10 @@ export default class DataInspector extends Component<
ancestry, ancestry,
collapsed, collapsed,
tooltips, tooltips,
highlight,
} = this.props; } = this.props;
const {resDiff, isExpandable, isExpanded, res} = this.state; const {resDiff, isExpandable, isExpanded, res} = this.state;
const highlighter = this.context; // useHighlighter();
if (!res) { if (!res) {
return null; return null;
@@ -619,7 +617,6 @@ export default class DataInspector extends Component<
data={metadata.data} data={metadata.data}
diff={metadata.diff} diff={metadata.diff}
tooltips={tooltips} tooltips={tooltips}
highlight={highlight}
/> />
); );
@@ -657,9 +654,7 @@ export default class DataInspector extends Component<
title={tooltips != null && tooltips[name]} title={tooltips != null && tooltips[name]}
key="name" key="name"
options={nameTooltipOptions}> options={nameTooltipOptions}>
<InspectorName> <InspectorName>{highlighter.render(name)}</InspectorName>
<Highlight text={name} highlight={this.props.highlight} />
</InspectorName>
</Tooltip>, </Tooltip>,
); );
nameElems.push(<span key="sep">: </span>); nameElems.push(<span key="sep">: </span>);
@@ -675,7 +670,6 @@ export default class DataInspector extends Component<
type={type} type={type}
value={value} value={value}
extra={extra} extra={extra}
highlight={highlight}
/> />
); );
} else { } else {

View File

@@ -106,7 +106,7 @@ export default class DataPreview extends PureComponent<{
propertyNodes.push( propertyNodes.push(
<span key={key}> <span key={key}>
<InspectorName>Highlight{key}</InspectorName> <InspectorName>{key}</InspectorName>
{ellipsis} {ellipsis}
</span>, </span>,
); );

View File

@@ -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 <span>{text}</span>;
}
const index = text.toLowerCase().indexOf(highlight.toLowerCase());
if (index === -1) {
return <span>{text}</span>;
}
return (
<span>
{text.substr(0, index)}
<Highlighted>{text.substr(index, highlight.length)}</Highlighted>
{text.substr(index + highlight.length)}
</span>
);
};

View File

@@ -12,6 +12,7 @@ import {PureComponent} from 'react';
import DataInspector from './DataInspector'; import DataInspector from './DataInspector';
import React from 'react'; import React from 'react';
import {DataValueExtractor} from './DataPreview'; import {DataValueExtractor} from './DataPreview';
import {HighlightProvider} from '../Highlight';
type ManagedDataInspectorProps = { type ManagedDataInspectorProps = {
/** /**
@@ -147,19 +148,20 @@ export default class ManagedDataInspector extends PureComponent<
render() { render() {
return ( return (
<DataInspector <HighlightProvider text={this.props.filter}>
data={this.props.data} <DataInspector
diff={this.props.diff} data={this.props.data}
extractValue={this.props.extractValue} diff={this.props.diff}
setValue={this.props.setValue} extractValue={this.props.extractValue}
expanded={this.state.expanded} setValue={this.props.setValue}
onExpanded={this.onExpanded} expanded={this.state.expanded}
onDelete={this.props.onDelete} onExpanded={this.onExpanded}
expandRoot={this.props.expandRoot} onDelete={this.props.onDelete}
collapsed={this.props.filter ? true : this.props.collapsed} expandRoot={this.props.expandRoot}
tooltips={this.props.tooltips} collapsed={this.props.filter ? true : this.props.collapsed}
highlight={this.props.filter} tooltips={this.props.tooltips}
/> />
</HighlightProvider>
); );
} }
} }

View File

@@ -8,10 +8,11 @@
*/ */
import * as React from 'react'; 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'); jest.mock('../../../../fb/Logger');
import ManagedDataInspector from '../ManagedDataInspector'; import ManagedDataInspector from '../ManagedDataInspector';
import {sleep} from '../../../../utils';
const mocks = { const mocks = {
requestIdleCallback(fn: Function) { requestIdleCallback(fn: Function) {
@@ -105,14 +106,19 @@ test('can filter for data', async () => {
); );
await res.findByText(/awesomely/); // everything is shown await res.findByText(/awesomely/); // everything is shown
res.rerender( // act here is used to make sure the highlight changes have propagated
<ManagedDataInspector await act(async () => {
data={json} res.rerender(
collapsed={false} <ManagedDataInspector
expandRoot data={json}
filter="sOn" collapsed={false}
/>, expandRoot
); filter="sOn"
/>,
);
await sleep(200);
});
const element = await res.findByText(/son/); // N.B. search for 'son', as the text was split up 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 // snapshot to make sure the hilighiting did it's job
expect(element.parentElement).toMatchInlineSnapshot(` expect(element.parentElement).toMatchInlineSnapshot(`
@@ -132,23 +138,36 @@ test('can filter for data', async () => {
}); });
// find by key // find by key
res.rerender( await act(async () => {
<ManagedDataInspector res.rerender(
data={json} <ManagedDataInspector
collapsed={false} data={json}
expandRoot collapsed={false}
filter="somel" expandRoot
/>, filter="somel"
); />,
);
await sleep(200);
});
await res.findByText(/cool/); await res.findByText(/cool/);
// hides the other part of the tree // hides the other part of the tree
await waitFor(() => { await waitFor(() => {
expect(res.queryByText(/json/)).toBeNull(); expect(res.queryByText(/json/)).toBeNull();
}); });
res.rerender( await act(async () => {
<ManagedDataInspector data={json} collapsed={false} expandRoot filter="" />, res.rerender(
); <ManagedDataInspector
data={json}
collapsed={false}
expandRoot
filter=""
/>,
);
await sleep(200);
});
// everything visible again // everything visible again
await res.findByText(/awesomely/); await res.findByText(/awesomely/);
await res.findByText(/json/); await res.findByText(/json/);

View File

@@ -131,6 +131,7 @@ const ElementsRowAttributeValue = styled.span({
}); });
ElementsRowAttributeValue.displayName = 'Elements:ElementsRowAttributeValue'; ElementsRowAttributeValue.displayName = 'Elements:ElementsRowAttributeValue';
// Merge this functionality with components/Highlight
class PartialHighlight extends PureComponent<{ class PartialHighlight extends PureComponent<{
selected: boolean; selected: boolean;
highlighted: string | undefined | null; highlighted: string | undefined | null;

View File

@@ -175,3 +175,4 @@ export {default as Info} from './components/Info';
export {default as Bordered} from './components/Bordered'; export {default as Bordered} from './components/Bordered';
export {default as AlternatingRows} from './components/AlternatingRows'; export {default as AlternatingRows} from './components/AlternatingRows';
export {default as Layout} from './components/Layout'; export {default as Layout} from './components/Layout';
export * from './components/Highlight';