diff --git a/libs/features/personalization/personalization.js b/libs/features/personalization/personalization.js index cd571934b4..bb7dffa340 100644 --- a/libs/features/personalization/personalization.js +++ b/libs/features/personalization/personalization.js @@ -262,35 +262,106 @@ function normalizeKeys(obj) { }, {}); } -function getSelectedElement(selector, action) { +const getDivInTargetedCell = (tableCell) => { + tableCell.replaceChildren(); + const div = document.createElement('div'); + tableCell.appendChild(div); + return div; +}; + +const querySelector = (el, selector, all = false) => { try { - if ((action.includes('appendtosection') || action.includes('prependtosection')) && selector.includes('section')) { - let section = selector.trim().replace('section', ''); - if (section === '') section = 1; - if (Number.isNaN(section)) return null; - return document.querySelector(`main > :nth-child(${section})`); - } - if (checkSelectorType(selector) === 'fragment') { + return all ? el.querySelectorAll(selector) : el.querySelector(selector); + } catch (e) { + /* eslint-disable-next-line no-console */ + console.log('Invalid selector: ', selector); + return null; + } +}; + +function getTrailingNumber(s) { + const match = s.match(/\d+$/); + return match ? parseInt(match[0], 10) : null; +} + +function getSection(rootEl, idx) { + return rootEl === document + ? document.querySelector(`body > main > div:nth-child(${idx})`) + : rootEl.querySelector(`:scope > div:nth-child(${idx})`); +} + +function getSelectedElement(selector, action, rootEl) { + if (!selector) return null; + if ((action.includes('appendtosection') || action.includes('prependtosection'))) { + if (!selector.includes('section')) return null; + const section = selector.trim().replace('section', ''); + if (section !== '' && Number.isNaN(section)) return null; + } + if (checkSelectorType(selector) === 'fragment') { + try { const fragment = document.querySelector(`a[href*="${normalizePath(selector)}"]`); - if (fragment) { - return fragment.parentNode; - } + if (fragment) return fragment.parentNode; + return null; + } catch (e) { return null; } - return document.querySelector(selector); - } catch (e) { - return null; } + + let selectedEl; + if (selector.includes('.') || !['section', 'block', 'row'].some((s) => selector.includes(s))) { + selectedEl = querySelector(rootEl, selector); + if (selectedEl) return selectedEl; + } + + const terms = selector.split(/\s+/); + + let section = null; + if (terms[0]?.startsWith('section')) { + const sectionIdx = getTrailingNumber(terms[0]) || 1; + section = getSection(rootEl, sectionIdx); + terms.shift(); + } + + if (terms.length) { + const blockStr = terms.shift(); + if (blockStr.includes('.')) { + selectedEl = querySelector(section || rootEl, blockStr); + } else { + const blockIndex = getTrailingNumber(blockStr) || 1; + const blockName = blockStr.replace(`${blockIndex}`, ''); + if (blockName === 'block') { + if (!section) section = getSection(rootEl, 1); + selectedEl = querySelector(section, `:scope > div:nth-of-type(${blockIndex})`); + } else { + selectedEl = querySelector(section || rootEl, `.${blockName}`, true)?.[blockIndex - 1]; + } + } + } else if (section) { + return section; + } + + if (terms.length) { + // find targeted table cell in rowX colY format + const rowColMatch = /row(?\d+)\s+col(?\d+)/gm.exec(terms.join(' ')); + if (rowColMatch) { + const { row, col } = rowColMatch.groups; + const tableCell = querySelector(selectedEl, `:nth-child(${row}) > :nth-child(${col})`); + if (!tableCell) return null; + return getDivInTargetedCell(tableCell); + } + } + + return selectedEl; } -function handleCommands(commands, manifestId) { +function handleCommands(commands, manifestId, rootEl = document) { commands.forEach((cmd) => { const { action, selector, target } = cmd; if (action in COMMANDS) { - const el = getSelectedElement(selector, action); + const el = getSelectedElement(selector, action, rootEl); COMMANDS[action](el, target, manifestId); } else if (action in CREATE_CMDS) { - const el = getSelectedElement(selector, action); + const el = getSelectedElement(selector, action, rootEl); el?.insertAdjacentElement(CREATE_CMDS[action], createFrag(el, target, manifestId)); } else { /* c8 ignore next 2 */ @@ -396,7 +467,6 @@ function parsePlaceholders(placeholders, config, selectedVariantName = '') { } return config; } -/* c8 ignore stop */ const checkForParamMatch = (paramStr) => { const [name, val] = paramStr.split('param-')[1].split('='); @@ -513,6 +583,29 @@ export async function getPersConfig(info, override = false) { 'manifest-type': ['Personalization', 'Promo', 'Test'], 'manifest-execution-order': ['First', 'Normal', 'Last'], }; + if (infoTab) { + const infoObj = infoTab?.reduce((acc, item) => { + acc[item.key] = item.value; + return acc; + }, {}); + config.manifestOverrideName = infoObj?.['manifest-override-name']?.toLowerCase(); + config.manifestType = infoObj?.['manifest-type']?.toLowerCase(); + const executionOrder = { + 'manifest-type': 1, + 'manifest-execution-order': 1, + }; + Object.keys(infoObj).forEach((key) => { + if (!infoKeyMap[key]) return; + const index = infoKeyMap[key].indexOf(infoObj[key]); + executionOrder[key] = index > -1 ? index : 1; + }); + config.executionOrder = `${executionOrder['manifest-execution-order']}-${executionOrder['manifest-type']}`; + } else { + // eslint-disable-next-line prefer-destructuring + config.manifestType = infoKeyMap[1]; + config.executionOrder = '1-1'; + } + if (infoTab) { const infoObj = infoTab?.reduce((acc, item) => { acc[item.key] = item.value; @@ -615,6 +708,13 @@ function compareExecutionOrder(a, b) { return a.executionOrder > b.executionOrder ? 1 : -1; } +export function cleanAndSortManifestList(manifests) { + const manifestObj = {}; +function compareExecutionOrder(a, b) { + if (a.executionOrder === b.executionOrder) return 0; + return a.executionOrder > b.executionOrder ? 1 : -1; +} + export function cleanAndSortManifestList(manifests) { const manifestObj = {}; manifests.forEach((manifest) => { @@ -633,9 +733,11 @@ export function cleanAndSortManifestList(manifests) { manifestObj[manifest.manifestPath] = freshManifest; } else { manifestObj[manifest.manifestPath] = manifest; + manifestObj[manifest.manifestPath] = manifest; } }); return Object.values(manifestObj).sort(compareExecutionOrder); + return Object.values(manifestObj).sort(compareExecutionOrder); } export function handleFragmentCommand(command, a) { diff --git a/test/features/personalization/mocks/deprecatedActions/manifestReplaceContent.json b/test/features/personalization/mocks/deprecatedActions/manifestReplaceContent.json index 1d338e7133..741b1b7783 100644 --- a/test/features/personalization/mocks/deprecatedActions/manifestReplaceContent.json +++ b/test/features/personalization/mocks/deprecatedActions/manifestReplaceContent.json @@ -22,6 +22,16 @@ "firefox": "", "android": "", "ios": "" + }, + { + "action": "replaceContent", + "selector": ".z-pattern.small row3 col2", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/milo-replace-r3c2", + "firefox": "", + "android": "", + "ios": "" } ], ":type": "sheet" diff --git a/test/features/personalization/mocks/manifestBlockNumber.json b/test/features/personalization/mocks/manifestBlockNumber.json new file mode 100644 index 0000000000..40ddd653c5 --- /dev/null +++ b/test/features/personalization/mocks/manifestBlockNumber.json @@ -0,0 +1,22 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replaceContent", + "selector": "marquee row2 col1", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "/fragments/replace/marquee/r2c1" + }, + { + "action": "replaceContent", + "selector": "section5 marquee row2 col2", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "/fragments/replace/marquee-2/r2c2" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestInvalidSelector.json b/test/features/personalization/mocks/manifestInvalidSelector.json new file mode 100644 index 0000000000..a68fb520bf --- /dev/null +++ b/test/features/personalization/mocks/manifestInvalidSelector.json @@ -0,0 +1,15 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replaceContent", + "selector": ".bad...selector", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "on" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestSectionBlock.json b/test/features/personalization/mocks/manifestSectionBlock.json new file mode 100644 index 0000000000..77088f07f5 --- /dev/null +++ b/test/features/personalization/mocks/manifestSectionBlock.json @@ -0,0 +1,29 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "removeContent", + "selector": "section2 block2", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "on" + }, + { + "action": "removeContent", + "selector": "custom-block2", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "on" + }, + { + "action": "removeContent", + "selector": "section5 custom-block", + "page filter (optional)": "", + "param-newoffer=123": "", + "all": "on" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/personalization.html b/test/features/personalization/mocks/personalization.html index 4e26216096..f2ed9f2cb9 100644 --- a/test/features/personalization/mocks/personalization.html +++ b/test/features/personalization/mocks/personalization.html @@ -1,111 +1,83 @@
-
+
+
+
-
-
-
- - - -
-
-
-
-

Milo Experimentation Platform

-

Leverage the Milo Experimentation Platform (MEP) for all your personalization needs on Milo!

-

Review Docs

-
-
- - - -
-
+
+ + +
-
-
-
-

Features of Milo Experimentation Platform

-

Learn more about the features of the Milo Experimentation Platform and what it can do

-
-
-
-
- - - -
-
-

Who will win?

-

A/B/N Testing

-

Milo Experimentation Platform is integrated with Adobe Target and can help you test new experiences.

-

Learn more

-
-
-
-
- - - -
-
-

Speak directly to your audience

-

Audience Experience Targeting

-

Leveraging Adobe Target and Adobe Audience Manager, Milo Experimentation Platform can help serve specific experiences to specific visitors.

-

Learn more

-
-
-
-
- - - -
-
-

Personalize based on visitor attributes

-

Attribute Experience Targeting

-

For simple use cases, the Milo Experimentation Platform can hide/show/add content to a page based on the attributes of a visitor.

-

Learn more Learn more

-
-
+
+

Milo Experimentation Platform

+

Leverage the Milo Experimentation Platform (MEP) for all your personalization needs on Milo!

+

Review + Docs

+
+
+ + +
+
+
-
-
-
-

How to leverage Milo Experimentation Platform

-

This will explain the basic steps on how to use the Milo Experimentation Platform.

-

- - - -

-
-
-
-
-
    -
  • Create a webpage using Milo
  • -
  • Create a page manifest
  • -
  • Configure the personalization based on your requirements
  • -
  • Sit back and watch visitors enjoy your personalization!
  • -
-
-
+
Custom block 1
+
+
+
+
+
+
+
+

Features of Milo Experimentation Platform

+

Learn more about the features of the Milo Experimentation Platform and what it can do

-

/fragments/replaceme

+
+ + + +
+
+

Who will win?

+

A/B/N Testing

+

Milo Experimentation Platform is integrated with Adobe Target and can help you test new experiences.

+

Learn more

+
-
-
-
Old Promo Block
-
+
+ + + +
+
+

Speak directly to your audience

+

Audience Experience Targeting

+

Leveraging Adobe Target and Adobe Audience Manager, Milo Experimentation Platform can help serve specific + experiences to specific visitors.

+

Learn more

+
+
+
+
+ + + +
+
+

Personalize based on visitor attributes

+

Attribute Experience Targeting

+

For simple use cases, the Milo Experimentation Platform can hide/show/add content to a page based on the + attributes of a visitor.

+

Learn more Learn + more

@@ -117,10 +89,91 @@

How to leverage Milo Expe

This block does not exist
+
+
+
+
Special block that is second in the section
+
+
+
+
+
Custom block 2
+
+
+
+
+
+
+
+

How to leverage Milo Experimentation Platform

+

This will explain the basic steps on how to use the Milo Experimentation Platform.

+

+ + + +

+
+
+
+
+
    +
  • Create a webpage using Milo
  • +
  • Create a page manifest
  • +
  • Configure the personalization based on your requirements
  • +
  • Sit back and watch visitors enjoy your personalization!
  • +
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
+

The second marquee

+

Blah blah blah

+

Docs + Here

+
+
+ + + +
+
+
+
+
+
Custom block 3
+
+
+
+
+
+
+
Old Promo Block
+
+
+
This block does not exist
+
+
+
Custom block 4
+
+
+
-
-
- +
+ diff --git a/test/features/personalization/personalization.test.js b/test/features/personalization/personalization.test.js index 69b5a47702..15e2da9179 100644 --- a/test/features/personalization/personalization.test.js +++ b/test/features/personalization/personalization.test.js @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { readFile } from '@web/test-runner-commands'; -import { stub } from 'sinon'; +import { assert, stub } from 'sinon'; import { getConfig, setConfig } from '../../../libs/utils/utils.js'; import { applyPers, matchGlob } from '../../../libs/features/personalization/personalization.js'; @@ -18,6 +18,12 @@ const setFetchResponse = (data, type = 'json') => { window.fetch = stub().returns(getFetchPromise(data, type)); }; +async function loadManifestAndSetResponse(manifestPath) { + let manifestJson = await readFile({ path: manifestPath }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); +} + // Note that the manifestPath doesn't matter as we stub the fetch describe('Functional Test', () => { before(() => { @@ -31,9 +37,7 @@ describe('Functional Test', () => { }); it('Invalid selector should not fail page render and rest of items', async () => { - let manifestJson = await readFile({ path: './mocks/manifestInvalid.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestInvalid.json'); expect(document.querySelector('.marquee')).to.not.be.null; expect(document.querySelector('a[href="/test/features/personalization/mocks/fragments/insertafter2"]')).to.be.null; @@ -41,6 +45,41 @@ describe('Functional Test', () => { const fragment = document.querySelector('a[href="/test/features/personalization/mocks/fragments/insertafter2"]'); expect(fragment).to.not.be.null; expect(fragment.parentElement.previousElementSibling.className).to.equal('marquee'); + // TODO: add check for after3 + }); + + it('Can select elements using block-#', async () => { + await loadManifestAndSetResponse('./mocks/manifestBlockNumber.json'); + + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('a[href="/fragments/replace/marquee/r2c1"]')).to.be.null; + expect(document.querySelector('a[href="/fragments/replace/marquee-2/r3c2"]')).to.be.null; + const secondMarquee = document.getElementsByClassName('marquee')[1]; + expect(secondMarquee).to.not.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + const fragment = document.querySelector('a[href="/fragments/replace/marquee/r2c1"]'); + expect(fragment).to.not.be.null; + const replacedCell = document.querySelector('.marquee > div:nth-child(2) > div:nth-child(1)'); + expect(replacedCell.firstChild.firstChild).to.equal(fragment); + const secondFrag = document.querySelector('a[href="/fragments/replace/marquee-2/r2c2"]'); + expect(secondMarquee.lastElementChild.lastElementChild.firstChild.firstChild) + .to.equal(secondFrag); + }); + + it('Can select blocks using section and block indexs', async () => { + await loadManifestAndSetResponse('./mocks/manifestSectionBlock.json'); + + expect(document.querySelector('.special-block')).to.not.be.null; + expect(document.querySelector('.custom-block-2')).to.not.be.null; + expect(document.querySelector('.custom-block-3')).to.not.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(document.querySelector('.special-block')).to.be.null; + expect(document.querySelector('.custom-block-2')).to.be.null; + expect(document.querySelector('.custom-block-3')).to.be.null; }); it('scheduled manifest should apply changes if active (bts)', async () => { @@ -58,9 +97,7 @@ describe('Functional Test', () => { }); it('scheduled manifest should not apply changes if not active (blackfriday)', async () => { - let manifestJson = await readFile({ path: './mocks/manifestScheduledInactive.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestScheduledInactive.json'); expect(document.querySelector('a[href="/fragments/insertafter4"]')).to.be.null; const event = { name: 'blackfriday', start: new Date('2022-11-24T13:00:00+00:00'), end: new Date('2022-11-24T13:00:00+00:00') }; await applyPers([{ manifestPath: '/promos/blackfriday/manifest.json', disabled: true, event }]); @@ -69,30 +106,24 @@ describe('Functional Test', () => { expect(fragment).to.be.null; }); - it('test or promo manifest type', async () => { + it('test or promo manifest', async () => { let config = getConfig(); config.mep = {}; - let manifestJson = await readFile({ path: './mocks/manifestTestOrPromo.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestTestOrPromo.json'); config = getConfig(); await applyPers([{ manifestPath: '/path/to/manifest.json' }]); - expect(config.experiments[0].manifestType).to.equal('test or promo'); + expect(config.mep?.martech).to.be.undefined; }); it('should choose chrome & logged out', async () => { - let manifestJson = await readFile({ path: './mocks/manifestWithAmpersand.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestWithAmpersand.json'); await applyPers([{ manifestPath: '/path/to/manifest.json' }]); const config = getConfig(); expect(config.mep?.martech).to.equal('|chrome & logged|ampersand'); }); it('should choose not firefox', async () => { - let manifestJson = await readFile({ path: './mocks/manifestWithNot.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestWithNot.json'); await applyPers([{ manifestPath: '/path/to/manifest.json' }]); const config = getConfig(); expect(config.mep?.martech).to.equal('|not firefox|not'); @@ -104,12 +135,43 @@ describe('Functional Test', () => { config.consumerEntitlements = { 'consumer-defined-entitlement': 'fireflies' }; config.entitlements = () => Promise.resolve(['indesign-any', 'fireflies', 'after-effects-any']); - let manifestJson = await readFile({ path: './mocks/manifestUseEntitlements.json' }); - manifestJson = JSON.parse(manifestJson); - setFetchResponse(manifestJson); + await loadManifestAndSetResponse('./mocks/manifestUseEntitlements.json'); await applyPers([{ manifestPath: '/path/to/manifest.json' }]); expect(getConfig().mep?.martech).to.equal('|fireflies|manifest'); }); + + it('invalid selector should output error to console', async () => { + window.console.log = stub(); + + await loadManifestAndSetResponse('./mocks/manifestInvalidSelector.json'); + + await applyPers([{ manifestPath: '/mocks/manifestRemove.json' }]); + + assert.calledWith(window.console.log, 'Invalid selector: ', '.bad...selector'); + window.console.log.reset(); + }); +}); + +describe('matchGlob function', () => { + it('should match page', async () => { + const result = matchGlob('/products/special-offers', '/products/special-offers'); + expect(result).to.be.true; + }); + + it('should match page with HTML extension', async () => { + const result = matchGlob('/products/special-offers', '/products/special-offers.html'); + expect(result).to.be.true; + }); + + it('should not match child page', async () => { + const result = matchGlob('/products/special-offers', '/products/special-offers/free-download'); + expect(result).to.be.false; + }); + + it('should match child page', async () => { + const result = matchGlob('/products/special-offers**', '/products/special-offers/free-download'); + expect(result).to.be.true; + }); }); describe('matchGlob function', () => {