diff --git a/src/components/Personalization/createComponent.js b/src/components/Personalization/createComponent.js index 08cc8022b..9b001c12d 100644 --- a/src/components/Personalization/createComponent.js +++ b/src/components/Personalization/createComponent.js @@ -27,6 +27,7 @@ export default ({ viewCache, showContainers, applyPropositions, + trackProposition, setTargetMigration, mergeDecisionsMeta, renderedPropositions, @@ -121,7 +122,8 @@ export default ({ optionsValidator: options => validateApplyPropositionsOptions({ logger, options }), run: applyPropositions - } + }, + trackProposition: trackProposition.command } }; }; diff --git a/src/components/Personalization/createTrackProposition.js b/src/components/Personalization/createTrackProposition.js new file mode 100644 index 000000000..acd0142ec --- /dev/null +++ b/src/components/Personalization/createTrackProposition.js @@ -0,0 +1,116 @@ +/* +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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { anything, arrayOf, objectOf, string } from "../../utils/validation"; +import createDecorateProposition from "./handlers/createDecorateProposition"; +import { includes } from "../../utils"; +import { HTML_CONTENT_ITEM, JSON_CONTENT_ITEM } from "../../constants/schema"; +import isDomElement from "./dom-actions/dom/isDomElement"; +import selectNodes from "../../utils/dom/selectNodes"; + +const validateOptions = ({ options }) => { + const validator = objectOf({ + proposition: objectOf({ + id: string(), + scope: string(), + scopeDetails: anything(), + items: arrayOf(anything()) + }).required(), + element: anything(), + selector: string() + }).noUnknownFields(); + + return validator(options); +}; + +const REQUIRED_FIELDS_PROPOSITION = ["id", "scope", "scopeDetails", "items"]; +const REQUIRED_FIELDS_ITEM = ["id", "schema", "data"]; + +const checkMalformedPropositionJSON = propositionJSON => { + for (let i = 0; i < REQUIRED_FIELDS_PROPOSITION.length; i += 1) { + if ( + !Object.hasOwnProperty.call( + propositionJSON, + REQUIRED_FIELDS_PROPOSITION[i] + ) + ) { + return new Error( + `Proposition object is missing "${REQUIRED_FIELDS_PROPOSITION[i]}" field` + ); + } + } + + const { items } = propositionJSON; + + if (!Array.isArray(items)) { + return new Error(`Proposition items must be an Array`); + } + + for (let i = 0; i < items.length; i += 1) { + for (let j = 0; j < REQUIRED_FIELDS_ITEM.length; j += 1) { + if (!Object.hasOwnProperty.call(items[i], REQUIRED_FIELDS_ITEM[j])) { + return new Error( + `Proposition item is missing "${REQUIRED_FIELDS_ITEM[j]}" field` + ); + } + } + } + + return undefined; +}; + +export default ({ + autoTrackPropositionInteractions, + storeInteractionMeta, + createProposition +}) => { + const run = ({ proposition: propositionJSON, element, selector }) => { + const error = checkMalformedPropositionJSON(propositionJSON); + + if (error) { + return Promise.reject(error); + } + + const elements = isDomElement(element) ? [element] : selectNodes(selector); + + const proposition = createProposition(propositionJSON); + + proposition + .getItems() + .filter(item => + includes([HTML_CONTENT_ITEM, JSON_CONTENT_ITEM], item.getSchema()) + ) + .forEach(item => { + const decorateProposition = createDecorateProposition( + autoTrackPropositionInteractions, + item.getSchemaType(), + proposition.getId(), + item.getId(), + item.getTrackingLabel(), + proposition.getScopeType(), + proposition.getNotification(), + storeInteractionMeta + ); + elements.forEach(el => decorateProposition(el)); + }); + + return Promise.resolve(); + }; + + const optionsValidator = options => validateOptions({ options }); + + return { + command: { + optionsValidator, + run + } + }; +}; diff --git a/src/components/Personalization/dom-actions/dom/isDomElement.js b/src/components/Personalization/dom-actions/dom/isDomElement.js new file mode 100644 index 000000000..3bc38eb09 --- /dev/null +++ b/src/components/Personalization/dom-actions/dom/isDomElement.js @@ -0,0 +1,13 @@ +/* +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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +export default element => + element instanceof Element || element instanceof HTMLDocument; diff --git a/src/components/Personalization/index.js b/src/components/Personalization/index.js index 121424261..c7c877979 100644 --- a/src/components/Personalization/index.js +++ b/src/components/Personalization/index.js @@ -60,6 +60,7 @@ import { NEVER, PROPOSITION_INTERACTION_TYPES } from "../../constants/propositionInteractionType"; +import createTrackProposition from "./createTrackProposition"; const createPersonalization = ({ config, logger, eventManager }) => { const { @@ -147,6 +148,13 @@ const createPersonalization = ({ config, logger, eventManager }) => { getClickSelectors, autoTrackPropositionInteractions }); + + const trackProposition = createTrackProposition({ + autoTrackPropositionInteractions, + storeInteractionMeta, + createProposition + }); + const viewChangeHandler = createViewChangeHandler({ processPropositions, viewCache @@ -178,6 +186,7 @@ const createPersonalization = ({ config, logger, eventManager }) => { viewCache, showContainers, applyPropositions, + trackProposition, setTargetMigration, mergeDecisionsMeta, renderedPropositions, diff --git a/test/functional/helpers/createAlloyProxy.js b/test/functional/helpers/createAlloyProxy.js index d1fbf154c..112527754 100644 --- a/test/functional/helpers/createAlloyProxy.js +++ b/test/functional/helpers/createAlloyProxy.js @@ -91,6 +91,7 @@ const commands = [ "getLibraryInfo", "appendIdentityToUrl", "applyPropositions", + "trackProposition", "subscribeRulesetItems", "evaluateRulesets" ]; diff --git a/test/functional/specs/LibraryInfo/C2589.js b/test/functional/specs/LibraryInfo/C2589.js index 2b7758a95..7f50ebbb4 100644 --- a/test/functional/specs/LibraryInfo/C2589.js +++ b/test/functional/specs/LibraryInfo/C2589.js @@ -53,7 +53,8 @@ test("C2589: getLibraryInfo command returns library information.", async () => { "getIdentity", "sendEvent", "setConsent", - "setDebug" + "setDebug", + "trackProposition" ]; const currentConfigs = { clickCollectionEnabled: true, diff --git a/test/unit/helpers/createMockProposition.js b/test/unit/helpers/createMockProposition.js new file mode 100644 index 000000000..5ddcabb24 --- /dev/null +++ b/test/unit/helpers/createMockProposition.js @@ -0,0 +1,29 @@ +/* +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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import injectCreateProposition from "../../../src/components/Personalization/handlers/injectCreateProposition"; + +const createProposition = injectCreateProposition({ + preprocess: data => data, + isPageWideSurface: () => false +}); + +export default (item, scopeDetails = {}) => { + return createProposition({ + id: "id", + scope: "scope", + scopeDetails: { + decisionProvider: "AJO", + ...scopeDetails + }, + items: [item] + }); +}; diff --git a/test/unit/specs/components/Personalization/createComponent.spec.js b/test/unit/specs/components/Personalization/createComponent.spec.js index 0a3b77945..9b1a64c52 100644 --- a/test/unit/specs/components/Personalization/createComponent.spec.js +++ b/test/unit/specs/components/Personalization/createComponent.spec.js @@ -28,6 +28,11 @@ describe("Personalization", () => { let renderedPropositions; let cacheUpdate; + const trackProposition = { + optionsValidator: jasmine.createSpy("optionsValidator"), + run: jasmine.createSpy("run") + }; + const build = () => { personalizationComponent = createComponent({ logger, @@ -40,7 +45,8 @@ describe("Personalization", () => { showContainers, setTargetMigration, mergeDecisionsMeta, - renderedPropositions + renderedPropositions, + trackProposition }); }; diff --git a/test/unit/specs/components/Personalization/createTrackProposition.spec.js b/test/unit/specs/components/Personalization/createTrackProposition.spec.js new file mode 100644 index 000000000..162dab2ae --- /dev/null +++ b/test/unit/specs/components/Personalization/createTrackProposition.spec.js @@ -0,0 +1,259 @@ +/* +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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import createTrackProposition from "../../../../../src/components/Personalization/createTrackProposition"; +import { + ADOBE_JOURNEY_OPTIMIZER, + ADOBE_TARGET +} from "../../../../../src/constants/decisionProvider"; +import { + ALWAYS, + NEVER +} from "../../../../../src/constants/propositionInteractionType"; +import createInteractionStorage from "../../../../../src/components/Personalization/createInteractionStorage"; +import { + DEFAULT_CONTENT_ITEM, + DOM_ACTION, + JSON_CONTENT_ITEM, + HTML_CONTENT_ITEM, + MEASUREMENT_SCHEMA, + MESSAGE_FEED_ITEM, + MESSAGE_IN_APP, + MESSAGE_NATIVE_ALERT, + REDIRECT_ITEM, + RULESET_ITEM +} from "../../../../../src/constants/schema"; +import createMockProposition from "../../../helpers/createMockProposition"; +import cleanUpDomChanges from "../../../helpers/cleanUpDomChanges"; +import { appendNode, createNode } from "../../../../../src/utils/dom"; +import { getAttribute } from "../../../../../src/components/Personalization/dom-actions/dom"; +import { INTERACT_ID_DATA_ATTRIBUTE } from "../../../../../src/components/Personalization/handlers/createDecorateProposition"; +import injectCreateProposition from "../../../../../src/components/Personalization/handlers/injectCreateProposition"; + +describe("Personalization:trackProposition", () => { + const testElementId = "superfluous123"; + + let autoTrackPropositionInteractions; + const { storeInteractionMeta } = createInteractionStorage(); + let trackProposition; + + const createProposition = injectCreateProposition({ + preprocess: data => data, + isPageWideSurface: () => false + }); + + beforeEach(() => { + autoTrackPropositionInteractions = { + [ADOBE_JOURNEY_OPTIMIZER]: ALWAYS, + [ADOBE_TARGET]: NEVER + }; + + trackProposition = createTrackProposition({ + autoTrackPropositionInteractions, + storeInteractionMeta, + createProposition + }); + + const element = createNode("div", { + id: testElementId, + class: `test-element-${testElementId}` + }); + element.innerHTML = "test element"; + appendNode(document.body, element); + }); + + afterEach(() => { + cleanUpDomChanges(testElementId); + }); + + it("has a command defined", () => { + const { command } = trackProposition; + + expect(command).toEqual({ + optionsValidator: jasmine.any(Function), + run: jasmine.any(Function) + }); + }); + + it("decorates element for html-content-item", async () => { + const { command } = trackProposition; + + const proposition = createMockProposition({ + id: "abc", + schema: HTML_CONTENT_ITEM, + data: "
hello world
" + }); + + await expectAsync( + command.run({ + proposition: proposition.toJSON(), + selector: `#${testElementId}` + }) + ).toBeResolved(); + + const element = document.getElementById(testElementId); + expect(getAttribute(element, INTERACT_ID_DATA_ATTRIBUTE)).not.toBeNull(); + }); + + it("decorates element for json-content-item", async () => { + const { command } = trackProposition; + + const proposition = createMockProposition({ + id: "abc", + schema: JSON_CONTENT_ITEM, + data: { word: "up" } + }); + + await expectAsync( + command.run({ + proposition: proposition.toJSON(), + selector: `#${testElementId}` + }) + ).toBeResolved(); + + const element = document.getElementById(testElementId); + expect(getAttribute(element, INTERACT_ID_DATA_ATTRIBUTE)).not.toBeNull(); + }); + + it("decorates element using element reference instead of selector", async () => { + const { command } = trackProposition; + + const proposition = createMockProposition({ + id: "abc", + schema: JSON_CONTENT_ITEM, + data: { word: "up" } + }); + + const element = document.getElementById(testElementId); + await expectAsync( + command.run({ proposition: proposition.toJSON(), element }) + ).toBeResolved(); + + expect(getAttribute(element, INTERACT_ID_DATA_ATTRIBUTE)).not.toBeNull(); + }); + + it("does not decorate elements for non-code-based schemas", () => { + const { command } = trackProposition; + + [ + DEFAULT_CONTENT_ITEM, + DOM_ACTION, + RULESET_ITEM, + REDIRECT_ITEM, + MESSAGE_IN_APP, + MESSAGE_FEED_ITEM, + MESSAGE_NATIVE_ALERT, + MEASUREMENT_SCHEMA + ].forEach(schema => { + const proposition = createMockProposition({ + id: "abc", + schema, + data: undefined + }); + + const element = document.getElementById(testElementId); + command.run({ proposition: proposition.toJSON(), element }); + + expect(getAttribute(element, INTERACT_ID_DATA_ATTRIBUTE)).toBeNull(); + }); + }); + + it("fails gracefully if malformed proposition json", async () => { + const { command } = trackProposition; + + const scopeDetails = { decisionProvider: "AJO" }; + + const element = document.getElementById(testElementId); + await expectAsync( + command.run({ proposition: {}, element }) + ).toBeRejectedWithError('Proposition object is missing "id" field'); + + await expectAsync( + command.run({ proposition: { id: 1 }, element }) + ).toBeRejectedWithError('Proposition object is missing "scope" field'); + + await expectAsync( + command.run({ + proposition: { id: 1, scope: "web://aepdemo.com/" }, + element + }) + ).toBeRejectedWithError( + 'Proposition object is missing "scopeDetails" field' + ); + + await expectAsync( + command.run({ + proposition: { id: 1, scope: "web://aepdemo.com/", scopeDetails }, + element + }) + ).toBeRejectedWithError('Proposition object is missing "items" field'); + + await expectAsync( + command.run({ + proposition: { + id: 1, + scope: "web://aepdemo.com/", + scopeDetails, + items: undefined + }, + element + }) + ).toBeRejectedWithError("Proposition items must be an Array"); + + await expectAsync( + command.run({ + proposition: { + id: 1, + scope: "web://aepdemo.com/", + scopeDetails, + items: [{}] + }, + element + }) + ).toBeRejectedWithError('Proposition item is missing "id" field'); + + await expectAsync( + command.run({ + proposition: { + id: 1, + scope: "web://aepdemo.com/", + scopeDetails, + items: [{ id: "abc" }] + }, + element + }) + ).toBeRejectedWithError('Proposition item is missing "schema" field'); + + await expectAsync( + command.run({ + proposition: { + id: 1, + scope: "web://aepdemo.com/", + scopeDetails, + items: [{ id: "abc", schema: JSON_CONTENT_ITEM }] + }, + element + }) + ).toBeRejectedWithError('Proposition item is missing "data" field'); + + await expectAsync( + command.run({ + proposition: { + id: 1, + scope: "web://aepdemo.com/", + scopeDetails, + items: [{ id: "abc", schema: JSON_CONTENT_ITEM, data: {} }] + }, + element + }) + ).not.toBeRejected(); + }); +}); diff --git a/test/unit/specs/components/Personalization/dom-actions/dom/isDomElement.spec.js b/test/unit/specs/components/Personalization/dom-actions/dom/isDomElement.spec.js new file mode 100644 index 000000000..a3f0b9b65 --- /dev/null +++ b/test/unit/specs/components/Personalization/dom-actions/dom/isDomElement.spec.js @@ -0,0 +1,43 @@ +/* +Copyright 2019 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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { appendNode, createNode } from "../../../../../../../src/utils/dom"; +import cleanUpDomChanges from "../../../../../helpers/cleanUpDomChanges"; +import isDomElement from "../../../../../../../src/components/Personalization/dom-actions/dom/isDomElement"; + +describe("Personalization::DOM::isDomElement", () => { + const testElementId = "superfluous123"; + + beforeEach(() => { + const element = createNode("div", { + id: testElementId, + class: `test-element-${testElementId}` + }); + element.innerHTML = "test element"; + appendNode(document.body, element); + }); + + afterEach(() => { + cleanUpDomChanges(testElementId); + }); + + it("validates dom element", () => { + expect(isDomElement(document.getElementById(testElementId))).toBeTrue(); + }); + + it("validates not a dom element", () => { + expect(isDomElement({}).toBeFalse); + expect(isDomElement([]).toBeFalse); + expect(isDomElement(true).toBeFalse); + expect(isDomElement("something").toBeFalse); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js index c6aaa8e89..0d99e4754 100644 --- a/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js +++ b/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js @@ -10,7 +10,6 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import createProcessDomAction from "../../../../../../src/components/Personalization/handlers/createProcessDomAction"; -import injectCreateProposition from "../../../../../../src/components/Personalization/handlers/injectCreateProposition"; import cleanUpDomChanges from "../../../../helpers/cleanUpDomChanges"; import { appendNode, createNode } from "../../../../../../src/utils/dom"; import { DOM_ACTION } from "../../../../../../src/constants/schema"; @@ -22,6 +21,7 @@ import { ALWAYS, NEVER } from "../../../../../../src/constants/propositionInteractionType"; +import createMockProposition from "../../../../helpers/createMockProposition"; describe("createProcessDomAction", () => { let modules; @@ -30,23 +30,6 @@ describe("createProcessDomAction", () => { let storeClickMeta; let processDomAction; - const createProposition = injectCreateProposition({ - preprocess: data => data, - isPageWideSurface: () => false - }); - - const createMockProposition = (item, scopeDetails = {}) => { - return createProposition({ - id: "id", - scope: "__view__", - scopeDetails: { - decisionProvider: "AJO", - ...scopeDetails - }, - items: [item] - }); - }; - beforeEach(() => { cleanUpDomChanges("click-element"); @@ -175,7 +158,7 @@ describe("createProcessDomAction", () => { selector: ".click-element", meta: { id: "id", - scope: "__view__", + scope: "scope", scopeDetails: { decisionProvider: "AJO", characteristics: { @@ -184,7 +167,7 @@ describe("createProcessDomAction", () => { } }, trackingLabel: "mytrackinglabel", - scopeType: "page" + scopeType: "proposition" } }); }); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js index 18abb15ea..f29b5d057 100644 --- a/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js +++ b/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js @@ -15,35 +15,18 @@ import { } from "../../../../../../src/constants/decisionProvider"; import createProcessHtmlContent from "../../../../../../src/components/Personalization/handlers/createProcessHtmlContent"; import createInteractionStorage from "../../../../../../src/components/Personalization/createInteractionStorage"; -import injectCreateProposition from "../../../../../../src/components/Personalization/handlers/injectCreateProposition"; import { HTML_CONTENT_ITEM } from "../../../../../../src/constants/schema"; import { ALWAYS, NEVER } from "../../../../../../src/constants/propositionInteractionType"; +import createMockProposition from "../../../../helpers/createMockProposition"; describe("createProcessHtmlContent", () => { let modules; let logger; let processHtmlContent; - const createProposition = injectCreateProposition({ - preprocess: data => data, - isPageWideSurface: () => false - }); - - const createMockProposition = item => { - return createProposition({ - id: "id", - scope: "scope", - scopeDetails: { - characteristics: { scopeType: "page" }, - decisionProvider: "AJO" - }, - items: [item] - }); - }; - beforeEach(() => { const { storeInteractionMeta } = createInteractionStorage(); diff --git a/test/unit/specs/components/Personalization/topLevel/buildAlloy.js b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js index 1d157b490..209f33f01 100644 --- a/test/unit/specs/components/Personalization/topLevel/buildAlloy.js +++ b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js @@ -58,6 +58,7 @@ import { DOM_ACTION_SET_TEXT } from "../../../../../../src/components/Personalization/dom-actions/initDomActionsModules"; import collectClicks from "../../../../../../src/components/Personalization/dom-actions/clicks/collectClicks"; +import createTrackProposition from "../../../../../../src/components/Personalization/createTrackProposition"; const createAction = renderFunc => ({ selector, content }) => { if (selector === "#error") { @@ -174,6 +175,13 @@ const buildComponent = ({ getClickMetas, getClickSelectors }); + + const trackProposition = createTrackProposition({ + autoTrackPropositionInteractions, + storeInteractionMeta, + createProposition + }); + const viewChangeHandler = createViewChangeHandler({ processPropositions, viewCache @@ -205,6 +213,7 @@ const buildComponent = ({ viewCache, showContainers, applyPropositions, + trackProposition, setTargetMigration, mergeDecisionsMeta, renderedPropositions,