Skip to content

feat: sends modal event data to modal wrapper for use in hooks #1170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/components/modal/v2/lib/postMessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { uniqueID } from '@krakenjs/belter/src';

// these constants are defined in PostMessenger
const POSTMESSENGER_EVENT_TYPES = {
ACK: 'ack',
MESSAGE: 'message'
};
const POSTMESSENGER_ACK_PAYLOAD = {
ok: 'true'
};
// these constants should maintain parity with MESSAGE_MODAL_EVENT_NAMES in core-web-sdk
export const POSTMESSENGER_EVENT_NAMES = {
CALCULATE: 'paypal-messages-modal-calculate',
CLOSE: 'paypal-messages-modal-close',
SHOW: 'paypal-messages-modal-show'
};

export function sendEvent(payload, trustedOrigin) {
if (!trustedOrigin) {
return;
}

const isTest = process.env.NODE_ENV === 'test';
const targetWindow = !isTest && window.parent === window ? window.opener : window.parent;

targetWindow.postMessage(payload, trustedOrigin);
}

// This function provides data security by preventing accidentally exposing sensitive data; we are adding
// an extra layer of validation here by only allowing explicitly approved fields to be included
function createSafePayload(unscreenedPayload) {
const allowedFields = [
'linkName' // close event
];

const safePayload = {};
if (unscreenedPayload) {
const entries = Object.entries(unscreenedPayload);
entries.forEach(entry => {
const [key, value] = entry;
if (allowedFields.includes(key)) {
safePayload[key] = value;
} else {
console.warn(`modal hook payload param should be allowlisted if secure: ${key}`);

Check warning on line 44 in src/components/modal/v2/lib/postMessage.js

View workflow job for this annotation

GitHub Actions / Lint and Unit Tests

Unexpected console statement
}
});
}

return safePayload;
}

export function createPostMessengerEvent(typeArg, eventName, eventPayloadArg) {
let type;
let eventPayload;

if (typeArg === 'ack') {
type = POSTMESSENGER_EVENT_TYPES.ACK;
eventPayload = POSTMESSENGER_ACK_PAYLOAD;
} else if (typeArg === 'message') {
type = POSTMESSENGER_EVENT_TYPES.MESSAGE;
// createSafePayload, only call this if a payload is sent
eventPayload = createSafePayload(eventPayloadArg);
}

return {
eventName,
id: uniqueID(),
type,
eventPayload: eventPayload || {}
};
}
47 changes: 3 additions & 44 deletions src/components/modal/v2/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,58 +114,17 @@ export function formatDateByCountry(country) {
return currentDate.toLocaleDateString('en-GB', options);
}

export function createUUID() {
// crypto.randomUUID() is only available in HTTPS secure environments and modern browsers
if (typeof crypto !== 'undefined' && crypto && crypto.randomUUID instanceof Function) {
return crypto.randomUUID();
}

const validChars = '0123456789abcdefghijklmnopqrstuvwxyz';
const stringLength = 32;
let randomId = '';
for (let index = 0; index < stringLength; index++) {
const randomIndex = Math.floor(Math.random() * validChars.length);
randomId += validChars.charAt(randomIndex);
}
return randomId;
}

export function validateProps(updatedProps) {
const validatedProps = {};
Object.entries(updatedProps).forEach(entry => {
const [k, v] = entry;
if (k === 'offerTypes') {
if (k === 'offerType') {
validatedProps.offer = validate.offer({ props: { offer: v } });
} else if (!Object.keys(validate).includes(k)) {
validatedProps[k] = v;
} else {
validatedProps[k] = validate[k]({ props: { [k]: v } });
}
});
return validatedProps;
}

export function sendEventAck(eventId, trustedOrigin) {
// skip this step if running in test env because jest's target windows don't support postMessage
if (process.env.NODE_ENV === 'test') {
return;
}

// target window selection depends on if checkout window is in popup or modal iframe
let targetWindow;
const popupCheck = window.parent === window;
if (popupCheck) {
targetWindow = window.opener;
} else {
targetWindow = window.parent;
}

targetWindow.postMessage(
{
// PostMessenger stops reposting an event when it receives an eventName which matches the id in the message it sent and type 'ack'
eventName: eventId,
type: 'ack',
eventPayload: { ok: true },
id: createUUID()
},
trustedOrigin
);
}
69 changes: 49 additions & 20 deletions src/components/modal/v2/lib/zoid-polyfill.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* global Android */
import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src';
import { getOrCreateDeviceID, logger } from '../../../../utils';
import { isIframe, validateProps, sendEventAck } from './utils';
import { validateProps, isIframe } from './utils';
import { sendEvent, createPostMessengerEvent, POSTMESSENGER_EVENT_NAMES } from './postMessage';

const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler';
const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler';
Expand All @@ -13,21 +14,43 @@ function updateProps(newProps, propListeners) {
Object.assign(window.xprops, newProps);
}

export function handleBrowserEvents(initialProps, propListeners, updatedPropsEvent) {
export function handlePropsUpdateEvent(propListeners, updatedPropsEvent) {
const {
origin: eventOrigin,
data: { eventName, id, eventPayload: newProps }
data: { eventPayload: newProps }
} = updatedPropsEvent;
const clientOrigin = decodeURIComponent(initialProps.origin);

if (eventOrigin === clientOrigin && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') {
// send event ack so PostMessenger will stop reposting event
sendEventAck(id, clientOrigin);
if (newProps && typeof newProps === 'object') {
const validProps = validateProps(newProps);
updateProps(validProps, propListeners);
}
}

export function logModalClose(linkName) {
logger.track({
index: '1',
et: 'CLICK',
event_type: 'modal_close',
page_view_link_name: linkName
});
}

export function handleBrowserEvents(clientOrigin, propListeners, event) {
const {
origin: eventOrigin,
data: { eventName, id }
} = event;
if (eventOrigin !== clientOrigin) {
return;
}
if (eventName === 'PROPS_UPDATE') {
handlePropsUpdateEvent(propListeners, event);
}
if (eventName === 'MODAL_CLOSED') {
logModalClose(event.data.eventPayload.linkName);
}
// send event ack with original event id so PostMessenger will stop reposting event
sendEvent(createPostMessengerEvent('ack', id), clientOrigin);
}

const getAccount = (merchantId, clientId, payerId) => {
if (merchantId) {
return merchantId;
Expand All @@ -44,10 +67,15 @@ const getAccount = (merchantId, clientId, payerId) => {
const setupBrowser = props => {
const propListeners = new Set();

let trustedOrigin = decodeURIComponent(props.origin || '');
if (isIframe && document.referrer && !process.env.NODE_ENV === 'test') {
trustedOrigin = new window.URL(document.referrer).origin;
}

window.addEventListener(
'message',
event => {
handleBrowserEvents(props, propListeners, event);
handleBrowserEvents(trustedOrigin, propListeners, event);
},
false
);
Expand Down Expand Up @@ -110,6 +138,7 @@ const setupBrowser = props => {
});
},
onCalculate: ({ value }) => {
sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CALCULATE), trustedOrigin);
logger.track({
index: '1',
et: 'CLICK',
Expand All @@ -120,6 +149,7 @@ const setupBrowser = props => {
});
},
onShow: () => {
sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.SHOW), trustedOrigin);
logger.track({
index: '1',
et: 'CLIENT_IMPRESSION',
Expand All @@ -128,16 +158,15 @@ const setupBrowser = props => {
});
},
onClose: ({ linkName }) => {
if (isIframe && document.referrer) {
const targetOrigin = new window.URL(document.referrer).origin;
window.parent.postMessage('paypal-messages-modal-close', targetOrigin);
}
logger.track({
index: '1',
et: 'CLICK',
event_type: 'modal_close',
page_view_link_name: linkName
});
const eventPayload = {
linkName
// for data security, also add new params to createSafePayload in ./postMessage.js
};
sendEvent(
createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CLOSE, eventPayload),
trustedOrigin
);
logModalClose(linkName);
},
// Overridable defaults
integrationType: __MESSAGES__.__TARGET__,
Expand Down
2 changes: 0 additions & 2 deletions src/components/modal/v2/parts/Container.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ const Container = ({ children }) => {
useEffect(() => {
if (transitionState === 'CLOSED') {
contentWrapperRef.current.scrollTop = 0;
} else if (transitionState === 'OPEN') {
window.focus();
}
}, [transitionState]);

Expand Down
25 changes: 24 additions & 1 deletion tests/unit/spec/src/components/modal/v2/lib/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatDateByCountry } from 'src/components/modal/v2/lib/utils';
import { formatDateByCountry, validateProps } from 'src/components/modal/v2/lib/utils';

describe('Date function should return correct date format based on country', () => {
it('US country date should be formatted MM/DD/YYYY', () => {
Expand All @@ -14,3 +14,26 @@ describe('Date function should return correct date format based on country', ()
expect(result).toMatch(expectedFormat);
});
});

describe('validateProps', () => {
it('validates amount, contextualComponents, and offerType, and preserves value of other props', () => {
const propsToFix = {
amount: '10',
offerType: 'PAY_LATER_SHORT_TERM, PAY_LATER_LONG_TERM',
contextualComponents: 'paypal_button'
};
const propsToPreserve = {
itemSkus: ['123', '456'],
presentationMode: 'auto'
};

const output = validateProps({ ...propsToFix, ...propsToPreserve });

const fixedPropOutputValues = {
amount: 10,
offer: 'PAY_LATER_LONG_TERM,PAY_LATER_SHORT_TERM',
contextualComponents: 'PAYPAL_BUTTON'
};
expect(output).toMatchObject({ ...fixedPropOutputValues, ...propsToPreserve });
});
});
Loading
Loading