Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 83 additions & 24 deletions lib/public/components/Filters/common/FilteringModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@
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';

const WARNING_TYPES = Object.freeze({
PAGE_MISMATCH: 'Page-Filter mismatch',
UNKNOWN_FILTERS: 'Unknown Filters',
UNPARSABLE_URL: 'Unparseable URL',
UNPARSABLE_FILTERS: 'Unparsable Filters',
});

/**
* Model representing a filtering system, including filter inputs visibility, filters values and so on
Expand Down Expand Up @@ -58,6 +65,10 @@ export class FilteringModel extends Observable {
* @return {void}
*/
reset(notify = false, clearUrl = false) {
if (!this.isAnyFilterActive()) {
return;
}

for (const model of this._filterModels) {
model.reset();
}
Expand All @@ -67,6 +78,7 @@ export class FilteringModel extends Observable {
}

if (clearUrl) {
this._clearWarnings();
const { params } = this._router;
params.filter = this.normalized;
this._router.go(buildUrl('?', params), false, true);
Expand Down Expand Up @@ -137,52 +149,63 @@ export class FilteringModel extends Observable {
this.notify();
}

/**
* Compute seach parameters based a url or router
*
* @param {string} url the url that is to be parsed
* @returns {object} the serach parameters object
*/
_computeParameters(url) {
try {
return parseUrlParameters(new URL(url).searchParams);
} catch {
this._warnings.set(WARNING_TYPES.UNPARSABLE_URL, `URL could not be parsed. URL: ${url}`);
this.notify();
return {};
}
}

/**
* Look for parameters used for filtering in URL and apply them in the layout if it exists
*
* @param {boolean} notify if observers should be notified after setting the filters
* @param {string|null} [url=null] the url that is to be parsed into active filters
* @returns {undefined}
*/
setFilterFromURL(notify = false) {
const { params: { page = '', filter } } = this._router;
setFilterFromURL(notify = false, url = null) {
this._clearWarnings();

const params = url ? this._computeParameters(url) : this._router.params;
const { page, filter } = params;

if (this._pageIdentifier === page) {
if (this._pageIdentifier !== page) {
if (url && page) { // 'page' might be undefined if the url is unparsable
this._warnings.set(WARNING_TYPES.PAGE_MISMATCH, `The filters provided were meant for ${page}`);
}
} else {
if (!filter) {
this.reset();
return;
}

const unknownFilters = [];
const setFilterErrors = [];

for (const [key, value] of Object.entries(filter)) {
if (key in this._filters) {
try {
this._filters[key].normalized = value;
} catch {
setFilterErrors.push(`${buildUrl('', { [key]: value }).slice(1)}`);
}
} else {
unknownFilters.push(`'${key}'`);
}
}
const { setFilterErrors, unknownFilters } = this._setFilters(filter);

if (setFilterErrors.length > 0) {
this._warnings.set(
'Unparsable Filters',
WARNING_TYPES.UNPARSABLE_FILTERS,
`The following filter-value pairs could not be parsed: [${setFilterErrors.join(', ')}]`,
);
} else {
this._warnings.delete('Unparsable Filters');
}

if (unknownFilters.length > 0) {
this._warnings.set(
'Unknown Filters',
WARNING_TYPES.UNKNOWN_FILTERS,
`The filters: [${unknownFilters.join(', ')}]; are not reccognised. Check if they are spelled correctly.`,
);
} else {
this._warnings.delete('Unknown Filters');
}

if (url) {
this._router.go(buildUrl('?', params), false, true);
}
}

Expand All @@ -191,6 +214,42 @@ export class FilteringModel extends Observable {
}
}

/**
* Clear all filter-related warnings from the warnings map
*
* @returns {undefined}
*/
_clearWarnings() {
for (const key in Object.keys(WARNING_TYPES)) {
this._warnings.delete(key);
}
}

/**
* Sets all filters using their normalized setters
*
* @param {object<string, object|string} filters an object containing normalized filter values
* @returns {object<string, string[]>} an object containging the uknown filters and the filters that failed to parse
*/
_setFilters(filters) {
const unknownFilters = [];
const setFilterErrors = [];

for (const [key, value] of Object.entries(filters)) {
if (key in this._filters) {
try {
this._filters[key].normalized = value;
} catch {
setFilterErrors.push(`${buildUrl('', { [key]: value }).slice(1)}`);
}
} else {
unknownFilters.push(`'${key}'`);
}
}

return { unknownFilters, setFilterErrors };
}

/**
* Add new filter
*
Expand Down
102 changes: 88 additions & 14 deletions lib/public/components/Filters/common/filtersPanelPopover.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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),
]);

/**
Expand Down Expand Up @@ -114,13 +123,78 @@ 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),
{
...PopoverTriggerPreConfiguration.click,
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: 'filters' }, '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('button.btn.btn-primary', {
onclick: async () => {
const url = await navigator.clipboard.readText();
filteringModel.setFilterFromURL(true, url);
},
disabled: !clipboardSupported,
id: 'paste-filters',
}, '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),
],
),
),
],
);
};
70 changes: 70 additions & 0 deletions test/public/components/filtersPopoverPanel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @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-filters', 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-filters', 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 () => {
Comment thread
graduta marked this conversation as resolved.
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 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);
});
};
2 changes: 2 additions & 0 deletions test/public/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Loading