From d31266b967751d5efc7e6f5be04cc78050dac8a2 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Wed, 29 Apr 2026 14:09:08 +0200 Subject: [PATCH 1/4] chore: add a url parameter to setFilterFromUrl --- .../Filters/common/FilteringModel.js | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index 86479ed590..b96f11aab1 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,38 @@ 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) { + let params = {}; + + if (url) { + try { + params = parseUrlParameters(new URL(url).searchParams); + } catch (e) { + this._warnings.set('Unparseable URL', `URL could not be parsed. URL: ${url}`); + this.notify(); + } + } else { + params = this._router.params; + } + + return 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 +198,10 @@ export class FilteringModel extends Observable { this._warnings.delete('Unknown Filters'); } + if (url) { + this._router.go(buildUrl('?', params), false, true); + } + this.notify(); } From 083f52330f602c1b96f233bee8b7f4c171c5692a Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Wed, 29 Apr 2026 16:55:21 +0200 Subject: [PATCH 2/4] feat: create dropdown options --- .../Filters/common/filtersPanelPopover.js | 98 ++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 1d729eb93e..8aaa90df20 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,25 +36,34 @@ 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'); /** - * Create main header of the filters panel - * @param {FilteringModel} filteringModel filtering model - * @returns {Component} main panel header + * Button component that resets all filters upon click + * + * @param {FilteringModel|OverviewPageModel} filteringModel the FilteringModel + * @returns {Component} the reset button component */ -const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ - h('.f4', 'Filters'), - h( +const resetFiltersButton = (filteringModel) => h( 'button#reset-filters.btn.btn-danger', { + disabled: !filteringModel.isAnyFilterActive(), onclick: () => filteringModel.resetFiltering ? filteringModel.resetFiltering(true, true) : filteringModel.reset(true, true), - disabled: !filteringModel.isAnyFilterActive(), }, 'Reset all filters', - ), + ); + + +/** + * Create main header of the filters panel + * @param {FilteringModel} filteringModel filtering model + * @returns {Component} main panel header + */ +const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ + h('.f4', 'Filters'), + resetFiltersButton(filteringModel), ]); /** @@ -114,9 +124,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 +134,67 @@ 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} filteringModel the FilteringModel + * @returns {Component} the paste button component + */ +const pasteButtonOption = (filteringModel) => { + const clipboardSupported = navigator?.clipboard && window.isSecureContext; + + // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) + if (filteringModel.filteringModel) { + filteringModel = filteringModel.filteringModel; + } + + return h('.btn.btn-primary', { onclick: async () => { + const url = await navigator.clipboard.readText(); + filteringModel.setFilterFromURL(url); + + }, disabled: !clipboardSupported}, '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), + ], + ), + ), + ], + ); +} From 439bcf75979058ac30b0f9c65c8db0355d40eb23 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Wed, 29 Apr 2026 17:18:41 +0200 Subject: [PATCH 3/4] fix linting issues --- .../Filters/common/FilteringModel.js | 11 ++--- .../Filters/common/filtersPanelPopover.js | 47 +++++++++---------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index b96f11aab1..251dbd07a8 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -149,20 +149,17 @@ export class FilteringModel extends Observable { * @returns {object} the serach parameters object */ _computeParameters(url = null) { - let params = {}; - if (url) { try { - params = parseUrlParameters(new URL(url).searchParams); - } catch (e) { + return parseUrlParameters(new URL(url).searchParams); + } catch { this._warnings.set('Unparseable URL', `URL could not be parsed. URL: ${url}`); this.notify(); + return {}; } - } else { - params = this._router.params; } - return params; + return this._router.params; } /** diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 8aaa90df20..db75241844 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -45,16 +45,15 @@ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primar * @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', - ); - + '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 @@ -144,29 +143,29 @@ const filtersPanelButton = (filteringModel, filtersConfiguration, configuration) const copyButtonOption = (activeFilters) => h( '', { style: activeFilters ? {} : { opacity: 0.5, pointerEvents: 'none' } }, - h(CopyToClipboardComponent, { value: location.href, id: "filter" }, 'Copy Active Filters'), -) + 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} filteringModel the FilteringModel + * @param {FilteringModel|OverviewPageModel} model the FilteringModel * @returns {Component} the paste button component */ -const pasteButtonOption = (filteringModel) => { +const pasteButtonOption = (model) => { const clipboardSupported = navigator?.clipboard && window.isSecureContext; // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) - if (filteringModel.filteringModel) { - filteringModel = filteringModel.filteringModel; - } + const { filteringModel = model } = model; - return h('.btn.btn-primary', { onclick: async () => { - const url = await navigator.clipboard.readText(); - filteringModel.setFilterFromURL(url); - - }, disabled: !clipboardSupported}, 'Paste filters'); -} + return h('.btn.btn-primary', { + onclick: async () => { + const url = await navigator.clipboard.readText(); + filteringModel.setFilterFromURL(url); + }, + disabled: !clipboardSupported, + }, 'Paste filters'); +}; /** * Return component composed of the filter popover button and a dropdown trigger @@ -197,4 +196,4 @@ export const filtersPanelPopover = (filteringModel, filtersConfiguration, config ), ], ); -} +}; From 1462d2336ea144a3cc1a105bd634c1c3b1927587 Mon Sep 17 00:00:00 2001 From: NarrowsProjects Date: Thu, 30 Apr 2026 09:56:19 +0200 Subject: [PATCH 4/4] chore: add tests for new buttons --- .../Filters/common/filtersPanelPopover.js | 1 + .../components/filtersPopoverPanel.test.js | 86 +++++++++++++++++++ test/public/components/index.js | 2 + 3 files changed, 89 insertions(+) create mode 100644 test/public/components/filtersPopoverPanel.test.js diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index db75241844..8f0ca39a4b 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -164,6 +164,7 @@ const pasteButtonOption = (model) => { filteringModel.setFilterFromURL(url); }, disabled: !clipboardSupported, + id: 'paste-filter', }, 'Paste filters'); }; 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) };