diff --git a/plugins/experimentation/src/index.js b/plugins/experimentation/src/index.js index bf354993..86b96ea2 100644 --- a/plugins/experimentation/src/index.js +++ b/plugins/experimentation/src/index.js @@ -89,12 +89,13 @@ async function replaceInner(path, main) { const html = await resp.text(); // parse with DOMParser to guarantee valid HTML, and no script execution(s) const dom = new DOMParser().parseFromString(html, 'text/html'); - // eslint-disable-next-line no-param-reassign - main.replaceWith(dom.querySelector('main')); + // do not use replaceWith API here since this would replace the main reference + // in scripts.js as well and prevent proper decoration of the sections/blocks + main.innerHTML = dom.querySelector('main').innerHTML; return path; } catch (e) { // eslint-disable-next-line no-console - console.log(`error loading content: ${plainPath}`, e); + console.log(`error loading content: ${path}`, e); } return null; } @@ -210,7 +211,7 @@ function inferEmptyPercentageSplits(variants) { if (variantsWithoutPercentage.length) { const missingPercentage = remainingPercentage / variantsWithoutPercentage.length; variantsWithoutPercentage.forEach((v) => { - v.percentageSplit = missingPercentage.toFixed(2); + v.percentageSplit = missingPercentage.toFixed(4); }); } } @@ -240,12 +241,15 @@ function getConfigForInstantExperiment( variantNames: [], }; - const pages = instantExperiment.split(',').map((p) => new URL(p.trim(), window.location).pathname); + const nbOfVariants = Number(instantExperiment); + const pages = Number.isNaN(nbOfVariants) + ? instantExperiment.split(',').map((p) => new URL(p.trim(), window.location).pathname) + : new Array(nbOfVariants).fill(window.location.pathname); const splitString = context.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); const splits = splitString // custom split - ? splitString.split(',').map((i) => parseInt(i, 10) / 100) + ? splitString.split(',').map((i) => parseFloat(i) / 100) // even split fallback : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); @@ -261,7 +265,7 @@ function getConfigForInstantExperiment( const vname = `challenger-${i + 1}`; config.variantNames.push(vname); config.variants[vname] = { - percentageSplit: `${splits[i].toFixed(2)}`, + percentageSplit: `${splits[i].toFixed(4)}`, pages: [page], blocks: [], label: `Challenger ${i + 1}`, @@ -435,6 +439,8 @@ export async function runExperiment(document, options, context) { console.debug(`running experiment (${window.hlx.experiment.id}) -> ${window.hlx.experiment.selectedVariant}`); if (experimentConfig.selectedVariant === experimentConfig.variantNames[0]) { + document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); + document.body.classList.add(`variant-${context.toClassName(experimentConfig.selectedVariant)}`); context.sampleRUM('experiment', { source: experimentConfig.id, target: experimentConfig.selectedVariant, @@ -450,13 +456,18 @@ export async function runExperiment(document, options, context) { const currentPath = window.location.pathname; const control = experimentConfig.variants[experimentConfig.variantNames[0]]; const index = control.pages.indexOf(currentPath); - if (index < 0 || pages[index] === currentPath) { + if (index < 0) { return false; } // Fullpage content experiment document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); - const result = await replaceInner(pages[index], document.querySelector('main')); + let result; + if (pages[index] !== currentPath) { + result = await replaceInner(pages[index], document.querySelector('main')); + } else { + result = currentPath; + } experimentConfig.servedExperience = result || currentPath; if (!result) { // eslint-disable-next-line no-console @@ -668,14 +679,25 @@ function adjustedRumSamplingRate(checkpoint, options, context) { }; } -export async function loadEager(document, options, context) { - document.addEventListener('rum', (event) => { - const checkpoint = event.detail ? event.detail.checkpoint || '' : ''; - if(['audiences', 'campaign', 'experiment'].includes(checkpoint)) { - adjustedRumSamplingRate(checkpoint, options, context); - } - }); +function adjustRumSampligRate(document, options, context) { + const checkpoints = ['audiences', 'campaign', 'experiment']; + if (context.sampleRUM.always) { // RUM v1.x + checkpoints.forEach((ck) => { + context.sampleRUM.always.on(ck, adjustedRumSamplingRate(ck, options, context)); + }); + } else { // RUM 2.x + document.addEventListener('rum', (event) => { + if (event.detail + && event.detail.checkpoint + && checkpoints.includes(event.detail.checkpoint)) { + adjustedRumSamplingRate(event.detail.checkpoint, options, context); + } + }); + } +} +export async function loadEager(document, options, context) { + adjustRumSampligRate(document, options, context); let res = await runCampaign(document, options, context); if (!res) { res = await runExperiment(document, options, context); @@ -694,9 +716,9 @@ export async function loadLazy(document, options, context) { if (window.location.hostname.endsWith('.live') || (typeof options.isProd === 'function' && options.isProd()) || (options.prodHost - && (options.prodHost === window.location.host - || options.prodHost === window.location.hostname - || options.prodHost === window.location.origin))) { + && (options.prodHost === window.location.host + || options.prodHost === window.location.hostname + || options.prodHost === window.location.origin))) { return; } // eslint-disable-next-line import/no-cycle diff --git a/plugins/experimentation/src/preview.css b/plugins/experimentation/src/preview.css index ea402f4e..49f2b504 100644 --- a/plugins/experimentation/src/preview.css +++ b/plugins/experimentation/src/preview.css @@ -9,25 +9,44 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ + [hidden] { + display: none !important; +} + +.hlx-highlight { + --highlight-size: .5rem; - .hlx-preview-overlay { - z-index: 999; + outline-color: #888; + outline-offset: calc(-1 * var(--highlight-size)); + outline-style: dashed; + outline-width: var(--highlight-size); + background-color: #8882; +} + +.hlx-preview-overlay { + z-index: 99999; position: fixed; - bottom: 32px; - right: 32px; color: #eee; + font-size: 1rem; font-weight: 600; - font-size: 24px; display: flex; flex-direction: column; - gap: 16px; + gap: .5rem; + inset: auto auto 1em; + align-items: center; + justify-content: flex-end; + width: 100%; } .hlx-badge { - border-radius: 32px; - background-color: #888; + --color: #888; + + border-radius: 2em; + background-color: var(--color); + border-style: solid; + border-color: #fff; color: #eee; - padding: 16px 32px; + padding: 1em 1.5em; cursor: pointer; display: flex; align-items: center; @@ -35,14 +54,18 @@ font-size: inherit; overflow: initial; margin: 0; - line-height: 1.5; + justify-content: space-between; text-transform: none; - font-family: system-ui, sans-serif; } .hlx-badge:focus, .hlx-badge:hover { - background-color: #888; + --color: #888; +} + +.hlx-badge:focus-visible { + outline-style: solid; + outline-width: .25em; } .hlx-badge > span { @@ -81,16 +104,22 @@ .hlx-popup { position: absolute; - bottom: 64px; - right: 0; + display: grid; + grid-template: + "header" min-content + "content" 1fr; + bottom: 6.5em; + left: 50%; + transform: translateX(-50%); + max-height: calc(100vh - 100px - var(--nav-height, 100px)); + max-width: calc(100vw - 2em); + min-width: calc(300px - 2em); background-color: #444; - min-width: 300px; border-radius: 16px; box-shadow: 0 0 10px #000; font-size: 12px; text-align: initial; white-space: initial; - line-height: 1.5; } .hlx-popup a:any-link { @@ -100,19 +129,25 @@ display: inline-block; border-radius: 20px; text-decoration: none; - word-break: normal; } .hlx-popup-header { display: grid; + grid-area: header; grid-template: - "label actions" + "label actions" "description actions" / 1fr min-content; background-color: #222; border-radius: 16px 16px 0 0; padding: 24px 16px; - gap: 0 16px; +} + +.hlx-popup-items { + overflow-y: auto; + grid-area: content; + scrollbar-gutter: stable; + scrollbar-width: thin; } .hlx-popup-header-label { @@ -129,11 +164,8 @@ flex-direction: column; } -.hlx-popup h4, -.hlx-popup h5 { - font-family: system-ui, sans-serif; +.hlx-popup h4, .hlx-popup h5 { margin: 0; - color: inherit; } .hlx-popup h4 { @@ -144,27 +176,22 @@ font-size: 14px; } -.hlx-popup p, -.hlx-popup code { + +.hlx-popup p { margin: 0; - padding: 0; - background: inherit; - border: inherit; - color: inherit; - font-size: inherit; - line-height: 1.5; } .hlx-popup::before { - content: ""; + content: ''; width: 0; height: 0; position: absolute; border-left: 15px solid transparent; - border-right: 15px solid transparent; + border-right: 15px solid transparent; border-top: 15px solid #444; bottom: -15px; - right: 30px; + right: 50%; + transform: translateX(50%); } .hlx-hidden { @@ -173,28 +200,29 @@ .hlx-badge.is-active, .hlx-badge[aria-pressed="true"] { - background-color: #280; + --color: #280; } .hlx-badge.is-inactive, .hlx-badge[aria-pressed="false"] { - background-color: #fa0f00; + --color: #fa0f00; } .hlx-popup-item { display: grid; grid-template: - "label actions" + "label actions" "description actions" / 1fr min-content; - margin: 16px; - padding: 16px; - border-radius: 16px; - gap: 0 16px; + margin: 1em; + padding: 1em; + border-radius: 1em; + gap: .5em 1em; } .hlx-popup-item-label { grid-area: label; + white-space: nowrap; } .hlx-popup-item-description { @@ -215,10 +243,47 @@ flex: 0 0 auto; } -/* stylelint-disable-next-line media-feature-range-notation */ -@media (min-width: 900px) { +@media (width >= 600px) { + .hlx-highlight { + --highlight-size: .75rem; + } + + .hlx-preview-overlay { + right: 1em; + align-items: end; + font-size: 1.25rem; + } + + .hlx-popup { + right: 0; + left: auto; + transform: none; + min-width: 300px; + bottom: 8em; + } + + .hlx-popup::before { + right: 26px; + transform: none; + } +} + +@media (width >= 900px) { + .hlx-highlight { + --highlight-size: 1rem; + } + .hlx-preview-overlay { - flex-flow: row-reverse wrap-reverse; - justify-content: flex-start; + flex-flow: row wrap-reverse; + justify-content: flex-end; + font-size: 1.5rem; + } + + .hlx-popup { + bottom: 9em; + } + + .hlx-popup::before { + right: 32px; } } diff --git a/plugins/experimentation/src/preview.js b/plugins/experimentation/src/preview.js index ed0885ce..8cde95b8 100644 --- a/plugins/experimentation/src/preview.js +++ b/plugins/experimentation/src/preview.js @@ -12,9 +12,40 @@ const DOMAIN_KEY_NAME = 'aem-domainkey'; -function createPreviewOverlay(cls) { - const overlay = document.createElement('div'); - overlay.className = cls; +class AemExperimentationBar extends HTMLElement { + connectedCallback() { + // Create a shadow root + const shadow = this.attachShadow({ mode: 'open' }); + + const cssPath = new URL(new Error().stack.split('\n')[2].match(/[a-z]+?:\/\/.*?\/[^:]+/)[0]).pathname.replace('preview.js', 'preview.css'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + link.onload = () => { + shadow.querySelector('.hlx-preview-overlay').removeAttribute('hidden'); + }; + shadow.append(link); + + const el = document.createElement('div'); + el.className = 'hlx-preview-overlay'; + el.setAttribute('hidden', true); + shadow.append(el); + } +} +customElements.define('aem-experimentation-bar', AemExperimentationBar); + +function createPreviewOverlay() { + const overlay = document.createElement('aem-experimentation-bar'); + return overlay; +} + +function getOverlay() { + let overlay = document.querySelector('aem-experimentation-bar')?.shadowRoot.children[1]; + if (!overlay) { + const el = createPreviewOverlay(); + document.body.append(el); + [, overlay] = el.shadowRoot.children; + } return overlay; } @@ -40,7 +71,7 @@ function createPopupItem(item) { ${item.description ? `
${item.description}
` : ''} ${actions.length ? `
${actions}
` : ''}`; const buttons = [...div.querySelectorAll('.hlx-button a')]; - item.actions.forEach((action, index) => { + item.actions?.forEach((action, index) => { if (action.onclick) { buttons[index].addEventListener('click', action.onclick); } @@ -68,7 +99,7 @@ function createPopupDialog(header, items = []) { list.append(createPopupItem(item)); }); const buttons = [...popup.querySelectorAll('.hlx-popup-header-actions .hlx-button a')]; - actions.forEach((action, index) => { + header.actions?.forEach((action, index) => { if (action.onclick) { buttons[index].addEventListener('click', action.onclick); } @@ -103,16 +134,7 @@ function createToggleButton(label) { return button; } -function getOverlay() { - let overlay = document.querySelector('.hlx-preview-overlay'); - if (!overlay) { - overlay = createPreviewOverlay('hlx-preview-overlay'); - document.body.append(overlay); - } - return overlay; -} - -const percentformat = new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 2 }); +const percentformat = new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 3 }); const countformat = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 2 }); const significanceformat = { format: (value) => { @@ -183,6 +205,7 @@ async function fetchRumData(experiment, options) { } resultsURL.searchParams.set('domainkey', options.domainKey); resultsURL.searchParams.set('experiment', experiment); + resultsURL.searchParams.set('conversioncheckpoint', options.conversionName); const response = await fetch(resultsURL.href); if (!response.ok) { @@ -263,7 +286,7 @@ async function fetchRumData(experiment, options) { function populatePerformanceMetrics(div, config, { richVariants, totals, variantsAsNums, winner, -}) { +}, conversionName = 'click') { // add summary const summary = div.querySelector('.hlx-info'); summary.innerHTML = `Showing results for ${bigcountformat.format(totals.total_experimentations)} visits and ${bigcountformat.format(totals.total_conversions)} conversions: `; @@ -279,10 +302,10 @@ function populatePerformanceMetrics(div, config, { // add traffic allocation to control and each variant config.variantNames.forEach((variantName, index) => { - const variantDiv = document.querySelectorAll('.hlx-popup-item')[index]; + const variantDiv = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[index]; const percentage = variantDiv.querySelector('.percentage'); percentage.innerHTML = ` - ${bigcountformat.format(richVariants[variantName].variant_conversions)} clicks / + ${bigcountformat.format(richVariants[variantName].variant_conversions)} ${conversionName} events / ${bigcountformat.format(richVariants[variantName].variant_experimentations)} visits (${percentformat.format(richVariants[variantName].variant_experimentations / totals.total_experimentations)} split) `; @@ -290,11 +313,11 @@ function populatePerformanceMetrics(div, config, { // add click rate and significance to each variant variantsAsNums.forEach((result) => { - const variant = document.querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)]; + const variant = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)]; if (variant) { const performance = variant.querySelector('.performance'); performance.innerHTML = ` - click rate: ${percentformat.format(result.variant_conversion_rate)} + ${conversionName} conversion rate: ${percentformat.format(result.variant_conversion_rate)} vs. ${percentformat.format(result.control_conversion_rate)} ${significanceformat.format(result.p_value)} `; @@ -316,6 +339,9 @@ async function decorateExperimentPill(overlay, options, context) { console.log('preview experiment', experiment); const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME); + const conversionName = config.conversionName + || context.getMetadata('conversion-name') + || 'click'; const pill = createPopupButton( `Experiment: ${config.id}`, { @@ -344,12 +370,13 @@ async function decorateExperimentPill(overlay, options, context) { window.localStorage.setItem(DOMAIN_KEY_NAME, key); const performanceMetrics = await fetchRumData(experiment, { ...options, + conversionName, domainKey: key, }); if (performanceMetrics === null) { return; } - populatePerformanceMetrics(pill, config, performanceMetrics); + populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); } else if (key === '') { window.localStorage.removeItem(DOMAIN_KEY_NAME); } @@ -364,11 +391,13 @@ async function decorateExperimentPill(overlay, options, context) { } overlay.append(pill); - const performanceMetrics = await fetchRumData(experiment, { ...options, domainKey }); + const performanceMetrics = await fetchRumData(experiment, { + ...options, domainKey, conversionName, + }); if (performanceMetrics === null) { return; } - populatePerformanceMetrics(pill, config, performanceMetrics); + populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); } function createCampaign(campaign, isSelected, options) { @@ -485,7 +514,6 @@ async function decorateAudiencesPill(overlay, options, context) { */ export default async function decoratePreviewMode(document, options, context) { try { - context.loadCSS(`${options.basePath || window.hlx.codeBasePath}/plugins/experimentation/src/preview.css`); const overlay = getOverlay(options); await decorateAudiencesPill(overlay, options, context); await decorateCampaignPill(overlay, options, context); diff --git a/plugins/experimentation/src/ued.js b/plugins/experimentation/src/ued.js index d28e91c3..4d4e3a39 100644 --- a/plugins/experimentation/src/ued.js +++ b/plugins/experimentation/src/ued.js @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +/* eslint-disable */ var storage = window.sessionStorage;