Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions QualityControl/lib/dtos/ObjectGetDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import Joi from 'joi';
import { RunNumberDto } from './filters/RunNumberDto.js';
import { DetectorNameDto } from './filters/DetectorNameDto.js';

const periodNamePattern = /^LHC\d{1,2}[a-z0-9]+$/i;
const qcVersionPattern = /^\d+\.\d+(\.\d+)?$/;
Expand All @@ -26,6 +27,7 @@ const qcVersionPattern = /^\d+\.\d+(\.\d+)?$/;
function createFiltersSchema(runTypes) {
return Joi.object({
RunNumber: RunNumberDto.optional(),
DetectorName: DetectorNameDto.optional(),
RunType: runTypes.length > 0
? Joi.string().valid(...runTypes).optional()
: Joi.string().optional(),
Expand Down
30 changes: 30 additions & 0 deletions QualityControl/lib/dtos/filters/DetectorNameDto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
* All rights not expressly granted are reserved.
*
* This software is distributed under the terms of the GNU General Public
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import Joi from 'joi';

/**
* Joi validation schema for QcDetectorName filter (also known as detector when fetched from BKP and used in UI)
* @type {Joi.StringSchema}
*/
export const DetectorNameDto = Joi.string()
.uppercase()
.length(3)
.pattern(/^[A-Z]{3}$/)
.messages({
'string.base': 'Detector name must be a string',
'string.uppercase': 'Detector name must be uppercase',
'string.length': 'Detector name must be exactly 3 characters',
'string.pattern.base': 'Detector name must contain only uppercase letters (e.g., TPC, ITS)',
});
4 changes: 3 additions & 1 deletion QualityControl/lib/services/FilterService.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export class FilterService {
}
try {
const detectorSummaries = await this._bookkeepingService.retrieveDetectorSummaries();
this._detectors = Object.freeze(detectorSummaries.map(({ name, type }) => Object.freeze({ name, type })));
this._detectors = Object.freeze(detectorSummaries
.filter(({ name, type }) => name && type)
.map(({ name, type }) => Object.freeze({ name, type })));
} catch (error) {
this._logger.errorMessage(`Failed to retrieve detectors: ${error?.message || error}`);
}
Expand Down
1 change: 1 addition & 0 deletions QualityControl/lib/services/ccdb/CcdbConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const CcdbMetadataFields = Object.freeze({
PeriodName: 'PeriodName',
PassName: 'PassName',
QcVersion: 'qc_version',
DetectorName: 'qc_detector_name',
});

/**
Expand Down
93 changes: 84 additions & 9 deletions QualityControl/public/common/filters/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ import { h, RemoteData } from '/js/src/index.js';
* @param {object} config.filterMap - Map of the current filter values.
* @param {string} [config.type='text'] - The type of the filter element (e.g., 'text', 'number').
* @param {RemoteData} [config.options=RemoteData.notAsked()] - List of options for a dropdown selector (optional).
* @param {Function} config.onChangeCallback - Callback to be triggered on the change event of the filter.
* @param {Function} config.onInputCallback - Callback to be triggered on the input event.
* @param {Function} config.onEnterCallback - Callback to be triggered when the Enter key is pressed.
* @param {onchange} config.onChangeCallback - Callback to be triggered on the change event of the filter.
* @param {oninput} config.onInputCallback - Callback to be triggered on the input event.
* @param {onkeydown} config.onEnterCallback - Callback to be triggered when the Enter key is pressed.
* @param {string} [config.filterType=FilterType.DROPDOWN] - The type of filter to be used.
* @param {string} [config.width='.'] - The CSS class that defines the width of the filter.
* @returns {vnode} - A virtual node element representing the filter element (input or dropdown).
Expand Down Expand Up @@ -61,6 +61,81 @@ export const dynamicSelector = (config) => {
});
};

/**
* Represents options grouped for HTML <optgroup>.
* Keys are group labels (for the <optgroup> label),
* values are arrays of option values (for <option> elements).
* @typedef {Record<string, string[]>} GroupedDropdownOptions
*/

/**
* Builds a filter element. If options to show, selector filter element; otherwise, input element.
* @param {object} config - Configuration object for building the filter element.
* @param {string} config.queryLabel - The key used to query the storage with this parameter.
* @param {string} config.placeholder - The placeholder text to be displayed in the input field.
* @param {string} config.id - The unique identifier for the input field.
* @param {object} config.filterMap - Map of the current filter values.
* @param {string} [config.type='text'] - The type of the filter element (e.g., 'text', 'number').
* @param {GroupedDropdownOptions} [config.options={}] - List of options for a grouped dropdown selector (optional).
* @param {(filterId: string, value: string, setUrl: boolean) => void} config.onChangeCallback
* - Callback to be triggered on the change event of the filter.
* @param {(filterId: string, value: string, setUrl: boolean) => void} config.onInputCallback
* - Callback to be triggered on the input event.
* @param {(filterId: string, value: string, setUrl: boolean) => void} config.onEnterCallback
* - Callback to be triggered when the Enter key is pressed.
* @param {string} [config.width='.w-20'] - The CSS class that defines the width of the filter.
* @returns {vnode} A virtual node element representing the filter element (input or grouped dropdown).
*/
export const groupedDropdownComponent = ({
queryLabel,
placeholder,
id,
filterMap,
options = {},
onChangeCallback,
onInputCallback,
onEnterCallback,
type = 'text',
width = '.w-20',
}) => {
const groups = Object.keys(options);
if (!groups.length) {
return filterInput({ queryLabel, placeholder, id, filterMap, onInputCallback, onEnterCallback, type, width });
}

const selectedOption = filterMap[queryLabel];
const validValue = Object.values(options).flat().some((option) => option === selectedOption);
if (selectedOption && !validValue) {
onChangeCallback(queryLabel, '', true);
}

const sortedGroupedOptions = groups
.sort((a, b) => a.localeCompare(b)) // sort group labels
.reduce((acc, key) => {
// sort option names and add to accumulator
acc[key] = [...options[key]].sort((a, b) => a.localeCompare(b));
return acc;
}, {});

return h(`${width}`, [
h('select.form-control', {
placeholder,
id,
name: id,
value: validValue ? selectedOption : '',
onchange: (event) => onChangeCallback(queryLabel, event.target.value, true),
}, [
h('option', { value: '' }, placeholder),
h('hr'),
...Object.entries(sortedGroupedOptions).map(([key, value]) => h(
'optgroup',
{ label: key },
value.map((option) => h('option', { value: option }, option)),
)),
]),
]);
};

/**
* Builds a filter input element that allows the user to specify a parameter to be used when querying objects.
* This function renders a text input element with event handling for input and Enter key press.
Expand All @@ -69,8 +144,8 @@ export const dynamicSelector = (config) => {
* @param {string} config.placeholder - The placeholder text to be displayed in the input field.
* @param {string} config.id - The unique identifier for the input field.
* @param {object} config.filterMap - Map of the current filter values.
* @param {Function} config.onInputCallback - Callback to be triggered on the input event.
* @param {Function} config.onEnterCallback - Callback to be triggered when the Enter key is pressed.
* @param {oninput} config.onInputCallback - Callback to be triggered on the input event.
* @param {onkeydown} config.onEnterCallback - Callback to be triggered when the Enter key is pressed.
* @param {string} [config.type='text'] - The type of the filter element (e.g., 'text', 'number').
* @param {string} [config.width='.w-20'] - The CSS class that defines the width of the filter.
* @returns {vnode} - A virtual node element representing the filter input.
Expand Down Expand Up @@ -105,7 +180,7 @@ export const filterInput = (config) => {
* @param {string} config.id - The unique identifier for the select field.
* @param {object} config.filterMap - Map of the current filter values.
* @param {Array<string>} config.options - List of available options to be shown in the dropdown.
* @param {Function} config.onChangeCallback - Callback to be triggered on the change event of the selector.
* @param {onchange} config.onChangeCallback - Callback to be triggered on the change event of the selector.
* @param {string} [config.width='.w-20'] - The CSS class that defines the width of the dropdown.
* @returns {vnode} - A virtual node element representing the dropdown selector.
*/
Expand Down Expand Up @@ -139,9 +214,9 @@ const dropdownSelector = (config) => {
* @param {object} config - Selector config ({ id, placeholder, width }).
* @param {object} filterMap - Current filters (RunNumber or empty).
* @param {RemoteData} options - Available ongoing runs.
* @param {Function} onChangeCallback - To change the selection and update the filterMap
* @param {Function} onEnterCallback - To trigger the filter
* @param {Function} [onFocusCallback] - To retrieve ongoing runs
* @param {onchange} onChangeCallback - To change the selection and update the filterMap
* @param {onkeydown} onEnterCallback - To trigger the filter
* @param {onfocus} [onFocusCallback] - To retrieve ongoing runs
* @returns {object} Virtual DOM node (hyperscript element).
*/
export const ongoingRunsSelector = (config, filterMap, options, onChangeCallback, onEnterCallback, onFocusCallback) => {
Expand Down
1 change: 1 addition & 0 deletions QualityControl/public/common/filters/filterTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
const FilterType = {
INPUT: 'input',
DROPDOWN: 'dropdownSelector',
GROUPED_DROPDOWN: 'groupedDropdownSelector',
RUN_MODE: 'runModeSelector',
};

Expand Down
9 changes: 8 additions & 1 deletion QualityControl/public/common/filters/filterViews.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
* or submit itself to any jurisdiction.
*/

import { filterInput, dynamicSelector, ongoingRunsSelector } from './filter.js';
import {
filterInput,
dynamicSelector,
ongoingRunsSelector,
groupedDropdownComponent,
} from './filter.js';
import { FilterType } from './filterTypes.js';
import { filtersConfig, runModeFilterConfig } from './filtersConfig.js';
import { runModeCheckbox } from './runMode/runModeCheckbox.js';
Expand All @@ -28,7 +33,7 @@
* Creates an input element for a specific metadata field;
* @param {object} config - The configuration for this particular field
* @param {object} filterMap - An object that contains the keys and values of the filters
* @param {Function} onInputCallback - A callback function that triggers upon Input

Check warning on line 36 in QualityControl/public/common/filters/filterViews.js

View workflow job for this annotation

GitHub Actions / Check eslint rules on ubuntu-latest

Prefer a more specific type to `Function`
* @param {Function} onEnterCallback - A callback function that triggers upon Enter
* @param {Function} onChangeCallback - A callback function that triggers upon Change
* @param onFocusCallback
Expand All @@ -50,6 +55,8 @@
case FilterType.INPUT: return filterInput({ ...commonConfig, type: inputType });
case FilterType.DROPDOWN:
return dynamicSelector({ ...commonConfig, options, onChangeCallback, inputType });
case FilterType.GROUPED_DROPDOWN:
return groupedDropdownComponent({ ...commonConfig, options, onChangeCallback, inputType });
case FilterType.RUN_MODE:
return ongoingRunsSelector(
{ ...commonConfig },
Expand Down
23 changes: 20 additions & 3 deletions QualityControl/public/common/filters/filtersConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import { FilterType } from './filterTypes.js';
/**
* Returns an array of filter configuration objects used to render dynamic filter inputs.
* @param {FilterService} filterService - service to get the data to populate the filters
* @param {Array<string>} filterService.runTypes - run types to show in the filter
* @returns {Array<object>} Filter configuration array
* @param {string[]} filterService.runTypes - run types to show in the filter
* @param {DetectorSummary[]} filterService.detectors - detectors to show in the filter
* @returns {object[]} Filter configuration array
*/
export const filtersConfig = ({ runTypes }) => [
export const filtersConfig = ({ runTypes, detectors }) => [
{
type: FilterType.INPUT,
queryLabel: 'RunNumber',
Expand All @@ -35,6 +36,22 @@ export const filtersConfig = ({ runTypes }) => [
id: 'runTypeFilter',
options: runTypes,
},
{
type: FilterType.GROUPED_DROPDOWN,
queryLabel: 'DetectorName',
placeholder: 'Detector (any)',
id: 'detectorFilter',
options: detectors.match({
Success: (detectors) => detectors.reduce((acc, detector) => {
if (!acc[detector.type]) {
acc[detector.type] = [];
}
acc[detector.type].push(detector.name);
return acc;
}, {}),
Other: () => {},
}),
},
{
type: FilterType.INPUT,
queryLabel: 'PeriodName',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import FilterService from '../../../services/Filter.service.js';
import { RunStatus } from '../../../library/runStatus.enum.js';
import { prettyFormatDate } from '../../utils.js';

const CCDB_QUERY_PARAMS = ['PeriodName', 'PassName', 'RunNumber', 'RunType'];
const CCDB_QUERY_PARAMS = ['PeriodName', 'PassName', 'RunNumber', 'RunType', 'DetectorName', 'QcVersion'];

const RUN_INFORMATION_MAP = {
startTime: prettyFormatDate,
Expand Down
33 changes: 27 additions & 6 deletions QualityControl/public/services/Filter.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,27 @@ export default class FilterService {
this.filterModel = filterModel;
this.loader = filterModel.model.loader;

this.runTypes = RemoteData.notAsked();
this._runTypes = RemoteData.notAsked();
this._detectors = RemoteData.notAsked();

this.ongoingRuns = RemoteData.notAsked();
}

/**
* Method to get all run types to show in the filter
* @returns {RemoteData} - result within a RemoteData object
*/
async getRunTypes() {
this.runTypes = RemoteData.loading();
async getFilterConfigurations() {
this._runTypes = RemoteData.loading();
this._detectors = RemoteData.loading();
this.filterModel.notify();
const { result, ok } = await this.loader.get('/api/filter/configuration');
if (ok) {
this.runTypes = RemoteData.success(result?.runTypes || []);
this._runTypes = RemoteData.success(result?.runTypes || []);
this._detectors = RemoteData.success(result?.detectors || []);
} else {
this.runTypes = RemoteData.failure('Error retrieving runTypes');
this._runTypes = RemoteData.failure('Error retrieving runTypes');
this._detectors = RemoteData.failure('Error retrieving detectors');
}
this.filterModel.notify();
}
Expand Down Expand Up @@ -73,7 +78,7 @@ export default class FilterService {
* @returns {void}
*/
async initFilterService() {
await this.getRunTypes();
await this.getFilterConfigurations();
}

/**
Expand Down Expand Up @@ -105,4 +110,20 @@ export default class FilterService {
}
this.filterModel.notify();
}

/**
* Gets the list of run types.
* @returns {string[]} An array containing the run types.
*/
get runTypes() {
return this._runTypes;
}

/**
* Gets the list of detectors.
* @returns {DetectorSummary[]} An array containing detector objects.
*/
get detectors() {
return this._detectors;
}
}
10 changes: 9 additions & 1 deletion QualityControl/test/lib/services/FilterService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,20 @@ export const filterServiceTestSuite = async () => {
name: 'Detector human-readable name',
type: 'Detector type identifier',
},
{
name: '',
type: 'OTHER',
},
{
name: 'Another Detector',
type: '',
},
];

bookkeepingServiceMock.retrieveDetectorSummaries.resolves(DETECTOR_SUMMARIES);
await filterService._initializeDetectors();

deepStrictEqual(filterService._detectors, DETECTOR_SUMMARIES);
deepStrictEqual(filterService._detectors, [DETECTOR_SUMMARIES[0]]);
ok(Object.isFrozen(filterService._detectors));
});
});
Expand Down
31 changes: 31 additions & 0 deletions QualityControl/test/public/pages/object-tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,35 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent)
deepStrictEqual(options, ['', 'runType1', 'runType2']);
},
);

await testParent.test(
'should have a grouped selector with sorted options to filter by detector if there are detectors loaded',
{ timeout },
async () => {
const optionsObject = await page.evaluate(() => {
const optionElements = document.querySelectorAll('#detectorFilter > optgroup > option');

return Array.from(optionElements).reduce((acc, option) => {
const optgroup = option.parentElement.label;
if (!optgroup) {
return acc;
}

if (!acc[optgroup]) {
acc[optgroup] = [];
}
acc[optgroup].push(option.value);

return acc;
}, {});
});

deepStrictEqual(optionsObject, {
'AOT-EVENT': ['EVS'],
PHYSICAL: ['ACO', 'CPV'],
QC: ['GLO'],
VIRTUAL: ['TST'],
});
},
);
};
Loading