diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 3603c1341..760f03660 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -1,50 +1,77 @@ import React from 'react'; import { connect } from 'react-redux'; +import { Map as makeMap } from 'immutable'; +import { searchNodeMatchesSelector } from '../selectors/search'; import { selectedNetworkNodesIdsSelector } from '../selectors/node-networks'; -import { currentTopologySearchNodeMatchesSelector } from '../selectors/search'; import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils'; import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { + constructor(props, context) { + super(props, context); + + // Edge decorators + this.edgeFocusedDecorator = this.edgeFocusedDecorator.bind(this); + this.edgeBlurredDecorator = this.edgeBlurredDecorator.bind(this); + this.edgeHighlightedDecorator = this.edgeHighlightedDecorator.bind(this); + this.edgeScaleDecorator = this.edgeScaleDecorator.bind(this); + } + + edgeHighlightedDecorator(edge) { + return edge.set('highlighted', this.props.highlightedEdgeIds.has(edge.get('id'))); + } + + edgeFocusedDecorator(edge) { + const sourceSelected = (this.props.selectedNodeId === edge.get('source')); + const targetSelected = (this.props.selectedNodeId === edge.get('target')); + return edge.set('focused', this.props.hasSelectedNode && (sourceSelected || targetSelected)); + } + + edgeBlurredDecorator(edge) { + const { selectedNodeId, searchNodeMatches, selectedNetworkNodesIds } = this.props; + const sourceSelected = (selectedNodeId === edge.get('source')); + const targetSelected = (selectedNodeId === edge.get('target')); + const otherNodesSelected = this.props.hasSelectedNode && !sourceSelected && !targetSelected; + const sourceNoMatches = searchNodeMatches.get(edge.get('source'), makeMap()).isEmpty(); + const targetNoMatches = searchNodeMatches.get(edge.get('target'), makeMap()).isEmpty(); + const notMatched = this.props.searchQuery && (sourceNoMatches || targetNoMatches); + const sourceInNetwork = selectedNetworkNodesIds.contains(edge.get('source')); + const targetInNetwork = selectedNetworkNodesIds.contains(edge.get('target')); + const notInNetwork = this.props.selectedNetwork && (!sourceInNetwork || !targetInNetwork); + return edge.set('blurred', !edge.get('highlighted') && !edge.get('focused') && + (otherNodesSelected || notMatched || notInNetwork)); + } + + edgeScaleDecorator(edge) { + return edge.set('scale', edge.get('focused') ? this.props.selectedScale : 1); + } + render() { - const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery, - isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodesIds, - searchNodeMatches } = this.props; + const { layoutEdges, isAnimated } = this.props; + + const edgesToRender = layoutEdges.toIndexedSeq() + .map(this.edgeHighlightedDecorator) + .map(this.edgeFocusedDecorator) + .map(this.edgeBlurredDecorator) + .map(this.edgeScaleDecorator); return ( - {layoutEdges.toIndexedSeq().map((edge) => { - const sourceSelected = selectedNodeId === edge.get('source'); - const targetSelected = selectedNodeId === edge.get('target'); - const highlighted = highlightedEdgeIds.has(edge.get('id')); - const focused = hasSelectedNode && (sourceSelected || targetSelected); - const otherNodesSelected = hasSelectedNode && !sourceSelected && !targetSelected; - const noMatches = searchQuery && - !(searchNodeMatches.has(edge.get('source')) && - searchNodeMatches.has(edge.get('target'))); - const noSelectedNetworks = selectedNetwork && - !(selectedNetworkNodesIds.contains(edge.get('source')) && - selectedNetworkNodesIds.contains(edge.get('target'))); - const blurred = !highlighted && (otherNodesSelected || - (!focused && noMatches) || - (!focused && noSelectedNetworks)); - - return ( - - ); - })} + {edgesToRender.map(edge => ( + + ))} ); } @@ -53,11 +80,11 @@ class NodesChartEdges extends React.Component { export default connect( state => ({ hasSelectedNode: hasSelectedNodeFn(state), - highlightedEdgeIds: state.get('highlightedEdgeIds'), - searchNodeMatches: currentTopologySearchNodeMatchesSelector(state), - searchQuery: state.get('searchQuery'), - selectedNetwork: state.get('selectedNetwork'), + searchNodeMatches: searchNodeMatchesSelector(state), selectedNetworkNodesIds: selectedNetworkNodesIdsSelector(state), + searchQuery: state.get('searchQuery'), + highlightedEdgeIds: state.get('highlightedEdgeIds'), + selectedNetwork: state.get('selectedNetwork'), selectedNodeId: state.get('selectedNodeId'), }) )(NodesChartEdges); diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 1a0a57a88..26e619b5e 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -1,77 +1,118 @@ import React from 'react'; import { connect } from 'react-redux'; +import { Map as makeMap } from 'immutable'; -import { nodeNetworksSelector, selectedNetworkNodesIdsSelector } from '../selectors/node-networks'; import { nodeMetricSelector } from '../selectors/node-metric'; -import { currentTopologySearchNodeMatchesSelector } from '../selectors/search'; +import { searchNodeMatchesSelector } from '../selectors/search'; +import { nodeNetworksSelector, selectedNetworkNodesIdsSelector } from '../selectors/node-networks'; import { getAdjacentNodes } from '../utils/topology-utils'; import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { + constructor(props, context) { + super(props, context); + + this.nodeDisplayLayer = this.nodeDisplayLayer.bind(this); + // Node decorators + // TODO: Consider moving some of these one level up (or even to global selectors) so that + // other components, like NodesChartEdges, could read more info directly from the nodes. + this.nodeHighlightedDecorator = this.nodeHighlightedDecorator.bind(this); + this.nodeFocusedDecorator = this.nodeFocusedDecorator.bind(this); + this.nodeBlurredDecorator = this.nodeBlurredDecorator.bind(this); + this.nodeMatchesDecorator = this.nodeMatchesDecorator.bind(this); + this.nodeNetworksDecorator = this.nodeNetworksDecorator.bind(this); + this.nodeMetricDecorator = this.nodeMetricDecorator.bind(this); + this.nodeScaleDecorator = this.nodeScaleDecorator.bind(this); + } + + nodeHighlightedDecorator(node) { + const nodeSelected = (this.props.selectedNodeId === node.get('id')); + const nodeHighlighted = this.props.highlightedNodeIds.has(node.get('id')); + return node.set('highlighted', nodeHighlighted || nodeSelected); + } + + nodeFocusedDecorator(node) { + const nodeSelected = (this.props.selectedNodeId === node.get('id')); + const isNeighborOfSelected = this.props.neighborsOfSelectedNode.includes(node.get('id')); + return node.set('focused', nodeSelected || isNeighborOfSelected); + } + + nodeBlurredDecorator(node) { + const belongsToNetwork = this.props.selectedNetworkNodesIds.contains(node.get('id')); + const noMatches = this.props.searchNodeMatches.get(node.get('id'), makeMap()).isEmpty(); + const notMatched = (this.props.searchQuery && !node.get('highlighted') && noMatches); + const notFocused = (this.props.selectedNodeId && !node.get('focused')); + const notInNetwork = (this.props.selectedNetwork && !belongsToNetwork); + return node.set('blurred', notMatched || notFocused || notInNetwork); + } + + nodeMatchesDecorator(node) { + return node.set('matches', this.props.searchNodeMatches.get(node.get('id'))); + } + + nodeNetworksDecorator(node) { + return node.set('networks', this.props.nodeNetworks.get(node.get('id'))); + } + + nodeMetricDecorator(node) { + return node.set('metric', this.props.nodeMetric.get(node.get('id'))); + } + + nodeScaleDecorator(node) { + return node.set('scale', node.get('focused') ? this.props.selectedScale : 1); + } + + // make sure blurred nodes are in the background + nodeDisplayLayer(node) { + if (node.get('id') === this.props.mouseOverNodeId) { + return 3; + } + if (node.get('blurred') && !node.get('focused')) { + return 0; + } + if (node.get('highlighted')) { + return 2; + } + return 1; + } + render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, - mouseOverNodeId, nodeMetric, selectedScale, searchQuery, selectedNetwork, - selectedNodeId, searchNodeMatches, nodeNetworks, selectedNetworkNodesIds } = this.props; - - // highlighter functions - const setHighlighted = node => node.set('highlighted', - highlightedNodeIds.has(node.get('id')) || selectedNodeId === node.get('id')); - const setFocused = node => node.set('focused', selectedNodeId - && (selectedNodeId === node.get('id') - || (adjacentNodes && adjacentNodes.includes(node.get('id'))))); - const setBlurred = node => node.set('blurred', - (selectedNodeId && !node.get('focused')) - || (searchQuery && !searchNodeMatches.has(node.get('id')) && !node.get('highlighted')) - || (selectedNetwork && !selectedNetworkNodesIds.contains(node.get('id')))); - - // make sure blurred nodes are in the background - const sortNodes = (node) => { - if (node.get('id') === mouseOverNodeId) { - return 3; - } - if (node.get('blurred') && !node.get('focused')) { - return 0; - } - if (node.get('highlighted')) { - return 2; - } - return 1; - }; + const { layoutNodes, isAnimated } = this.props; const nodesToRender = layoutNodes.toIndexedSeq() - .map(setHighlighted) - .map(setFocused) - .map(setBlurred) - .sortBy(sortNodes); + .map(this.nodeHighlightedDecorator) + .map(this.nodeFocusedDecorator) + .map(this.nodeBlurredDecorator) + .map(this.nodeMatchesDecorator) + .map(this.nodeNetworksDecorator) + .map(this.nodeMetricDecorator) + .map(this.nodeScaleDecorator) + .sortBy(this.nodeDisplayLayer); return ( - {nodesToRender.map((node) => { - const nodeScale = node.get('focused') ? selectedScale : 1; - const nodeId = node.get('id'); - return ( - - ); - })} + {nodesToRender.map(node => ( + + ))} ); } @@ -79,11 +120,11 @@ class NodesChartNodes extends React.Component { function mapStateToProps(state) { return { - adjacentNodes: getAdjacentNodes(state), nodeMetric: nodeMetricSelector(state), nodeNetworks: nodeNetworksSelector(state), - searchNodeMatches: currentTopologySearchNodeMatchesSelector(state), + searchNodeMatches: searchNodeMatchesSelector(state), selectedNetworkNodesIds: selectedNetworkNodesIdsSelector(state), + neighborsOfSelectedNode: getAdjacentNodes(state), highlightedNodeIds: state.get('highlightedNodeIds'), mouseOverNodeId: state.get('mouseOverNodeId'), selectedNetwork: state.get('selectedNetwork'), diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 2c9288b0c..9c4898110 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -17,7 +17,7 @@ import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus'; import { graphLayout } from '../selectors/nodes-chart-layout'; -const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100; +const GRAPH_COMPLEXITY_NODES_TRESHOLD = 150; const ZOOM_CACHE_FIELDS = [ 'panTranslateX', 'panTranslateY', 'zoomScale', 'minZoomScale', 'maxZoomScale' diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 158fa4b30..cfd9cd037 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -7,7 +7,7 @@ import NodeDetailsTable from '../components/node-details/node-details-table'; import { clickNode, sortOrderChanged } from '../actions/app-actions'; import { shownNodesSelector } from '../selectors/node-filters'; -import { currentTopologySearchNodeMatchesSelector } from '../selectors/search'; +import { searchNodeMatchesSelector } from '../selectors/search'; import { getNodeColor } from '../utils/color-utils'; @@ -115,7 +115,7 @@ class NodesGrid extends React.Component { id: '', nodes: nodes .toList() - .filter(n => !searchQuery || searchNodeMatches.has(n.get('id'))) + .filter(n => !(searchQuery && searchNodeMatches.get(n.get('id'), makeMap()).isEmpty())) .toJS(), columns: getColumns(nodes) }; @@ -149,7 +149,7 @@ function mapStateToProps(state) { gridSortedDesc: state.get('gridSortedDesc'), currentTopology: state.get('currentTopology'), currentTopologyId: state.get('currentTopologyId'), - searchNodeMatches: currentTopologySearchNodeMatchesSelector(state), + searchNodeMatches: searchNodeMatchesSelector(state), searchQuery: state.get('searchQuery'), selectedNodeId: state.get('selectedNodeId') }; diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index d57ab02ba..5a9d9c319 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -4,7 +4,7 @@ import classnames from 'classnames'; import { debounce } from 'lodash'; import { blurSearch, doSearch, focusSearch, showHelp } from '../actions/app-actions'; -import { searchNodeMatchesSelector } from '../selectors/search'; +import { searchMatchCountByTopologySelector } from '../selectors/search'; import { slugify } from '../utils/string-utils'; import { isTopologyEmpty } from '../utils/topology-utils'; import SearchItem from './search-item'; @@ -102,11 +102,11 @@ class Search extends React.Component { } render() { - const { inputId = 'search', nodes, pinnedSearches, searchFocused, - searchNodeMatches, searchQuery, topologiesLoaded, onClickHelp } = this.props; + const { nodes, pinnedSearches, searchFocused, searchMatchCountByTopology, + searchQuery, topologiesLoaded, onClickHelp, inputId = 'search' } = this.props; const disabled = this.props.isTopologyEmpty; - const matchCount = searchNodeMatches - .reduce((count, topologyMatches) => count + topologyMatches.size, 0); + const matchCount = searchMatchCountByTopology + .reduce((count, topologyMatchCount) => count + topologyMatchCount, 0); const showPinnedSearches = pinnedSearches.size > 0; // manual clear (null) has priority, then props, then state const value = this.state.value === null ? '' : this.state.value || searchQuery || ''; @@ -154,11 +154,11 @@ export default connect( state => ({ nodes: state.get('nodes'), isTopologyEmpty: isTopologyEmpty(state), + topologiesLoaded: state.get('topologiesLoaded'), pinnedSearches: state.get('pinnedSearches'), searchFocused: state.get('searchFocused'), searchQuery: state.get('searchQuery'), - searchNodeMatches: searchNodeMatchesSelector(state), - topologiesLoaded: state.get('topologiesLoaded') + searchMatchCountByTopology: searchMatchCountByTopologySelector(state), }), { blurSearch, doSearch, focusSearch, onClickHelp: showHelp } )(Search); diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js index 272799553..02bb50115 100644 --- a/client/app/scripts/components/topologies.js +++ b/client/app/scripts/components/topologies.js @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import { searchNodeMatchesSelector } from '../selectors/search'; +import { searchMatchCountByTopologySelector } from '../selectors/search'; import { clickTopology } from '../actions/app-actions'; @@ -30,10 +30,9 @@ class Topologies extends React.Component { } renderSubTopology(subTopology) { - const isActive = subTopology === this.props.currentTopology; const topologyId = subTopology.get('id'); - const searchMatches = this.props.searchNodeMatches.get(subTopology.get('id')); - const searchMatchCount = searchMatches ? searchMatches.size : 0; + const isActive = subTopology === this.props.currentTopology; + const searchMatchCount = this.props.searchMatchCountByTopology.get(topologyId) || 0; const title = basicTopologyInfo(subTopology, searchMatchCount); const className = classnames('topologies-sub-item', { 'topologies-sub-item-active': isActive, @@ -53,8 +52,7 @@ class Topologies extends React.Component { renderTopology(topology) { const isActive = topology === this.props.currentTopology; - const searchMatches = this.props.searchNodeMatches.get(topology.get('id')); - const searchMatchCount = searchMatches ? searchMatches.size : 0; + const searchMatchCount = this.props.searchMatchCountByTopology.get(topology.get('id')) || 0; const className = classnames('topologies-item-main', { 'topologies-item-main-active': isActive, 'topologies-item-main-matched': searchMatchCount @@ -91,8 +89,8 @@ class Topologies extends React.Component { function mapStateToProps(state) { return { topologies: state.get('topologies'), - searchNodeMatches: searchNodeMatchesSelector(state), - currentTopology: state.get('currentTopology') + currentTopology: state.get('currentTopology'), + searchMatchCountByTopology: searchMatchCountByTopologySelector(state), }; } diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index a7dfbf860..250304c29 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -64,7 +64,7 @@ describe('RootReducer', () => { } ], stats: { - edge_count: 319, + edge_count: 379, filtered_nodes: 214, node_count: 320, nonpseudo_node_count: 320 diff --git a/client/app/scripts/selectors/__tests__/search-test.js b/client/app/scripts/selectors/__tests__/search-test.js new file mode 100644 index 000000000..e825d5528 --- /dev/null +++ b/client/app/scripts/selectors/__tests__/search-test.js @@ -0,0 +1,118 @@ +import { fromJS, Map as makeMap } from 'immutable'; + +const SearchSelectors = require('../search'); + +describe('Search selectors', () => { + const nodeSets = { + someNodes: fromJS({ + n1: { + id: 'n1', + label: 'node label 1', + metadata: [{ + id: 'fieldId1', + label: 'Label 1', + value: 'value 1' + }], + metrics: [{ + id: 'metric1', + label: 'Metric 1', + value: 1 + }] + }, + n2: { + id: 'n2', + label: 'node label 2', + metadata: [{ + id: 'fieldId2', + label: 'Label 2', + value: 'value 2' + }], + tables: [{ + id: 'metric1', + type: 'property-list', + rows: [{ + id: 'label1', + entries: { + label: 'Label 1', + value: 'Label Value 1' + } + }, { + id: 'label2', + entries: { + label: 'Label 2', + value: 'Label Value 2' + } + }] + }, { + id: 'metric2', + type: 'multicolumn-table', + columns: [{ + id: 'a', + label: 'A' + }, { + id: 'c', + label: 'C' + }], + rows: [{ + id: 'row1', + entries: { + a: 'xxxa', + b: 'yyya', + c: 'zzz1' + } + }, { + id: 'row2', + entries: { + a: 'yyyb', + b: 'xxxb', + c: 'zzz2' + } + }, { + id: 'row3', + entries: { + a: 'Value 1', + b: 'Value 2', + c: 'Value 3' + } + }] + }], + }, + }) + }; + + describe('searchNodeMatchesSelector', () => { + const selector = SearchSelectors.searchNodeMatchesSelector; + + it('should return no matches on an empty topology', () => { + const result = selector(fromJS({ + nodes: makeMap(), + searchQuery: '', + })); + expect(result.filter(m => !m.isEmpty()).size).toEqual(0); + }); + + it('should return no matches when no query is present', () => { + const result = selector(fromJS({ + nodes: nodeSets.someNodes, + searchQuery: '', + })); + expect(result.filter(m => !m.isEmpty()).size).toEqual(0); + }); + + it('should return no matches when query matches nothing', () => { + const result = selector(fromJS({ + nodes: nodeSets.someNodes, + searchQuery: 'cantmatch', + })); + expect(result.filter(m => !m.isEmpty()).size).toEqual(0); + }); + + it('should return a matches when a query matches something', () => { + const result = selector(fromJS({ + nodes: nodeSets.someNodes, + searchQuery: 'value 2', + })); + expect(result.filter(m => !m.isEmpty()).size).toEqual(1); + }); + }); +}); diff --git a/client/app/scripts/selectors/node-networks.js b/client/app/scripts/selectors/node-networks.js index 27f9fe6dd..830c2b98f 100644 --- a/client/app/scripts/selectors/node-networks.js +++ b/client/app/scripts/selectors/node-networks.js @@ -1,6 +1,6 @@ import { createSelector } from 'reselect'; import { createMapSelector } from 'reselect-map'; -import { fromJS, List as makeList } from 'immutable'; +import { fromJS, Map as makeMap, List as makeList } from 'immutable'; const extractNodeNetworksValue = (node) => { @@ -36,10 +36,12 @@ export const availableNetworksSelector = createSelector( .sortBy(m => m.get('label')) ); +// NOTE: Don't use this selector directly in mapStateToProps +// as it would get called too many times. export const selectedNetworkNodesIdsSelector = createSelector( [ state => state.get('networkNodes'), state => state.get('selectedNetwork'), ], - (networkNodes, selectedNetwork) => networkNodes.get(selectedNetwork) + (networkNodes, selectedNetwork) => networkNodes.get(selectedNetwork, makeMap()) ); diff --git a/client/app/scripts/selectors/nodes-chart-focus.js b/client/app/scripts/selectors/nodes-chart-focus.js index a5b4ce637..c9da89261 100644 --- a/client/app/scripts/selectors/nodes-chart-focus.js +++ b/client/app/scripts/selectors/nodes-chart-focus.js @@ -13,23 +13,13 @@ const radiusDensity = scaleThreshold() .domain([3, 6]) .range([2.5, 3.5, 3]); - -const layoutNodesSelector = state => state.layoutNodes; -const layoutEdgesSelector = state => state.layoutEdges; -const stateWidthSelector = state => state.width; -const stateHeightSelector = state => state.height; -const stateScaleSelector = state => state.zoomScale; -const stateTranslateXSelector = state => state.panTranslateX; -const stateTranslateYSelector = state => state.panTranslateY; -const inputNodesSelector = (_, props) => props.nodes; -const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId; -const propsMarginsSelector = (_, props) => props.margins; +// TODO: Make all the selectors below pure (so that they only depend on the global state). // The narrower dimension of the viewport, used for scaling. const viewportExpanseSelector = createSelector( [ - stateWidthSelector, - stateHeightSelector, + state => state.width, + state => state.height, ], (width, height) => Math.min(width, height) ); @@ -38,12 +28,12 @@ const viewportExpanseSelector = createSelector( // panel is open), used for focusing the selected node. const viewportCenterSelector = createSelector( [ - stateWidthSelector, - stateHeightSelector, - stateTranslateXSelector, - stateTranslateYSelector, - stateScaleSelector, - propsMarginsSelector, + state => state.width, + state => state.height, + state => state.panTranslateX, + state => state.panTranslateY, + state => state.zoomScale, + (_, props) => props.margins, ], (width, height, translateX, translateY, scale, margins) => { const viewportHalfWidth = ((width + margins.left) - DETAILS_PANEL_WIDTH) / 2; @@ -57,10 +47,11 @@ const viewportCenterSelector = createSelector( // List of all the adjacent nodes to the selected // one, excluding itself (in case of loops). +// TODO: Use createMapSelector here instead. const selectedNodeNeighborsIdsSelector = createSelector( [ - propsSelectedNodeIdSelector, - inputNodesSelector, + (_, props) => props.selectedNodeId, + (_, props) => props.nodes, ], (selectedNodeId, nodes) => { let adjacentNodes = makeSet(); @@ -84,11 +75,11 @@ const selectedNodeNeighborsIdsSelector = createSelector( const selectedNodesLayoutSettingsSelector = createSelector( [ + state => state.zoomScale, selectedNodeNeighborsIdsSelector, viewportExpanseSelector, - stateScaleSelector, ], - (circularNodesIds, viewportExpanse, scale) => { + (scale, circularNodesIds, viewportExpanse) => { const circularNodesCount = circularNodesIds.length; // Here we calculate the zoom factor of the nodes that get selected into focus. @@ -114,14 +105,14 @@ const selectedNodesLayoutSettingsSelector = createSelector( export const layoutWithSelectedNode = createSelector( [ - layoutNodesSelector, - layoutEdgesSelector, + state => state.layoutNodes, + state => state.layoutEdges, + (_, props) => props.selectedNodeId, viewportCenterSelector, - propsSelectedNodeIdSelector, selectedNodeNeighborsIdsSelector, selectedNodesLayoutSettingsSelector, ], - (layoutNodes, layoutEdges, viewportCenter, selectedNodeId, neighborsIds, layoutSettings) => { + (layoutNodes, layoutEdges, selectedNodeId, viewportCenter, neighborsIds, layoutSettings) => { // Do nothing if the layout doesn't contain the selected node anymore. if (!layoutNodes.has(selectedNodeId)) { return {}; diff --git a/client/app/scripts/selectors/nodes-chart-layout.js b/client/app/scripts/selectors/nodes-chart-layout.js index 894c97492..604b81dfd 100644 --- a/client/app/scripts/selectors/nodes-chart-layout.js +++ b/client/app/scripts/selectors/nodes-chart-layout.js @@ -1,5 +1,5 @@ import debug from 'debug'; -import { createSelector } from 'reselect'; +import { createSelector, createStructuredSelector } from 'reselect'; import { Map as makeMap } from 'immutable'; import timely from 'timely'; @@ -9,15 +9,6 @@ import { doLayout } from '../charts/nodes-layout'; const log = debug('scope:nodes-chart'); -const stateWidthSelector = state => state.width; -const stateHeightSelector = state => state.height; -const inputNodesSelector = (_, props) => props.nodes; -const propsMarginsSelector = (_, props) => props.margins; -const forceRelayoutSelector = (_, props) => props.forceRelayout; -const topologyIdSelector = (_, props) => props.topologyId; -const topologyOptionsSelector = (_, props) => props.topologyOptions; - - function initEdgesFromNodes(nodes) { let edges = makeMap(); @@ -47,23 +38,20 @@ function initEdgesFromNodes(nodes) { return edges; } -const layoutOptionsSelector = createSelector( - [ - stateWidthSelector, - stateHeightSelector, - propsMarginsSelector, - forceRelayoutSelector, - topologyIdSelector, - topologyOptionsSelector, - ], - (width, height, margins, forceRelayout, topologyId, topologyOptions) => ( - { width, height, margins, forceRelayout, topologyId, topologyOptions } - ) -); +// TODO: Make all the selectors below pure (so that they only depend on the global state). + +const layoutOptionsSelector = createStructuredSelector({ + width: state => state.width, + height: state => state.height, + margins: (_, props) => props.margins, + forceRelayout: (_, props) => props.forceRelayout, + topologyId: (_, props) => props.topologyId, + topologyOptions: (_, props) => props.topologyOptions, +}); export const graphLayout = createSelector( [ - inputNodesSelector, + (_, props) => props.nodes, layoutOptionsSelector, ], (nodes, options) => { diff --git a/client/app/scripts/selectors/nodes-chart-zoom.js b/client/app/scripts/selectors/nodes-chart-zoom.js index ede1e9c45..d95dbc46c 100644 --- a/client/app/scripts/selectors/nodes-chart-zoom.js +++ b/client/app/scripts/selectors/nodes-chart-zoom.js @@ -3,23 +3,19 @@ import { createSelector } from 'reselect'; import { NODE_BASE_SIZE } from '../constants/styles'; import { zoomCacheKey } from '../utils/topology-utils'; -const layoutNodesSelector = state => state.layoutNodes; -const stateWidthSelector = state => state.width; -const stateHeightSelector = state => state.height; -const propsMarginsSelector = (_, props) => props.margins; -const cachedZoomStateSelector = (state, props) => state.zoomCache[zoomCacheKey(props)]; +// TODO: Make all the selectors below pure (so that they only depend on the global state). const viewportWidthSelector = createSelector( [ - stateWidthSelector, - propsMarginsSelector, + state => state.width, + (_, props) => props.margins, ], (width, margins) => width - margins.left - margins.right ); const viewportHeightSelector = createSelector( [ - stateHeightSelector, - propsMarginsSelector, + state => state.height, + (_, props) => props.margins, ], (height, margins) => height - margins.top ); @@ -27,12 +23,12 @@ const viewportHeightSelector = createSelector( // Compute the default zoom settings for the given graph layout. const defaultZoomSelector = createSelector( [ - layoutNodesSelector, + state => state.layoutNodes, + (_, props) => props.margins, viewportWidthSelector, viewportHeightSelector, - propsMarginsSelector, ], - (layoutNodes, width, height, margins) => { + (layoutNodes, margins, width, height) => { if (layoutNodes.size === 0) { return {}; } @@ -67,7 +63,7 @@ const defaultZoomSelector = createSelector( // otherwise use the default zoom options computed from the graph layout. export const topologyZoomState = createSelector( [ - cachedZoomStateSelector, + (state, props) => state.zoomCache[zoomCacheKey(props)], defaultZoomSelector, ], (cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState diff --git a/client/app/scripts/selectors/search.js b/client/app/scripts/selectors/search.js index 354bc6677..00b16b1bb 100644 --- a/client/app/scripts/selectors/search.js +++ b/client/app/scripts/selectors/search.js @@ -2,7 +2,7 @@ import { createSelector } from 'reselect'; import { createMapSelector } from 'reselect-map'; import { Map as makeMap } from 'immutable'; -import { parseQuery, searchTopology, getSearchableFields } from '../utils/search-utils'; +import { parseQuery, searchNode, searchTopology, getSearchableFields } from '../utils/search-utils'; const parsedSearchQuerySelector = createSelector( @@ -13,27 +13,26 @@ const parsedSearchQuerySelector = createSelector( ); export const searchNodeMatchesSelector = createMapSelector( + [ + state => state.get('nodes'), + parsedSearchQuerySelector, + ], + (node, parsed) => (parsed ? searchNode(node, parsed) : makeMap()) +); + +export const searchMatchCountByTopologySelector = createMapSelector( [ state => state.get('nodesByTopology'), parsedSearchQuerySelector, ], // TODO: Bring map selectors one level deeper here so that `searchTopology` is // not executed against all the topology nodes when the nodes delta is small. - (nodes, parsed) => (parsed ? searchTopology(nodes, parsed) : makeMap()) -); - -export const currentTopologySearchNodeMatchesSelector = createSelector( - [ - state => state.get('currentTopologyId'), - searchNodeMatchesSelector, - ], - (currentTopologyId, nodesByTopology) => nodesByTopology.get(currentTopologyId) || makeMap() + (nodes, parsed) => (parsed ? searchTopology(nodes, parsed).size : 0) ); export const searchableFieldsSelector = createSelector( [ state => state.get('nodes'), ], - // TODO: Bring this function in the selectors. getSearchableFields ); diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 51ec0aa1d..ee1629d61 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -117,70 +117,80 @@ function findNodeMatchMetric(nodeMatches, keyPath, fieldValue, fieldLabel, metri return nodeMatches; } - -export function searchTopology(nodes, { prefix, query, metric, comp, value }) { +export function searchNode(node, { prefix, query, metric, comp, value }) { let nodeMatches = makeMap(); + + if (query) { + // top level fields + SEARCH_FIELDS.forEach((field, label) => { + const keyPath = [label]; + if (node.has(field)) { + nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), + query, prefix, label); + } + }); + + // metadata + if (node.get('metadata')) { + node.get('metadata').forEach((field) => { + const keyPath = ['metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, prefix, field.get('label'), field.get('truncate')); + }); + } + + // parents and relatives + if (node.get('parents')) { + node.get('parents').forEach((parent) => { + const keyPath = ['parents', parent.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, parent.get('label'), + query, prefix, parent.get('topologyId')); + }); + } + + // property lists + (node.get('tables') || []).filter(isPropertyList).forEach((propertyList) => { + (propertyList.get('rows') || []).forEach((row) => { + const entries = row.get('entries'); + const keyPath = ['property-lists', row.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, entries.get('value'), + query, prefix, entries.get('label')); + }); + }); + + // generic tables + (node.get('tables') || []).filter(isGenericTable).forEach((table) => { + (table.get('rows') || []).forEach((row) => { + table.get('columns').forEach((column) => { + const val = row.get('entries').get(column.get('id')); + const keyPath = ['tables', genericTableEntryKey(row, column)]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, val, query); + }); + }); + }); + } else if (metric) { + const metrics = node.get('metrics'); + if (metrics) { + metrics.forEach((field) => { + const keyPath = ['metrics', field.get('id')]; + nodeMatches = findNodeMatchMetric(nodeMatches, keyPath, field.get('value'), + field.get('label'), metric, comp, value); + }); + } + } + + return nodeMatches; +} + +export function searchTopology(nodes, parsedQuery) { + let nodesMatches = makeMap(); nodes.forEach((node, nodeId) => { - if (query) { - // top level fields - SEARCH_FIELDS.forEach((field, label) => { - const keyPath = [nodeId, label]; - if (node.has(field)) { - nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), - query, prefix, label); - } - }); - - // metadata - if (node.get('metadata')) { - node.get('metadata').forEach((field) => { - const keyPath = [nodeId, 'metadata', field.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), - query, prefix, field.get('label'), field.get('truncate')); - }); - } - - // parents and relatives - if (node.get('parents')) { - node.get('parents').forEach((parent) => { - const keyPath = [nodeId, 'parents', parent.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, parent.get('label'), - query, prefix, parent.get('topologyId')); - }); - } - - // property lists - (node.get('tables') || []).filter(isPropertyList).forEach((propertyList) => { - (propertyList.get('rows') || []).forEach((row) => { - const entries = row.get('entries'); - const keyPath = [nodeId, 'property-lists', row.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, entries.get('value'), - query, prefix, entries.get('label')); - }); - }); - - // generic tables - (node.get('tables') || []).filter(isGenericTable).forEach((table) => { - (table.get('rows') || []).forEach((row) => { - table.get('columns').forEach((column) => { - const val = row.get('entries').get(column.get('id')); - const keyPath = [nodeId, 'tables', genericTableEntryKey(row, column)]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, val, query); - }); - }); - }); - } else if (metric) { - const metrics = node.get('metrics'); - if (metrics) { - metrics.forEach((field) => { - const keyPath = [nodeId, 'metrics', field.get('id')]; - nodeMatches = findNodeMatchMetric(nodeMatches, keyPath, field.get('value'), - field.get('label'), metric, comp, value); - }); - } + const nodeMatches = searchNode(node, parsedQuery); + if (!nodeMatches.isEmpty()) { + nodesMatches = nodesMatches.set(nodeId, nodeMatches); } }); - return nodeMatches; + return nodesMatches; } /** diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 80aca9e5a..f70810578 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -174,11 +174,6 @@ export function getCurrentTopologyUrl(state) { return state.getIn(['currentTopology', 'url']); } -export function isNodeMatchingQuery(node, query) { - return node.get('label').includes(query) || - node.get('labelMinor').includes(query); -} - export function graphExceedsComplexityThresh(stats) { // Check to see if complexity is high. Used to trigger table view on page load. return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 1000; diff --git a/client/package.json b/client/package.json index 4ce6c23b7..db2b8096d 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,6 @@ "moment": "2.17.1", "page": "1.7.1", "react": "15.4.1", - "react-addons-perf": "15.4.1", "react-dom": "15.4.1", "react-motion": "0.4.5", "react-redux": "4.4.6", @@ -72,6 +71,7 @@ "json-loader": "0.5.4", "node-sass": "3.13.1", "postcss-loader": "1.2.0", + "react-addons-perf": "15.4.1", "redux-devtools": "3.3.1", "redux-devtools-dock-monitor": "1.1.1", "redux-devtools-log-monitor": "1.1.1",