From a8ea3cd3444034985417b19d15fb455ad9e8ddfc Mon Sep 17 00:00:00 2001 From: Raghav Sharma <118168183+sharmrj@users.noreply.github.com> Date: Wed, 10 Jul 2024 23:29:27 +0530 Subject: [PATCH] MWPW-154013 PEP prompt redirection is broken in stage after the PEP dismissal PR merge (#2547) * Added a way to mock entitlements in non prod environments for testing purposes * Added a pulsing animation after dismissing the PEP modal * Added the ability to debug pep in prod; added skipPepEntitlements option * fixed url for loading the pep dismissal animation * Another url fix for the pep dismissal animation css file * slowed down the default animation * Added the tooltip * re-enabled tracking dismissed prompts * added tooltip that uses data attributes; cleaned up ring animation slightly * styled the tooltip in accordance with the new figma spec sheet * Changed the color of the tooltip * Pick up pep dismissal action config from section metadata; fixed lowercase url issue * Updated unit tests * updated an incomplete pep test * Fixed dismissal actions firing when redirecting * Removed a comment * added tests for running dismissal actions * Tooltip and animation are now cleared if the app switcher is clicked * added a missing semicolon * small refactor of remove animation/tooltip on click logic * A bit of cleanup * removed some unnecessary code * Fixed a bug introduced by one of the previous fixes * fixed a classname * Formatting of an html string * replaced all usage of right in absolute positioning with left * Added CONFIG default values to dismissal action function parameter lists * renamed a variable * Removed an unused variable * Grouped together common css rules in tooltip.css * Combined dismissal css with the general webapp-prompt css * Removed an unused import * Added a unit test * Removed some unneeded newlines * Moved the tooltip down slightly * Added a missing curly brace * added a missing semicolon on line 92 of libs/features/webapp-prompt/webapp-prompt.js Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * made the dismissal config its own entity * added some variables to the focus animation css * removed a redundant style * cleaned up the padding css for the tooltip * minor refactoring * Changed the tooltip fontfamily to use the milo styles variable defined in root * Removed an unnecessary css rule * Replaced tabs with spaces in webapp-prompt.css * Fixed the PEP unit tests * clock cleanup in tests * fixed an issue with the redirect * small change * Fixed eslint error by making a method static * Fixed failing tests * Fixed an issue where the redirect wasn't happening * Added a newline to get Franklin to pick up the new css * Removing the newline added previously * Fixing the tests --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- libs/features/webapp-prompt/webapp-prompt.css | 115 ++++++++++++++++++ libs/features/webapp-prompt/webapp-prompt.js | 99 +++++++++++++-- .../webapp-prompt/mocks/pep-prompt-content.js | 27 +++- test/features/webapp-prompt/test-utilities.js | 13 +- .../webapp-prompt/webapp-prompt.test.js | 113 ++++++++++++----- 5 files changed, 322 insertions(+), 45 deletions(-) diff --git a/libs/features/webapp-prompt/webapp-prompt.css b/libs/features/webapp-prompt/webapp-prompt.css index 1fbc0baae8..cd421f6f8a 100644 --- a/libs/features/webapp-prompt/webapp-prompt.css +++ b/libs/features/webapp-prompt/webapp-prompt.css @@ -188,3 +188,118 @@ } } } + +/* DISMISSAL TOOLTIP */ + +[data-pep-dismissal-tooltip]::after { + content: attr(data-pep-dismissal-tooltip); + display: inline-flex; + z-index: 3; + height: fit-content; + width: 8.875rem; + top: 125%; + left: -300%; + word-break: break-word; + border-radius: 7px; + + padding-inline: 0.5625rem; + padding-block: 0.25rem 0.3125rem; + + font-family: var(--body-font-family); + font-size: 0.75rem; + line-height: 0.9375rem; + color: white; +} + +[data-pep-dismissal-tooltip]::before { + content: ''; + z-index: 2; + width: 0.44rem; + height: 0.44rem; + border-radius: 0.05rem; + left: calc(50% - 0.22rem); + top: 115%; + + transform: rotate(45deg); +} + +[data-pep-dismissal-tooltip]::before, +[data-pep-dismissal-tooltip]::after { + background-color: #3B63FB; + position: absolute; + pointer-events: none; + transition: opacity 0.5s; +} + +@media (min-width: 1520px) { + [data-pep-dismissal-tooltip]::after { + left: calc(50% - 5rem); + } +} + +/* DISMISSAL ANIMATION */ + +.coach-indicator { + --coach-indicator-ring-default-color: rgba(56,146,243); + --coach-indicator-ring-diameter: 1.25rem; + --coach-indicator-ring-border-size: 2px; + --coach-indicator-ring-inline-size: var(--coach-indicator-ring-diameter); + --coach-indicator-ring-block-size: var(--coach-indicator-ring-diameter); + --coach-indicator-first-ring-delay-fraction: 0; + --coach-indicator-second-ring-delay-fraction: 0.33; + --coach-indicator-third-ring-delay-fraction: 0.66; + --animation-duration: 3000ms; +} + +@keyframes pulse { + 0% { + transform: scale(0.8); + opacity: 0; + } + + 50% { + transform: scale(1.5); + opacity: 1; + } + + 100% { + transform: scale(2); + opacity: 0; + } +} + +.coach-indicator .coach-indicator-ring { + display: block; + position: absolute; + top: 14%; + left: 13%; + + border-style: solid; + border-width: var(--coach-indicator-ring-border-size); + border-color: var(--coach-indicator-ring-default-color); + + inline-size: var(--coach-indicator-ring-inline-size); + block-size: var(--coach-indicator-ring-block-size); + animation: pulse var(--animation-duration) linear; + animation-fill-mode: both; + + border-radius: 5px; +} + +.coach-indicator .coach-indicator-ring:nth-child(1) { + animation-delay: calc(var(--animation-duration)*var(--coach-indicator-first-ring-delay-fraction)); +} + +.coach-indicator .coach-indicator-ring:nth-child(2) { + animation-delay: calc(var(--animation-duration)*var(--coach-indicator-second-ring-delay-fraction)); +} + +.coach-indicator .coach-indicator-ring:nth-child(3) { + animation-delay: calc(var(--animation-duration)*var(--coach-indicator-third-ring-delay-fraction)); +} + +@media (prefers-reduced-motion: reduce) { + .coach-indicator .coach-indicator-ring { + animation: none; + } +} diff --git a/libs/features/webapp-prompt/webapp-prompt.js b/libs/features/webapp-prompt/webapp-prompt.js index 418d2ad68d..ea9bc072d0 100644 --- a/libs/features/webapp-prompt/webapp-prompt.js +++ b/libs/features/webapp-prompt/webapp-prompt.js @@ -8,13 +8,21 @@ import { import { getConfig, decorateSVG } from '../../utils/utils.js'; import { replaceKey, replaceText } from '../placeholders.js'; +export const DISMISSAL_CONFIG = { + animationCount: 2, + animationDuration: 2500, + tooltipMessage: 'Use the App Switcher to quickly find apps.', + tooltipDuration: 5000, +}; + const CONFIG = { selectors: { prompt: '.appPrompt' }, delay: 7000, loaderColor: '#EB1000', + ...DISMISSAL_CONFIG, }; -const getElemText = (elem) => elem?.textContent?.trim().toLowerCase(); +const getElemText = (elem) => elem?.textContent?.trim(); const getMetadata = (el) => [...el.childNodes].reduce((acc, row) => { if (row.children?.length === 2) { @@ -35,6 +43,54 @@ const getIcon = (content) => { return icons.company; }; +const showTooltip = ( + element, + message = CONFIG.tooltipMessage, + time = CONFIG.tooltipDuration, +) => { + element.setAttribute('data-pep-dismissal-tooltip', message); + const cleanup = () => element.removeAttribute('data-pep-dismissal-tooltip'); + const timeoutID = setTimeout(cleanup, time); + element.addEventListener('click', () => { + cleanup(); + clearTimeout(timeoutID); + }, { once: true }); +}; + +const playFocusAnimation = ( + element, + iterationCount = CONFIG.animationCount, + animationDuration = CONFIG.animationDuration, +) => { + element.classList.add('coach-indicator'); + element.style.setProperty('--animation-duration', `${animationDuration}ms`); + const rings = []; + const createRing = () => toFragment` +
+
`; + for (let i = 0; i < 3; i += 1) { + const ring = createRing(); + element.insertAdjacentElement('afterbegin', ring); + rings.push(ring); + } + // The cleanup function is added to the event queue + // some time after the end of the animation because + // the cleanup isn't high priority but it should be done + // eventually. (Animation truly ends slightly after + // animationDuration * iterationCount due to animation-delay) + const cleanup = () => { + rings.forEach((ring) => ring.remove()); + element.classList.remove('coach-indicator'); + }; + const timeoutID = setTimeout(cleanup, (iterationCount + 1) * animationDuration); + element.addEventListener('click', () => { + cleanup(); + clearTimeout(timeoutID); + }, { once: true }); +}; + const modalsActive = () => !!document.querySelector('.dialog-modal'); const waitForClosedModalsThen = (loadPEP) => { @@ -45,7 +101,7 @@ const waitForClosedModalsThen = (loadPEP) => { loadPEP(); }; -class AppPrompt { +export class AppPrompt { constructor({ promptPath, entName, parent, getAnchorState } = {}) { this.promptPath = promptPath; this.entName = entName; @@ -59,7 +115,10 @@ class AppPrompt { () => waitForClosedModalsThen(this.init), { once: true }, ); - } else this.init(); + this.initializationQueued = true; + return; + } + this.initializationQueued = false; } init = async () => { @@ -162,6 +221,10 @@ class AppPrompt { 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; + metadata['dismissal-animation-count'] = parseInt(metadata['dismissal-animation-count'] ?? CONFIG.animationCount, 10); + metadata['dismissal-animation-duration'] = parseInt(metadata['dismissal-animation-duration'] ?? CONFIG.animationDuration, 10); + metadata['dismissal-tooltip-message'] ??= CONFIG.tooltipMessage; + metadata['dismissal-tooltip-duration'] = parseInt(metadata['dismissal-tooltip-duration'] ?? CONFIG.tooltipDuration, 10); this.options = metadata; }; @@ -181,7 +244,7 @@ class AppPrompt { : ''; return toFragment`
${this.elements.closeIcon}
@@ -200,7 +263,7 @@ class AppPrompt { }; addEventListeners = () => { - this.anchor?.addEventListener('click', this.close); + this.anchor?.addEventListener('click', () => this.close({ dismissalActions: false })); document.addEventListener('keydown', this.handleKeyDown); [this.elements.closeIcon, this.elements.cta] @@ -211,9 +274,13 @@ class AppPrompt { if (event.key === 'Escape') this.close(); }; + static redirectTo(url) { + window.location.assign(url); + } + initRedirect = () => setTimeout(() => { - this.close({ saveDismissal: false }); - window.location.assign(this.options['redirect-url']); + this.close({ saveDismissal: false, dismissalActions: false }); + AppPrompt.redirectTo(this.options['redirect-url']); }, this.options['loader-duration']); isDismissedPrompt = () => AppPrompt.getDismissedPrompts().includes(this.id); @@ -224,7 +291,7 @@ class AppPrompt { document.cookie = `dismissedAppPrompts=${JSON.stringify([...dismissedPrompts])};path=/`; }; - close = ({ saveDismissal = true } = {}) => { + close = ({ saveDismissal = true, dismissalActions = true } = {}) => { const appPromptElem = document.querySelector(CONFIG.selectors.prompt); appPromptElem?.remove(); clearTimeout(this.redirectFn); @@ -232,6 +299,19 @@ class AppPrompt { document.removeEventListener('keydown', this.handleKeyDown); this.anchor?.focus(); this.anchor?.removeEventListener('click', this.close); + + if (dismissalActions) { + playFocusAnimation( + this.anchor, + this.options['dismissal-animation-count'], + this.options['dismissal-animation-duration'], + ); + showTooltip( + this.anchor, + this.options['dismissal-tooltip-message'], + this.options['dismissal-tooltip-duration'], + ); + } }; static getDismissedPrompts = () => { @@ -246,7 +326,8 @@ class AppPrompt { export default async function init(config) { try { - const appPrompt = await new AppPrompt(config); + const appPrompt = new AppPrompt(config); + if (!appPrompt.initializationQueued) await appPrompt.init(); return appPrompt; } catch (e) { lanaLog({ message: 'Could not initialize PEP', e, tags: 'errorType=error,module=pep' }); diff --git a/test/features/webapp-prompt/mocks/pep-prompt-content.js b/test/features/webapp-prompt/mocks/pep-prompt-content.js index 9f32193ae4..165185e344 100644 --- a/test/features/webapp-prompt/mocks/pep-prompt-content.js +++ b/test/features/webapp-prompt/mocks/pep-prompt-content.js @@ -1,4 +1,13 @@ -export default ({ color, loaderDuration, redirectUrl, productName }) => `
+export default ({ + color, + loaderDuration, + redirectUrl, + productName, + animationCount, + animationDuration, + tooltipMessage, + tooltipDuration, +}) => `

@@ -27,5 +36,21 @@ export default ({ color, loaderDuration, redirectUrl, productName }) => `

product-name
${productName}
`} + ${animationCount && `
+
dismissal-animation-count
+
${animationCount}
+
`} + ${animationDuration && `
+
dismissal-animation-duration
+
${animationDuration}
+
`} + ${tooltipMessage && `
+
dismissal-tooltip-message
+
${tooltipMessage}
+
`} + ${tooltipDuration && `
+
dismissal-tooltip-duration
+
${tooltipDuration}
+
`}
`; diff --git a/test/features/webapp-prompt/test-utilities.js b/test/features/webapp-prompt/test-utilities.js index d74376db6c..8c134d6058 100644 --- a/test/features/webapp-prompt/test-utilities.js +++ b/test/features/webapp-prompt/test-utilities.js @@ -1,7 +1,7 @@ import { setViewport } from '@web/test-runner-commands'; -import sinon from 'sinon'; -import init from '../../../libs/features/webapp-prompt/webapp-prompt.js'; +import init, { DISMISSAL_CONFIG } from '../../../libs/features/webapp-prompt/webapp-prompt.js'; import { viewports, mockRes as importedMockRes } from '../../blocks/global-navigation/test-utilities.js'; +import { setUserProfile } from '../../../libs/blocks/global-navigation/utilities/utilities.js'; import { getConfig, loadStyle, setConfig, updateConfig } from '../../../libs/utils/utils.js'; export const allSelectors = { @@ -17,13 +17,16 @@ export const allSelectors = { progressWrapper: '.appPrompt-progressWrapper', progress: '.appPrompt-progress', appSwitcher: '#unav-app-switcher', + indicatorRing: '.coach-indicator-ring', + tooltip: '[data-pep-dismissal-tooltip]', }; export const defaultConfig = { color: '#b30b00', loaderDuration: 7500, - redirectUrl: 'https://www.adobe.com/?pep=true', + redirectUrl: '#soup', productName: 'photoshop', + ...DISMISSAL_CONFIG, }; export const mockRes = importedMockRes; @@ -38,6 +41,7 @@ export const initPep = async ({ entName = 'firefly-web-usage', isAnchorOpen = fa await setViewport(viewports.desktop); await loadStyle('../../../libs/features/webapp-prompt/webapp-prompt.css'); + setUserProfile({}); const pep = await init({ promptPath: 'https://pep-mocks.test/pep-prompt-content.plain.html', getAnchorState: getAnchorStateMock || (async () => ({ id: 'unav-app-switcher', isOpen: isAnchorOpen })), @@ -45,6 +49,7 @@ export const initPep = async ({ entName = 'firefly-web-usage', isAnchorOpen = fa parent: document.querySelector('div.feds-utilities'), }); - sinon.stub(pep, 'initRedirect').callsFake(() => null); + // sinon.stub(window.location, 'assign').returns(null); + return pep; }; diff --git a/test/features/webapp-prompt/webapp-prompt.test.js b/test/features/webapp-prompt/webapp-prompt.test.js index c3e6bf6c6c..c065acd726 100644 --- a/test/features/webapp-prompt/webapp-prompt.test.js +++ b/test/features/webapp-prompt/webapp-prompt.test.js @@ -3,17 +3,12 @@ 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. @@ -33,7 +28,6 @@ describe('PEP', () => { afterEach(() => { sinon.restore(); - clock.restore(); document.body.innerHTML = ''; document.cookie = `${document.cookie};expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; }); @@ -41,20 +35,17 @@ describe('PEP', () => { 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; }); @@ -71,7 +62,6 @@ describe('PEP', () => { return null; }); await initPep({}); - await clock.runAllAsync(); expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; }); @@ -82,27 +72,22 @@ describe('PEP', () => { 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; }); it('should not render PEP when the GRM is open', async () => { + const clock = sinon.useFakeTimers(); document.body.insertAdjacentHTML('afterbegin', '
'); document.body.insertAdjacentHTML('afterbegin', '
'); await initPep({}); - try { - clock.runAll(); - } catch (e) { - expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; - } + expect(document.querySelector(allSelectors.pepWrapper)).to.not.exist; const event = new CustomEvent('milo:modal:closed'); window.dispatchEvent(event); @@ -110,70 +95,138 @@ describe('PEP', () => { document.querySelector('.locale-modal-v2')?.remove(); document.querySelector('.dialog-modal')?.remove(); - await clock.runAllAsync(); + await clock.tickAsync(300); + expect(document.querySelector(allSelectors.pepWrapper)).to.exist; + clock.uninstall(); }); }); describe('PEP configuration tests', () => { - it('should use config values when metadata loader color or duration are not provided', async () => { + it('should use config values when metadata loader color, duration, or dismissal options 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 }) }); + if (url.includes('pep-prompt-content.plain.html')) { + return mockRes({ + payload: pepPromptContent({ + ...defaultConfig, + color: false, + loaderDuration: false, + animationCount: false, + animationDuration: false, + tooltipMessage: false, + tooltipDuration: 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); + const { + 'loader-color': pepColor, + 'loader-duration': pepDuration, + 'dismissal-animation-count': animCount, + 'dismissal-animation-duration': animDuration, + 'dismissal-tooltip-message': tooltipMessage, + 'dismissal-tooltip-duration': tooltipDuration, + } = pep.options; + const configPresent = [ + pepColor, + pepDuration, + animCount, + animDuration, + tooltipMessage, + tooltipDuration, + ].reduce((acc, x) => acc && !!x, true); + expect(configPresent).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; }); + + it('redirects when the PEP timer runs out', async () => { + const clock = sinon.useFakeTimers(); + await initPep({}); + + expect(window.location.hash).to.not.equal('soup'); + + clock.tick(10000); + + expect(window.location.hash).to.equal('#soup'); + + clock.uninstall(); + }); }); 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 dismissal tests', () => { + it('adds three rings to the app switcher and removes them after the required amount of time', async () => { + const clock = sinon.useFakeTimers(); + await initPep({}); + + document.querySelector(allSelectors.closeIcon).click(); + expect([...document.querySelectorAll(allSelectors.indicatorRing)].length).to.equal(3); + clock.tick(7500); + expect([...document.querySelectorAll(allSelectors.indicatorRing)].length).to.equal(0); + clock.uninstall(); + }); + + it('adds a data attribute to the app switcher with the correct data and removes it after the allotted time', async () => { + const clock = sinon.useFakeTimers(); + await initPep({}); + + document.querySelector(allSelectors.closeIcon).click(); + expect(document.querySelector(allSelectors.tooltip)).to.exist; + + clock.tick(5000); + expect(document.querySelector(allSelectors.tooltip)).to.not.exist; + clock.uninstall(); + }); + + it('removes the dismissal animation and the tooltip upon clicking the anchor element', async () => { + await initPep({}); + document.querySelector(allSelectors.closeIcon).click(); + expect(document.querySelector(allSelectors.tooltip)).to.exist; + document.querySelector(allSelectors.appSwitcher).click(); + expect(document.querySelector(allSelectors.tooltip)).to.not.exist; + }); + }); + describe('PEP logging tests', () => { beforeEach(() => { window.lana.log = sinon.spy(); @@ -185,7 +238,6 @@ describe('PEP', () => { 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; }); @@ -203,7 +255,6 @@ describe('PEP', () => { 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; });