diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js index a0c3b17193..cc7badb53c 100644 --- a/lib/public/components/Filters/common/FilterModel.js +++ b/lib/public/components/Filters/common/FilterModel.js @@ -14,6 +14,7 @@ import { Observable } from '/js/src/index.js'; /** * Model storing the state of a given filter + * * @abstract */ export class FilterModel extends Observable { @@ -49,7 +50,7 @@ export class FilterModel extends Observable { /** * Returns the normalized value of the filter, that can be used as URL parameter * - * @return {string|number|object|array|null} the normalized value + * @return {string|number|object|string[]|number[]|null} the normalized value * @abstract */ get normalized() { @@ -64,4 +65,19 @@ export class FilterModel extends Observable { get visualChange$() { return this._visualChange$; } + + /** + * Utility function to register a filter model as sub-filter model + * + * TODO for now this function simply propagate observable notifications, in the future this should register submodels with a key and allow + * automated normalization (recursive filter model) + * + * @param {object} subModel the submodel to register + * @return {void} + * @protected + */ + _addSubmodel(subModel) { + subModel.bubbleTo(this); + subModel?.visualChange$.bubbleTo(this._visualChange$); + } } diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js new file mode 100644 index 0000000000..9eb5f9cdb9 --- /dev/null +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -0,0 +1,100 @@ +/** + * @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. + */ + +import { Observable } from '/js/src/index.js'; + +/** + * Model representing a filtering system, including filter inputs visibility, filters values and so on + */ +export class FilteringModel extends Observable { + /** + * Constructor + * + * @param {Object} filters the filters with their label and model + */ + constructor(filters) { + super(); + + this._visualChange$ = new Observable(); + + this._filters = filters; + this._filterModels = Object.values(filters); + for (const model of this._filterModels) { + model.bubbleTo(this); + model.visualChange$?.bubbleTo(this._visualChange$); + } + } + + /** + * Reset the filters + * + * @return {void} + */ + reset() { + for (const model of this._filterModels) { + model.reset(); + } + } + + /** + * Returns the normalized value of all the filters, without null values + * + * @return {object} the normalized values + */ + get normalized() { + const ret = {}; + for (const [filterKey, filter] of Object.entries(this._filters)) { + if (filter && !filter.isEmpty) { + ret[filterKey] = filter.normalized; + } + } + return ret; + } + + /** + * States if there is currently at least one filter active + * + * @return {boolean} true if at least one filter is active + */ + isAnyFilterActive() { + for (const model of this._filterModels) { + if (!model.isEmpty) { + return true; + } + } + return false; + } + + /** + * Returns the observable notified any time there is a visual change which has no impact on the actual filtering + * + * @return {Observable} the filters visibility observable + */ + get visualChange$() { + return this._visualChange$; + } + + /** + * Return the filter model for a given key + * + * @param {string} key the key of the filtering model + * @return {FilterModel} the filtering model + */ + get(key) { + if (!(key in this._filters)) { + throw new Error(`No filter found with key ${key}`); + } + + return this._filters[key]; + } +} diff --git a/lib/public/components/Filters/common/TagFilterModel.js b/lib/public/components/Filters/common/TagFilterModel.js index cd929076c3..08df672c9c 100644 --- a/lib/public/components/Filters/common/TagFilterModel.js +++ b/lib/public/components/Filters/common/TagFilterModel.js @@ -10,14 +10,14 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { Observable } from '/js/src/index.js'; import { CombinationOperatorChoiceModel } from './CombinationOperatorChoiceModel.js'; import { TagSelectionDropdownModel } from '../../tag/TagSelectionDropdownModel.js'; +import { FilterModel } from './FilterModel.js'; /** * Model to handle the state of a tags filter */ -export class TagFilterModel extends Observable { +export class TagFilterModel extends FilterModel { /** * Constructor * @@ -27,29 +27,38 @@ export class TagFilterModel extends Observable { constructor(operators) { super(); this._selectionModel = new TagSelectionDropdownModel({ includeArchived: true }); - this._selectionModel.bubbleTo(this); + this._addSubmodel(this._selectionModel); this._combinationOperatorModel = new CombinationOperatorChoiceModel(operators); - this._combinationOperatorModel.bubbleTo(this); + this._addSubmodel(this._combinationOperatorModel); } + // eslint-disable-next-line valid-jsdoc /** - * States if the filter has no tags selected - * - * @return {boolean} true if no tags are selected + * @inheritDoc */ - isEmpty() { - return this.selected.length === 0; + reset() { + this._selectionModel.reset(); + this._combinationOperatorModel.reset(); } + // eslint-disable-next-line valid-jsdoc /** - * Reset the model to its default state - * - * @return {void} + * @inheritDoc */ - reset() { - this._selectionModel.reset(); - this._combinationOperatorModel.reset(); + get isEmpty() { + return this._selectionModel.isEmpty; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + */ + get normalized() { + return { + values: this.selected.join(), + operation: this.combinationOperator, + }; } /** @@ -87,13 +96,4 @@ export class TagFilterModel extends Observable { get combinationOperator() { return this._combinationOperatorModel.current; } - - /** - * Returns an observable notified any time a visual change occurs that has no impact on the actual selection - * - * @return {Observable} the visual change observable - */ - get visualChange$() { - return this._selectionModel.visualChange$; - } } diff --git a/lib/public/components/Filters/common/filters/remoteDataTagFilter.js b/lib/public/components/Filters/common/filters/remoteDataTagFilter.js deleted file mode 100644 index 89f50cb4f4..0000000000 --- a/lib/public/components/Filters/common/filters/remoteDataTagFilter.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @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. - */ - -import spinner from '../../../common/spinner.js'; -import { tagFilter } from './tagFilter.js'; - -/** - * Return a filter component to apply filtering on a remote data list of tags, handling each possible remote data status - * - * @param {RemoteData} tagsRemoteData the remote data tags list - * @param {TagFilterModel} filter The model storing the filter's state - * - * @return {vnode|vnode[]|null} A collection of checkboxes to toggle table rows by tags - */ -export const remoteDataTagFilter = (tagsRemoteData, filter) => tagsRemoteData.match({ - NotAsked: () => null, - Loading: () => spinner({ - size: 2, - justify: 'left', - absolute: false, - }), - Success: (tags) => tagFilter(tags, filter), - Failure: () => null, -}); diff --git a/lib/public/components/common/selection/SelectionModel.js b/lib/public/components/common/selection/SelectionModel.js index f02839b049..b703206e72 100644 --- a/lib/public/components/common/selection/SelectionModel.js +++ b/lib/public/components/common/selection/SelectionModel.js @@ -263,6 +263,15 @@ export class SelectionModel extends Observable { return [...new Set(this._selectedOptions.map(({ value }) => value))]; } + /** + * States if the current selection is empty + * + * @return {boolean} true if the selection is empty + */ + get isEmpty() { + return this.selected.length === 0; + } + /** * If the selection allows one and only one selection, current will return the currently selected option. In any other case it will throw an * error diff --git a/lib/public/views/Home/Overview/HomePage.js b/lib/public/views/Home/Overview/HomePage.js index 4df3bedd5a..4a319fcb62 100644 --- a/lib/public/views/Home/Overview/HomePage.js +++ b/lib/public/views/Home/Overview/HomePage.js @@ -25,7 +25,8 @@ const MIN_ROWS = 5; /** * Home Page component - * @param {Object} model global model + * + * @param {Model} model global model * @return {Component} Return the component of the home page */ export const HomePage = ({ home: { logsOverviewModel, runsOverviewModel, lhcFillsOverviewModel } }) => { diff --git a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index 0216b6ac33..c43b04b917 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -134,6 +134,12 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: (tags) => formatTagsList(tags), + + /** + * Tag filter component + * @param {LogsOverviewModel} logsModel the log model + * @return {Component} the filter component + */ filter: (logsModel) => tagFilter(logsModel.listingTagsFilterModel), balloon: true, profiles: [profiles.none, 'embeded'], diff --git a/lib/public/views/Logs/Overview/LogsOverviewModel.js b/lib/public/views/Logs/Overview/LogsOverviewModel.js index d020266cd3..494f748326 100644 --- a/lib/public/views/Logs/Overview/LogsOverviewModel.js +++ b/lib/public/views/Logs/Overview/LogsOverviewModel.js @@ -159,7 +159,7 @@ export class LogsOverviewModel extends Observable { || !this._authorFilter.isEmpty || this.createdFilterFrom !== '' || this.createdFilterTo !== '' - || !this.listingTagsFilterModel.isEmpty() + || !this.listingTagsFilterModel.isEmpty || this.runFilterValues.length !== 0 || this.environmentFilterValues.length !== 0 || this.lhcFillFilterValues.length !== 0 @@ -396,7 +396,7 @@ export class LogsOverviewModel extends Observable { 'filter[created][to]': new Date(`${this.createdFilterTo.replace(/\//g, '-')}T23:59:59.999`).getTime(), }, - ...!this.listingTagsFilterModel.isEmpty() && { + ...!this.listingTagsFilterModel.isEmpty && { 'filter[tags][values]': this.listingTagsFilterModel.selected.join(), 'filter[tags][operation]': this.listingTagsFilterModel.combinationOperator, }, diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index 0ec18fb4b0..17edd06250 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -134,7 +134,14 @@ export const runsActiveColumns = { classes: 'w-5 f6', format: (tags) => formatTagsList(tags), exportFormat: (tags) => tags?.length ? tags.map(({ text }) => text).join('-') : '-', - filter: (runModel) => tagFilter(runModel.listingTagsFilterModel), + + /** + * Tags filter component + * + * @param {RunsOverviewModel} runModel the runs overview model + * @return {Component} the filter component + */ + filter: (runModel) => tagFilter(runModel.filteringModel.get('tags')), balloon: (tags) => tags && tags.length > 0, }, fillNumber: { diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index 03f2be92c5..96d47b24af 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -26,6 +26,7 @@ import { TimeRangeInputModel } from '../../../components/Filters/common/filters/ import { FloatComparisonFilterModel } from '../../../components/Filters/common/filters/FloatComparisonFilterModel.js'; import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { AliceL3AndDipoleFilteringModel } from '../../../components/Filters/RunsFilter/AliceL3AndDipoleFilteringModel.js'; +import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; import { buildUrl } from '../../../utilities/fetch/buildUrl.js'; /** @@ -41,13 +42,13 @@ export class RunsOverviewModel extends OverviewPageModel { constructor(model) { super(); - this._listingTagsFilterModel = new TagFilterModel([ - CombinationOperator.AND, - CombinationOperator.OR, - CombinationOperator.NONE_OF, - ]); - this._listingTagsFilterModel.observe(() => this._applyFilters(true)); - this._listingTagsFilterModel.visualChange$.bubbleTo(this); + this._filteringModel = new FilteringModel({ + tags: new TagFilterModel([ + CombinationOperator.AND, + CombinationOperator.OR, + CombinationOperator.NONE_OF, + ]), + }); this._detectorsFilterModel = new DetectorsFilterModel(detectorsProvider.dataTaking$); this._detectorsFilterModel.observe(() => this._applyFilters(true)); @@ -93,6 +94,9 @@ export class RunsOverviewModel extends OverviewPageModel { this._inelasticInteractionRateAtEndFilterModel.observe(() => this._applyFilters()); this._inelasticInteractionRateAtEndFilterModel.visualChange$.bubbleTo(this); + this._filteringModel.observe(() => this._applyFilters(true)); + this._filteringModel.visualChange$.bubbleTo(this); + // Export items this._allRuns = RemoteData.NotAsked(); @@ -108,7 +112,7 @@ export class RunsOverviewModel extends OverviewPageModel { * @inheritdoc */ getRootEndpoint() { - return buildUrl('/api/runs', this._getFilterQueryParams()); + return buildUrl('/api/runs', { ...this._getFilterQueryParams(), ...{ filter: this.filteringModel.normalized } }); } // eslint-disable-next-line valid-jsdoc @@ -185,12 +189,13 @@ export class RunsOverviewModel extends OverviewPageModel { * @return {void} */ resetFiltering(fetch = true) { + this._filteringModel.reset(); + this.runFilterOperation = 'AND'; this.runFilterValues = ''; this._runDefinitionFilter = []; this._detectorsFilterModel.reset(); - this._listingTagsFilterModel.reset(); this._listingRunTypesFilterModel.reset(); this._eorReasonsFilterModel.reset(); this._o2StartFilterModel.reset(); @@ -240,33 +245,42 @@ export class RunsOverviewModel extends OverviewPageModel { * @return {Boolean} If any filter is active */ isAnyFilterActive() { - return this.runFilterValues !== '' - || this._runDefinitionFilter.length > 0 - || !this._eorReasonsFilterModel.isEmpty() - || !this._o2StartFilterModel.isEmpty - || !this._o2StopFilterModel.isEmpty - || !this._detectorsFilterModel.isEmpty() - || !this._listingTagsFilterModel.isEmpty() - || this._listingRunTypesFilterModel.selected.length !== 0 - || this._aliceL3AndDipoleCurrentFilter.selected.length !== 0 - || this._fillNumbersFilter !== '' - || this._runDurationFilter !== null - || this._lhcPeriodsFilter !== null - || this.environmentIdsFilter !== '' - || this.runQualitiesFilters.length !== 0 - || this._triggerValuesFilters.size !== 0 - || this.nDetectorsFilter !== null - || this._nEpnsFilter !== null - || this.nFlpsFilter !== null - || this.ddflpFilter !== '' - || this.dcsFilter !== '' - || this.epnFilter !== '' - || this._odcTopologyFullNameFilter !== '' - || this._muInelasticInteractionRateFilterModel.isEmpty - || this._inelasticInteractionRateAvgFilterModel.isEmpty - || this._inelasticInteractionRateAtStartFilterModel.isEmpty - || this._inelasticInteractionRateAtMidFilterModel.isEmpty - || this._inelasticInteractionRateAtEndFilterModel.isEmpty; + return this._filteringModel.isAnyFilterActive() + || this.runFilterValues !== '' + || this._runDefinitionFilter.length > 0 + || !this._eorReasonsFilterModel.isEmpty() + || !this._o2StartFilterModel.isEmpty + || !this._o2StopFilterModel.isEmpty + || !this._detectorsFilterModel.isEmpty() + || this._listingRunTypesFilterModel.selected.length !== 0 + || this._aliceL3AndDipoleCurrentFilter.selected.length !== 0 + || this._fillNumbersFilter !== '' + || this._runDurationFilter !== null + || this._lhcPeriodsFilter !== null + || this.environmentIdsFilter !== '' + || this.runQualitiesFilters.length !== 0 + || this._triggerValuesFilters.size !== 0 + || this.nDetectorsFilter !== null + || this._nEpnsFilter !== null + || this.nFlpsFilter !== null + || this.ddflpFilter !== '' + || this.dcsFilter !== '' + || this.epnFilter !== '' + || this._odcTopologyFullNameFilter !== '' + || this._muInelasticInteractionRateFilterModel.isEmpty + || this._inelasticInteractionRateAvgFilterModel.isEmpty + || this._inelasticInteractionRateAtStartFilterModel.isEmpty + || this._inelasticInteractionRateAtMidFilterModel.isEmpty + || this._inelasticInteractionRateAtEndFilterModel.isEmpty; + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; } /** @@ -656,15 +670,6 @@ export class RunsOverviewModel extends OverviewPageModel { this._applyFilters(); } - /** - * Return the model handling the filtering on tags - * - * @return {TagFilterModel} the filtering model - */ - get listingTagsFilterModel() { - return this._listingTagsFilterModel; - } - /** * Returns the model handling the filtering on detectors * @@ -825,10 +830,6 @@ export class RunsOverviewModel extends OverviewPageModel { ...this._runDefinitionFilter.length > 0 && { 'filter[definitions]': this._runDefinitionFilter.join(','), }, - ...!this._listingTagsFilterModel.isEmpty() && { - 'filter[tags][values]': this._listingTagsFilterModel.selected.join(), - 'filter[tags][operation]': this._listingTagsFilterModel.combinationOperator, - }, ...this._fillNumbersFilter && { 'filter[fillNumbers]': this._fillNumbersFilter, }, @@ -896,7 +897,7 @@ export class RunsOverviewModel extends OverviewPageModel { ...Object.fromEntries(Object.entries(inelFilterModels) .filter(([_, filterModel]) => !filterModel.isEmpty) .map(([property, filterModel]) => [ - `filter[${property}][${filterModel.value.operator}]`, + `filter[${property}][${encodeURIComponent(filterModel.value.operator)}]`, filterModel.value.operand, ])), }; diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 4f574cbbcf..a37e96a5c0 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -37,6 +37,7 @@ const { expectLink, expectUrlParams, expectAttributeValue, + getColumnCellsInnerTexts, } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -474,22 +475,13 @@ module.exports = () => { expect(await runDurationOperator.evaluate((element) => element.value)).to.equal('='); const runDurationLimitSelector = '#duration-limit'; - const runDurationLimit = await page.$(runDurationLimitSelector) || null; - - await page.waitForSelector(runDurationLimitSelector); - expect(runDurationLimit).to.not.be.null; - - await page.focus(runDurationLimitSelector); - await page.keyboard.type('1500'); + await fillInput(page, runDurationLimitSelector, '1500'); await waitForTableLength(page, 3); await page.select(runDurationOperatorSelector, '='); await waitForTableLength(page, 3); - let runDurationList = await page.evaluate(() => Array.from(document.querySelectorAll('tbody tr')).map((row) => { - const rowId = row.id; - return document.querySelector(`#${rowId}-runDuration-text`)?.innerText; - })); + let runDurationList = await getColumnCellsInnerTexts(page, 'runDuration'); expect(runDurationList.every((runDuration) => { const time = runDuration.replace('*', ''); @@ -1053,12 +1045,12 @@ module.exports = () => { await expectLink(page, `${popoverSelector} a:nth-of-type(1)`, { href: 'http://localhost:8081/?q={%22partition%22:{%22match%22:%22TDI59So3d%22},' - + '%22run%22:{%22match%22:%22104%22},%22severity%22:{%22in%22:%22W%20E%20F%22}}', + + '%22run%22:{%22match%22:%22104%22},%22severity%22:{%22in%22:%22W%20E%20F%22}}', innerText: 'Infologger FLP', }); await expectLink(page, `${popoverSelector} a:nth-of-type(2)`, { href: 'http://localhost:8082/' + - '?page=layoutShow&runNumber=104&definition=COMMISSIONING&detector=CPV&pdpBeamType=cosmic&runType=COSMICS', + '?page=layoutShow&runNumber=104&definition=COMMISSIONING&detector=CPV&pdpBeamType=cosmic&runType=COSMICS', innerText: 'QCG', });