From af79d3a3e9eb5a307c0c544a3aa46f99c8bf94a5 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Tue, 20 Oct 2026 17:22:25 +0200 Subject: [PATCH 1/6] 1.21.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc3f22baf0..a6f3268414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.20.0", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aliceo2/bookkeeping", - "version": "1.20.0", + "version": "1.21.0", "bundleDependencies": [ "@aliceo2/web-ui", "@grpc/grpc-js", diff --git a/package.json b/package.json index e45a483892..70e1a7c754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.20.0", + "version": "1.21.0", "author": "ALICEO2", "repository": { "type": "git", From 6052706d7c3ebb53d7c71ebde0ef1e571a0818b4 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 21 Oct 2026 16:07:04 +0200 Subject: [PATCH 2/6] [O2B-1634] Adapt the header to multiple screen sizes - Collapse the page tabs into a menu button below 1280px, keep the header height identical on every screen size - Always display the Bookkeeping title, move the account menu to the left and shrink the create button to a "+" on phones - Highlight the current page in the menu and close the navbar menus on outside click or Escape - Merge the "Log" and "EoS report" buttons into a single "Add an entry" menu listing the log types and the EoS report shift types - Remove the EoS report shift type selection page: opening the creation page without a shift type redirects to the home page --- lib/public/Model.js | 10 ++ lib/public/app.css | 48 +++++ lib/public/components/NavBar/index.js | 170 +++++++++++------- lib/public/views/EosReport/EosReportModel.js | 2 + .../EosReport/create/EosReportCreationPage.js | 19 +- test/public/logs/create.test.js | 1 + 6 files changed, 173 insertions(+), 77 deletions(-) diff --git a/lib/public/Model.js b/lib/public/Model.js index 0d0ae222f3..ea7e8b827f 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -190,6 +190,16 @@ export default class Model extends Observable { // Navbar dropdown menus this.dropdownMenu = false; + this.window.document.addEventListener('click', ({ target }) => { + if (this.dropdownMenu && !target.closest?.('#navbar')) { + this.clearDropdownMenu(); + } + }); + this.window.document.addEventListener('keydown', ({ key }) => { + if (key === 'Escape' && this.dropdownMenu) { + this.clearDropdownMenu(); + } + }); } /** diff --git a/lib/public/app.css b/lib/public/app.css index abd8f54853..ea397e0050 100644 --- a/lib/public/app.css +++ b/lib/public/app.css @@ -798,8 +798,45 @@ label { * x-large : 1920 <= x */ +/* Navbar: below 1280px the page links collapse into a menu, the create button shrinks to a '+' below 600px */ +.navbar-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: calc(100vh - 100%); + overflow-y: auto; +} + +.navbar-dropdown .dropdown-menu.navbar-dropdown-menu-left { + left: 0; + right: auto; +} + +.navbar-dropdown .menu-title { + color: var(--color-gray-darker); + font-weight: 700; +} + +.navbar-menu .menu-item.selected { + font-weight: 700; + box-shadow: inset 4px 0 0 var(--color-primary-dark); +} + +.navbar-links, .navbar-create-label { + display: none; +} + +.navbar-links .btn, .navbar-create-button { + white-space: nowrap; +} + /* md */ @media screen and (min-width: 600px) { + .navbar-create-label { + display: inline; + } + .columns-3, .columns-2 { grid-template-columns: repeat(2, 1fr); } @@ -816,6 +853,17 @@ label { } } +/* navbar: the whole bar needs about 1240px to fit on one line */ +@media screen and (min-width: 1280px) { + .navbar-links { + display: inline-flex; + } + + .navbar-menu-toggle { + display: none; + } +} + /* xl */ @media screen and (min-width: 1920px) { .columns-3-xl { diff --git a/lib/public/components/NavBar/index.js b/lib/public/components/NavBar/index.js index d6d873ef48..1d59fb5180 100644 --- a/lib/public/components/NavBar/index.js +++ b/lib/public/components/NavBar/index.js @@ -12,12 +12,13 @@ */ import { h } from '/js/src/index.js'; -import { iconPerson, iconPlus } from '/js/src/icons.js'; +import { iconMenu, iconPerson, iconPlus } from '/js/src/icons.js'; import { userPreferencesStore } from '../../utilities/userPreferencesStore.js'; import { frontLink } from '../common/navigation/frontLink.js'; import { absoluteFrontLink } from '../common/navigation/absoluteFrontLink.js'; import { externalLinks } from '../common/navigation/externalLinks.js'; import { configurationService } from '../../services/configurationService.js'; +import { SHIFT_TYPES } from '../../domain/enums/ShiftTypes.js'; import { selectionDropdown } from '../common/selection/dropdown/selectionDropdown.js'; /** @@ -45,15 +46,20 @@ const basePageTab = (page, content, onclick, isSelected = false) => frontLink( * @param {string} page The internal page to navigate to * @param {Function} onclick the html element onclick function * @param {boolean} isSelected if true component is styled as selected + * @param {object} [options] additional options + * @param {boolean} [options.isSelected] force the selected state (defaults to the current page being the linked one) + * @param {object} [options.params] query parameters of the page to navigate to + * @param {object} [options.attributes] additional html attributes of the link * @return {Component} The generated link tag */ -const basePageMenuLink = (content, page, onclick, isSelected = false) => frontLink( +const basePageMenuLink = (content, page, onclick, isSelected = false, { params = null, attributes = {} } = {}) => frontLink( content, page, - null, + params, { class: ['menu-item', isSelected ? 'selected' : ''].join(' '), onclick, + ...attributes, }, ); @@ -87,6 +93,21 @@ const aliFlpLinkTab = (aliFlpIndexPageUrl) => absoluteFrontLink( }, ); +const NAVIGATION_PAGES = [ + ['home', 'Home'], + ['log-overview', 'Log Entries'], + ['env-overview', 'Environments'], + ['lhc-fill-overview', 'LHC Fills'], + ['run-overview', 'Runs'], + ['lhc-period-overview', 'RCT'], + ['statistics', 'Statistics'], +]; + +const OVERVIEW_PAGES = [ + ['tag-overview', 'Tag Overview'], + ['qc-flag-types-overview', 'QC Flag Types'], +]; + const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(--color-gray-dark)' } }); /** @@ -99,13 +120,16 @@ const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(-- * @param {object} [options.menuAttributes] attributes of dropdown menu * @param {boolean} [options.isSelected] inform whether dropdown should be styled as selected * @param {string} [options.dropdownAttributes] attributes of dropdown button + * @param {string} [options.buttonClass] css class of the dropdown button + * @param {object} [options.buttonAttributes] additional html attributes of the dropdown button * @return {Component} dropdown menu */ -const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { menuAttributes, isSelected, dropdownAttributes } = {}) => +const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { menuAttributes, isSelected, dropdownAttributes, buttonClass = 'btn-tab', buttonAttributes = {} } = {}) => h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, dropdownAttributes, [ h('button', { - class: `btn btn-tab ${isSelected ? 'selected' : ''}`, + class: `btn ${buttonClass} ${isSelected ? 'selected' : ''}`, onclick, + ...buttonAttributes, }, dropdownButtonContent), h('.dropdown-menu', menuAttributes, entries), ]); @@ -118,12 +142,67 @@ const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { me function navBar(model) { const { page: currentPage } = model.router.params; const pageTab = (page, content) => basePageTab(page, content, () => model.clearDropdownMenu(), currentPage === page); - const pageMenuLink = (content, page) => basePageMenuLink(content, page, () => model.clearDropdownMenu(), currentPage === page); + const pageMenuLink = (content, page, options) => + basePageMenuLink(content, page, () => model.clearDropdownMenu(), options?.isSelected ?? currentPage === page, options); const absoluteMenuLink = (content, page) => baseAbsoluteMenuLink(content, page, () => model.clearDropdownMenu()); - return h('.flex-row.justify-between.items-center.p2.shadow-level2.level2.bg-gray-light', [ - h('.f4.gray-darker#navbar-title', 'Bookkeeping'), - h('.btn-group', [ + const aliFlpIndexUrl = configurationService.configuration$.getCurrent().match({ + Success: (configuration) => configuration.AliFlpIndexUrl, + Other: () => null, + }); + + const accountMenu = dropdownSubMenu(iconPerson(), model.isDropdownMenuOpened('account'), () => model.toggleDropdownMenu('account'), [ + h('p.mh3.text-ellipsis', `Welcome ${model.session.name}`), + h('hr'), + absoluteMenuLink('Jira', `${externalLinks.ALICE_JIRA}/projects/O2B/summary`), + absoluteMenuLink('GitHub', externalLinks.BOOKKEEPING_GITHUB), + absoluteMenuLink('Support Forum', externalLinks.BOOKKEEPING_SUPPORT_FORUM), + h('hr'), + h('p.mh3.text-ellipsis', 'Preferences'), + h('.gray-darker.flex-row.g3.ph3.pv1', [ + h('input', { + id: 'preferences-raw-timestamps', + onclick: () => { + userPreferencesStore.rawTimestamps = !userPreferencesStore.rawTimestamps; + }, + type: 'checkbox', + checked: userPreferencesStore.rawTimestamps, + }), + h('label', { + for: 'preferences-raw-timestamps', + }, 'Display raw timestamps'), + ]), + h('hr'), + model.session.personid === 0 // Anonymous user has id 0 + ? h('.mh3', [ + h('p', 'Roles selection dropdown'), + selectionDropdown( + model.userRoleSelectionModel, + { selectorPrefix: 'bkp-roles' }, + ), + h('p.gray-darker', 'This instance of the application does not require authentication.'), + ]) + : absoluteMenuLink('Logout', 'https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/logout'), + ], { dropdownAttributes: { title: 'User Actions' }, menuAttributes: { class: 'navbar-dropdown-menu-left' } }); + + return h('#navbar.flex-row.justify-between.items-center.p2.shadow-level2.level2.bg-gray-light.relative', [ + h('.flex-row.items-center.g2', [ + accountMenu, + h('button#navbar-menu-toggle.btn.btn-tab.navbar-menu-toggle', { + title: 'Navigation menu', + onclick: () => model.toggleDropdownMenu('navigation'), + }, iconMenu()), + h('.f4.gray-darker#navbar-title', 'Bookkeeping'), + ]), + model.isDropdownMenuOpened('navigation') && h('.navbar-menu.navbar-menu-toggle.flex-column.p2.bg-white.shadow-level3', [ + aliFlpIndexUrl && absoluteMenuLink('ALI FLP', aliFlpIndexUrl), + ...NAVIGATION_PAGES.map(([page, content]) => pageMenuLink(content, page)), + h('hr'), + h('p.mh3.gray-darker.f6', 'Overview'), + ...OVERVIEW_PAGES.map(([page, content]) => pageMenuLink(content, page)), + pageMenuLink('About', 'about-overview'), + ]), + h('.btn-group.navbar-links', [ ...configurationService.configuration$.getCurrent().match({ /** @@ -138,66 +217,35 @@ function navBar(model) { ], Other: () => [], }), - pageTab('home', 'Home'), - pageTab('log-overview', 'Log Entries'), - pageTab('env-overview', 'Environments'), - pageTab('lhc-fill-overview', 'LHC Fills'), - pageTab('run-overview', 'Runs'), - pageTab('lhc-period-overview', 'RCT'), - pageTab('statistics', 'Statistics'), + ...NAVIGATION_PAGES.map(([page, content]) => pageTab(page, content)), dropdownSubMenu('Overview', model.isDropdownMenuOpened('overview'), () => model.toggleDropdownMenu('overview'), [ - pageMenuLink('Tag Overview', 'tag-overview'), - pageMenuLink('QC Flag Types', 'qc-flag-types-overview'), + ...OVERVIEW_PAGES.map(([page, content]) => pageMenuLink(content, page)), ], { menuAttributes: { style: 'width: max-content;' }, - isSelected: ['tag-overview', 'qc-flag-types-overview'].includes(currentPage), + isSelected: OVERVIEW_PAGES.some(([page]) => page === currentPage), }), pageTab('about-overview', 'About'), ]), h('.flex-row.g2', [ - frontLink(h('.flex-row.items-center.g1', [h('small', iconPlus()), 'Log']), 'log-create', {}, { - id: 'create-log-button', - class: 'btn btn-primary', - title: 'Create a new log', - }), - frontLink(h('.flex-row.items-center.g1', [h('small', iconPlus()), 'EoS report']), 'eos-report-create', {}, { - class: 'btn', - title: 'Create a new end of shift report', - }), - dropdownSubMenu(iconPerson(), model.isDropdownMenuOpened('account'), () => model.toggleDropdownMenu('account'), [ - h('p.mh3.text-ellipsis', `Welcome ${model.session.name}`), - h('hr'), - absoluteMenuLink('Jira', `${externalLinks.ALICE_JIRA}/projects/O2B/summary`), - absoluteMenuLink('GitHub', externalLinks.BOOKKEEPING_GITHUB), - absoluteMenuLink('Support Forum', externalLinks.BOOKKEEPING_SUPPORT_FORUM), - h('hr'), - h('p.mh3.text-ellipsis', 'Preferences'), - h('.gray-darker.flex-row.g3.ph3.pv1', [ - h('input', { - id: 'preferences-raw-timestamps', - onclick: () => { - userPreferencesStore.rawTimestamps = !userPreferencesStore.rawTimestamps; - }, - type: 'checkbox', - checked: userPreferencesStore.rawTimestamps, - }), - h('label', { - for: 'preferences-raw-timestamps', - }, 'Display raw timestamps'), - ]), - h('hr'), - model.session.personid === 0 // Anonymous user has id 0 - ? h('.mh3', [ - h('p', 'Roles selection dropdown'), - selectionDropdown( - model.userRoleSelectionModel, - { selectorPrefix: 'bkp-roles' }, - ), - h('p.gray-darker', 'This instance of the application does not require authentication.'), - ]) - : absoluteMenuLink('Logout', 'https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/logout'), - ], { dropdownAttributes: { title: 'User Actions' } }), + dropdownSubMenu( + h('.flex-row.items-center.g1', [h('small', iconPlus()), h('span.navbar-create-label', 'Add an entry')]), + model.isDropdownMenuOpened('create'), + () => model.toggleDropdownMenu('create'), + [ + pageMenuLink('Log entry', 'log-create', { isSelected: false, attributes: { id: 'create-log-button' } }), + pageMenuLink('On-call log', 'log-create', { isSelected: false, params: { templateKey: 'on-call' } }), + pageMenuLink('RC daily meeting log', 'log-create', { isSelected: false, params: { templateKey: 'rc-daily-meeting' } }), + h('hr'), + h('.menu-title', 'EoS report'), + ...SHIFT_TYPES.map((shiftType) => pageMenuLink(shiftType, 'eos-report-create', { isSelected: false, params: { shiftType } })), + ], + { + buttonClass: 'btn-primary flex-shrink-0 navbar-create-button', + buttonAttributes: { id: 'create-menu-button', title: 'Create a new log or end of shift report' }, + menuAttributes: { style: 'width: max-content;' }, + }, + ), ]), ]); } diff --git a/lib/public/views/EosReport/EosReportModel.js b/lib/public/views/EosReport/EosReportModel.js index 3c20268cdc..ab7b94ce1a 100644 --- a/lib/public/views/EosReport/EosReportModel.js +++ b/lib/public/views/EosReport/EosReportModel.js @@ -40,7 +40,9 @@ export class EosReportModel extends Observable { this._creationModel = new EosReportCreationModel(shiftType, (logId) => this._model.router.go(`/?page=log-detail&id=${logId}`, true)); this._creationModel.bubbleTo(this); } else { + // The shift type is mandatory, it is chosen from the navbar menu this._creationModel = null; + this._model.router.go('/?page=home', true); } } diff --git a/lib/public/views/EosReport/create/EosReportCreationPage.js b/lib/public/views/EosReport/create/EosReportCreationPage.js index 1ccd0c20c0..9f728b225e 100644 --- a/lib/public/views/EosReport/create/EosReportCreationPage.js +++ b/lib/public/views/EosReport/create/EosReportCreationPage.js @@ -11,28 +11,15 @@ * or submit itself to any jurisdiction. */ import { eosReportCreationComponent } from './eosReportCreationComponent.js'; -import { h } from '/js/src/index.js'; -import { SHIFT_TYPES } from '../../../domain/enums/ShiftTypes.js'; -import { frontLink } from '../../../components/common/navigation/frontLink.js'; /** - * Returns the EOS report creation page + * Returns the EOS report creation page, the shift type is expected to be chosen beforehand (see navbar "create" menu) * * @param {Model} model the global model - * @return {Component} the EOS report creation page + * @return {Component|null} the EOS report creation page */ export const EosReportCreationPage = (model) => { const { creationModel } = model.eosReportModel; - if (creationModel) { - return eosReportCreationComponent(creationModel); - } else { - return [ - h('h1', 'Shift type'), - h( - '.flex-row.g3', - SHIFT_TYPES.map((shiftType) => frontLink(shiftType, 'eos-report-create', { shiftType }, { class: 'btn btn-primary' })), - ), - ]; - } + return creationModel ? eosReportCreationComponent(creationModel) : null; }; diff --git a/test/public/logs/create.test.js b/test/public/logs/create.test.js index 92d2ca51aa..09869a66a8 100644 --- a/test/public/logs/create.test.js +++ b/test/public/logs/create.test.js @@ -250,6 +250,7 @@ module.exports = () => { }); it('should successfully provide a tag picker with search input', async () => { + await pressElement(page, '#create-menu-button'); await waitForNavigation(page, () => pressElement(page, '#create-log-button')); await page.locator('.tag-search-input').fill('P'); From abe0e7550f5b2a29dff807ce13a33f9f3138c48b Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 21 Oct 2026 16:09:15 +0200 Subject: [PATCH 3/6] [O2B-1634] Add header UI test suite - New "Header" UI suite covering the full and collapsed navigation bar, the "Add an entry" menu and the account menu on phone, tablet, laptop and desktop screen sizes - Register the suite in the CI matrix and in the local parallel test runner - Set a big viewport in the existing navbar tabs test as tabs are collapsed on small screens --- .github/workflows/bookkeeping.yml | 1 + test/public/components/navBar.test.js | 6 + test/public/header/index.js | 18 ++ test/public/header/navBar.test.js | 268 ++++++++++++++++++++++++++ test/public/index.js | 2 + test/scripts/parallel-local/main.js | 1 + test/scripts/test-header.js | 28 +++ 7 files changed, 324 insertions(+) create mode 100644 test/public/header/index.js create mode 100644 test/public/header/navBar.test.js create mode 100644 test/scripts/test-header.js diff --git a/.github/workflows/bookkeeping.yml b/.github/workflows/bookkeeping.yml index 47a2974476..efdf5ac37a 100644 --- a/.github/workflows/bookkeeping.yml +++ b/.github/workflows/bookkeeping.yml @@ -35,6 +35,7 @@ jobs: flps, home, about, + header, eos-report, data-passes, simulation-passes, diff --git a/test/public/components/navBar.test.js b/test/public/components/navBar.test.js index 5c85bf8130..705fcd162f 100644 --- a/test/public/components/navBar.test.js +++ b/test/public/components/navBar.test.js @@ -25,6 +25,12 @@ module.exports = () => { before(async () => { [page, browser] = await defaultBefore(); + // The tabs are only displayed on big enough screens, below that they are collapsed in a menu + await page.setViewport({ + width: 1920, + height: 1080, + deviceScaleFactor: 1, + }); }); it('NavBar Tabs should navigate to their respective pages', async () => { diff --git a/test/public/header/index.js b/test/public/header/index.js new file mode 100644 index 0000000000..bf24c08f24 --- /dev/null +++ b/test/public/header/index.js @@ -0,0 +1,18 @@ +/** + * @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. + */ + +const NavBarSuite = require('./navBar.test.js'); + +module.exports = () => { + describe('Navigation bar', NavBarSuite); +}; diff --git a/test/public/header/navBar.test.js b/test/public/header/navBar.test.js new file mode 100644 index 0000000000..3051d0d039 --- /dev/null +++ b/test/public/header/navBar.test.js @@ -0,0 +1,268 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { + defaultBefore, + defaultAfter, + goToPage, + pressElement, + waitForNavigation, + expectUrlParams, + expectInnerText, +} = require('../defaults.js'); +const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); +const { ShiftTypes } = require('../../../lib/domain/enums/ShiftTypes.js'); + +/** + * Expected header height, which must not depend on the screen size + */ +const NAVBAR_HEIGHT = 48; + +const VIEWPORTS = { + phone: { width: 375, height: 812 }, + tablet: { width: 820, height: 1180 }, + laptop: { width: 1280, height: 800 }, + desktop: { width: 1920, height: 1080 }, +}; + +module.exports = () => { + let page; + let browser; + + /** + * Resize the browser and reload the home page so that the layout is computed for the new size + * + * @param {{width: number, height: number}} viewport the new viewport size + * @return {Promise} resolves once the page is loaded + */ + const openHomeWithViewport = async (viewport) => { + await page.setViewport({ ...viewport, deviceScaleFactor: 1 }); + await goToPage(page, 'home'); + await page.waitForSelector('#navbar'); + }; + + /** + * Returns true if the first element matching the selector is rendered (not hidden with display none) + * + * @param {string} selector the css selector + * @return {Promise} true if displayed + */ + const isDisplayed = (selector) => page.$eval(selector, (element) => window.getComputedStyle(element).display !== 'none'); + + /** + * Returns the inner texts of the entries of the currently opened navbar dropdown + * + * @param {string} selector the selector of the dropdown entries + * @return {Promise} the entries texts + */ + const getEntriesTexts = (selector) => page.$$eval(selector, (elements) => elements.map(({ innerText }) => innerText.trim())); + + before(async () => { + [page, browser] = await defaultBefore(page, browser); + await resetDatabaseContent(); + }); + + after(async () => { + [page, browser] = await defaultAfter(page, browser); + }); + + describe('Full navigation bar (laptop and bigger screens)', () => { + for (const size of ['laptop', 'desktop']) { + it(`should display the page tabs and no menu button on a ${size} screen`, async () => { + await openHomeWithViewport(VIEWPORTS[size]); + + expect(await isDisplayed('.navbar-links')).to.be.true; + expect(await isDisplayed('#navbar-menu-toggle')).to.be.false; + await expectInnerText(page, '#navbar-title', 'Bookkeeping'); + }); + + it(`should keep the page tabs on one line, without overflowing, on a ${size} screen`, async () => { + await openHomeWithViewport(VIEWPORTS[size]); + + const { height, hasHorizontalOverflow } = await page.evaluate(() => ({ + height: document.querySelector('#navbar').getBoundingClientRect().height, + hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth, + })); + expect(height).to.equal(NAVBAR_HEIGHT); + expect(hasHorizontalOverflow).to.be.false; + }); + } + }); + + describe('Collapsed navigation bar (tablet and phone screens)', () => { + for (const size of ['tablet', 'phone']) { + it(`should replace the page tabs by a menu button on a ${size} screen`, async () => { + await openHomeWithViewport(VIEWPORTS[size]); + + expect(await isDisplayed('.navbar-links')).to.be.false; + expect(await isDisplayed('#navbar-menu-toggle')).to.be.true; + }); + + it(`should keep the same header height as on bigger screens on a ${size} screen`, async () => { + await openHomeWithViewport(VIEWPORTS[size]); + + const height = await page.$eval('#navbar', (element) => element.getBoundingClientRect().height); + expect(height).to.equal(NAVBAR_HEIGHT); + }); + + it(`should always display the Bookkeeping title on a ${size} screen`, async () => { + await openHomeWithViewport(VIEWPORTS[size]); + + expect(await isDisplayed('#navbar-title')).to.be.true; + await expectInnerText(page, '#navbar-title', 'Bookkeeping'); + }); + } + + it('should list all the pages in the menu when pressing the menu button', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + + const entries = await getEntriesTexts('.navbar-menu .menu-item'); + for (const expectedEntry of ['Home', 'Log Entries', 'Environments', 'LHC Fills', 'Runs', 'RCT', 'Statistics', 'Tag Overview', 'QC Flag Types', 'About']) { + expect(entries).to.include(expectedEntry); + } + }); + + it('should navigate to the chosen page and close the menu', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + await waitForNavigation(page, () => page.evaluate(() => { + [...document.querySelectorAll('.navbar-menu .menu-item')].find(({ innerText }) => innerText.trim() === 'Runs').click(); + })); + + expectUrlParams(page, { page: 'run-overview' }); + await page.waitForSelector('.navbar-menu', { hidden: true }); + }); + + it('should highlight the current page in the menu', async () => { + await page.setViewport({ ...VIEWPORTS.tablet, deviceScaleFactor: 1 }); + await goToPage(page, 'run-overview'); + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + + expect(await getEntriesTexts('.navbar-menu .menu-item.selected')).to.eql(['Runs']); + }); + + it('should close the menu when clicking outside of it', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + await pressElement(page, '#global-container', true); + + await page.waitForSelector('.navbar-menu', { hidden: true }); + }); + + it('should close the menu when pressing Escape', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + await page.keyboard.press('Escape'); + + await page.waitForSelector('.navbar-menu', { hidden: true }); + }); + }); + + describe('Create menu', () => { + it('should display the full label of the create button on bigger screens', async () => { + await openHomeWithViewport(VIEWPORTS.laptop); + + expect(await isDisplayed('.navbar-create-label')).to.be.true; + await expectInnerText(page, '#create-menu-button', 'Add an entry'); + }); + + it('should shrink the create button to a plus on a phone screen', async () => { + await openHomeWithViewport(VIEWPORTS.phone); + + expect(await isDisplayed('#create-menu-button')).to.be.true; + expect(await isDisplayed('.navbar-create-label')).to.be.false; + }); + + it('should list the log types and the EoS report shift types', async () => { + await openHomeWithViewport(VIEWPORTS.laptop); + + await pressElement(page, '#create-menu-button'); + await page.waitForSelector('.navbar-dropdown.dropdown-open .menu-item'); + + const entries = await getEntriesTexts('.navbar-dropdown.dropdown-open .menu-item'); + expect(entries).to.eql(['Log entry', 'On-call log', 'RC daily meeting log', ...Object.values(ShiftTypes)]); + // The EoS report heading is not a link + expect(await getEntriesTexts('.navbar-dropdown.dropdown-open .menu-title')).to.eql(['EoS report'.toUpperCase()]); + }); + + it('should not highlight any entry of the create menu', async () => { + await page.setViewport({ ...VIEWPORTS.laptop, deviceScaleFactor: 1 }); + await goToPage(page, 'log-create'); + + await pressElement(page, '#create-menu-button'); + await page.waitForSelector('.navbar-dropdown.dropdown-open .menu-item'); + + expect(await getEntriesTexts('.navbar-dropdown.dropdown-open .menu-item.selected')).to.eql([]); + }); + + it('should open the on-call log template', async () => { + await openHomeWithViewport(VIEWPORTS.laptop); + + await pressElement(page, '#create-menu-button'); + await page.waitForSelector('.navbar-dropdown.dropdown-open .menu-item'); + await waitForNavigation(page, () => page.evaluate(() => { + [...document.querySelectorAll('.navbar-dropdown.dropdown-open .menu-item')] + .find(({ innerText }) => innerText.trim() === 'On-call log').click(); + })); + + expectUrlParams(page, { page: 'log-create', templateKey: 'on-call' }); + }); + + it('should open the EoS report creation page of the chosen shift type', async () => { + await openHomeWithViewport(VIEWPORTS.laptop); + + await pressElement(page, '#create-menu-button'); + await page.waitForSelector('.navbar-dropdown.dropdown-open .menu-item'); + await waitForNavigation(page, () => page.evaluate((shiftType) => { + [...document.querySelectorAll('.navbar-dropdown.dropdown-open .menu-item')] + .find(({ innerText }) => innerText.trim() === shiftType).click(); + }, ShiftTypes.SL)); + + expectUrlParams(page, { page: 'eos-report-create', shiftType: ShiftTypes.SL }); + }); + + it('should redirect to the home page when opening the EoS report creation page without shift type', async () => { + await page.setViewport({ ...VIEWPORTS.laptop, deviceScaleFactor: 1 }); + await goToPage(page, 'eos-report-create'); + + await page.waitForFunction(() => new URL(window.location.href).searchParams.get('page') === 'home'); + }); + }); + + describe('Account menu', () => { + it('should open the account menu on the left of the header and keep it inside of the screen', async () => { + await openHomeWithViewport(VIEWPORTS.phone); + + await pressElement(page, '[title="User Actions"] button'); + await page.waitForSelector('[title="User Actions"].dropdown-open .dropdown-menu'); + + const { left, right } = await page.$eval( + '[title="User Actions"] .dropdown-menu', + (element) => element.getBoundingClientRect().toJSON(), + ); + expect(left).to.be.at.least(0); + expect(right).to.be.at.most(VIEWPORTS.phone.width); + }); + }); +}; diff --git a/test/public/index.js b/test/public/index.js index 6acacbeb69..e737d483ce 100644 --- a/test/public/index.js +++ b/test/public/index.js @@ -18,6 +18,7 @@ const TagsSuite = require('./tags'); const FlpsSuite = require('./flps'); const HomeSuite = require('./home'); const AboutSuite = require('./about'); +const HeaderSuite = require('./header'); const EnvsSuite = require('./envs'); const ErrorSuite = require('./error'); const EosReportSuite = require('./eosReport'); @@ -43,6 +44,7 @@ module.exports = () => { describe('Flps', FlpsSuite); describe('Home', HomeSuite); describe('About', AboutSuite); + describe('Header', HeaderSuite); describe('EosReport', EosReportSuite); describe('DataPasses', DataPassesSuite); describe('SimulationPasses', SimulationPassesSuite); diff --git a/test/scripts/parallel-local/main.js b/test/scripts/parallel-local/main.js index 305f2b25ab..db434305f1 100644 --- a/test/scripts/parallel-local/main.js +++ b/test/scripts/parallel-local/main.js @@ -22,6 +22,7 @@ const testSuites = [ 'flps', 'home', 'about', + 'header', 'eos-report', 'data-passes', 'simulation-passes', diff --git a/test/scripts/test-header.js b/test/scripts/test-header.js new file mode 100644 index 0000000000..289705b445 --- /dev/null +++ b/test/scripts/test-header.js @@ -0,0 +1,28 @@ +/** + * @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. + */ + +const application = require('../../lib/application'); +const HeaderSuite = require('../public/header'); + +describe('Bookkeeping', () => { + before(async () => { + await application.run(); + await application.connectDatabase(); + }); + + after(async () => { + await application.stop(true); + }); + + describe('Header', HeaderSuite); +}); From 1e91413eb8d2280b2e69991dcc44b5f72845eb9d Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 21 Oct 2026 16:50:29 +0200 Subject: [PATCH 4/6] [O2B-1634] Fix header linter errors --- lib/public/components/NavBar/index.js | 36 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/public/components/NavBar/index.js b/lib/public/components/NavBar/index.js index 1d59fb5180..419c6f6893 100644 --- a/lib/public/components/NavBar/index.js +++ b/lib/public/components/NavBar/index.js @@ -47,12 +47,17 @@ const basePageTab = (page, content, onclick, isSelected = false) => frontLink( * @param {Function} onclick the html element onclick function * @param {boolean} isSelected if true component is styled as selected * @param {object} [options] additional options - * @param {boolean} [options.isSelected] force the selected state (defaults to the current page being the linked one) * @param {object} [options.params] query parameters of the page to navigate to * @param {object} [options.attributes] additional html attributes of the link * @return {Component} The generated link tag */ -const basePageMenuLink = (content, page, onclick, isSelected = false, { params = null, attributes = {} } = {}) => frontLink( +const basePageMenuLink = ( + content, + page, + onclick, + isSelected = false, + { params = null, attributes = {} } = {}, +) => frontLink( content, page, params, @@ -124,7 +129,13 @@ const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(-- * @param {object} [options.buttonAttributes] additional html attributes of the dropdown button * @return {Component} dropdown menu */ -const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { menuAttributes, isSelected, dropdownAttributes, buttonClass = 'btn-tab', buttonAttributes = {} } = {}) => +const dropdownSubMenu = ( + dropdownButtonContent, + isOpened, + onclick, + entries, + { menuAttributes, isSelected, dropdownAttributes, buttonClass = 'btn-tab', buttonAttributes = {} } = {}, +) => h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, dropdownAttributes, [ h('button', { class: `btn ${buttonClass} ${isSelected ? 'selected' : ''}`, @@ -218,12 +229,16 @@ function navBar(model) { Other: () => [], }), ...NAVIGATION_PAGES.map(([page, content]) => pageTab(page, content)), - dropdownSubMenu('Overview', model.isDropdownMenuOpened('overview'), () => model.toggleDropdownMenu('overview'), [ - ...OVERVIEW_PAGES.map(([page, content]) => pageMenuLink(content, page)), - ], { - menuAttributes: { style: 'width: max-content;' }, - isSelected: OVERVIEW_PAGES.some(([page]) => page === currentPage), - }), + dropdownSubMenu( + 'Overview', + model.isDropdownMenuOpened('overview'), + () => model.toggleDropdownMenu('overview'), + OVERVIEW_PAGES.map(([page, content]) => pageMenuLink(content, page)), + { + menuAttributes: { style: 'width: max-content;' }, + isSelected: OVERVIEW_PAGES.some(([page]) => page === currentPage), + }, + ), pageTab('about-overview', 'About'), ]), @@ -238,7 +253,8 @@ function navBar(model) { pageMenuLink('RC daily meeting log', 'log-create', { isSelected: false, params: { templateKey: 'rc-daily-meeting' } }), h('hr'), h('.menu-title', 'EoS report'), - ...SHIFT_TYPES.map((shiftType) => pageMenuLink(shiftType, 'eos-report-create', { isSelected: false, params: { shiftType } })), + ...SHIFT_TYPES.map((shiftType) => + pageMenuLink(shiftType, 'eos-report-create', { isSelected: false, params: { shiftType } })), ], { buttonClass: 'btn-primary flex-shrink-0 navbar-create-button', From 411fefd47a077c4963ba76e5570a53fc1909b958 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 21 Oct 2026 16:50:29 +0200 Subject: [PATCH 5/6] [O2B-1634] Use js clicks on header tabs in tests running on small viewports The header tabs are collapsed below 1280px, so the envs and runs overview tests (700px and 1200px wide) cannot click them with a real click anymore. --- test/public/envs/overview.test.js | 2 +- test/public/runs/overview.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/public/envs/overview.test.js b/test/public/envs/overview.test.js index 742e0cd643..fe5671206e 100644 --- a/test/public/envs/overview.test.js +++ b/test/public/envs/overview.test.js @@ -196,7 +196,7 @@ module.exports = () => { }); it('should successfully display dropdown links', async () => { - await waitForNavigation(page, () => pressElement(page, 'a#env-overview')); + await waitForNavigation(page, () => pressElement(page, 'a#env-overview', true)); // Running env let envId = 'CmCvjNbg'; diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 16feb209db..362faf7273 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -325,8 +325,8 @@ module.exports = () => { it('should successfully display links to infologger, QC GUI and ECS', async () => { const { id: createdRunId, runNumber: createdRunNumber } = await runService.create({ runNumber: 1000, timeTrgStart: new Date(), environmentId: 'CmCvjNbg' }); - await waitForNavigation(page, () => pressElement(page, 'a#home')); - await waitForNavigation(page, () => pressElement(page, 'a#run-overview')); + await waitForNavigation(page, () => pressElement(page, 'a#home', true)); + await waitForNavigation(page, () => pressElement(page, 'a#run-overview', true)); // Not running run, wait for popover to be visible await pressElement(page, '#row104-runNumber-text .popover-trigger'); From aa3a874c24cffda5ca24dc002cf2f5b16781560f Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 21 Oct 2026 17:59:15 +0200 Subject: [PATCH 6/6] [O2B-1634] Address review comments on the header accessibility - Give the focus back to the opened menu's button when closing it with Escape - Expose aria-expanded and aria-haspopup on the navbar menu buttons - Open the ALI FLP link of the collapsed menu in a new tab like the desktop one - Test the three behaviours in the header suite --- lib/public/Model.js | 3 ++ lib/public/components/NavBar/index.js | 21 ++++++++++---- test/public/header/navBar.test.js | 42 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/lib/public/Model.js b/lib/public/Model.js index ea7e8b827f..7aada66706 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -197,7 +197,10 @@ export default class Model extends Observable { }); this.window.document.addEventListener('keydown', ({ key }) => { if (key === 'Escape' && this.dropdownMenu) { + const closedDropdownKey = this.dropdownMenu; this.clearDropdownMenu(); + // Give the focus back to the button which opened the menu, otherwise it is lost when the focused entry is removed + this.window.document.querySelector(`[data-dropdown-trigger="${closedDropdownKey}"]`)?.focus(); } }); } diff --git a/lib/public/components/NavBar/index.js b/lib/public/components/NavBar/index.js index 419c6f6893..e8cfd8a2e1 100644 --- a/lib/public/components/NavBar/index.js +++ b/lib/public/components/NavBar/index.js @@ -73,14 +73,16 @@ const basePageMenuLink = ( * @param {string} content The link to navigate to * @param {string} href The text to display on the link * @param {Function} onclick the html element onclick function + * @param {object} [attributes] additional html attributes of the link * @return {Component} The generated link tag */ -const baseAbsoluteMenuLink = (content, href, onclick) => absoluteFrontLink( +const baseAbsoluteMenuLink = (content, href, onclick, attributes = {}) => absoluteFrontLink( content, href, { class: 'menu-item', onclick, + ...attributes, }, ); @@ -122,6 +124,7 @@ const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(-- * @param {Function} onclick the html element onclick function * @param {Component[]} entries entries of the menu * @param {object} options options of the dropdown menu + * @param {string} [options.key] key of the dropdown, as used by the model to know which one is opened * @param {object} [options.menuAttributes] attributes of dropdown menu * @param {boolean} [options.isSelected] inform whether dropdown should be styled as selected * @param {string} [options.dropdownAttributes] attributes of dropdown button @@ -134,12 +137,15 @@ const dropdownSubMenu = ( isOpened, onclick, entries, - { menuAttributes, isSelected, dropdownAttributes, buttonClass = 'btn-tab', buttonAttributes = {} } = {}, + { key, menuAttributes, isSelected, dropdownAttributes, buttonClass = 'btn-tab', buttonAttributes = {} } = {}, ) => h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, dropdownAttributes, [ h('button', { class: `btn ${buttonClass} ${isSelected ? 'selected' : ''}`, onclick, + 'aria-haspopup': 'true', + 'aria-expanded': String(isOpened), + 'data-dropdown-trigger': key, ...buttonAttributes, }, dropdownButtonContent), h('.dropdown-menu', menuAttributes, entries), @@ -155,7 +161,7 @@ function navBar(model) { const pageTab = (page, content) => basePageTab(page, content, () => model.clearDropdownMenu(), currentPage === page); const pageMenuLink = (content, page, options) => basePageMenuLink(content, page, () => model.clearDropdownMenu(), options?.isSelected ?? currentPage === page, options); - const absoluteMenuLink = (content, page) => baseAbsoluteMenuLink(content, page, () => model.clearDropdownMenu()); + const absoluteMenuLink = (content, page, attributes) => baseAbsoluteMenuLink(content, page, () => model.clearDropdownMenu(), attributes); const aliFlpIndexUrl = configurationService.configuration$.getCurrent().match({ Success: (configuration) => configuration.AliFlpIndexUrl, @@ -194,19 +200,22 @@ function navBar(model) { h('p.gray-darker', 'This instance of the application does not require authentication.'), ]) : absoluteMenuLink('Logout', 'https://auth.cern.ch/auth/realms/cern/protocol/openid-connect/logout'), - ], { dropdownAttributes: { title: 'User Actions' }, menuAttributes: { class: 'navbar-dropdown-menu-left' } }); + ], { key: 'account', dropdownAttributes: { title: 'User Actions' }, menuAttributes: { class: 'navbar-dropdown-menu-left' } }); return h('#navbar.flex-row.justify-between.items-center.p2.shadow-level2.level2.bg-gray-light.relative', [ h('.flex-row.items-center.g2', [ accountMenu, h('button#navbar-menu-toggle.btn.btn-tab.navbar-menu-toggle', { title: 'Navigation menu', + 'aria-haspopup': 'true', + 'aria-expanded': String(model.isDropdownMenuOpened('navigation')), + 'data-dropdown-trigger': 'navigation', onclick: () => model.toggleDropdownMenu('navigation'), }, iconMenu()), h('.f4.gray-darker#navbar-title', 'Bookkeeping'), ]), model.isDropdownMenuOpened('navigation') && h('.navbar-menu.navbar-menu-toggle.flex-column.p2.bg-white.shadow-level3', [ - aliFlpIndexUrl && absoluteMenuLink('ALI FLP', aliFlpIndexUrl), + aliFlpIndexUrl && absoluteMenuLink('ALI FLP', aliFlpIndexUrl, { target: '_blank' }), ...NAVIGATION_PAGES.map(([page, content]) => pageMenuLink(content, page)), h('hr'), h('p.mh3.gray-darker.f6', 'Overview'), @@ -235,6 +244,7 @@ function navBar(model) { () => model.toggleDropdownMenu('overview'), OVERVIEW_PAGES.map(([page, content]) => pageMenuLink(content, page)), { + key: 'overview', menuAttributes: { style: 'width: max-content;' }, isSelected: OVERVIEW_PAGES.some(([page]) => page === currentPage), }, @@ -257,6 +267,7 @@ function navBar(model) { pageMenuLink(shiftType, 'eos-report-create', { isSelected: false, params: { shiftType } })), ], { + key: 'create', buttonClass: 'btn-primary flex-shrink-0 navbar-create-button', buttonAttributes: { id: 'create-menu-button', title: 'Create a new log or end of shift report' }, menuAttributes: { style: 'width: max-content;' }, diff --git a/test/public/header/navBar.test.js b/test/public/header/navBar.test.js index 3051d0d039..dd74558267 100644 --- a/test/public/header/navBar.test.js +++ b/test/public/header/navBar.test.js @@ -177,6 +177,48 @@ module.exports = () => { await page.waitForSelector('.navbar-menu', { hidden: true }); }); + + it('should give the focus back to the menu button when closing the menu with Escape', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + + // Move the focus inside of the menu + await page.keyboard.press('Tab'); + expect(await page.evaluate(() => Boolean(document.activeElement.closest('.navbar-menu')))).to.be.true; + + await page.keyboard.press('Escape'); + await page.waitForSelector('.navbar-menu', { hidden: true }); + + expect(await page.evaluate(() => document.activeElement.id)).to.equal('navbar-menu-toggle'); + }); + + it('should expose the opened state of the menu button', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + const getExpandedState = () => page.$eval('#navbar-menu-toggle', (element) => element.getAttribute('aria-expanded')); + expect(await getExpandedState()).to.equal('false'); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + expect(await getExpandedState()).to.equal('true'); + + await page.keyboard.press('Escape'); + await page.waitForSelector('.navbar-menu', { hidden: true }); + expect(await getExpandedState()).to.equal('false'); + }); + + it('should open the ALI FLP link of the menu in a new tab', async () => { + await openHomeWithViewport(VIEWPORTS.tablet); + + await pressElement(page, '#navbar-menu-toggle'); + await page.waitForSelector('.navbar-menu'); + + const aliFlpLinkTarget = await page.evaluate(() => [...document.querySelectorAll('.navbar-menu .menu-item')] + .find(({ innerText }) => innerText.trim() === 'ALI FLP')?.getAttribute('target')); + expect(aliFlpLinkTarget).to.equal('_blank'); + }); }); describe('Create menu', () => {