From ca3b94f524d88084a88682a7a1e2b7f23ff8e304 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 13:26:33 +0200 Subject: [PATCH 01/25] refactor --- lib/public/models/DataExportModel.js | 128 +++++++++++++++++ .../views/Runs/Overview/RunsOverviewModel.js | 86 ++---------- .../views/Runs/Overview/RunsOverviewPage.js | 6 +- .../Overview/exportRunsTriggerAndModal.js | 129 ------------------ .../Runs/Overview/exportTriggerAndModal.js | 129 ++++++++++++++++++ .../RunsPerDataPassOverviewPage.js | 4 +- .../RunsPerLhcPeriodOverviewPage.js | 4 +- .../RunsPerSimulationPassOverviewPage.js | 4 +- 8 files changed, 279 insertions(+), 211 deletions(-) create mode 100644 lib/public/models/DataExportModel.js delete mode 100644 lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js create mode 100644 lib/public/views/Runs/Overview/exportTriggerAndModal.js diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js new file mode 100644 index 0000000000..0868de1e45 --- /dev/null +++ b/lib/public/models/DataExportModel.js @@ -0,0 +1,128 @@ +/** + * @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'; +import { createCSVExport, createJSONExport } from '../utilities/export.js'; +import pick from '../utilities/pick.js'; + +/** + * Model handling export configuration and creation + */ +export class DataExportModel extends Observable { + /** + * Constructor + * @param {ObservableData>} items$ observable data used as source for export + */ + constructor(items$) { + super(); + + /** @type {ObservableData>} */ + this._items$ = items$; + this._items$.bubbleTo(this); + + /** @type {string[]} */ + this._selectedFields = []; + + /** @type {string} */ + this._selectedExportType = 'JSON'; + + /** @type {Observable} */ + this._visualChange$ = new Observable(); + + this._exportName = 'data'; + + this.columnFormats = null; + } + + /** + * Return the current items remote data + * + * @return {RemoteData} the items + */ + get items() { + return this._items$.getCurrent(); + } + + /** + * Observable notified when the export configuration visually changes + * @return {Observable} + */ + get visualChange$() { + return this._visualChange$; + } + + /** + * Get export type selected by the user + * @return {string} export type + */ + get selectedExportType() { + return this._selectedExportType; + } + + /** + * Set export type + * @param {string} exportType export type + * @return {void} + */ + setSelectedExportType(exportType) { + this._selectedExportType = exportType; + this.notify(); + this._visualChange$.notify(); + } + + /** + * Get selected fields + * @return {string[]} selected fields + */ + get selectedFields() { + return this._selectedFields; + } + + /** + * Update selected fields from HTML options list + * @param {HTMLCollection|Array} selectedOptions options collection + * @return {void} + */ + setSelectedFields(selectedOptions) { + this._selectedFields = []; + [...selectedOptions].forEach(({ value }) => this._selectedFields.push(value)); + this.notify(); + this._visualChange$.notify(); + } + + /** + * Create export using current items observable + * @return {Promise} void + */ + async createExport() { + this.items.apply({ + Success: (items) => { + const { selectedFields } = this; + + const formatted = items.map((item) => { + const selectedEntries = Object.entries(pick(item, selectedFields)); + const mappedEntries = selectedEntries.map(([key, value]) => { + const formatter = this.columnFormats[key]?.exportFormat || ((v) => v); + return [key, formatter(value, item)]; + }); + + return Object.fromEntries(mappedEntries); + }); + + this.selectedExportType === 'CSV' + ? createCSVExport(formatted, `${this._exportName}.csv`, 'text/csv;charset=utf-8;') + : createJSONExport(formatted, `${this._exportName}.json`, 'application/json'); + }, + }); + } +} diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index 6f1aef4e02..bc13d9a859 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -12,13 +12,11 @@ */ import { buildUrl, RemoteData } from '/js/src/index.js'; -import { createCSVExport, createJSONExport } from '../../../utilities/export.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; import { debounce } from '../../../utilities/debounce.js'; import { DetectorsFilterModel } from '../../../components/Filters/RunsFilter/DetectorsFilterModel.js'; import { RunTypesFilterModel } from '../../../components/runTypes/RunTypesFilterModel.js'; import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/EorReasonFilterModel.js'; -import pick from '../../../utilities/pick.js'; import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { CombinationOperator } from '../../../components/Filters/common/CombinationOperatorChoiceModel.js'; @@ -35,6 +33,7 @@ import { RawTextFilterModel } from '../../../components/Filters/common/filters/R import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter/RunDefinitionFilterModel.js'; import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; +import { DataExportModel } from '../../../models/DataExportModel.js'; /** * Model representing handlers for runs page @@ -100,10 +99,21 @@ export class RunsOverviewModel extends OverviewPageModel { const updateDebounceTime = () => { this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); }; + + this._exportModel = new DataExportModel(this._observableItems); + model.appConfiguration$.observe(() => updateDebounceTime()); updateDebounceTime(); } + /** + * Get export model + * @return {DataExportModel} export model + */ + get exportModel() { + return this._exportModel; + } + /** * @inheritdoc */ @@ -119,55 +129,6 @@ export class RunsOverviewModel extends OverviewPageModel { super.load(); } - /** - * Create the export with the variables set in the model, handling errors appropriately - * @param {object[]} runs The source content. - * @param {string} fileName The name of the file including the output format. - * @param {Object>} exportFormats defines how particular fields of data units will be formated - * @return {void} - */ - async createRunsExport(runs, fileName, exportFormats) { - if (runs.length > 0) { - const selectedRunsFields = this.getSelectedRunsFields() || []; - runs = runs.map((selectedRun) => { - const entries = Object.entries(pick(selectedRun, selectedRunsFields)); - const formattedEntries = entries.map(([key, value]) => { - const formatExport = exportFormats[key].exportFormat || ((identity) => identity); - return [key, formatExport(value, selectedRun)]; - }); - return Object.fromEntries(formattedEntries); - }); - this.getSelectedExportType() === 'CSV' - ? createCSVExport(runs, `${fileName}.csv`, 'text/csv;charset=utf-8;') - : createJSONExport(runs, `${fileName}.json`, 'application/json'); - } else { - this._observableItems.setCurrent(RemoteData.failure([ - { - title: 'No data found', - detail: 'No valid runs were found for provided run number(s)', - }, - ])); - this.notify(); - } - } - - /** - * Get the field values that will be exported - * @return {Array} the field objects of the current export being created - */ - getSelectedRunsFields() { - return this.selectedRunsFields; - } - - /** - * Get the output format of the export - * - * @return {string} the output format - */ - getSelectedExportType() { - return this.selectedExportType; - } - /** * Returns all filtering, sorting and pagination settings to their default values * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset @@ -220,29 +181,6 @@ export class RunsOverviewModel extends OverviewPageModel { return this._filteringModel; } - /** - * Set the export type parameter of the current export being created - * @param {string} selectedExportType Received string from the view - * @return {void} - */ - setSelectedExportType(selectedExportType) { - this.selectedExportType = selectedExportType; - this.notify(); - } - - /** - * Updates the selected fields ID array according to the HTML attributes of the options - * - * @param {HTMLCollection} selectedOptions The currently selected fields by the user, - * according to HTML specification - * @return {undefined} - */ - setSelectedRunsFields(selectedOptions) { - this.selectedRunsFields = []; - [...selectedOptions].map((selectedOption) => this.selectedRunsFields.push(selectedOption.value)); - this.notify(); - } - /** * Getter for the trigger values filter Set * @return {Set} set of trigger filter values diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index 3e689a5472..96eaf04126 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewPage.js +++ b/lib/public/views/Runs/Overview/RunsOverviewPage.js @@ -13,7 +13,7 @@ import { h } from '/js/src/index.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportRunsTriggerAndModal } from './exportRunsTriggerAndModal.js'; +import { exportTriggerAndModal } from './exportTriggerAndModal.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; @@ -51,12 +51,14 @@ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, m PAGE_USED_HEIGHT, )); + runsOverviewModel.exportModel.columnFormats = runsActiveColumns; + return h('', [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(runsOverviewModel, runsActiveColumns), h('.pl2#runOverviewFilter', runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers'))), togglePhysicsOnlyFilter(runsOverviewModel.filteringModel.get('definitions')), - exportRunsTriggerAndModal(runsOverviewModel, modalModel), + exportTriggerAndModal(runsOverviewModel.exportModel, modalModel), ]), h('.flex-column.w-100', [ table(runsOverviewModel.items, runsActiveColumns), diff --git a/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js b/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js deleted file mode 100644 index 54c0eb6611..0000000000 --- a/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js +++ /dev/null @@ -1,129 +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 { h } from '/js/src/index.js'; -import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; - -/** - * Export form component, containing the fields to export, the export type and the export button - * - * @param {RunsOverviewModel} runsOverviewModel the runsOverviewModel - * @param {array} runs the runs to export - * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export - * - * @return {vnode[]} the form component - */ -const exportForm = (runsOverviewModel, runs, modalHandler) => { - const exportTypes = ['JSON', 'CSV']; - const selectedRunsFields = runsOverviewModel.getSelectedRunsFields() || []; - const selectedExportType = runsOverviewModel.getSelectedExportType() || exportTypes[0]; - const runsFields = Object.keys(runsActiveColumns); - const enabled = selectedRunsFields.length > 0; - - return [ - runsOverviewModel.isAllRunsTruncated - ? h( - '#truncated-export-warning.warning', - `The runs export is limited to ${runs.length} entries, only the last runs will be exported (sorted by run number)`, - ) - : null, - h('label.form-check-label.f4.mt1', { for: 'run-number' }, 'Fields'), - h( - 'label.form-check-label.f6', - { for: 'run-number' }, - 'Select which fields to be exported. (CTRL + click for multiple selection)', - ), - h('select#fields.form-control', { - style: 'min-height: 20rem;', - multiple: true, - onchange: ({ target }) => runsOverviewModel.setSelectedRunsFields(target.selectedOptions), - }, [ - ...runsFields - .filter((name) => !['id', 'actions'].includes(name)) - .map((name) => h('option', { - value: name, - selected: selectedRunsFields.length ? selectedRunsFields.includes(name) : false, - }, name)), - ]), - h('label.form-check-label.f4.mt1', { for: 'run-number' }, 'Export type'), - h('label.form-check-label.f6', { for: 'run-number' }, 'Select output format'), - h('.flex-row.g3', exportTypes.map((exportType) => { - const id = `runs-export-type-${exportType}`; - return h('.form-check', [ - h('input.form-check-input', { - id, - type: 'radio', - value: exportType, - checked: selectedExportType.length ? selectedExportType.includes(exportType) : false, - name: 'runs-export-type', - onclick: () => runsOverviewModel.setSelectedExportType(exportType), - }), - h('label.form-check-label', { - for: id, - }, exportType), - ]); - })), - h('button.shadow-level1.btn.btn-success.mt2#send', { - disabled: !enabled, - onclick: async () => { - await runsOverviewModel.createRunsExport( - runs, - 'runs', - runsActiveColumns, - ); - modalHandler.dismiss(); - }, - }, runs ? 'Export' : 'Loading data'), - ]; -}; - -const errorDisplay = () => h('.danger', 'Data fetching failed'); - -/** - * A function to construct the exports runs screen - * @param {RunsOverviewModel} runsOverviewModel Pass the model to access the defined functions - * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export - * @return {Component} Return the view of the inputs - */ -const exportRunsModal = (runsOverviewModel, modalHandler) => { - const runsRemoteData = runsOverviewModel.allRuns; - - return h('div#export-runs-modal', [ - h('h2', 'Export Runs'), - runsRemoteData.match({ - NotAsked: () => errorDisplay(), - Loading: () => exportForm(runsOverviewModel, null, modalHandler), - Success: (payload) => exportForm(runsOverviewModel, payload, modalHandler), - Failure: () => errorDisplay(), - }), - ]); -}; - -/** - * Builds a button which will open popover for data export - * @param {RunsOverviewModel} runsModel runs overview model - * @param {ModelModel} modalModel modal model - * @param {object} [displayConfiguration] additional display options - * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not - * @returns {Component} button - */ -export const exportRunsTriggerAndModal = (runsModel, modalModel, { autoMarginLeft = true } = {}) => - h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-runs-trigger`, { - disabled: runsModel.items.match({ - Success: (payload) => payload.length === 0, - NotAsked: () => true, - Failure: () => true, - Loading: () => true, - }), - onclick: () => modalModel.display({ content: (modalModel) => exportRunsModal(runsModel, modalModel), size: 'medium' }), - }, 'Export Runs'); diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js new file mode 100644 index 0000000000..1c1de68e42 --- /dev/null +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -0,0 +1,129 @@ +/** + * @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 { h } from '/js/src/index.js'; + +/** + * Export form component, containing the fields to export, the export type and the export button + * + * @param {RunsOverviewModel} exportModel the runsOverviewModel + * @param {array} runs the runs to export + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * + * @return {vnode[]} the form component + */ +const exportForm = (exportModel, modalHandler) => { + const exportTypes = ['JSON', 'CSV']; + + const { selectedFields } = exportModel; + const { selectedExportType } = exportModel; + const fields = Object.keys(exportModel.columnFormats); + + const fieldsSelected = selectedFields.length > 0; + + const fieldsSelectionHeader1 = h('label.form-check-label.f4.mt1', { for: '' }, 'Fields'); + const fieldsSelectionHeader2 = h( + 'label.form-check-label.f6', + { for: '' }, + 'Select which fields to be exported. (CTRL + click for multiple selection)', + ); + + const fieldsSelection = h('select#fields.form-control', { + style: 'min-height: 20rem;', + multiple: true, + onchange: ({ target }) => exportModel.setSelectedFields(target.selectedOptions), + }, [ + ...fields + .filter((name) => !['id', 'actions'].includes(name)) + .map((name) => h('option', { + value: name, + selected: selectedFields.length ? selectedFields.includes(name) : false, + }, name)), + ]); + + const exportTypeSelectionHeader1 = h('label.form-check-label.f4.mt1', { for: '' }, 'Export type'); + const exportTypeSelectionHeader2 = h('label.form-check-label.f6', { for: '' }, 'Select output format'); + + const exportTypeSelect = h('.flex-row.g3', exportTypes.map((exportType) => { + const id = `runs-export-type-${exportType}`; + return h('.form-check', [ + h('input.form-check-input', { + id, + type: 'radio', + value: exportType, + checked: selectedExportType.length ? selectedExportType.includes(exportType) : false, + name: 'export-type', + onclick: () => exportModel.setSelectedExportType(exportType), + }), + h('label.form-check-label', { + for: id, + }, exportType), + ]); + })); + + const dataAvailable = exportModel.items.apply({ Success: () => true, Other: () => false }); + + const exportBtn = h('button.shadow-level1.btn.btn-success.mt2#send', { + disabled: !(fieldsSelected && dataAvailable), + onclick: async () => { + await exportModel.createExport(); + modalHandler.dismiss(); + }, + }, dataAvailable ? 'Export' : 'Loading data'); + + return [ + fieldsSelectionHeader1, + fieldsSelectionHeader2, + fieldsSelection, + exportTypeSelectionHeader1, + exportTypeSelectionHeader2, + exportTypeSelect, + exportBtn, + ]; +}; + +const errorDisplay = () => h('.danger', 'Data fetching failed'); + +/** + * A function to construct the exports runs screen + * + * @param {ExportModel} exportModel pass the model to access the defined functions + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * @return {Component} Return the view of the inputs + */ +const exportModal = (exportModel, modalHandler) => h('div#export-runs-modal', [ + h('h2', 'Export Runs'), + exportModel.items.match({ + NotAsked: () => errorDisplay(), + Failure: () => errorDisplay(), + Other: () => exportForm(exportModel, modalHandler), + }), +]); + +/** + * Builds a button which will open popover for data export + * + * @param {ExportModel} exportModel runs overview model + * @param {ModelModel} modalModel modal model + * @param {object} [displayConfiguration] additional display options + * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not + * @returns {Component} button + */ +export const exportTriggerAndModal = (exportModel, modalModel, { autoMarginLeft = true } = {}) => + h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-runs-trigger`, { + disabled: exportModel.items.match({ + Success: (payload) => payload.length === 0, + Other: () => true, + }), + onclick: () => modalModel.display({ content: (modalModel) => exportModal(exportModel, modalModel), size: 'medium' }), + }, 'Export data'); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index e629b13730..0ed8890445 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -15,7 +15,7 @@ import { DropdownComponent, h, iconWarning, sessionService, switchCase } from '/ import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; +import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; @@ -239,7 +239,7 @@ export const RunsPerDataPassOverviewPage = ({ h('#actions-dropdown-button', DropdownComponent( h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), h('.flex-column.p2.g2', [ - exportRunsTriggerAndModal(perDataPassOverviewModel, modalModel, { autoMarginLeft: false }), + exportTriggerAndModal(perDataPassOverviewModel, modalModel, { autoMarginLeft: false }), frontLink( h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { disabled: runDetectorsSelectionIsEmpty, diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index 1ae252ab85..450794638b 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -15,7 +15,7 @@ import { h } from '/js/src/index.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; +import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { runDetectorsQualitiesActiveColumns } from '../ActiveColumns/runDetectorsQualitiesActiveColumns.js'; import { inelasticInteractionRateActiveColumnsForProtonProton } from '../ActiveColumns/inelasticInteractionRateActiveColumnsForProtonProton.js'; @@ -106,7 +106,7 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel return h('.intermediate-flex-column', [ h('.flex-row.justify-between.g2', [ h('h2', `Good, physics runs of ${lhcPeriodName}`), - exportRunsTriggerAndModal(perLhcPeriodOverviewModel, modalModel), + exportTriggerAndModal(perLhcPeriodOverviewModel, modalModel), ]), ...tabbedPanelComponent( tabbedPanelModel, diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js index 75f6a88736..fe2975fb46 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js @@ -15,7 +15,7 @@ import { h } from '/js/src/index.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; +import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import spinner from '../../../components/common/spinner.js'; @@ -95,7 +95,7 @@ export const RunsPerSimulationPassOverviewPage = ({ breadcrumbs([commonTitle, h('h2#breadcrumb-simulation-pass-name', simulationPass.name)]), ), h('.mlauto', qcSummaryLegendTooltip()), - exportRunsTriggerAndModal(perSimulationPassOverviewModel, modalModel, { autoMarginLeft: false }), + exportTriggerAndModal(perSimulationPassOverviewModel, modalModel, { autoMarginLeft: false }), frontLink( h( 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', From 2a799733b91eb1ea19e75d14454ea590e857bfaa Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 15:21:02 +0200 Subject: [PATCH 02/25] handle btn disable and itemsCount --- lib/public/models/DataExportModel.js | 34 ++++++++++- lib/public/models/OverviewModel.js | 60 ++++++++++++++++--- .../views/Runs/Overview/RunsOverviewModel.js | 6 +- .../Runs/Overview/exportTriggerAndModal.js | 24 ++++---- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js index 0868de1e45..c5699a1470 100644 --- a/lib/public/models/DataExportModel.js +++ b/lib/public/models/DataExportModel.js @@ -22,8 +22,9 @@ export class DataExportModel extends Observable { /** * Constructor * @param {ObservableData>} items$ observable data used as source for export + * @param {function} onNoDataAction */ - constructor(items$) { + constructor(items$, onNoDataAction) { super(); /** @type {ObservableData>} */ @@ -42,6 +43,37 @@ export class DataExportModel extends Observable { this._exportName = 'data'; this.columnFormats = null; + this._onNoDataAction = onNoDataAction; + + this._disabled = null; + + this._totalExistingItemsCount = null; + } + + get totalExistingItemsCount() { + return this._totalExistingItemsCount; + } + + setTotalExistingItemsCount(totalExistingItemsCount) { + this._totalExistingItemsCount = totalExistingItemsCount; + } + + get disabled() { + return this._disabled; + } + + setDisabled(value) { + this._disabled = value; + } + + /** + * + */ + askForData() { + this.items.match({ + NotAsked: () => this._onNoDataAction(), + Other: () => null, + }); } /** diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 68f6250b5d..522d9806db 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -56,8 +56,19 @@ export class OverviewPageModel extends Observable { this._dataSource = new PaginatedRemoteDataSource(); this._dataSource.pipe(dataSourceObservable); - this._observableItems = ObservableData.builder().initialValue(RemoteData.loading()).build(); - this._observableItems.bubbleTo(this); + this._item$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); + this._item$.bubbleTo(this); + + { + const allDataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); + allDataSourceObservable.observe(() => this.parseAllApiRemoteData(allDataSourceObservable.getCurrent())); + + this._allDataSource = new PaginatedRemoteDataSource(); + this._allDataSource.pipe(allDataSourceObservable); + + this._allItems$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); + this._allItems$.bubbleTo(this); + } /** * @type {ObservableData} @@ -84,7 +95,7 @@ export class OverviewPageModel extends Observable { * @returns {void} */ reset() { - this._observableItems.setCurrent(RemoteData.notAsked()); + this._item$.setCurrent(RemoteData.notAsked()); this._pagination.reset(); } @@ -102,8 +113,8 @@ export class OverviewPageModel extends Observable { const keepExisting = this._pagination.currentPage > 1 && this._pagination.isInfiniteScrollEnabled; remoteData.match({ - NotAsked: () => this._observableItems.setCurrent(RemoteData.notAsked()), - Loading: () => this._pagination.isInfiniteScrollEnabled ? null : this._observableItems.setCurrent(RemoteData.loading()), + NotAsked: () => this._item$.setCurrent(RemoteData.notAsked()), + Loading: () => this._pagination.isInfiniteScrollEnabled ? null : this._item$.setCurrent(RemoteData.loading()), Success: ({ items, totalCount }) => { const concatenateWith = keepExisting ? this.items.match({ Success: (payload) => payload, @@ -112,9 +123,26 @@ export class OverviewPageModel extends Observable { Failure: () => [], }) : []; this._pagination.itemsCount = totalCount; - this._observableItems.setCurrent(RemoteData.success([...concatenateWith, ...this.processItems(items)])); + this._item$.setCurrent(RemoteData.success([...concatenateWith, ...this.processItems(items)])); }, - Failure: (error) => this._observableItems.setCurrent(RemoteData.failure(error)), + Failure: (error) => this._item$.setCurrent(RemoteData.failure(error)), + }); + } + + /** + * Parse the API remote data to extract the list of items + * + * @param {RemoteData<{items: T[], totalCount: number}, *>} remoteData the API remote data + * @return {void} + */ + parseAllApiRemoteData(remoteData) { + remoteData.match({ + NotAsked: () => this._allItems$.setCurrent(RemoteData.notAsked()), + Loading: () => this._allItems$.setCurrent(RemoteData.loading()), + Success: ({ items }) => { + this._allItems$.setCurrent(RemoteData.success(this.processItems(items))); + }, + Failure: (error) => this._allItems$.setCurrent(RemoteData.failure(error)), }); } @@ -130,14 +158,26 @@ export class OverviewPageModel extends Observable { /** * Fetch all the relevant items from the API + * it takes into account pagination parameters * * @return {Promise} void */ async load() { const params = await this.getLoadParameters(); + this._allItems$.setCurrent(RemoteData.notAsked()); await this._dataSource.fetch(buildUrl(this.getRootEndpoint(), params)); } + /** + * Fetch all the relevant items from the API + * it does not take into account pagincation parameters + * + * @return {Promise} void + */ + async loadAll() { + await this._allDataSource.fetch(this.getRootEndpoint()); + } + /** * Return the query params to use to get load the overview data * @@ -163,7 +203,7 @@ export class OverviewPageModel extends Observable { * @return {RemoteData} the items */ get items() { - return this._observableItems.getCurrent(); + return this._item$.getCurrent(); } /** @@ -200,4 +240,8 @@ export class OverviewPageModel extends Observable { get sortModel() { return this._sortModel; } + + hasAnyData() { + return this._item$.getCurrent().match({ Success: ({ length = 0 } = {}) => length > 0, Other: () => false }); + } } diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index bc13d9a859..839e141a2b 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -100,7 +100,11 @@ export class RunsOverviewModel extends OverviewPageModel { this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); }; - this._exportModel = new DataExportModel(this._observableItems); + this._exportModel = new DataExportModel(this._allItems$, () => this.loadAll()); + this._item$.observe(() => { + this._exportModel.setDisabled(!this.hasAnyData()); + this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); + }); model.appConfiguration$.observe(() => updateDebounceTime()); updateDebounceTime(); diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 1c1de68e42..b6cc9361c7 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -101,14 +101,17 @@ const errorDisplay = () => h('.danger', 'Data fetching failed'); * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export * @return {Component} Return the view of the inputs */ -const exportModal = (exportModel, modalHandler) => h('div#export-runs-modal', [ - h('h2', 'Export Runs'), - exportModel.items.match({ - NotAsked: () => errorDisplay(), - Failure: () => errorDisplay(), - Other: () => exportForm(exportModel, modalHandler), - }), -]); +const exportModal = (exportModel, modalHandler) => { + exportModel.askForData(); + return h('div#export-runs-modal', [ + h('h2', 'Export data'), + exportModel.items.match({ + NotAsked: () => errorDisplay(), + Failure: () => errorDisplay(), + Other: () => exportForm(exportModel, modalHandler), + }), + ]); +}; /** * Builds a button which will open popover for data export @@ -121,9 +124,6 @@ const exportModal = (exportModel, modalHandler) => h('div#export-runs-modal', [ */ export const exportTriggerAndModal = (exportModel, modalModel, { autoMarginLeft = true } = {}) => h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-runs-trigger`, { - disabled: exportModel.items.match({ - Success: (payload) => payload.length === 0, - Other: () => true, - }), + disabled: exportModel.disabled, onclick: () => modalModel.display({ content: (modalModel) => exportModal(exportModel, modalModel), size: 'medium' }), }, 'Export data'); From 30f6ffccf2248b9410ca8cb3af5ee4b23e3f0088 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 15:43:47 +0200 Subject: [PATCH 03/25] show data are truncated --- .../views/Runs/Overview/exportTriggerAndModal.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index b6cc9361c7..35193f1032 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -71,7 +71,7 @@ const exportForm = (exportModel, modalHandler) => { ]); })); - const dataAvailable = exportModel.items.apply({ Success: () => true, Other: () => false }); + const dataAvailable = exportModel.items.match({ Success: () => true, Other: () => false }); const exportBtn = h('button.shadow-level1.btn.btn-success.mt2#send', { disabled: !(fieldsSelected && dataAvailable), @@ -81,7 +81,15 @@ const exportForm = (exportModel, modalHandler) => { }, }, dataAvailable ? 'Export' : 'Loading data'); + const dataLength = exportModel.items.match({ Success: ({ length } = {}) => length, Other: () => null }); + const { totalExistingItemsCount } = exportModel; return [ + dataLength && dataLength < totalExistingItemsCount + ? h( + '#truncated-export-warning.warning', + `The data export is limited to ${dataLength} entries, only the last data will be exported`, + ) + : null, fieldsSelectionHeader1, fieldsSelectionHeader2, fieldsSelection, From 4c41d6c95c0d6dffc5214d3bf4c2d5846c49eb98 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 15:56:53 +0200 Subject: [PATCH 04/25] docS --- lib/public/models/DataExportModel.js | 49 +++++++++++++------ .../Runs/Overview/exportTriggerAndModal.js | 2 +- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js index c5699a1470..971471d771 100644 --- a/lib/public/models/DataExportModel.js +++ b/lib/public/models/DataExportModel.js @@ -21,59 +21,76 @@ import pick from '../utilities/pick.js'; export class DataExportModel extends Observable { /** * Constructor - * @param {ObservableData>} items$ observable data used as source for export - * @param {function} onNoDataAction + * @param {ObservableData>} items$ observable data used as source for export + * @param {function} onDataNotAskedAction */ - constructor(items$, onNoDataAction) { + constructor(items$, onDataNotAskedAction) { super(); - /** @type {ObservableData>} */ this._items$ = items$; this._items$.bubbleTo(this); - /** @type {string[]} */ this._selectedFields = []; - /** @type {string} */ this._selectedExportType = 'JSON'; - /** @type {Observable} */ this._visualChange$ = new Observable(); this._exportName = 'data'; this.columnFormats = null; - this._onNoDataAction = onNoDataAction; + this._onDataNotAskedAction = onDataNotAskedAction; this._disabled = null; this._totalExistingItemsCount = null; } + /** + * Get total number of items that model knows they exist + * @return {number} totalExistingItemsCount + */ get totalExistingItemsCount() { return this._totalExistingItemsCount; } + /** + * Set total number of items that model should they exist + * @param {number} totalExistingItemsCount count + */ setTotalExistingItemsCount(totalExistingItemsCount) { this._totalExistingItemsCount = totalExistingItemsCount; } + /** + * State whether exporting is enabled + * @return {boolean} if true disabled, otherwise enabled + */ get disabled() { return this._disabled; } - setDisabled(value) { - this._disabled = value; + /** + * Set whether exporting is enabled + * @param {boolean} disabled if true disabled, otherwise enabled + */ + setDisabled(disabled) { + this._disabled = disabled; } /** - * + * If the model has no data to be exported, it calls for it via action function provided in constructor + * @return {Promise|null} promise if data were not asked for, null otherwise */ - askForData() { - this.items.match({ - NotAsked: () => this._onNoDataAction(), - Other: () => null, - }); + callForData() { + if (this._onDataNotAskedAction) { + return this.items.match({ + NotAsked: () => this._onDataNotAskedAction(), + Other: () => null, + }); + } else { + throw new Error('onDataNotAskedAction was not provided'); + } } /** diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 35193f1032..7b9eed34c7 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -110,7 +110,7 @@ const errorDisplay = () => h('.danger', 'Data fetching failed'); * @return {Component} Return the view of the inputs */ const exportModal = (exportModel, modalHandler) => { - exportModel.askForData(); + exportModel.callForData(); return h('div#export-runs-modal', [ h('h2', 'Export data'), exportModel.items.match({ From 68fb45fbb10ee42d65feeafcbc235348de54d6d5 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 16:03:04 +0200 Subject: [PATCH 05/25] rename ids --- lib/public/views/Runs/Overview/exportTriggerAndModal.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 7b9eed34c7..2173784854 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -111,7 +111,7 @@ const errorDisplay = () => h('.danger', 'Data fetching failed'); */ const exportModal = (exportModel, modalHandler) => { exportModel.callForData(); - return h('div#export-runs-modal', [ + return h('div#export-data-modal', [ h('h2', 'Export data'), exportModel.items.match({ NotAsked: () => errorDisplay(), @@ -131,7 +131,7 @@ const exportModal = (exportModel, modalHandler) => { * @returns {Component} button */ export const exportTriggerAndModal = (exportModel, modalModel, { autoMarginLeft = true } = {}) => - h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-runs-trigger`, { + h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-data-trigger`, { disabled: exportModel.disabled, onclick: () => modalModel.display({ content: (modalModel) => exportModal(exportModel, modalModel), size: 'medium' }), }, 'Export data'); From 7c89951f86efe4e1b145b6001de925f3fe6e548c Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 16:25:55 +0200 Subject: [PATCH 06/25] add spinner --- .../Runs/Overview/exportTriggerAndModal.js | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 2173784854..370325136e 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -11,6 +11,7 @@ * or submit itself to any jurisdiction. */ +import spinner from '../../../components/common/spinner.js'; import { h } from '/js/src/index.js'; /** @@ -31,12 +32,14 @@ const exportForm = (exportModel, modalHandler) => { const fieldsSelected = selectedFields.length > 0; - const fieldsSelectionHeader1 = h('label.form-check-label.f4.mt1', { for: '' }, 'Fields'); - const fieldsSelectionHeader2 = h( - 'label.form-check-label.f6', - { for: '' }, - 'Select which fields to be exported. (CTRL + click for multiple selection)', - ); + const fieldsSelectionLabels = [ + h('label.form-check-label.f4.mt1', { for: 'fields' }, 'Fields'), + h( + 'label.form-check-label.f6', + { for: 'fields' }, + 'Select which fields to be exported. (CTRL + click for multiple selection)', + ), + ]; const fieldsSelection = h('select#fields.form-control', { style: 'min-height: 20rem;', @@ -51,8 +54,10 @@ const exportForm = (exportModel, modalHandler) => { }, name)), ]); - const exportTypeSelectionHeader1 = h('label.form-check-label.f4.mt1', { for: '' }, 'Export type'); - const exportTypeSelectionHeader2 = h('label.form-check-label.f6', { for: '' }, 'Select output format'); + const exportTypeSelectionHeader = [ + h('.form-check-label.f4.mt1', 'Export type'), + h('.form-check-label.f6', 'Select output format'), + ]; const exportTypeSelect = h('.flex-row.g3', exportTypes.map((exportType) => { const id = `runs-export-type-${exportType}`; @@ -83,18 +88,19 @@ const exportForm = (exportModel, modalHandler) => { const dataLength = exportModel.items.match({ Success: ({ length } = {}) => length, Other: () => null }); const { totalExistingItemsCount } = exportModel; + + const truncatedDataInfo = dataLength && dataLength < totalExistingItemsCount + ? h( + '#truncated-export-warning.warning', + `The data export is limited to ${dataLength} entries, only the last data will be exported`, + ) + : null; + return [ - dataLength && dataLength < totalExistingItemsCount - ? h( - '#truncated-export-warning.warning', - `The data export is limited to ${dataLength} entries, only the last data will be exported`, - ) - : null, - fieldsSelectionHeader1, - fieldsSelectionHeader2, + dataAvailable ? truncatedDataInfo : spinner({ size: 2, absolute: false }), + fieldsSelectionLabels, fieldsSelection, - exportTypeSelectionHeader1, - exportTypeSelectionHeader2, + exportTypeSelectionHeader, exportTypeSelect, exportBtn, ]; From 826ea91883ae610dc282b72faf542914e5c7e71d Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 16:43:44 +0200 Subject: [PATCH 07/25] cleanup --- .../Runs/Overview/exportTriggerAndModal.js | 32 +++++++++++-------- test/public/runs/overview.test.js | 4 +-- .../runs/runsPerDataPass.overview.test.js | 2 +- .../runs/runsPerLhcPeriod.overview.test.js | 2 +- .../runsPerSimulationPass.overview.test.js | 2 +- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 370325136e..4eabfcd90f 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -17,8 +17,7 @@ import { h } from '/js/src/index.js'; /** * Export form component, containing the fields to export, the export type and the export button * - * @param {RunsOverviewModel} exportModel the runsOverviewModel - * @param {array} runs the runs to export + * @param {DataExportModel} exportModel export model * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export * * @return {vnode[]} the form component @@ -54,13 +53,13 @@ const exportForm = (exportModel, modalHandler) => { }, name)), ]); - const exportTypeSelectionHeader = [ - h('.form-check-label.f4.mt1', 'Export type'), - h('.form-check-label.f6', 'Select output format'), + const exportTypeSelectionLabels = [ + h('label.form-check-label.f4.mt1', 'Export type'), + h('label.form-check-label.f6', 'Select output format'), ]; const exportTypeSelect = h('.flex-row.g3', exportTypes.map((exportType) => { - const id = `runs-export-type-${exportType}`; + const id = `data-export-type-${exportType}`; return h('.form-check', [ h('input.form-check-input', { id, @@ -84,7 +83,7 @@ const exportForm = (exportModel, modalHandler) => { await exportModel.createExport(); modalHandler.dismiss(); }, - }, dataAvailable ? 'Export' : 'Loading data'); + }, dataAvailable ? 'Export' : 'Loading data...'); const dataLength = exportModel.items.match({ Success: ({ length } = {}) => length, Other: () => null }); const { totalExistingItemsCount } = exportModel; @@ -92,15 +91,15 @@ const exportForm = (exportModel, modalHandler) => { const truncatedDataInfo = dataLength && dataLength < totalExistingItemsCount ? h( '#truncated-export-warning.warning', - `The data export is limited to ${dataLength} entries, only the last data will be exported`, + `The data export is limited to ${dataLength} entries, only the most recent data will be exported`, ) : null; return [ - dataAvailable ? truncatedDataInfo : spinner({ size: 2, absolute: false }), + truncatedDataInfo, fieldsSelectionLabels, fieldsSelection, - exportTypeSelectionHeader, + exportTypeSelectionLabels, exportTypeSelect, exportBtn, ]; @@ -109,16 +108,21 @@ const exportForm = (exportModel, modalHandler) => { const errorDisplay = () => h('.danger', 'Data fetching failed'); /** - * A function to construct the exports runs screen + * A function to construct the exports data screen * - * @param {ExportModel} exportModel pass the model to access the defined functions + * @param {DataExportModel} exportModel pass the model to access the defined functions * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export * @return {Component} Return the view of the inputs */ const exportModal = (exportModel, modalHandler) => { exportModel.callForData(); + const dataLoading = exportModel.items.match({ Loading: () => true, Other: () => false }); + return h('div#export-data-modal', [ - h('h2', 'Export data'), + h('.flex-row', [ + h('h2.w-50', 'Export data'), + dataLoading ? h('.w-50', spinner({ size: 2, absolute: false })) : null, + ]), exportModel.items.match({ NotAsked: () => errorDisplay(), Failure: () => errorDisplay(), @@ -130,7 +134,7 @@ const exportModal = (exportModel, modalHandler) => { /** * Builds a button which will open popover for data export * - * @param {ExportModel} exportModel runs overview model + * @param {DataExportModel} exportModel export model * @param {ModelModel} modalModel modal model * @param {object} [displayConfiguration] additional display options * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 3fa37fa77f..3f8d005d6d 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -46,7 +46,7 @@ const { runService } = require('../../../lib/server/services/run/RunService.js') const { expect } = chai; -const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; +const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; module.exports = () => { let page; @@ -885,7 +885,7 @@ module.exports = () => { const truncatedExportWarning = await page.waitForSelector('#export-runs-modal #truncated-export-warning'); expect(await truncatedExportWarning.evaluate((warning) => warning.innerText)) .to - .equal('The runs export is limited to 100 entries, only the last runs will be exported (sorted by run number)'); + .equal('The data export is limited to 100 entries, only the most recent data will be exported'); }); it('should successfully display disabled runs export button when there is no runs available', async () => { diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 374b4c22e4..642fafafe2 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -268,7 +268,7 @@ module.exports = () => { // First export await pressElement(page, '#actions-dropdown-button .popover-trigger', true); - await pressElement(page, '#export-runs-trigger'); + await pressElement(page, '#export-data-trigger'); await page.waitForSelector('#export-runs-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); diff --git a/test/public/runs/runsPerLhcPeriod.overview.test.js b/test/public/runs/runsPerLhcPeriod.overview.test.js index 7e23ba1fcf..d6fb93130d 100644 --- a/test/public/runs/runsPerLhcPeriod.overview.test.js +++ b/test/public/runs/runsPerLhcPeriod.overview.test.js @@ -179,7 +179,7 @@ module.exports = () => { await waitForTableLength(page, 4); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; it('should successfully export all runs per lhc Period', async () => { await page.evaluate(() => { diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index adb30973f3..bdd1960dba 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -191,7 +191,7 @@ module.exports = () => { }); it('should successfully export runs', async () => { - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; const targetFileName = 'runs.json'; From e51e69ab53baa8cc33f85709f40a3156d8a917c2 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 16:53:30 +0200 Subject: [PATCH 08/25] refactor --- lib/public/models/DataExportModel.js | 19 ++++++++++++++----- lib/public/models/OverviewModel.js | 5 +++++ .../views/Runs/Overview/RunsOverviewModel.js | 3 ++- .../views/Runs/Overview/RunsOverviewPage.js | 2 -- .../Runs/Overview/exportTriggerAndModal.js | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js index 971471d771..94214de3a8 100644 --- a/lib/public/models/DataExportModel.js +++ b/lib/public/models/DataExportModel.js @@ -22,9 +22,10 @@ export class DataExportModel extends Observable { /** * Constructor * @param {ObservableData>} items$ observable data used as source for export - * @param {function} onDataNotAskedAction + * @param {object} fieldsFormatting defines selectable fields and their formatting + * @param {function} onDataNotAskedAction function to be called in case of missing data */ - constructor(items$, onDataNotAskedAction) { + constructor(items$, fieldsFormatting, onDataNotAskedAction) { super(); this._items$ = items$; @@ -38,7 +39,7 @@ export class DataExportModel extends Observable { this._exportName = 'data'; - this.columnFormats = null; + this._fieldsFormatting = fieldsFormatting; this._onDataNotAskedAction = onDataNotAskedAction; this._disabled = null; @@ -46,6 +47,14 @@ export class DataExportModel extends Observable { this._totalExistingItemsCount = null; } + /** + * Get mapping of selecteble fields to their format functions + * @return {object} mapping + */ + get fieldsFormatting() { + return this._fieldsFormatting; + } + /** * Get total number of items that model knows they exist * @return {number} totalExistingItemsCount @@ -104,7 +113,7 @@ export class DataExportModel extends Observable { /** * Observable notified when the export configuration visually changes - * @return {Observable} + * @return {Observable} the visual change observable */ get visualChange$() { return this._visualChange$; @@ -161,7 +170,7 @@ export class DataExportModel extends Observable { const formatted = items.map((item) => { const selectedEntries = Object.entries(pick(item, selectedFields)); const mappedEntries = selectedEntries.map(([key, value]) => { - const formatter = this.columnFormats[key]?.exportFormat || ((v) => v); + const formatter = this.fieldsFormatting[key]?.exportFormat || ((v) => v); return [key, formatter(value, item)]; }); diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 522d9806db..23ea6691ef 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -241,6 +241,11 @@ export class OverviewPageModel extends Observable { return this._sortModel; } + /** + * State whther some data was successfuly fetched + * + * @return {boolean} true if any data was successfuly fetched, false otherwise + */ hasAnyData() { return this._item$.getCurrent().match({ Success: ({ length = 0 } = {}) => length > 0, Other: () => false }); } diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index 839e141a2b..350c2140bc 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -34,6 +34,7 @@ import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { DataExportModel } from '../../../models/DataExportModel.js'; +import { runsActiveColumns as fieldsFormattingConf } from '../ActiveColumns/runsActiveColumns.js'; /** * Model representing handlers for runs page @@ -100,7 +101,7 @@ export class RunsOverviewModel extends OverviewPageModel { this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); }; - this._exportModel = new DataExportModel(this._allItems$, () => this.loadAll()); + this._exportModel = new DataExportModel(this._allItems$, fieldsFormattingConf, () => this.loadAll()); this._item$.observe(() => { this._exportModel.setDisabled(!this.hasAnyData()); this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index 96eaf04126..2cc1a609bb 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewPage.js +++ b/lib/public/views/Runs/Overview/RunsOverviewPage.js @@ -51,8 +51,6 @@ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, m PAGE_USED_HEIGHT, )); - runsOverviewModel.exportModel.columnFormats = runsActiveColumns; - return h('', [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(runsOverviewModel, runsActiveColumns), diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 4eabfcd90f..753ff0a88f 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -27,7 +27,7 @@ const exportForm = (exportModel, modalHandler) => { const { selectedFields } = exportModel; const { selectedExportType } = exportModel; - const fields = Object.keys(exportModel.columnFormats); + const fields = Object.keys(exportModel.fieldsFormatting); const fieldsSelected = selectedFields.length > 0; From 175b6f1f4a94edcd4d309667d9422a33725ab51e Mon Sep 17 00:00:00 2001 From: xsalonx Date: Wed, 30 Jul 2025 17:18:43 +0200 Subject: [PATCH 09/25] fix --- test/public/runs/overview.test.js | 12 ++++++------ test/public/runs/runsPerDataPass.overview.test.js | 2 +- .../runs/runsPerSimulationPass.overview.test.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 3f8d005d6d..e804c6c755 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -867,12 +867,12 @@ module.exports = () => { }); it('should successfully display runs export modal on click on export button', async () => { - let exportModal = await page.$('#export-runs-modal'); + let exportModal = await page.$('#export-data-modal'); expect(exportModal).to.be.null; await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); - exportModal = await page.$('#export-runs-modal'); + await page.waitForSelector('#export-data-modal'); + exportModal = await page.$('#export-data-modal'); expect(exportModal).to.not.be.null; }); @@ -882,7 +882,7 @@ module.exports = () => { await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); - const truncatedExportWarning = await page.waitForSelector('#export-runs-modal #truncated-export-warning'); + const truncatedExportWarning = await page.waitForSelector('#export-data-modal #truncated-export-warning'); expect(await truncatedExportWarning.evaluate((warning) => warning.innerText)) .to .equal('The data export is limited to 100 entries, only the most recent data will be exported'); @@ -907,7 +907,7 @@ module.exports = () => { // First export await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); + await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); @@ -944,7 +944,7 @@ module.exports = () => { ///// Download await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); + await page.waitForSelector('#export-data-modal'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 642fafafe2..dfc9c45fea 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -269,7 +269,7 @@ module.exports = () => { // First export await pressElement(page, '#actions-dropdown-button .popover-trigger', true); await pressElement(page, '#export-data-trigger'); - await page.waitForSelector('#export-runs-modal'); + await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index bdd1960dba..852f8b6be8 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -197,7 +197,7 @@ module.exports = () => { // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); - await page.waitForSelector('#export-runs-modal'); + await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); From ae3f37704acd633345840e46a297453202eea33f Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 11:19:40 +0200 Subject: [PATCH 10/25] cleanup --- lib/public/models/DataExportModel.js | 37 +++++++++++++++---- lib/public/models/OverviewModel.js | 24 ++++++------ .../views/Runs/Overview/RunsOverviewModel.js | 4 +- .../Runs/Overview/exportTriggerAndModal.js | 2 +- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js index 94214de3a8..f796dacdb8 100644 --- a/lib/public/models/DataExportModel.js +++ b/lib/public/models/DataExportModel.js @@ -15,17 +15,30 @@ import { Observable } from '/js/src/index.js'; import { createCSVExport, createJSONExport } from '../utilities/export.js'; import pick from '../utilities/pick.js'; +/** + * @typedef FieldExportConfiguration + * + * @property {function|null} exportFormat + */ + +/** + * @typdef DataExportConfiguration + * + * @type {object} mapping: field name -> field export configuration + */ + /** * Model handling export configuration and creation */ export class DataExportModel extends Observable { /** * Constructor + * * @param {ObservableData>} items$ observable data used as source for export - * @param {object} fieldsFormatting defines selectable fields and their formatting + * @param {DataExportConfiguration} dataExportConfiguration defines selectable fields and their formatting * @param {function} onDataNotAskedAction function to be called in case of missing data */ - constructor(items$, fieldsFormatting, onDataNotAskedAction) { + constructor(items$, dataExportConfiguration, onDataNotAskedAction) { super(); this._items$ = items$; @@ -39,7 +52,7 @@ export class DataExportModel extends Observable { this._exportName = 'data'; - this._fieldsFormatting = fieldsFormatting; + this._dataExportConfiguration = dataExportConfiguration; this._onDataNotAskedAction = onDataNotAskedAction; this._disabled = null; @@ -48,15 +61,17 @@ export class DataExportModel extends Observable { } /** - * Get mapping of selecteble fields to their format functions - * @return {object} mapping + * Get data export configuration + * + * @return {DataExportConfiguration>} configuration */ - get fieldsFormatting() { - return this._fieldsFormatting; + get dataExportConfiguration() { + return this._dataExportConfiguration; } /** * Get total number of items that model knows they exist + * * @return {number} totalExistingItemsCount */ get totalExistingItemsCount() { @@ -113,6 +128,7 @@ export class DataExportModel extends Observable { /** * Observable notified when the export configuration visually changes + * * @return {Observable} the visual change observable */ get visualChange$() { @@ -121,6 +137,7 @@ export class DataExportModel extends Observable { /** * Get export type selected by the user + * * @return {string} export type */ get selectedExportType() { @@ -129,6 +146,7 @@ export class DataExportModel extends Observable { /** * Set export type + * * @param {string} exportType export type * @return {void} */ @@ -140,6 +158,7 @@ export class DataExportModel extends Observable { /** * Get selected fields + * * @return {string[]} selected fields */ get selectedFields() { @@ -148,6 +167,7 @@ export class DataExportModel extends Observable { /** * Update selected fields from HTML options list + * * @param {HTMLCollection|Array} selectedOptions options collection * @return {void} */ @@ -160,6 +180,7 @@ export class DataExportModel extends Observable { /** * Create export using current items observable + * * @return {Promise} void */ async createExport() { @@ -170,7 +191,7 @@ export class DataExportModel extends Observable { const formatted = items.map((item) => { const selectedEntries = Object.entries(pick(item, selectedFields)); const mappedEntries = selectedEntries.map(([key, value]) => { - const formatter = this.fieldsFormatting[key]?.exportFormat || ((v) => v); + const formatter = this.dataExportConfiguration[key]?.exportFormat || ((v) => v); return [key, formatter(value, item)]; }); diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 23ea6691ef..30bfaea2ee 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -46,12 +46,13 @@ export class OverviewPageModel extends Observable { }); this._sortModel.visualChange$.bubbleTo(this); + // Single page data handling this._pagination = new PaginationModel(); this._pagination.observe(() => this.load()); this._pagination.itemsPerPageSelector$.observe(() => this.notify()); const dataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); - dataSourceObservable.observe(() => this.parseApiRemoteData(dataSourceObservable.getCurrent())); + dataSourceObservable.observe(() => this.parseApiPaginatedRemoteData(dataSourceObservable.getCurrent())); this._dataSource = new PaginatedRemoteDataSource(); this._dataSource.pipe(dataSourceObservable); @@ -59,16 +60,15 @@ export class OverviewPageModel extends Observable { this._item$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); this._item$.bubbleTo(this); - { - const allDataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); - allDataSourceObservable.observe(() => this.parseAllApiRemoteData(allDataSourceObservable.getCurrent())); + // All data handling + const allDataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); + allDataSourceObservable.observe(() => this.parseApiNotPaginatedRemoteData(allDataSourceObservable.getCurrent())); - this._allDataSource = new PaginatedRemoteDataSource(); - this._allDataSource.pipe(allDataSourceObservable); + this._allDataSource = new PaginatedRemoteDataSource(); + this._allDataSource.pipe(allDataSourceObservable); - this._allItems$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); - this._allItems$.bubbleTo(this); - } + this._allItems$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); + this._allItems$.bubbleTo(this); /** * @type {ObservableData} @@ -105,7 +105,7 @@ export class OverviewPageModel extends Observable { * @param {RemoteData<{items: T[], totalCount: number}, *>} remoteData the API remote data * @return {void} */ - parseApiRemoteData(remoteData) { + parseApiPaginatedRemoteData(remoteData) { /* * When fetching data, to avoid concurrency issues, save a flag stating if the fetched data should be concatenated with the current one * (infinite scroll) or if they should replace them @@ -135,7 +135,7 @@ export class OverviewPageModel extends Observable { * @param {RemoteData<{items: T[], totalCount: number}, *>} remoteData the API remote data * @return {void} */ - parseAllApiRemoteData(remoteData) { + parseApiNotPaginatedRemoteData(remoteData) { remoteData.match({ NotAsked: () => this._allItems$.setCurrent(RemoteData.notAsked()), Loading: () => this._allItems$.setCurrent(RemoteData.loading()), @@ -158,7 +158,7 @@ export class OverviewPageModel extends Observable { /** * Fetch all the relevant items from the API - * it takes into account pagination parameters + * it takes into account pagination parameters, but also reset not-paginated observable data * * @return {Promise} void */ diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index 350c2140bc..efa4ba4b22 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -34,7 +34,7 @@ import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { DataExportModel } from '../../../models/DataExportModel.js'; -import { runsActiveColumns as fieldsFormattingConf } from '../ActiveColumns/runsActiveColumns.js'; +import { runsActiveColumns as dataExportConfiguration } from '../ActiveColumns/runsActiveColumns.js'; /** * Model representing handlers for runs page @@ -101,7 +101,7 @@ export class RunsOverviewModel extends OverviewPageModel { this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); }; - this._exportModel = new DataExportModel(this._allItems$, fieldsFormattingConf, () => this.loadAll()); + this._exportModel = new DataExportModel(this._allItems$, dataExportConfiguration, () => this.loadAll()); this._item$.observe(() => { this._exportModel.setDisabled(!this.hasAnyData()); this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 753ff0a88f..92b176a1b3 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -27,7 +27,7 @@ const exportForm = (exportModel, modalHandler) => { const { selectedFields } = exportModel; const { selectedExportType } = exportModel; - const fields = Object.keys(exportModel.fieldsFormatting); + const fields = Object.keys(exportModel.dataExportConfiguration); const fieldsSelected = selectedFields.length > 0; From ef07a8dc2264b41b2d86f903eab9a4ebd3b2cb0e Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 11:21:43 +0200 Subject: [PATCH 11/25] docs --- lib/public/views/Runs/Overview/exportTriggerAndModal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js index 92b176a1b3..673adda77e 100644 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -135,7 +135,7 @@ const exportModal = (exportModel, modalHandler) => { * Builds a button which will open popover for data export * * @param {DataExportModel} exportModel export model - * @param {ModelModel} modalModel modal model + * @param {ModalModel} modalModel modal model * @param {object} [displayConfiguration] additional display options * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not * @returns {Component} button From 9f94d6b6f4521314eaedb20e5d80d0e91f535824 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 11:26:26 +0200 Subject: [PATCH 12/25] fixes, cleanup --- lib/public/models/DataExportModel.js | 2 +- .../views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js | 2 +- .../views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js | 2 +- .../RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js index f796dacdb8..796f2a913e 100644 --- a/lib/public/models/DataExportModel.js +++ b/lib/public/models/DataExportModel.js @@ -63,7 +63,7 @@ export class DataExportModel extends Observable { /** * Get data export configuration * - * @return {DataExportConfiguration>} configuration + * @return {DataExportConfiguration} configuration */ get dataExportConfiguration() { return this._dataExportConfiguration; diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 0ed8890445..81539250a3 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -239,7 +239,7 @@ export const RunsPerDataPassOverviewPage = ({ h('#actions-dropdown-button', DropdownComponent( h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), h('.flex-column.p2.g2', [ - exportTriggerAndModal(perDataPassOverviewModel, modalModel, { autoMarginLeft: false }), + exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), frontLink( h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { disabled: runDetectorsSelectionIsEmpty, diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index 450794638b..b8344fb144 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -106,7 +106,7 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel return h('.intermediate-flex-column', [ h('.flex-row.justify-between.g2', [ h('h2', `Good, physics runs of ${lhcPeriodName}`), - exportTriggerAndModal(perLhcPeriodOverviewModel, modalModel), + exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), ]), ...tabbedPanelComponent( tabbedPanelModel, diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js index fe2975fb46..1d1c9968de 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js @@ -95,7 +95,7 @@ export const RunsPerSimulationPassOverviewPage = ({ breadcrumbs([commonTitle, h('h2#breadcrumb-simulation-pass-name', simulationPass.name)]), ), h('.mlauto', qcSummaryLegendTooltip()), - exportTriggerAndModal(perSimulationPassOverviewModel, modalModel, { autoMarginLeft: false }), + exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), frontLink( h( 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', From 0e8da18b1197f74d18a7cb70c81b91b34b6daf1b Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 14:39:00 +0200 Subject: [PATCH 13/25] fix --- lib/public/views/Runs/Overview/RunsOverviewModel.js | 1 + test/public/runs/overview.test.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index efa4ba4b22..e8dd6c9a1a 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -102,6 +102,7 @@ export class RunsOverviewModel extends OverviewPageModel { }; this._exportModel = new DataExportModel(this._allItems$, dataExportConfiguration, () => this.loadAll()); + this._exportModel.bubbleTo(this); this._item$.observe(() => { this._exportModel.setDisabled(!this.hasAnyData()); this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index e804c6c755..4317302b5b 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -903,7 +903,7 @@ module.exports = () => { it('should successfully export filtered runs', async () => { await goToPage(page, 'run-overview'); - const targetFileName = 'runs.json'; + const targetFileName = 'data.json'; // First export await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); From 52b9551c6a20e00b75dd3435c97c98a2618bbbb5 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 15:39:16 +0200 Subject: [PATCH 14/25] fix --- test/public/runs/runsPerDataPass.overview.test.js | 3 ++- test/public/runs/runsPerSimulationPass.overview.test.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index dfc9c45fea..e2def9b6ab 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -264,7 +264,7 @@ module.exports = () => { it('should successfully export runs', async () => { await navigateToRunsPerDataPass(page, 1, 3, 4); - const targetFileName = 'runs.json'; + const targetFileName = 'data.json'; // First export await pressElement(page, '#actions-dropdown-button .popover-trigger', true); @@ -432,6 +432,7 @@ module.exports = () => { }); it('should successfully mark as skimmable', async () => { + await expectInnerText(page, '#skimmableControl .badge', 'Skimmable'); await DataPassRepository.updateAll({ skimmingStage: null }, { where: { id: 1 } }); await navigateToRunsPerDataPass(page, 2, 1, 3); diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index 852f8b6be8..18ab625bfa 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -193,7 +193,7 @@ module.exports = () => { it('should successfully export runs', async () => { const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; - const targetFileName = 'runs.json'; + const targetFileName = 'data.json'; // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); From b0d78a44906f79e6e12ca0d5c8da503dce3bcdd6 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Thu, 31 Jul 2025 16:32:05 +0200 Subject: [PATCH 15/25] include qcFlags --- lib/database/adapters/RunAdapter.js | 8 +++ lib/database/adapters/index.js | 1 + lib/domain/dtos/filters/RunFilterDto.js | 1 + lib/usecases/run/GetAllRunsUseCase.js | 79 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/lib/database/adapters/RunAdapter.js b/lib/database/adapters/RunAdapter.js index 1e3cef7074..289ed386f2 100644 --- a/lib/database/adapters/RunAdapter.js +++ b/lib/database/adapters/RunAdapter.js @@ -75,6 +75,11 @@ class RunAdapter { */ this.userAdapter = null; + /** + * @type {QcFlagAdapter|null} + */ + this.qcFlagAdapter = null; + this.toEntity = this.toEntity.bind(this); this.toDatabase = this.toDatabase.bind(this); this.toMinifiedEntity = this.toMinifiedEntity.bind(this); @@ -167,6 +172,7 @@ class RunAdapter { phaseShiftAtEndBeam2, userStart, userStop, + qcFlags, } = databaseObject; /** @@ -281,6 +287,8 @@ class RunAdapter { entityObject.muInelasticInteractionRate = null; } + entityObject.qcFlags = qcFlags ? qcFlags.map(this.qcFlagAdapter.toEntity) : []; + return entityObject; } diff --git a/lib/database/adapters/index.js b/lib/database/adapters/index.js index 6566fd1b95..5ff6404b3d 100644 --- a/lib/database/adapters/index.js +++ b/lib/database/adapters/index.js @@ -139,6 +139,7 @@ runAdapter.logAdapter = logAdapter; runAdapter.runTypeAdapter = runTypeAdapter; runAdapter.tagAdapter = tagAdapter; runAdapter.userAdapter = userAdapter; +runAdapter.qcFlagAdapter = qcFlagAdapter; simulationPassQcFlagAdapter.simulationPassAdapter = simulationPassAdapter; simulationPassQcFlagAdapter.qcFlagAdapter = qcFlagAdapter; diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index 0131b441cc..e486ac2eea 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -90,6 +90,7 @@ exports.RunFilterDto = Joi.object({ odcTopologyFullName: Joi.string().trim(), detectors: DetectorsFilterDto, lhcPeriods: Joi.string().trim(), + lhcPeriodIds: Joi.array().items(Joi.number().positive().integer()), dataPassIds: Joi.array().items(Joi.number()), simulationPassIds: Joi.array().items(Joi.number()), runTypes: CustomJoi.stringArray().items(Joi.string()).single().optional(), diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index 5fc94ea179..3f02a3d997 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -51,6 +51,7 @@ class GetAllRunsUseCase { epn, fillNumbers, lhcPeriods, + lhcPeriodIds, dataPassIds, simulationPassIds, nDetectors, @@ -340,6 +341,19 @@ class GetAllRunsUseCase { }); } + if (lhcPeriodIds) { + const runNumbers = (await RunRepository.findAll({ + attributes: ['runNumber'], + raw: true, + include: { + association: 'lhcPeriod', + attributes: [], + where: { id: { [Op.in]: lhcPeriodIds } }, + }, + })).map(({ runNumber }) => runNumber); + filteringQueryBuilder.where('runNumber').oneOf(...runNumbers); + } + if (dataPassIds) { const runNumbers = (await RunRepository.findAll({ attributes: ['runNumber'], @@ -460,6 +474,71 @@ class GetAllRunsUseCase { fetchQueryBuilder.orderBy(property, sort[property]); } + if (filter?.include?.effectiveQcFlags) { + const [dataPassId] = filter?.dataPassIds ?? []; + const [simulationPassId] = filter?.simulationPassIds ?? []; + const [lhcPeriodId] = filter?.lhcPeriodIds ?? []; + + if (Boolean(dataPassId) + Boolean(simulationPassId) + Boolean(lhcPeriodId) > 1) { + throw new BadParameterError('If including QC flags, one and exactly one' + + 'of `dataPassId`, `simulationPassId` and `lhcPeriodId` is required'); + } + + if (dataPassId) { + fetchQueryBuilder.include({ + association: 'qcFlags', + required: false, + include: [ + { + association: 'dataPasses', + required: true, + where: { id: dataPassId }, + }, + { + association: 'effectivePeriods', + required: true, + }, + ], + }); + } else if (simulationPassId) { + fetchQueryBuilder.include({ + association: 'qcFlags', + required: false, + include: [ + { + association: 'simulationPasses', + required: true, + where: { id: simulationPassId }, + }, + { + association: 'effectivePeriods', + required: true, + }, + ], + }); + } else { + fetchQueryBuilder.include({ + association: 'qcFlags', + required: false, + include: [ + { + association: 'simulationPasses', + required: false, + }, + { + association: 'dataPasses', + required: false, + }, + { + association: 'effectivePeriods', + required: true, + }, + ], + }).where('$qcFlags.dataPasses.id$').is(null) + .where('$qcFlags.simulationPasses.id$').is(null); + } + } + const runs = (await RunRepository.findAll(fetchQueryBuilder)).map(runAdapter.toEntity); return { count, runs }; From 48725fab3308ab5946f8eece702a9ddfc6dc6421 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Fri, 1 Aug 2025 09:13:01 +0200 Subject: [PATCH 16/25] refactor --- test/public/runs/runsPerLhcPeriod.overview.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/public/runs/runsPerLhcPeriod.overview.test.js b/test/public/runs/runsPerLhcPeriod.overview.test.js index d6fb93130d..b58be68d5c 100644 --- a/test/public/runs/runsPerLhcPeriod.overview.test.js +++ b/test/public/runs/runsPerLhcPeriod.overview.test.js @@ -187,7 +187,7 @@ module.exports = () => { model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; }); - const targetFileName = 'runs.json'; + const targetFileName = 'data.json'; // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); From fa67b430432cd8c86ef2443f53b3a45fe22974c2 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Fri, 1 Aug 2025 11:05:25 +0200 Subject: [PATCH 17/25] a --- lib/domain/dtos/GetAllRunsDto.js | 10 ++++++ lib/usecases/run/GetAllRunsUseCase.js | 46 +++++++-------------------- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/lib/domain/dtos/GetAllRunsDto.js b/lib/domain/dtos/GetAllRunsDto.js index 1c886740cf..18b792678d 100644 --- a/lib/domain/dtos/GetAllRunsDto.js +++ b/lib/domain/dtos/GetAllRunsDto.js @@ -20,6 +20,16 @@ const QueryDto = Joi.object({ filter: RunFilterDto, page: PaginationDto, sort: DtoFactory.order(['id', 'runNumber', 'text']), + include: Joi.object({ effectiveQcFlags: Joi.boolean().custom((effectiveQcFlags, helpers) => { + const [, { filter: { dataPassIds, simulationPassIds, lhcPeriodIds } = {} }] = helpers.state.ancestors; + const runsCollectionFilters = [dataPassIds, simulationPassIds, lhcPeriodIds].filter(({ length } = {}) => length >= 1); + if (runsCollectionFilters.length !== 1 || runsCollectionFilters[0].length !== 1) { + return helpers.message('Including effectiveQcFlags is allowed only when filtering by one nd exactly one of: ' + + 'dataPassIds, simulationPassIds, lhcPeriodIds with exactly one ID.'); + } + + return effectiveQcFlags; + }) }), token: Joi.string(), }); diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index 3f02a3d997..ea133639e1 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -35,7 +35,7 @@ class GetAllRunsUseCase { async execute(dto = {}) { const filteringQueryBuilder = new QueryBuilder(); const { query = {} } = dto; - const { filter, page = {}, sort = { runNumber: 'desc' } } = query; + const { filter, page = {}, sort = { runNumber: 'desc' }, include: { effectiveQcFlags = false } = {} } = query; const SEARCH_ITEMS_SEPARATOR = ','; @@ -474,14 +474,14 @@ class GetAllRunsUseCase { fetchQueryBuilder.orderBy(property, sort[property]); } - if (filter?.include?.effectiveQcFlags) { + if (effectiveQcFlags) { const [dataPassId] = filter?.dataPassIds ?? []; const [simulationPassId] = filter?.simulationPassIds ?? []; const [lhcPeriodId] = filter?.lhcPeriodIds ?? []; if (Boolean(dataPassId) + Boolean(simulationPassId) + Boolean(lhcPeriodId) > 1) { throw new BadParameterError('If including QC flags, one and exactly one' - + 'of `dataPassId`, `simulationPassId` and `lhcPeriodId` is required'); + + 'of `dataPassId`, `simulationPassId` or `lhcPeriodId` is required'); } if (dataPassId) { @@ -489,15 +489,8 @@ class GetAllRunsUseCase { association: 'qcFlags', required: false, include: [ - { - association: 'dataPasses', - required: true, - where: { id: dataPassId }, - }, - { - association: 'effectivePeriods', - required: true, - }, + { association: 'dataPasses', required: true, where: { id: dataPassId } }, + { association: 'effectivePeriods', required: true }, ], }); } else if (simulationPassId) { @@ -505,15 +498,8 @@ class GetAllRunsUseCase { association: 'qcFlags', required: false, include: [ - { - association: 'simulationPasses', - required: true, - where: { id: simulationPassId }, - }, - { - association: 'effectivePeriods', - required: true, - }, + { association: 'simulationPasses', required: true, where: { id: simulationPassId } }, + { association: 'effectivePeriods', required: true }, ], }); } else { @@ -521,20 +507,12 @@ class GetAllRunsUseCase { association: 'qcFlags', required: false, include: [ - { - association: 'simulationPasses', - required: false, - }, - { - association: 'dataPasses', - required: false, - }, - { - association: 'effectivePeriods', - required: true, - }, + { association: 'simulationPasses', required: false }, + { association: 'dataPasses', required: false }, + { association: 'effectivePeriods', required: true }, ], - }).where('$qcFlags.dataPasses.id$').is(null) + }) + .where('$qcFlags.dataPasses.id$').is(null) .where('$qcFlags.simulationPasses.id$').is(null); } } From 4870a31b398c9f9fbbf4499c7ee15171b78f5780 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Fri, 1 Aug 2025 11:29:34 +0200 Subject: [PATCH 18/25] ref --- lib/database/adapters/RunAdapter.js | 7 ++++++- lib/usecases/run/GetAllRunsUseCase.js | 15 +++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/database/adapters/RunAdapter.js b/lib/database/adapters/RunAdapter.js index 289ed386f2..ae27078343 100644 --- a/lib/database/adapters/RunAdapter.js +++ b/lib/database/adapters/RunAdapter.js @@ -287,7 +287,12 @@ class RunAdapter { entityObject.muInelasticInteractionRate = null; } - entityObject.qcFlags = qcFlags ? qcFlags.map(this.qcFlagAdapter.toEntity) : []; + const adaptedQcFlags = qcFlags ? qcFlags.map(this.qcFlagAdapter.toEntity) : []; + entityObject.qcFlags = adaptedQcFlags.reduce((acc, qcFlag) => { + acc[qcFlag.dplDetectorId] = acc[qcFlag.dplDetectorId] ?? []; + acc[qcFlag.dplDetectorId].push(qcFlag); + return acc; + }, {}); return entityObject; } diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index ea133639e1..2ff56e1dc7 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -484,10 +484,15 @@ class GetAllRunsUseCase { + 'of `dataPassId`, `simulationPassId` or `lhcPeriodId` is required'); } + const commondQcFlagsAssociationDef = { + association: 'qcFlags', + required: false, + where: { deleted: false }, + }; + if (dataPassId) { fetchQueryBuilder.include({ - association: 'qcFlags', - required: false, + ...commondQcFlagsAssociationDef, include: [ { association: 'dataPasses', required: true, where: { id: dataPassId } }, { association: 'effectivePeriods', required: true }, @@ -495,8 +500,7 @@ class GetAllRunsUseCase { }); } else if (simulationPassId) { fetchQueryBuilder.include({ - association: 'qcFlags', - required: false, + ...commondQcFlagsAssociationDef, include: [ { association: 'simulationPasses', required: true, where: { id: simulationPassId } }, { association: 'effectivePeriods', required: true }, @@ -504,8 +508,7 @@ class GetAllRunsUseCase { }); } else { fetchQueryBuilder.include({ - association: 'qcFlags', - required: false, + ...commondQcFlagsAssociationDef, include: [ { association: 'simulationPasses', required: false }, { association: 'dataPasses', required: false }, From 05ea6726ac7a73eb9de9fd157f75d4247e8b351e Mon Sep 17 00:00:00 2001 From: xsalonx Date: Fri, 1 Aug 2025 12:10:48 +0200 Subject: [PATCH 19/25] fix --- .../seeders/20240404100811-qc-flags.js | 6 ++ lib/usecases/run/GetAllRunsUseCase.js | 2 +- test/api/runs.test.js | 34 ++++++++++++ .../usecases/run/GetAllRunsUseCase.test.js | 55 +++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lib/database/seeders/20240404100811-qc-flags.js b/lib/database/seeders/20240404100811-qc-flags.js index 4ff17be617..b4933d7aa0 100644 --- a/lib/database/seeders/20240404100811-qc-flags.js +++ b/lib/database/seeders/20240404100811-qc-flags.js @@ -298,6 +298,12 @@ module.exports = { from: '2019-08-08 13:46:40', to: '2019-08-09 07:50:00', }, + { + id: 6, + flag_id: 6, + from: '2019-08-09 08:50:00', + to: null, + }, { id: 7, flag_id: 7, diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index 2ff56e1dc7..1d89255f38 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -479,7 +479,7 @@ class GetAllRunsUseCase { const [simulationPassId] = filter?.simulationPassIds ?? []; const [lhcPeriodId] = filter?.lhcPeriodIds ?? []; - if (Boolean(dataPassId) + Boolean(simulationPassId) + Boolean(lhcPeriodId) > 1) { + if (Boolean(dataPassId) + Boolean(simulationPassId) + Boolean(lhcPeriodId) !== 1) { throw new BadParameterError('If including QC flags, one and exactly one' + 'of `dataPassId`, `simulationPassId` or `lhcPeriodId` is required'); } diff --git a/test/api/runs.test.js b/test/api/runs.test.js index c1a05c441a..2cad779e14 100644 --- a/test/api/runs.test.js +++ b/test/api/runs.test.js @@ -21,6 +21,7 @@ const { RunDetectorQualities } = require('../../lib/domain/enums/RunDetectorQual const { RunCalibrationStatus } = require('../../lib/domain/enums/RunCalibrationStatus.js'); const { updateRun } = require('../../lib/server/services/run/updateRun.js'); const { RunDefinition } = require('../../lib/domain/enums/RunDefinition.js'); +const { qcFlagService } = require('../../lib/server/services/qualityControlFlag/QcFlagService.js'); module.exports = () => { before(resetDatabaseContent); @@ -570,6 +571,39 @@ module.exports = () => { expect(runs.every(({ aliceDipoleCurrent, aliceDipolePolarity }) => Math.round(aliceDipoleCurrent * (aliceDipolePolarity === 'NEGATIVE' ? -1 : 1) / 1000) === 0)).to.be.true; }); + + it('should successfully handle query including QC flags', async () => { + { // Data Passes + const response = await request(server).get(`/api/runs?filter[dataPassIds][]=1&include[effectiveQcFlags]=true`) + expect(response.status).to.equal(200); + const { data: runs } = response.body; + + expect(runs).to.have.lengthOf(3); + expect(runs.find(({ runNumber }) => runNumber === 107).qcFlags['1'].map(({ id }) => id)).to.have.all.members([202, 201]); + expect(runs.find(({ runNumber }) => runNumber === 107).qcFlags['2'].map(({ id }) => id)).to.have.all.members([203]); + expect(runs.find(({ runNumber }) => runNumber === 106).qcFlags['1'].map(({ id }) => id)).to.have.all.members([3, 2, 1]); + } + + { // Simulation Passes + const response = await request(server).get(`/api/runs?filter[simulationPassIds][]=1&include[effectiveQcFlags]=true`) + expect(response.status).to.equal(200); + const { data: runs } = response.body; + expect(runs).to.have.lengthOf(3); + + console.log('TOBEC', await qcFlagService.getAllPerSimulationPassAndRunAndDetector({ simulationPassId: 1, runNumber: 106, detectorId: 1})); + + expect(runs.find(({ runNumber }) => runNumber === 106).qcFlags['1'].map(({ id }) => id)).to.have.all.members([6, 5]); + } + + { // Synchronous flags + const response = await request(server).get(`/api/runs?filter[lhcPeriodIds][]=1&include[effectiveQcFlags]=true`) + expect(response.status).to.equal(200); + const { data: runs } = response.body; + expect(runs).to.have.lengthOf(4); + expect(runs.find(({ runNumber }) => runNumber === 56).qcFlags['7'].map(({ id }) => id)).to.have.all.members([101, 100]); + expect(runs.find(({ runNumber }) => runNumber === 56).qcFlags['4'].map(({ id }) => id)).to.have.all.members([102]); + } + }); }); describe('GET /api/runs/reasonTypes', () => { diff --git a/test/lib/usecases/run/GetAllRunsUseCase.test.js b/test/lib/usecases/run/GetAllRunsUseCase.test.js index 350e325653..114abc59f5 100644 --- a/test/lib/usecases/run/GetAllRunsUseCase.test.js +++ b/test/lib/usecases/run/GetAllRunsUseCase.test.js @@ -17,6 +17,8 @@ const chai = require('chai'); const { RunQualities } = require('../../../../lib/domain/enums/RunQualities.js'); const { RunCalibrationStatus } = require('../../../../lib/domain/enums/RunCalibrationStatus.js'); const { RunDefinition } = require('../../../../lib/domain/enums/RunDefinition.js'); +const assert = require('assert'); +const { BadParameterError } = require('../../../../lib/server/errors/BadParameterError.js'); const { expect } = chai; @@ -795,4 +797,57 @@ module.exports = () => { expect(runs).to.have.lengthOf(0); } }); + + it('should successfully handle query including QC flags', async () => { + { + await assert.rejects(() => new GetAllRunsUseCase().execute({ + query: { + include: { effectiveQcFlags: true }, + }, + }), new BadParameterError('If including QC flags, one and exactly one' + + 'of `dataPassId`, `simulationPassId` or `lhcPeriodId` is required')); + } + + { // Data Passes + const { runs } = await new GetAllRunsUseCase().execute({ + query: { + filter: { + dataPassIds: [1], + }, + include: { effectiveQcFlags: true }, + }, + }); + expect(runs).to.have.lengthOf(3); + expect(runs.find(({ runNumber }) => runNumber === 107).qcFlags['1'].map(({ id }) => id)).to.have.all.members([202, 201]); + expect(runs.find(({ runNumber }) => runNumber === 107).qcFlags['2'].map(({ id }) => id)).to.have.all.members([203]); + expect(runs.find(({ runNumber }) => runNumber === 106).qcFlags['1'].map(({ id }) => id)).to.have.all.members([3, 2, 1]); + } + + { // Simulation Passes + const { runs } = await new GetAllRunsUseCase().execute({ + query: { + filter: { + simulationPassIds: [1], + }, + include: { effectiveQcFlags: true }, + }, + }); + expect(runs).to.have.lengthOf(3); + expect(runs.find(({ runNumber }) => runNumber === 106).qcFlags['1'].map(({ id }) => id)).to.have.all.members([6, 5]); + } + + { // Synchronous flags + const { runs } = await new GetAllRunsUseCase().execute({ + query: { + filter: { + lhcPeriodIds: [1], + }, + include: { effectiveQcFlags: true }, + }, + }); + expect(runs).to.have.lengthOf(4); + expect(runs.find(({ runNumber }) => runNumber === 56).qcFlags['7'].map(({ id }) => id)).to.have.all.members([101, 100]); + expect(runs.find(({ runNumber }) => runNumber === 56).qcFlags['4'].map(({ id }) => id)).to.have.all.members([102]); + } + }); }; From b1b15dace2f6e26290d022c9d49a85bee48d2b87 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Fri, 1 Aug 2025 12:21:48 +0200 Subject: [PATCH 20/25] fix --- lib/usecases/run/GetAllRunsUseCase.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index 1d89255f38..ae4afb0e51 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -514,9 +514,17 @@ class GetAllRunsUseCase { { association: 'dataPasses', required: false }, { association: 'effectivePeriods', required: true }, ], - }) - .where('$qcFlags.dataPasses.id$').is(null) - .where('$qcFlags.simulationPasses.id$').is(null); + where: { + deleted: false, + [Op.or]: [ + { '$qcFlags.id$': null }, + { + '$qcFlags.dataPasses.id$': null, + '$qcFlags.simulationPasses.id$': null, + }, + ], + }, + }); } } From bfb1022147674ad5b64293f758970b941f4ca5a1 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Mon, 4 Aug 2025 12:08:20 +0200 Subject: [PATCH 21/25] cherry pick --- lib/public/app.css | 23 ++++ .../Filters/common/FilteringModel.js | 17 +++ .../Filters/common/filtersPanelPopover.js | 113 +++++++++------ .../common/popover/overflowBalloon.js | 7 +- lib/public/models/OverviewModel.js | 69 ++-------- .../runDetectorsAsyncQcActiveColumns.js | 47 ++++++- .../Runs/ActiveColumns/runsActiveColumns.js | 5 + .../views/Runs/Overview/RunsOverviewModel.js | 92 ++++++++++--- .../views/Runs/Overview/RunsOverviewPage.js | 4 +- .../Overview/exportRunsTriggerAndModal.js | 129 ++++++++++++++++++ .../RunsPerDataPassOverviewModel.js | 38 ++---- .../RunsPerDataPassOverviewPage.js | 18 +-- .../RunsPerLhcPeriodOverviewPage.js | 4 +- .../RunsPerSimulationPassOverviewPage.js | 6 +- test/public/runs/overview.test.js | 18 +-- .../runs/runsPerDataPass.overview.test.js | 21 ++- .../runs/runsPerLhcPeriod.overview.test.js | 4 +- .../runsPerSimulationPass.overview.test.js | 6 +- 18 files changed, 440 insertions(+), 181 deletions(-) create mode 100644 lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js diff --git a/lib/public/app.css b/lib/public/app.css index 74fc0facd2..2bb71ed1a5 100644 --- a/lib/public/app.css +++ b/lib/public/app.css @@ -226,6 +226,29 @@ th.text-center, td.text-center { } +.section-divider { + display: flex; + align-items: center; + text-align: center; + color: #333; + margin: 1rem 0; +} + +.section-divider::before, +.section-divider::after { + content: ""; + flex: 1; + border-bottom: 1px solid #333; +} + +.section-divider::before { + margin-right: 0.75em; +} + +.section-divider::after { + margin-left: 0.75em; +} + /* alerts */ .alert { diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index 0cd6e1ced8..1202fa75a1 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -104,4 +104,21 @@ export class FilteringModel extends Observable { return this._filters[key]; } + + /** + * Add new filter + * + * @param {string} key key of a new filter + * @param {FilterModel} filter the new filter + */ + put(key, filter) { + if (key in this._filters) { + throw new Error(`Filter under key ${key} already exists`); + } + + this._filters[key] = filter; + this._filterModels.push(filter); + filter.bubbleTo(this); + filter.visualChange$?.bubbleTo(this._visualChange$); + } } diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 14f87ca23c..e0c0a7490c 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -15,6 +15,21 @@ import { profiles } from '../../common/table/profiles.js'; import { applyProfile } from '../../../utilities/applyProfile.js'; import { tooltip } from '../../common/popover/tooltip.js'; +/** + * @typedef FilterConfiguration + * + * @property {string} name + * @property {function|Component} filter + * @property {string|Component} filterTooltip + * @property {object|string|string[]} profiles + */ + +/** + * @typedef FiltersConfiguration + * + * @type {object} mapping: filterable property -> filter configuration + */ + /** * Return the filters panel popover trigger * @@ -23,36 +38,36 @@ import { tooltip } from '../../common/popover/tooltip.js'; const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); /** - * Return the filters panel popover content (i.e. the actual filters) - * - * TODO Separate filters from active columns + * Create main header of the filters panel + * @param {FilteringModel} filteringModel filtering model + * @returns {Component} main panel header + */ +const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ + h('.f4', 'Filters'), + h( + 'button#reset-filters.btn.btn-danger', + { + onclick: () => filteringModel.resetFiltering + ? filteringModel.resetFiltering() + : filteringModel.reset(true), + disabled: !filteringModel.isAnyFilterActive(), + }, + 'Reset all filters', + ), +]); + +/** + * Return the filters panel popover content section * - * @param {object} filteringModel the filtering model - * @param {object} columns the list of columns containing filters + * @param {FilteringModel} filteringModel the filtering model + * @param {FiltersConfiguration} filtersConfiguration filters configuration * @param {object} [configuration] additional configuration * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column - * @return {Component} the filters panel + * @return {Component} the filters section */ -const filtersToggleContent = ( - filteringModel, - columns, - { profile: appliedProfile = profiles.none } = {}, -) => h('.w-l.flex-column.p3.g3', [ - h('.flex-row.justify-between', [ - h('.f4', 'Filters'), - h( - 'button#reset-filters.btn.btn-danger', - { - onclick: () => filteringModel.resetFiltering - ? filteringModel.resetFiltering() - : filteringModel.reset(true), - disabled: !filteringModel.isAnyFilterActive(), - }, - 'Reset all filters', - ), - ]), +export const filtersSection = (filteringModel, filtersConfiguration, { profile: appliedProfile = profiles.none } = {}) => h('.flex-column.g2', [ - Object.entries(columns) + Object.entries(filtersConfiguration) .filter(([_, column]) => { let columnProfiles = column.profiles ?? [profiles.none]; if (typeof columnProfiles === 'string') { @@ -60,30 +75,50 @@ const filtersToggleContent = ( } return applyProfile(column, appliedProfile, columnProfiles)?.filter; }) - .map(([columnKey, { name, filterTooltip, filter }]) => [ - h(`.flex-row.items-baseline.${columnKey}-filter`, [ - h('.w-30.f5.flex-row.items-center.g2', [ - name, - filterTooltip ? tooltip(info(), filterTooltip) : null, - ]), - h('.w-70', typeof filter === 'function' ? filter(filteringModel) : filter), - ]), - ]), - ]), + .map(([columnKey, { name, filterTooltip, filter }]) => + name + ? [ + h(`.flex-row.items-baseline.${columnKey}-filter`, [ + h('.w-30.f5.flex-row.items-center.g2', [ + name, + filterTooltip ? tooltip(info(), filterTooltip) : null, + ]), + h('.w-70', typeof filter === 'function' ? filter(filteringModel) : filter), + ]), + ] + : typeof filter === 'function' ? filter(filteringModel) : filter), + ]); + +/** + * Return the filters panel popover content (i.e. the actual filters) + * + * @param {FilteringModel} filteringModel the filtering model + * @param {FiltersConfiguration} filtersConfiguration filters configuration + * @param {object} [configuration] additional configuration + * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column + * @return {Component} the filters panel + */ +const filtersToggleContent = ( + filteringModel, + filtersConfiguration, + configuration = {}, +) => h('.w-l.flex-column.p3.g3', [ + filtersToggleContentHeader(filteringModel), + filtersSection(filteringModel, filtersConfiguration, configuration), ]); /** * Return component composed of the filtering popover and its button trigger * - * @param {object} filterModel the filter model - * @param {object} activeColumns the list of active columns containing the filtering configuration + * @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 = (filterModel, activeColumns, configuration) => popover( +export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => popover( filtersToggleTrigger(), - filtersToggleContent(filterModel, activeColumns, configuration), + filtersToggleContent(filteringModel, filtersConfiguration, configuration), { ...PopoverTriggerPreConfiguration.click, anchor: PopoverAnchors.RIGHT_START, diff --git a/lib/public/components/common/popover/overflowBalloon.js b/lib/public/components/common/popover/overflowBalloon.js index 501ed0496a..6485ba9e97 100644 --- a/lib/public/components/common/popover/overflowBalloon.js +++ b/lib/public/components/common/popover/overflowBalloon.js @@ -68,11 +68,12 @@ export const overflowBalloon = (content, options = null) => { }, /** - * Lifecycle method called when the component is created - * @returns {void} + * States whether popover is visible + * + * @returns {boolean} true if visible, false otherwise */ showCondition: function () { - doesElementOverflow(this.triggerNode.querySelector('.w-wrapped')); + return doesElementOverflow(this.triggerNode.querySelector('.w-wrapped')); }, }, ); diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 30bfaea2ee..68f6250b5d 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -46,29 +46,18 @@ export class OverviewPageModel extends Observable { }); this._sortModel.visualChange$.bubbleTo(this); - // Single page data handling this._pagination = new PaginationModel(); this._pagination.observe(() => this.load()); this._pagination.itemsPerPageSelector$.observe(() => this.notify()); const dataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); - dataSourceObservable.observe(() => this.parseApiPaginatedRemoteData(dataSourceObservable.getCurrent())); + dataSourceObservable.observe(() => this.parseApiRemoteData(dataSourceObservable.getCurrent())); this._dataSource = new PaginatedRemoteDataSource(); this._dataSource.pipe(dataSourceObservable); - this._item$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); - this._item$.bubbleTo(this); - - // All data handling - const allDataSourceObservable = ObservableData.builder().initialValue(RemoteData.loading()).build(); - allDataSourceObservable.observe(() => this.parseApiNotPaginatedRemoteData(allDataSourceObservable.getCurrent())); - - this._allDataSource = new PaginatedRemoteDataSource(); - this._allDataSource.pipe(allDataSourceObservable); - - this._allItems$ = ObservableData.builder().initialValue(RemoteData.loading()).build(); - this._allItems$.bubbleTo(this); + this._observableItems = ObservableData.builder().initialValue(RemoteData.loading()).build(); + this._observableItems.bubbleTo(this); /** * @type {ObservableData} @@ -95,7 +84,7 @@ export class OverviewPageModel extends Observable { * @returns {void} */ reset() { - this._item$.setCurrent(RemoteData.notAsked()); + this._observableItems.setCurrent(RemoteData.notAsked()); this._pagination.reset(); } @@ -105,7 +94,7 @@ export class OverviewPageModel extends Observable { * @param {RemoteData<{items: T[], totalCount: number}, *>} remoteData the API remote data * @return {void} */ - parseApiPaginatedRemoteData(remoteData) { + parseApiRemoteData(remoteData) { /* * When fetching data, to avoid concurrency issues, save a flag stating if the fetched data should be concatenated with the current one * (infinite scroll) or if they should replace them @@ -113,8 +102,8 @@ export class OverviewPageModel extends Observable { const keepExisting = this._pagination.currentPage > 1 && this._pagination.isInfiniteScrollEnabled; remoteData.match({ - NotAsked: () => this._item$.setCurrent(RemoteData.notAsked()), - Loading: () => this._pagination.isInfiniteScrollEnabled ? null : this._item$.setCurrent(RemoteData.loading()), + NotAsked: () => this._observableItems.setCurrent(RemoteData.notAsked()), + Loading: () => this._pagination.isInfiniteScrollEnabled ? null : this._observableItems.setCurrent(RemoteData.loading()), Success: ({ items, totalCount }) => { const concatenateWith = keepExisting ? this.items.match({ Success: (payload) => payload, @@ -123,26 +112,9 @@ export class OverviewPageModel extends Observable { Failure: () => [], }) : []; this._pagination.itemsCount = totalCount; - this._item$.setCurrent(RemoteData.success([...concatenateWith, ...this.processItems(items)])); - }, - Failure: (error) => this._item$.setCurrent(RemoteData.failure(error)), - }); - } - - /** - * Parse the API remote data to extract the list of items - * - * @param {RemoteData<{items: T[], totalCount: number}, *>} remoteData the API remote data - * @return {void} - */ - parseApiNotPaginatedRemoteData(remoteData) { - remoteData.match({ - NotAsked: () => this._allItems$.setCurrent(RemoteData.notAsked()), - Loading: () => this._allItems$.setCurrent(RemoteData.loading()), - Success: ({ items }) => { - this._allItems$.setCurrent(RemoteData.success(this.processItems(items))); + this._observableItems.setCurrent(RemoteData.success([...concatenateWith, ...this.processItems(items)])); }, - Failure: (error) => this._allItems$.setCurrent(RemoteData.failure(error)), + Failure: (error) => this._observableItems.setCurrent(RemoteData.failure(error)), }); } @@ -158,26 +130,14 @@ export class OverviewPageModel extends Observable { /** * Fetch all the relevant items from the API - * it takes into account pagination parameters, but also reset not-paginated observable data * * @return {Promise} void */ async load() { const params = await this.getLoadParameters(); - this._allItems$.setCurrent(RemoteData.notAsked()); await this._dataSource.fetch(buildUrl(this.getRootEndpoint(), params)); } - /** - * Fetch all the relevant items from the API - * it does not take into account pagincation parameters - * - * @return {Promise} void - */ - async loadAll() { - await this._allDataSource.fetch(this.getRootEndpoint()); - } - /** * Return the query params to use to get load the overview data * @@ -203,7 +163,7 @@ export class OverviewPageModel extends Observable { * @return {RemoteData} the items */ get items() { - return this._item$.getCurrent(); + return this._observableItems.getCurrent(); } /** @@ -240,13 +200,4 @@ export class OverviewPageModel extends Observable { get sortModel() { return this._sortModel; } - - /** - * State whther some data was successfuly fetched - * - * @return {boolean} true if any data was successfuly fetched, false otherwise - */ - hasAnyData() { - return this._item$.getCurrent().match({ Success: ({ length = 0 } = {}) => length > 0, Other: () => false }); - } } diff --git a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js index e8c0a5bc64..d2b5096cae 100644 --- a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js @@ -10,7 +10,7 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { h, iconBan, iconX } from '/js/src/index.js'; +import { h, iconBan, iconX, info } from '/js/src/index.js'; import { qcFlagCreationPanelLink } from '../../../components/qcFlags/qcFlagCreationPanelLink.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js'; @@ -20,6 +20,8 @@ import { qcFlagOverviewPanelLink } from '../../../components/qcFlags/qcFlagOverv import { remoteDplDetectorUserHasAccessTo } from '../../../services/detectors/remoteDplDetectorUserHasAccessTo.js'; import errorAlert from '../../../components/common/errorAlert.js'; import spinner from '../../../components/common/spinner.js'; +import { numericalComparisonFilter } from '../../../components/Filters/common/filters/numericalComparisonFilter.js'; +import { filtersSection } from '../../../components/Filters/common/filtersPanelPopover.js'; /** * Render QC summary for given run and detector @@ -57,7 +59,7 @@ const runDetectorAsyncQualityDisplay = ({ dataPassId, simulationPassId }, run, d * @param {DataPass} [monalisaProduction.dataPass] data pass containing the run -- exclusive with `simulationPass` * @param {SimulationPass} [monalisaProduction.simulationPass] simulation pass containing the run -- exclusive with `dataPass` * @param {object} configuration display configuration - * @param {object|string|string[]} [configuration.profiles] profiles which the column is restricted to + * @param {string} [configuration.profile] profile which the column is restricted to * @param {QcSummary} [configuration.qcSummary] QC summary for given data/simulation pass * @return {object} active columns configuration */ @@ -66,7 +68,7 @@ export const createRunDetectorsAsyncQcActiveColumns = ( dplDetectors, remoteDplDetectorsUserHasAccessTo, { dataPass, simulationPass }, - { profiles, qcSummary } = {}, + { profile, qcSummary } = {}, ) => { if (!dataPass && !simulationPass) { throw new Error('`dataPass` or `simulationPass` is required'); @@ -75,7 +77,7 @@ export const createRunDetectorsAsyncQcActiveColumns = ( throw new Error('`dataPass` and `simulationPass` are exclusive options'); } - return Object.fromEntries(dplDetectors?.map(({ name: detectorName, id: dplDetectorId }) => [ + let activeColumnEntries = dplDetectors?.map(({ name: detectorName, id: dplDetectorId }) => [ detectorName, { name: detectorName.toUpperCase(), @@ -136,7 +138,40 @@ export const createRunDetectorsAsyncQcActiveColumns = ( }, Loading: () => spinner(), }), - profiles, + profiles: profile, }, - ]) ?? []); + ]) ?? []; + + const filtersEntries = dplDetectors?.map(({ name: detectorName, id: dplDetectorId }) => [ + detectorName, + { + name: detectorName.toUpperCase(), + visible: false, + profiles: profile, + filter: (filteringModel) => { + const filterModel = filteringModel.get(`detectorsQc[_${dplDetectorId}][notBadFraction]`); + return filterModel + ? numericalComparisonFilter(filterModel, { step: 0.1, selectorPrefix: `detectorsQc-for-${dplDetectorId}-notBadFraction` }) + : null; + }, + }, + ]) ?? []; + + if (activeColumnEntries.length > 0) { + activeColumnEntries = [ + [ + 'detectorsQc', + { + name: null, + filter: ({ filteringModel }) => [ + h('.section-divider', ['Detector QC', tooltip(info(), 'not-bad fraction expressed as a percentage')]), + filtersSection(filteringModel, Object.fromEntries(filtersEntries), { profile }), + ], + profiles: profile, + }, + ], ...activeColumnEntries, + ]; + } + + return Object.fromEntries(activeColumnEntries); }; diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index c760478e28..2278a39466 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -667,4 +667,9 @@ export const runsActiveColumns = { ), profiles: ['runsPerLhcPeriod', 'runsPerDataPass', 'runsPerSimulationPass', profiles.none], }, + + collidingBunchesCount: { + name: 'Colliding bunches count', + visible: false, + }, }; diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index e8dd6c9a1a..6f1aef4e02 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -12,11 +12,13 @@ */ import { buildUrl, RemoteData } from '/js/src/index.js'; +import { createCSVExport, createJSONExport } from '../../../utilities/export.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; import { debounce } from '../../../utilities/debounce.js'; import { DetectorsFilterModel } from '../../../components/Filters/RunsFilter/DetectorsFilterModel.js'; import { RunTypesFilterModel } from '../../../components/runTypes/RunTypesFilterModel.js'; import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/EorReasonFilterModel.js'; +import pick from '../../../utilities/pick.js'; import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { CombinationOperator } from '../../../components/Filters/common/CombinationOperatorChoiceModel.js'; @@ -33,8 +35,6 @@ import { RawTextFilterModel } from '../../../components/Filters/common/filters/R import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter/RunDefinitionFilterModel.js'; import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; -import { DataExportModel } from '../../../models/DataExportModel.js'; -import { runsActiveColumns as dataExportConfiguration } from '../ActiveColumns/runsActiveColumns.js'; /** * Model representing handlers for runs page @@ -100,26 +100,10 @@ export class RunsOverviewModel extends OverviewPageModel { const updateDebounceTime = () => { this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); }; - - this._exportModel = new DataExportModel(this._allItems$, dataExportConfiguration, () => this.loadAll()); - this._exportModel.bubbleTo(this); - this._item$.observe(() => { - this._exportModel.setDisabled(!this.hasAnyData()); - this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); - }); - model.appConfiguration$.observe(() => updateDebounceTime()); updateDebounceTime(); } - /** - * Get export model - * @return {DataExportModel} export model - */ - get exportModel() { - return this._exportModel; - } - /** * @inheritdoc */ @@ -135,6 +119,55 @@ export class RunsOverviewModel extends OverviewPageModel { super.load(); } + /** + * Create the export with the variables set in the model, handling errors appropriately + * @param {object[]} runs The source content. + * @param {string} fileName The name of the file including the output format. + * @param {Object>} exportFormats defines how particular fields of data units will be formated + * @return {void} + */ + async createRunsExport(runs, fileName, exportFormats) { + if (runs.length > 0) { + const selectedRunsFields = this.getSelectedRunsFields() || []; + runs = runs.map((selectedRun) => { + const entries = Object.entries(pick(selectedRun, selectedRunsFields)); + const formattedEntries = entries.map(([key, value]) => { + const formatExport = exportFormats[key].exportFormat || ((identity) => identity); + return [key, formatExport(value, selectedRun)]; + }); + return Object.fromEntries(formattedEntries); + }); + this.getSelectedExportType() === 'CSV' + ? createCSVExport(runs, `${fileName}.csv`, 'text/csv;charset=utf-8;') + : createJSONExport(runs, `${fileName}.json`, 'application/json'); + } else { + this._observableItems.setCurrent(RemoteData.failure([ + { + title: 'No data found', + detail: 'No valid runs were found for provided run number(s)', + }, + ])); + this.notify(); + } + } + + /** + * Get the field values that will be exported + * @return {Array} the field objects of the current export being created + */ + getSelectedRunsFields() { + return this.selectedRunsFields; + } + + /** + * Get the output format of the export + * + * @return {string} the output format + */ + getSelectedExportType() { + return this.selectedExportType; + } + /** * Returns all filtering, sorting and pagination settings to their default values * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset @@ -187,6 +220,29 @@ export class RunsOverviewModel extends OverviewPageModel { return this._filteringModel; } + /** + * Set the export type parameter of the current export being created + * @param {string} selectedExportType Received string from the view + * @return {void} + */ + setSelectedExportType(selectedExportType) { + this.selectedExportType = selectedExportType; + this.notify(); + } + + /** + * Updates the selected fields ID array according to the HTML attributes of the options + * + * @param {HTMLCollection} selectedOptions The currently selected fields by the user, + * according to HTML specification + * @return {undefined} + */ + setSelectedRunsFields(selectedOptions) { + this.selectedRunsFields = []; + [...selectedOptions].map((selectedOption) => this.selectedRunsFields.push(selectedOption.value)); + this.notify(); + } + /** * Getter for the trigger values filter Set * @return {Set} set of trigger filter values diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index 2cc1a609bb..3e689a5472 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewPage.js +++ b/lib/public/views/Runs/Overview/RunsOverviewPage.js @@ -13,7 +13,7 @@ import { h } from '/js/src/index.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportTriggerAndModal } from './exportTriggerAndModal.js'; +import { exportRunsTriggerAndModal } from './exportRunsTriggerAndModal.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; @@ -56,7 +56,7 @@ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, m filtersPanelPopover(runsOverviewModel, runsActiveColumns), h('.pl2#runOverviewFilter', runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers'))), togglePhysicsOnlyFilter(runsOverviewModel.filteringModel.get('definitions')), - exportTriggerAndModal(runsOverviewModel.exportModel, modalModel), + exportRunsTriggerAndModal(runsOverviewModel, modalModel), ]), h('.flex-column.w-100', [ table(runsOverviewModel.items, runsActiveColumns), diff --git a/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js b/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js new file mode 100644 index 0000000000..54c0eb6611 --- /dev/null +++ b/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js @@ -0,0 +1,129 @@ +/** + * @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 { h } from '/js/src/index.js'; +import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; + +/** + * Export form component, containing the fields to export, the export type and the export button + * + * @param {RunsOverviewModel} runsOverviewModel the runsOverviewModel + * @param {array} runs the runs to export + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * + * @return {vnode[]} the form component + */ +const exportForm = (runsOverviewModel, runs, modalHandler) => { + const exportTypes = ['JSON', 'CSV']; + const selectedRunsFields = runsOverviewModel.getSelectedRunsFields() || []; + const selectedExportType = runsOverviewModel.getSelectedExportType() || exportTypes[0]; + const runsFields = Object.keys(runsActiveColumns); + const enabled = selectedRunsFields.length > 0; + + return [ + runsOverviewModel.isAllRunsTruncated + ? h( + '#truncated-export-warning.warning', + `The runs export is limited to ${runs.length} entries, only the last runs will be exported (sorted by run number)`, + ) + : null, + h('label.form-check-label.f4.mt1', { for: 'run-number' }, 'Fields'), + h( + 'label.form-check-label.f6', + { for: 'run-number' }, + 'Select which fields to be exported. (CTRL + click for multiple selection)', + ), + h('select#fields.form-control', { + style: 'min-height: 20rem;', + multiple: true, + onchange: ({ target }) => runsOverviewModel.setSelectedRunsFields(target.selectedOptions), + }, [ + ...runsFields + .filter((name) => !['id', 'actions'].includes(name)) + .map((name) => h('option', { + value: name, + selected: selectedRunsFields.length ? selectedRunsFields.includes(name) : false, + }, name)), + ]), + h('label.form-check-label.f4.mt1', { for: 'run-number' }, 'Export type'), + h('label.form-check-label.f6', { for: 'run-number' }, 'Select output format'), + h('.flex-row.g3', exportTypes.map((exportType) => { + const id = `runs-export-type-${exportType}`; + return h('.form-check', [ + h('input.form-check-input', { + id, + type: 'radio', + value: exportType, + checked: selectedExportType.length ? selectedExportType.includes(exportType) : false, + name: 'runs-export-type', + onclick: () => runsOverviewModel.setSelectedExportType(exportType), + }), + h('label.form-check-label', { + for: id, + }, exportType), + ]); + })), + h('button.shadow-level1.btn.btn-success.mt2#send', { + disabled: !enabled, + onclick: async () => { + await runsOverviewModel.createRunsExport( + runs, + 'runs', + runsActiveColumns, + ); + modalHandler.dismiss(); + }, + }, runs ? 'Export' : 'Loading data'), + ]; +}; + +const errorDisplay = () => h('.danger', 'Data fetching failed'); + +/** + * A function to construct the exports runs screen + * @param {RunsOverviewModel} runsOverviewModel Pass the model to access the defined functions + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * @return {Component} Return the view of the inputs + */ +const exportRunsModal = (runsOverviewModel, modalHandler) => { + const runsRemoteData = runsOverviewModel.allRuns; + + return h('div#export-runs-modal', [ + h('h2', 'Export Runs'), + runsRemoteData.match({ + NotAsked: () => errorDisplay(), + Loading: () => exportForm(runsOverviewModel, null, modalHandler), + Success: (payload) => exportForm(runsOverviewModel, payload, modalHandler), + Failure: () => errorDisplay(), + }), + ]); +}; + +/** + * Builds a button which will open popover for data export + * @param {RunsOverviewModel} runsModel runs overview model + * @param {ModelModel} modalModel modal model + * @param {object} [displayConfiguration] additional display options + * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not + * @returns {Component} button + */ +export const exportRunsTriggerAndModal = (runsModel, modalModel, { autoMarginLeft = true } = {}) => + h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-runs-trigger`, { + disabled: runsModel.items.match({ + Success: (payload) => payload.length === 0, + NotAsked: () => true, + Failure: () => true, + Loading: () => true, + }), + onclick: () => modalModel.display({ content: (modalModel) => exportRunsModal(runsModel, modalModel), size: 'medium' }), + }, 'Export Runs'); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 08301fba2c..fdc2ee2f31 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -35,6 +35,14 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo super(model); this._detectors$ = detectorsProvider.qc$; this._detectors$.bubbleTo(this); + this._detectors$.observe((observableData) => observableData.getCurrent().apply({ + Success: (detectors) => detectors.forEach(({ id }) => { + this._filteringModel.put(`detectorsQc[_${id}][notBadFraction]`, new NumericalComparisonFilterModel({ + scale: 0.01, + integer: false, + })); + }), + })); this._dataPass = new ObservableData(RemoteData.notAsked()); this._dataPass.bubbleTo(this); this._qcSummary$ = new ObservableData(RemoteData.notAsked()); @@ -50,12 +58,10 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._skimmableRuns$ = new ObservableData(RemoteData.notAsked()); this._skimmableRuns$.bubbleTo(this); - this._gaqNotBadFractionFilterModel = new NumericalComparisonFilterModel({ + this._filteringModel.put('gaq[notBadFraction]', new NumericalComparisonFilterModel({ scale: 0.01, integer: false, - }); - this._gaqNotBadFractionFilterModel.observe(() => this._applyFilters()); - this._gaqNotBadFractionFilterModel.visualChange$.bubbleTo(this); + })); this._runDetectorsSelectionModel = new RunDetectorsSelectionModel(); this._runDetectorsSelectionModel.bubbleTo(this); @@ -67,13 +73,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._discardAllQcFlagsActionState$.bubbleTo(this); } - /** - * @inheritDoc - */ - isAnyFilterActive() { - return super.isAnyFilterActive() || !this._gaqNotBadFractionFilterModel.isEmpty; - } - /** * Change ready for skimming flag for given run * @@ -133,13 +132,16 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo * @inheritdoc */ getRootEndpoint() { + const gaqNotBadFilter = this._filteringModel.get('gaq[notBadFraction]'); const filter = { dataPassIds: [this._dataPassId] }; - if (!this._gaqNotBadFractionFilterModel.isEmpty) { + if (!gaqNotBadFilter.isEmpty) { filter.gaq = { - notBadFraction: this._gaqNotBadFractionFilterModel.normalized, mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, }; } + filter.detectorsQc = { + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + }; return buildUrl(super.getRootEndpoint(), { filter }); } @@ -149,7 +151,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo */ resetFiltering(fetch = true) { super.resetFiltering(fetch); - this._gaqNotBadFractionFilterModel?.reset(); // Reset is called in super class } /** @@ -227,15 +228,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo return this._discardAllQcFlagsActionState$.getCurrent(); } - /** - * Get gaqNotBadFraction filter model - * - * @return {NumericalComparisonFilterModel} filter model - */ - get gaqNotBadFractionFilterModel() { - return this._gaqNotBadFractionFilterModel; - } - /** * Send request to mark current data pass as skimmable * @return {Promise} resolved once request is handled diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 81539250a3..15f58fd57e 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -15,7 +15,7 @@ import { DropdownComponent, h, iconWarning, sessionService, switchCase } from '/ import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; +import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; @@ -72,7 +72,7 @@ const mcReproducibleAsNotBadToggle = (value, onChange) => h('#mcReproducibleAsNo * @param {DataPass} dataPass data pass * @param {function} onclick onclick action * @param {RemoteData} requestResult remote data of result of request for marking data pass as skimmable - * @return {Component} badge or button + * @return {Component|null} badge or button */ const skimmableControl = (dataPass, onclick, requestResult) => { const { name, pdpBeamType, skimmingStage } = dataPass; @@ -91,6 +91,8 @@ const skimmableControl = (dataPass, onclick, requestResult) => { NotAsked: () => h('button.btn.primary', { onclick }, buttonContent), }); } + + return null; }; /** @@ -192,11 +194,11 @@ export const RunsPerDataPassOverviewPage = ({ return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); }, - filter: ({ gaqNotBadFractionFilterModel }) => numericalComparisonFilter( - gaqNotBadFractionFilterModel, - { step: 0.0001, selectorPrefix: 'gaqNotBadFraction' }, + filter: ({ filteringModel }) => numericalComparisonFilter( + filteringModel.get('gaq[notBadFraction]'), + { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }, ), - filterTooltip: 'expressed as a percentage', + filterTooltip: 'not-bad fraction expressed as a percentage', profiles: ['runsPerDataPass'], }, ...createRunDetectorsAsyncQcActiveColumns( @@ -205,7 +207,7 @@ export const RunsPerDataPassOverviewPage = ({ remoteDplDetectorsUserHasAccessTo, { dataPass }, { - profiles: 'runsPerDataPass', + profile: 'runsPerDataPass', qcSummary, mcReproducibleAsNotBad, }, @@ -239,7 +241,7 @@ export const RunsPerDataPassOverviewPage = ({ h('#actions-dropdown-button', DropdownComponent( h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), h('.flex-column.p2.g2', [ - exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + exportRunsTriggerAndModal(perDataPassOverviewModel, modalModel, { autoMarginLeft: false }), frontLink( h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { disabled: runDetectorsSelectionIsEmpty, diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index b8344fb144..1ae252ab85 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -15,7 +15,7 @@ import { h } from '/js/src/index.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; +import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { runDetectorsQualitiesActiveColumns } from '../ActiveColumns/runDetectorsQualitiesActiveColumns.js'; import { inelasticInteractionRateActiveColumnsForProtonProton } from '../ActiveColumns/inelasticInteractionRateActiveColumnsForProtonProton.js'; @@ -106,7 +106,7 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel return h('.intermediate-flex-column', [ h('.flex-row.justify-between.g2', [ h('h2', `Good, physics runs of ${lhcPeriodName}`), - exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), + exportRunsTriggerAndModal(perLhcPeriodOverviewModel, modalModel), ]), ...tabbedPanelComponent( tabbedPanelModel, diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js index 1d1c9968de..1cf1287076 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js @@ -15,7 +15,7 @@ import { h } from '/js/src/index.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { exportTriggerAndModal } from '../Overview/exportTriggerAndModal.js'; +import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import spinner from '../../../components/common/spinner.js'; @@ -82,7 +82,7 @@ export const RunsPerSimulationPassOverviewPage = ({ remoteDplDetectorsUserHasAccessTo, { simulationPass }, { - profiles: 'runsPerSimulationPass', + profile: 'runsPerSimulationPass', qcSummary, }, ), @@ -95,7 +95,7 @@ export const RunsPerSimulationPassOverviewPage = ({ breadcrumbs([commonTitle, h('h2#breadcrumb-simulation-pass-name', simulationPass.name)]), ), h('.mlauto', qcSummaryLegendTooltip()), - exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + exportRunsTriggerAndModal(perSimulationPassOverviewModel, modalModel, { autoMarginLeft: false }), frontLink( h( 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 4317302b5b..3fa37fa77f 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -46,7 +46,7 @@ const { runService } = require('../../../lib/server/services/run/RunService.js') const { expect } = chai; -const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; +const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; module.exports = () => { let page; @@ -867,12 +867,12 @@ module.exports = () => { }); it('should successfully display runs export modal on click on export button', async () => { - let exportModal = await page.$('#export-data-modal'); + let exportModal = await page.$('#export-runs-modal'); expect(exportModal).to.be.null; await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-data-modal'); - exportModal = await page.$('#export-data-modal'); + await page.waitForSelector('#export-runs-modal'); + exportModal = await page.$('#export-runs-modal'); expect(exportModal).to.not.be.null; }); @@ -882,10 +882,10 @@ module.exports = () => { await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); - const truncatedExportWarning = await page.waitForSelector('#export-data-modal #truncated-export-warning'); + const truncatedExportWarning = await page.waitForSelector('#export-runs-modal #truncated-export-warning'); expect(await truncatedExportWarning.evaluate((warning) => warning.innerText)) .to - .equal('The data export is limited to 100 entries, only the most recent data will be exported'); + .equal('The runs export is limited to 100 entries, only the last runs will be exported (sorted by run number)'); }); it('should successfully display disabled runs export button when there is no runs available', async () => { @@ -903,11 +903,11 @@ module.exports = () => { it('should successfully export filtered runs', async () => { await goToPage(page, 'run-overview'); - const targetFileName = 'data.json'; + const targetFileName = 'runs.json'; // First export await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-data-modal'); + await page.waitForSelector('#export-runs-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); @@ -944,7 +944,7 @@ module.exports = () => { ///// Download await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-data-modal'); + await page.waitForSelector('#export-runs-modal'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index e2def9b6ab..1656ee6a20 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -264,12 +264,12 @@ module.exports = () => { it('should successfully export runs', async () => { await navigateToRunsPerDataPass(page, 1, 3, 4); - const targetFileName = 'data.json'; + const targetFileName = 'runs.json'; // First export await pressElement(page, '#actions-dropdown-button .popover-trigger', true); - await pressElement(page, '#export-data-trigger'); - await page.waitForSelector('#export-data-modal'); + await pressElement(page, '#export-runs-trigger'); + await page.waitForSelector('#export-runs-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); @@ -417,6 +417,20 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); + it('should successfully apply detectors notBadFraction filters', async () => { + await page.waitForSelector('#detectorsQc-for-1-notBadFraction-operator'); + await page.select('#detectorsQc-for-1-notBadFraction-operator', '<='); + await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); + await expectColumnValues(page, 'runNumber', ['106']); + + await pressElement(page, '#mcReproducibleAsNotBadToggle input', true); + await expectColumnValues(page, 'runNumber', ['107', '106']); + + await pressElement(page, '#openFilterToggle', true); + await pressElement(page, '#reset-filters', true); + await expectColumnValues(page, 'runNumber', ['108', '107', '106']); + }); + it('should successfully apply muInelasticInteractionRate filters', async () => { await navigateToRunsPerDataPass(page, 2, 1, 3); await pressElement(page, '#openFilterToggle'); @@ -432,7 +446,6 @@ module.exports = () => { }); it('should successfully mark as skimmable', async () => { - await expectInnerText(page, '#skimmableControl .badge', 'Skimmable'); await DataPassRepository.updateAll({ skimmingStage: null }, { where: { id: 1 } }); await navigateToRunsPerDataPass(page, 2, 1, 3); diff --git a/test/public/runs/runsPerLhcPeriod.overview.test.js b/test/public/runs/runsPerLhcPeriod.overview.test.js index b58be68d5c..7e23ba1fcf 100644 --- a/test/public/runs/runsPerLhcPeriod.overview.test.js +++ b/test/public/runs/runsPerLhcPeriod.overview.test.js @@ -179,7 +179,7 @@ module.exports = () => { await waitForTableLength(page, 4); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; it('should successfully export all runs per lhc Period', async () => { await page.evaluate(() => { @@ -187,7 +187,7 @@ module.exports = () => { model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; }); - const targetFileName = 'data.json'; + const targetFileName = 'runs.json'; // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index 18ab625bfa..adb30973f3 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -191,13 +191,13 @@ module.exports = () => { }); it('should successfully export runs', async () => { - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; - const targetFileName = 'data.json'; + const targetFileName = 'runs.json'; // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); - await page.waitForSelector('#export-data-modal'); + await page.waitForSelector('#export-runs-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); From e90524a23b77804f81ca77d9c5ca3d5b233bfb03 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Mon, 4 Aug 2025 12:10:29 +0200 Subject: [PATCH 22/25] cleanup --- lib/public/models/DataExportModel.js | 207 ------------------ .../Runs/Overview/exportTriggerAndModal.js | 147 ------------- package-lock.json | 25 +-- package.json | 4 +- 4 files changed, 8 insertions(+), 375 deletions(-) delete mode 100644 lib/public/models/DataExportModel.js delete mode 100644 lib/public/views/Runs/Overview/exportTriggerAndModal.js diff --git a/lib/public/models/DataExportModel.js b/lib/public/models/DataExportModel.js deleted file mode 100644 index 796f2a913e..0000000000 --- a/lib/public/models/DataExportModel.js +++ /dev/null @@ -1,207 +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 { Observable } from '/js/src/index.js'; -import { createCSVExport, createJSONExport } from '../utilities/export.js'; -import pick from '../utilities/pick.js'; - -/** - * @typedef FieldExportConfiguration - * - * @property {function|null} exportFormat - */ - -/** - * @typdef DataExportConfiguration - * - * @type {object} mapping: field name -> field export configuration - */ - -/** - * Model handling export configuration and creation - */ -export class DataExportModel extends Observable { - /** - * Constructor - * - * @param {ObservableData>} items$ observable data used as source for export - * @param {DataExportConfiguration} dataExportConfiguration defines selectable fields and their formatting - * @param {function} onDataNotAskedAction function to be called in case of missing data - */ - constructor(items$, dataExportConfiguration, onDataNotAskedAction) { - super(); - - this._items$ = items$; - this._items$.bubbleTo(this); - - this._selectedFields = []; - - this._selectedExportType = 'JSON'; - - this._visualChange$ = new Observable(); - - this._exportName = 'data'; - - this._dataExportConfiguration = dataExportConfiguration; - this._onDataNotAskedAction = onDataNotAskedAction; - - this._disabled = null; - - this._totalExistingItemsCount = null; - } - - /** - * Get data export configuration - * - * @return {DataExportConfiguration} configuration - */ - get dataExportConfiguration() { - return this._dataExportConfiguration; - } - - /** - * Get total number of items that model knows they exist - * - * @return {number} totalExistingItemsCount - */ - get totalExistingItemsCount() { - return this._totalExistingItemsCount; - } - - /** - * Set total number of items that model should they exist - * @param {number} totalExistingItemsCount count - */ - setTotalExistingItemsCount(totalExistingItemsCount) { - this._totalExistingItemsCount = totalExistingItemsCount; - } - - /** - * State whether exporting is enabled - * @return {boolean} if true disabled, otherwise enabled - */ - get disabled() { - return this._disabled; - } - - /** - * Set whether exporting is enabled - * @param {boolean} disabled if true disabled, otherwise enabled - */ - setDisabled(disabled) { - this._disabled = disabled; - } - - /** - * If the model has no data to be exported, it calls for it via action function provided in constructor - * @return {Promise|null} promise if data were not asked for, null otherwise - */ - callForData() { - if (this._onDataNotAskedAction) { - return this.items.match({ - NotAsked: () => this._onDataNotAskedAction(), - Other: () => null, - }); - } else { - throw new Error('onDataNotAskedAction was not provided'); - } - } - - /** - * Return the current items remote data - * - * @return {RemoteData} the items - */ - get items() { - return this._items$.getCurrent(); - } - - /** - * Observable notified when the export configuration visually changes - * - * @return {Observable} the visual change observable - */ - get visualChange$() { - return this._visualChange$; - } - - /** - * Get export type selected by the user - * - * @return {string} export type - */ - get selectedExportType() { - return this._selectedExportType; - } - - /** - * Set export type - * - * @param {string} exportType export type - * @return {void} - */ - setSelectedExportType(exportType) { - this._selectedExportType = exportType; - this.notify(); - this._visualChange$.notify(); - } - - /** - * Get selected fields - * - * @return {string[]} selected fields - */ - get selectedFields() { - return this._selectedFields; - } - - /** - * Update selected fields from HTML options list - * - * @param {HTMLCollection|Array} selectedOptions options collection - * @return {void} - */ - setSelectedFields(selectedOptions) { - this._selectedFields = []; - [...selectedOptions].forEach(({ value }) => this._selectedFields.push(value)); - this.notify(); - this._visualChange$.notify(); - } - - /** - * Create export using current items observable - * - * @return {Promise} void - */ - async createExport() { - this.items.apply({ - Success: (items) => { - const { selectedFields } = this; - - const formatted = items.map((item) => { - const selectedEntries = Object.entries(pick(item, selectedFields)); - const mappedEntries = selectedEntries.map(([key, value]) => { - const formatter = this.dataExportConfiguration[key]?.exportFormat || ((v) => v); - return [key, formatter(value, item)]; - }); - - return Object.fromEntries(mappedEntries); - }); - - this.selectedExportType === 'CSV' - ? createCSVExport(formatted, `${this._exportName}.csv`, 'text/csv;charset=utf-8;') - : createJSONExport(formatted, `${this._exportName}.json`, 'application/json'); - }, - }); - } -} diff --git a/lib/public/views/Runs/Overview/exportTriggerAndModal.js b/lib/public/views/Runs/Overview/exportTriggerAndModal.js deleted file mode 100644 index 673adda77e..0000000000 --- a/lib/public/views/Runs/Overview/exportTriggerAndModal.js +++ /dev/null @@ -1,147 +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 '../../../components/common/spinner.js'; -import { h } from '/js/src/index.js'; - -/** - * Export form component, containing the fields to export, the export type and the export button - * - * @param {DataExportModel} exportModel export model - * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export - * - * @return {vnode[]} the form component - */ -const exportForm = (exportModel, modalHandler) => { - const exportTypes = ['JSON', 'CSV']; - - const { selectedFields } = exportModel; - const { selectedExportType } = exportModel; - const fields = Object.keys(exportModel.dataExportConfiguration); - - const fieldsSelected = selectedFields.length > 0; - - const fieldsSelectionLabels = [ - h('label.form-check-label.f4.mt1', { for: 'fields' }, 'Fields'), - h( - 'label.form-check-label.f6', - { for: 'fields' }, - 'Select which fields to be exported. (CTRL + click for multiple selection)', - ), - ]; - - const fieldsSelection = h('select#fields.form-control', { - style: 'min-height: 20rem;', - multiple: true, - onchange: ({ target }) => exportModel.setSelectedFields(target.selectedOptions), - }, [ - ...fields - .filter((name) => !['id', 'actions'].includes(name)) - .map((name) => h('option', { - value: name, - selected: selectedFields.length ? selectedFields.includes(name) : false, - }, name)), - ]); - - const exportTypeSelectionLabels = [ - h('label.form-check-label.f4.mt1', 'Export type'), - h('label.form-check-label.f6', 'Select output format'), - ]; - - const exportTypeSelect = h('.flex-row.g3', exportTypes.map((exportType) => { - const id = `data-export-type-${exportType}`; - return h('.form-check', [ - h('input.form-check-input', { - id, - type: 'radio', - value: exportType, - checked: selectedExportType.length ? selectedExportType.includes(exportType) : false, - name: 'export-type', - onclick: () => exportModel.setSelectedExportType(exportType), - }), - h('label.form-check-label', { - for: id, - }, exportType), - ]); - })); - - const dataAvailable = exportModel.items.match({ Success: () => true, Other: () => false }); - - const exportBtn = h('button.shadow-level1.btn.btn-success.mt2#send', { - disabled: !(fieldsSelected && dataAvailable), - onclick: async () => { - await exportModel.createExport(); - modalHandler.dismiss(); - }, - }, dataAvailable ? 'Export' : 'Loading data...'); - - const dataLength = exportModel.items.match({ Success: ({ length } = {}) => length, Other: () => null }); - const { totalExistingItemsCount } = exportModel; - - const truncatedDataInfo = dataLength && dataLength < totalExistingItemsCount - ? h( - '#truncated-export-warning.warning', - `The data export is limited to ${dataLength} entries, only the most recent data will be exported`, - ) - : null; - - return [ - truncatedDataInfo, - fieldsSelectionLabels, - fieldsSelection, - exportTypeSelectionLabels, - exportTypeSelect, - exportBtn, - ]; -}; - -const errorDisplay = () => h('.danger', 'Data fetching failed'); - -/** - * A function to construct the exports data screen - * - * @param {DataExportModel} exportModel pass the model to access the defined functions - * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export - * @return {Component} Return the view of the inputs - */ -const exportModal = (exportModel, modalHandler) => { - exportModel.callForData(); - const dataLoading = exportModel.items.match({ Loading: () => true, Other: () => false }); - - return h('div#export-data-modal', [ - h('.flex-row', [ - h('h2.w-50', 'Export data'), - dataLoading ? h('.w-50', spinner({ size: 2, absolute: false })) : null, - ]), - exportModel.items.match({ - NotAsked: () => errorDisplay(), - Failure: () => errorDisplay(), - Other: () => exportForm(exportModel, modalHandler), - }), - ]); -}; - -/** - * Builds a button which will open popover for data export - * - * @param {DataExportModel} exportModel export model - * @param {ModalModel} modalModel modal model - * @param {object} [displayConfiguration] additional display options - * @param {boolean} [displayConfiguration.autoMarginLeft = true] if true margin left is set to auto, otherwise not - * @returns {Component} button - */ -export const exportTriggerAndModal = (exportModel, modalModel, { autoMarginLeft = true } = {}) => - h(`button.btn.btn-primary${autoMarginLeft ? '.mlauto' : ''}#export-data-trigger`, { - disabled: exportModel.disabled, - onclick: () => modalModel.display({ content: (modalModel) => exportModal(exportModel, modalModel), size: 'medium' }), - }, 'Export data'); diff --git a/package-lock.json b/package-lock.json index 4182f244f6..43dabe0501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aliceo2/bookkeeping", - "version": "1.10.0", + "version": "1.11.0", "bundleDependencies": [ "@aliceo2/web-ui", "@grpc/grpc-js", @@ -24,7 +24,7 @@ "umzug" ], "dependencies": { - "@aliceo2/web-ui": "2.8.4", + "@aliceo2/web-ui": "2.8.5", "@grpc/grpc-js": "1.13.0", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", @@ -74,9 +74,9 @@ } }, "node_modules/@aliceo2/web-ui": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.8.4.tgz", - "integrity": "sha512-ZMZbKpFrs6jiEcQyIXpHNksYx4GMyi6U2HOjNWwm8+Ne1ltz1R18z9JvANKfpBymzxe/DQgATyblHZJhruJ4Kg==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.8.5.tgz", + "integrity": "sha512-lpHYn/cnFxNJdl5du/+vt2gTbc6/xc4RyTTHadl+0UexduBIJLoQTHA7RZ5Uu4ix9oIVRkaXtGq+MeyNQnhvFA==", "inBundle": true, "license": "GPL-3.0", "dependencies": { @@ -4023,19 +4023,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.30.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", - "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, "node_modules/eslint/node_modules/debug": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", diff --git a/package.json b/package.json index bcb90438b6..24c63bfa1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.10.0", + "version": "1.11.0", "author": "ALICEO2", "scripts": { "coverage": "nyc npm test && npm run coverage:report", @@ -22,7 +22,7 @@ "node": ">= 22.x" }, "dependencies": { - "@aliceo2/web-ui": "2.8.4", + "@aliceo2/web-ui": "2.8.5", "@grpc/grpc-js": "1.13.0", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", From 0613f66cab4aa626d39df75b30800f845b2c2ac2 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Mon, 4 Aug 2025 12:20:07 +0200 Subject: [PATCH 23/25] typo, cleanup --- lib/domain/dtos/GetAllRunsDto.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/domain/dtos/GetAllRunsDto.js b/lib/domain/dtos/GetAllRunsDto.js index 18b792678d..4848667de1 100644 --- a/lib/domain/dtos/GetAllRunsDto.js +++ b/lib/domain/dtos/GetAllRunsDto.js @@ -24,7 +24,7 @@ const QueryDto = Joi.object({ const [, { filter: { dataPassIds, simulationPassIds, lhcPeriodIds } = {} }] = helpers.state.ancestors; const runsCollectionFilters = [dataPassIds, simulationPassIds, lhcPeriodIds].filter(({ length } = {}) => length >= 1); if (runsCollectionFilters.length !== 1 || runsCollectionFilters[0].length !== 1) { - return helpers.message('Including effectiveQcFlags is allowed only when filtering by one nd exactly one of: ' + + return helpers.message('Including effectiveQcFlags is allowed only when filtering by one and exactly one of: ' + 'dataPassIds, simulationPassIds, lhcPeriodIds with exactly one ID.'); } From 72d43f19f4ad4237724bdcf0f6febaeda3be2df4 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Mon, 4 Aug 2025 12:30:49 +0200 Subject: [PATCH 24/25] fixes --- lib/usecases/run/GetAllRunsUseCase.js | 2 +- test/api/qcFlags.test.js | 4 ++-- .../server/services/qualityControlFlag/QcFlagService.test.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index 295e0d1a0b..1af0e385f2 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -458,7 +458,7 @@ class GetAllRunsUseCase { attributes: ['runNumber'], raw: true, include: { - association: 'lhcPeriods', + association: 'lhcPeriod', attributes: [], where: { id: { [Op.in]: lhcPeriodIds } }, }, diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index f511365c58..ad43a30cdc 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -150,9 +150,9 @@ module.exports = () => { expect(data).to.be.eql({ 106: { 1: { - missingVerificationsCount: 1, + missingVerificationsCount: 2, mcReproducible: false, - badEffectiveRunCoverage: 0.7222222, + badEffectiveRunCoverage: 0.9288889, explicitlyNotBadEffectiveRunCoverage: 0, }, }, diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index 16e05d3c2c..0636cb833a 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -290,9 +290,9 @@ module.exports = () => { expect(await qcFlagSummaryService.getSummary({ simulationPassId: 1 })).to.be.eql({ 106: { 1: { - missingVerificationsCount: 1, + missingVerificationsCount: 2, mcReproducible: false, - badEffectiveRunCoverage: 0.7222222, + badEffectiveRunCoverage: 0.9288889, explicitlyNotBadEffectiveRunCoverage: 0, }, }, From 160ce7a47ed194c154b94d0a4efcd165ed4f59f9 Mon Sep 17 00:00:00 2001 From: xsalonx Date: Mon, 4 Aug 2025 16:10:23 +0200 Subject: [PATCH 25/25] refactor --- lib/domain/dtos/GetAllRunsDto.js | 13 ++++++---- lib/domain/dtos/filters/RunFilterDto.js | 12 +++++---- lib/domain/dtos/utils.js | 33 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 lib/domain/dtos/utils.js diff --git a/lib/domain/dtos/GetAllRunsDto.js b/lib/domain/dtos/GetAllRunsDto.js index 4848667de1..4f2eab197f 100644 --- a/lib/domain/dtos/GetAllRunsDto.js +++ b/lib/domain/dtos/GetAllRunsDto.js @@ -15,6 +15,7 @@ const Joi = require('joi'); const PaginationDto = require('./PaginationDto'); const { RunFilterDto } = require('./filters/RunFilterDto.js'); const { DtoFactory } = require('./DtoFactory'); +const { singleRunsCollectionCustomCheck } = require('./utils.js'); const QueryDto = Joi.object({ filter: RunFilterDto, @@ -22,11 +23,13 @@ const QueryDto = Joi.object({ sort: DtoFactory.order(['id', 'runNumber', 'text']), include: Joi.object({ effectiveQcFlags: Joi.boolean().custom((effectiveQcFlags, helpers) => { const [, { filter: { dataPassIds, simulationPassIds, lhcPeriodIds } = {} }] = helpers.state.ancestors; - const runsCollectionFilters = [dataPassIds, simulationPassIds, lhcPeriodIds].filter(({ length } = {}) => length >= 1); - if (runsCollectionFilters.length !== 1 || runsCollectionFilters[0].length !== 1) { - return helpers.message('Including effectiveQcFlags is allowed only when filtering by one and exactly one of: ' + - 'dataPassIds, simulationPassIds, lhcPeriodIds with exactly one ID.'); - } + + singleRunsCollectionCustomCheck( + { dataPassIds, simulationPassIds, lhcPeriodIds }, + helpers, + 'Including effectiveQcFlags is allowed only when ' + + 'the dataPassIds, simulationPassIds and lhcPeriodIds filters collectively contain exactly one ID', + ); return effectiveQcFlags; }) }), diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index 2f9a2d9ba5..0c8c032b63 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -17,6 +17,7 @@ const { RUN_QUALITIES } = require('../../enums/RunQualities.js'); const { IntegerComparisonDto, FloatComparisonDto } = require('./NumericalComparisonDto.js'); const { RUN_CALIBRATION_STATUS } = require('../../enums/RunCalibrationStatus.js'); const { RUN_DEFINITIONS } = require('../../enums/RunDefinition.js'); +const { singleRunsCollectionCustomCheck } = require('../utils.js'); const DetectorsFilterDto = Joi.object({ operator: Joi.string().valid('or', 'and', 'none').required(), @@ -128,12 +129,13 @@ exports.RunFilterDto = Joi.object({ }) .custom((detectorsQcObj, helpers) => { const [{ dataPassIds, simulationPassIds, lhcPeriodIds }] = helpers.state.ancestors; - const runsCollectionFilters = [dataPassIds, simulationPassIds, lhcPeriodIds].filter(({ length } = {}) => length >= 1); - if (runsCollectionFilters.length !== 1 || runsCollectionFilters[0].length !== 1) { - return helpers.message('Filtering by detector not-bad fraction is allowed only with exactly one of: ' + - 'dataPassIds, simulationPassIds, lhcPeriodIds with exactly one ID.'); - } + singleRunsCollectionCustomCheck( + { dataPassIds, simulationPassIds, lhcPeriodIds }, + helpers, + 'Filtering by detector not-bad fraction is allowed only when ' + + 'the dataPassIds, simulationPassIds and lhcPeriodIds filters collectively contain exactly one ID', + ); return detectorsQcObj; }), diff --git a/lib/domain/dtos/utils.js b/lib/domain/dtos/utils.js new file mode 100644 index 0000000000..34be3ee2ca --- /dev/null +++ b/lib/domain/dtos/utils.js @@ -0,0 +1,33 @@ +/** + * @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. + */ + +/** + * Used in Joi schemas to assert that the dataPassIds, simulationPassIds and lhcPeriodIds filters collectively contain exactly one ID + * + * @param {object} collections runs ids collections + * @param {number[]} collections.dataPassIds data pass ids + * @param {number[]} collections.simulationPassIds simulation pass ids + * @param {number[]} collections.lhcPeriodIds LHC periods ids + * @param {Joi.helpers} helpers joi helpers + * @param {string} message to be send in case of filters refer to more than one runs collection + * @returns {void} + */ +const singleRunsCollectionCustomCheck = ({ dataPassIds, simulationPassIds, lhcPeriodIds }, helpers, message) => { + const runsCollectionFilters = [dataPassIds, simulationPassIds, lhcPeriodIds].filter(({ length } = {}) => length >= 1); + + if (runsCollectionFilters.length !== 1 || runsCollectionFilters[0].length !== 1) { + return helpers.message(message); + } +}; + +module.exports.singleRunsCollectionCustomCheck = singleRunsCollectionCustomCheck;