From fd84820ee5d81fc5b9d26d4b6fdcc9507951dc42 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Mon, 4 May 2020 04:14:29 -0700 Subject: [PATCH] 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 --- .../data-inspector/DataDescription.tsx | 25 ++++++- .../data-inspector/DataInspector.tsx | 30 +++++--- .../components/data-inspector/DataPreview.tsx | 2 +- .../components/data-inspector/Highlight.tsx | 36 +++++++++ .../data-inspector/ManagedDataInspector.tsx | 73 ++++++++++++++++++- .../__tests__/DataInspector.node.tsx | 59 ++++++++++++++- 6 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 desktop/app/src/ui/components/data-inspector/Highlight.tsx diff --git a/desktop/app/src/ui/components/data-inspector/DataDescription.tsx b/desktop/app/src/ui/components/data-inspector/DataDescription.tsx index 38ca2fd0e..6d9f579a0 100644 --- a/desktop/app/src/ui/components/data-inspector/DataDescription.tsx +++ b/desktop/app/src/ui/components/data-inspector/DataDescription.tsx @@ -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) => { this.props.commit({ @@ -609,7 +615,9 @@ class DataDescriptionContainer extends Component<{ if (val.startsWith('http://') || val.startsWith('https://')) { return ( <> - {val} + + + ); } else { - return "{String(val || '')}"; + return ( + + + + ); } case 'enum': - return {String(val)}; + return ( + + + + ); 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 358e1dc96..38e2c9245 100644 --- a/desktop/app/src/ui/components/data-inspector/DataInspector.tsx +++ b/desktop/app/src/ui/components/data-inspector/DataInspector.tsx @@ -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}> - {name} + + + , ); nameElems.push(: ); @@ -664,6 +675,7 @@ 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 0755915d4..67423b693 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( - {key} + Highlight{key} {ellipsis} , ); diff --git a/desktop/app/src/ui/components/data-inspector/Highlight.tsx b/desktop/app/src/ui/components/data-inspector/Highlight.tsx new file mode 100644 index 000000000..b0afb12aa --- /dev/null +++ b/desktop/app/src/ui/components/data-inspector/Highlight.tsx @@ -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 {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 958f280f4..19c372771 100644 --- a/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx +++ b/desktop/app/src/ui/components/data-inspector/ManagedDataInspector.tsx @@ -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 = {}; + 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} /> ); } 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 2e7510283..ca535b20d 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 @@ -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( + , + ); + await res.findByText(/awesomely/); // everything is shown + + res.rerender( + , + ); + 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(` + + "j + + son + + " + + `); + // hides the other part of the tree + await waitFor(() => { + expect(res.queryByText(/cool/)).toBeNull(); + }); + + // find by key + res.rerender( + , + ); + await res.findByText(/cool/); + // hides the other part of the tree + await waitFor(() => { + expect(res.queryByText(/json/)).toBeNull(); + }); + + res.rerender( + , + ); + // everything visible again + await res.findByText(/awesomely/); + await res.findByText(/json/); });