feat(ui): filter history menu

This commit is contained in:
Łukasz Mierzwa
2018-07-07 22:41:29 +02:00
parent a8116dfcf5
commit e8fcaf1e27
8 changed files with 292 additions and 1 deletions

45
ui/package-lock.json generated
View File

@@ -2332,6 +2332,15 @@
"object-assign": "4.1.1"
}
},
"create-react-context": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz",
"integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==",
"requires": {
"fbjs": "0.8.17",
"gud": "1.0.0"
}
},
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -4789,6 +4798,11 @@
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE="
},
"gud": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
"integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
},
"gzip-size": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-3.0.0.tgz",
@@ -8106,6 +8120,11 @@
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz",
"integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow=="
},
"popper.js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.3.tgz",
"integrity": "sha1-FDj5jQRqz3tNeM1QK/QYrGTU8JU="
},
"portfinder": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.13.tgz",
@@ -9652,6 +9671,19 @@
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-0.7.9.tgz",
"integrity": "sha512-JpHQgpB+p3oqZv583xlJakPXgqdIjGBZvngHYcFhlYxTYs0NDZSdoM1FCpU9T2QmqKOEFBVuhhJBMvJzTkF/wQ=="
},
"react-popper": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.0.0.tgz",
"integrity": "sha1-uZRSFE6P5KzHf6PZWajHngemUIQ=",
"requires": {
"babel-runtime": "6.26.0",
"create-react-context": "0.2.2",
"popper.js": "1.14.3",
"prop-types": "15.6.2",
"typed-styles": "0.0.5",
"warning": "3.0.0"
}
},
"react-resize-detector": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-3.1.0.tgz",
@@ -11414,6 +11446,11 @@
"mime-types": "2.1.18"
}
},
"typed-styles": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.5.tgz",
"integrity": "sha512-ht+rEe5UsdEBAa3gr64+QjUOqjOLJfWLvl5HZR5Ev9uo/OnD3p43wPeFSB1hNFc13GXQF/JU1Bn0YHLUqBRIlw=="
},
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -11764,6 +11801,14 @@
"makeerror": "1.0.11"
}
},
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"requires": {
"loose-envify": "1.3.1"
}
},
"watch": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/watch/-/watch-0.10.0.tgz",

View File

@@ -27,6 +27,7 @@
"react-linkify": "^0.2.2",
"react-masonry-infinite": "^1.2.2",
"react-moment": "^0.7.9",
"react-popper": "^1.0.0",
"react-resize-detector": "^3.0.1",
"react-scripts": "1.1.4",
"react-transition-group": "^2.4.0",

View File

@@ -0,0 +1,4 @@
.badge.components-label-history {
/* fix align after text-truncate */
vertical-align: middle;
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { observer } from "mobx-react";
import { BaseLabel } from "Components/Labels/BaseLabel";
import "./index.css";
const HistoryLabel = observer(
class HistoryLabel extends BaseLabel {
render() {
const { name, value } = this.props;
return (
<span
className={`components-label components-label-history text-nowrap text-truncate badge badge-${this.getColorClass(
name,
value
)} mw-100`}
style={this.getColorStyle(name, value)}
>
{name}: {value}
</span>
);
}
}
);
export { HistoryLabel };

View File

@@ -0,0 +1,197 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { action, observable, toJS } from "mobx";
import { observer } from "mobx-react";
import { localStored } from "mobx-stored";
import { Manager, Reference, Popper } from "react-popper";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown } from "@fortawesome/free-solid-svg-icons/faCaretDown";
import { AlertStore } from "Stores/AlertStore";
import { HistoryLabel } from "Components/Labels/HistoryLabel";
const defaultHistory = {
filters: []
};
// takes a filter object out of alertStore.history.values and creates a new
// object with only those keys that will be stored in history
function reduceFilter(filter) {
return {
raw: filter.raw,
name: filter.name,
matcher: filter.matcher,
value: filter.value
};
}
const HistoryMenu = ({
popperPlacement,
popperRef,
popperStyle,
filters,
alertStore,
afterClick
}) => {
return (
<div
className="dropdown-menu d-block components-navbar-historymenu"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
{filters.length === 0 ? (
<h6 className="dropdown-header text-muted text-center">Empty</h6>
) : (
filters.map(historyFilters => (
<button
className="dropdown-item cursor-pointer px-3"
key={JSON.stringify(historyFilters.map(f => f.raw))}
onClick={() => {
alertStore.filters.setFilters(historyFilters.map(f => f.raw));
afterClick();
}}
>
<div className="components-navbar-historymenu-labels pl-2">
{historyFilters.map(f => (
<HistoryLabel
key={f.raw}
alertStore={alertStore}
name={f.name}
value={f.value}
/>
))}
</div>
</button>
))
)}
</div>
);
};
HistoryMenu.propTypes = {
popperPlacement: PropTypes.string,
popperRef: PropTypes.func,
popperStyle: PropTypes.object,
filters: PropTypes.array.isRequired,
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
afterClick: PropTypes.func.isRequired
};
const History = observer(
class History extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired
};
// how many filter sets do we store in local storage and render in the
// dropdown menu
maxSize = 8;
// this will be dumped to local storage via mbox-stored
history = localStored("history.filters", defaultHistory, { delay: 100 });
collapse = observable(
{
value: true,
toggle() {
this.value = !this.value;
},
hide() {
this.value = true;
}
},
{ toggle: action.bound, hide: action.bound },
{ name: "History menu toggle" }
);
appendToHistory = action(() => {
const { alertStore } = this.props;
// we don't store unapplied (we only have raw text for those, we need
// name & value for coloring) or invalid filters
const validAppliedFilters = alertStore.filters.values
.filter(f => f.applied === true && f.isValid === true)
.map(f => reduceFilter(f));
// don't store empty filters in history
if (validAppliedFilters.length === 0) return;
// make a JSON dump for comparing later with what's already stored
const filtersJSON = JSON.stringify(validAppliedFilters);
// dump observable array with stored filters to JS objects, without this
// we'll be passing around and comparing proxy objects that might mutate
// while we do so
const storedFilters = toJS(this.history.filters);
// rewrite history putting current filter set on top, this will move
// it up if user selects a filter set that was already in history
let newHistory = [
...[validAppliedFilters],
...storedFilters.filter(f => JSON.stringify(f) !== filtersJSON)
].slice(0, this.maxSize);
this.history.filters = newHistory;
});
componentDidUpdate() {
// every time this component updates we will rewrite history
// (if there are changes)
this.appendToHistory();
}
render() {
const { alertStore } = this.props;
return (
// data-filters is there to register filters for observation in mobx
// it needs to be using full filter object to notice changes to
// name & value but ignore hits
// using it this way will force re-render on every change, which is
// needed to keep track of all filter changes
<Manager
data-filters={alertStore.filters.values
.map(f => reduceFilter(f))
.join(" ")}
>
<Reference>
{({ ref }) => (
<button
ref={ref}
onClick={this.collapse.toggle}
className="btn btn-light dropdown-toggle rounded-right"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="true"
>
<FontAwesomeIcon icon={faCaretDown} />
</button>
)}
</Reference>
<Popper
placement="top"
modifiers={{
arrow: { enabled: false }
}}
>
{this.collapse.value
? ({ placement, ref, style }) => <span />
: ({ placement, ref, style }) => (
<HistoryMenu
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
filters={this.history.filters}
alertStore={alertStore}
afterClick={this.collapse.hide}
/>
)}
</Popper>
</Manager>
);
}
}
);
export { History };

View File

@@ -11,3 +11,15 @@
padding: 0;
vertical-align: middle;
}
.dropdown-menu.components-navbar-historymenu {
max-width: 400px;
}
.dropdown-menu.components-navbar-historymenu > .dropdown-item {
white-space: normal;
}
.components-navbar-historymenu-labels {
border-left: 3px solid;
}

View File

@@ -16,6 +16,7 @@ import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch";
import { AlertStore, FormatUnseeBackendURI } from "Stores/AlertStore";
import { FilterInputLabel } from "Components/Labels/FilterInputLabel";
import { AutosuggestTheme } from "./Constants";
import { History } from "./History";
import "./index.css";
@@ -124,6 +125,9 @@ const FilterInput = observer(
{...otherProps}
/>
</div>
<div className="input-group-append">
<History alertStore={alertStore} />
</div>
</div>
);
};

View File

@@ -9,7 +9,7 @@ import { App } from "./App";
if (process.env.NODE_ENV === "development") {
const { whyDidYouUpdate } = require("why-did-you-update");
whyDidYouUpdate(React, {
exclude: [/^Linkify$/, /^CSSTransition$/, /^inject-/]
exclude: [/^Linkify$/, /^CSSTransition$/, /^inject-/, /^InnerReference$/]
});
}