diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 3338c8ec56..9193b9cb18 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -16,6 +16,13 @@ import { ObservableData } from '../utilities/ObservableData.js'; import { PaginatedRemoteDataSource } from '../utilities/fetch/PaginatedRemoteDataSource.js'; import { PaginationModel } from '../components/Pagination/PaginationModel.js'; import { buildUrl } from '../utilities/fetch/buildUrl.js'; +import pick from '../utilities/pick.js'; +import { createCSVExport, createJSONExport } from '../utilities/export.js'; + +export const EXPORT_FORMATS = { + JSON: 'JSON', + CSV: 'CSV', +}; /** * Interface of a model representing an overview page state @@ -51,6 +58,25 @@ export class OverviewPageModel extends Observable { this._observableItems = ObservableData.builder().initialValue(RemoteData.loading()).build(); this._observableItems.bubbleTo(this); + + this._exportDataSource = new PaginatedRemoteDataSource(); + this._exportObservableItems = ObservableData.builder() + .initialValue(RemoteData.notAsked()) + .apply((remoteData) => remoteData.apply({ Success: ({ items }) => this.processItems(items) })) + .build(); + this._exportObservableItems.bubbleTo(this); + this._exportDataSource.pipe(this._exportObservableItems); + + /** + * Default name for export data file, can be overriden in inherting classes + */ + this._defaultExportName = 'bkp-export'; + this._exportName = ''; + + /** + * Default format + */ + this._defaultExportFormat = EXPORT_FORMATS.JSON; } /** @@ -119,8 +145,97 @@ export class OverviewPageModel extends Observable { * @return {Promise} void */ async load() { - const params = await this.getLoadParameters(); - await this._dataSource.fetch(buildUrl(this.getRootEndpoint(), params)); + const paginationParams = await this.getLoadParameters(); + this._exportObservableItems.setCurrent(RemoteData.notAsked()); + await this._dataSource.fetch(buildUrl(this.getRootEndpoint(), paginationParams)); + } + + /** + * Fetch all the relevant items from the API + * @return {Promise} void + */ + async loadExport() { + return this._exportObservableItems.getCurrent().match({ + Success: () => null, + Loading: () => null, + Other: () => this._exportDataSource.fetch(this.getRootEndpoint()), + }); + } + + /** + * Create the export with the variables set in the model, handling errors appropriately + * @param {object[]} items The source content. + * @param {Object>} exportFormats defines how particual fields of data units will be formated + * @return {void} + */ + async export(items, exportFormats = {}) { + const { exportFields, exportName } = this; + const exportData = items.map((item) => { + const entries = Object.entries(pick(item, exportFields)); + const formattedEntries = entries.map(([key, value]) => { + const formatExport = exportFormats[key].exportFormat || ((identity) => identity); + return [key, formatExport(value, item)]; + }); + return Object.fromEntries(formattedEntries); + }); + if (this.exportFormat === EXPORT_FORMATS.JSON) { + createJSONExport(exportData, `${exportName}.json`, 'application/json'); + } else if (this.exportFormat === EXPORT_FORMATS.CSV) { + createCSVExport(exportData, `${exportName}.csv`, 'text/csv;charset=utf-8;'); + } else { + throw new Error('Incorrect export format'); + } + } + + /** + * Get export name + * @return {string} name + */ + get exportName() { + return this._exportName || this._defaultExportName; + } + + /** + * Set export name + * @param {string} exportName name + */ + set exportName(exportName) { + this._exportName = exportName; + } + + /** + * Get the field values that will be exported + * @return {string[]} the list of fields of a export items to be included in the export + */ + get exportFields() { + return this._exportFields || []; + } + + /** + * Set the selected fields to be exported + * @param {SelectionOption[]|HTMLCollection} exportFields the list of fields of export items to be included in the export + */ + set exportFields(exportFields) { + this._exportFields = []; + [...exportFields].map((selectedOption) => this._exportFields.push(selectedOption.value)); + this.notify(); + } + + /** + * Get the output format of the export + * @return {string} the output format + */ + get exportFormat() { + return this._exportFormat || this._defaultExportFormat; + } + + /** + * Set the export type parameter of the export to be created + * @param {string} exportFormat one of acceptable export formats @see EXPORT_FORMATS + */ + set exportFormat(exportFormat) { + this._exportFormat = exportFormat; + this.notify(); } /** @@ -136,8 +251,30 @@ export class OverviewPageModel extends Observable { } /** - * Return the current items remote data + * States if the list of NOT paginated runs contains the full list of runs available under the given criteria * + * @return {boolean|null} true if the runs list is not truncated (null if all items are not yet available) + */ + get areExportItemsTruncated() { + return this.exportItems.match({ + Success: (payload) => this.items.match({ + Success: () => payload.length < this._pagination.itemsCount, + Other: () => null, + }), + Other: () => null, + }); + } + + /** + * Return the export items remote data + * @return {RemoteData} the items + */ + get exportItems() { + return this._exportObservableItems.getCurrent(); + } + + /** + * Return the current items remote data * @return {RemoteData} the items */ get items() { diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index e1f4cf6951..365b78adbd 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -11,16 +11,12 @@ * or submit itself to any jurisdiction. */ -import { 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 { RunTypesSelectionDropdownModel } from '../../../components/runTypes/RunTypesSelectionDropdownModel.js'; import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/EorReasonsFilterModel.js'; -import pick from '../../../utilities/pick.js'; import { OverviewPageModel } from '../../../models/OverviewModel.js'; -import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; /** * Model representing handlers for runs page @@ -51,8 +47,7 @@ export class RunsOverviewModel extends OverviewPageModel { this._eorReasonsFilterModel.observe(() => this._applyFilters()); this._eorReasonsFilterModel.visualChange$.observe(() => this.notify()); - // Export items - this._allRuns = RemoteData.NotAsked(); + this._defaultExportName = 'runs'; this.reset(false); // eslint-disable-next-line no-return-assign,require-jsdoc @@ -70,64 +65,6 @@ export class RunsOverviewModel extends OverviewPageModel { return `/api/runs?${paramsString}`; } - // eslint-disable-next-line valid-jsdoc - /** - * @inheritdoc - */ - async load() { - this._allRuns = RemoteData.NotAsked(); - 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 particual 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 Whether to refetch all logs after filters have been reset @@ -302,29 +239,6 @@ export class RunsOverviewModel extends OverviewPageModel { return this.activeFilters; } - /** - * 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(); - } - /** * Returns the current runNumber substring filter * @return {String} The current runNumber substring filter @@ -799,32 +713,6 @@ export class RunsOverviewModel extends OverviewPageModel { return this._detectorsFilterModel; } - /** - * Return all the runs currently filtered, without paging - * - * @return {RemoteData} the remote data of the runs - */ - get allRuns() { - if (this._allRuns.isNotAsked()) { - this._fetchAllRunsWithoutPagination(); - } - - return this._allRuns; - } - - /** - * States if the list of NOT paginated runs contains the full list of runs available under the given criteria - * - * @return {boolean|null} true if the runs list is not truncated (null if all runs are not yet available) - */ - get isAllRunsTruncated() { - const { allRuns } = this; - if (!allRuns.isSuccess()) { - return null; - } - return allRuns.payload.length < this._pagination.itemsCount; - } - /** * Return the model handling the filtering on run types * @@ -942,33 +830,6 @@ export class RunsOverviewModel extends OverviewPageModel { }; } - /** - * Update the cache containing all the runs without paging - * - * @return {Promise} void - * @private - */ - async _fetchAllRunsWithoutPagination() { - if (this.items.isSuccess() && this.items.payload.length === this._pagination.itemsCount) { - this._allRuns = RemoteData.success([...this.items.payload]); - this.notify(); - return; - } - this._allRuns = RemoteData.loading(); - this.notify(); - - const endpoint = this.getRootEndpoint(); - - try { - const { items } = await getRemoteDataSlice(endpoint); - this._allRuns = RemoteData.success(items); - } catch (errors) { - this._allRuns = RemoteData.failure(errors); - } - - this.notify(); - } - /** * Apply the current filtering and update the remote data list * diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index 1658bebfee..b0b8fe96db 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'; @@ -56,7 +56,7 @@ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, m filtersPanelPopover(runsOverviewModel, runsActiveColumns), h('.pl2#runOverviewFilter', runNumberFilter(runsOverviewModel)), togglePhysicsOnlyFilter(runsOverviewModel), - exportRunsTriggerAndModal(runsOverviewModel, modalModel), + exportTriggerAndModal(runsOverviewModel, modalModel, runsActiveColumns), ]), 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 b7462fb8ab..0000000000 --- a/lib/public/views/Runs/Overview/exportRunsTriggerAndModal.js +++ /dev/null @@ -1,127 +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'), - ]; -}; - -// eslint-disable-next-line require-jsdoc -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 - * @returns {Component} button - */ -export const exportRunsTriggerAndModal = (runsModel, modalModel) => h('button.btn.btn-primary.w-15.h2.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..dbbb53965c --- /dev/null +++ b/lib/public/views/Runs/Overview/exportTriggerAndModal.js @@ -0,0 +1,143 @@ +/** + * @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 { EXPORT_FORMATS } from '../../../models/OverviewModel.js'; +import { h } from '/js/src/index.js'; + +/** + * Export form component, containing the fields to export, the export type and the export button + * + * @param {OverviewPageModel} model the runsOverviewModel + * @param {object[]} items the runs to export + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * @param {object} activeColumns active columns + * @return {vnode[]} the form component + */ +const exportForm = (model, items, modalHandler, activeColumns) => { + const { exportFormat: currentExportFormat, exportFields, areExportItemsTruncated } = model; + const selectableFields = Object.keys(activeColumns); + + return [ + areExportItemsTruncated + ? h( + '#truncated-export-warning.warning', + `The items export is limited to ${items.length} entries, only the last items will be exported`, + ) + : null, + h('label.form-check-label.f4.mt1', { for: 'export-fields' }, 'Fields'), + h( + 'label.form-check-label.f6', + { for: 'export-fields' }, + 'Select which fields to be exported. (CTRL + click for multiple selection)', + ), + h('select#fields.form-control', { + style: 'min-height: 20rem;', + multiple: true, + id: 'export-fields', + // eslint-disable-next-line no-return-assign + onchange: ({ target: { selectedOptions } }) => model.exportFields = selectedOptions, + }, [ + ...selectableFields + .filter((name) => !['id', 'actions'].includes(name)) + .map((name) => h('option', { + value: name, + selected: exportFields.length ? exportFields.includes(name) : false, + }, name)), + ]), + h('.flex-row.justify-between', [ + h('', [ + h('label.form-check-label.f4.mt1', 'Export format'), + h('label.form-check-label.f6', 'Select output format'), + h('.flex-row.g3', Object.values(EXPORT_FORMATS).map((exportFormat) => { + const id = `runs-export-format-${exportFormat}`; + return h('.form-check', [ + h('input.form-check-input', { + id, + type: 'radio', + value: exportFormat, + checked: exportFormat === currentExportFormat, + name: 'runs-export-type', + // eslint-disable-next-line no-return-assign + onclick: () => model.exportFormat = exportFormat, + }), + h('label.form-check-label', { + for: id, + }, exportFormat), + ]); + })), + ]), + + h('', [ + h('label.form-check-label.f4.mt1', { for: 'export-name' }, 'Export name'), + h('input', { + id: 'export-name', + value: model.exportName, + // eslint-disable-next-line no-return-assign + oninput: ({ target: { value: currentText } }) => model.exportName = currentText, + }), + ]), + ]), + h('button.shadow-level1.btn.btn-success.mt2#download-export', { + disabled: exportFields.length === 0, + onclick: async () => { + await model.export( + items, + activeColumns, + ); + await modalHandler.dismiss(); + }, + }, 'Export'), + ]; +}; + +// eslint-disable-next-line require-jsdoc +const errorDisplay = () => h('.danger', 'Data fetching failed'); + +/** + * A function to construct the exports runs screen + * @param {OverviewPageModel} model Pass the model to access the defined functions + * @param {ModalHandler} modalHandler The modal handler, used to dismiss modal after export + * @param {object} activeColumns active columns + * @return {Component} Return the view of the inputs + */ +const exportModal = (model, modalHandler, activeColumns) => { + model.loadExport(); + + return h('div#export-modal', [ + h('h2', 'Export'), + model.exportItems.match({ + NotAsked: () => errorDisplay(), + Loading: () => spinner({ size: 3, absolute: false }), + Success: (payload) => exportForm(model, payload, modalHandler, activeColumns), + Failure: () => errorDisplay(), + }), + ]); +}; + +/** + * Builds a button which will open popover for data export + * @param {OverviewPageModel} model runs overview model + * @param {ModelModel} modalModel modal model + * @param {object} activeColumns active columns + * @returns {Component} button + */ +export const exportTriggerAndModal = (model, modalModel, activeColumns) => h('button.btn.btn-primary.w-15.h2.mlauto#export-trigger', { + disabled: model.items.match({ + Success: (payload) => payload.length === 0, + NotAsked: () => true, + Failure: () => true, + Loading: () => true, + }), + onclick: () => modalModel.display({ content: (modalModel) => exportModal(model, modalModel, activeColumns), size: 'medium' }), +}, 'Export'); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 758f349e51..b023cbf0a4 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -16,7 +16,7 @@ import { table } from '../../../components/common/table/table.js'; import { createRunDetectorsActiveColumns } from '../ActiveColumns/runDetectorsActiveColumns.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'; @@ -61,7 +61,7 @@ export const RunsPerDataPassOverviewPage = ({ runs: { perDataPassOverviewModel } NotAsked: () => [commonTitle, tooltip(h('.f3', iconWarning()), 'No data was asked for')], }), ), - exportRunsTriggerAndModal(perDataPassOverviewModel, modalModel), + exportTriggerAndModal(perDataPassOverviewModel, modalModel, runsActiveColumns), ]), h('.flex-column.w-100', [ table(runs, activeColumns, null, { profile: 'runsPerDataPass' }), diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index e3b7c102b5..b223dceefe 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -16,7 +16,7 @@ import { table } from '../../../components/common/table/table.js'; import { createRunDetectorsActiveColumns } from '../ActiveColumns/runDetectorsActiveColumns.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'; const TABLEROW_HEIGHT = 59; @@ -48,7 +48,7 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel return h('', [ h('.flex-row.justify-between.items-center', [ h('h2', `Good, physics runs of ${lhcPeriodName}`), - exportRunsTriggerAndModal(perLhcPeriodOverviewModel, modalModel), + exportTriggerAndModal(perLhcPeriodOverviewModel, modalModel, runsActiveColumns), ]), h('.flex-column.w-100', [ table(detectors.isSuccess() ? runs : detectors, activeColumns, null, { profile: 'runsPerLhcPeriod' }), diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 8b43057f76..f1ac8044bc 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -23,6 +23,7 @@ const { goToPage, checkColumnBalloon, waitForNetworkIdleAndRedraw, + waitForTableDataReload, } = require('../defaults'); const { RunDefinition } = require('../../../lib/server/services/run/getRunDefinition.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); @@ -1009,54 +1010,39 @@ module.exports = () => { expect(inputText).to.equal(''); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; + const EXPORT_MODAL_TRIGGER_ID = '#export-trigger'; it('should successfully display runs export button', async () => { await goToPage(page, 'run-overview'); - await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); - const runsExportButton = await page.$(EXPORT_RUNS_TRIGGER_SELECTOR); + await page.waitForSelector(EXPORT_MODAL_TRIGGER_ID); + const runsExportButton = await page.$(EXPORT_MODAL_TRIGGER_ID); expect(runsExportButton).to.be.not.null; }); it('should successfully display runs export modal on click on export button', async () => { - let exportModal = await page.$('#export-runs-modal'); - expect(exportModal).to.be.null; - - await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await waitForTimeout(100); - exportModal = await page.$('#export-runs-modal'); - - expect(exportModal).to.not.be.null; + await goToPage(page, 'run-overview'); + await page.waitForSelector('#export-modal', { hidden: true, timeout: 250 }); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await page.waitForSelector('#export-modal', { timeout: 250 }); }); it('should successfully display information when export will be truncated', async () => { await goToPage(page, 'run-overview'); - await waitForTimeout(200); - - await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await waitForTimeout(100); - - const truncatedExportWarning = await page.$('#export-runs-modal #truncated-export-warning'); - expect(truncatedExportWarning).to.not.be.null; - 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)'); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await expectInnerText( + page, + '#export-modal #truncated-export-warning', + 'The items export is limited to 100 entries, only the last items will be exported', + ); }); it('should successfully display disabled runs export button when there is no runs available', async () => { await goToPage(page, 'run-overview'); - await waitForTimeout(200); - await pressElement(page, '#openFilterToggle'); - await waitForTimeout(200); - // Type a fake run number to have no runs - await page.focus(runNumberInputSelector); - await page.keyboard.type('99999999999'); - await waitForTimeout(300); - + await waitForTableDataReload(page, () => fillInput(page, runNumberInputSelector, '99999999999')); await pressElement(page, '#openFilterToggle'); - - expect(await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.disabled)).to.be.true; + await page.waitForSelector(`${EXPORT_MODAL_TRIGGER_ID}[disabled]`, { timeout: 250 }); }); it('should successfully export filtered runs', async () => { @@ -1073,23 +1059,19 @@ module.exports = () => { }); let downloadFilesNames; - const targetFileName = 'runs.json'; + let targetFileName = 'runs.json'; let runs; - let exportModal; // First export - await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); - await page.waitForSelector('#send:disabled'); - await page.waitForSelector('.form-control'); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await page.waitForSelector('#download-export:disabled', { timeout: 500 }); + await expectInnerText(page, '#download-export', 'Export'); + await page.waitForSelector('.form-control', { timeout: 250 }); await page.select('.form-control', 'runQuality', 'runNumber'); - await page.waitForSelector('#send:enabled'); - const exportButtonText = await page.$eval('#send', (button) => button.innerText); - expect(exportButtonText).to.be.eql('Export'); + await page.waitForSelector('#download-export:enabled'); + await expectInnerText(page, '#download-export', 'Export'); - await page.$eval('#send', (button) => button.click()); - - await waitForDownload(session); + await waitForDownload(session, () => pressElement(page, '#download-export')); // Check download downloadFilesNames = fs.readdirSync(downloadPath); @@ -1104,33 +1086,27 @@ module.exports = () => { // Second export // Apply filtering - const filterInputSelectorPrefix = '#runQualityCheckbox'; - const badFilterSelector = `${filterInputSelectorPrefix}bad`; - - await pressElement(page, '#openFilterToggle'); - await page.waitForSelector(badFilterSelector); - await page.$eval(badFilterSelector, (element) => element.click()); - await page.waitForSelector('.atom-spinner'); - await page.waitForSelector('tbody tr:nth-child(2)'); - await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); + await waitForTableDataReload(page, async () => { + await pressElement(page, '#openFilterToggle'); + await pressElement(page, '#runQualityCheckboxbad'); + }); ///// Download - await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); - expect(exportModal).to.not.be.null; - - await page.waitForSelector('.form-control'); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await page.waitForSelector('#export-modal', { timeout: 500 }); + await page.waitForSelector('.form-control', { timeout: 250 }); await page.select('.form-control', 'runQuality', 'runNumber'); - await page.waitForSelector('#send:enabled'); - await page.$eval('#send', (button) => button.click()); + await fillInput(page, 'input#export-name', 'filtered-runs'); + targetFileName = 'filtered-runs.json'; - await waitForDownload(session); + await waitForDownload(session, () => pressElement(page, '#download-export')); // Check download downloadFilesNames = fs.readdirSync(downloadPath); expect(downloadFilesNames.filter((name) => name == targetFileName)).to.be.lengthOf(1); runs = JSON.parse(fs.readFileSync(path.resolve(downloadPath, targetFileName))); expect(runs).to.have.all.deep.members([{ runNumber: 2, runQuality: 'bad' }, { runNumber: 1, runQuality: 'bad' }]); + fs.unlinkSync(path.resolve(downloadPath, targetFileName)); }); it('should successfully navigate to the LHC fill details page', async () => { diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 3aab4ccfe3..79d6bac990 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -207,9 +207,10 @@ module.exports = () => { expect(urlParameters).to.contain(`runNumber=${expectedRunNumber}`); }); - it('should successfully export runs', async () => { + it('should successfully export all runs pere data pass', async () => { await goToPage(page, 'runs-per-data-pass', { queryParameters: { dataPassId: 3 } }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; + + const EXPORT_MODAL_TRIGGER_ID = '#export-trigger'; const downloadPath = path.resolve('./download'); @@ -224,18 +225,15 @@ module.exports = () => { const targetFileName = 'runs.json'; // First export - await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); - await page.waitForSelector('#export-runs-modal'); - await page.waitForSelector('#send:disabled'); - await page.waitForSelector('.form-control'); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await page.waitForSelector('#download-export:disabled', { timeout: 500 }); + await expectInnerText(page, '#download-export', 'Export'); + await page.waitForSelector('.form-control', { timeout: 250 }); await page.select('.form-control', 'runQuality', 'runNumber'); - await page.waitForSelector('#send:enabled'); - const exportButtonText = await page.$eval('#send', (button) => button.innerText); - expect(exportButtonText).to.be.eql('Export'); - - await page.$eval('#send', (button) => button.click()); + await page.waitForSelector('#download-export:enabled'); + await expectInnerText(page, '#download-export', 'Export'); - await waitForDownload(session); + await waitForDownload(session, () => pressElement(page, '#download-export')); // Check download const downloadFilesNames = fs.readdirSync(downloadPath); diff --git a/test/public/runs/runsPerPeriod.overview.test.js b/test/public/runs/runsPerPeriod.overview.test.js index 3411e74959..94313b8bce 100644 --- a/test/public/runs/runsPerPeriod.overview.test.js +++ b/test/public/runs/runsPerPeriod.overview.test.js @@ -204,17 +204,12 @@ module.exports = () => { expect(urlParameters).to.contain(`runNumber=${expectedRunNumber}`); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; - it('should successfully export all runs per lhc Period', async () => { await goToPage(page, 'runs-per-lhc-period', { queryParameters: { lhcPeriodName: 'LHC22a' } }); - const downloadPath = path.resolve('./download'); + const EXPORT_MODAL_TRIGGER_ID = '#export-trigger'; - await page.evaluate(() => { - // eslint-disable-next-line no-undef - model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; - }); + const downloadPath = path.resolve('./download'); // Check accessibility on frontend const session = await page.target().createCDPSession(); @@ -227,14 +222,15 @@ module.exports = () => { const targetFileName = 'runs.json'; // First export - await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); - await page.waitForSelector('select.form-control', { timeout: 200 }); - await page.select('select.form-control', 'runQuality', 'runNumber', 'definition', 'lhcPeriod'); - await expectInnerText(page, '#send:enabled', 'Export'); - await Promise.all([ - waitForDownload(session), - pressElement(page, '#send:enabled'), - ]); + await pressElement(page, EXPORT_MODAL_TRIGGER_ID); + await page.waitForSelector('#download-export:disabled', { timeout: 500 }); + await expectInnerText(page, '#download-export', 'Export'); + await page.waitForSelector('.form-control', { timeout: 250 }); + await page.select('.form-control', 'runQuality', 'runNumber', 'definition', 'lhcPeriod'); + await page.waitForSelector('#download-export:enabled'); + await expectInnerText(page, '#download-export', 'Export'); + + await waitForDownload(session, () => pressElement(page, '#download-export')); // Check download const downloadFilesNames = fs.readdirSync(downloadPath); diff --git a/test/utilities/waitForDownload.js b/test/utilities/waitForDownload.js index c29fdfd269..dc2f0d9c10 100644 --- a/test/utilities/waitForDownload.js +++ b/test/utilities/waitForDownload.js @@ -14,22 +14,26 @@ /** * Create promise which is resolved when last initiated download is completed and rejected when canceled * @param {CDPSession} session puppetear CDP session + * @param {funciton} trigger triggering download * @param {object} options options altering behaviour * @param {number} [options.timeout = 5000] timeout (ms) to reject if not downloaded * @return {Promise} promise * !!! Downloading requires to set 'Browser.setDownloadBehavior' behaviour on the given CDP session */ -async function waitForDownload(session, { timeout = 5000 } = {}) { - return new Promise((resolve, reject) => { - session.on('Browser.downloadProgress', (event) => { - if (event.state === 'completed') { - resolve('download completed'); - } else if (event.state === 'canceled') { - reject('download canceled'); - } - }); - setTimeout(() => reject(`Download timeout after ${timeout} ms`), timeout); - }); +async function waitForDownload(session, trigger, { timeout = 5000 } = {}) { + return Promise.all([ + new Promise((resolve, reject) => { + session.on('Browser.downloadProgress', (event) => { + if (event.state === 'completed') { + resolve('download completed'); + } else if (event.state === 'canceled') { + reject('download canceled'); + } + }); + setTimeout(() => reject(`Download timeout after ${timeout} ms`), timeout); + }), + trigger(), + ]); } exports.waitForDownload = waitForDownload;