Apply search as filter

This commit is contained in:
David Kaltschmidt
2016-05-04 12:22:50 +02:00
parent 3ee802a516
commit d1609658bf
11 changed files with 231 additions and 87 deletions

View File

@@ -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();

View File

@@ -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 (
<g className="nodes-chart-edges">

View File

@@ -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;

View File

@@ -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)

View File

@@ -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());
}
}

View File

@@ -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 (
<span className="search-item">
<span className="search-item-label">{this.props.query}</span>
<span className="search-item-icon fa fa-close" onClick={this.handleClick} />
</span>
);
}
}
export default connect(null, { unpinSearch })(SearchItem);

View File

@@ -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 {
<div className="search-wrapper">
<div className={classNames} title={title}>
<div className="search-input">
<i className="fa fa-search search-input-icon"></i>
<label className="search-input-label" htmlFor={inputId}>
Search
</label>
{showPinnedSearches && <span className="search-input-items">
{pinnedSearches.toIndexedSeq()
.map(query => <SearchItem query={query} key={query} />)}
</span>}
<input className="search-input-field" type="text" id={inputId}
value={this.state.value} onChange={this.handleChange}
onBlur={this.handleBlur} onFocus={this.handleFocus}
disabled={disabled} />
<label className="search-input-label" htmlFor={inputId}>
<i className="fa fa-search search-input-label-icon"></i>
<span className="search-input-label-text">Search</span>
</label>
</div>
<div className="search-hint">{getHint(this.props.nodes)}</div>
{!showPinnedSearches && <div className="search-hint">
{getHint(nodes)}
</div>}
</div>
</div>
);
@@ -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'),

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;