Skip to content

Commit

Permalink
Merge pull request #1429 from weaveworks/search
Browse files Browse the repository at this point in the history
Search on canvas
  • Loading branch information
davkal committed May 12, 2016
2 parents df38389 + b51e7a9 commit b5a8b19
Show file tree
Hide file tree
Showing 29 changed files with 1,579 additions and 159 deletions.
94 changes: 92 additions & 2 deletions client/app/scripts/actions/app-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import ActionTypes from '../constants/action-types';
import { saveGraph } from '../utils/file-utils';
import { modulo } from '../utils/math-utils';
import { updateRoute } from '../utils/router-utils';
import { parseQuery } from '../utils/search-utils';
import { bufferDeltaUpdate, resumeUpdate,
resetUpdateBuffer } from '../utils/update-buffer-utils';
import { doControlRequest, getNodesDelta, getNodeDetails,
import { doControlRequest, getAllNodes, getNodesDelta, getNodeDetails,
getTopologies, deletePipe } from '../utils/web-api-utils';
import { getActiveTopologyOptions,
getCurrentTopologyUrl } from '../utils/topology-utils';
Expand Down Expand Up @@ -69,6 +70,20 @@ export function pinNextMetric(delta) {
};
}

export function unpinSearch(query) {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.UNPIN_SEARCH,
query
});
updateRoute(getState);
};
}

export function blurSearch() {
return { type: ActionTypes.BLUR_SEARCH };
}

export function changeTopologyOption(option, value, topologyId) {
return (dispatch, getState) => {
dispatch({
Expand Down Expand Up @@ -128,7 +143,11 @@ export function clickCloseTerminal(pipeId, closePipe) {
}

export function clickDownloadGraph() {
saveGraph();
return (dispatch) => {
dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: true });
saveGraph();
dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: false });
};
}

export function clickForceRelayout() {
Expand Down Expand Up @@ -265,6 +284,16 @@ export function doControl(nodeId, control) {
};
}

export function doSearch(searchQuery) {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.DO_SEARCH,
searchQuery
});
updateRoute(getState);
};
}

export function enterEdge(edgeId) {
return {
type: ActionTypes.ENTER_EDGE,
Expand All @@ -279,12 +308,61 @@ export function enterNode(nodeId) {
};
}

export function focusSearch() {
return (dispatch, getState) => {
dispatch({ type: ActionTypes.FOCUS_SEARCH });
// update nodes cache to allow search across all topologies,
// wait a second until animation is over
setTimeout(() => {
getAllNodes(getState, dispatch);
}, 1200);
};
}

export function hitBackspace() {
return (dispatch, getState) => {
const state = getState();
// remove last pinned query if search query is empty
if (state.get('searchFocused') && !state.get('searchQuery')) {
const query = state.get('pinnedSearches').last();
if (query) {
dispatch({
type: ActionTypes.UNPIN_SEARCH,
query
});
updateRoute(getState);
}
}
};
}

export function hitEnter() {
return (dispatch, getState) => {
const state = getState();
// pin query based on current search field
if (state.get('searchFocused')) {
const query = state.get('searchQuery');
if (query && parseQuery(query)) {
dispatch({
type: ActionTypes.PIN_SEARCH,
query
});
updateRoute(getState);
}
}
};
}

export function hitEsc() {
return (dispatch, getState) => {
const state = getState();
const controlPipe = state.get('controlPipes').last();
if (state.get('showingHelp')) {
dispatch(hideHelp());
} else if (state.get('searchQuery')) {
dispatch(doSearch(''));
} else if (state.get('searchFocused')) {
dispatch(blurSearch());
} else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') {
dispatch({
type: ActionTypes.CLICK_CLOSE_TERMINAL,
Expand Down Expand Up @@ -351,9 +429,17 @@ export function receiveNodesDelta(delta) {
};
}

export function receiveNodesForTopology(nodes, topologyId) {
return {
type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY,
nodes,
topologyId
};
}

export function receiveTopologies(topologies) {
return (dispatch, getState) => {
const firstLoad = !getState().get('topologiesLoaded');
dispatch({
type: ActionTypes.RECEIVE_TOPOLOGIES,
topologies
Expand All @@ -369,6 +455,10 @@ export function receiveTopologies(topologies) {
state.get('nodeDetails'),
dispatch
);
// populate search matches on first load
if (firstLoad && state.get('searchQuery')) {
dispatch(focusSearch());
}
};
}

Expand Down
124 changes: 73 additions & 51 deletions client/app/scripts/charts/node.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import classNames from 'classnames';
import classnames from 'classnames';
import { Map as makeMap } from 'immutable';

import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils';
import MatchedText from '../components/matched-text';
import MatchedResults from '../components/matched-results';

import NodeShapeCircle from './node-shape-circle';
import NodeShapeStack from './node-shape-stack';
Expand Down Expand Up @@ -34,14 +37,15 @@ function getNodeShape({ shape, stack }) {
return stack ? stackedShape(nodeShape) : nodeShape;
}

function ellipsis(text, fontSize, maxWidth) {
const averageCharLength = fontSize / 1.5;
const allowedChars = maxWidth / averageCharLength;
let truncatedText = text;
if (text && text.length > allowedChars) {
truncatedText = `${text.slice(0, allowedChars)}...`;
}
return truncatedText;
function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) {
return (
<g className="node-label-svg">
<text className={labelClassName} y={labelOffsetY + 18} textAnchor="middle">{label}</text>
<text className={subLabelClassName} y={labelOffsetY + 35} textAnchor="middle">
{subLabel}
</text>
</g>
);
}

class Node extends React.Component {
Expand All @@ -51,64 +55,79 @@ class Node extends React.Component {
this.handleMouseClick = this.handleMouseClick.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.state = { hovered: false };
this.state = {
hovered: false,
matched: false
};
}

componentWillReceiveProps(nextProps) {
// marks as matched only when search query changes
if (nextProps.searchQuery !== this.props.searchQuery) {
this.setState({
matched: nextProps.matched
});
} else {
this.setState({
matched: false
});
}
}

render() {
const { blurred, focused, highlighted, label, pseudo, rank,
subLabel, scaleFactor, transform, zoomScale } = this.props;
const { hovered } = this.state;
const { blurred, focused, highlighted, label, matches = makeMap(),
pseudo, rank, subLabel, scaleFactor, transform, zoomScale, exportingGraph } = this.props;
const { hovered, matched } = this.state;
const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale;

const color = getNodeColor(rank, label, pseudo);
const truncate = !focused && !hovered;
const labelText = truncate ? ellipsis(label, 14, nodeScale(4 * scaleFactor)) : label;
const subLabelText = truncate ? ellipsis(subLabel, 12, nodeScale(4 * scaleFactor)) : subLabel;

let labelOffsetY = 18;
let subLabelOffsetY = 35;
let labelFontSize = 14;
let subLabelFontSize = 12;

// render focused nodes in normal size
if (focused) {
labelFontSize /= zoomScale;
subLabelFontSize /= zoomScale;
labelOffsetY /= zoomScale;
subLabelOffsetY /= zoomScale;
}
const labelTransform = focused ? `scale(${1 / zoomScale})` : '';
const labelWidth = nodeScale(scaleFactor * 4);
const labelOffsetX = -labelWidth / 2;
const labelOffsetY = focused ? nodeScale(0.5) : nodeScale(0.5 * scaleFactor);

const className = classNames({
node: true,
const nodeClassName = classnames('node', {
highlighted,
blurred,
blurred: blurred && !focused,
hovered,
matched,
pseudo
});

const labelClassName = classnames('node-label', { truncate });
const subLabelClassName = classnames('node-sublabel', { truncate });

const NodeShapeType = getNodeShape(this.props);
const useSvgLabels = exportingGraph;

return (
<g className={className} transform={transform} onClick={this.handleMouseClick}
<g className={nodeClassName} transform={transform}
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<rect className="hover-box"
x={-nodeScale(scaleFactor * 0.5)}
y={-nodeScale(scaleFactor * 0.5)}
width={nodeScale(scaleFactor)}
height={nodeScale(scaleFactor) + subLabelOffsetY}
/>
<text className="node-label" textAnchor="middle" style={{fontSize: labelFontSize}}
x="0" y={labelOffsetY + nodeScale(0.5 * scaleFactor)}>
{labelText}
</text>
<text className="node-sublabel" textAnchor="middle" style={{fontSize: subLabelFontSize}}
x="0" y={subLabelOffsetY + nodeScale(0.5 * scaleFactor)}>
{subLabelText}
</text>
<NodeShapeType
size={nodeScale(scaleFactor)}
color={color}
{...this.props} />

{useSvgLabels ?

svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :

<foreignObject x={labelOffsetX} y={labelOffsetY} width={labelWidth}
height="10em" transform={labelTransform}>
<div className="node-label-wrapper" onClick={this.handleMouseClick}>
<div className={labelClassName}>
<MatchedText text={label} match={matches.get('label')} />
</div>
<div className={subLabelClassName}>
<MatchedText text={subLabel} match={matches.get('sublabel')} />
</div>
{!blurred && <MatchedResults matches={matches.get('metadata')} />}
</div>
</foreignObject>}

<g onClick={this.handleMouseClick}>
<NodeShapeType
size={nodeScale(scaleFactor)}
color={color}
{...this.props} />
</g>
</g>
);
}
Expand All @@ -131,6 +150,9 @@ class Node extends React.Component {
}

export default connect(
null,
state => ({
searchQuery: state.get('searchQuery'),
exportingGraph: state.get('exportingGraph')
}),
{ clickNode, enterNode, leaveNode }
)(Node);
21 changes: 15 additions & 6 deletions client/app/scripts/charts/nodes-chart-edges.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap } from 'immutable';

import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils';
import EdgeContainer from './edge-container';

class NodesChartEdges extends React.Component {
render() {
const {hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision,
selectedNodeId} = this.props;
const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
selectedNodeId } = this.props;

return (
<g className="nodes-chart-edges">
{layoutEdges.toIndexedSeq().map(edge => {
const sourceSelected = selectedNodeId === edge.get('source');
const targetSelected = selectedNodeId === edge.get('target');
const blurred = hasSelectedNode && !sourceSelected && !targetSelected;
const highlighted = highlightedEdgeIds.has(edge.get('id'));
const focused = hasSelectedNode && (sourceSelected || targetSelected);
const blurred = !(highlightedEdgeIds.size > 0 && highlighted)
&& ((hasSelectedNode && !sourceSelected && !targetSelected)
|| !focused && searchQuery && !(searchNodeMatches.has(edge.get('source'))
&& searchNodeMatches.has(edge.get('target'))));

return (
<EdgeContainer
Expand All @@ -27,7 +33,7 @@ class NodesChartEdges extends React.Component {
blurred={blurred}
focused={focused}
layoutPrecision={layoutPrecision}
highlighted={highlightedEdgeIds.has(edge.get('id'))}
highlighted={highlighted}
/>
);
})}
Expand All @@ -37,10 +43,13 @@ class NodesChartEdges extends React.Component {
}

function mapStateToProps(state) {
const currentTopologyId = state.get('currentTopologyId');
return {
hasSelectedNode: hasSelectedNodeFn(state),
selectedNodeId: state.get('selectedNodeId'),
highlightedEdgeIds: state.get('highlightedEdgeIds')
highlightedEdgeIds: state.get('highlightedEdgeIds'),
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
searchQuery: state.get('searchQuery'),
selectedNodeId: state.get('selectedNodeId')
};
}

Expand Down
Loading

0 comments on commit b5a8b19

Please sign in to comment.