diff --git a/assets/static/__mocks__/localStorageMock.js b/assets/static/__mocks__/localStorageMock.js new file mode 100644 index 000000000..78e61825a --- /dev/null +++ b/assets/static/__mocks__/localStorageMock.js @@ -0,0 +1,21 @@ +class LocalStorageMock { + + constructor() { + this.store = {}; + } + + getItem(key) { + return this.store[key] || null; + } + + setItem(key, value) { + this.store[key] = value.toString(); + } + + clear() { + this.store = {}; + } + +} + +module.exports = new LocalStorageMock(); diff --git a/assets/static/__mocks__/templatesMock.js b/assets/static/__mocks__/templatesMock.js index 72aa4f95a..6aa69656f 100644 --- a/assets/static/__mocks__/templatesMock.js +++ b/assets/static/__mocks__/templatesMock.js @@ -9,6 +9,7 @@ function loadTemplates() { "modal.html", "silence.html", "summary.html", + "history.html", ]; templateFiles.forEach(function(filename){ var templatePath = path.join(__dirname, "../../templates/", filename); diff --git a/assets/static/__snapshots__/filters.test.js.snap b/assets/static/__snapshots__/filters.test.js.snap new file mode 100644 index 000000000..91b106da6 --- /dev/null +++ b/assets/static/__snapshots__/filters.test.js.snap @@ -0,0 +1,390 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`appended filtes should be present in history 1`] = ` +"
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`appended filtes should be present in history 2`] = ` +"
  • + + + default + + + + + default + + + +
  • + + + + + +
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`appended filtes should be present in history 3`] = ` +"
  • + + + default,bar + + + + + default + + + + bar + + + +
  • + + + + + +
  • + + + default + + + + + default + + + +
  • + + + + + +
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`appended filtes should be present in history 4`] = ` +"
  • + + + default,bar,@state=active + + + + + default + + + + bar + + + + @state=active + + + +
  • + + + + + +
  • + + + default,bar + + + + + default + + + + bar + + + +
  • + + + + + +
  • + + + default + + + + + default + + + +
  • + + + + + +
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`appended filtes should be present in history 5`] = ` +"
  • + + + default,bar,@state=active + + + + + default + + + + bar + + + + @state=active + + + +
  • + + + + + +
  • + + + default,bar + + + + + default + + + + bar + + + +
  • + + + + + +
  • + + + default + + + + + default + + + +
  • + + + + + +
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`appended filtes should be present in history 6`] = ` +"
  • + + + @state=active + + + + + @state=active + + + +
  • + + + + + +
  • + + + default,bar,@state=active + + + + + default + + + + bar + + + + @state=active + + + +
  • + + + + + +
  • + + + default,bar + + + + + default + + + + bar + + + +
  • + + + + + +
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; + +exports[`default filter should be in history after setting filter to foo 1`] = ` +"
  • + + +
  • + + + default + + + + + default + + + +
  • " +`; diff --git a/assets/static/base.css b/assets/static/base.css index 5721cc432..41c59024f 100644 --- a/assets/static/base.css +++ b/assets/static/base.css @@ -141,7 +141,7 @@ span.label-ts-span { /* make filter input take all the space it can */ div.filterbar { - width: 950px; + width: 905px; } div.filterbar > .input-group-addon.input-sm { width: 10px; @@ -186,12 +186,14 @@ div.bootstrap-tagsinput > .twitter-typeahead > input.tt-input { /* menus uses links with icons instead of buttons, fix focus color so the link doesn't stay colored after clicking */ +#history:focus, #help:focus, #settings:focus, #refresh:focus { color: #fff; } +#history:hover, #help:hover, #settings:hover, #refresh:hover { @@ -498,3 +500,10 @@ a[aria-expanded=false] .fa-chevron-down { .alert-static-elements { margin-bottom: 4px; } + +li.history-menu > a.history-menu-item { + padding-left: 10px; + padding-right: 4px; + padding-top: 2px; + padding-bottom: 6px; +} diff --git a/assets/static/filters.js b/assets/static/filters.js index 9fe8e5cea..79bac98bf 100644 --- a/assets/static/filters.js +++ b/assets/static/filters.js @@ -13,11 +13,16 @@ require("./bootstrap-tagsinput.less"); const autocomplete = require("./autocomplete"); const unsee = require("./unsee"); const querystring = require("./querystring"); +const templates = require("./templates"); var selectors = { filter: "#filter", - icon: "#filter-icon" + icon: "#filter-icon", + historyMenu: "#historyMenu" }; +var appendsEnabled = true; +var historyStorage; +const historyKey = "filterHistory"; function addBadge(text) { $.each($("span.tag"), function(i, tag) { @@ -51,6 +56,27 @@ function addFilter(text) { $(selectors.filter).tagsinput("add", text); } +function applyFilterList(filterList) { + // we need to add filters one by one, this would reload alerts on every + // add() so let's pause reloads and resume once we're done with updating + // filters + unsee.pause(); + // disable history appends as it would record each new filter in the + // history + appendsEnabled = false; + $(selectors.filter).tagsinput("removeAll"); + for (var i = 0; i < filterList.length; i++) { + $(selectors.filter).tagsinput("add", filterList[i]); + } + // enable everything again + appendsEnabled = true; + unsee.resume(); +} + +function clearFilters() { + $(selectors.filter).tagsinput("removeAll"); +} + function setUpdating() { // visual hint that alerts are reloaded due to filter change $(selectors.icon).removeClass("fa-search fa-pause").addClass("fa-circle-o-notch fa-spin"); @@ -64,17 +90,64 @@ function setPause() { $(selectors.icon).removeClass("fa-circle-o-notch fa-spin fa-search").addClass("fa-pause"); } +function renderHistory() { + var historicFilters = []; + + const currentFilterText = getFilters().join(","); + + const history = historyStorage.getItem(historyKey); + if (history) { + historicFilters = history.split("\n"); + } + + var historyMenuHTML = templates.renderTemplate("historyMenu", { + activeFilter: currentFilterText, + defaultFilter: $(selectors.filter).data("default-filter"), + savedFilter: Cookies.get("defaultFilter.v2"), + filters: historicFilters + }); + $(selectors.historyMenu).html(historyMenuHTML); +} + +function appendFilterToHistory(text) { + // require non empty text and enabled appends + if (!text || !appendsEnabled) return false; + + // final filter list we'll save to storage + var filterList = new Set([ text ]); + + // get current history list from storage and append it to our final list + // of filters, but avoid duplicates + const history = historyStorage.getItem(historyKey); + if (history) { + const historyArr = history.split("\n"); + for (var i = 0; i < historyArr.length; i++) { + filterList.add(historyArr[i]); + } + } + + // truncate the history to up to 11 elements + const filterListTrunc = Array.from(filterList).slice(0, 10); + + historyStorage.setItem(historyKey, filterListTrunc.join("\n")); +} + function setFilters() { setUpdating(); // update location so it's easy to share it querystring.update("q", getFilters().join(",")); + // append filter to the history and render it + appendFilterToHistory(getFilters().join(",")); + renderHistory(); + // reload alerts unsee.triggerReload(); } -function init() { +function init(historyStore) { + historyStorage = historyStore; var initialFilter; if ($(selectors.filter).data("default-used") == "false" || $(selectors.filter).data("default-used") === false) { @@ -141,10 +214,18 @@ function init() { $("body").css("padding-top", $(".navbar").height()); }); + renderHistory(); + $(selectors.historyMenu).on("click", "a.history-menu-item", function(event) { + var elem = $(event.target).parents("li.history-menu"); + const filtersList = elem.find(".rawFilter").text().trim().split(","); + applyFilterList(filtersList); + }); + } exports.init = init; exports.addFilter = addFilter; +exports.clearFilters = clearFilters; exports.setFilters = setFilters; exports.getFilters = getFilters; exports.addBadge = addBadge; @@ -152,3 +233,4 @@ exports.reloadBadges = reloadBadges; exports.updateDone = updateDone; exports.setUpdating = setUpdating; exports.setPause = setPause; +exports.renderHistory = renderHistory; diff --git a/assets/static/filters.test.js b/assets/static/filters.test.js index 14b52a818..0fe7ad582 100644 --- a/assets/static/filters.test.js +++ b/assets/static/filters.test.js @@ -1,7 +1,133 @@ +const $ = window.jQuery = require("jquery"); +const LocalStorageMock = require("./__mocks__/localStorageMock"); + test("filters addBadge()", () => { - // mock data attr with default filter - //document.body.innerHTML = "
    "; - window.jQuery = require("jquery"); const filters = require("./filters"); filters.addBadge("foo=bar"); }); + +test("default filter should be in history after setting filter to foo", () => { + LocalStorageMock.clear(); + document.body.innerHTML = + "
    " + + "
    " + + " " + + "
    " + + " " + + "
    " + + "
    "; + + const templatesMock = require("./__mocks__/templatesMock"); + document.body.innerHTML += templatesMock.loadTemplates(); + const templates = require("./templates"); + templates.init(); + + const autocomplete = require("./autocomplete"); + const filters = require("./filters"); + + autocomplete.init(); + filters.init(LocalStorageMock); + filters.setFilters(); + filters.renderHistory(); + + // use snapshot to check that generated HTML is what we expect + const historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + + // we set foo, so that what should be in history + expect(LocalStorageMock.getItem("filterHistory")).toBe("foo"); +}); + +test("appended filtes should be present in history", () => { + LocalStorageMock.clear(); + document.body.innerHTML = + "
    " + + "
    " + + " " + + "
    " + + " " + + "
    " + + "
    "; + + const templatesMock = require("./__mocks__/templatesMock"); + document.body.innerHTML += templatesMock.loadTemplates(); + const templates = require("./templates"); + templates.init(); + + const autocomplete = require("./autocomplete"); + const filters = require("./filters"); + + autocomplete.init(); + filters.init(LocalStorageMock); + filters.setFilters(); + filters.renderHistory(); + + // we only used default, so there should be a single (default) entry + let historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + // and that's what history should have + expect(LocalStorageMock.getItem("filterHistory")).toBe("default"); + + // now we append more filters, so q=default becomes q=default,bar + filters.addFilter("bar"); + filters.setFilters(); + // now we got non-default filter as active, so we should have 2 entries + // both for default (as recent and as global default) + historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + expect( + LocalStorageMock.getItem("filterHistory").split("\n") + ).toMatchObject( + [ "default,bar", "default" ] + ); + + // append another filter, so we now have: q=default,bar,@state=active + filters.addFilter("@state=active"); + filters.setFilters(); + // now we should have 3 entries, 2x default + default,bar + historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + expect( + LocalStorageMock.getItem("filterHistory").split("\n") + ).toMatchObject( + [ "default,bar,@state=active", "default,bar", "default" ] + ); + + // clear filters, so now we have: q= + filters.clearFilters(); + filters.setFilters(); + // now we should have 4 entries, 2x default + default,bar + default,bar,@state=active + historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + expect( + LocalStorageMock.getItem("filterHistory").split("\n") + ).toMatchObject( + [ "default,bar,@state=active", "default,bar", "default" ] + ); + + // now add a filter back, so now we have: q=@state=active + filters.addFilter("@state=active"); + filters.setFilters(); + // we should have same filters as before + historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + expect( + LocalStorageMock.getItem("filterHistory").split("\n") + ).toMatchObject( + [ "@state=active", "default,bar,@state=active", "default,bar", "default" ] + ); + + // as a last test add default back to have @state=active rendered + filters.clearFilters(); + filters.addFilter("default"); + filters.setFilters(); + // we should have same filters as before + historyMenu = $("#historyMenu").html().trim(); + expect(historyMenu).toMatchSnapshot(); + // default should move from the bottom to to top of the list + expect( + LocalStorageMock.getItem("filterHistory").split("\n") + ).toMatchObject( + [ "default", "@state=active", "default,bar,@state=active", "default,bar" ] + ); +}); diff --git a/assets/static/templates.js b/assets/static/templates.js index c2e8f605a..2035ed6cb 100644 --- a/assets/static/templates.js +++ b/assets/static/templates.js @@ -45,7 +45,11 @@ var templates = {}, alertGroupLabels: "#alert-group-labels", alertGroupElements: "#alert-group-elements", alertGroupSilence: "#alert-group-silence", - alertGroupLabelMap: "#alert-group-label-map" + alertGroupLabelMap: "#alert-group-label-map", + + // history dropdown + historyMenu: "#history-menu", + historyMenuItem: "#history-menu-item" }; function getConfig() { diff --git a/assets/static/unsee.js b/assets/static/unsee.js index 9157c25c2..49b5e1b9c 100644 --- a/assets/static/unsee.js +++ b/assets/static/unsee.js @@ -304,7 +304,7 @@ function setupPageVisibilityHandler() { } } -function init() { +function init(localStore) { progress.init(); config.init({ @@ -318,7 +318,7 @@ function init() { summary.init(); grid.init(); autocomplete.init(); - filters.init(); + filters.init(localStore); watchdog.init(30, 60*15); // set watchdog to 15 minutes $(selectors.refreshButton).click(function() { @@ -331,7 +331,7 @@ function init() { setupPageVisibilityHandler(); } -function onReady() { +function onReady(localStore) { // wrap all inits so we can handle errors try { // init all elements using bootstrapSwitch @@ -347,7 +347,7 @@ function onReady() { ui.setupModal(); silence.setupSilenceForm(); unsilence.init(); - init(); + init(localStore); // delay initial alert load to allow browser finish rendering setTimeout(function() { @@ -381,4 +381,6 @@ exports.flash = flash; exports.parseAJAXError = parseAJAXError; exports.onReady = onReady; -$(document).ready(onReady); +$(document).ready(function() { + onReady(window.localStorage); +}); diff --git a/assets/static/unsee.test.js b/assets/static/unsee.test.js index cb84b8fab..c24ceee71 100644 --- a/assets/static/unsee.test.js +++ b/assets/static/unsee.test.js @@ -1,6 +1,7 @@ const $ = require("jquery"); const templatesMock = require("./__mocks__/templatesMock"); const alertsMock = require("./__mocks__/alertsMock"); +const LocalStorageMock = require("./__mocks__/localStorageMock"); jest.useFakeTimers(); @@ -50,7 +51,7 @@ if (alertsServer) { require("bootstrap/js/popover.js"); alertsServer.start(); - unsee.onReady(); + unsee.onReady(LocalStorageMock); unsee.triggerReload(); jest.runOnlyPendingTimers(); // we should have 2 alerts diff --git a/assets/templates/history.html b/assets/templates/history.html new file mode 100644 index 000000000..55c8e9943 --- /dev/null +++ b/assets/templates/history.html @@ -0,0 +1,34 @@ + + + diff --git a/assets/templates/index.html b/assets/templates/index.html index 5c600fe72..b2eb79523 100644 --- a/assets/templates/index.html +++ b/assets/templates/index.html @@ -47,6 +47,13 @@