diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index 86479ed590..251dbd07a8 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -14,7 +14,7 @@ import { expandQueryLikeNestedKey } from '../../../utilities/expandNestedKey.js'; import { SelectionModel } from '../../common/selection/SelectionModel.js'; import { FilterModel } from './FilterModel.js'; -import { buildUrl, Observable } from '/js/src/index.js'; +import { buildUrl, Observable, parseUrlParameters } from '/js/src/index.js'; /** * Model representing a filtering system, including filter inputs visibility, filters values and so on @@ -142,12 +142,35 @@ export class FilteringModel extends Observable { this.notify(); } + /** + * Compute seach parameters based a url or router + * + * @param {string|null} [url=null] the url that is to be parsed + * @returns {object} the serach parameters object + */ + _computeParameters(url = null) { + if (url) { + try { + return parseUrlParameters(new URL(url).searchParams); + } catch { + this._warnings.set('Unparseable URL', `URL could not be parsed. URL: ${url}`); + this.notify(); + return {}; + } + } + + return this._router.params; + } + /** * Look for parameters used for filtering in URL and apply them in the layout if it exists + * + * @param {string|null} [url=null] the url that is to be parsed into active filters * @returns {undefined} */ - async setFilterFromURL() { - const { params: { page = '', filter = {} } } = this._router; + setFilterFromURL(url = null) { + const params = this._computeParameters(url); + const { page, filter = {} } = params; if (!(this._pageIdentifier === page)) { return; @@ -172,6 +195,10 @@ export class FilteringModel extends Observable { this._warnings.delete('Unknown Filters'); } + if (url) { + this._router.go(buildUrl('?', params), false, true); + } + this.notify(); } diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 1d729eb93e..8f0ca39a4b 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -10,7 +10,8 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; +import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration, DropdownComponent, CopyToClipboardComponent } from '/js/src/index.js'; +import { iconCaretBottom } from '/js/src/icons.js'; import { profiles } from '../../common/table/profiles.js'; import { applyProfile } from '../../../utilities/applyProfile.js'; import { tooltip } from '../../common/popover/tooltip.js'; @@ -35,7 +36,24 @@ import { tooltip } from '../../common/popover/tooltip.js'; * * @return {Component} the button component */ -const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); +const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary.first-item', 'Filters'); + +/** + * Button component that resets all filters upon click + * + * @param {FilteringModel|OverviewPageModel} filteringModel the FilteringModel + * @returns {Component} the reset button component + */ +const resetFiltersButton = (filteringModel) => h( + 'button#reset-filters.btn.btn-danger', + { + disabled: !filteringModel.isAnyFilterActive(), + onclick: () => filteringModel.resetFiltering + ? filteringModel.resetFiltering(true, true) + : filteringModel.reset(true, true), + }, + 'Reset all filters', +); /** * Create main header of the filters panel @@ -44,16 +62,7 @@ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primar */ const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ h('.f4', 'Filters'), - h( - 'button#reset-filters.btn.btn-danger', - { - onclick: () => filteringModel.resetFiltering - ? filteringModel.resetFiltering(true, true) - : filteringModel.reset(true, true), - disabled: !filteringModel.isAnyFilterActive(), - }, - 'Reset all filters', - ), + resetFiltersButton(filteringModel), ]); /** @@ -114,9 +123,9 @@ const filtersToggleContent = ( * @param {FiltersConfiguration} filtersConfiguration filters configuration * @param {object} [configuration] optional configuration * @param {string} [configuration.profile] specify for which profile filtering should be enabled - * @return {Component} the filter component + * @return {Component} the filter button component */ -export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => popover( +const filtersPanelButton = (filteringModel, filtersConfiguration, configuration) => popover( filtersToggleTrigger(), filtersToggleContent(filteringModel, filtersConfiguration, configuration), { @@ -124,3 +133,68 @@ export const filtersPanelPopover = (filteringModel, filtersConfiguration, config anchor: PopoverAnchors.RIGHT_START, }, ); + +/** + * A button component that lets the user copy the url if there are active filters. + * + * @param {boolean} activeFilters if false, will disable the button + * @returns {Component} the copy button component + */ +const copyButtonOption = (activeFilters) => h( + '', + { style: activeFilters ? {} : { opacity: 0.5, pointerEvents: 'none' } }, + h(CopyToClipboardComponent, { value: location.href, id: 'filter' }, 'Copy Active Filters'), +); + +/** + * A button component that lets the user paste the first entry of their clipboard as a filter url. + * + * @param {FilteringModel|OverviewPageModel} model the FilteringModel + * @returns {Component} the paste button component + */ +const pasteButtonOption = (model) => { + const clipboardSupported = navigator?.clipboard && window.isSecureContext; + + // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) + const { filteringModel = model } = model; + + return h('.btn.btn-primary', { + onclick: async () => { + const url = await navigator.clipboard.readText(); + filteringModel.setFilterFromURL(url); + }, + disabled: !clipboardSupported, + id: 'paste-filter', + }, 'Paste filters'); +}; + +/** + * Return component composed of the filter popover button and a dropdown trigger + * + * @param {FilteringModel} filteringModel the filtering model + * @param {FiltersConfiguration} filtersConfiguration filters configuration + * @param {object} [configuration] optional configuration + * @param {string} [configuration.profile] specify for which profile filtering should be enabled + * @return {Component} the filter component + */ +export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => { + const hasActiveFilters = filteringModel.isAnyFilterActive(); + + return h( + '.flex-row.items-center.btn-group', + [ + filtersPanelButton(filteringModel, filtersConfiguration, configuration), + DropdownComponent( + h('.btn.btn-group-item.last-item', iconCaretBottom()), + h( + '.flex-column.p2.g2', + [ + copyButtonOption(hasActiveFilters), + pasteButtonOption(filteringModel), + resetFiltersButton(filteringModel), + ], + ), + ), + ], + ); +}; diff --git a/test/public/components/filtersPopoverPanel.test.js b/test/public/components/filtersPopoverPanel.test.js new file mode 100644 index 0000000000..7e6d7e0011 --- /dev/null +++ b/test/public/components/filtersPopoverPanel.test.js @@ -0,0 +1,86 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { expect } = require('chai'); +const { defaultBefore, defaultAfter, pressElement, takeScreenshot, expectInputValue } = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + let context; + let url; + + before(async () => { + [page, browser, url] = await defaultBefore(page, browser); + context = browser.defaultBrowserContext(); + context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); + }); + + it('Should copy url when clicking filer copy button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + await page.goto(url, { waitUntil: 'load' }); + await takeScreenshot(page, 'test'); + await pressElement(page, '#copy-filter', true); + + const clipboardContents = await page.evaluate(async () => decodeURI(await navigator.clipboard.readText())); + expect(clipboardContents).to.equal(url); + }); + + it('Should set filters when pressing paste active filters button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '#paste-filter', true); + + const actualUrl = page.url(); + expect(actualUrl).to.equal(url); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'name'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '100'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); + }); + + it('Should set filters when pressing paste active filters button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '#paste-filter', true); + + const actualUrl = page.url(); + expect(actualUrl).to.equal(url); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'name'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '100'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); + }); + + it('Should reset filters when pressing the reset all filters button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + + await page.goto(url, { waitUntil: 'load' }); + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '.dropdown #reset-filters', true); + + const actualUrl = page.url(); + expect(actualUrl).to.equal('http://localhost:4000/?page=lhc-period-overview'); + + await expectInputValue(page, '.name-filter input', ''); + await expectInputValue(page, '.year-filter input', ''); + await expectInputValue(page, '.pdpBeamTypes-filter input', ''); + }); + + after(async () => { + await defaultAfter(page, browser); + }); +}; diff --git a/test/public/components/index.js b/test/public/components/index.js index 700564755c..794ae79252 100644 --- a/test/public/components/index.js +++ b/test/public/components/index.js @@ -13,8 +13,10 @@ const NavBarSuite = require('./navBar.test') const WarningSuite = require('./warnings.test') +const FiltersPanelSuite = require('./filtersPopoverPanel.test') module.exports = () => { describe('Navbar component', NavBarSuite); describe('Warning component', WarningSuite) + describe('FiltersPanelPopover component', FiltersPanelSuite) };