Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5f45fb4
feat: add fetching of already ongoing runs at server start
AlexJanson Jan 15, 2026
882fa2b
test: add tests for fetching ongoing runs at server start
AlexJanson Jan 15, 2026
6e9ee66
feat: add combobox option to the filter input types
AlexJanson Jan 5, 2026
eaf0900
feat: add dropdown with the options
AlexJanson Jan 5, 2026
994e9d1
feat: add styling based upon the framework defined styles
AlexJanson Jan 5, 2026
9da30ca
feat: add keyboard navigation to the combobox
AlexJanson Jan 5, 2026
cda1dad
docs: rename service to filterService
AlexJanson Jan 5, 2026
25ea043
fix: call fetch ongoing runs once
AlexJanson Jan 5, 2026
b81167d
fix: change run number filter to combobox
AlexJanson Jan 5, 2026
8c55622
fix: empty list throwing errors for undefined
AlexJanson Jan 6, 2026
daecc32
feat: add unselectable placeholder and onblur remove highlighted rows
AlexJanson Jan 9, 2026
acde26c
feat: add fetching ongoing runs at filter service initialisation
AlexJanson Jan 15, 2026
2f0037e
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
graduta Jan 17, 2026
1a5ff86
RunMode is not fetching data anymore from BKP
graduta Jan 17, 2026
3b2ddde
Use convention for ongoing runs variable
graduta Jan 17, 2026
a665809
Fix combox functionality
graduta Jan 17, 2026
77fb819
Revert test change
graduta Jan 17, 2026
bdff5ca
Return normal inputfilter model if BKP no data
graduta Jan 17, 2026
038f662
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
graduta Jan 19, 2026
b2638b8
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
graduta Jan 20, 2026
d99f85a
Update test for ongoing runs fetching
graduta Jan 20, 2026
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
35 changes: 35 additions & 0 deletions QualityControl/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,38 @@
flex: 1;
}
}

.combobox-container {
position: relative;

&:focus-within .combobox-list {
display: block;
width: 100%;
}

& .combobox-list {
padding: 0;
margin: 0;
padding-inline-start: 0;
list-style: none;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
margin-top: var(--space-xs);

& .combobox-item {
&:hover, &.is-highlighted {
background-color: var(--color-primary);
color: var(--color-white);
}
}

& .combobox-header {
padding: 4px 12px;
font-size: 0.85rem;
color: var(--color-gray-darker);
font-weight: bold;
}
}
}
106 changes: 106 additions & 0 deletions QualityControl/public/common/filters/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,109 @@ export const ongoingRunsSelector = (config, filterMap, options, onChangeCallback
),
]);
};

/**
* Renders a searchable Combobox with keyboard navigation using ALICE O2 design.
* This component is a stateless function that leverages CSS `:focus-within` for visibility
* and direct DOM manipulation for arrow-key highlighting to avoid FilterModel pollution.
* @param {object} config - The configuration for the combobox field.
* @param {string} config.id - The unique HTML ID for the input element.
* @param {string} config.inputType - The type for the HTML input element.
* @param {string} config.queryLabel - The key name in the filterMap to update.
* @param {string} config.placeholder - The placeholder text for the input.
* @param {string} config.width - Width of the input container.
* @param {object} filterMap - Object containing current filter keys and values.
* @param {RemoteData} options - RemoteData object containing the list of available options.
* @param {onenter} onEnterCallback - Callback to trigger filtering.
* @param {oninput} onInputCallback - Callback to update the filter value.
* @returns {vnode} - A virtual node representing the combobox.
*/
export const combobox = (
{ id, inputType, queryLabel, placeholder, width = '.w-20' },
filterMap,
options,
onEnterCallback,
onInputCallback,
) => {
const ongoingRuns = options.isSuccess() && options.payload.length > 0 ? options.payload : [];
if (!ongoingRuns.length) {
return filterInput({
queryLabel, placeholder, id, filterMap, onInputCallback, onEnterCallback, type: inputType, width,
});
}
const filtered = filterMap[queryLabel]
? ongoingRuns?.filter((option) =>
String(option).toLowerCase().includes(filterMap[queryLabel].toLowerCase()))
: ongoingRuns;

const handleKeyNavigation = (e) => {
const container = e.target.closest('.combobox-container');
const items = [...container.querySelectorAll('.combobox-item:not(.combobox-header)')];
const current = container.querySelector('.is-highlighted');
const index = items.indexOf(current);

const move = (nextIndex) => {
if (current) {
current.classList.remove('is-highlighted');
}
if (items[nextIndex]) {
items[nextIndex].classList.add('is-highlighted');
items[nextIndex].scrollIntoView({ block: 'nearest' });
}
};

const keys = {
ArrowDown: () => move(Math.min(index + 1, items.length - 1)),
ArrowUp: () => move(Math.max(index - 1, 0)),
Enter: () => {
if (current) {
onInputCallback(queryLabel, current.innerText, true);
}
e.target.blur();
},
};

if (keys[e.key]) {
if (e.key !== 'Enter') {
e.preventDefault();
}
keys[e.key]();
}
};

return h(`${width}.combobox-container`, [
h('input.form-control', {
id,
placeholder,
type: inputType,
autocomplete: 'off',
min: 0,
value: filterMap[queryLabel] || '',
oninput: (event) => onInputCallback(queryLabel, event.target.value, true),
onkeydown: handleKeyNavigation,
onblur: (e) => {
const container = e.target.closest('.combobox-container');
container.querySelector('.is-highlighted')?.classList.remove('is-highlighted');
},
}),

filtered.length > 0 && h(
'ul.combobox-list.dropdown-menu',
[
h('li.combobox-header.dropdown-header', {
style: { pointerEvents: 'none', userSelect: 'none' },
}, placeholder),

...filtered.map((option) =>
h('li.combobox-item.menu-item', {
onmousedown: (e) => {
// onmousedown to capture before blur event
e.preventDefault();
onInputCallback(queryLabel, option, true);
e.target.closest('.combobox-container').querySelector('input')?.blur();
},
}, option)),
],
),
]);
};
1 change: 1 addition & 0 deletions QualityControl/public/common/filters/filterTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const FilterType = {
DROPDOWN: 'dropdownSelector',
GROUPED_DROPDOWN: 'groupedDropdownSelector',
RUN_MODE: 'runModeSelector',
COMBOBOX: 'combobox',
};

export { FilterType };
15 changes: 9 additions & 6 deletions QualityControl/public/common/filters/filterViews.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ongoingRunsSelector,
groupedDropdownComponent,
inputWithDropdownComponent,
combobox,
} from './filter.js';
import { FilterType } from './filterTypes.js';
import { filtersConfig, runModeFilterConfig } from './filtersConfig.js';
Expand All @@ -34,10 +35,10 @@ import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js';
* 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
* @param {Function} onEnterCallback - A callback function that triggers upon Enter
* @param {Function} onChangeCallback - A callback function that triggers upon Change
* @param onFocusCallback
* @param {oninput} onInputCallback - A callback function that triggers upon Input
* @param {onenter} onEnterCallback - A callback function that triggers upon Enter
* @param {onchange} onChangeCallback - A callback function that triggers upon Change
* @param {onfocus} onFocusCallback - A callback function that triggers upon Focus
* @returns {undefined}
*/
const createFilterElement =
Expand Down Expand Up @@ -69,6 +70,8 @@ const createFilterElement =
onEnterCallback,
onFocusCallback,
);
case FilterType.COMBOBOX:
return combobox({ ...config }, filterMap, options, onEnterCallback, onInputCallback);
default: return null;
}
};
Expand Down Expand Up @@ -126,7 +129,7 @@ export function filtersPanel(filterModel, viewModel) {

/**
* Button which will allow the user to update filter parameters after the input
* @param {Function} onClickCallback - Function to trigger the filter mechanism
* @param {onclick} onClickCallback - Function to trigger the filter mechanism
* @param {FilterModel} filterModel - Model that manages filter state
* @returns {vnode} - virtual node element
*/
Expand All @@ -149,7 +152,7 @@ const triggerFiltersButton = (onClickCallback, filterModel) => {

/**
* Button which will allow the user to clear the filter element
* @param {Function} clearFilterCallback - Function that clears the filter state.
* @param {onclick} clearFilterCallback - Function that clears the filter state.
* @returns {vnode} - virtual node element
*/
const clearFiltersButton = (clearFilterCallback) =>
Expand Down
5 changes: 3 additions & 2 deletions QualityControl/public/common/filters/filtersConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import { FilterType } from './filterTypes.js';
* @param {RemoteData<DataPass[]>} filterService.dataPasses - data passes to show in the filter
* @returns {object[]} Filter configuration array
*/
export const filtersConfig = ({ runTypes, detectors, dataPasses }) => [
export const filtersConfig = ({ runTypes, detectors, dataPasses, ongoingRuns }) => [
{
type: FilterType.INPUT,
type: FilterType.COMBOBOX,
queryLabel: 'RunNumber',
placeholder: 'RunNumber (e.g. 546783)',
id: 'runNumberFilter',
inputType: 'number',
options: ongoingRuns,
},
{
type: FilterType.DROPDOWN,
Expand Down
22 changes: 15 additions & 7 deletions QualityControl/public/common/filters/model/FilterModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export default class FilterModel extends Observable {
this._runInformation = {};

this.ONGOING_RUN_INTERVAL_MS = 15000;

this.filterService.fetchOngoingRuns();
}

/**
Expand Down Expand Up @@ -103,10 +105,14 @@ export default class FilterModel extends Observable {
* @returns {undefined}
*/
setFilterValue(key, value, setUrl = false) {
if (value?.trim()) {
this._filterMap[key] = value;
} else {
delete this._filterMap[key];
if (typeof value === 'string') {
if (value && value?.trim()) {
this._filterMap[key] = value;
} else {
delete this._filterMap[key];
}
} else if (typeof value === 'number') {
this._filterMap[key] = String(value);
}

if (setUrl) {
Expand Down Expand Up @@ -187,9 +193,11 @@ export default class FilterModel extends Observable {
*/
async activateRunsMode(viewModel) {
this.isRunModeActivated = true;
await this.filterService.fetchOngoingRuns();
if (this._filterMap.RunNumber) {
this._filterMap = { RunNumber: this._filterMap.RunNumber };
const { RunNumber } = this._filterMap;
this.clearFilters();

if (RunNumber) {
this._filterMap = { RunNumber };
this.triggerFilter(viewModel);
} else {
const { ongoingRuns } = this.filterService;
Expand Down
16 changes: 12 additions & 4 deletions QualityControl/public/services/Filter.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default class FilterService {
this._detectors = RemoteData.notAsked();
this._dataPasses = RemoteData.notAsked();

this.ongoingRuns = RemoteData.notAsked();
this._ongoingRuns = RemoteData.notAsked();
}

/**
Expand Down Expand Up @@ -104,13 +104,13 @@ export default class FilterService {
* @returns {void} assigns the remoteData object to ongoingRuns
*/
async fetchOngoingRuns() {
this.ongoingRuns = RemoteData.loading();
this._ongoingRuns = RemoteData.loading();
this.filterModel.notify();
const { result, ok } = await this.loader.get('/api/filter/ongoingRuns');
if (ok) {
this.ongoingRuns = RemoteData.success(result?.ongoingRuns);
this._ongoingRuns = RemoteData.success(result?.ongoingRuns);
} else {
this.ongoingRuns = RemoteData.failure('Error retrieving ongoing runs');
this._ongoingRuns = RemoteData.failure('Error retrieving ongoing runs');
}
this.filterModel.notify();
}
Expand Down Expand Up @@ -138,4 +138,12 @@ export default class FilterService {
get dataPasses() {
return this._dataPasses;
}

/**
* Gets the list of ongoing runs.
* @returns {RemoteData<number[]>} An array containing the ongoing run numbers.
*/
get ongoingRuns() {
return this._ongoingRuns;
}
}
2 changes: 1 addition & 1 deletion QualityControl/test/public/features/filterTest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => {
strictEqual(value, '0', 'RunNumber filter should still be set to 0 on objectView page');

// Navigate to layout show
await page.locator('.menu-item:nth-child(1)').click();
await page.locator('nav .menu-item:nth-child(1)').click();
await page.waitForSelector('#runNumberFilter', { visible: true });

// Check that filter is still set to 0
Expand Down
9 changes: 0 additions & 9 deletions QualityControl/test/public/features/runMode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,13 @@ import { ONGOING_RUN_NUMBER } from '../../setup/mockKafkaEvents.js';
// import nock from 'nock';
export const runModeTests = async (url, page, timeout = 5000, testParent) => {
const mockedTestRunNumber = ONGOING_RUN_NUMBER;
let countOngoingRunsCalls = 0;
let countRunStatusCalls = 0;
let expectCountRunStatusCalls = 0;
let countObjectsCalls = 0;

page.on('request', (req) => {
const url = req.url();
const decodedUrl = decodeURIComponent(url);
if (url.includes('/api/filter/ongoingRuns')) {
countOngoingRunsCalls++;
}
if (url.includes('/api/objects') && decodedUrl.includes(`filters[RunNumber]=${mockedTestRunNumber}`)) {
countObjectsCalls++;
} else if (url.includes(`/api/filter/run-status/${mockedTestRunNumber}`)) {
Expand Down Expand Up @@ -167,11 +163,6 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => {
ok(isRunModeActivated, 'Run mode should be activated');
});

await testParent.test('should make a request to ongoing runs API', { timeout }, async () => {
await delay(200);
strictEqual(countOngoingRunsCalls, expectCountRunStatusCalls, `Expect 1 req to /api/filter/ongoingRuns, but got ${countOngoingRunsCalls}`);
});

await testParent.test('should display ongoing runs selector', { timeout }, async () => {
await page.waitForSelector('#ongoingRunsFilter', { timeout: 1000 });
const selector = await page.locator('#ongoingRunsFilter');
Expand Down
26 changes: 22 additions & 4 deletions QualityControl/test/public/initialPageSetup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* or submit itself to any jurisdiction.
*/

import assert from 'node:assert';
import { strictEqual, ok } from 'node:assert';

/**
* Initial page setup tests
Expand Down Expand Up @@ -41,14 +41,32 @@ export const initialPageSetupTests = async (url, page, timeout = 1000, testParen

await testParent.test('should successfully have redirected to default page "/?page=layoutList"', async () => {
const location = await page.evaluate(() => window.location);
assert.strictEqual(location.search, '?page=layoutList');
strictEqual(location.search, '?page=layoutList');
});

await testParent.test('should have a layout with header, sidebar and section', async () => {
const headerContent = await page.evaluate(() => document.querySelector('header').innerHTML);
const sidebarContent = await page.evaluate(() => document.querySelector('nav').innerHTML);

assert.ok(headerContent.includes('Quality Control'));
assert.ok(sidebarContent.includes('Explore'));
ok(headerContent.includes('Quality Control'));
ok(sidebarContent.includes('Explore'));
});

await testParent.test('should make a request to ongoing runs API on page load', { timeout }, async () => {
let countOngoingRunsCalls = 0;

page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/filter/ongoingRuns')) {
countOngoingRunsCalls++;
}
});

await page.goto(
`${url}?page=objectTree`,
{ waitUntil: 'networkidle0' },
);

strictEqual(countOngoingRunsCalls, 1, `Expect 1 req to /api/filter/ongoingRuns, but got ${countOngoingRunsCalls}`);
});
};
Loading