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/lib/public/Model.js b/lib/public/Model.js index 0d0ae222f3..7aada66706 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -190,6 +190,19 @@ 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) { + 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/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..e8cfd8a2e1 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,25 @@ 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 {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, - null, + onclick, + isSelected = false, + { params = null, attributes = {} } = {}, +) => frontLink( + content, + page, + params, { class: ['menu-item', isSelected ? 'selected' : ''].join(' '), onclick, + ...attributes, }, ); @@ -62,14 +73,16 @@ const basePageMenuLink = (content, page, onclick, isSelected = false) => frontLi * @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, }, ); @@ -87,6 +100,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)' } }); /** @@ -96,16 +124,29 @@ 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 + * @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, + { key, 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, + 'aria-haspopup': 'true', + 'aria-expanded': String(isOpened), + 'data-dropdown-trigger': key, + ...buttonAttributes, }, dropdownButtonContent), h('.dropdown-menu', menuAttributes, entries), ]); @@ -118,12 +159,70 @@ 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 absoluteMenuLink = (content, page) => baseAbsoluteMenuLink(content, page, () => model.clearDropdownMenu()); + const pageMenuLink = (content, page, options) => + basePageMenuLink(content, page, () => model.clearDropdownMenu(), options?.isSelected ?? currentPage === page, options); + const absoluteMenuLink = (content, page, attributes) => baseAbsoluteMenuLink(content, page, () => model.clearDropdownMenu(), attributes); + + const aliFlpIndexUrl = configurationService.configuration$.getCurrent().match({ + Success: (configuration) => configuration.AliFlpIndexUrl, + Other: () => null, + }); - 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 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'), + ], { 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, { target: '_blank' }), + ...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 +237,42 @@ 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'), - dropdownSubMenu('Overview', model.isDropdownMenuOpened('overview'), () => model.toggleDropdownMenu('overview'), [ - pageMenuLink('Tag Overview', 'tag-overview'), - pageMenuLink('QC Flag Types', 'qc-flag-types-overview'), - ], { - menuAttributes: { style: 'width: max-content;' }, - isSelected: ['tag-overview', 'qc-flag-types-overview'].includes(currentPage), - }), + ...NAVIGATION_PAGES.map(([page, content]) => pageTab(page, content)), + dropdownSubMenu( + 'Overview', + model.isDropdownMenuOpened('overview'), + () => 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), + }, + ), 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 } })), + ], + { + 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/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/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/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/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..dd74558267 --- /dev/null +++ b/test/public/header/navBar.test.js @@ -0,0 +1,310 @@ +/** + * @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 }); + }); + + 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', () => { + 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/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'); 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'); 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); +});