diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index f87d25d01..949393517 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -4,6 +4,7 @@ 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, getAllNodes, getNodesDelta, getNodeDetails, @@ -69,6 +70,20 @@ export function pinNextMetric(delta) { }; } +export function pinSearch(query) { + return { + type: ActionTypes.PIN_SEARCH, + query + }; +} + +export function unpinSearch(query) { + return { + type: ActionTypes.UNPIN_SEARCH, + query + }; +} + export function blurSearch() { return { type: ActionTypes.BLUR_SEARCH }; } @@ -301,6 +316,22 @@ export function focusSearch() { }; } +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 + }); + } + } + }; +} + export function hitEsc() { return (dispatch, getState) => { const state = getState(); diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index d646a2c71..b643d79fa 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -7,8 +7,9 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { - const { hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, - searchNodeMatches = makeMap(), searchQuery, selectedNodeId } = this.props; + const { hasSelectedNode, highlightedEdgeIds, layoutEdges, + layoutPrecision, searchNodeMatches = makeMap(), searchQuery, + selectedNodeId } = this.props; return ( diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 1a2d8ca62..1a7a6bc49 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -7,10 +7,10 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, - nodeScale, scale, searchNodeMatches = makeMap(), searchQuery, - selectedMetric, selectedNodeScale, - selectedNodeId, topCardNode } = this.props; + const { adjacentNodes, highlightedNodeIds, layoutNodes, + layoutPrecision, nodeScale, scale, searchNodeMatches = makeMap(), + searchQuery, selectedMetric, selectedNodeScale, selectedNodeId, + topCardNode } = this.props; const zoomScale = scale; diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 9a88e7847..59ed21351 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -198,17 +198,14 @@ class NodesChart extends React.Component { if (!edges.has(edgeId)) { const source = edge[0]; const target = edge[1]; - - if (!stateNodes.has(source) || !stateNodes.has(target)) { - log('Missing edge node', edge[0], edge[1]); + if (stateNodes.has(source) && stateNodes.has(target)) { + edges = edges.set(edgeId, makeMap({ + id: edgeId, + value: 1, + source, + target + })); } - - edges = edges.set(edgeId, makeMap({ - id: edgeId, - value: 1, - source, - target - })); } }); } @@ -404,7 +401,7 @@ function mapStateToProps(state) { return { adjacentNodes: getAdjacentNodes(state), forceRelayout: state.get('forceRelayout'), - nodes: state.get('nodes'), + nodes: state.get('nodes').filter(node => !node.get('filtered')), selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('topologyId'), topologyOptions: getActiveTopologyOptions(state) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index d50aaf601..271b07672 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -11,7 +11,7 @@ import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies } from '../utils/web-api-utils'; -import { pinNextMetric, hitEsc, unpinMetric, +import { pinNextMetric, hitEnter, hitEsc, unpinMetric, selectMetric, toggleHelp } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; @@ -23,6 +23,7 @@ import DebugToolbar, { showingDebugToolbar, import { getUrlState } from '../utils/router-utils'; import { getActiveTopologyOptions } from '../utils/topology-utils'; +const ENTER_KEY_CODE = 13; const ESC_KEY_CODE = 27; const keyPressLog = debug('scope:app-key-press'); @@ -55,6 +56,8 @@ class App extends React.Component { // don't get esc in onKeyPress if (ev.keyCode === ESC_KEY_CODE) { this.props.dispatch(hitEsc()); + } else if (ev.keyCode === ENTER_KEY_CODE) { + this.props.dispatch(hitEnter()); } } diff --git a/client/app/scripts/components/search-item.js b/client/app/scripts/components/search-item.js new file mode 100644 index 000000000..518ffa11c --- /dev/null +++ b/client/app/scripts/components/search-item.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { unpinSearch } from '../actions/app-actions'; + +class SearchItem extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick(ev) { + ev.preventDefault(); + this.props.unpinSearch(this.props.query); + } + + render() { + return ( + + {this.props.query} + + + ); + } +} + +export default connect(null, { unpinSearch })(SearchItem); diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index 39691b74e..e0bb7e3c3 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -6,6 +6,7 @@ import _ from 'lodash'; import { blurSearch, doSearch, focusSearch } from '../actions/app-actions'; import { slugify } from '../utils/string-utils'; import { isTopologyEmpty } from '../utils/topology-utils'; +import SearchItem from './search-item'; // dynamic hint based on node names function getHint(nodes) { @@ -18,12 +19,20 @@ function getHint(nodes) { label = node.get('label'); if (node.get('metadata')) { const metadataField = node.get('metadata').first(); - metadataLabel = slugify(metadataField.get('label')).toLowerCase(); - metadataValue = metadataField.get('value').toLowerCase(); + metadataLabel = slugify(metadataField.get('label')) + .toLowerCase() + .split(' ')[0] + .split('.').pop() + .substr(0, 20); + metadataValue = metadataField.get('value') + .toLowerCase() + .split(' ')[0] + .substr(0, 12); } } - return `Try "${label}" or "${metadataLabel}:${metadataValue}".`; + return `Try "${label}" or "${metadataLabel}:${metadataValue}". + Hit enter to apply the search as a filter.`; } class Search extends React.Component { @@ -65,14 +74,17 @@ class Search extends React.Component { } render() { - const inputId = this.props.inputId || 'search'; - const disabled = this.props.isTopologyEmpty || !this.props.topologiesLoaded; - const matchCount = this.props.searchNodeMatches + const { inputId = 'search', nodes, pinnedSearches, searchFocused, + searchNodeMatches, topologiesLoaded } = this.props; + const disabled = this.props.isTopologyEmpty || !topologiesLoaded; + const matchCount = searchNodeMatches .reduce((count, topologyMatches) => count + topologyMatches.size, 0); + const showPinnedSearches = pinnedSearches.size > 0; const classNames = cx('search', { + 'search-pinned': showPinnedSearches, 'search-matched': matchCount, 'search-filled': this.state.value, - 'search-focused': this.props.searchFocused, + 'search-focused': searchFocused, 'search-disabled': disabled }); const title = matchCount ? `${matchCount} matches` : null; @@ -81,16 +93,22 @@ class Search extends React.Component {
+ + + {showPinnedSearches && + {pinnedSearches.toIndexedSeq() + .map(query => )} + } -
-
{getHint(this.props.nodes)}
+ {!showPinnedSearches &&
+ {getHint(nodes)} +
}
); @@ -101,6 +119,7 @@ export default connect( state => ({ nodes: state.get('nodes'), isTopologyEmpty: isTopologyEmpty(state), + pinnedSearches: state.get('pinnedSearches'), searchFocused: state.get('searchFocused'), searchQuery: state.get('searchQuery'), searchNodeMatches: state.get('searchNodeMatches'), diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 731b675ba..18e42a69f 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -1,6 +1,7 @@ import _ from 'lodash'; const ACTION_TYPES = [ + 'ADD_QUERY_FILTER', 'BLUR_SEARCH', 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', @@ -28,7 +29,9 @@ const ACTION_TYPES = [ 'LEAVE_EDGE', 'LEAVE_NODE', 'PIN_METRIC', + 'PIN_SEARCH', 'UNPIN_METRIC', + 'UNPIN_SEARCH', 'OPEN_WEBSOCKET', 'RECEIVE_CONTROL_NODE_REMOVED', 'RECEIVE_CONTROL_PIPE', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index c785aaf75..062e92e89 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -5,7 +5,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, import ActionTypes from '../constants/action-types'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { updateNodeMatches } from '../utils/search-utils'; +import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils'; import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; @@ -55,6 +55,7 @@ export const initialState = makeMap({ // allows us to keep the same metric "type" selected when the topology changes. pinnedMetricType: null, plugins: makeList(), + pinnedSearches: makeList(), // list of node filters routeSet: false, searchFocused: false, searchNodeMatches: makeMap(), @@ -398,6 +399,13 @@ export function rootReducer(state = initialState, action) { return state.set('searchFocused', true); } + case ActionTypes.PIN_SEARCH: { + state = state.set('searchQuery', ''); + const pinnedSearches = state.get('pinnedSearches'); + state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query); + return applyPinnedSearches(state); + } + case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { return closeNodeDetails(state, action.nodeId); } @@ -476,6 +484,9 @@ export function rootReducer(state = initialState, action) { state = state.setIn(['nodes', node.id], fromJS(makeNode(node))); }); + // apply pinned searches, filters nodes that dont match + state = applyPinnedSearches(state); + state = state.set('availableCanvasMetrics', state.get('nodes') .valueSeq() .flatMap(n => (n.get('metrics') || makeList()).map(m => ( @@ -578,6 +589,12 @@ export function rootReducer(state = initialState, action) { return state; } + case ActionTypes.UNPIN_SEARCH: { + const pinnedSearches = state.get('pinnedSearches').filter(query => query !== action.query); + state = state.set('pinnedSearches', pinnedSearches); + return applyPinnedSearches(state); + } + default: { return state; } diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index e5b7ba26e..88f9b88bf 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -31,7 +31,7 @@ function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { return nodeMatches; } -function searchTopology(nodes, prefix, query) { +export function searchTopology(nodes, { prefix, query }) { let nodeMatches = makeMap(); nodes.forEach((node, nodeId) => { // top level fields @@ -67,24 +67,36 @@ function searchTopology(nodes, prefix, query) { return nodeMatches; } +export function parseQuery(query) { + if (query) { + const prefixQuery = query.split(PREFIX_DELIMITER); + const isPrefixQuery = prefixQuery && prefixQuery.length === 2; + const valid = !isPrefixQuery || prefixQuery.every(s => s); + if (valid) { + let prefix = null; + if (isPrefixQuery) { + prefix = prefixQuery[0]; + query = prefixQuery[1]; + } + return { + query, + prefix + }; + } + } + return null; +} + /** * Returns {topologyId: {nodeId: matches}} */ export function updateNodeMatches(state) { - let query = state.get('searchQuery'); - const prefixQuery = query && query.split(PREFIX_DELIMITER); - const isPrefixQuery = prefixQuery && prefixQuery.length === 2; - const isValidPrefixQuery = isPrefixQuery && prefixQuery.every(s => s); - - if (query && (isPrefixQuery === isValidPrefixQuery)) { - const prefix = isValidPrefixQuery ? prefixQuery[0] : null; - if (isPrefixQuery) { - query = prefixQuery[1]; - } + const parsed = parseQuery(state.get('searchQuery')); + if (parsed) { state.get('topologyUrlsById').forEach((url, topologyId) => { const topologyNodes = state.getIn(['nodesByTopology', topologyId]); if (topologyNodes) { - const nodeMatches = searchTopology(topologyNodes, prefix, query); + const nodeMatches = searchTopology(topologyNodes, parsed); state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); } }); @@ -94,3 +106,30 @@ export function updateNodeMatches(state) { return state; } + +/** + * Set `filtered:true` in state's nodes if a pinned search matches + */ +export function applyPinnedSearches(state) { + // clear old filter state + state = state.update('nodes', + nodes => nodes.map(node => node.set('filtered', false))); + + const pinnedSearches = state.get('pinnedSearches'); + if (pinnedSearches.size > 0) { + state.get('pinnedSearches').forEach(query => { + const parsed = parseQuery(query); + if (parsed) { + const nodeMatches = searchTopology(state.get('nodes'), parsed); + const filteredNodes = state.get('nodes') + .map(node => node.set('filtered', + node.get('filtered') // matched by previous pinned search + || nodeMatches.size === 0 // no match, filter all nodes + || !nodeMatches.has(node.get('id')))); // filter matches + state = state.set('nodes', filteredNodes); + } + }); + } + + return state; +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 43db6a9cc..b6ce58573 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -1213,7 +1213,7 @@ h2 { &-hint { font-size: 0.7rem; position: absolute; - padding: 0 1em 0 3em; + padding: 0 1em; color: @text-tertiary-color; top: 0; opacity: 0; @@ -1226,89 +1226,76 @@ h2 { background: #fff; position: relative; z-index: 1; - display: inline-block; + display: flex; border-radius: @border-radius; width: 100%; border: 1px solid transparent; + padding: 2px 4px; + text-align: left; + + &-items { + padding: 2px 4px; + } &-field { font-size: 0.8rem; line-height: 150%; position: relative; - display: block; - float: right; - padding: 4px 8px 4px 32px; - width: 100%; + padding: 1px 4px 1px 0.75em; border: none; border-radius: 0; background: transparent; color: @text-color; + flex: 1; &:focus { outline: none; } } + &-icon { + position: relative; + width: 1.285em; + text-align: center; + color: @text-secondary-color; + position: relative; + top: 4px; + left: 4px; + } + &-label { user-select: none; display: inline-block; - float: right; - padding: 0 0.75em; + padding: 2px 1em; font-size: 0.8rem; position: absolute; - top: -10px; - width: 100%; text-align: left; pointer-events: none; color: @text-secondary-color; - - &-icon { - top: 10px; - position: relative; - width: 1.285em; - text-align: center; - color: @text-secondary-color; - transition: opacity 0.3s 0.4s @base-ease; - opacity: 0; - display: inline-block; - } - - &-text { - color: @text-secondary-color; - text-align: left; - padding: 4px 0; - top: 10px; - position: relative; - left: -1.2em; - transition: opacity 0.3s 0.5s @base-ease; - opacity: 1; - display: inline-block; - text-transform: uppercase; - } + text-transform: uppercase; + transition: opacity 0.3s 0.5s @base-ease; + opacity: 1; } } - &-focused &-input-label-icon, - &-filled &-input-label-icon { - transition: opacity 0.3s 0s @base-ease; - opacity: 1; - } - - &-focused &-input-label-text, - &-filled &-input-label-text { + &-focused &-input-label, + &-pinned &-input-label, + &-filled &-input-label { transition: opacity 0.1s 0s @base-ease; opacity: 0; } &-focused &-hint, - &-filled &-hint { + &-filled &-hint, + &-pinned &-hint { opacity: 1; transform: translate3d(0, 2.75em, 0); transition: transform 0.3s 0.3s @base-ease, opacity 0.3s 0.3s @base-ease; } &-focused, - &-filled { + &-filled, + &-pinned { width: 100%; } @@ -1318,6 +1305,25 @@ h2 { } +.search-item { + background-color: fade(@weave-blue, 20%); + border-radius: @border-radius / 2; + margin-left: 4px; + + &-label { + padding: 2px 4px; + } + + &-icon { + .btn-opacity; + padding: 2px 4px 2px 2px; + cursor: pointer; + font-size: 80%; + position: relative; + top: -1px; + } +} + @keyframes focusing { 0% { opacity: 0;