From 833bd6cee802c69295e97a167566b9416be23e63 Mon Sep 17 00:00:00 2001 From: leio10 Date: Thu, 27 Feb 2020 17:38:36 +0100 Subject: [PATCH] Fix use of browse history with filters (#5749) * Fix bug on #5654 Parent checkbox updating was failing * Add CheckBoxesTree component to Decidim exports * Fix search updating when using the browser history * Push initial filters state to URL to allow returning back to that page * Additional tests * Changelog entry added * Move delayed function to a separate file Triggered by "Too long file" linting message * Move check_boxes_tree related code to the component Triggered by "Too long file" linting message * Avoid unnecesary lets * Fix and complete JS tests * Fix lint issues * Fix rubocop issue * Undo 71ef93ac and fix bug on #5654 The problem was caused because scopes and categories trees where different. * Use history state to store initial filters, instead of using URL Changing the initial URL breaks too many things Co-authored-by: Oliver Valls --- CHANGELOG.md | 1 + .../app/assets/javascripts/decidim.js.es6 | 4 + .../decidim/check_boxes_tree.js.es6 | 292 ++++++++++-------- .../assets/javascripts/decidim/delayed.js.es6 | 26 ++ .../decidim/form_filter.component.js.es6 | 150 ++++----- .../decidim/form_filter.component.test.js | 46 ++- .../assets/javascripts/decidim/history.js.es6 | 17 +- .../spec/system/filter_proposals_spec.rb | 55 ++++ package.json | 1 + 9 files changed, 384 insertions(+), 208 deletions(-) create mode 100644 decidim-core/app/assets/javascripts/decidim/delayed.js.es6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a82c118e374..723de5312aae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Thanks to [#5342](https://github.com/decidim/decidim/pull/5342), Decidim now sup - **decidim-core**: Expand top-level navigation on mobile by default [#5580](https://github.com/decidim/decidim/pull/5580) - **decidim-proposals**: Filtering by state working when searching amendments [#5703](https://github.com/decidim/decidim/pull/5703) - **decidim-core**: Fix: Display values on translated fields with hashtaggable option on edit forms [#5661](https://github.com/decidim/decidim/pull/5661) +- **decidim-core**: Fix: use of browse history with filters [#5749](https://github.com/decidim/decidim/pull/5749) - **decidim-budgets**: Add a missing fix applied to proposals in [\#5654](https://github.com/decidim/decidim/pull/5654) but not to projects [\#5743](https://github.com/decidim/decidim/pull/5743) - **decidim-proposals**: Admin: fix "Answer Proposal" action tooltip [/#5750](https://github.com/decidim/decidim/pull/5750) diff --git a/decidim-core/app/assets/javascripts/decidim.js.es6 b/decidim-core/app/assets/javascripts/decidim.js.es6 index f23bf1677728c..54aa126dc7ebc 100644 --- a/decidim-core/app/assets/javascripts/decidim.js.es6 +++ b/decidim-core/app/assets/javascripts/decidim.js.es6 @@ -24,6 +24,7 @@ // = require decidim/tooltip_keep_on_hover // = require decidim/diff_mode_dropdown // = require decidim/check_boxes_tree +// = require decidim/delayed // = require_tree ./decidim/vizzs // = require_self @@ -37,6 +38,9 @@ $(() => { if (window.Decidim.DataPicker) { window.theDataPicker = new window.Decidim.DataPicker($(".data-picker")); } + if (window.Decidim.CheckBoxesTree) { + window.theCheckBoxesTree = new window.Decidim.CheckBoxesTree(); + } $(document).foundation(); diff --git a/decidim-core/app/assets/javascripts/decidim/check_boxes_tree.js.es6 b/decidim-core/app/assets/javascripts/decidim/check_boxes_tree.js.es6 index 440ba4f9842d5..06ba9d26ae530 100644 --- a/decidim-core/app/assets/javascripts/decidim/check_boxes_tree.js.es6 +++ b/decidim-core/app/assets/javascripts/decidim/check_boxes_tree.js.es6 @@ -1,154 +1,190 @@ /** - * Checkboxes tree component. + * CheckBoxesTree component. */ -class CheckBoxesTree { - constructor() { - this.checkboxesTree = document.querySelectorAll("[data-checkboxes-tree]"); - if (!this.checkboxesTree) { - return; +((exports) => { + class CheckBoxesTree { + constructor() { + this.checkboxesTree = document.querySelectorAll("[data-checkboxes-tree]"); + if (!this.checkboxesTree) { + return; + } + + this.globalChecks = document.querySelectorAll("[data-global-checkbox] input"); + this.globalChecks.forEach((global) => { + if (global.value === "") { + global.classList.add("ignore-filter") + } + }); + this.checkGlobalCheck(); + + // Event listeners + this.checkboxesTree.forEach((input) => input.addEventListener("click", (event) => this.checkTheCheckBoxes(event.target))); + document.querySelectorAll("[data-children-checkbox] input").forEach((input) => { + input.addEventListener("change", (event) => this.checkTheCheckParent(event.target)); + }); + + // Review parent checkboxes on initial load + document.querySelectorAll("[data-children-checkbox] input").forEach((input) => { + this.checkTheCheckParent(input); + }); } - this.globalChecks = document.querySelectorAll("[data-global-checkbox] input"); - this.globalChecks.forEach((global) => { - if (global.value === "") { - global.classList.add("ignore-filter") - } - }); - this.checkGlobalCheck(); - - // Event listeners - this.checkboxesTree.forEach((input) => input.addEventListener("click", this.checkTheCheckBoxes)); - document.querySelectorAll("[data-children-checkbox] input").forEach((input) => { - input.addEventListener("change", (event) => this.checkTheCheckParent(event.target)); - }); - - // Review parent checkboxes on initial load - document.querySelectorAll("[data-children-checkbox] input").forEach((input) => { - this.checkTheCheckParent(input); - }); - } + /** + * Set checkboxes as checked if included in given values + * @public + * @param {Array} checkboxes - array of checkboxs to check + * @param {Array} values - values of checkboxes that should be checked + * @returns {Void} - Returns nothing. + */ + updateChecked(checkboxes, values) { + checkboxes.each((index, checkbox) => { + if ((checkbox.value === "" && values.length === 1) || (checkbox.value !== "" && values.includes(checkbox.value))) { + checkbox.checked = true; + this.checkTheCheckBoxes(checkbox); + this.checkTheCheckParent(checkbox); + } + }); + } - /** - * Handles the click action on any checkbox. - * @private - * @param {Event} event - the click event related information - * @returns {Void} - Returns nothing. - */ - checkTheCheckBoxes(event) { - // Quis custodiet ipsos custodes? - const targetChecks = event.target.dataset.checkboxesTree; - const checkStatus = event.target.checked; - const allChecks = document.querySelectorAll(`#${targetChecks} input[type='checkbox']`); - - allChecks.forEach((input) => { - input.checked = checkStatus; - input.indeterminate = false; - input.classList.add("ignore-filter"); - }); - } + /** + * Set the container form(s) for the component, to disable ignored filters before submitting them + * @public + * @param {query} theForm - form or forms where the component will be used + * @returns {Void} - Returns nothing. + */ + setContainerForm(theForm) { + theForm.on("submit ajax:before", () => { + theForm.find(".ignore-filters input, input.ignore-filter").each((idx, elem) => { + elem.disabled = true; + }); + }); + + theForm.on("ajax:send", () => { + theForm.find(".ignore-filters input, input.ignore-filter").each((idx, elem) => { + elem.disabled = false; + }); + }); + } + + /** + * Handles the click action on any checkbox. + * @private + * @param {Input} target - the input that has been checked + * @returns {Void} - Returns nothing. + */ + checkTheCheckBoxes(target) { + // Quis custodiet ipsos custodes? + const targetChecks = target.dataset.checkboxesTree; + const checkStatus = target.checked; + const allChecks = document.querySelectorAll(`#${targetChecks} input[type='checkbox']`); + + allChecks.forEach((input) => { + input.checked = checkStatus; + input.indeterminate = false; + input.classList.add("ignore-filter"); + }); + } - /** - * Update global checkboxes state when the currention selection changes - * @private - * @returns {Void} - Returns nothing. - */ - checkGlobalCheck() { - this.globalChecks.forEach((global) => { - const checksContext = global.dataset.checkboxesTree; - const totalInputs = document.querySelectorAll( - `#${checksContext} input[type='checkbox']` + /** + * Update global checkboxes state when the current selection changes + * @private + * @returns {Void} - Returns nothing. + */ + checkGlobalCheck() { + this.globalChecks.forEach((global) => { + const checksContext = global.dataset.checkboxesTree; + const totalInputs = document.querySelectorAll( + `#${checksContext} input[type='checkbox']` + ); + const checkedInputs = document.querySelectorAll( + `#${checksContext} input[type='checkbox']:checked` + ); + const indeterminateInputs = Array.from(checkedInputs).filter((checkbox) => checkbox.indeterminate) + + if (checkedInputs.length === 0) { + global.checked = false; + global.indeterminate = false; + } else if (checkedInputs.length === totalInputs.length && indeterminateInputs.length === 0) { + global.checked = true; + global.indeterminate = false; + } else { + global.checked = true; + global.indeterminate = true; + } + + totalInputs.forEach((input) => { + if (global.indeterminate && !input.indeterminate) { + input.classList.remove("ignore-filter"); + } else { + input.classList.add("ignore-filter"); + } + const subfilters = input.parentNode.parentNode.nextElementSibling; + if (subfilters && subfilters.classList.contains("filters__subfilters")) { + if (input.indeterminate) { + subfilters.classList.remove("ignore-filters"); + } else { + subfilters.classList.add("ignore-filters"); + } + } + }); + }); + } + + /** + * Update children checkboxes state when the current selection changes + * @private + * @param {Input} input - the checkbox to check its parent + * @returns {Void} - Returns nothing. + */ + checkTheCheckParent(input) { + const checkBoxContext = $(input).parents(".filters__subfilters").attr("id"); + if (!checkBoxContext) { + this.checkGlobalCheck(); + return; + } + + const parentCheck = document.querySelector( + `[data-checkboxes-tree=${checkBoxContext}]` ); - const checkedInputs = document.querySelectorAll( - `#${checksContext} input[type='checkbox']:checked` + const totalCheckSiblings = document.querySelectorAll( + `#${checkBoxContext} > div > [data-children-checkbox] > input, #${checkBoxContext} > [data-children-checkbox] > input` ); - const indeterminateInputs = document.querySelectorAll( - `#${checksContext} input[type='checkbox']:indeterminate` + const checkedSiblings = document.querySelectorAll( + `#${checkBoxContext} > div > [data-children-checkbox] > input:checked, #${checkBoxContext} > [data-children-checkbox] > input:checked` ); + const indeterminateSiblings = Array.from(checkedSiblings).filter((checkbox) => checkbox.indeterminate) - if (checkedInputs.length === 0) { - global.checked = false; - global.indeterminate = false; - } else if (checkedInputs.length === totalInputs.length && indeterminateInputs.length === 0) { - global.checked = true; - global.indeterminate = false; + if (checkedSiblings.length === 0) { + parentCheck.checked = false; + parentCheck.indeterminate = false; + } else if (checkedSiblings.length === totalCheckSiblings.length && indeterminateSiblings.length === 0) { + parentCheck.checked = true; + parentCheck.indeterminate = false; } else { - global.checked = true; - global.indeterminate = true; + parentCheck.checked = true; + parentCheck.indeterminate = true; } - totalInputs.forEach((input) => { - if (global.indeterminate && !input.indeterminate) { - input.classList.remove("ignore-filter"); + totalCheckSiblings.forEach((sibling) => { + if (parent.indeterminate && !sibling.indeterminate) { + sibling.classList.remove("ignore-filter"); } else { - input.classList.add("ignore-filter"); + sibling.classList.add("ignore-filter"); } - const subfilters = input.parentNode.parentNode.nextElementSibling; + const subfilters = sibling.parentNode.parentNode.nextElementSibling; if (subfilters && subfilters.classList.contains("filters__subfilters")) { - if (input.indeterminate) { + if (sibling.indeterminate) { subfilters.classList.remove("ignore-filters"); } else { subfilters.classList.add("ignore-filters"); } } }); - }); - } - - /** - * Update children checkboxes state when the currention selection changes - * @private - * @param {Input} input - the checkbox to check its parent - * @returns {Void} - Returns nothing. - */ - checkTheCheckParent(input) { - const checkBoxContext = input.parentNode.parentNode.parentNode.getAttribute("id"); - if (!checkBoxContext) { - this.checkGlobalCheck(); - return; - } - const parentCheck = document.querySelector( - `[data-checkboxes-tree=${checkBoxContext}]` - ); - const totalCheckSiblings = document.querySelectorAll( - `#${checkBoxContext} > div > [data-children-checkbox] > input` - ); - const checkedSiblings = document.querySelectorAll( - `#${checkBoxContext} > div > [data-children-checkbox] > input:checked` - ); - const indeterminateSiblings = document.querySelectorAll( - `#${checkBoxContext} > div > [data-children-checkbox] > input:indeterminate` - ); - - if (checkedSiblings.length === 0) { - parentCheck.checked = false; - parentCheck.indeterminate = false; - } else if (checkedSiblings.length === totalCheckSiblings.length && indeterminateSiblings.length === 0) { - parentCheck.checked = true; - parentCheck.indeterminate = false; - } else { - parentCheck.checked = true; - parentCheck.indeterminate = true; + this.checkTheCheckParent(parentCheck); } - - totalCheckSiblings.forEach((sibling) => { - if (parent.indeterminate && !sibling.indeterminate) { - sibling.classList.remove("ignore-filter"); - } else { - sibling.classList.add("ignore-filter"); - } - const subfilters = sibling.parentNode.parentNode.nextElementSibling; - if (subfilters && subfilters.classList.contains("filters__subfilters")) { - if (sibling.indeterminate) { - subfilters.classList.remove("ignore-filters"); - } else { - subfilters.classList.add("ignore-filters"); - } - } - }); - - this.checkTheCheckParent(parentCheck); } -} -$(() => new CheckBoxesTree()); + exports.Decidim = exports.Decidim || {}; + exports.Decidim.CheckBoxesTree = CheckBoxesTree; +})(window); diff --git a/decidim-core/app/assets/javascripts/decidim/delayed.js.es6 b/decidim-core/app/assets/javascripts/decidim/delayed.js.es6 new file mode 100644 index 0000000000000..77eebd2db62e8 --- /dev/null +++ b/decidim-core/app/assets/javascripts/decidim/delayed.js.es6 @@ -0,0 +1,26 @@ +((exports) => { + + /** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. + * @param {Object} context - the context for the called function. + * @param {Function} func - the function to be executed. + * @param {int} wait - number of milliseconds to wait before executing the function. + * @private + * @returns {Void} - Returns nothing. + */ + exports.delayed = (context, func, wait) => { + let timeout = null; + + return function(...args) { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + timeout = null; + Reflect.apply(func, context, args); + }, wait); + } + } +})(window); diff --git a/decidim-core/app/assets/javascripts/decidim/form_filter.component.js.es6 b/decidim-core/app/assets/javascripts/decidim/form_filter.component.js.es6 index 3f2590e142c29..d60e2379cdf93 100644 --- a/decidim-core/app/assets/javascripts/decidim/form_filter.component.js.es6 +++ b/decidim-core/app/assets/javascripts/decidim/form_filter.component.js.es6 @@ -11,8 +11,10 @@ this.$form = $form; this.id = this.$form.attr("id") || this._getUID(); this.mounted = false; + this.changeEvents = true; - this._onFormChange = this._delayed(this._onFormChange.bind(this)); + this._updateInitialState(); + this._onFormChange = exports.delayed(this, this._onFormChange.bind(this)); this._onPopState = this._onPopState.bind(this); if (window.Decidim.PopStateHandler) { @@ -56,17 +58,7 @@ this.currentFormRequest = e.originalEvent.detail[0]; }); - this.$form.on("ajax:before", () => { - this.$form.find(".ignore-filters input, .ignore-filters select, .ignore-filter").each((idx, elem) => { - elem.disabled = true; - }); - }); - - this.$form.on("ajax:send", () => { - this.$form.find(".ignore-filters input, .ignore-filters select, .ignore-filter").each((idx, elem) => { - elem.disabled = false; - }); - }); + exports.theCheckBoxesTree.setContainerForm(this.$form); exports.Decidim.History.registerCallback(`filters-${this.id}`, (state) => { this._onPopState(state); @@ -75,27 +67,36 @@ } /** - * Finds the current location. + * Sets path in the browser history with the initial filters state, to allow to restoring it when using browser history. * @private - * @returns {String} - Returns the current location. + * @returns {Void} - Returns nothing. */ - _getLocation() { - return exports.location.toString(); + _updateInitialState() { + const [initialPath, initialState] = this._currentStateAndPath(); + initialState._path = initialPath + exports.Decidim.History.replaceState(null, initialState); } /** - * Finds the values of the location params that match the given regexp. + * Finds the current location. + * @param {boolean} withHost - include the host part in the returned location * @private - * @param {Regexp} regex - a Regexp to match the params. - * @returns {String[]} - An array of values of the params that match the regexp. + * @returns {String} - Returns the current location. */ - _getLocationParams(regex) { - const location = decodeURIComponent(this._getLocation()); - let values = location.match(regex); - if (values) { - values = values.map((val) => val.match(/=(.*)/)[1]); + _getLocation(withHost = true) { + const state = exports.Decidim.History.state(); + let path = ""; + + if (state && state._path) { + path = state._path; + } else { + path = exports.location.pathname + exports.location.search + exports.location.hash; } - return values; + + if (withHost) { + return exports.location.origin + path; + } + return path; } /** @@ -153,7 +154,9 @@ * @returns {Void} - Returns nothing. */ _clearForm() { - this.$form.find("input[type=checkbox]").attr("checked", false); + this.$form.find("input[type=checkbox]").each((index, element) => { + element.checked = element.indeterminate = false; + }); this.$form.find("input[type=radio]").attr("checked", false); this.$form.find(".data-picker").each((_index, picker) => { exports.theDataPicker.clear(picker); @@ -174,6 +177,7 @@ * @returns {Void} - Returns nothing. */ _onPopState(state) { + this.changeEvents = false; this._clearForm(); const filterParams = this._parseLocationFilterValues(); @@ -185,22 +189,25 @@ const fieldIds = Object.keys(filterParams); // Iterate the filter params and set the correct form values - fieldIds.forEach((fieldId) => { - let field = null; - - // Since we are using Ruby on Rails generated forms the field ids for a - // checkbox or a radio button has the following form: filter_${key}_${value} - field = this.$form.find(`input#filter_${fieldId}_${filterParams[fieldId]}`); - if (field.length > 0) { - field[0].checked = true; - } else { - // If the field is not a checkbox neither a radio it means is a input or a select. - // Ruby on Rails ensure the ids are constructed like this: filter_${key} - field = this.$form.find(`input#filter_${fieldId},select#filter_${fieldId}`); + fieldIds.forEach((fieldName) => { + let value = filterParams[fieldName]; - if (field.length > 0) { - field.val(filterParams[fieldId]); - } + if (Array.isArray(value)) { + let checkboxes = this.$form.find(`input[type=checkbox][name="filter[${fieldName}][]"]`); + window.theCheckBoxesTree.updateChecked(checkboxes, value); + } else { + this.$form.find(`*[name="filter[${fieldName}]"]`).each((index, element) => { + switch (element.type) { + case "hidden": + break; + case "radio": + case "checkbox": + element.checked = value === element.value; + break; + default: + element.value = value; + } + }); } }); } @@ -217,6 +224,8 @@ if (this.popStateSubmiter) { exports.Rails.fire(this.$form[0], "submit"); } + + this.changeEvents = true; } /** @@ -225,26 +234,45 @@ * @returns {Void} - Returns nothing. */ _onFormChange() { - const formAction = this.$form.attr("action"); - const params = this.$form.find(":not(.ignore-filters)").find("select:not(.ignore-filter), input:not(.ignore-filter)").serialize(); + if (!this.changeEvents) { + return; + } - let newUrl = ""; - let newState = {}; + const [newPath, newState] = this._currentStateAndPath(); + const path = this._getLocation(false); + + if (newPath === path) { + return; + } exports.Rails.fire(this.$form[0], "submit"); + exports.Decidim.History.pushState(newPath, newState); + } + + /** + * Calculates the path and the state associated to the filters inputs. + * @private + * @returns {Array} - Returns an array with the path and the state for the current filters state. + */ + _currentStateAndPath() { + const formAction = this.$form.attr("action"); + const params = this.$form.find(":not(.ignore-filters)").find("select:not(.ignore-filter), input:not(.ignore-filter)").serialize(); + + let path = ""; + let state = {}; if (formAction.indexOf("?") < 0) { - newUrl = `${formAction}?${params}`; + path = `${formAction}?${params}`; } else { - newUrl = `${formAction}&${params}`; + path = `${formAction}&${params}`; } // Stores picker information for selected values (value, text and link) in the state object $(".data-picker", this.$form).each((_index, picker) => { - newState[picker.id] = exports.theDataPicker.save(picker); + state[picker.id] = exports.theDataPicker.save(picker); }) - exports.Decidim.History.pushState(newUrl, newState); + return [path, state]; } /** @@ -255,30 +283,6 @@ _getUID() { return `filter-form-${new Date().setUTCMilliseconds()}-${Math.floor(Math.random() * 10000000)}`; } - - /** - * Returns a function, that, as long as it continues to be invoked, will not - * be triggered. The function will be called after it stops being called for - * N milliseconds. - * @param {Function} func - the function to be executed. - * @param {int} wait - number of milliseconds to wait before executing the function. - * @private - * @returns {Void} - Returns nothing. - */ - _delayed(func, wait) { - let that = this, - timeout = null; - - return function(...args) { - if (timeout) { - clearTimeout(timeout); - } - timeout = setTimeout(() => { - timeout = null; - Reflect.apply(func, that, args); - }, wait); - } - } } exports.Decidim = exports.Decidim || {}; diff --git a/decidim-core/app/assets/javascripts/decidim/form_filter.component.test.js b/decidim-core/app/assets/javascripts/decidim/form_filter.component.test.js index 2059350f66490..6c05ee73def8d 100644 --- a/decidim-core/app/assets/javascripts/decidim/form_filter.component.test.js +++ b/decidim-core/app/assets/javascripts/decidim/form_filter.component.test.js @@ -2,8 +2,10 @@ /* eslint-disable id-length */ window.$ = require("jquery"); +require("./delayed.js.es6"); require("./history.js.es6"); require("./data_picker.js.es6"); +require("./check_boxes_tree.js.es6"); require("./form_filter.component.js.es6"); const { Decidim: { FormFilterComponent } } = window; @@ -11,13 +13,13 @@ const { Decidim: { FormFilterComponent } } = window; describe("FormFilterComponent", () => { const selector = "form#new_filter"; let subject = null; - let scopesPickerState = {filter_scope_id: [{ url: "picker_url_3", value: 3, text: "Scope 3"}, { url: "picker_url_4", value: 4, text: "Scope 4"}]} // eslint-disable-line camelcase + let scopesPickerState = {filter_somerandomid_scope_id: [{ url: "picker_url_3", value: 3, text: "Scope 3"}, { url: "picker_url_4", value: 4, text: "Scope 4"}]} // eslint-disable-line camelcase beforeEach(() => { let form = `
-
+
Scope 1 @@ -33,16 +35,43 @@ describe("FormFilterComponent", () => {
-
+ +
+ + +
+ + + + +
+
`; $("body").append(form); window.theDataPicker = new window.Decidim.DataPicker($(".data-picker")); + window.theCheckBoxesTree = new window.Decidim.CheckBoxesTree(); subject = new FormFilterComponent($(document).find("form")); }); @@ -90,13 +119,18 @@ describe("FormFilterComponent", () => { }); it("sets the correct form fields based on the current location", () => { - spyOn(subject, "_getLocation").and.returnValue("/filters?filter[scope_id][]=3&filter[scope_id][]=4&filter[category_id]=2"); + const path = "/filters?filter[scope_id][]=3&filter[scope_id][]=4&filter[category_id]=2&filter[state][]=&filter[state][]=accepted&filter[state][]=evaluating"; + spyOn(subject, "_getLocation").and.returnValue(path); window.onpopstate({ isTrusted: true, state: scopesPickerState}); - expect($(selector).find("select#filter_category_id").val()).toEqual("2"); - expect($(`${selector} #filter_scope_id .picker-values div input`).map(function(_index, input) { + expect($(selector).find("select#filter_somerandomid_category_id").val()).toEqual("2"); + expect($(`${selector} #filter_somerandomid_scope_id .picker-values div input`).map(function(_index, input) { return $(input).val(); }).get()).toEqual(["3", "4"]); + + let checked = Array.from($(`${selector} input[name="filter[state][]"]:checked`)); + expect(checked.map((input) => input.value)).toEqual(["", "accepted", "evaluating"]); + expect(checked.filter((input) => input.indeterminate).map((input) => input.value)).toEqual([""]); }); }); }); diff --git a/decidim-core/app/assets/javascripts/decidim/history.js.es6 b/decidim-core/app/assets/javascripts/decidim/history.js.es6 index c2e01890395ac..5f795814bda70 100644 --- a/decidim-core/app/assets/javascripts/decidim/history.js.es6 +++ b/decidim-core/app/assets/javascripts/decidim/history.js.es6 @@ -29,10 +29,25 @@ } }; + const replaceState = (url, state = null) => { + if (window.history) { + window.history.replaceState(state, null, url); + } + }; + + const state = () => { + if (window.history) { + return window.history.state; + } + return null; + }; + exports.Decidim = exports.Decidim || {}; exports.Decidim.History = { registerCallback, unregisterCallback, - pushState + pushState, + replaceState, + state }; })(window); diff --git a/decidim-proposals/spec/system/filter_proposals_spec.rb b/decidim-proposals/spec/system/filter_proposals_spec.rb index 6f93e16feb628..98f5774ed634f 100644 --- a/decidim-proposals/spec/system/filter_proposals_spec.rb +++ b/decidim-proposals/spec/system/filter_proposals_spec.rb @@ -557,4 +557,59 @@ end end end + + context "when using the browser history", :slow do + before do + create_list(:proposal, 2, component: component) + create_list(:proposal, 2, :official, component: component) + create_list(:proposal, 2, :official, :accepted, component: component) + create_list(:proposal, 2, :official, :rejected, component: component) + + visit_component + end + + it "recover filters from initial pages" do + within ".filters .state_check_boxes_tree_filter" do + check "Rejected" + end + + expect(page).to have_css(".card.card--proposal", count: 8) + + page.go_back + + expect(page).to have_css(".card.card--proposal", count: 6) + end + + it "recover filters from previous pages" do + within ".filters .state_check_boxes_tree_filter" do + check "All" + uncheck "All" + end + within ".filters .origin_check_boxes_tree_filter" do + uncheck "All" + end + + within ".filters .origin_check_boxes_tree_filter" do + check "Official" + end + + within ".filters .state_check_boxes_tree_filter" do + check "Accepted" + end + + expect(page).to have_css(".card.card--proposal", count: 2) + + page.go_back + + expect(page).to have_css(".card.card--proposal", count: 6) + + page.go_back + + expect(page).to have_css(".card.card--proposal", count: 8) + + page.go_forward + + expect(page).to have_css(".card.card--proposal", count: 6) + end + end end diff --git a/package.json b/package.json index fee0006d17311..bd0f0ca9cfb84 100644 --- a/package.json +++ b/package.json @@ -274,6 +274,7 @@ "uuid": "^3.2.1" }, "jest": { + "testURL": "https://decidim.dev/", "setupFiles": [ "raf/polyfill", "/decidim-admin/app/frontend/entry_test.ts",