diff --git a/.github/workflows/email-release.yaml b/.github/workflows/email-release.yaml deleted file mode 100644 index 767a33be59..0000000000 --- a/.github/workflows/email-release.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: Milo Bot Release Email - -on: - pull_request_target: - types: - - closed - branches: - - main - -jobs: - action: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v3 - - name: Use email bot - uses: adobecom/milo-email-bot@main - env: - TO_EMAIL_NEW_FEATURE: ${{ secrets.TO_EMAIL_NEW_FEATURE }} - TO_EMAIL_HIGH_IMPACT: ${{ secrets.TO_EMAIL_HIGH_IMPACT }} - FROM_EMAIL: 'bot@em2344.milo.pink' - FROM_NAME: 'Milo Bot' - SG_KEY: ${{ secrets.SG_KEY }} - SG_TEMPLATE: 'd-44d50e7138c341959fa1ecf5374fb8e1' diff --git a/libs/blocks/global-navigation/global-navigation.css b/libs/blocks/global-navigation/global-navigation.css index f446ef6e25..d18af79c8a 100644 --- a/libs/blocks/global-navigation/global-navigation.css +++ b/libs/blocks/global-navigation/global-navigation.css @@ -370,6 +370,7 @@ header.global-navigation { } .feds-utilities { + position: relative; display: flex; align-items: center; padding: 0 var(--feds-gutter); diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index 5d638011c5..9171e75629 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -8,38 +8,37 @@ import { loadStyle, } from '../../utils/utils.js'; import { - toFragment, - getFedsPlaceholderConfig, - getAnalyticsValue, + closeAllDropdowns, decorateCta, + fetchAndProcessPlainHtml, + getActiveLink, + getAnalyticsValue, getExperienceName, - loadDecorateMenu, + getFedsPlaceholderConfig, + hasActiveLink, + icons, + isDesktop, + isTangentToViewport, + lanaLog, + loadBaseStyles, loadBlock, + loadDecorateMenu, loadStyles, - trigger, + logErrorFor, + selectors, setActiveDropdown, - closeAllDropdowns, - loadBaseStyles, - yieldToMain, - isDesktop, - isTangentToViewport, - setCurtainState, - hasActiveLink, setActiveLink, - getActiveLink, - selectors, - logErrorFor, - lanaLog, - fetchAndProcessPlainHtml, + setCurtainState, + setUserProfile, + toFragment, + trigger, + yieldToMain, } from './utilities/utilities.js'; import { replaceKey, replaceKeyArray } from '../../features/placeholders.js'; -const CONFIG = { - icons: { - company: '', - search: '', - }, +export const CONFIG = { + icons, delays: { mainNavDropdowns: 800, loadDelayed: 3000, @@ -63,6 +62,13 @@ const CONFIG = { config: { enableLocalSection: true, miniAppContext: { + onMessage: (name, payload) => { + if (name === 'System' && payload.subType === 'AppInitiated') { + window.adobeProfile?.getUserProfile() + .then((data) => { setUserProfile(data); }) + .catch(() => { setUserProfile({}); }); + } + }, logger: { trace: () => {}, debug: () => {}, @@ -425,6 +431,8 @@ class Gnav { }; imsReady = async () => { + if (!window.adobeIMS.isSignedInUser() || !this.useUniversalNav) setUserProfile({}); + const tasks = [this.useUniversalNav ? this.decorateUniversalNav : this.decorateProfile]; try { @@ -589,6 +597,9 @@ class Gnav { locale, imsClientId: window.adobeid?.client_id, theme: 'light', + onReady: () => { + this.decorateAppPrompt({ getAnchorState: () => window.UniversalNav.getComponent?.('app-switcher') }); + }, analyticsContext: { consumer: { name: 'adobecom', @@ -610,6 +621,27 @@ class Gnav { }); }; + decorateAppPrompt = async ({ getAnchorState } = {}) => { + const state = getMetadata('app-prompt')?.toLowerCase(); + const entName = getMetadata('app-prompt-entitlement')?.toLowerCase(); + const promptPath = getMetadata('app-prompt-path')?.toLowerCase(); + if (state === 'off' + || !window.adobeIMS?.isSignedInUser() + || !isDesktop.matches + || !entName?.length + || !promptPath?.length) return; + + const { base } = getConfig(); + const [ + webappPrompt, + ] = await Promise.all([ + import('../../features/webapp-prompt/webapp-prompt.js'), + loadStyle(`${base}/features/webapp-prompt/webapp-prompt.css`), + ]); + + webappPrompt.default({ promptPath, entName, parent: this.blocks.universalNav, getAnchorState }); + }; + loadSearch = () => { if (this.blocks?.search?.instance) return null; diff --git a/libs/blocks/global-navigation/utilities/menu/menu.js b/libs/blocks/global-navigation/utilities/menu/menu.js index dd4dba107c..e71b77322f 100644 --- a/libs/blocks/global-navigation/utilities/menu/menu.js +++ b/libs/blocks/global-navigation/utilities/menu/menu.js @@ -1,20 +1,19 @@ import { decorateCta, + fetchAndProcessPlainHtml, getActiveLink, getAnalyticsValue, - logErrorFor, - setActiveDropdown, - trigger, + icons, isDesktop, + lanaLog, + logErrorFor, selectors, + setActiveDropdown, toFragment, + trigger, yieldToMain, - fetchAndProcessPlainHtml, - lanaLog, } from '../utilities.js'; -const homeIcon = ''; - const decorateHeadline = (elem, index) => { if (!(elem instanceof HTMLElement)) return null; @@ -279,7 +278,7 @@ const decorateCrossCloudMenu = (content) => { crossCloudMenuEl.className = 'feds-crossCloudMenu-wrapper'; crossCloudMenuEl.querySelector('div').className = 'feds-crossCloudMenu'; crossCloudMenuEl.querySelectorAll('ul li').forEach((el, index) => { - if (index === 0) el.querySelector('a')?.prepend(toFragment`${homeIcon}`); + if (index === 0) el.querySelector('a')?.prepend(toFragment`${icons.home}`); el.className = 'feds-crossCloudMenu-item'; }); diff --git a/libs/blocks/global-navigation/utilities/utilities.js b/libs/blocks/global-navigation/utilities/utilities.js index 74ebf0d30e..3fd7d7ba3b 100644 --- a/libs/blocks/global-navigation/utilities/utilities.js +++ b/libs/blocks/global-navigation/utilities/utilities.js @@ -32,6 +32,12 @@ export const selectors = { columnBreak: '.column-break', }; +export const icons = { + company: '', + search: '', + home: '', +}; + export const lanaLog = ({ message, e = '', tags = 'errorType=default' }) => { const url = getMetadata('gnav-source'); window.lana.log(`${message} | gnav-source: ${url} | href: ${window.location.href} | ${e.reason || e.error || e.message || e}`, { @@ -337,3 +343,29 @@ export async function fetchAndProcessPlainHtml({ url, shouldDecorateLinks = true body.innerHTML = await replaceText(body.innerHTML, getFedsPlaceholderConfig()); return body; } + +export const [setUserProfile, getUserProfile] = (() => { + let profileData; + let profileResolve; + let profileTimeout; + + const profilePromise = new Promise((resolve) => { + profileResolve = resolve; + + profileTimeout = setTimeout(() => { + profileData = {}; + resolve(profileData); + }, 5000); + }); + + return [ + (data) => { + if (data && !profileData) { + profileData = data; + clearTimeout(profileTimeout); + profileResolve(profileData); + } + }, + () => profilePromise, + ]; +})(); diff --git a/libs/blocks/marketo/marketo.js b/libs/blocks/marketo/marketo.js index ddbbd32bb5..e496345174 100644 --- a/libs/blocks/marketo/marketo.js +++ b/libs/blocks/marketo/marketo.js @@ -13,7 +13,7 @@ /* * Marketo Form */ -import { parseEncodedConfig, loadScript, createTag, createIntersectionObserver } from '../../utils/utils.js'; +import { parseEncodedConfig, loadScript, localizeLink, createTag, createIntersectionObserver } from '../../utils/utils.js'; const ROOT_MARGIN = 1000; const FORM_ID = 'form id'; @@ -48,7 +48,10 @@ export const decorateURL = (destination, baseURL = window.location) => { destinationUrl.pathname = `${pathname}.html`; } - return destinationUrl; + const localized = localizeLink(destinationUrl.href, null, true); + destinationUrl.pathname = new URL(localized, baseURL.origin).pathname; + + return destinationUrl.href; } catch (e) { window.lana?.log(`Error with Marketo destination URL: ${destination} ${e.message}`); } @@ -95,7 +98,7 @@ const readyForm = (form) => { }; const setPreference = (key, value) => { - if (key && key.includes('.')) { + if (value && key?.includes('.')) { const keyParts = key.split('.'); const lastKey = keyParts.pop(); const formDataObject = keyParts.reduce((obj, part) => { @@ -168,7 +171,7 @@ export default function init(el) { if (destinationUrl) { formData['form.success.type'] = 'redirect'; - formData['form.success.content'] = destinationUrl.href; + formData['form.success.content'] = destinationUrl; } } diff --git a/libs/blocks/mobile-app-banner/README.md b/libs/blocks/mobile-app-banner/README.md new file mode 100644 index 0000000000..5b11b80d34 --- /dev/null +++ b/libs/blocks/mobile-app-banner/README.md @@ -0,0 +1,7 @@ +# Mobile App Banner + +## Performance Impact +This block has a known issue of CLS and can add anywhere from 0.03 to 0.07 CLS (the CWV metric for CLS starts failing at 0.1). +Please make an informed decision based on the business requirements and current CLS on your page when using this block. + +This CLS impact can be mitigated if branch banner is configured to load at the bottom of the page in branch dashboard. diff --git a/libs/blocks/ost/ost.js b/libs/blocks/ost/ost.js index 00ed19d305..d1faa839ee 100644 --- a/libs/blocks/ost/ost.js +++ b/libs/blocks/ost/ost.js @@ -25,6 +25,20 @@ export const WCS_LANDSCAPE = 'PUBLISHED'; */ const METADATA_MAPPINGS = { 'checkout-workflow': 'workflow' }; +const priceDefaultOptions = { + term: true, + seat: true, + tax: false, + old: false, + exclusive: false, +}; + +const updateParams = (params, key, value) => { + if (value !== priceDefaultOptions[key]) { + params.set(key, value); + } +}; + document.body.classList.add('tool', 'tool-ost'); /** @@ -65,11 +79,11 @@ export const createLinkMarkup = ( displayOldPrice, forceTaxExclusive, } = options; - params.set('term', displayRecurrence); - params.set('seat', displayPerUnit); - params.set('tax', displayTax); - params.set('old', displayOldPrice); - params.set('exclusive', forceTaxExclusive); + updateParams(params, 'term', displayRecurrence); + updateParams(params, 'seat', displayPerUnit); + updateParams(params, 'tax', displayTax); + updateParams(params, 'old', displayOldPrice); + updateParams(params, 'exclusive', forceTaxExclusive); } return `https://milo.adobe.com/tools/ost?${params.toString()}`; }; diff --git a/libs/blocks/review/review.js b/libs/blocks/review/review.js index 5879243166..484945ec81 100644 --- a/libs/blocks/review/review.js +++ b/libs/blocks/review/review.js @@ -4,7 +4,10 @@ import { getMetadata, loadStyle, getConfig } from '../../utils/utils.js'; import HelixReview from './components/helixReview/HelixReview.js'; import { checkPostUrl } from './utils/utils.js'; -const COMMENT_THRESHOLD = 3; +const getCommentThreshold = (defaultThreshold = 3) => { + const threshold = parseInt(getMetadata('comment-threshold'), 10); + return (Number.isInteger(threshold) && threshold > 0) ? threshold : defaultThreshold; +}; const getReviewPath = (url) => { try { @@ -44,7 +47,7 @@ const getProductJson = () => { const App = ({ strings }) => html` <${HelixReview} clickTimeout="5000" - commentThreshold=${COMMENT_THRESHOLD} + commentThreshold=${getCommentThreshold()} hideTitleOnReload=${strings.hideTitleOnReload} lang=${getPageLocale()} reviewTitle=${strings.reviewTitle} diff --git a/libs/blocks/table/table.css b/libs/blocks/table/table.css index d502b5786c..ff126a0298 100644 --- a/libs/blocks/table/table.css +++ b/libs/blocks/table/table.css @@ -160,11 +160,7 @@ } .table.header-left .row-heading .col.col-heading .buttons-wrapper > * { - margin-left: 0; -} - -[dir='rtl'] .table.header-left .row-heading .col.col-heading .buttons-wrapper > * { - margin-right: 0; + margin-inline-start: 0; } .table .row-heading .col-heading.top-left-rounded { diff --git a/libs/features/personalization/entitlements.js b/libs/features/personalization/entitlements.js index de8e76a315..831fefcf9f 100644 --- a/libs/features/personalization/entitlements.js +++ b/libs/features/personalization/entitlements.js @@ -17,6 +17,11 @@ const ENTITLEMENT_MAP = { '015c52cb-30b0-4ac9-b02e-f8716b39bfb6': 'not-q-always-on-promo', '42e06851-64cd-4684-a54a-13777403487a': '3d-substance-collection', 'eda8c774-420b-44c2-9006-f9a8d0fb5168': '3d-substance-texturing', + // PEP segments + '6cb0d58c-3a65-47e2-b459-c52bb158d5b6': 'lightroom-web-usage', + 'caa3de84-6336-4fa8-8db2-240fc88106cc': 'photoshop-web-usage', + '5c6a4bb8-a2f3-4202-8cca-f5e918b969dc': 'firefly-web-usage', + '3df0b0b0-d06e-4fcc-986e-cc97f54d04d8': 'acrobat-web-usage', }; export const getEntitlementMap = async () => { diff --git a/libs/features/personalization/stage-entitlements.js b/libs/features/personalization/stage-entitlements.js index 7dc2b58e2d..b690fe2241 100644 --- a/libs/features/personalization/stage-entitlements.js +++ b/libs/features/personalization/stage-entitlements.js @@ -9,6 +9,11 @@ const STAGE_ENTITLEMENTS = { '569f0f9d-83e8-45b4-adbf-07ef08a83398': 'any-cc-product-with-stock', '47e204a3-220a-4e53-a95e-94b6eded0d26': '3d-substance-collection', '4ec7b469-42c9-4367-a7da-39f11a32d880': '3d-substance-texturing', + // PEP segments + '9202b767-77dc-4e6e-8d74-488d9ef08900': 'lightroom-web-usage', + '3a7ffcce-11b8-4242-8cdf-8c8d059ae1cd': 'photoshop-web-usage', + 'cbe1d7ab-db7d-49cb-969e-a6a2bbe8c660': 'firefly-web-usage', + '96adf81f-97ca-4943-81ff-c41fbe8f3af7': 'acrobat-web-usage', }; export default STAGE_ENTITLEMENTS; diff --git a/libs/features/webapp-prompt/webapp-prompt.css b/libs/features/webapp-prompt/webapp-prompt.css new file mode 100644 index 0000000000..1fbc0baae8 --- /dev/null +++ b/libs/features/webapp-prompt/webapp-prompt.css @@ -0,0 +1,190 @@ +/* Hide banner if user shrinks to mobile after being displayed */ +.appPrompt { + --pep-background-prompt: #ffffff; + --pep-background-progress: #e9e9e9; + display: none; +} + +@media (min-width: 900px) { + .appPrompt { + position: absolute; + top: 90%; + right: 0; + width: 400px; + padding: 24px 24px 28px; + display: flex; + flex-direction: column; + row-gap: 16px; + border-radius: 16px; + background: var(--pep-background-prompt); + overflow: hidden; + box-sizing: border-box; + box-shadow: 0 0 3px 0 rgb(0 0 0 / 20%); + } + + [dir = "rtl"] .appPrompt { + right: unset; + left: 0; + } + + .appPrompt-icon { + min-height: 32px; + } + + .appPrompt-icon img, + .appPrompt-icon svg { + width: 32px; + display: block; + } + + .appPrompt-title { + font-size: 18px; + line-height: 1.3; + font-weight: 800; + } + + .appPrompt-profile { + display: flex; + column-gap: 12px; + } + + .appPrompt-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + } + + .appPrompt-avatar-image { + display: block; + object-fit: cover; + } + + .appPrompt-user { + overflow: hidden; + } + + .appPrompt-name { + font-size: 16px; + line-height: 1.3; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .appPrompt-email { + font-size: 14px; + line-height: 1.5; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .appPrompt-footer { + margin: 8px 0; + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 12px; + } + + .appPrompt-text { + font-size: 14px; + line-height: 1.5; + } + + .appPrompt-cta { + display: flex; + flex-shrink: 0; + height: 32px; + min-width: 72px; + padding: 0 14px; + border-width: 2px; + border-style: solid; + border-radius: 16px; + font-size: 15px; + font-weight: 700; + line-height: 0; + box-sizing: border-box; + align-items: center; + justify-content: center; + overflow: visible; + white-space: nowrap; + transition-property: color, border-color, background-color; + transition-duration: 130ms; + transition-timing-function: ease-out; + cursor: pointer; + } + + .appPrompt-cta--close { + background-color: rgb(255, 255, 255); + border-color: rgb(75, 75, 75); + color: rgb(75, 75, 75); + } + + .appPrompt-cta--close:hover, + .appPrompt-cta--close:focus { + background-color: rgb(75, 75, 75); + color: rgb(255, 255, 255); + } + + .appPrompt-close { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + padding: 0; + border: 0; + font: bold 15px Trebuchet MS, sans-serif; + background: transparent; + cursor: pointer; + } + + .appPrompt-close:before { + content: '\2715'; + } + + .appPrompt-close:focus { + background-color: var(--pep-background-progress); + border-radius: 50%; + border: 3px solid var(--pep-background-prompt); + } + + [dir = "rtl"] .appPrompt-close { + right: unset; + left: 12px; + } + + .appPrompt-close:focus { + /* For Firefox */ + outline: auto; + /* For Chrome, Edge, and Safari */ + outline: 2px solid -webkit-focus-ring-color; + } + + .appPrompt-progressWrapper { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 12px; + background-color: var(--pep-background-progress); + } + + .appPrompt-progress { + width: 100%; + height: 100%; + animation: appPrompt-animate 7s linear forwards; + transform-origin: 0 0; + transform: scaleX(0) translateZ(0); + } + + @keyframes appPrompt-animate { + 100% { + transform: scaleX(1); + } + } +} diff --git a/libs/features/webapp-prompt/webapp-prompt.js b/libs/features/webapp-prompt/webapp-prompt.js new file mode 100644 index 0000000000..94181e390d --- /dev/null +++ b/libs/features/webapp-prompt/webapp-prompt.js @@ -0,0 +1,236 @@ +import { + getFedsPlaceholderConfig, + getUserProfile, + icons, + lanaLog, + toFragment, +} from '../../blocks/global-navigation/utilities/utilities.js'; +import { getConfig, decorateSVG } from '../../utils/utils.js'; +import { replaceKey, replaceText } from '../placeholders.js'; + +const CONFIG = { + selectors: { prompt: '.appPrompt' }, + delay: 7000, + loaderColor: '#EB1000', +}; + +const getElemText = (elem) => elem?.textContent?.trim().toLowerCase(); + +const getMetadata = (el) => [...el.childNodes].reduce((acc, row) => { + if (row.children?.length === 2) { + const key = getElemText(row.children[0]); + const val = getElemText(row.children[1]); + if (key && val) acc[key] = val; + } + return acc; +}, {}); + +const getIcon = (content) => { + const picture = content.querySelector('picture'); + if (picture) return picture; + + const svg = content.querySelector('a[href$=".svg"]'); + if (svg) return decorateSVG(svg); + + return icons.company; +}; + +class AppPrompt { + constructor({ promptPath, entName, parent, getAnchorState } = {}) { + this.promptPath = promptPath; + this.entName = entName; + this.parent = parent; + this.getAnchorState = getAnchorState; + this.id = this.promptPath.split('/').pop(); + this.elements = {}; + + this.init(); + } + + init = async () => { + if (this.isDismissedPrompt() || !this.parent) return; + + const entMatch = await this.doesEntitlementMatch(); + if (!entMatch) return; + + const content = await this.fetchContent(); + if (!content) return; + + await this.getData(content); + if (!this.options['redirect-url'] || !this.options['product-name']) return; + + ({ id: this.anchorId, isOpen: this.isAnchorExpanded } = await this.getAnchorState() + .catch((e) => { + lanaLog({ + message: 'Error on getting anchor state', + e, + tags: 'errorType=error,module=pep', + }); + return {}; + })); + if (this.isAnchorExpanded) return; + + if (this.anchorId) this.anchor = document.querySelector(`#${this.anchorId}`); + this.offset = this.anchor + ? this.parent.offsetWidth - (this.anchor.offsetWidth + this.anchor.offsetLeft) : 0; + this.template = this.decorate(); + + this.addEventListeners(); + + this.parent.prepend(this.template); + this.elements.closeIcon.focus(); + + this.redirectFn = this.initRedirect(); + }; + + doesEntitlementMatch = async () => { + const entitlements = await getConfig().entitlements(); + return entitlements?.length && entitlements.includes(this.entName); + }; + + fetchContent = async () => { + const res = await fetch(`${this.promptPath}.plain.html`); + + if (!res.ok) { + lanaLog({ + message: `Error fetching content for prompt: ${this.promptPath}.plain.html`, + e: `Status ${res.status} when trying to fetch content for prompt`, + tags: 'errorType=error,module=pep', + }); + return ''; + } + + const text = await res.text(); + const content = await replaceText(text, getFedsPlaceholderConfig()); + const html = new DOMParser().parseFromString(content, 'text/html'); + + return html; + }; + + getData = async (content) => { + this.icon = getIcon(content); + + const selectors = { + title: 'h2', + subtitle: 'h3', + cancel: 'em > a', + }; + + await Promise.all(Object.keys(selectors).map(async (key) => { + const element = content.querySelector(selectors[key]); + if (element?.innerText) this[key] = element.innerText; + else { + const label = await replaceKey(`pep-prompt-${key}`, getFedsPlaceholderConfig()); + this[key] = label === `pep prompt ${key}` ? '' : label; + } + })); + + await getUserProfile() + .then((data) => { + const requiredFields = ['display_name', 'email', 'avatar']; + const hasRequiredFields = requiredFields.every((field) => !!data[field]); + if (!hasRequiredFields) return; + + this.profile = data; + }).catch((e) => { + lanaLog({ + message: 'Error fetching user profile', + e, + tags: 'errorType=error,module=pep', + }); + }); + + const metadata = getMetadata(content.querySelector('.section-metadata')); + metadata['loader-duration'] = parseInt(metadata['loader-duration'] || CONFIG.delay, 10); + metadata['loader-color'] = metadata['loader-color'] || CONFIG.loaderColor; + this.options = metadata; + }; + + decorate = () => { + this.elements.closeIcon = toFragment``; + this.elements.cta = toFragment``; + this.elements.profile = this.profile + ? toFragment`
+
+ +
+
+
${this.profile.display_name}
+
${this.profile.email}
+
+
` + : ''; + + return toFragment`
+ ${this.elements.closeIcon} +
+ ${this.icon} +
+
${this.title}
+ ${this.elements.profile} + +
+
+
+
`; + }; + + addEventListeners = () => { + this.anchor?.addEventListener('click', this.close); + document.addEventListener('keydown', this.handleKeyDown); + + [this.elements.closeIcon, this.elements.cta] + .forEach((elem) => elem.addEventListener('click', this.close)); + }; + + handleKeyDown = (event) => { + if (event.key === 'Escape') this.close(); + }; + + initRedirect = () => setTimeout(() => { + this.close({ saveDismissal: false }); + window.location.assign(this.options['redirect-url']); + }, this.options['loader-duration']); + + isDismissedPrompt = () => AppPrompt.getDismissedPrompts().includes(this.id); + + setDismissedPrompt = () => { + const dismissedPrompts = new Set(AppPrompt.getDismissedPrompts()); + dismissedPrompts.add(this.id); + document.cookie = `dismissedAppPrompts=${JSON.stringify([...dismissedPrompts])};path=/`; + }; + + close = ({ saveDismissal = true } = {}) => { + const appPromptElem = document.querySelector(CONFIG.selectors.prompt); + appPromptElem?.remove(); + clearTimeout(this.redirectFn); + if (saveDismissal) this.setDismissedPrompt(); + document.removeEventListener('keydown', this.handleKeyDown); + this.anchor?.focus(); + this.anchor?.removeEventListener('click', this.close); + }; + + static getDismissedPrompts = () => { + const cookie = document.cookie + .split(';') + .find((item) => item.trim().startsWith('dismissedAppPrompts=')) + ?.split('=')[1]; + + return cookie ? JSON.parse(cookie) : []; + }; +} + +export default async function init(config) { + try { + const appPrompt = await new AppPrompt(config); + return appPrompt; + } catch (e) { + lanaLog({ message: 'Could not initialize PEP', e, tags: 'errorType=error,module=pep' }); + return null; + } +} diff --git a/libs/scripts/delayed.js b/libs/scripts/delayed.js index 5ea3c7b52d..5b7425061e 100644 --- a/libs/scripts/delayed.js +++ b/libs/scripts/delayed.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 Adobe. All rights reserved. + * Copyright 2024 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/libs/utils/sidekick-decorate.js b/libs/utils/sidekick-decorate.js new file mode 100644 index 0000000000..ec125ef650 --- /dev/null +++ b/libs/utils/sidekick-decorate.js @@ -0,0 +1,59 @@ +export default function stylePublish(sk) { + const style = new CSSStyleSheet(); + style.replaceSync(` + :host { + --bg-color: rgb(129 27 14); + --text-color: #fff0f0; + color-scheme: light dark; + } + .publish.plugin { + order: 100; + } + .publish.plugin button { + background: var(--bg-color); + border-color: #b46157; + color: var(--text-color); + position: relative; + } + .publish.plugin button:hover { + background-color: var(--hlx-sk-button-hover-bg); + border-color: unset; + color: var(--hlx-sk-button-hover-color); + } + .publish.plugin button > span { + display: none; + background: var(--bg-color); + border-radius: 4px; + line-height: 1.2rem; + padding: 8px 12px; + position: absolute; + top: 34px; + left: 50%; + transform: translateX(-50%); + width: 150px; + white-space: pre-wrap; + } + .publish.plugin button:hover > span { + display: block; + color: var(--text-color); + } + .publish.plugin button > span:before { + content: ''; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid var(--bg-color); + position: absolute; + text-align: center; + top: -6px; + left: 50%; + transform: translateX(-50%); + } + `); + sk.shadowRoot.adoptedStyleSheets = [style]; + setTimeout(() => { + const btn = sk.shadowRoot.querySelector('.publish.plugin button'); + btn?.insertAdjacentHTML('beforeend', ` + Are you sure? This will publish to production. + `); + }, 500); +} diff --git a/libs/utils/sidekick.js b/libs/utils/sidekick.js index 80a92c65b4..2d2060026b 100644 --- a/libs/utils/sidekick.js +++ b/libs/utils/sidekick.js @@ -1,57 +1,4 @@ -function stylePublish(sk) { - const pubPlg = sk.shadowRoot.querySelector('.publish.plugin'); - if (!pubPlg) return; - const style = document.createElement('style'); - const span = document.createElement('span'); - span.textContent = 'Are you sure? This will publish to production.'; - const btn = pubPlg.querySelector('button'); - const publishStyles = ` - .plugin.update { - --bg-color: rgb(129 27 14); - --text-color: #fff0f0; - - color-scheme: light dark; - display: flex; - order: 100; - } - .publish.plugin > button { - background: var(--bg-color); - border-color: #b46157; - color: var(--text-color); - } - .publish.plugin > button > span { - display: none; - background: var(--bg-color); - border-radius: 4px; - line-height: 1.2rem; - padding: 8px 12px; - position: absolute; - top: 34px; - left: 50%; - transform: translateX(-50%); - width: 150px; - white-space: pre-wrap; - } - .publish.plugin > button:hover > span { - display: block; - color: var(--text-color); - } - .publish.plugin > button > span:before { - content: ''; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - border-bottom: 6px solid var(--bg-color); - position: absolute; - text-align: center; - top: -6px; - left: 50%; - transform: translateX(-50%); - } - `; - style.append(publishStyles); - pubPlg.prepend(style); - btn.append(span); -} +import stylePublish from './sidekick-decorate.js'; // loadScript and loadStyle are passed in to avoid circular dependencies export default function init({ createTag, loadBlock, loadScript, loadStyle }) { diff --git a/test/blocks/global-navigation/global-navigation.test.js b/test/blocks/global-navigation/global-navigation.test.js index 13270b7ac2..7e50960332 100644 --- a/test/blocks/global-navigation/global-navigation.test.js +++ b/test/blocks/global-navigation/global-navigation.test.js @@ -71,12 +71,15 @@ describe('global navigation', () => { }); it("should log when there's issues within onReady", async () => { + const ogIms = window.adobeIMS; const gnav = await createFullGlobalNavigation({}); sinon.stub(gnav, 'decorateProfile').callsFake(() => { throw new Error('error'); }); + window.adobeIMS = { isSignedInUser: () => true }; await gnav.imsReady(); expect(window.lana.log.getCalls().find((c) => c.args[0].includes('issues within onReady'))).to.exist; + window.adobeIMS = ogIms; }); it('should log when IMS signIn method is not available', async () => { @@ -1273,6 +1276,7 @@ describe('global navigation', () => { it('should reload unav on viewport change', async () => { await createFullGlobalNavigation({ unavContent: 'on' }); await setViewport(viewports.mobile); + isDesktop.dispatchEvent(new Event('change')); await clock.runAllAsync(); expect(window.UniversalNav.reload.getCall(0)).to.exist; }); diff --git a/test/blocks/marketo/marketo.test.js b/test/blocks/marketo/marketo.test.js index 3d3f66e7c3..f3ab41549c 100644 --- a/test/blocks/marketo/marketo.test.js +++ b/test/blocks/marketo/marketo.test.js @@ -1,6 +1,7 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { delay } from '../../helpers/waitfor.js'; +import { setConfig } from '../../../libs/utils/utils.js'; import init, { setPreferences, decorateURL } from '../../../libs/blocks/marketo/marketo.js'; const innerHTML = await readFile({ path: './mocks/body.html' }); @@ -45,31 +46,31 @@ describe('marketo decorateURL', () => { it('decorates absolute URL with local base URL', () => { const baseURL = new URL('http://localhost:6456/marketo-block'); const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); - expect(result.href).to.equal('http://localhost:6456/marketo-block/thank-you'); + expect(result).to.equal('http://localhost:6456/marketo-block/thank-you'); }); it('decorates relative URL with absolute base URL', () => { const baseURL = new URL('https://main--milo--adobecom.hlx.page/marketo-block'); const result = decorateURL('/marketo-block/thank-you', baseURL); - expect(result.href).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); + expect(result).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); }); it('decorates absolute URL with matching base URL', () => { const baseURL = new URL('https://main--milo--adobecom.hlx.page/marketo-block'); const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); - expect(result.href).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); + expect(result).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); }); it('decorates absolute URL with .html base URL', () => { const baseURL = new URL('https://business.adobe.com/marketo-block.html'); const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); - expect(result.href).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); + expect(result).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); }); it('keeps identical absolute URL with .html base URL', () => { const baseURL = new URL('https://business.adobe.com/marketo-block.html'); const result = decorateURL('https://business.adobe.com/marketo-block/thank-you.html', baseURL); - expect(result.href).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); + expect(result).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); }); it('returns null when provided a malformed URL', () => { @@ -81,6 +82,19 @@ describe('marketo decorateURL', () => { it('Does not add .html to ending slash', () => { const baseURL = new URL('https://business.adobe.com/marketo-block.html'); const result = decorateURL('https://business.adobe.com/', baseURL); - expect(result.href).to.equal('https://business.adobe.com/'); + expect(result).to.equal('https://business.adobe.com/'); + }); + + it('localizes URL with .html base URL', () => { + setConfig({ + pathname: '/uk/marketo-block.html', + locales: { + '': {}, + uk: {}, + }, + }); + const baseURL = new URL('https://business.adobe.com/uk/marketo-block.html'); + const result = decorateURL('/marketo-block/thank-you', baseURL); + expect(result).to.equal('https://business.adobe.com/uk/marketo-block/thank-you.html'); }); }); diff --git a/test/blocks/marketo/mocks/marketo-utils.js b/test/blocks/marketo/mocks/marketo-utils.js index 28bb451b7a..0c98787010 100644 --- a/test/blocks/marketo/mocks/marketo-utils.js +++ b/test/blocks/marketo/mocks/marketo-utils.js @@ -61,3 +61,5 @@ export function createIntersectionObserver({ el, callback /* , once = true, opti // fire immediately callback(el, { target: el }); } + +export const localizeLink = (href) => href; diff --git a/test/blocks/ost/mocks/ost-utils.js b/test/blocks/ost/mocks/ost-utils.js index eb333e66c2..e8f4ff9b28 100644 --- a/test/blocks/ost/mocks/ost-utils.js +++ b/test/blocks/ost/mocks/ost-utils.js @@ -101,5 +101,5 @@ function unmockOstDeps() { } export { - getConfig, getLocale, getMetadata, loadScript, loadStyle, mockOstDeps, mockRes, unmockOstDeps, + getConfig, getLocale, getMetadata, loadScript, loadStyle, mockOstDeps, unmockOstDeps, mockRes, }; diff --git a/test/blocks/ost/ost.test.html.js b/test/blocks/ost/ost.test.html.js index 15f31b0ec3..55ecb5e68a 100644 --- a/test/blocks/ost/ost.test.html.js +++ b/test/blocks/ost/ost.test.html.js @@ -1,15 +1,59 @@ import { expect } from '@esm-bundle/chai'; import { mockOstDeps, unmockOstDeps } from './mocks/ost-utils.js'; +import { CheckoutWorkflow, CheckoutWorkflowStep } from '../../../libs/deps/commerce.js'; +import { DEFAULT_CTA_TEXT, createLinkMarkup } from '../../../libs/blocks/ost/ost.js'; + +const { perpM2M } = await fetch('./mocks/wcs-artifacts-mock.json').then((res) => res.json()); +const defaults = { + checkoutWorkflow: 'UCv3', + checkoutWorkflowStep: 'email', +}; +const osi = 'cea462e983f649bca2293325c9894bdd'; +const promo = 'test-promo'; +const texts = { + buy: DEFAULT_CTA_TEXT, + try: 'free-trial', +}; +const types = { + checkoutUrl: 'checkoutUrl', + price: 'price', + opticalPrice: 'opticalPrice', +}; + +function assertLink(link, offer, params, text = texts.buy) { + const { searchParams } = new URL(link.href); + Object.entries(params).forEach(([key, value]) => { + expect(searchParams.get(key)).to.equal(String(value)); + }); + if (params.type === types.checkoutUrl) { + expect(searchParams.get('text')).to.equal(text); + expect(link.text).to.equal(`CTA {{${text}}}`); + } else { + expect(link.text).to.equal(`PRICE - ${offer.planType} - ${offer.name}`); + } +} + +function createLink(params = {}) { + return createLinkMarkup( + defaults, + params.osi ?? osi, + params.type, + perpM2M, + params, + params.promo, + ); +} + +beforeEach(() => { + sessionStorage.clear(); +}); afterEach(() => { unmockOstDeps(); }); -describe('loadOstEnv', async () => { - beforeEach(() => { - sessionStorage.clear(); - }); +describe('OST: loadOstEnv', async () => { it('fetches and returns page status and metadata', async () => { const { options: { country, language, workflow }, @@ -81,10 +125,7 @@ describe('loadOstEnv', async () => { }); }); -describe('init', () => { - beforeEach(() => { - sessionStorage.clear(); - }); +describe('OST: init', () => { it('opens OST without waiting for IMS if query string includes token', async () => { const { options: { country, language, workflow }, @@ -179,3 +220,69 @@ describe('init', () => { }); }); }); + +describe('OST: merch link creation', () => { + describe('checkout-link', () => { + const type = types.checkoutUrl; + + it('with default params', async () => { + const link = createLink({ type }); + assertLink(link, perpM2M, { osi, type }); + expect({ ...link.dataset }).to.eql({}); + }); + + it('with promo and custom text', async () => { + const ctaText = texts.try; + const link = createLink({ ctaText, promo, type }); + assertLink(link, perpM2M, { osi, promo, type }, ctaText); + }); + + it('to UCv2 workflow', async () => { + const workflow = CheckoutWorkflow.V2; + const workflowStep = CheckoutWorkflowStep.CHECKOUT_EMAIL; + const link = createLink({ type, workflow, workflowStep }); + assertLink(link, perpM2M, { osi, type, workflow, workflowStep }); + }); + }); + + describe('inline-price', () => { + const type = types.price; + + it('with default params', async () => { + const link = createLink({ type }); + assertLink(link, perpM2M, { osi, type }); + }); + + it('with default params from OST', async () => { + const link = createLink({ + type, + displayRecurrence: true, + displayPerUnit: true, + displayTax: false, + displayOldPrice: false, + forceTaxExclusive: false, + }); + expect(link.href).to.eql('https://milo.adobe.com/tools/ost?osi=cea462e983f649bca2293325c9894bdd&type=price&perp=true'); + }); + + it('with custom options', async () => { + const displayRecurrence = true; + const displayPerUnit = true; + const displayTax = true; + const forceTaxExclusive = true; + const link = createLink({ + displayRecurrence, + displayPerUnit, + displayTax, + forceTaxExclusive, + type, + }); + assertLink(link, perpM2M, { + tax: displayTax, + exclusive: forceTaxExclusive, + osi, + type, + }); + }); + }); +}); diff --git a/test/blocks/ost/ost.test.js b/test/blocks/ost/ost.test.js deleted file mode 100644 index 0934d7058d..0000000000 --- a/test/blocks/ost/ost.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { readFile } from '@web/test-runner-commands'; - -const { CheckoutWorkflow, CheckoutWorkflowStep } = await import('../../../libs/deps/commerce.js'); -const { DEFAULT_CTA_TEXT, createLinkMarkup } = await import('../../../libs/blocks/ost/ost.js'); - -const data = await readFile({ path: './mocks/wcs-artifacts-mock.json' }); -const { perpM2M } = JSON.parse(data); -const defaults = { - checkoutWorkflow: 'UCv3', - checkoutWorkflowStep: 'email', -}; -const osi = 'cea462e983f649bca2293325c9894bdd'; -const promo = 'test-promo'; -const texts = { - buy: DEFAULT_CTA_TEXT, - try: 'free-trial', -}; -const types = { - checkoutUrl: 'checkoutUrl', - price: 'price', - opticalPrice: 'opticalPrice', -}; - -function assertLink(link, offer, params, text = texts.buy) { - const { searchParams } = new URL(link.href); - Object.entries(params).forEach(([key, value]) => { - expect(searchParams.get(key)).to.equal(String(value)); - }); - if (params.type === types.checkoutUrl) { - expect(searchParams.get('text')).to.equal(text); - expect(link.text).to.equal(`CTA {{${text}}}`); - } else { - expect(link.text).to.equal(`PRICE - ${offer.planType} - ${offer.name}`); - } -} - -function createLink(params = {}) { - return createLinkMarkup( - defaults, - params.osi ?? osi, - params.type, - perpM2M, - params, - params.promo, - ); -} - -describe('function "createLinkMarkup"', () => { - describe('creates "cta" link', () => { - const type = types.checkoutUrl; - - it('with default params', async () => { - const link = createLink({ type }); - assertLink(link, perpM2M, { osi, type }); - }); - - it('with promo and custom text', async () => { - const ctaText = texts.try; - const link = createLink({ ctaText, promo, type }); - assertLink(link, perpM2M, { osi, promo, type }, ctaText); - }); - - it('to UCv2 workflow', async () => { - const workflow = CheckoutWorkflow.V2; - const workflowStep = CheckoutWorkflowStep.CHECKOUT_EMAIL; - const link = createLink({ type, workflow, workflowStep }); - assertLink(link, perpM2M, { osi, type, workflow, workflowStep }); - }); - }); - - describe('creates "price" link', () => { - const type = types.price; - - it('with default params', async () => { - const link = createLink({ type }); - assertLink(link, perpM2M, { osi, type }); - }); - - it('with custom options', async () => { - const displayRecurrence = true; - const displayPerUnit = true; - const displayTax = true; - const forceTaxExclusive = true; - const link = createLink({ - displayRecurrence, - displayPerUnit, - displayTax, - forceTaxExclusive, - type, - }); - assertLink(link, perpM2M, { - term: displayRecurrence, - seat: displayPerUnit, - tax: displayTax, - exclusive: forceTaxExclusive, - osi, - type, - }); - }); - }); -}); diff --git a/test/blocks/review/components/review/Review.test.js b/test/blocks/review/components/review/Review.test.js index 02f5f07a17..0dd4ab4a79 100644 --- a/test/blocks/review/components/review/Review.test.js +++ b/test/blocks/review/components/review/Review.test.js @@ -7,9 +7,12 @@ import Review from '../../../../../libs/blocks/review/components/review/Review.j describe('Review', () => { beforeEach(() => { window.localStorage.setItem('/data/review', JSON.stringify({ rating: 5 })); - const review = html`<${Review} averageRating="4" initialRating="4" />`; + const review = html`<${Review} averageRating="5" initialRating="5" />`; render(review, document.body); }); + afterEach(() => { + localStorage.removeItem('/data/review'); + }); it('should display review', async () => { const reviewElement = await waitForElement('.hlx-ReviewWrapper'); @@ -18,22 +21,45 @@ describe('Review', () => { expect(titleElement).to.exist; }); - it('should test ratings click above comment threshold', async () => { + it('should test ratings active decoration ', async () => { + await delay(100); const reviewElement = await waitForElement('.hlx-ReviewWrapper'); - const ratingsElement = reviewElement.querySelectorAll( - '.hlx-Review-ratingFields input', - )[4]; - ratingsElement.dispatchEvent(new Event('click')); - expect(ratingsElement.classList.contains('is-active')).to.be.false; + const ratingElements = reviewElement.querySelectorAll('.hlx-Review-ratingFields input'); + await delay(100); + ratingElements.forEach((rating) => { + expect(rating.classList.contains('is-Active')).to.be.true; + }); + await delay(100); }); it('should test ratings click above comment threshold', async () => { const reviewElement = await waitForElement('.hlx-ReviewWrapper'); - const ratingsElement = reviewElement.querySelectorAll( - '.hlx-Review-ratingFields input', - )[1]; - ratingsElement.dispatchEvent(new Event('click')); - expect(ratingsElement.classList.contains('is-active')).to.be.false; + const ratingElement = reviewElement.querySelectorAll('.hlx-Review-ratingFields input')[4]; + ratingElement.dispatchEvent(new Event('click')); + await delay(100); + const comments = reviewElement.querySelector('#rating-comments'); + expect(ratingElement.getAttribute('aria-checked')).to.equal('true'); + expect(comments).not.to.exist; + }); + + it('should test click ratings below comment threshold', async () => { + const reviewElement = await waitForElement('.hlx-ReviewWrapper'); + const ratingElement = reviewElement.querySelectorAll('.hlx-Review-ratingFields input')[1]; + ratingElement.dispatchEvent(new Event('click')); + await delay(100); + const comments = reviewElement.querySelector('#rating-comments'); + expect(ratingElement.getAttribute('aria-checked')).to.equal('true'); + expect(comments).to.exist; + }); + + it('should test click ratings equal comment threshold', async () => { + const reviewElement = await waitForElement('.hlx-ReviewWrapper'); + const ratingElement = reviewElement.querySelectorAll('.hlx-Review-ratingFields input')[2]; + ratingElement.dispatchEvent(new Event('click')); + await delay(100); + const comments = reviewElement.querySelector('#rating-comments'); + expect(ratingElement.getAttribute('aria-checked')).to.equal('true'); + expect(comments).to.exist; }); it('should test for input change', async () => { diff --git a/test/blocks/review/mocks/body.html b/test/blocks/review/mocks/body.html index f79bbcf7b0..ae37542fd9 100644 --- a/test/blocks/review/mocks/body.html +++ b/test/blocks/review/mocks/body.html @@ -44,3 +44,26 @@
5
+ diff --git a/test/blocks/review/review.test.js b/test/blocks/review/review.test.js index 91af4b419a..951d65fda6 100644 --- a/test/blocks/review/review.test.js +++ b/test/blocks/review/review.test.js @@ -1,19 +1,99 @@ +import sinon from 'sinon'; import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -import { waitForElement } from '../../helpers/waitfor.js'; +import { waitForElement, delay } from '../../helpers/waitfor.js'; import init from '../../../libs/blocks/review/review.js'; -describe('Review Comp', () => { +describe('Review Component Ratings vs. Thresholds', () => { + let fetchStub; beforeEach(async () => { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); - window.localStorage.setItem('/data/review', JSON.stringify({ rating: 5 })); window.s_adobe = { visitor: { getMarketingCloudVisitorID: () => 'abcd' } }; + fetchStub = sinon.stub(window, 'fetch'); }); - it('could be initialized', async () => { - const div = document.querySelector('.review'); - await init(div); - const review = await waitForElement('.hlx-ReviewWrapper'); - expect(review).to.exist; + afterEach(() => { + sinon.restore(); + localStorage.removeItem('/data/review'); + const metaTag = document.querySelector('meta[name="comment-threshold"]'); + if (metaTag) { + metaTag.remove(); + } + }); + + const thresholds = [4, 5]; + const ratings = [5, 4, 3, 2, 1]; + + describe('Reviews loaded', () => { + it('could be initialized', async () => { + const div = document.querySelector('.review'); + await init(div); + fetchStub.resolves( + new Response(JSON.stringify({ + total: 4, + offset: 0, + limit: 4, + data: [ + { country: 'all', total: '17', average: '3.5' }, + { country: 'en', total: '6', average: '3.3' }, + { country: 'fr', total: '3', average: '3.3' }, + { country: 'de', total: '3', average: '3.3' }, + ], + ':type': 'sheet', + }), { + status: 200, + headers: { 'Content-type': 'application/json' }, + }), + ); + const review = await waitForElement('.hlx-ReviewWrapper'); + expect(review).to.exist; + }); + }); + + thresholds.forEach((threshold) => { + describe(`with a comment threshold of ${threshold}`, () => { + ratings.forEach((rating) => { + it(`tests rating ${rating}`, async () => { + const metaTag = document.createElement('meta'); + metaTag.setAttribute('name', 'comment-threshold'); + metaTag.setAttribute('content', threshold); + document.head.appendChild(metaTag); + + fetchStub.resolves( + new Response(JSON.stringify({ + total: 4, + offset: 0, + limit: 4, + data: [ + { country: 'all', total: '17', average: '3.5' }, + { country: 'en', total: '6', average: '3.3' }, + { country: 'fr', total: '3', average: '3.3' }, + { country: 'de', total: '3', average: '3.3' }, + ], + ':type': 'sheet', + }), { + status: 200, + headers: { 'Content-type': 'application/json' }, + }), + ); + + await init(document.querySelector('.review')); + const review = await waitForElement('.hlx-ReviewWrapper'); + const ratingInputs = review.querySelectorAll('.hlx-Review-ratingFields input'); + await delay(125); + await ratingInputs[rating - 1].dispatchEvent(new Event('click')); + await delay(125); + expect(ratingInputs[rating - 1].getAttribute('aria-checked')).to.equal('true'); + await delay(125); + const comment = document.querySelectorAll('#rating-comments'); + if (rating <= threshold) { + expect(comment.length).to.equal(1); + } else { + expect(comment.length).to.equal(0); + } + await delay(125); + }); + }); + }); }); }); diff --git a/test/features/webapp-prompt/mocks/media-icon.png b/test/features/webapp-prompt/mocks/media-icon.png new file mode 100644 index 0000000000..ae39a15393 Binary files /dev/null and b/test/features/webapp-prompt/mocks/media-icon.png differ diff --git a/test/features/webapp-prompt/mocks/pep-prompt-content.js b/test/features/webapp-prompt/mocks/pep-prompt-content.js new file mode 100644 index 0000000000..9f32193ae4 --- /dev/null +++ b/test/features/webapp-prompt/mocks/pep-prompt-content.js @@ -0,0 +1,31 @@ +export default ({ color, loaderDuration, redirectUrl, productName }) => `
+

+ + + + + + +

+

Taking you to Creative Cloud

+

Cancel to stay on web page

+

Stay on this page

+
+ ${color && `
+
loader-color
+
${color}
+
`} + ${loaderDuration && `
+
loader-duration
+
${loaderDuration}
+
`} + ${redirectUrl && `
+
redirect-url
+
${redirectUrl}
+
`} + ${productName && `
+
product-name
+
${productName}
+
`} +
+
`; diff --git a/test/features/webapp-prompt/test-utilities.js b/test/features/webapp-prompt/test-utilities.js new file mode 100644 index 0000000000..d74376db6c --- /dev/null +++ b/test/features/webapp-prompt/test-utilities.js @@ -0,0 +1,50 @@ +import { setViewport } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import init from '../../../libs/features/webapp-prompt/webapp-prompt.js'; +import { viewports, mockRes as importedMockRes } from '../../blocks/global-navigation/test-utilities.js'; +import { getConfig, loadStyle, setConfig, updateConfig } from '../../../libs/utils/utils.js'; + +export const allSelectors = { + fedsUtilities: '.feds-utilities', + pepWrapper: '.appPrompt', + closeIcon: '.appPrompt-close', + promptIcon: '.appPrompt-icon', + avatarImage: '.appPrompt-avatar-image', + title: '.appPrompt-title', + footer: '.appPrompt-footer', + subtitle: '.appPrompt-text', + cta: '.appPrompt-cta--close', + progressWrapper: '.appPrompt-progressWrapper', + progress: '.appPrompt-progress', + appSwitcher: '#unav-app-switcher', +}; + +export const defaultConfig = { + color: '#b30b00', + loaderDuration: 7500, + redirectUrl: 'https://www.adobe.com/?pep=true', + productName: 'photoshop', +}; + +export const mockRes = importedMockRes; + +export const initPep = async ({ entName = 'firefly-web-usage', isAnchorOpen = false, getAnchorStateMock = false }) => { + setConfig({ + imsClientId: 'milo', + codeRoot: '/libs', + locales: { '': { ietf: 'en-US', tk: 'hah7vzn.css' } }, + }); + updateConfig({ ...getConfig(), entitlements: () => ['firefly-web-usage'] }); + await setViewport(viewports.desktop); + await loadStyle('../../../libs/features/webapp-prompt/webapp-prompt.css'); + + const pep = await init({ + promptPath: 'https://pep-mocks.test/pep-prompt-content.plain.html', + getAnchorState: getAnchorStateMock || (async () => ({ id: 'unav-app-switcher', isOpen: isAnchorOpen })), + entName, + parent: document.querySelector('div.feds-utilities'), + }); + + sinon.stub(pep, 'initRedirect').callsFake(() => null); + return pep; +}; diff --git a/test/features/webapp-prompt/webapp-prompt.test.js b/test/features/webapp-prompt/webapp-prompt.test.js new file mode 100644 index 0000000000..635b8ff6d8 --- /dev/null +++ b/test/features/webapp-prompt/webapp-prompt.test.js @@ -0,0 +1,189 @@ +import { expect } from '@esm-bundle/chai'; +import sinon, { stub } from 'sinon'; +import pepPromptContent from './mocks/pep-prompt-content.js'; + +describe('PEP', () => { + let clock; + let allSelectors; + let defaultConfig; + let mockRes; + let initPep; + + beforeEach(async () => { + clock = sinon.useFakeTimers({ + toFake: ['setTimeout'], + shouldAdvanceTime: true, + }); + // We need to import the utilities after mocking setTimeout to ensure + // their setTimeout calls use Sinon's mocked implementation. + // Importing before mocking would lead to a 5s PEP timeout, exceeding the 2s test limit. + const { allSelectors: importedAllSelectors, defaultConfig: importedDefaultConfig, mockRes: importedMockRes } = await import('./test-utilities.js'); + allSelectors = importedAllSelectors; + defaultConfig = importedDefaultConfig; + mockRes = importedMockRes; + initPep = (await import('./test-utilities.js')).initPep; + document.body.innerHTML = `
+
App Switcher
+
`; + stub(window, 'fetch').callsFake(async (url) => { + if (url.includes('pep-prompt-content.plain.html')) return mockRes({ payload: pepPromptContent({ ...defaultConfig }) }); + return null; + }); + }); + + afterEach(() => { + sinon.restore(); + clock.restore(); + document.body.innerHTML = ''; + document.cookie = `${document.cookie};expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; + }); + + describe('PEP rendering tests', () => { + it('should render PEP', async () => { + await initPep({}); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.exist; + }); + + it('should not render PEP when previously dismissed', async () => { + document.cookie = 'dismissedAppPrompts=["pep-prompt-content.plain.html"]'; + await initPep({}); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should not render PEP when the entitlement does not match', async () => { + await initPep({ entName: 'not-matching-entitlement' }); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should not render PEP when there is no prompt content', async () => { + sinon.restore(); + stub(window, 'fetch').callsFake(async (url) => { + if (url.includes('pep-prompt-content.plain.html')) { + return mockRes({ + payload: null, + ok: false, + status: 400, + }); + } + return null; + }); + await initPep({}); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should not render PEP when the redirect url or product name are not provided', async () => { + sinon.restore(); + stub(window, 'fetch').callsFake(async (url) => { + if (url.includes('pep-prompt-content.plain.html')) return mockRes({ payload: pepPromptContent({ ...defaultConfig, redirectUrl: false, productName: false }) }); + return null; + }); + await initPep({}); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should not render PEP when the anchor element is open', async () => { + await initPep({ isAnchorOpen: true }); + await clock.runAllAsync(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + }); + + describe('PEP configuration tests', () => { + it('should use config values when metadata loader color or duration are not provided', async () => { + sinon.restore(); + stub(window, 'fetch').callsFake(async (url) => { + if (url.includes('pep-prompt-content.plain.html')) return mockRes({ payload: pepPromptContent({ ...defaultConfig, color: false, loaderDuration: false }) }); + return null; + }); + const pep = await initPep({}); + await clock.runAllAsync(); + const { 'loader-color': pepColor, 'loader-duration': pepDuration } = pep.options; + expect(!!pepColor && !!pepDuration).to.equal(true); + }); + }); + + describe('PEP interaction tests', () => { + it('should close PEP on Escape key', async () => { + await initPep({}); + await clock.runAllAsync(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should close PEP on clicking the close icon', async () => { + await initPep({}); + await clock.runAllAsync(); + document.querySelector(allSelectors.closeIcon).click(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should close PEP on clicking the CTA', async () => { + await initPep({}); + await clock.runAllAsync(); + document.querySelector(allSelectors.cta).click(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + + it('should close PEP on clicking the anchor element', async () => { + await initPep({}); + await clock.runAllAsync(); + document.querySelector(allSelectors.appSwitcher).click(); + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; + }); + }); + + describe('PEP focus tests', () => { + it('should focus on the close icon on initial render', async () => { + await initPep({}); + await clock.runAllAsync(); + expect(document.activeElement).to.equal(document.querySelector(allSelectors.closeIcon)); + }); + + it('should focus on the anchor element after closing', async () => { + await initPep({}); + await clock.runAllAsync(); + document.querySelector(allSelectors.closeIcon).click(); + expect(document.activeElement).to.equal(document.querySelector(allSelectors.appSwitcher)); + }); + }); + + describe('PEP logging tests', () => { + beforeEach(() => { + window.lana.log = sinon.spy(); + }); + + it('should send log when not getting anchor state', async () => { + await initPep({ + getAnchorStateMock: () => new Promise((resolve, reject) => { + reject(new Error('Cannot get anchor state')); + }), + }); + await clock.runAllAsync(); + expect(window.lana.log.getCalls().find((c) => c.args[0].includes('Error on getting anchor state'))).to.exist; + expect(window.lana.log.getCalls().find((c) => c.args[1].tags.includes('errorType=error,module=pep'))).to.exist; + }); + + it('should send log when cannot fetch content for prompt', async () => { + sinon.restore(); + stub(window, 'fetch').callsFake(async (url) => { + if (url.includes('pep-prompt-content.plain.html')) { + return mockRes({ + payload: null, + ok: false, + status: 400, + }); + } + return null; + }); + await initPep({}); + await clock.runAllAsync(); + expect(window.lana.log.getCalls().find((c) => c.args[0].includes('Error fetching content for prompt'))).to.exist; + expect(window.lana.log.getCalls().find((c) => c.args[1].tags.includes('errorType=error,module=pep'))).to.exist; + }); + }); +});