diff --git a/lib/domain/dtos/filters/LhcFillsFilterDto.js b/lib/domain/dtos/filters/LhcFillsFilterDto.js index d1d2af5929..9db91e46d1 100644 --- a/lib/domain/dtos/filters/LhcFillsFilterDto.js +++ b/lib/domain/dtos/filters/LhcFillsFilterDto.js @@ -12,10 +12,12 @@ */ const Joi = require('joi'); const { validateRange } = require('../../../utilities/rangeUtils'); +const { validateTimeDuration } = require('../../../utilities/validateTime'); exports.LhcFillsFilterDto = Joi.object({ hasStableBeams: Joi.boolean(), fillNumbers: Joi.string().trim().custom(validateRange).messages({ 'any.invalid': '{{#message}}', }), + beamDuration: validateTimeDuration, }); diff --git a/lib/domain/dtos/filters/NumericalComparisonDto.js b/lib/domain/dtos/filters/NumericalComparisonDto.js index d71a7858e9..f0d5802125 100644 --- a/lib/domain/dtos/filters/NumericalComparisonDto.js +++ b/lib/domain/dtos/filters/NumericalComparisonDto.js @@ -24,3 +24,5 @@ exports.FloatComparisonDto = Joi.object({ operator: Joi.string().valid(...NUMERICAL_COMPARISON_OPERATORS), limit: Joi.number().min(0), }); + +exports.NUMERICAL_COMPARISON_OPERATORS = NUMERICAL_COMPARISON_OPERATORS; diff --git a/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js b/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js new file mode 100644 index 0000000000..8417d3be21 --- /dev/null +++ b/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js @@ -0,0 +1,31 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { comparisonOperatorFilter } from '../common/filters/comparisonOperatorFilter.js'; +import { rawTextFilter } from '../common/filters/rawTextFilter.js'; + +/** + * Component to filter LHC-fills by beam duration + * + * @param {TextComparisonFilterModel} beamDurationFilterModel beamDurationFilterModel + * @returns {Component} the text field + */ +export const beamDurationFilter = (beamDurationFilterModel) => { + const amountFilter = rawTextFilter( + beamDurationFilterModel.operandInputModel, + { id: 'beam-duration-filter-operand', classes: ['w-100', 'beam-duration-filter'], placeholder: 'e.g 16:14:15 (HH:MM:SS)' }, + ); + + return comparisonOperatorFilter(amountFilter, beamDurationFilterModel.operatorSelectionModel.current, (value) => + beamDurationFilterModel.operatorSelectionModel.select(value), { id: 'beam-duration-filter-operator' }); +}; diff --git a/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js new file mode 100644 index 0000000000..113809ca9c --- /dev/null +++ b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js @@ -0,0 +1,79 @@ +/** + * @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 { ComparisonSelectionModel } from './ComparisonSelectionModel.js'; +import { FilterModel } from '../FilterModel.js'; +import { RawTextFilterModel } from './RawTextFilterModel.js'; + +/** + * TextComparisonFilterModel + */ +export class TextComparisonFilterModel extends FilterModel { + /** + * Constructor + */ + constructor() { + super(); + + this._operatorSelectionModel = new ComparisonSelectionModel(); + this._operatorSelectionModel.visualChange$.bubbleTo(this._visualChange$); + // Unless the filter contains a value don't apply the filters. + this._operatorSelectionModel.observe(() => this._operandInputModel.value ? this.notify() : this._visualChange$.notify()); + + this._operandInputModel = new RawTextFilterModel(); + this._operandInputModel.visualChange$.bubbleTo(this._visualChange$); + this._operandInputModel.bubbleTo(this); + } + + /** + * Return raw text filter model + * + * @return {RawTextFilterModel} operand input model + */ + get operandInputModel() { + return this._operandInputModel; + } + + /** + * Get operator selection model + * + * @return {ComparisonSelectionModel} selection model + */ + get operatorSelectionModel() { + return this._operatorSelectionModel; + } + + /** + * @inheritDoc + */ + reset() { + this._operandInputModel.reset(); + this._operatorSelectionModel.reset(); + } + + /** + * @inheritDoc + */ + get normalized() { + return { + operator: this._operatorSelectionModel.current, + limit: this._operandInputModel.value, + }; + } + + /** + * @inheritDoc + */ + get isEmpty() { + return !this._operandInputModel.value; + } +} diff --git a/lib/public/components/Filters/common/filters/rawTextFilter.js b/lib/public/components/Filters/common/filters/rawTextFilter.js index aa72387767..7cf39199e1 100644 --- a/lib/public/components/Filters/common/filters/rawTextFilter.js +++ b/lib/public/components/Filters/common/filters/rawTextFilter.js @@ -23,11 +23,12 @@ import { h } from '/js/src/index.js'; * @return {Component} the filter */ export const rawTextFilter = (filterModel, configuration) => { - const { classes = [], placeholder = '' } = configuration || {}; + const { classes = [], placeholder = '', id = '' } = configuration || {}; return h( 'input', { type: 'text', + id: id, class: classes.join(' '), value: filterModel.value, placeholder: placeholder, diff --git a/lib/public/views/Home/Overview/HomePageModel.js b/lib/public/views/Home/Overview/HomePageModel.js index 40b6cfac85..fca0331fe7 100644 --- a/lib/public/views/Home/Overview/HomePageModel.js +++ b/lib/public/views/Home/Overview/HomePageModel.js @@ -32,7 +32,7 @@ export class HomePageModel extends Observable { this._logsOverviewModel = new LogsOverviewModel(model, true); this._logsOverviewModel.bubbleTo(this); - this._lhcFillsOverviewModel = new LhcFillsOverviewModel(true); + this._lhcFillsOverviewModel = new LhcFillsOverviewModel(model, true); this._lhcFillsOverviewModel.bubbleTo(this); } diff --git a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js index 5442ed8bcc..8d0ef13e1e 100644 --- a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js +++ b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js @@ -25,6 +25,7 @@ import { formatBeamType } from '../../../utilities/formatting/formatBeamType.js' import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; import { fillNumberFilter } from '../../../components/Filters/LhcFillsFilter/fillNumberFilter.js'; +import { beamDurationFilter } from '../../../components/Filters/LhcFillsFilter/beamDurationFilter.js'; /** * List of active columns for a lhc fills table @@ -108,6 +109,7 @@ export const lhcFillsActiveColumns = { return '-'; }, + filter: (lhcFillModel) => beamDurationFilter(lhcFillModel.filteringModel.get('beamDuration')), profiles: { lhcFill: true, environment: true, diff --git a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js index 55a417dc66..78f8272535 100644 --- a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js +++ b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js @@ -17,6 +17,7 @@ import { StableBeamFilterModel } from '../../../components/Filters/LhcFillsFilte import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { addStatisticsToLhcFill } from '../../../services/lhcFill/addStatisticsToLhcFill.js'; +import { TextComparisonFilterModel } from '../../../components/Filters/common/filters/TextComparisonFilterModel.js'; /** * Model for the LHC fills overview page @@ -27,6 +28,7 @@ export class LhcFillsOverviewModel extends OverviewPageModel { /** * Constructor * + * @param {model} model global model * @param {boolean} [stableBeamsOnly=false] if true, overview will load stable beam only */ constructor(stableBeamsOnly = false) { @@ -34,10 +36,11 @@ export class LhcFillsOverviewModel extends OverviewPageModel { this._filteringModel = new FilteringModel({ fillNumbers: new RawTextFilterModel(), + beamDuration: new TextComparisonFilterModel(), hasStableBeams: new StableBeamFilterModel(), }); - this._filteringModel.observe(() => this._applyFilters(true)); + this._filteringModel.observe(() => this._applyFilters()); this._filteringModel.visualChange$.bubbleTo(this); this.reset(false); @@ -61,7 +64,10 @@ export class LhcFillsOverviewModel extends OverviewPageModel { * @inheritDoc */ getRootEndpoint() { - return buildUrl('/api/lhcFills', { filter: this.filteringModel.normalized }); + const params = { + filter: this.filteringModel.normalized, + }; + return buildUrl('/api/lhcFills', params); } /** @@ -83,7 +89,7 @@ export class LhcFillsOverviewModel extends OverviewPageModel { this._filteringModel.reset(); if (fetch) { - this._applyFilters(true); + this._applyFilters(); } } @@ -106,6 +112,7 @@ export class LhcFillsOverviewModel extends OverviewPageModel { /** * Apply the current filtering and update the remote data list + * @param {boolean} now if true, filtering will be applied now without debouncing * * @return {void} */ diff --git a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js index 360b396968..30321645e2 100644 --- a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js +++ b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js @@ -45,7 +45,7 @@ class GetAllLhcFillsUseCase { const queryBuilder = new QueryBuilder(); if (filter) { - const { hasStableBeams, fillNumbers } = filter; + const { hasStableBeams, fillNumbers, beamDuration } = filter; if (hasStableBeams) { // For now, if a stableBeamsStart is present, then a beam is stable queryBuilder.where('stableBeamsStart').not().is(null); @@ -62,6 +62,11 @@ class GetAllLhcFillsUseCase { : queryBuilder.where('fillNumber').oneOf(...finalFillnumberList); } } + // Beam duration filter, limit and corresponding operator. + if (beamDuration?.limit !== undefined && beamDuration?.operator) { + const beamDurationLimit = Number(beamDuration.limit) === 0 ? null : beamDuration.limit; + queryBuilder.where('stableBeamsDuration').applyOperator(beamDuration.operator, beamDurationLimit); + } } const { count, rows } = await TransactionHelper.provide(async () => { diff --git a/lib/utilities/validateTime.js b/lib/utilities/validateTime.js new file mode 100644 index 0000000000..3b9349883b --- /dev/null +++ b/lib/utilities/validateTime.js @@ -0,0 +1,51 @@ +/** + * @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 Joi from 'joi'; +import { NUMERICAL_COMPARISON_OPERATORS } from '../domain/dtos/filters/NumericalComparisonDto.js'; + +const joiTimeDurationErrorText = 'Invalid duration value'; + +/** + * Transform digital time in string format + * + * @param {string} incomingValue The time to transform + * @param {*} helpers The Joi helpers object + * @returns {number|import("joi").ValidationError} The value if transformation passes, as seconds (Number) + */ +export const transformTime = (incomingValue, helpers) => { + try { + // Extract time to seconds... + const [hoursStr, minutesStr, secondsStr] = incomingValue.split(':'); + + const hours = Number(hoursStr); + const minutes = Number(minutesStr); + const seconds = Number(secondsStr); + + return hours * 3600 + minutes * 60 + seconds; + } catch (error) { + return helpers.error('any.invalid', { message: `Validation error: ${error?.message ?? 'failed to transform time'}` }); + } +}; + +/** + * Joi object that validates time duration filters. + * This is for duration, not a point in time. 10000:59:59 is valid. + * The operator is also validated. + */ +export const validateTimeDuration = Joi.object({ + limit: Joi.string().trim().pattern(/^\d+:[0-5]?\d:[0-5]?\d$/).custom(transformTime).messages({ + 'string.pattern.base': joiTimeDurationErrorText, + 'string.base': joiTimeDurationErrorText, + }), + operator: Joi.string().valid(...NUMERICAL_COMPARISON_OPERATORS), +}); diff --git a/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js b/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js index fdccf49678..f0f9c89cae 100644 --- a/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js +++ b/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js @@ -100,4 +100,64 @@ module.exports = () => { expect(lhcFill.fillNumber).oneOf([6,3]) }); }) + + // Beam duration filter tests + + it('should only contain specified stable beam durations, < 12:00:00', async () => { + getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '43200', operator: '<'} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto); + expect(lhcFills).to.be.an('array').and.lengthOf(3) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).lessThan(43200) + }); + }); + + it('should only contain specified stable beam durations, <= 12:00:00', async () => { + getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '43200', operator: '<='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + expect(lhcFills).to.be.an('array').and.lengthOf(4) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).lessThanOrEqual(43200) + }); + }) + + it('should only contain specified stable beam durations, = 00:01:40', async () => { + getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '100', operator: '='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + expect(lhcFills).to.be.an('array').and.lengthOf(3) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).equal(100) + }); + }); + + it('should only contain specified stable beam durations, >= 00:01:40', async () => { + getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '100', operator: '>='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(4) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).greaterThanOrEqual(100) + }); + }) + + it('should only contain specified stable beam durations, > 00:01:40', async () => { + getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '100', operator: '>'} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).greaterThan(100) + }); + }) + + it('should only contain specified stable beam durations, = 00:00:00', async () => { + // Tests the usecase's ability to replace the request for 0 to a request for null. + getAllLhcFillsDto.query = { filter: { hasStableBeams: true, beamDuration: {limit: '0', operator: '='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.stableBeamsDuration).equals(null) + }); + }) }; diff --git a/test/public/lhcFills/overview.test.js b/test/public/lhcFills/overview.test.js index 02b05c1591..284c42fec0 100644 --- a/test/public/lhcFills/overview.test.js +++ b/test/public/lhcFills/overview.test.js @@ -24,6 +24,7 @@ const { waitForTableLength, expectLink, openFilteringPanel, + expectAttributeValue, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -267,12 +268,21 @@ module.exports = () => { it('should successfully display filter elements', async () => { const filterSBExpect = { selector: '.stableBeams-filter .w-30', value: 'Stable Beams Only' }; - const filterFillNRExpect = {selector: 'div.items-baseline:nth-child(1) > div:nth-child(1)', value: 'Fill #'} + const filterFillNRExpect = {selector: 'div.items-baseline:nth-child(1) > div:nth-child(1)', value: 'Fill #'}; + const filterSBDurationExpect = {selector: 'div.items-baseline:nth-child(3) > div:nth-child(1)', value: 'SB Duration'}; + const filterSBDurationPlaceholderExpect = {selector: 'input.w-100:nth-child(2)', value: 'e.g 16:14:15 (HH:MM:SS)'}; + const filterSBDurationOperatorExpect = { value: true }; + + await goToPage(page, 'lhc-fill-overview'); // Open the filtering panel await openFilteringPanel(page); + // Note: expectAttributeValue does not work here. + expect(await page.evaluate(() => document.querySelector('#beam-duration-filter-operator > option:nth-child(3)').selected)).to.equal(filterSBDurationOperatorExpect.value); await expectInnerText(page, filterSBExpect.selector, filterSBExpect.value); await expectInnerText(page, filterFillNRExpect.selector, filterFillNRExpect.value); + await expectInnerText(page, filterSBDurationExpect.selector, filterSBDurationExpect.value); + await expectAttributeValue(page, filterSBDurationPlaceholderExpect.selector, 'placeholder', filterSBDurationPlaceholderExpect.value); }); it('should successfully un-apply Stable Beam filter menu', async () => {