From 4fefe66a29cc8567ee25226db4ce4efce108fde0 Mon Sep 17 00:00:00 2001 From: Martin Boulais <31805063+martinboulais@users.noreply.github.com> Date: Tue, 13 Dec 2022 15:29:34 +0100 Subject: [PATCH] [O2B-532] Create a reusable filtering system --- .../components/Filters/common/FilterModel.js | 3 +- .../Filters/common/FilteringModel.js | 106 ++++++++++++++++++ .../Filters/common/TagFilterModel.js | 43 +++---- .../common/filters/remoteDataTagFilter.js | 34 ------ .../Logs/ActiveColumns/logsActiveColumns.js | 5 + .../views/Logs/Overview/LogsOverviewModel.js | 2 +- .../Runs/ActiveColumns/runsActiveColumns.js | 7 +- .../views/Runs/Overview/RunsOverviewModel.js | 53 ++++----- 8 files changed, 170 insertions(+), 83 deletions(-) create mode 100644 lib/public/components/Filters/common/FilteringModel.js delete mode 100644 lib/public/components/Filters/common/filters/remoteDataTagFilter.js diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js index a0c3b17193..79bc54e501 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() { diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js new file mode 100644 index 0000000000..ab39d071a2 --- /dev/null +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -0,0 +1,106 @@ +/** + * @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'; + +/** + * @typedef FilteringItem + * @property {FilterModel} model the model of the filter + * @property {string} label the label of the filter + */ + +/** + * 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).map(({ model }) => model); + 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, { model: 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 {FilteringModel} the filtering model + */ + get(key) { + if (!(key in this._filters)) { + throw new Error(`No filter found with key ${key}`); + } + + return this._filters[key].model; + } +} diff --git a/lib/public/components/Filters/common/TagFilterModel.js b/lib/public/components/Filters/common/TagFilterModel.js index cd929076c3..c56b91da69 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 * @@ -28,28 +28,38 @@ export class TagFilterModel extends Observable { super(); this._selectionModel = new TagSelectionDropdownModel({ includeArchived: true }); this._selectionModel.bubbleTo(this); + this._selectionModel.visualChange$.bubbleTo(this.visualChange$); this._combinationOperatorModel = new CombinationOperatorChoiceModel(operators); this._combinationOperatorModel.bubbleTo(this); } + // eslint-disable-next-line valid-jsdoc /** - * States if the filter has no tags selected - * - * @return {boolean} true if no tags are selected + * @inheritDoc */ - isEmpty() { + reset() { + this._selectionModel.reset(); + this._combinationOperatorModel.reset(); + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + */ + get isEmpty() { return this.selected.length === 0; } + // eslint-disable-next-line valid-jsdoc /** - * Reset the model to its default state - * - * @return {void} + * @inheritDoc */ - reset() { - this._selectionModel.reset(); - this._combinationOperatorModel.reset(); + get normalized() { + return { + values: this.selected.join(), + operation: this.combinationOperator, + }; } /** @@ -87,13 +97,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/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index 0216b6ac33..e517df360a 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -134,6 +134,11 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: (tags) => formatTagsList(tags), + + /** + * @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 2972678393..8c963df9b0 100644 --- a/lib/public/views/Logs/Overview/LogsOverviewModel.js +++ b/lib/public/views/Logs/Overview/LogsOverviewModel.js @@ -395,7 +395,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 486648e7b3..a640632024 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -134,7 +134,12 @@ 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), + + /** + * @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 dcdefbd09b..7c2526bba5 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -26,6 +26,8 @@ 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'; /** * Model representing handlers for runs page @@ -40,13 +42,16 @@ 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: { + model: new TagFilterModel([ + CombinationOperator.AND, + CombinationOperator.OR, + CombinationOperator.NONE_OF, + ]), + label: 'Tags', + }, + }); this._detectorsFilterModel = new DetectorsFilterModel(detectorsProvider.dataTaking$); this._detectorsFilterModel.observe(() => this._applyFilters(true)); @@ -92,6 +97,10 @@ 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(); @@ -107,8 +116,7 @@ export class RunsOverviewModel extends OverviewPageModel { * @inheritdoc */ getRootEndpoint() { - const paramsString = new URLSearchParams(this._getFilterQueryParams()).toString(); - return `/api/runs?${paramsString}`; + return buildUrl('/api/runs', { ...this._getFilterQueryParams(), ...{ filter: this.filteringModel.normalized } }); } // eslint-disable-next-line valid-jsdoc @@ -190,7 +198,6 @@ export class RunsOverviewModel extends OverviewPageModel { this._runDefinitionFilter = []; this._detectorsFilterModel.reset(); - this._listingTagsFilterModel.reset(); this._listingRunTypesFilterModel.reset(); this._eorReasonsFilterModel.reset(); this._o2StartFilterModel.reset(); @@ -240,13 +247,13 @@ export class RunsOverviewModel extends OverviewPageModel { * @return {Boolean} If any filter is active */ isAnyFilterActive() { - return this.runFilterValues !== '' + return this._filteringModel.isAnyFilterActive() + || 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 !== '' @@ -269,6 +276,15 @@ export class RunsOverviewModel extends OverviewPageModel { || this._inelasticInteractionRateAtEndFilterModel.isEmpty; } + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; + } + /** * Set the export type parameter of the current export being created * @param {string} selectedExportType Received string from the view @@ -656,15 +672,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 +832,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, },