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 React, {KeyboardEvent} from 'react';
|
||||
import Glyph from '../Glyph';
|
||||
import {Highlight} from './Highlight';
|
||||
|
||||
const NullValue = styled.span({
|
||||
color: 'rgb(128, 128, 128)',
|
||||
@@ -84,6 +85,7 @@ type DataDescriptionProps = {
|
||||
value: any;
|
||||
extra?: any;
|
||||
setValue: DataInspectorSetValue | null | undefined;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
type DescriptionCommitOptions = {
|
||||
@@ -278,6 +280,7 @@ export default class DataDescription extends PureComponent<
|
||||
editable={Boolean(this.props.setValue)}
|
||||
commit={this.commit}
|
||||
onEdit={this.onEditStart}
|
||||
highlight={this.props.highlight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -446,6 +449,7 @@ class DataDescriptionPreview extends Component<{
|
||||
editable: boolean;
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
onEdit?: () => void;
|
||||
highlight?: string;
|
||||
}> {
|
||||
onClick = () => {
|
||||
const {onEdit} = this.props;
|
||||
@@ -463,6 +467,7 @@ class DataDescriptionPreview extends Component<{
|
||||
value={value}
|
||||
editable={this.props.editable}
|
||||
commit={this.props.commit}
|
||||
highlight={this.props.highlight}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -543,6 +548,7 @@ class DataDescriptionContainer extends Component<{
|
||||
value: any;
|
||||
editable: boolean;
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
highlight?: string;
|
||||
}> {
|
||||
onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.commit({
|
||||
@@ -609,7 +615,9 @@ class DataDescriptionContainer extends Component<{
|
||||
if (val.startsWith('http://') || val.startsWith('https://')) {
|
||||
return (
|
||||
<>
|
||||
<Link href={val}>{val}</Link>
|
||||
<Link href={val}>
|
||||
<Highlight text={val} highlight={this.props.highlight} />
|
||||
</Link>
|
||||
<Glyph
|
||||
name="pencil"
|
||||
variant="outline"
|
||||
@@ -620,11 +628,22 @@ class DataDescriptionContainer extends Component<{
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <StringValue>"{String(val || '')}"</StringValue>;
|
||||
return (
|
||||
<StringValue>
|
||||
<Highlight
|
||||
text={`"${val || ''}"`}
|
||||
highlight={this.props.highlight}
|
||||
/>
|
||||
</StringValue>
|
||||
);
|
||||
}
|
||||
|
||||
case 'enum':
|
||||
return <StringValue>{String(val)}</StringValue>;
|
||||
return (
|
||||
<StringValue>
|
||||
<Highlight text={val} highlight={this.props.highlight} />
|
||||
</StringValue>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return editable ? (
|
||||
|
||||
@@ -23,6 +23,7 @@ import deepEqual from 'deep-equal';
|
||||
import React from 'react';
|
||||
import {TooltipOptions} from '../TooltipProvider';
|
||||
import {shallowEqual} from 'react-redux';
|
||||
import {Highlight} from './Highlight';
|
||||
|
||||
export {DataValueExtractor} from './DataPreview';
|
||||
|
||||
@@ -63,6 +64,14 @@ const ExpandControl = styled.span({
|
||||
});
|
||||
ExpandControl.displayName = 'DataInspector:ExpandControl';
|
||||
|
||||
const Added = styled.div({
|
||||
backgroundColor: colors.tealTint70,
|
||||
});
|
||||
|
||||
const Removed = styled.div({
|
||||
backgroundColor: colors.cherryTint70,
|
||||
});
|
||||
|
||||
const nameTooltipOptions: TooltipOptions = {
|
||||
position: 'toLeft',
|
||||
showTail: true,
|
||||
@@ -145,6 +154,10 @@ 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) => {
|
||||
@@ -377,7 +390,8 @@ export default class DataInspector extends Component<
|
||||
nextProps.onDelete !== props.onDelete ||
|
||||
nextProps.setValue !== props.setValue ||
|
||||
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,
|
||||
collapsed,
|
||||
tooltips,
|
||||
highlight,
|
||||
} = this.props;
|
||||
|
||||
const {resDiff, isExpandable, isExpanded, res} = this.state;
|
||||
@@ -585,13 +600,6 @@ export default class DataInspector extends Component<
|
||||
|
||||
const keys = getSortedKeys({...value, ...diffValue});
|
||||
|
||||
const Added = styled.div({
|
||||
backgroundColor: colors.tealTint70,
|
||||
});
|
||||
const Removed = styled.div({
|
||||
backgroundColor: colors.cherryTint70,
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
const diffMetadataArr = diffMetadataExtractor(value, key, diffValue);
|
||||
for (const metadata of diffMetadataArr) {
|
||||
@@ -611,6 +619,7 @@ export default class DataInspector extends Component<
|
||||
data={metadata.data}
|
||||
diff={metadata.diff}
|
||||
tooltips={tooltips}
|
||||
highlight={highlight}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -648,7 +657,9 @@ export default class DataInspector extends Component<
|
||||
title={tooltips != null && tooltips[name]}
|
||||
key="name"
|
||||
options={nameTooltipOptions}>
|
||||
<InspectorName>{name}</InspectorName>
|
||||
<InspectorName>
|
||||
<Highlight text={name} highlight={this.props.highlight} />
|
||||
</InspectorName>
|
||||
</Tooltip>,
|
||||
);
|
||||
nameElems.push(<span key="sep">: </span>);
|
||||
@@ -664,6 +675,7 @@ export default class DataInspector extends Component<
|
||||
type={type}
|
||||
value={value}
|
||||
extra={extra}
|
||||
highlight={highlight}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -106,7 +106,7 @@ export default class DataPreview extends PureComponent<{
|
||||
|
||||
propertyNodes.push(
|
||||
<span key={key}>
|
||||
<InspectorName>{key}</InspectorName>
|
||||
<InspectorName>Highlight{key}</InspectorName>
|
||||
{ellipsis}
|
||||
</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
|
||||
*/
|
||||
tooltips?: Object;
|
||||
/**
|
||||
* Filter nodes by some search text
|
||||
*/
|
||||
filter?: string;
|
||||
};
|
||||
|
||||
type ManagedDataInspectorState = {
|
||||
expanded: DataInspectorExpanded;
|
||||
filter: string;
|
||||
};
|
||||
|
||||
const MAX_RESULTS = 50;
|
||||
|
||||
/**
|
||||
* Wrapper around `DataInspector` that handles expanded state.
|
||||
*
|
||||
@@ -68,6 +75,69 @@ export default class ManagedDataInspector extends PureComponent<
|
||||
super(props, context);
|
||||
this.state = {
|
||||
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}
|
||||
onDelete={this.props.onDelete}
|
||||
expandRoot={this.props.expandRoot}
|
||||
collapsed={this.props.collapsed}
|
||||
collapsed={this.props.filter ? true : this.props.collapsed}
|
||||
tooltips={this.props.tooltips}
|
||||
highlight={this.props.filter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,5 +94,62 @@ test('can manually collapse properties', async () => {
|
||||
fireEvent.click(await res.findByText(/data/));
|
||||
await res.findByText(/is/);
|
||||
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