Add a new search section to the help popover

This commit is contained in:
Simon Howe
2016-10-12 17:35:26 +02:00
parent 1d3ae9576d
commit db5baabeee
6 changed files with 331 additions and 55 deletions

View File

@@ -1,4 +1,10 @@
import React from 'react';
import { connect } from 'react-redux';
import { searchableFieldsSelector } from '../selectors/chartSelectors';
import { CANVAS_MARGINS } from '../constants/styles';
import { hideHelp } from '../actions/app-actions';
const GENERAL_SHORTCUTS = [
{key: 'esc', label: 'Close active panel'},
@@ -8,17 +14,19 @@ const GENERAL_SHORTCUTS = [
{key: 'g', label: 'Toggle Graph mode'},
];
const CANVAS_METRIC_SHORTCUTS = [
{key: '<', label: 'Select and pin previous metric'},
{key: '>', label: 'Select and pin next metric'},
{key: 'q', label: 'Unpin current metric'},
];
function renderShortcuts(cuts) {
return (
<div>
{cuts.map(({key, label}) => (
<div key={key} className="help-panel-shortcut">
<div key={key} className="help-panel-shortcuts-shortcut">
<div className="key"><kbd>{key}</kbd></div>
<div className="label">{label}</div>
</div>
@@ -27,20 +35,135 @@ function renderShortcuts(cuts) {
);
}
export default class HelpPanel extends React.Component {
render() {
return (
<div className="help-panel">
<div className="help-panel-header">
<h2>Keyboard Shortcuts</h2>
function renderShortcutPanel() {
return (
<div className="help-panel-shortcuts">
<h2>Shortcuts</h2>
<h3>General</h3>
{renderShortcuts(GENERAL_SHORTCUTS)}
<h3>Canvas Metrics</h3>
{renderShortcuts(CANVAS_METRIC_SHORTCUTS)}
</div>
);
}
const BASIC_SEARCHES = [
{term: 'foo', label: 'All fields for foo'},
{term: 'pid: 12345', label: 'Any field matching pid for the value 12345'},
];
const REGEX_SEARCHES = [
{term: 'foo|bar', label: 'All fields for foo or bar'},
{term: 'command: foo(bar|baz)', label: 'Command field for foobar or foobaz'},
];
const METRIC_SEARCHES = [
{term: 'cpu > 4%', label: 'CPU greater than 4%'},
{term: 'memory < 10mb', label: 'memory less than 4mb'},
];
function renderSearches(searches) {
return (
<div>
{searches.map(({term, label}) => (
<div key={term} className="help-panel-search-row">
<div className="help-panel-search-row-term">
<i className="fa fa-search search-label-icon"></i>
{term}
</div>
<div className="help-panel-search-row-term-label">{label}</div>
</div>
<div className="help-panel-main">
<h3>General</h3>
{renderShortcuts(GENERAL_SHORTCUTS)}
<h3>Canvas Metrics</h3>
{renderShortcuts(CANVAS_METRIC_SHORTCUTS)}
))}
</div>
);
}
function renderSearchPanel() {
return (
<div className="help-panel-search">
<h2>Search</h2>
<h3>Basics</h3>
{renderSearches(BASIC_SEARCHES)}
<h3>Regular expressions</h3>
{renderSearches(REGEX_SEARCHES)}
<h3>Metrics</h3>
{renderSearches(METRIC_SEARCHES)}
</div>
);
}
function renderFieldsPanel(currentTopologyName, searchableFields) {
const none = <span style={{fontStyle: 'italic'}}>None</span>;
return (
<div className="help-panel-fields">
<h2>Fields and Metrics</h2>
<p>
Searchable fields and metrics in the <br />
currently selected <span className="help-panel-fields-current-topology">
{currentTopologyName}</span> topology:
</p>
<div className="help-panel-fields-fields">
<div className="help-panel-fields-fields-column">
<h3>Fields</h3>
<div className="help-panel-fields-fields-column-content">
{searchableFields.get('fields').map(f => (
<div key={f}>{f}</div>
))}
{searchableFields.get('fields').size === 0 && none}
</div>
</div>
<div className="help-panel-fields-fields-column">
<h3>Metrics</h3>
<div className="help-panel-fields-fields-column-content">
{searchableFields.get('metrics').map(m => (
<div key={m}>{m}</div>
))}
{searchableFields.get('metrics').size === 0 && none}
</div>
</div>
</div>
);
}
</div>
);
}
function HelpPanel({currentTopologyName, searchableFields, onClickClose}) {
return (
<div className="help-panel-wrapper">
<div className="help-panel" style={{marginTop: CANVAS_MARGINS.top}}>
<div className="help-panel-header">
<h2>Help</h2>
</div>
<div className="help-panel-main">
{renderShortcutPanel()}
{renderSearchPanel()}
{renderFieldsPanel(currentTopologyName, searchableFields)}
</div>
<div className="help-panel-tools">
<span title="Close details" className="fa fa-close" onClick={onClickClose} />
</div>
</div>
</div>
);
}
function mapStateToProps(state) {
return {
searchableFields: searchableFieldsSelector(state),
currentTopologyName: state.getIn(['currentTopology', 'fullName'])
};
}
export default connect(mapStateToProps, { onClickClose: hideHelp })(HelpPanel);

View File

@@ -4,11 +4,12 @@ import { connect } from 'react-redux';
import classnames from 'classnames';
import _ from 'lodash';
import { blurSearch, doSearch, focusSearch } from '../actions/app-actions';
import { blurSearch, doSearch, focusSearch, showHelp } from '../actions/app-actions';
import { slugify } from '../utils/string-utils';
import { isTopologyEmpty } from '../utils/topology-utils';
import SearchItem from './search-item';
function shortenHintLabel(text) {
return text
.split(' ')[0]
@@ -16,6 +17,7 @@ function shortenHintLabel(text) {
.substr(0, 12);
}
// dynamic hint based on node names
function getHint(nodes) {
let label = 'mycontainer';
@@ -38,6 +40,7 @@ function getHint(nodes) {
Hit enter to apply the search as a filter.`;
}
class Search extends React.Component {
constructor(props, context) {
@@ -95,7 +98,7 @@ class Search extends React.Component {
render() {
const { inputId = 'search', nodes, pinnedSearches, searchFocused,
searchNodeMatches, searchQuery, topologiesLoaded } = this.props;
searchNodeMatches, searchQuery, topologiesLoaded, onClickHelp } = this.props;
const disabled = this.props.isTopologyEmpty;
const matchCount = searchNodeMatches
.reduce((count, topologyMatches) => count + topologyMatches.size, 0);
@@ -130,7 +133,9 @@ class Search extends React.Component {
</label>
</div>
{!showPinnedSearches && <div className="search-hint">
{getHint(nodes)}
{getHint(nodes)} <span className="search-help-link" onClick={onClickHelp}>
Help!
</span>
</div>}
</div>
</div>
@@ -138,6 +143,7 @@ class Search extends React.Component {
}
}
export default connect(
state => ({
nodes: state.get('nodes'),
@@ -148,5 +154,5 @@ export default connect(
searchNodeMatches: state.get('searchNodeMatches'),
topologiesLoaded: state.get('topologiesLoaded')
}),
{ blurSearch, doSearch, focusSearch }
{ blurSearch, doSearch, focusSearch, onClickHelp: showHelp }
)(Search);

View File

@@ -3,6 +3,7 @@ import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect'
import { Map as makeMap, is, Set } from 'immutable';
import { getAdjacentNodes } from '../utils/topology-utils';
import { getSearchableFields } from '../utils/search-utils';
const log = debug('scope:selectors');
@@ -95,6 +96,12 @@ export const dataNodesSelector = createSelector(
);
export const searchableFieldsSelector = createSelector(
allNodesSelector,
getSearchableFields
);
//
// FIXME: this is a bit of a hack...
//

View File

@@ -1,4 +1,4 @@
import { Map as makeMap } from 'immutable';
import { Map as makeMap, Set as makeSet, List as makeList } from 'immutable';
import _ from 'lodash';
import { slugify } from './string-utils';
@@ -247,6 +247,40 @@ export function updateNodeMatches(state) {
return state;
}
export function getSearchableFields(nodes) {
const get = (node, key) => node.get(key) || makeList();
const baseLabels = makeSet(nodes.size > 0 ? SEARCH_FIELDS.valueSeq() : []);
const metadataLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'metadata').map(f => f.get('label')))
), makeSet());
const parentLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'parents').map(p => p.get('topologyId')))
), makeSet());
const tableRowLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'tables').flatMap(t => (t.get('rows') || makeList)
.map(f => f.get('label'))
))
), makeSet());
const metricLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'metrics').map(f => f.get('label')))
), makeSet());
return makeMap({
fields: baseLabels.union(metadataLabels, parentLabels, tableRowLabels)
.map(slugify)
.toList()
.sort(),
metrics: metricLabels.toList().map(slugify).sort()
});
}
/**
* Set `filtered:true` in state's nodes if a pinned search matches
*/

View File

@@ -65,7 +65,7 @@ export const formatMetric = makeFormatMetric(renderHtml);
export const formatMetricSvg = makeFormatMetric(renderSvg);
export const formatDate = d3.time.format.iso;
const CLEAN_LABEL_REGEX = /\W/g;
const CLEAN_LABEL_REGEX = /[^A-Za-z]/g;
export function slugify(label) {
return label.replace(CLEAN_LABEL_REGEX, '').toLowerCase();
}

View File

@@ -1339,6 +1339,16 @@ h2 {
text-align: left;
}
&-help-link {
cursor: pointer;
font-weight: bold;
text-transform: uppercase;
&:hover {
text-decoration: underline;
}
}
&-label {
position: absolute;
pointer-events: none;
@@ -1482,62 +1492,158 @@ h2 {
// Help panel!
//
@help-panel-width: 400px;
@help-panel-height: 420px;
.help-panel {
position: absolute;
-webkit-transform: translate3d(0, 0, 0);
top: 50%;
left: 50%;
width: @help-panel-width;
height: @help-panel-height;
margin-left: @help-panel-width / -2;
margin-top: @help-panel-height / -2;
z-index: 2048;
background-color: white;
.shadow-2;
display: flex;
position: relative;
&-wrapper {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
}
&-header {
background-color: @weave-blue;
padding: 36px;
padding: 12px 24px;
color: white;
h2 {
margin: 0;
text-transform: uppercase;
font-size: 125%;
}
}
&-tools {
position: absolute;
top: 6px;
right: 8px;
span {
.btn-opacity;
padding: 4px 5px;
margin-left: 2px;
font-size: 110%;
color: #8383ac;
cursor: pointer;
border: 1px solid rgba(131, 131, 172, 0);
border-radius: 10%;
&:hover {
border-color: rgba(131, 131, 172, 0.6);
}
}
}
&-main {
padding: 12px 36px 36px;
padding: 12px 36px 36px 36px;
display: flex;
flex-direction: row;
align-items: stretch;
h2 {
text-transform: uppercase;
line-height: 150%;
font-size: 125%;
color: #8383ac;
padding: 4px 0;
border-bottom: 1px solid rgba(131, 131, 172, 0.1);
}
h3 {
text-transform: uppercase;
font-size: 90%;
color: #8383ac;
padding: 4px 0;
}
p {
margin: 0;
}
}
h3 {
text-transform: uppercase;
font-size: 90%;
color: #8383ac;
padding: 4px 0;
&-shortcuts {
margin-right: 36px;
&-shortcut {
kbd {
display: inline-block;
padding: 3px 5px;
font-size: 11px;
line-height: 10px;
color: #555;
vertical-align: middle;
background-color: #fcfcfc;
border: solid 1px #ccc;
border-bottom-color: #bbb;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #bbb;
}
div.key {
width: 80px;
display: inline-block;
}
div.label {
display: inline-block;
}
}
}
&-shortcut {
kbd {
display: inline-block;
padding: 3px 5px;
font-size: 11px;
line-height: 10px;
color: #555;
vertical-align: middle;
background-color: #fcfcfc;
border: solid 1px #ccc;
border-bottom-color: #bbb;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #bbb;
&-search {
margin-right: 36px;
&-row {
display: flex;
flex-direction: row;
&-term {
flex: 1;
color: #5b5b88;
}
&-term-label {
flex: 1;
}
}
div.key {
width: 100px;
display: inline-block;
}
&-fields {
display: flex;
flex-direction: column;
&-current-topology {
text-transform: uppercase;
color: #8383ac;
}
div.label {
display: inline-block;
&-fields {
display: flex;
align-items: stretch;
&-column {
display: flex;
flex-direction: column;
flex: 1;
margin-right: 12px;
&-content {
overflow: auto;
// 160px for top and bottom margins and the rest of the help window
// is about 160px too.
// Notes: Firefox gets a bit messy if you try and bubble
// heights + overflow up (min-height issue + still doesn't work v.well),
// so this is a bit of a hack.
max-height: ~"calc(100vh - 160px - 160px - 160px)";
}
}
}
}
}