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:
committed by
Facebook GitHub Bot
parent
fe1c52f2f7
commit
fd84820ee5
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
36
desktop/app/src/ui/components/data-inspector/Highlight.tsx
Normal file
36
desktop/app/src/ui/components/data-inspector/Highlight.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user