diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index dd9d444198..3da419550b 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -1059,7 +1059,7 @@ const getSource = async () => { const { locale, dynamicNavKey } = getConfig(); let url = getMetadata('gnav-source') || `${locale.contentRoot}/gnav`; if (dynamicNavKey) { - const { default: dynamicNav } = await import('../../features/dynamic-navigation.js'); + const { default: dynamicNav } = await import('../../features/dynamic-navigation/dynamic-navigation.js'); url = dynamicNav(url, dynamicNavKey); } return url; diff --git a/libs/features/dynamic-navigation.js b/libs/features/dynamic-navigation/dynamic-navigation.js similarity index 75% rename from libs/features/dynamic-navigation.js rename to libs/features/dynamic-navigation/dynamic-navigation.js index 7f901a2629..7354518a58 100644 --- a/libs/features/dynamic-navigation.js +++ b/libs/features/dynamic-navigation/dynamic-navigation.js @@ -1,19 +1,21 @@ -import { getMetadata } from '../utils/utils.js'; +import { getMetadata } from '../../utils/utils.js'; -function isDynamicNavDisabled() { +export function foundDisableValues() { const dynamicNavDisableValues = getMetadata('dynamic-nav-disable'); if (!dynamicNavDisableValues) return false; const metadataPairsMap = dynamicNavDisableValues.split(',').map((pair) => pair.split(';')); - return metadataPairsMap.some(([metadataKey, metadataContent]) => { + const foundValues = metadataPairsMap.filter(([metadataKey, metadataContent]) => { const metaTagContent = getMetadata(metadataKey.toLowerCase()); return (metaTagContent && metaTagContent.toLowerCase() === metadataContent.toLowerCase()); }); + + return foundValues.length ? foundValues : false; } export default function dynamicNav(url, key) { - if (isDynamicNavDisabled()) return url; + if (foundDisableValues()) return url; const metadataContent = getMetadata('dynamic-nav'); if (metadataContent === 'entry') { diff --git a/libs/features/dynamic-navigation/status.css b/libs/features/dynamic-navigation/status.css new file mode 100644 index 0000000000..7d816a8fe9 --- /dev/null +++ b/libs/features/dynamic-navigation/status.css @@ -0,0 +1,179 @@ +.dynamic-nav-status { + border: 2px solid white; + border-radius: 32px; + color: #eee; + font-size: 16px; + padding: 12px 24px; + cursor: pointer; + display: flex; + align-items: center; + margin: 12px; + position: relative; +} + +.dynamic-nav-status .title { + display: flex; +} + +.dynamic-nav-status.active { + background-color: #280; +} + +.dynamic-nav-status.enabled { + background-color: #ec4; +} + +.dynamic-nav-status.inactive { + background-color: #e20; +} + +.dns-badge { + border: 2px solid white; + border-radius: 32px; + background-color: transparent; + box-sizing: border-box; + color: #eee; + padding: 8px; + height: 12px; + width: 12px; + margin: 4px 8px 4px 0; + cursor: pointer; + display: flex; + align-items: center; + position: relative; +} + +.dns-badge::after { + content: ''; + display: block; + box-sizing: border-box; + position: absolute; + width: 6px; + height: 6px; + border-top: 2px solid; + border-right: 2px solid; + transform: rotate(45deg); + left: 5px; + bottom: 5px; + transition-duration: 0.2s; +} + +.dns-badge.dns-open::after { + transform: rotate(135deg); + transition-duration: 0.2s; +} + +.dynamic-nav-status .hidden { + display: none; +} + +.dynamic-nav-status.enabled .title, +.dynamic-nav-status.enabled .dns-badge { + color: var(--feds-color-hamburger); + border-color: var(--feds-color-hamburger); +} + +.dynamic-nav-status .dns-close-container { + display: flex; + justify-content: flex-end; + width: 100%; + height: 10px; + padding: 2px; +} + +.dynamic-nav-status .dns-close { + cursor: pointer; + display: block; + position: absolute; + border: 2px solid white; + border-radius: 32px; + background-color: transparent; + color: #eee; + height: 20px; + width: 20px; + top: 6px; + right: 6px; + box-sizing: border-box; +} + +.dynamic-nav-status .dns-close::after { + content: 'x'; + display: block; + box-sizing: border-box; + position: absolute; + width: 6px; + height: 6px; + left: 4px; + top: -8px; + font-size: 18px; + font-weight: 600; +} + +.dynamic-nav-status .details { + position: absolute; + top: 60px; + right: 0; + background-color: #444; + min-width: 300px; + border-radius: 16px; + box-shadow: 0 0 10px #000; + font-size: 12px; + padding: 20px; + z-index: 1; +} + +.dynamic-nav-status .details::before { + content: ''; + width: 0; + height: 0; + position: absolute; + border-left: 15px solid transparent; + border-right: 15px solid transparent; + border-top: 15px solid #444; + top: -15px; + right: 75px; + rotate: 180deg; +} + +.dynamic-nav-status p { + margin: 2px; +} + +.dynamic-nav-status .details p { + font-weight: 600; +} + +.dynamic-nav-status .details span { + font-weight: 300; +} + +.dynamic-nav-status .details .additional-info { + border-bottom: 1px solid white; +} + +.dynamic-nav-status .disable-values { + min-width: 100%; +} + +.dynamic-nav-status .disable-values table { + border-collapse: collapse; + width: 100%; +} + +.dynamic-nav-status .disable-values caption { + min-width: 100%; + text-align: left; + font-weight: 600; +} + +.dynamic-nav-status .disable-values th, +.dynamic-nav-status .disable-values td { + border: 1px solid rgb(160 160 160); + padding: 8px 10px; +} + +@media screen and (max-width: 600px) { + .dynamic-nav-status { + display: none; + } +} diff --git a/libs/features/dynamic-navigation/status.js b/libs/features/dynamic-navigation/status.js new file mode 100644 index 0000000000..792f586765 --- /dev/null +++ b/libs/features/dynamic-navigation/status.js @@ -0,0 +1,133 @@ +import { createTag, getConfig, getMetadata } from '../../utils/utils.js'; +import { foundDisableValues } from './dynamic-navigation.js'; + +export const ACTIVE = 'active'; +export const ENABLED = 'enabled'; +export const INACTIVE = 'inactive'; +export const tooltipInfo = { + active: 'Displayed in green, this status appears when a user is on an entry page or a page with the Dynamic Nav enabled, indicating that the nav is fully functioning.', + enabled: 'Displayed in yellow, this status indicates that the Dynamic Nav is set to "on," but the user has not yet visited an entry page.', + inactive: 'Displayed in red, this status indicates that the Dynamic Nav is either not configured or has been disabled.', +}; + +const getCurrentSource = (status, storageSource, authoredSource) => { + if (status === 'on') { + return storageSource || authoredSource; + } + return authoredSource; +}; + +const getStatus = (status, disabled, storageSource) => { + if (status === 'entry') return ACTIVE; + + if (disabled) return INACTIVE; + + if (status === 'on' && storageSource) return ACTIVE; + + if (status === 'on' && !storageSource) return ENABLED; + + return INACTIVE; +}; + +const processDisableValues = (valueStr, elem, foundValues = false) => { + if (!valueStr || valueStr.length === 0) return; + + const disableValueList = valueStr.split(','); + const table = createTag('table'); + const flatValues = Array.isArray(foundValues) && foundValues.flat(); + + table.innerHTML = ` + Disable Values + + + Key + Value + Match? + + + + `; + + const tBody = table.querySelector('tbody'); + + disableValueList.forEach((pair) => { + const itemRow = createTag('tr'); + const [key, value] = pair.split(';'); + const keyElem = createTag('td'); + const valElem = createTag('td'); + const matchElem = createTag('td'); + keyElem.innerText = key; + valElem.innerText = value; + matchElem.innerText = flatValues && flatValues.includes(value) ? 'yes' : 'no'; + + itemRow.append(keyElem, valElem, matchElem); + tBody.append(itemRow); + }); + + elem.append(table); +}; + +const returnPath = (url) => { + if (!url.startsWith('https://')) return ''; + const sourceUrl = new URL(url); + return sourceUrl.pathname; +}; + +const createStatusWidget = (dynamicNavKey) => { + const storedSource = window.sessionStorage.getItem('gnavSource'); + const authoredSource = getMetadata('gnav-source') || 'Metadata not found: site gnav source'; + const dynamicNavSetting = getMetadata('dynamic-nav'); + const currentSource = getCurrentSource(dynamicNavSetting, storedSource, authoredSource); + const dynamicNavDisableValues = getMetadata('dynamic-nav-disable'); + const foundValues = foundDisableValues(); + const status = getStatus(dynamicNavSetting, foundValues.length >= 1, storedSource); + const statusWidget = createTag('div', { class: 'dynamic-nav-status' }); + + statusWidget.innerHTML = ` + Dynamic Nav + + `; + + processDisableValues(dynamicNavDisableValues, statusWidget.querySelector('.disable-values'), foundValues); + statusWidget.classList.add(status); + + statusWidget.addEventListener('click', () => { + statusWidget.querySelector('.details').classList.toggle('hidden'); + statusWidget.querySelector('.dns-badge').classList.toggle('dns-open'); + }); + + return statusWidget; +}; + +export default async function main() { + const { dynamicNavKey } = getConfig(); + const statusWidget = createStatusWidget(dynamicNavKey); + const topNav = document.querySelector('.feds-topnav'); + const fedsWrapper = document.querySelector('.feds-nav-wrapper'); + const dnsClose = statusWidget.querySelector('.dns-close'); + + dnsClose.addEventListener('click', () => { + topNav.removeChild(statusWidget); + }); + + fedsWrapper.after(statusWidget); +} diff --git a/libs/utils/utils.js b/libs/utils/utils.js index abe839d943..549b036425 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -1136,6 +1136,12 @@ export async function loadDeferred(area, blocks, config) { import('../features/personalization/preview.js') .then(({ default: decoratePreviewMode }) => decoratePreviewMode()); } + if (config?.dynamicNavKey && config?.env?.name !== 'prod') { + const { miloLibs } = config; + loadStyle(`${miloLibs}/features/dynamic-navigation/status.css`); + const { default: loadDNStatus } = await import('../features/dynamic-navigation/status.js'); + loadDNStatus(); + } } function initSidekick() { diff --git a/test/features/dynamic-nav/dynamicNav.test.js b/test/features/dynamic-nav/dynamicNav.test.js index 909abd9a44..a08c98ba22 100644 --- a/test/features/dynamic-nav/dynamicNav.test.js +++ b/test/features/dynamic-nav/dynamicNav.test.js @@ -1,7 +1,7 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { setConfig } from '../../../libs/utils/utils.js'; -import dynamicNav from '../../../libs/features/dynamic-navigation.js'; +import dynamicNav from '../../../libs/features/dynamic-navigation/dynamic-navigation.js'; describe('Dynamic nav', () => { beforeEach(() => { diff --git a/test/features/dynamic-nav/mocks/status.html b/test/features/dynamic-nav/mocks/status.html new file mode 100644 index 0000000000..7a425891e4 --- /dev/null +++ b/test/features/dynamic-nav/mocks/status.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/test/features/dynamic-nav/status.test.js b/test/features/dynamic-nav/status.test.js new file mode 100644 index 0000000000..2b9a261e61 --- /dev/null +++ b/test/features/dynamic-nav/status.test.js @@ -0,0 +1,259 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { getConfig, setConfig, createTag, loadDeferred } from '../../../libs/utils/utils.js'; +import dynamicNav from '../../../libs/features/dynamic-navigation/dynamic-navigation.js'; +import status, { tooltipInfo, ACTIVE, INACTIVE, ENABLED } from '../../../libs/features/dynamic-navigation/status.js'; + +const statusText = (parentElement) => { + const info = { + additionalInfo: parentElement.querySelector('.additional-info span').innerText, + status: parentElement.querySelector('.details .status span').innerText, + setting: parentElement.querySelector('.details .setting span').innerText, + consumerKey: parentElement.querySelector('.details .consumer-key span').innerText, + match: parentElement.querySelector('.nav-source-info p:nth-child(1) span').innerText, + authoredSource: parentElement.querySelector('.nav-source-info p:nth-child(2) span').innerText, + storedSource: parentElement.querySelector('.nav-source-info p:nth-child(3) span').innerText, + }; + return info; +}; + +const GNAV_SOURCE = 'https://main--milo--adobecom.hlx.live/some-source-string'; + +describe('Dynamic Nav Status', () => { + beforeEach(async () => { + const conf = { dynamicNavKey: 'bacom' }; + document.body.innerHTML = await readFile({ path: './mocks/status.html' }); + document.head.innerHTML = ''; + setConfig(conf); + }); + + it('does not load the widget on production', async () => { + const conf = getConfig(); + conf.env.name = 'prod'; + + await loadDeferred(document, [], conf, () => {}); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget).to.be.null; + }); + + it('does load the widget on a lower env', async () => { + const conf = getConfig(); + conf.env.name = 'local'; + + await loadDeferred(document, [], conf, () => {}); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget).to.exist; + }); + + it('loads the status widget', () => { + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget).to.not.be.null; + }); + + it('loads the status widget in an active state', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + window.sessionStorage.setItem('gnavSource', 'some-source-string'); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget.classList.contains(ACTIVE)).to.be.true; + }); + + it('loads the status widget in an enabled state', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget.classList.contains(ENABLED)).to.be.true; + }); + + it('loads the status widget in an inactive state', () => { + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + expect(statusWidget.classList.contains(INACTIVE)).to.be.true; + }); + + describe('content validation', () => { + it('displays the correct information to the user for the active state "entry"', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'entry'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', GNAV_SOURCE); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[ACTIVE]); + expect(info.status).to.equal(ACTIVE); + expect(info.setting).to.equal('entry'); + expect(info.consumerKey).to.equal('bacom'); + expect(info.match).to.equal('true'); + expect(info.authoredSource).to.equal('/some-source-string'); + expect(info.storedSource).to.equal('/some-source-string'); + }); + + it('displays the correct information to the user for the active state "on"', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[ACTIVE]); + expect(info.status).to.equal(ACTIVE); + expect(info.setting).to.equal('on'); + expect(info.consumerKey).to.equal('bacom'); + expect(info.match).to.equal('false'); + expect(info.authoredSource).to.equal('/test'); + expect(info.storedSource).to.equal('/some-source-string'); + }); + + it('displays the correct information to the user for the active state "off"', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', ''); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[INACTIVE]); + expect(info.status).to.equal(INACTIVE); + expect(info.setting).to.equal(''); + expect(info.consumerKey).to.equal('bacom'); + expect(info.match).to.equal('true'); + expect(info.authoredSource).to.equal('/test'); + expect(info.storedSource).to.equal('/test'); + }); + + it('displays the correct information to the user for the enabled state "on"', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[ENABLED]); + expect(info.status).to.equal(ENABLED); + expect(info.setting).to.equal('on'); + expect(info.consumerKey).to.equal('bacom'); + expect(info.match).to.equal('true'); + expect(info.authoredSource).to.equal('/test'); + expect(info.storedSource).to.equal('/test'); + }); + }); + + describe('disabled values', () => { + it('loads the status widget in an inactive state when disable values are present', () => { + const disableTag = createTag('meta', { name: 'dynamic-nav-disable', content: 'PrimaryProductName;Commerce Cloud' }); + const ppn = createTag('meta', { name: 'primaryproductname', content: 'Commerce Cloud' }); + document.querySelector('head').append(disableTag, ppn); + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[INACTIVE]); + expect(info.status).to.equal(INACTIVE); + }); + + it('loads the status widget in an active state when disabled values present but not correct', () => { + const disableTag = createTag('meta', { name: 'dynamic-nav-disable', content: 'PrimaryProductName;Digital Media' }); + const ppn = createTag('meta', { name: 'primaryproductname', content: 'Commerce Cloud' }); + document.querySelector('head').append(disableTag, ppn); + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + expect(info.additionalInfo).to.equal(tooltipInfo[ACTIVE]); + expect(info.status).to.equal(ACTIVE); + }); + + it('shows the correct disable values that are active in a table', () => { + const disableTag = createTag('meta', { name: 'dynamic-nav-disable', content: 'PrimaryProductName;Commerce Cloud' }); + const ppn = createTag('meta', { name: 'primaryproductname', content: 'Commerce Cloud' }); + document.querySelector('head').append(disableTag, ppn); + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + document.querySelector('meta[name="gnav-source"]').setAttribute('content', 'https://main--milo--adobecom.hlx/test'); + window.sessionStorage.setItem('gnavSource', GNAV_SOURCE); + + dynamicNav(); + status(); + + const statusWidget = document.querySelector('.dynamic-nav-status'); + const info = statusText(statusWidget); + const disableValuesTable = statusWidget.querySelector('.disable-values'); + expect(info.additionalInfo).to.equal(tooltipInfo[INACTIVE]); + expect(info.status).to.equal(INACTIVE); + expect(disableValuesTable.querySelector('caption')).to.exist; + expect(disableValuesTable.querySelector('tbody tr td:nth-child(1)')).to.exist; + expect(disableValuesTable.querySelector('tbody tr td:nth-child(1)').innerText).to.equal('PrimaryProductName'); + expect(disableValuesTable.querySelector('tbody tr td:nth-child(2)')).to.exist; + expect(disableValuesTable.querySelector('tbody tr td:nth-child(2)').innerText).to.equal('Commerce Cloud'); + expect(disableValuesTable.querySelector('tbody tr td:nth-child(3)')).to.exist; + expect(disableValuesTable.querySelector('tbody tr td:nth-child(3)').innerText).to.equal('yes'); + }); + }); + + describe('Event listeners', () => { + it('toggles the details', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + + dynamicNav(); + status(); + + const dynamicNavStatus = document.querySelector('.dynamic-nav-status'); + const details = dynamicNavStatus.querySelector('.details'); + + expect(details.classList.contains('hidden')).to.be.true; + dynamicNavStatus.click(); + expect(details.classList.contains('hidden')).to.be.false; + }); + + it('removes the whole status when close is clicked', () => { + document.querySelector('meta[name="dynamic-nav"]').setAttribute('content', 'on'); + + dynamicNav(); + status(); + + let dynamicNavStatus = document.querySelector('.dynamic-nav-status'); + const details = dynamicNavStatus.querySelector('.details'); + dynamicNavStatus.click(); + const closeButton = details.querySelector('.dns-close'); + closeButton.click(); + dynamicNavStatus = document.querySelector('.dynamic-nav-status'); + expect(dynamicNavStatus).to.be.null; + }); + }); + + afterEach(() => { + window.sessionStorage.clear(); + }); +});