diff --git a/lib/domain/dtos/filters/LhcFillsFilterDto.js b/lib/domain/dtos/filters/LhcFillsFilterDto.js index d1d2af5929..8d0ab51242 100644 --- a/lib/domain/dtos/filters/LhcFillsFilterDto.js +++ b/lib/domain/dtos/filters/LhcFillsFilterDto.js @@ -12,10 +12,17 @@ */ const Joi = require('joi'); const { validateRange } = require('../../../utilities/rangeUtils'); +const { validateTime } = require('../../../utilities/validateTime'); exports.LhcFillsFilterDto = Joi.object({ hasStableBeams: Joi.boolean(), fillNumbers: Joi.string().trim().custom(validateRange).messages({ 'any.invalid': '{{#message}}', }), + beamDuration: { + limit: Joi.string().trim().min(8).max(8).custom(validateTime).messages({ + 'any.invalid': '{{#message}}', + }), + operator: Joi.string().trim().min(1).max(2), + }, }); diff --git a/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js b/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js new file mode 100644 index 0000000000..a6b19fe87a --- /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, + { classes: ['w-100', 'beam-duration-filter'], placeholder: 'e.g 16:14:15 (HH:MM:SS)' }, + ); + + return comparisonOperatorFilter(amountFilter, beamDurationFilterModel.operatorSelectionModel.value, (value) => + beamDurationFilterModel.operatorSelectionModel.select(value)); +}; 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..8cff2f42e4 --- /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$); + + this._operandInputModel = new RawTextFilterModel(); + this._operandInputModel.visualChange$.bubbleTo(this._visualChange$); + this._operandInputModel.bubbleTo(this); + + this._operatorSelectionModel.observe(() => this._operandInputModel.value ? this.notify() : this._visualChange$.notify()); + } + + /** + * 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/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/LhcFills.js b/lib/public/views/LhcFills/LhcFills.js index 70b6c5eb3d..aa64a09ef0 100644 --- a/lib/public/views/LhcFills/LhcFills.js +++ b/lib/public/views/LhcFills/LhcFills.js @@ -29,7 +29,7 @@ export default class LhcFills extends Observable { this.model = model; // Sub-models - this._overviewModel = new LhcFillsOverviewModel(true); + this._overviewModel = new LhcFillsOverviewModel(model, true); this._overviewModel.bubbleTo(this); this._detailsModel = new LhcFillDetailsModel(); diff --git a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js index 55a417dc66..de7530b577 100644 --- a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js +++ b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js @@ -17,6 +17,10 @@ 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 { debounce } from '../../../utilities/debounce.js'; +import { TextComparisonFilterModel } from '../../../components/Filters/common/filters/TextComparisonFilterModel.js'; + +const defaultBeamDurationOperator = '='; /** * Model for the LHC fills overview page @@ -27,16 +31,20 @@ 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) { + constructor(model, stableBeamsOnly = false) { super(); this._filteringModel = new FilteringModel({ fillNumbers: new RawTextFilterModel(), + beamDuration: new TextComparisonFilterModel(), hasStableBeams: new StableBeamFilterModel(), }); + this._beamDurationOperator = defaultBeamDurationOperator; + this._filteringModel.observe(() => this._applyFilters(true)); this._filteringModel.visualChange$.bubbleTo(this); @@ -45,6 +53,12 @@ export class LhcFillsOverviewModel extends OverviewPageModel { if (stableBeamsOnly) { this._filteringModel.get('hasStableBeams').setStableBeamsOnly(true); } + + const updateDebounceTime = () => { + this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); + }; + model.appConfiguration$.observe(() => updateDebounceTime()); + updateDebounceTime(); } /** @@ -61,7 +75,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); } /** @@ -81,6 +98,7 @@ export class LhcFillsOverviewModel extends OverviewPageModel { */ resetFiltering(fetch = true) { this._filteringModel.reset(); + this._beamDurationOperator = defaultBeamDurationOperator; if (fetch) { this._applyFilters(true); @@ -106,11 +124,12 @@ 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} */ - _applyFilters() { + _applyFilters(now = false) { this._pagination.currentPage = 1; - this.load(); + now ? this.load() : this._debouncedLoad(true); } } 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..f584e7b750 --- /dev/null +++ b/lib/utilities/validateTime.js @@ -0,0 +1,26 @@ +import Joi from 'joi'; + +/** + * Validates digital time in string format + * + * @param {*} incomingValue The time to validate + * @param {*} helpers The helpers object + * @returns {number|import("joi").ValidationError} The value if validation passes, as seconds (Number) + */ +export const validateTime = (incomingValue, helpers) => { + // Checks for valid time format. + const { error, value } = Joi.string().pattern(/^\d{2}:[0-5]\d:[0-5]\d$/).validate(incomingValue); + + if (error !== undefined) { + return helpers.error('any.invalid', { message: `Validation error: ${error?.message ?? 'failed to validate time'}` }); + } + + // Extract time to seconds... + const [hoursStr, minutesStr, secondsStr] = value.split(':'); + + const hours = Number(hoursStr); + const minutes = Number(minutesStr); + const seconds = Number(secondsStr); + + return hours * 3600 + minutes * 60 + seconds; +}; 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..1b69518047 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'); @@ -268,11 +269,17 @@ 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 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)'} + + await goToPage(page, 'lhc-fill-overview'); // Open the filtering panel await openFilteringPanel(page); 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 () => {