diff --git a/ui/package-lock.json b/ui/package-lock.json index 119e58ffa..a0f5956cb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index db2bd93c1..c9b3f7131 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/Components/Labels/HistoryLabel/index.css b/ui/src/Components/Labels/HistoryLabel/index.css new file mode 100644 index 000000000..0e258c6bb --- /dev/null +++ b/ui/src/Components/Labels/HistoryLabel/index.css @@ -0,0 +1,4 @@ +.badge.components-label-history { + /* fix align after text-truncate */ + vertical-align: middle; +} diff --git a/ui/src/Components/Labels/HistoryLabel/index.js b/ui/src/Components/Labels/HistoryLabel/index.js new file mode 100644 index 000000000..5b0c52cba --- /dev/null +++ b/ui/src/Components/Labels/HistoryLabel/index.js @@ -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 ( + + {name}: {value} + + ); + } + } +); + +export { HistoryLabel }; diff --git a/ui/src/Components/NavBar/FilterInput/History.js b/ui/src/Components/NavBar/FilterInput/History.js new file mode 100644 index 000000000..98135c87d --- /dev/null +++ b/ui/src/Components/NavBar/FilterInput/History.js @@ -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 ( +
+ {filters.length === 0 ? ( +
Empty
+ ) : ( + filters.map(historyFilters => ( + + )) + )} +
+ ); +}; +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 + reduceFilter(f)) + .join(" ")} + > + + {({ ref }) => ( + + )} + + + {this.collapse.value + ? ({ placement, ref, style }) => + : ({ placement, ref, style }) => ( + + )} + + + ); + } + } +); + +export { History }; diff --git a/ui/src/Components/NavBar/FilterInput/index.css b/ui/src/Components/NavBar/FilterInput/index.css index b94387790..ec22f61d8 100644 --- a/ui/src/Components/NavBar/FilterInput/index.css +++ b/ui/src/Components/NavBar/FilterInput/index.css @@ -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; +} diff --git a/ui/src/Components/NavBar/FilterInput/index.js b/ui/src/Components/NavBar/FilterInput/index.js index 30b636083..191c7a878 100644 --- a/ui/src/Components/NavBar/FilterInput/index.js +++ b/ui/src/Components/NavBar/FilterInput/index.js @@ -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} /> +
+ +
); }; diff --git a/ui/src/index.js b/ui/src/index.js index 56a5489f9..c1947da45 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -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$/] }); }