Add filter and highlight to JSON

Summary:
Large GraphQL responses feel pretty unwieldy, added a search option.

Added filter functionality to ManagedDataInspector, and use it in GraphQL

changelog: It is now possible to search inside GraphQL responses

making it slightly more efficient, and scrolling to the matches will be done in a next diff

Reviewed By: jknoxville

Differential Revision: D21347880

fbshipit-source-id: 85c95be0964515e737de2ab41bbdd8cc6a87544e
This commit is contained in:
Michel Weststrate
2020-05-04 04:14:29 -07:00
committed by Facebook GitHub Bot
parent fe1c52f2f7
commit fd84820ee5
6 changed files with 210 additions and 15 deletions

View File

@@ -18,6 +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';
const NullValue = styled.span({ const NullValue = styled.span({
color: 'rgb(128, 128, 128)', color: 'rgb(128, 128, 128)',
@@ -84,6 +85,7 @@ type DataDescriptionProps = {
value: any; value: any;
extra?: any; extra?: any;
setValue: DataInspectorSetValue | null | undefined; setValue: DataInspectorSetValue | null | undefined;
highlight?: string;
}; };
type DescriptionCommitOptions = { type DescriptionCommitOptions = {
@@ -278,6 +280,7 @@ 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}
/> />
); );
} }
@@ -446,6 +449,7 @@ 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;
@@ -463,6 +467,7 @@ 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}
/> />
); );
@@ -543,6 +548,7 @@ class DataDescriptionContainer extends Component<{
value: any; value: any;
editable: boolean; editable: boolean;
commit: (opts: DescriptionCommitOptions) => void; commit: (opts: DescriptionCommitOptions) => void;
highlight?: string;
}> { }> {
onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => { onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.commit({ this.props.commit({
@@ -609,7 +615,9 @@ class DataDescriptionContainer extends Component<{
if (val.startsWith('http://') || val.startsWith('https://')) { if (val.startsWith('http://') || val.startsWith('https://')) {
return ( return (
<> <>
<Link href={val}>{val}</Link> <Link href={val}>
<Highlight text={val} highlight={this.props.highlight} />
</Link>
<Glyph <Glyph
name="pencil" name="pencil"
variant="outline" variant="outline"
@@ -620,11 +628,22 @@ class DataDescriptionContainer extends Component<{
</> </>
); );
} else { } else {
return <StringValue>"{String(val || '')}"</StringValue>; return (
<StringValue>
<Highlight
text={`"${val || ''}"`}
highlight={this.props.highlight}
/>
</StringValue>
);
} }
case 'enum': case 'enum':
return <StringValue>{String(val)}</StringValue>; return (
<StringValue>
<Highlight text={val} highlight={this.props.highlight} />
</StringValue>
);
case 'boolean': case 'boolean':
return editable ? ( return editable ? (

View File

@@ -23,6 +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';
export {DataValueExtractor} from './DataPreview'; export {DataValueExtractor} from './DataPreview';
@@ -63,6 +64,14 @@ const ExpandControl = styled.span({
}); });
ExpandControl.displayName = 'DataInspector:ExpandControl'; ExpandControl.displayName = 'DataInspector:ExpandControl';
const Added = styled.div({
backgroundColor: colors.tealTint70,
});
const Removed = styled.div({
backgroundColor: colors.cherryTint70,
});
const nameTooltipOptions: TooltipOptions = { const nameTooltipOptions: TooltipOptions = {
position: 'toLeft', position: 'toLeft',
showTail: true, showTail: true,
@@ -145,6 +154,10 @@ 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) => {
@@ -377,7 +390,8 @@ 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
); );
} }
@@ -544,6 +558,7 @@ 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;
@@ -585,13 +600,6 @@ export default class DataInspector extends Component<
const keys = getSortedKeys({...value, ...diffValue}); const keys = getSortedKeys({...value, ...diffValue});
const Added = styled.div({
backgroundColor: colors.tealTint70,
});
const Removed = styled.div({
backgroundColor: colors.cherryTint70,
});
for (const key of keys) { for (const key of keys) {
const diffMetadataArr = diffMetadataExtractor(value, key, diffValue); const diffMetadataArr = diffMetadataExtractor(value, key, diffValue);
for (const metadata of diffMetadataArr) { for (const metadata of diffMetadataArr) {
@@ -611,6 +619,7 @@ export default class DataInspector extends Component<
data={metadata.data} data={metadata.data}
diff={metadata.diff} diff={metadata.diff}
tooltips={tooltips} tooltips={tooltips}
highlight={highlight}
/> />
); );
@@ -648,7 +657,9 @@ 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>{name}</InspectorName> <InspectorName>
<Highlight text={name} highlight={this.props.highlight} />
</InspectorName>
</Tooltip>, </Tooltip>,
); );
nameElems.push(<span key="sep">: </span>); nameElems.push(<span key="sep">: </span>);
@@ -664,6 +675,7 @@ 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>{key}</InspectorName> <InspectorName>Highlight{key}</InspectorName>
{ellipsis} {ellipsis}
</span>, </span>,
); );

View File

@@ -0,0 +1,36 @@
/**
* 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

@@ -48,12 +48,19 @@ type ManagedDataInspectorProps = {
* Object of all properties that will have tooltips * Object of all properties that will have tooltips
*/ */
tooltips?: Object; tooltips?: Object;
/**
* Filter nodes by some search text
*/
filter?: string;
}; };
type ManagedDataInspectorState = { type ManagedDataInspectorState = {
expanded: DataInspectorExpanded; expanded: DataInspectorExpanded;
filter: string;
}; };
const MAX_RESULTS = 50;
/** /**
* Wrapper around `DataInspector` that handles expanded state. * Wrapper around `DataInspector` that handles expanded state.
* *
@@ -68,6 +75,69 @@ export default class ManagedDataInspector extends PureComponent<
super(props, context); super(props, context);
this.state = { this.state = {
expanded: {}, expanded: {},
filter: '',
};
}
static getDerivedStateFromProps(
nextProps: ManagedDataInspectorProps,
currentState: ManagedDataInspectorState,
) {
if (nextProps.filter === currentState.filter) {
return null;
}
if (!nextProps.filter) {
return {
filter: '',
// reset expanded when removing filter
expanded: currentState.filter ? {} : currentState.expanded,
};
}
const filter = nextProps.filter!.toLowerCase();
const paths: (number | string)[][] = [];
function walk(value: any, path: (number | string)[]) {
if (paths.length > MAX_RESULTS) {
return;
}
if (!value) {
return;
}
if (typeof value !== 'object') {
if (('' + value).toLowerCase().includes(filter!)) {
paths.push(path.slice());
}
} else if (Array.isArray(value)) {
value.forEach((value, index) => {
path.push(index);
walk(value, path);
path.pop();
});
} else {
// a plain object
Object.keys(value).forEach((key) => {
path.push(key);
walk(key, path); // is the key interesting?
walk(value[key], path);
path.pop();
});
}
}
if (filter.length >= 2) {
walk(nextProps.data, []);
}
const expanded: Record<string, boolean> = {};
paths.forEach((path) => {
for (let i = 1; i < path.length; i++)
expanded[path.slice(0, i).join('.')] = true;
});
return {
expanded,
filter,
}; };
} }
@@ -86,8 +156,9 @@ export default class ManagedDataInspector extends PureComponent<
onExpanded={this.onExpanded} onExpanded={this.onExpanded}
onDelete={this.props.onDelete} onDelete={this.props.onDelete}
expandRoot={this.props.expandRoot} expandRoot={this.props.expandRoot}
collapsed={this.props.collapsed} collapsed={this.props.filter ? true : this.props.collapsed}
tooltips={this.props.tooltips} tooltips={this.props.tooltips}
highlight={this.props.filter}
/> />
); );
} }

View File

@@ -94,5 +94,62 @@ test('can manually collapse properties', async () => {
fireEvent.click(await res.findByText(/data/)); fireEvent.click(await res.findByText(/data/));
await res.findByText(/is/); await res.findByText(/is/);
await res.findByText(/awesomely/); await res.findByText(/awesomely/);
expect((await res.queryAllByText(/json/)).length).toBe(0); await waitFor(() => {
expect(res.queryByText(/json/)).toBeNull();
});
});
test('can filter for data', async () => {
const res = render(
<ManagedDataInspector data={json} collapsed={false} expandRoot />,
);
await res.findByText(/awesomely/); // everything is shown
res.rerender(
<ManagedDataInspector
data={json}
collapsed={false}
expandRoot
filter="sOn"
/>,
);
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(`
<span>
"j
<span
class="css-1tdfls1"
>
son
</span>
"
</span>
`);
// hides the other part of the tree
await waitFor(() => {
expect(res.queryByText(/cool/)).toBeNull();
});
// find by key
res.rerender(
<ManagedDataInspector
data={json}
collapsed={false}
expandRoot
filter="somel"
/>,
);
await res.findByText(/cool/);
// hides the other part of the tree
await waitFor(() => {
expect(res.queryByText(/json/)).toBeNull();
});
res.rerender(
<ManagedDataInspector data={json} collapsed={false} expandRoot filter="" />,
);
// everything visible again
await res.findByText(/awesomely/);
await res.findByText(/json/);
}); });