diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 81a6b4c29..530e2e524 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -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; + } + } +} diff --git a/QualityControl/public/common/filters/filter.js b/QualityControl/public/common/filters/filter.js index b92ea7df6..9ba2217ba 100644 --- a/QualityControl/public/common/filters/filter.js +++ b/QualityControl/public/common/filters/filter.js @@ -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)), + ], + ), + ]); +}; diff --git a/QualityControl/public/common/filters/filterTypes.js b/QualityControl/public/common/filters/filterTypes.js index 0ac837bf3..2c4ef2baf 100644 --- a/QualityControl/public/common/filters/filterTypes.js +++ b/QualityControl/public/common/filters/filterTypes.js @@ -18,6 +18,7 @@ const FilterType = { DROPDOWN: 'dropdownSelector', GROUPED_DROPDOWN: 'groupedDropdownSelector', RUN_MODE: 'runModeSelector', + COMBOBOX: 'combobox', }; export { FilterType }; diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index da9337415..1980f66a4 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -18,6 +18,7 @@ import { ongoingRunsSelector, groupedDropdownComponent, inputWithDropdownComponent, + combobox, } from './filter.js'; import { FilterType } from './filterTypes.js'; import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; @@ -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 = @@ -69,6 +70,8 @@ const createFilterElement = onEnterCallback, onFocusCallback, ); + case FilterType.COMBOBOX: + return combobox({ ...config }, filterMap, options, onEnterCallback, onInputCallback); default: return null; } }; @@ -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 */ @@ -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) => diff --git a/QualityControl/public/common/filters/filtersConfig.js b/QualityControl/public/common/filters/filtersConfig.js index 45c2f9e75..20b2558e6 100644 --- a/QualityControl/public/common/filters/filtersConfig.js +++ b/QualityControl/public/common/filters/filtersConfig.js @@ -22,13 +22,14 @@ import { FilterType } from './filterTypes.js'; * @param {RemoteData} 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, diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index 58be9760e..3cb0582dc 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -49,6 +49,8 @@ export default class FilterModel extends Observable { this._runInformation = {}; this.ONGOING_RUN_INTERVAL_MS = 15000; + + this.filterService.fetchOngoingRuns(); } /** @@ -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) { @@ -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; diff --git a/QualityControl/public/services/Filter.service.js b/QualityControl/public/services/Filter.service.js index 0758cfaa6..d976ca6c8 100644 --- a/QualityControl/public/services/Filter.service.js +++ b/QualityControl/public/services/Filter.service.js @@ -31,7 +31,7 @@ export default class FilterService { this._detectors = RemoteData.notAsked(); this._dataPasses = RemoteData.notAsked(); - this.ongoingRuns = RemoteData.notAsked(); + this._ongoingRuns = RemoteData.notAsked(); } /** @@ -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(); } @@ -138,4 +138,12 @@ export default class FilterService { get dataPasses() { return this._dataPasses; } + + /** + * Gets the list of ongoing runs. + * @returns {RemoteData} An array containing the ongoing run numbers. + */ + get ongoingRuns() { + return this._ongoingRuns; + } } diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 39ac31d67..4d585d989 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -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 diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 9d8bada41..1b2772df7 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -23,7 +23,6 @@ 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; @@ -31,9 +30,6 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { 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}`)) { @@ -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'); diff --git a/QualityControl/test/public/initialPageSetup.test.js b/QualityControl/test/public/initialPageSetup.test.js index aa81f3f6a..ad5ef2bd5 100644 --- a/QualityControl/test/public/initialPageSetup.test.js +++ b/QualityControl/test/public/initialPageSetup.test.js @@ -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 @@ -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}`); }); };