diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 05dcac3bd..127d8430a 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 486968a5d..abb67a8f7 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/common/notifications/model/NotificationRunStartModel.js b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js index 37e9ad8eb..2bd53506c 100644 --- a/QualityControl/public/common/notifications/model/NotificationRunStartModel.js +++ b/QualityControl/public/common/notifications/model/NotificationRunStartModel.js @@ -96,7 +96,7 @@ export default class NotificationRunStartModel extends Observable { // We select the given `runNumber` in RunMode. // We do not have to set the parameter in the URL, as this is already achieved on navigation. - this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString()); + this.model.filterModel.setFilterValue('RunNumber', runNumber, true); }, }); } 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/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js index 8c05e9524..5c017af2e 100644 --- a/QualityControl/test/public/components/profileHeader.test.js +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -253,7 +253,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) }); await page.waitForFunction(() => document.querySelector('#run-mode-switch .switch input[type="checkbox"]')?.checked === true); - + await delay(500); // Wait a bit for the RunMode filter to update selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); strictEqual( selectedRun, diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 129c98391..20a265074 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -43,7 +43,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