diff --git a/package.json b/package.json index fbdc0004bb8..398d076c7f4 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,8 @@ }, "dependencies": { "@neo4j/browser-lambda-parser": "1.0.4", - "@relate-by-ui/css": "^1.0.4", + "@relate-by-ui/css": "1.0.5", + "@relate-by-ui/relatable": "1.0.1", "@relate-by-ui/saved-scripts": "^1.0.4", "ascii-data-table": "^2.1.1", "canvg": "^1.5.3", diff --git a/src/browser/modules/D3Visualization/components/Explorer.jsx b/src/browser/modules/D3Visualization/components/Explorer.jsx index c6238908316..30cf747f929 100644 --- a/src/browser/modules/D3Visualization/components/Explorer.jsx +++ b/src/browser/modules/D3Visualization/components/Explorer.jsx @@ -26,6 +26,8 @@ import neoGraphStyle from '../graphStyle' import { InspectorComponent } from './Inspector' import { LegendComponent } from './Legend' import { StyledFullSizeContainer } from './styled' +import { getMaxFieldItems } from 'shared/modules/settings/settingsDuck' +import { connect } from 'react-redux' const deduplicateNodes = nodes => { return nodes.reduce( @@ -231,6 +233,7 @@ export class ExplorerComponent extends Component { setGraph={this.props.setGraph} /> ({ + maxFieldItems: getMaxFieldItems(state) +}))(ExplorerComponent) diff --git a/src/browser/modules/D3Visualization/components/Inspector.jsx b/src/browser/modules/D3Visualization/components/Inspector.jsx index 02dcdaa151d..470c7f34910 100644 --- a/src/browser/modules/D3Visualization/components/Inspector.jsx +++ b/src/browser/modules/D3Visualization/components/Inspector.jsx @@ -40,6 +40,9 @@ import { GrassEditor } from './GrassEditor' import { RowExpandToggleComponent } from './RowExpandToggle' import ClickableUrls from '../../../components/clickable-urls' import numberToUSLocale from 'shared/utils/number-to-US-locale' +import { StyledTruncatedMessage } from 'browser/modules/Stream/styled' +import { Icon } from 'semantic-ui-react' +import Ellipsis from 'browser-components/Ellipsis' const mapItemProperties = itemProperties => itemProperties @@ -149,6 +152,12 @@ export class InspectorComponent extends Component { + {this.props.hasTruncatedFields && ( + + Record fields have been + truncated.  + + )} {description} diff --git a/src/browser/modules/Stream/CypherFrame/AsciiView.jsx b/src/browser/modules/Stream/CypherFrame/AsciiView.jsx index aeb2b72676a..5cd11e03ab9 100644 --- a/src/browser/modules/Stream/CypherFrame/AsciiView.jsx +++ b/src/browser/modules/Stream/CypherFrame/AsciiView.jsx @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -import React, { Component } from 'react' +import React, { Component, useMemo } from 'react' import asciitable from 'ascii-data-table' import Render from 'browser-components/Render' import Ellipsis from 'browser-components/Ellipsis' @@ -29,17 +29,22 @@ import { StyledBodyMessage, StyledRightPartial, StyledWidthSliderContainer, - StyledWidthSlider + StyledWidthSlider, + StyledTruncatedMessage } from '../styled' import { getBodyAndStatusBarMessages, getRecordsToDisplayInTable, transformResultRecordsToResultArray, - stringifyResultArray + stringifyResultArray, + resultHasTruncatedFields } from './helpers' import { stringModifier } from 'services/bolt/cypherTypesFormatting' +import { getMaxFieldItems } from 'shared/modules/settings/settingsDuck' +import { connect } from 'react-redux' +import { Icon } from 'semantic-ui-react' -export class AsciiView extends Component { +export class AsciiViewComponent extends Component { state = { serializedRows: [], bodyMessage: '' @@ -73,7 +78,7 @@ export class AsciiView extends Component { } makeState(props) { - const { result, maxRows } = props + const { result, maxRows, maxFieldItems } = props const { bodyMessage = null } = getBodyAndStatusBarMessages(result, maxRows) || {} this.setState({ bodyMessage }) @@ -82,7 +87,7 @@ export class AsciiView extends Component { const serializedRows = stringifyResultArray( stringModifier, - transformResultRecordsToResultArray(records) + transformResultRecordsToResultArray(records, maxFieldItems) ) || [] this.setState({ serializedRows }) const maxColWidth = asciitable.maxColumnWidth(serializedRows) @@ -110,12 +115,17 @@ export class AsciiView extends Component { } } -export class AsciiStatusbar extends Component { +export const AsciiView = connect(state => ({ + maxFieldItems: getMaxFieldItems(state) +}))(AsciiViewComponent) + +export class AsciiStatusbarComponent extends Component { state = { maxSliderWidth: 140, minSliderWidth: 3, maxColWidth: 70, - statusBarMessage: '' + statusBarMessage: '', + hasTruncatedFields: false } componentDidUpdate() { @@ -126,7 +136,11 @@ export class AsciiStatusbar extends Component { this.setMaxSliderWidth(props._asciiMaxColWidth) const { statusBarMessage = null } = getBodyAndStatusBarMessages(props.result, props.maxRows) || {} - this.setState({ statusBarMessage }) + const hasTruncatedFields = resultHasTruncatedFields( + props.result, + props.maxFieldItems + ) + this.setState({ statusBarMessage, hasTruncatedFields }) } shouldComponentUpdate(props, state) { @@ -156,13 +170,19 @@ export class AsciiStatusbar extends Component { render() { const hasRecords = this.props.result.records && this.props.result.records.length - const { maxColWidth, maxSliderWidth } = this.state + const { maxColWidth, maxSliderWidth, hasTruncatedFields } = this.state return ( {this.state.statusBarMessage} + {hasTruncatedFields && ( + + Record fields have been + truncated.  + + )} Max column width: @@ -180,3 +200,7 @@ export class AsciiStatusbar extends Component { ) } } + +export const AsciiStatusbar = connect(state => ({ + maxFieldItems: getMaxFieldItems(state) +}))(AsciiStatusbarComponent) diff --git a/src/browser/modules/Stream/CypherFrame/AsciiView.test.js b/src/browser/modules/Stream/CypherFrame/AsciiView.test.js index d18671cc25a..eb2278105d0 100644 --- a/src/browser/modules/Stream/CypherFrame/AsciiView.test.js +++ b/src/browser/modules/Stream/CypherFrame/AsciiView.test.js @@ -22,7 +22,10 @@ import React from 'react' import { render } from '@testing-library/react' import neo4j from 'neo4j-driver' -import { AsciiView, AsciiStatusbar } from './AsciiView' +import { + AsciiViewComponent as AsciiView, + AsciiStatusbarComponent as AsciiStatusbar +} from './AsciiView' describe('AsciiViews', () => { describe('AsciiView', () => { diff --git a/src/browser/modules/Stream/CypherFrame/CodeView.jsx b/src/browser/modules/Stream/CypherFrame/CodeView.jsx index 8afb974c4b7..6ff083602fd 100644 --- a/src/browser/modules/Stream/CypherFrame/CodeView.jsx +++ b/src/browser/modules/Stream/CypherFrame/CodeView.jsx @@ -29,7 +29,13 @@ import { StyledTd, StyledExpandable } from '../styled' -import { TableStatusbar } from './TableView' +import { + RelatableStatusbar, + RelatableStatusbarComponent +} from './relatable-view' +import { getMaxFieldItems } from 'shared/modules/settings/settingsDuck' +import { connect } from 'react-redux' +import { map, take } from 'lodash-es' class ExpandableContent extends Component { state = {} @@ -54,16 +60,33 @@ class ExpandableContent extends Component { } } -export class CodeView extends Component { +const fieldLimiterFactory = maxFieldItems => (key, val) => { + if (!maxFieldItems || key !== '_fields') { + return val + } + + return map(val, field => { + return Array.isArray(field) ? take(field, maxFieldItems) : field + }) +} + +export class CodeViewComponent extends Component { shouldComponentUpdate(props) { return !this.props.result || !deepEquals(props.result, this.props.result) } - render() { - const { request = {}, query } = this.props + const { request = {}, query, maxFieldItems } = this.props if (request.status !== 'success') return null - const resultJson = JSON.stringify(request.result.records, null, 2) - const summaryJson = JSON.stringify(request.result.summary, null, 2) + const resultJson = JSON.stringify( + request.result.records, + fieldLimiterFactory(maxFieldItems), + 2 + ) + const summaryJson = JSON.stringify( + request.result.summary, + fieldLimiterFactory(maxFieldItems), + 2 + ) return ( @@ -97,4 +120,9 @@ export class CodeView extends Component { } } -export const CodeStatusbar = TableStatusbar +export const CodeView = connect(state => ({ + maxFieldItems: getMaxFieldItems(state) +}))(CodeViewComponent) + +export const CodeStatusbarComponent = RelatableStatusbarComponent +export const CodeStatusbar = RelatableStatusbar diff --git a/src/browser/modules/Stream/CypherFrame/CodeView.test.js b/src/browser/modules/Stream/CypherFrame/CodeView.test.js index 03509d68aff..03453b2d3d3 100644 --- a/src/browser/modules/Stream/CypherFrame/CodeView.test.js +++ b/src/browser/modules/Stream/CypherFrame/CodeView.test.js @@ -22,7 +22,10 @@ import React from 'react' import { render } from '@testing-library/react' import neo4j from 'neo4j-driver' -import { CodeView, CodeStatusbar } from './CodeView' +import { + CodeViewComponent as CodeView, + CodeStatusbarComponent as CodeStatusbar +} from './CodeView' describe('CodeViews', () => { describe('CodeView', () => { diff --git a/src/browser/modules/Stream/CypherFrame/TableView.jsx b/src/browser/modules/Stream/CypherFrame/TableView.jsx deleted file mode 100644 index c999bb783a6..00000000000 --- a/src/browser/modules/Stream/CypherFrame/TableView.jsx +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright (c) 2002-2020 "Neo4j," - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Neo4j is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import React, { Component } from 'react' -import { v4 } from 'uuid' -import neo4j from 'neo4j-driver' -import { HTMLEntities } from 'services/santize.utils' - -import { - StyledStatsBar, - PaddedTableViewDiv, - StyledBodyMessage -} from '../styled' -import Ellipsis from 'browser-components/Ellipsis' -import { - StyledTable, - StyledBodyTr, - StyledTh, - StyledTd, - StyledJsonPre -} from 'browser-components/DataTables' -import { shallowEquals, stringifyMod } from 'services/utils' -import { - getBodyAndStatusBarMessages, - getRecordsToDisplayInTable, - transformResultRecordsToResultArray -} from './helpers' -import { stringModifier } from 'services/bolt/cypherTypesFormatting' -import ClickableUrls, { - convertUrlsToHrefTags -} from '../../../components/clickable-urls' - -const renderCell = entry => { - if (Array.isArray(entry)) { - const children = entry.map((item, index) => ( - - {renderCell(item)} - {index === entry.length - 1 ? null : ', '} - - )) - return [{children}] - } else if (typeof entry === 'object') { - return renderObject(entry) - } else { - return - } -} -export const renderObject = entry => { - if (neo4j.isInt(entry)) return entry.toString() - if (entry === null) return null - return ( - - ) -} -const buildData = entries => { - return entries.map(entry => { - if (entry !== null) { - return ( - - {renderCell(entry)} - - ) - } - return ( - - null - - ) - }) -} -const buildRow = item => { - return ( - - {buildData(item)} - - ) -} - -export class TableView extends Component { - state = { - columns: [], - data: [], - bodyMessage: '' - } - - componentDidMount() { - this.makeState() - } - - componentDidUpdate(prevProps) { - if ( - this.props === undefined || - this.props.result === undefined || - this.props.updated !== prevProps.updated - ) { - this.makeState() - } - } - - shouldComponentUpdate(props, state) { - return ( - this.props.updated !== props.updated || !shallowEquals(state, this.state) - ) - } - - makeState() { - const records = getRecordsToDisplayInTable( - this.props.result, - this.props.maxRows - ) - const table = transformResultRecordsToResultArray(records) || [] - const data = table ? table.slice() : [] - const columns = data.length > 0 ? data.shift() : [] - const { bodyMessage } = getBodyAndStatusBarMessages( - this.props.result, - this.props.maxRows - ) - this.setState({ data, columns, bodyMessage }) - } - - render() { - if (!this.state.columns.length) { - return ( - - {this.state.bodyMessage} - - ) - } - const tableHeader = this.state.columns.map((column, i) => ( - - {column} - - )) - const tableBody = ( - {this.state.data.map(item => buildRow(item))} - ) - return ( - - - - {tableHeader} - - {tableBody} - - - ) - } -} - -export class TableStatusbar extends Component { - state = { - statusBarMessage: '' - } - - componentDidMount() { - this.makeState() - } - - componentDidUpdate() { - this.makeState() - } - - shouldComponentUpdate(props, state) { - if (!shallowEquals(state, this.state)) return true - return false - } - - makeState() { - const { statusBarMessage } = getBodyAndStatusBarMessages( - this.props.result, - this.props.maxRows - ) - if (statusBarMessage !== undefined) this.setState({ statusBarMessage }) - } - - render() { - return ( - - {this.state.statusBarMessage} - - ) - } -} diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView.jsx b/src/browser/modules/Stream/CypherFrame/VisualizationView.jsx index a35fdb5eee0..2c4408d3fe6 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView.jsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView.jsx @@ -30,6 +30,8 @@ import { StyledVisContainer } from './VisualizationView.styled' import { CYPHER_REQUEST } from 'shared/modules/cypher/cypherDuck' import { NEO4J_BROWSER_USER_ACTION_QUERY } from 'services/bolt/txMetadata' +import { getMaxFieldItems } from 'shared/modules/settings/settingsDuck' +import { resultHasTruncatedFields } from 'browser/modules/Stream/CypherFrame/helpers' export class Visualization extends Component { state = { @@ -68,11 +70,18 @@ export class Visualization extends Component { nodes, relationships } = bolt.extractNodesAndRelationshipsFromRecordsForOldVis( - props.result.records + props.result.records, + true, + props.maxFieldItems + ) + const hasTruncatedFields = resultHasTruncatedFields( + props.result, + props.maxFieldItems ) this.setState({ nodes, relationships, + hasTruncatedFields, updated: new Date().getTime() }) } @@ -116,7 +125,8 @@ export class Visualization extends Component { : 0 const resultGraph = bolt.extractNodesAndRelationshipsFromRecordsForOldVis( response.result.records, - false + false, + this.props.maxFieldItems ) this.autoCompleteRelationships( this.graph._nodes, @@ -151,7 +161,8 @@ export class Visualization extends Component { resolve({ ...bolt.extractNodesAndRelationshipsFromRecordsForOldVis( response.result.records, - false + false, + this.props.maxFieldItems ) }) } @@ -172,6 +183,7 @@ export class Visualization extends Component { { return { - graphStyleData: grassActions.getGraphStyleData(state) + graphStyleData: grassActions.getGraphStyleData(state), + maxFieldItems: getMaxFieldItems(state) } } diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/AsciiView.test.js.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/AsciiView.test.js.snap index 3b47b6a7195..b89a18efa66 100644 --- a/src/browser/modules/Stream/CypherFrame/__snapshots__/AsciiView.test.js.snap +++ b/src/browser/modules/Stream/CypherFrame/__snapshots__/AsciiView.test.js.snap @@ -20,14 +20,14 @@ exports[`AsciiViews AsciiStatusbar displays statusBarMessage if no rows 2`] = ` class="styled__StyledStatsBar-sc-1dtvgs1-23 gWjkIU" >
Max column width:
(no changes, no records)
diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/CodeView.test.js.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/CodeView.test.js.snap index ccee9f210a8..45c1aec1270 100644 --- a/src/browser/modules/Stream/CypherFrame/__snapshots__/CodeView.test.js.snap +++ b/src/browser/modules/Stream/CypherFrame/__snapshots__/CodeView.test.js.snap @@ -34,85 +34,85 @@ exports[`CodeViews CodeView displays request and response info if successful que class="styled__PaddedDiv-sc-1dtvgs1-1 kyHRpW" > diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/TableView.test.js.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/TableView.test.js.snap deleted file mode 100644 index 4ed112f7c6d..00000000000 --- a/src/browser/modules/Stream/CypherFrame/__snapshots__/TableView.test.js.snap +++ /dev/null @@ -1,94 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TableViews TableStatusbar displays no statusBarMessage 1`] = ` -
-
-
-
-
-`; - -exports[`TableViews TableStatusbar displays statusBarMessage 1`] = ` -
-
-
- Started streaming 1 records after 5 ms and completed after 10 ms. -
-
-
-`; - -exports[`TableViews TableView displays bodyMessage if no rows 1`] = ` -
-
-
- (no changes, no records) -
-
-
-`; - -exports[`TableViews TableView does not display bodyMessage if rows, and escapes HTML 1`] = ` -
-
-
Server version xx1
Server address xx2
Query MATCH xx0
Summary
{, "server": {, "version": "xx1", ...
Response
[, {, "res": "xx3" ...
- - - - - - - - - - -
- x -
- - "String with HTML <strong>in</strong> it" - -
-
-
-`; - -exports[`TableViews TableView renderObject handles null values 1`] = ` - -`; - -exports[`TableViews TableView renderObject handles null values 2`] = ` - - null - -`; diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap new file mode 100644 index 00000000000..585a1056d13 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RelatableViews RelatableView displays bodyMessage if no rows 1`] = ` +
+
+
+ (no changes, no records) +
+
+
+`; + +exports[`RelatableViews RelatableView does not display bodyMessage if rows, and escapes HTML 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+ + + x +
+
+ 1 +
+ +
+ "String with HTML <strong>in</strong> it" +
+
+
+
+`; + +exports[`RelatableViews TableStatusbar displays no statusBarMessage 1`] = ` +
+
+
+
+
+`; + +exports[`RelatableViews TableStatusbar displays statusBarMessage 1`] = ` +
+
+
+ Started streaming 1 records after 5 ms and completed after 10 ms. +
+
+
+`; diff --git a/src/browser/modules/Stream/CypherFrame/helpers.js b/src/browser/modules/Stream/CypherFrame/helpers.js index 7945514ab8e..e3fda0b27e8 100644 --- a/src/browser/modules/Stream/CypherFrame/helpers.js +++ b/src/browser/modules/Stream/CypherFrame/helpers.js @@ -21,24 +21,46 @@ import neo4j from 'neo4j-driver' import { entries, + flatten, + filter, get, includes, isObjectLike, lowerCase, map, - reduce + some, + reduce, + take } from 'lodash-es' import bolt from 'services/bolt/bolt' import * as viewTypes from 'shared/modules/stream/frameViewTypes' -import { - recursivelyExtractGraphItems, - flattenArray -} from 'services/bolt/boltMappings' +import { recursivelyExtractGraphItems } from 'services/bolt/boltMappings' import { stringifyMod } from 'services/utils' import { stringModifier } from 'services/bolt/cypherTypesFormatting' +/** + * Checks if a results has records which fields will be truncated when displayed + * - O(N2) complexity + * @param {Object} result + * @param {Number} maxFieldItems + * @return {boolean} + */ +export const resultHasTruncatedFields = (result, maxFieldItems) => { + if (!maxFieldItems || !result) { + return false + } + + return some(result.records, record => + some(record.keys, key => { + const val = record.get(key) + + return Array.isArray(val) && val.length > maxFieldItems + }) + ) +} + export function getBodyAndStatusBarMessages(result, maxRows) { if (!result || !result.summary || !result.summary.resultAvailableAfter) { return {} @@ -96,6 +118,24 @@ export const getRecordsToDisplayInTable = (result, maxRows) => { : result.records } +export const flattenArrayDeep = arr => { + let toFlatten = arr + let result = [] + + while (toFlatten.length > 0) { + result = [...result, ...filter(toFlatten, item => !Array.isArray(item))] + toFlatten = flatten(filter(toFlatten, Array.isArray)) + } + + return result +} + +const VIS_MAX_SAFE_LIMIT = 1000 + +export const requestExceedsVisLimits = ({ result } = {}) => { + return resultHasTruncatedFields(result, VIS_MAX_SAFE_LIMIT) +} + export const resultHasNodes = (request, types = neo4j.types) => { if (!request) return false const { result = {} } = request @@ -106,7 +146,7 @@ export const resultHasNodes = (request, types = neo4j.types) => { for (let i = 0; i < records.length; i++) { const graphItems = keys.map(key => records[i].get(key)) const items = recursivelyExtractGraphItems(types, graphItems) - const flat = flattenArray(items) + const flat = flattenArrayDeep(items) const nodes = flat.filter( item => item instanceof types.Node || item instanceof types.Path ) @@ -175,7 +215,8 @@ export const initialView = (props, state = {}) => { } // No we don't care about the recentView // If the response have viz elements, we show the viz - if (resultHasNodes(props.request)) return viewTypes.VISUALIZATION + if (!requestExceedsVisLimits(props.request) && resultHasNodes(props.request)) + return viewTypes.VISUALIZATION return viewTypes.TABLE } @@ -199,13 +240,13 @@ export const stringifyResultArray = (formatter = stringModifier, arr = []) => { * Flattens graph items so only their props are left. * Leaves Neo4j Integers as they were. */ -export const transformResultRecordsToResultArray = records => { +export const transformResultRecordsToResultArray = (records, maxFieldItems) => { return records && records.length ? [records] - .map(extractRecordsToResultArray) - .map( + .map(recs => extractRecordsToResultArray(recs, maxFieldItems)) + .flatMap( flattenGraphItemsInResultArray.bind(null, neo4j.types, neo4j.isInt) - )[0] + ) : undefined } @@ -213,12 +254,20 @@ export const transformResultRecordsToResultArray = records => { * Transforms an array of neo4j driver records to an array of objects. * Leaves all values as they were, just changing the data structure. */ -export const extractRecordsToResultArray = (records = []) => { +export const extractRecordsToResultArray = (records = [], maxFieldItems) => { records = Array.isArray(records) ? records : [] const keys = records[0] ? [records[0].keys] : undefined return (keys || []).concat( records.map(record => { - return record.keys.map((key, i) => record._fields[i]) + return record.keys.map((key, i) => { + const val = record._fields[i] + + if (!maxFieldItems || !Array.isArray(val)) { + return val + } + + return take(val, maxFieldItems) + }) }) ) } @@ -329,7 +378,7 @@ export function recordToJSONMapper(record) { * @param {*} values * @return {*} */ -function mapNeo4jValuesToPlainValues(values) { +export function mapNeo4jValuesToPlainValues(values) { if (!isObjectLike(values)) { return values } diff --git a/src/browser/modules/Stream/CypherFrame/index.jsx b/src/browser/modules/Stream/CypherFrame/index.jsx index 074249dbc41..6148079a0ed 100644 --- a/src/browser/modules/Stream/CypherFrame/index.jsx +++ b/src/browser/modules/Stream/CypherFrame/index.jsx @@ -40,7 +40,6 @@ import { Spinner } from 'browser-components/icons/Icons' import { AsciiView, AsciiStatusbar } from './AsciiView' -import { TableView, TableStatusbar } from './TableView' import { CodeView, CodeStatusbar } from './CodeView' import { ErrorsViewBus as ErrorsView, ErrorsStatusbar } from './ErrorsView' import { WarningsView, WarningsStatusbar } from './WarningsView' @@ -67,6 +66,10 @@ import { } from 'shared/modules/settings/settingsDuck' import { setRecentView, getRecentView } from 'shared/modules/stream/streamDuck' import { CancelView } from './CancelView' +import RelatableView, { + RelatableStatusbar +} from 'browser/modules/Stream/CypherFrame/relatable-view' +import { requestExceedsVisLimits } from 'browser/modules/Stream/CypherFrame/helpers' export class CypherFrame extends Component { visElement = null @@ -136,9 +139,14 @@ export class CypherFrame extends Component { } sidebar = () => { + const canShowViz = + !requestExceedsVisLimits(this.props.request) && + resultHasNodes(this.props.request) && + !this.state.errors + return ( - + - + - diff --git a/src/browser/modules/Stream/CypherFrame/index.test.js b/src/browser/modules/Stream/CypherFrame/index.test.js index 3cd6f15d235..4d94fdfefe2 100644 --- a/src/browser/modules/Stream/CypherFrame/index.test.js +++ b/src/browser/modules/Stream/CypherFrame/index.test.js @@ -41,7 +41,12 @@ describe('CypherFrame', () => { const store = { subscribe: () => {}, dispatch: () => {}, - getState: () => ({}) + getState: () => ({ + settings: { + maxRows: 1000, + maxFieldItems: 1000 + } + }) } test('renders accordingly from pending to success to error to success', () => { // Given diff --git a/src/browser/modules/Stream/CypherFrame/relatable-view.jsx b/src/browser/modules/Stream/CypherFrame/relatable-view.jsx new file mode 100644 index 00000000000..f5be4a1990e --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/relatable-view.jsx @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * This file is part of Neo4j. + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +import React, { useCallback, useMemo } from 'react' +import Relatable from '@relate-by-ui/relatable' +import { + entries, + filter, + get, + head, + join, + map, + memoize, + slice +} from 'lodash-es' +import { Icon } from 'semantic-ui-react' +import { connect } from 'react-redux' + +import { HTMLEntities } from 'services/santize.utils' +import { + getBodyAndStatusBarMessages, + mapNeo4jValuesToPlainValues, + resultHasTruncatedFields +} from './helpers' +import arrayHasItems from 'shared/utils/array-has-items' +import { + getMaxFieldItems, + getMaxRows +} from 'shared/modules/settings/settingsDuck' + +import ClickableUrls, { + convertUrlsToHrefTags +} from '../../../components/clickable-urls' +import { StyledStatsBar, StyledTruncatedMessage } from '../styled' +import Ellipsis from '../../../components/Ellipsis' +import { RelatableStyleWrapper, StyledJsonPre } from './relatable-view.styled' +import { isPoint } from 'neo4j-driver' + +const RelatableView = connect(state => ({ + maxRows: getMaxRows(state), + maxFieldItems: getMaxFieldItems(state) +}))(RelatableViewComponent) + +export default RelatableView + +export function RelatableViewComponent({ maxRows, result, maxFieldItems }) { + const { records = [] } = result + const columns = useMemo(() => getColumns(records, Number(maxFieldItems)), [ + result, + maxFieldItems + ]) + const data = useMemo(() => slice(records, 0, maxRows), [records, maxRows]) + + if (!arrayHasItems(columns)) { + return + } + + return ( + + + + ) +} + +function getColumns(records, maxFieldItems) { + const keys = get(head(records), 'keys', []) + + return map(keys, key => ({ + Header: key, + accessor: record => { + const fieldItem = record.get(key) + + if (!Array.isArray(fieldItem)) return fieldItem + + return slice(fieldItem, 0, maxFieldItems) + }, + Cell: CypherCell + })) +} + +function CypherCell({ cell }) { + const { value } = cell + const mapper = useCallback( + value => { + const memo = memoize(mapNeo4jValuesToPlainValues, value => { + const { elementType, identity } = value || {} + + return elementType ? `${elementType}:${identity}` : identity + }) + + return memo(value) + }, + [memoize, mapNeo4jValuesToPlainValues] + ) + const mapped = mapper(value) + + if (Number.isInteger(value)) { + return `${value}.0` + } + + if (typeof mapped === 'string') { + return `"${mapped}"` + } + + if (isPoint(value)) { + const pairs = filter( + entries(mapped), + ([, val]) => val !== null && val !== undefined + ) + + return `point({${join( + map(pairs, pair => join(pair, ':')), + ', ' + )}})` + } + + if (mapped && typeof mapped === 'object') { + return ( + + ) + } + + return +} + +export function RelatableBodyMessage({ maxRows, result }) { + const { bodyMessage } = getBodyAndStatusBarMessages(result, maxRows) + + return ( + + {bodyMessage} + + ) +} + +export const RelatableStatusbar = connect(state => ({ + maxRows: getMaxRows(state), + maxFieldItems: getMaxFieldItems(state) +}))(RelatableStatusbarComponent) + +export function RelatableStatusbarComponent({ + maxRows, + result, + maxFieldItems +}) { + const hasTruncatedFields = useMemo( + () => resultHasTruncatedFields(result, maxFieldItems), + [result, maxFieldItems] + ) + const { statusBarMessage } = useMemo( + () => getBodyAndStatusBarMessages(result, maxRows), + [result, maxRows] + ) + + return ( + + + {hasTruncatedFields && ( + + Record fields have been + truncated.  + + )} + {statusBarMessage} + + + ) +} diff --git a/src/browser/modules/Stream/CypherFrame/relatable-view.styled.js b/src/browser/modules/Stream/CypherFrame/relatable-view.styled.js new file mode 100644 index 00000000000..34a80f9ed07 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/relatable-view.styled.js @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * This file is part of Neo4j. + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +import styled from 'styled-components' + +export const RelatableStyleWrapper = styled.div` + width: 100%; + /* semantic ui specificity... */ + .relatable__table-row, + .relatable__table-row.relatable__table-header-row + .relatable__table-header-cell { + background-color: ${props => props.theme.secondaryBackground}; + color: ${props => props.theme.secondaryText}; + } + .relatable__table-row-number { + color: ${props => props.theme.preText}; + background-color: ${props => props.theme.preBackground}; + } + .relatable__table-header-row .relatable__table-cell { + border-bottom: ${props => props.theme.inFrameBorder}; + } + .relatable__table-body-row .relatable__table-cell { + border-top: ${props => props.theme.inFrameBorder}; + } +` + +export const StyledJsonPre = styled.pre` + background-color: ${props => props.theme.preBackground}; + -webkit-border-radius: 5px; + border-radius: 5px; + margin: 20px 10px; + border-bottom: none; + color: ${props => props.theme.preText}; + line-height: 26px; + padding: 2px 10px; + max-width: 320px; + white-space: pre-wrap; +` diff --git a/src/browser/modules/Stream/CypherFrame/TableView.test.js b/src/browser/modules/Stream/CypherFrame/relatable-view.test.js similarity index 76% rename from src/browser/modules/Stream/CypherFrame/TableView.test.js rename to src/browser/modules/Stream/CypherFrame/relatable-view.test.js index 2140747e5d8..28f0941c22b 100644 --- a/src/browser/modules/Stream/CypherFrame/TableView.test.js +++ b/src/browser/modules/Stream/CypherFrame/relatable-view.test.js @@ -22,15 +22,16 @@ import React from 'react' import { render } from '@testing-library/react' import neo4j from 'neo4j-driver' -import { TableView, TableStatusbar, renderObject } from './TableView' +import { + RelatableViewComponent as RelatableView, + RelatableStatusbarComponent as RelatableStatusbar +} from './relatable-view' -describe('TableViews', () => { - describe('TableView', () => { +describe('RelatableViews', () => { + describe('RelatableView', () => { test('displays bodyMessage if no rows', () => { // Given - const sps = jest.fn() const props = { - setParentState: sps, result: { records: [], summary: { @@ -41,14 +42,14 @@ describe('TableViews', () => { } // When - const { container } = render() + const { container } = render( + + ) // Then expect(container).toMatchSnapshot() }) test('does not display bodyMessage if rows, and escapes HTML', () => { - // Given - const sps = jest.fn() const value = 'String with HTML in it' const result = { records: [{ keys: ['x'], _fields: [value], get: () => value }], @@ -60,22 +61,12 @@ describe('TableViews', () => { // When const { container } = render( - + ) // Then expect(container).toMatchSnapshot() }) - test('renderObject handles null values', () => { - // Given - const datas = [{ x: 1 }, null] - - // When - const results = datas.map(data => renderObject(data)) - - // Then - results.forEach((res, i) => expect(res).toMatchSnapshot()) - }) }) describe('TableStatusbar', () => { test('displays no statusBarMessage', () => { @@ -83,7 +74,9 @@ describe('TableViews', () => { const props = { result: {}, maxRows: 0 } // When - const { container } = render() + const { container } = render( + + ) // Then expect(container).toMatchSnapshot() @@ -104,7 +97,9 @@ describe('TableViews', () => { } // When - const { container } = render() + const { container } = render( + + ) // Then expect(container).toMatchSnapshot() diff --git a/src/browser/modules/Stream/__snapshots__/HistoryRow.test.js.snap b/src/browser/modules/Stream/__snapshots__/HistoryRow.test.js.snap index f1d699d4281..5ecbdf15456 100644 --- a/src/browser/modules/Stream/__snapshots__/HistoryRow.test.js.snap +++ b/src/browser/modules/Stream/__snapshots__/HistoryRow.test.js.snap @@ -3,7 +3,7 @@ exports[`HistoryRow triggers function on click 1`] = `
  • :clear
  • diff --git a/src/browser/modules/Stream/styled.jsx b/src/browser/modules/Stream/styled.jsx index 4b992ecfc93..44d5e28495e 100644 --- a/src/browser/modules/Stream/styled.jsx +++ b/src/browser/modules/Stream/styled.jsx @@ -177,6 +177,9 @@ export const StyledStatsBar = styled.div` padding-left: 24px; width: 100%; ` +export const StyledTruncatedMessage = styled.span` + color: orange; +` export const StyledOneRowStatsBar = styled(StyledStatsBar)` height: 39px; diff --git a/src/shared/modules/settings/__snapshots__/settingsDuck.test.js.snap b/src/shared/modules/settings/__snapshots__/settingsDuck.test.js.snap index 1eaf0a5c3f2..b012d8c5f8a 100644 --- a/src/shared/modules/settings/__snapshots__/settingsDuck.test.js.snap +++ b/src/shared/modules/settings/__snapshots__/settingsDuck.test.js.snap @@ -12,6 +12,7 @@ Object { "enableMultiStatementMode": true, "initCmd": ":play start", "initialNodeDisplay": 300, + "maxFieldItems": 500, "maxFrames": 30, "maxHistory": 30, "maxNeighbours": 100, diff --git a/src/shared/modules/settings/settingsDuck.js b/src/shared/modules/settings/settingsDuck.js index 1e6f5948bba..6fd3ad4e0f3 100644 --- a/src/shared/modules/settings/settingsDuck.js +++ b/src/shared/modules/settings/settingsDuck.js @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +import { get } from 'lodash-es' import { APP_START, USER_CLEAR } from 'shared/modules/app/appDuck' @@ -49,6 +50,8 @@ export const getBrowserSyncConfig = ( export const getMaxNeighbours = state => state[NAME].maxNeighbours || initialState.maxNeighbours export const getMaxRows = state => state[NAME].maxRows || initialState.maxRows +export const getMaxFieldItems = state => + get(state, [NAME, 'maxFieldItems'], initialState.maxFieldItems) export const getInitialNodeDisplay = state => state[NAME].initialNodeDisplay || initialState.initialNodeDisplay export const getScrollToTop = state => state[NAME].scrollToTop @@ -89,6 +92,7 @@ const initialState = { showSampleScripts: true, browserSyncDebugServer: null, maxRows: 1000, + maxFieldItems: 500, shouldReportUdc: true, autoComplete: true, scrollToTop: true, diff --git a/src/shared/services/bolt/bolt.js b/src/shared/services/bolt/bolt.js index 73e75e1ebd9..cac41f7eae1 100644 --- a/src/shared/services/bolt/bolt.js +++ b/src/shared/services/bolt/bolt.js @@ -233,18 +233,21 @@ export default { objectConverter: mappings.extractFromNeoObjects }) }, - extractNodesAndRelationshipsFromRecords: records => { + extractNodesAndRelationshipsFromRecords: (records, maxFieldItems) => { return mappings.extractNodesAndRelationshipsFromRecords( records, - neo4j.types + neo4j.types, + maxFieldItems ) }, extractNodesAndRelationshipsFromRecordsForOldVis: ( records, - filterRels = true + filterRels = true, + maxFieldItems ) => { const intChecker = neo4j.isInt const intConverter = val => val.toString() + return mappings.extractNodesAndRelationshipsFromRecordsForOldVis( records, neo4j.types, @@ -253,7 +256,8 @@ export default { intChecker, intConverter, objectConverter: mappings.extractFromNeoObjects - } + }, + maxFieldItems ) }, extractPlan: (result, calculateTotalDbHits) => { diff --git a/src/shared/services/bolt/boltMappings.js b/src/shared/services/bolt/boltMappings.js index 621a8a69bde..006565af839 100644 --- a/src/shared/services/bolt/boltMappings.js +++ b/src/shared/services/bolt/boltMappings.js @@ -19,6 +19,7 @@ */ import updateStatsFields from './updateStatisticsFields' +import { flatten, map, take } from 'lodash-es' import neo4j from 'neo4j-driver' import { stringModifier } from 'services/bolt/cypherTypesFormatting' import { @@ -152,30 +153,19 @@ const collectHits = function(operator) { export function extractNodesAndRelationshipsFromRecords( records, - types = neo4j.types + types = neo4j.types, + maxFieldItems ) { if (records.length === 0) { return { nodes: [], relationships: [] } } - const keys = records[0].keys - let rawNodes = [] - let rawRels = [] - records.forEach(record => { - const graphItems = keys.map(key => record.get(key)) - rawNodes = [ - ...rawNodes, - ...graphItems.filter(item => item instanceof types.Node) - ] - rawRels = [ - ...rawRels, - ...graphItems.filter(item => item instanceof types.Relationship) - ] - const paths = graphItems.filter(item => item instanceof types.Path) - paths.forEach(item => - extractNodesAndRelationshipsFromPath(item, rawNodes, rawRels, types) - ) - }) + const { rawNodes, rawRels } = extractRawNodesAndRelationShipsFromRecords( + records, + types, + maxFieldItems + ) + return { nodes: rawNodes, relationships: rawRels } } @@ -183,33 +173,17 @@ export function extractNodesAndRelationshipsFromRecordsForOldVis( records, types, filterRels, - converters + converters, + maxFieldItems ) { if (records.length === 0) { return { nodes: [], relationships: [] } } - const keys = records[0].keys - let rawNodes = [] - let rawRels = [] - - records.forEach(record => { - let graphItems = keys.map(key => record.get(key)) - graphItems = flattenArray( - recursivelyExtractGraphItems(types, graphItems) - ).filter(item => item !== false) - rawNodes = [ - ...rawNodes, - ...graphItems.filter(item => item instanceof types.Node) - ] - rawRels = [ - ...rawRels, - ...graphItems.filter(item => item instanceof types.Relationship) - ] - const paths = graphItems.filter(item => item instanceof types.Path) - paths.forEach(item => - extractNodesAndRelationshipsFromPath(item, rawNodes, rawRels, types) - ) - }) + const { rawNodes, rawRels } = extractRawNodesAndRelationShipsFromRecords( + records, + types, + maxFieldItems + ) const nodes = rawNodes.map(item => { return { @@ -255,27 +229,83 @@ export const recursivelyExtractGraphItems = (types, item) => { return item } -export const flattenArray = arr => { - return arr.reduce((all, curr) => { - if (Array.isArray(curr)) return all.concat(flattenArray(curr)) - return all.concat(curr) - }, []) -} +export function extractRawNodesAndRelationShipsFromRecords( + records, + types = neo4j.types, + maxFieldItems +) { + const items = new Set() + const paths = new Set() + const segments = new Set() + const rawNodes = new Set() + const rawRels = new Set() -const extractNodesAndRelationshipsFromPath = (item, rawNodes, rawRels) => { - const paths = Array.isArray(item) ? item : [item] - paths.forEach(path => { - let segments = path.segments - // Zero length path. No relationship, end === start - if (!Array.isArray(path.segments) || path.segments.length < 1) { - segments = [{ ...path, end: null }] + for (const record of records) { + for (const key of record.keys) { + items.add(record.get(key)) } - segments.forEach(segment => { - if (segment.start) rawNodes.push(segment.start) - if (segment.end) rawNodes.push(segment.end) - if (segment.relationship) rawRels.push(segment.relationship) - }) - }) + } + + const flatTruncatedItems = flatten( + map([...items], item => + maxFieldItems && Array.isArray(item) ? take(item, maxFieldItems) : item + ) + ) + + const findAllEntities = item => { + if (item instanceof types.Relationship) { + rawRels.add(item) + return + } + if (item instanceof types.Node) { + rawNodes.add(item) + return + } + if (item instanceof types.Path) { + paths.add(item) + return + } + if (Array.isArray(item)) { + for (const subItem of item) { + findAllEntities(subItem) + } + return + } + if (item && typeof item === 'object') { + for (const subItem of Object.values(item)) { + findAllEntities(subItem) + } + return + } + } + + findAllEntities(flatTruncatedItems) + + for (const path of paths) { + if (path.start) { + rawNodes.add(path.start) + } + if (path.end) { + rawNodes.add(path.end) + } + for (const segment of path.segments) { + segments.add(segment) + } + } + + for (const segment of segments) { + if (segment.start) { + rawNodes.add(segment.start) + } + if (segment.end) { + rawNodes.add(segment.end) + } + if (segment.relationship) { + rawRels.add(segment.relationship) + } + } + + return { rawNodes: [...rawNodes], rawRels: [...rawRels] } } export const retrieveFormattedUpdateStatistics = result => { @@ -290,7 +320,9 @@ export const retrieveFormattedUpdateStatistics = result => { }` ) return statsMessages.join(', ') - } else return null + } else { + return null + } } export const flattenProperties = rows => { diff --git a/src/shared/services/bolt/boltMappings.test.js b/src/shared/services/bolt/boltMappings.test.js index 59777c6aea3..2a9583e9977 100644 --- a/src/shared/services/bolt/boltMappings.test.js +++ b/src/shared/services/bolt/boltMappings.test.js @@ -204,6 +204,31 @@ describe('boltMappings', () => { expect(relationships[0].properties).toEqual({}) }) + test('should truncate field items when told to do so', () => { + let startNode = new neo4j.types.Node('1', ['Person'], { + prop1: 'prop1' + }) + let endNode = new neo4j.types.Node('2', ['Movie'], { + prop2: 'prop2' + }) + let boltRecord = { + keys: ['p'], + get: key => [startNode, endNode] + } + + let { nodes, relationships } = extractNodesAndRelationshipsFromRecords( + [boltRecord], + neo4j.types, + 1 + ) + expect(nodes.length).toBe(1) + expect(relationships.length).toBe(0) + let graphNode = nodes[0] + expect(graphNode).toBeDefined() + expect(graphNode.labels).toEqual(['Person']) + expect(graphNode.properties).toEqual({ prop1: 'prop1' }) + }) + test('should map bolt nodes and relationships to graph nodes and relationships', () => { const startNode = new neo4j.types.Node('1', ['Person'], { prop1: 'prop1' @@ -389,7 +414,7 @@ describe('boltMappings', () => { // Then expect(out.nodes.length).toEqual(4) }) - test('should find items in paths with segments', () => { + test('should find items in paths with segments, and only return unique items', () => { // Given const converters = { intChecker: () => false, @@ -423,7 +448,7 @@ describe('boltMappings', () => { ) // Then - expect(out.nodes.length).toEqual(4) + expect(out.nodes.length).toEqual(3) }) test('should find items in paths zero segments', () => { // Given diff --git a/yarn.lock b/yarn.lock index 8963ba0fc56..960365badd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1634,7 +1634,7 @@ resolved "https://neo.jfrog.io/neo/api/npm/npm/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@relate-by-ui/css@^1.0.4": +"@relate-by-ui/css@1.0.5": version "1.0.5" resolved "https://neo.jfrog.io/neo/api/npm/npm/@relate-by-ui/css/-/css-1.0.5.tgz#2c14bc91ebe3c199984d394f9add2f908f9fb695" integrity sha1-LBS8kevjwZmYTTlPmt0vkI+ftpU= @@ -1644,6 +1644,17 @@ react-dom "^16.8.6" react-placeholder "^3.0.2" +"@relate-by-ui/relatable@1.0.1": + version "1.0.1" + resolved "https://neo.jfrog.io/neo/api/npm/npm/@relate-by-ui/relatable/-/relatable-1.0.1.tgz#7dd117aa19e81b380b1cadeb83f364f93b6e9076" + integrity sha1-fdEXqhnoGzgLHK3rg/Nk+TtukHY= + dependencies: + "@emotion/core" "10.0.10" + "@emotion/styled" "10.0.10" + lodash-es "4.17.15" + react-table "7.0.4" + semantic-ui-react "0.88.2" + "@relate-by-ui/saved-scripts@^1.0.4": version "1.0.4" resolved "https://neo.jfrog.io/neo/api/npm/npm/@relate-by-ui/saved-scripts/-/saved-scripts-1.0.4.tgz#df50dae1c98c589637e12097031a42311e78279f" @@ -1660,6 +1671,11 @@ dependencies: any-observable "^0.3.0" +"@scarf/scarf@^0.1.5": + version "0.1.7" + resolved "https://neo.jfrog.io/neo/api/npm/npm/@scarf/scarf/-/scarf-0.1.7.tgz#e6ffb3660bda77f96037f8c4e21174177fed2d97" + integrity sha1-5v+zZgvad/lgN/jE4hF0F3/tLZc= + "@semantic-ui-react/event-stack@^3.1.0": version "3.1.1" resolved "https://neo.jfrog.io/neo/api/npm/npm/@semantic-ui-react/event-stack/-/event-stack-3.1.1.tgz#3263d17511db81a743167fe45281a24b3eb6b3c8" @@ -10009,6 +10025,13 @@ react-svg-inline@^2.1.1: classnames "^2.2.1" prop-types "^15.5.8" +react-table@7.0.4: + version "7.0.4" + resolved "https://neo.jfrog.io/neo/api/npm/npm/react-table/-/react-table-7.0.4.tgz#456838661982c83c3682f156c59a41b4339a2120" + integrity sha1-RWg4ZhmCyDw2gvFWxZpBtDOaISA= + dependencies: + "@scarf/scarf" "^0.1.5" + react-test-renderer@^16.9.0: version "16.13.1" resolved "https://neo.jfrog.io/neo/api/npm/npm/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" @@ -10703,7 +10726,7 @@ selfsigned@^1.10.7: dependencies: node-forge "0.9.0" -semantic-ui-react@^0.88.0: +semantic-ui-react@0.88.2, semantic-ui-react@^0.88.0: version "0.88.2" resolved "https://neo.jfrog.io/neo/api/npm/npm/semantic-ui-react/-/semantic-ui-react-0.88.2.tgz#3d4b54f8b799769b412435c8531475fd34aa4149" integrity sha1-PUtU+LeZdptBJDXIUxR1/TSqQUk=