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: "