diff --git a/changelog/fix-style-extraction-in-editor b/changelog/fix-style-extraction-in-editor new file mode 100644 index 00000000000..dc191757fe1 --- /dev/null +++ b/changelog/fix-style-extraction-in-editor @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Properly extract styles when using the site editor. diff --git a/client/checkout/blocks/payment-elements.js b/client/checkout/blocks/payment-elements.js index f782ba13545..3d89aec45bc 100644 --- a/client/checkout/blocks/payment-elements.js +++ b/client/checkout/blocks/payment-elements.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useEffect, useState, RawHTML } from '@wordpress/element'; +import { useEffect, useState, RawHTML, useRef } from '@wordpress/element'; import { Elements } from '@stripe/react-stripe-js'; // eslint-disable-next-line import/no-unresolved import { StoreNotice } from '@woocommerce/blocks-checkout'; @@ -20,6 +20,7 @@ import { getPaymentMethodTypes } from 'wcpay/checkout/utils/upe'; const PaymentElements = ( { api, ...props } ) => { const stripeForUPE = useStripeForUPE( api, props.paymentMethodId ); + const containerRef = useRef( null ); const [ errorMessage, setErrorMessage ] = useState( null ); const [ @@ -29,7 +30,8 @@ const PaymentElements = ( { api, ...props } ) => { const [ appearance, setAppearance ] = useState( getUPEConfig( 'wcBlocksUPEAppearance' ) ); - const [ fontRules ] = useState( getFontRulesFromPage() ); + const [ fontRules, setFontRules ] = useState( [] ); + const [ fingerprint, fingerprintErrorMessage ] = useFingerprint(); const amount = Number( getUPEConfig( 'cartTotal' ) ); const currency = getUPEConfig( 'currency' ).toLowerCase(); @@ -37,8 +39,18 @@ const PaymentElements = ( { api, ...props } ) => { useEffect( () => { async function generateUPEAppearance() { + if ( ! containerRef.current ) { + return; + } + setFontRules( + getFontRulesFromPage( containerRef.current.ownerDocument ) + ); // Generate UPE input styles. - let upeAppearance = getAppearance( 'blocks_checkout', false ); + let upeAppearance = getAppearance( + 'blocks_checkout', + false, + containerRef.current.ownerDocument + ); upeAppearance = await api.saveUPEAppearance( upeAppearance, 'blocks_checkout' @@ -61,46 +73,48 @@ const PaymentElements = ( { api, ...props } ) => { props.paymentMethodId, ] ); - if ( ! stripeForUPE ) { - return ; - } - return ( - - + - { paymentProcessorLoadErrorMessage?.error?.message && ( -
- - - { - paymentProcessorLoadErrorMessage.error - .message - } - - -
- ) } - -
-
+ + { paymentProcessorLoadErrorMessage?.error?.message && ( +
+ + + { + paymentProcessorLoadErrorMessage.error + .message + } + + +
+ ) } + +
+ +
+ ); }; diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index ecc339b0a32..7de075a51de 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -15,7 +15,7 @@ import { useStripeForUPE } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'wcpay/utils/checkout'; import { __ } from '@wordpress/i18n'; import './style.scss'; -import { useEffect, useMemo, useState } from '@wordpress/element'; +import { useEffect, useState, useRef } from '@wordpress/element'; import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; const paymentMethods = [ @@ -78,6 +78,7 @@ const PaymentMethodMessageWrapper = ( { }; export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { + const containerRef = useRef( null ); const cartData = wp.data.select( 'wc/store/cart' ).getCartData(); const isTestMode = getUPEConfig( 'testMode' ); const [ appearance, setAppearance ] = useState( @@ -88,7 +89,7 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { getUPEConfig( 'wcBlocksUPEAppearanceTheme' ) ); - const fontRules = useMemo( () => getFontRulesFromPage(), [] ); + const [ fontRules, setFontRules ] = useState( [] ); // Stripe expects the amount to be sent as the minor unit of 2 digits. const amount = parseInt( @@ -107,8 +108,18 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { useEffect( () => { async function generateUPEAppearance() { + if ( ! containerRef.current ) { + return; + } + setFontRules( + getFontRulesFromPage( containerRef.current.ownerDocument ) + ); // Generate UPE input styles. - let upeAppearance = getAppearance( 'blocks_checkout', false ); + let upeAppearance = getAppearance( + 'blocks_checkout', + false, + containerRef.current.ownerDocument + ); upeAppearance = await api.saveUPEAppearance( upeAppearance, 'blocks_checkout' @@ -130,7 +141,7 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { return ( <> -
+
{ title } { isTestMode && ( diff --git a/client/checkout/upe-styles/index.js b/client/checkout/upe-styles/index.js index 502cf370382..440c55fc5c6 100644 --- a/client/checkout/upe-styles/index.js +++ b/client/checkout/upe-styles/index.js @@ -50,7 +50,7 @@ export const appearanceSelectors = { pmmeRelativeTextSizeSelector: '.wc_payment_method > label', }, blocksCheckout: { - appendTarget: '#contact-fields', + appendTarget: '.wc-block-checkout__contact-fields', upeThemeInputSelector: '.wc-block-components-text-input #email', upeThemeLabelSelector: '.wc-block-components-text-input label', upeThemeTextSelectors: [ @@ -176,16 +176,17 @@ export const appearanceSelectors = { * Update selectors to use alternate if not present on DOM. * * @param {Object} selectors Object of selectors for updation. + * @param {Object} scope The document scope to search in. * * @return {Object} Updated selectors. */ - updateSelectors: function ( selectors ) { + updateSelectors: function ( selectors, scope ) { if ( selectors.hasOwnProperty( 'alternateSelectors' ) ) { Object.entries( selectors.alternateSelectors ).forEach( ( altSelector ) => { const [ key, value ] = altSelector; - if ( ! document.querySelector( selectors[ key ] ) ) { + if ( ! scope.querySelector( selectors[ key ] ) ) { selectors[ key ] = value; } } @@ -201,10 +202,11 @@ export const appearanceSelectors = { * Returns selectors based on checkout type. * * @param {boolean} elementsLocation The location of the elements. + * @param {Object} scope The document scope to search in. * * @return {Object} Selectors for checkout type specified. */ - getSelectors: function ( elementsLocation ) { + getSelectors: function ( elementsLocation, scope ) { let appearanceSelector = this.blocksCheckout; switch ( elementsLocation ) { @@ -230,7 +232,7 @@ export const appearanceSelectors = { return { ...this.default, - ...this.updateSelectors( appearanceSelector ), + ...this.updateSelectors( appearanceSelector, scope ), }; }, }; @@ -240,11 +242,12 @@ const hiddenElementsForUPE = { * Create hidden container for generating UPE styles. * * @param {string} elementID ID of element to create. + * @param {Object} scope The document scope to search in. * * @return {Object} Object of the created hidden container element. */ - getHiddenContainer: function ( elementID ) { - const hiddenDiv = document.createElement( 'div' ); + getHiddenContainer: function ( elementID, scope ) { + const hiddenDiv = scope.createElement( 'div' ); hiddenDiv.setAttribute( 'id', this.getIDFromSelector( elementID ) ); hiddenDiv.style.border = 0; hiddenDiv.style.clip = 'rect(0 0 0 0)'; @@ -261,12 +264,13 @@ const hiddenElementsForUPE = { * Create invalid element row for generating UPE styles. * * @param {string} elementType Type of element to create. - * @param {Array} classes Array of classes to be added to the element. Default: empty array. + * @param {Array} classes Array of classes to be added to the element. Default: empty array. + * @param {Object} scope The document scope to search in. * * @return {Object} Object of the created invalid row element. */ - createRow: function ( elementType, classes = [] ) { - const newRow = document.createElement( elementType ); + createRow: function ( elementType, classes = [], scope ) { + const newRow = scope.createElement( elementType ); if ( classes.length ) { newRow.classList.add( ...classes ); } @@ -276,12 +280,18 @@ const hiddenElementsForUPE = { /** * Append elements to target container. * - * @param {Object} appendTarget Element object where clone should be appended. + * @param {Object} appendTarget Element object where clone should be appended. * @param {string} elementToClone Selector of the element to be cloned. - * @param {string} newElementID Selector for the cloned element. + * @param {string} newElementID Selector for the cloned element. + * @param {Object} scope The document scope to search in. */ - appendClone: function ( appendTarget, elementToClone, newElementID ) { - const cloneTarget = document.querySelector( elementToClone ); + appendClone: function ( + appendTarget, + elementToClone, + newElementID, + scope + ) { + const cloneTarget = scope.querySelector( elementToClone ); if ( cloneTarget ) { const clone = cloneTarget.cloneNode( true ); clone.id = this.getIDFromSelector( newElementID ); @@ -309,11 +319,12 @@ const hiddenElementsForUPE = { * Initialize hidden fields to generate UPE styles. * * @param {boolean} elementsLocation The location of the elements. + * @param {Object} scope The scope of the elements. */ - init: function ( elementsLocation ) { + init: function ( elementsLocation, scope ) { const selectors = appearanceSelectors.getSelectors( elementsLocation ), - appendTarget = document.querySelector( selectors.appendTarget ), - elementToClone = document.querySelector( + appendTarget = scope.querySelector( selectors.appendTarget ), + elementToClone = scope.querySelector( selectors.upeThemeInputSelector ); @@ -323,70 +334,77 @@ const hiddenElementsForUPE = { } // Remove hidden container is already present on DOM. - if ( document.querySelector( selectors.hiddenContainer ) ) { - this.cleanup(); + if ( scope.querySelector( selectors.hiddenContainer ) ) { + this.cleanup( scope ); } // Create hidden container & append to target. const hiddenContainer = this.getHiddenContainer( - selectors.hiddenContainer + selectors.hiddenContainer, + scope ); appendTarget.appendChild( hiddenContainer ); // Create hidden valid row & append to hidden container. const hiddenValidRow = this.createRow( selectors.rowElement, - selectors.validClasses + selectors.validClasses, + scope ); hiddenContainer.appendChild( hiddenValidRow ); // Create hidden invalid row & append to hidden container. const hiddenInvalidRow = this.createRow( selectors.rowElement, - selectors.invalidClasses + selectors.invalidClasses, + scope ); hiddenContainer.appendChild( hiddenInvalidRow ); - // Clone & append target input to hidden valid row. + // Clone & append target input to hidden valid row. this.appendClone( hiddenValidRow, selectors.upeThemeInputSelector, - selectors.hiddenInput + selectors.hiddenInput, + scope ); // Clone & append target label to hidden valid row. this.appendClone( hiddenValidRow, selectors.upeThemeLabelSelector, - selectors.hiddenValidActiveLabel + selectors.hiddenValidActiveLabel, + scope ); - // Clone & append target input to hidden invalid row. + // Clone & append target input to hidden invalid row. this.appendClone( hiddenInvalidRow, selectors.upeThemeInputSelector, - selectors.hiddenInvalidInput + selectors.hiddenInvalidInput, + scope ); // Clone & append target label to hidden invalid row. this.appendClone( hiddenInvalidRow, selectors.upeThemeLabelSelector, - selectors.hiddenInvalidInput + selectors.hiddenInvalidInput, + scope ); // Remove transitions & focus on hidden element. - const wcpayHiddenInput = document.querySelector( - selectors.hiddenInput - ); + const wcpayHiddenInput = scope.querySelector( selectors.hiddenInput ); wcpayHiddenInput.style.transition = 'none'; }, /** - * Remove hidden container from DROM. + * Remove hidden container from DOM. + * + * @param {Object} scope The scope of the elements. */ - cleanup: function () { - const element = document.querySelector( + cleanup: function ( scope ) { + const element = scope.querySelector( appearanceSelectors.default.hiddenContainer ); if ( element ) { @@ -398,17 +416,20 @@ const hiddenElementsForUPE = { export const getFieldStyles = ( selector, upeElement, - backgroundColor = null + backgroundColor = null, + scope ) => { - if ( ! document.querySelector( selector ) ) { + if ( ! scope.querySelector( selector ) ) { return {}; } + const windowObject = scope.defaultView || window; + const validProperties = upeRestrictedProperties[ upeElement ]; - const elem = document.querySelector( selector ); + const elem = scope.querySelector( selector ); - const styles = window.getComputedStyle( elem ); + const styles = windowObject.getComputedStyle( elem ); const filteredStyles = {}; for ( let i = 0; i < styles.length; i++ ) { @@ -455,9 +476,9 @@ export const getFieldStyles = ( return filteredStyles; }; -export const getFontRulesFromPage = () => { +export const getFontRulesFromPage = ( scope = document ) => { const fontRules = [], - sheets = document.styleSheets, + sheets = scope.styleSheets, fontDomains = [ 'fonts.googleapis.com', 'fonts.gstatic.com', @@ -485,13 +506,15 @@ export const getFontRulesFromPage = () => { * @param {string} selector Selector of the element to be checked. * @param {string} fontSize Pre-computed font size. * @param {number} percentage Percentage (0-1) to be used relative to the font size of the target element. + * @param {Object} scope The scope of the elements. * * @return {string} Font size of the element. */ function ensureFontSizeSmallerThan( selector, fontSize, - percentage = PMME_RELATIVE_TEXT_SIZE + percentage = PMME_RELATIVE_TEXT_SIZE, + scope ) { const fontSizeNumber = parseFloat( fontSize ); @@ -500,7 +523,7 @@ function ensureFontSizeSmallerThan( } // If the element is not found, return the font size number multiplied by the percentage. - const elem = document.querySelector( selector ); + const elem = scope.querySelector( selector ); if ( ! elem ) { return `${ fontSizeNumber * percentage }px`; } @@ -520,21 +543,37 @@ function ensureFontSizeSmallerThan( return `${ fontSizeNumber }px`; } -export const getAppearance = ( elementsLocation, forWooPay = false ) => { - const selectors = appearanceSelectors.getSelectors( elementsLocation ); +export const getAppearance = ( + elementsLocation, + forWooPay = false, + scope = document +) => { + const selectors = appearanceSelectors.getSelectors( + elementsLocation, + scope + ); // Add hidden fields to DOM for generating styles. - hiddenElementsForUPE.init( elementsLocation ); + hiddenElementsForUPE.init( elementsLocation, scope ); - const inputRules = getFieldStyles( selectors.hiddenInput, '.Input' ); + const inputRules = getFieldStyles( + selectors.hiddenInput, + '.Input', + null, + scope + ); const inputInvalidRules = getFieldStyles( selectors.hiddenInvalidInput, - '.Input' + '.Input', + null, + scope ); const labelRules = getFieldStyles( selectors.upeThemeLabelSelector, - '.Label' + '.Label', + null, + scope ); const labelRestingRules = { @@ -543,13 +582,22 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { const paragraphRules = getFieldStyles( selectors.upeThemeTextSelectors, - '.Text' + '.Text', + null, + scope ); - const tabRules = getFieldStyles( selectors.upeThemeInputSelector, '.Tab' ); + const tabRules = getFieldStyles( + selectors.upeThemeInputSelector, + '.Tab', + null, + scope + ); const selectedTabRules = getFieldStyles( selectors.hiddenInput, - '.Tab--selected' + '.Tab--selected', + null, + scope ); const tabHoverRules = generateHoverRules( tabRules ); @@ -560,24 +608,57 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { color: selectedTabRules.color, }; - const backgroundColor = getBackgroundColor( selectors.backgroundSelectors ); - const headingRules = getFieldStyles( selectors.headingSelectors, '.Label' ); + const backgroundColor = getBackgroundColor( + selectors.backgroundSelectors, + scope + ); + const headingRules = getFieldStyles( + selectors.headingSelectors, + '.Label', + null, + scope + ); const blockRules = getFieldStyles( selectors.upeThemeLabelSelector, '.Block', - backgroundColor + backgroundColor, + scope + ); + const buttonRules = getFieldStyles( + selectors.buttonSelectors, + '.Input', + null, + scope + ); + const linkRules = getFieldStyles( + selectors.linkSelectors, + '.Label', + null, + scope ); - const buttonRules = getFieldStyles( selectors.buttonSelectors, '.Input' ); - const linkRules = getFieldStyles( selectors.linkSelectors, '.Label' ); const containerRules = getFieldStyles( selectors.containerSelectors, - '.Container' + '.Container', + null, + scope + ); + const headerRules = getFieldStyles( + selectors.headerSelectors, + '.Header', + null, + scope + ); + const footerRules = getFieldStyles( + selectors.footerSelectors, + '.Footer', + null, + scope ); - const headerRules = getFieldStyles( selectors.headerSelectors, '.Header' ); - const footerRules = getFieldStyles( selectors.footerSelectors, '.Footer' ); const footerLinkRules = getFieldStyles( selectors.footerLink, - '.Footer--link' + '.Footer--link', + null, + scope ); const globalRules = { colorBackground: backgroundColor, @@ -589,7 +670,9 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { if ( selectors.pmmeRelativeTextSizeSelector && globalRules.fontSizeBase ) { globalRules.fontSizeBase = ensureFontSizeSmallerThan( selectors.pmmeRelativeTextSizeSelector, - paragraphRules.fontSize + paragraphRules.fontSize, + PMME_RELATIVE_TEXT_SIZE, + scope ); } @@ -623,7 +706,9 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { appearance, getFieldStyles( selectors.hiddenValidActiveLabel, - '.Label--floating' + '.Label--floating', + null, + scope ) ); } @@ -642,6 +727,6 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { } // Remove hidden fields from DOM. - hiddenElementsForUPE.cleanup(); + hiddenElementsForUPE.cleanup( scope ); return appearance; }; diff --git a/client/checkout/upe-styles/test/index.js b/client/checkout/upe-styles/test/index.js index 4af3358484c..3df8e81ba1f 100644 --- a/client/checkout/upe-styles/test/index.js +++ b/client/checkout/upe-styles/test/index.js @@ -32,16 +32,18 @@ describe( 'Getting styles for automated theming', () => { }; test( 'getFieldStyles returns correct styles for inputs', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return mockElement; - } ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( () => { - return mockCSStyleDeclaration; - } ); + const scope = { + querySelector: jest.fn( () => mockElement ), + defaultView: { + getComputedStyle: jest.fn( () => mockCSStyleDeclaration ), + }, + }; const fieldStyles = upeStyles.getFieldStyles( '.woocommerce-checkout .form-row input', - '.Input' + '.Input', + null, + scope ); expect( fieldStyles ).toEqual( { backgroundColor: 'rgba(0, 0, 0, 0)', @@ -55,13 +57,15 @@ describe( 'Getting styles for automated theming', () => { } ); test( 'getFieldStyles returns empty object if it can not find the element', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return undefined; - } ); + const scope = { + querySelector: jest.fn( () => undefined ), + }; const fieldStyles = upeStyles.getFieldStyles( '.i-do-not-exist', - '.Input' + '.Input', + null, + scope ); expect( fieldStyles ).toEqual( {} ); } ); @@ -103,25 +107,31 @@ describe( 'Getting styles for automated theming', () => { }, 1: { href: null }, }; - jest.spyOn( document, 'styleSheets', 'get' ).mockReturnValue( - mockStyleSheets - ); + const scope = { + styleSheets: { + get: jest.fn( () => mockStyleSheets ), + }, + }; - const fontRules = upeStyles.getFontRulesFromPage(); + const fontRules = upeStyles.getFontRulesFromPage( scope ); expect( fontRules ).toEqual( [] ); } ); test( 'getAppearance returns the object with filtered CSS rules for UPE theming', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return mockElement; - } ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( () => { - return mockCSStyleDeclaration; - } ); + const scope = { + querySelector: jest.fn( () => mockElement ), + createElement: jest.fn( ( htmlTag ) => + document.createElement( htmlTag ) + ), + defaultView: { + getComputedStyle: jest.fn( () => mockCSStyleDeclaration ), + }, + }; const appearance = upeStyles.getAppearance( 'shortcode_checkout', - true + true, + scope ); expect( appearance ).toEqual( { variables: { @@ -285,23 +295,24 @@ describe( 'Getting styles for automated theming', () => { ], }, ].forEach( ( { elementsLocation, expectedSelectors } ) => { - afterEach( () => { - document.querySelector.mockClear(); - } ); - describe( `when elementsLocation is ${ elementsLocation }`, () => { test( 'getAppearance uses the correct appearanceSelectors based on the elementsLocation', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( - () => mockElement - ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( - () => mockCSStyleDeclaration - ); + const scope = { + querySelector: jest.fn( () => mockElement ), + createElement: jest.fn( ( htmlTag ) => + document.createElement( htmlTag ) + ), + defaultView: { + getComputedStyle: jest.fn( + () => mockCSStyleDeclaration + ), + }, + }; - upeStyles.getAppearance( elementsLocation ); + upeStyles.getAppearance( elementsLocation, false, scope ); expectedSelectors.forEach( ( selector ) => { - expect( document.querySelector ).toHaveBeenCalledWith( + expect( scope.querySelector ).toHaveBeenCalledWith( selector ); } ); diff --git a/client/checkout/upe-styles/utils.js b/client/checkout/upe-styles/utils.js index 7546a899653..0f02995b5fb 100644 --- a/client/checkout/upe-styles/utils.js +++ b/client/checkout/upe-styles/utils.js @@ -105,21 +105,25 @@ export const dashedToCamelCase = ( string ) => { /** * Searches through array of CSS selectors and returns first visible background color. * - * @param {Array} selectors List of CSS selectors to check. + * @param {Array} selectors List of CSS selectors to check. + * @param {Object} scope The document scope to search in. * @return {string} CSS color value. */ -export const getBackgroundColor = ( selectors ) => { +export const getBackgroundColor = ( selectors, scope = document ) => { const defaultColor = '#ffffff'; let color = null; let i = 0; while ( ! color && i < selectors.length ) { - const element = document.querySelector( selectors[ i ] ); + const element = scope.querySelector( selectors[ i ] ); if ( ! element ) { i++; continue; } - const bgColor = window.getComputedStyle( element ).backgroundColor; + const windowObject = scope.defaultView || window; + + const bgColor = windowObject.getComputedStyle( element ) + .backgroundColor; // If backgroundColor property present and alpha > 0. if ( bgColor && tinycolor( bgColor ).getAlpha() > 0 ) { color = bgColor; diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js index 7665524e08d..f6afe00fa8d 100644 --- a/tests/js/jest.config.js +++ b/tests/js/jest.config.js @@ -45,6 +45,14 @@ module.exports = { '/docker/', '/tests/e2e', ], + watchPathIgnorePatterns: [ + '/node_modules/', + '/vendor/', + '/.*/build/', + '/.*/build-module/', + '/docker/', + '/tests/e2e', + ], transform: { ...tsjPreset.transform, '^.+\\.(jpg|svg|png|gif)(\\?.*)?$': '/tests/js/fileMock.js',