rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 deletions

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import type { BlockAttributes } from '@wordpress/blocks';
export const BlockSettings = ( {
attributes,
setAttributes,
}: {
attributes: BlockAttributes;
setAttributes: ( attrs: BlockAttributes ) => void;
} ) => {
const { hasDarkControls } = attributes;
return (
<InspectorControls>
<PanelBody title={ __( 'Style', 'woo-gutenberg-products-block' ) }>
<ToggleControl
label={ __(
'Dark mode inputs',
'woo-gutenberg-products-block'
) }
help={ __(
'Inputs styled specifically for use on dark background colors.',
'woo-gutenberg-products-block'
) }
checked={ hasDarkControls }
onChange={ () =>
setAttributes( {
hasDarkControls: ! hasDarkControls,
} )
}
/>
</PanelBody>
</InspectorControls>
);
};

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { getBlockTypes } from '@wordpress/blocks';
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { CART_STORE_KEY } from '@woocommerce/block-data';
import { select } from '@wordpress/data';
// List of core block types to allow in inner block areas.
const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ];
/**
* Gets a list of allowed blocks types under a specific parent block type.
*/
export const getAllowedBlocks = ( block: string ): string[] => {
const additionalCartCheckoutInnerBlockTypes = applyCheckoutFilter( {
filterName: 'additionalCartCheckoutInnerBlockTypes',
defaultValue: [],
extensions: select( CART_STORE_KEY ).getCartData().extensions,
arg: { block },
validation: ( value ) => {
if (
Array.isArray( value ) &&
value.every( ( item ) => typeof item === 'string' )
) {
return true;
}
throw new Error(
'allowedBlockTypes filters must return an array of strings.'
);
},
} );
// Convert to set here so that we remove duplicated block types.
return Array.from(
new Set( [
...getBlockTypes()
.filter( ( blockType ) =>
( blockType?.parent || [] ).includes( block )
)
.map( ( { name } ) => name ),
...coreBlockTypes,
...additionalCartCheckoutInnerBlockTypes,
] )
);
};

View File

@@ -0,0 +1,177 @@
/**
* HACKS
*
* This file contains functionality to "lock" blocks i.e. to prevent blocks being moved or deleted. This needs to be
* kept in place until native support for locking is available in WordPress (estimated WordPress 5.9).
*/
/**
* @todo Remove custom block locking (requires native WordPress support)
*/
/**
* External dependencies
*/
import {
useBlockProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { isTextField } from '@wordpress/dom';
import { subscribe, select as _select } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { BACKSPACE, DELETE } from '@wordpress/keycodes';
import { hasFilter } from '@wordpress/hooks';
import { getBlockType } from '@wordpress/blocks';
import type { MutableRefObject } from 'react';
/**
* Toggle class on body.
*
* @param {string} className CSS Class name.
* @param {boolean} add True to add, false to remove.
*/
const toggleBodyClass = ( className: string, add = true ) => {
if ( add ) {
window.document.body.classList.add( className );
} else {
window.document.body.classList.remove( className );
}
};
/**
* addClassToBody
*
* This components watches the current selected block and adds a class name to the body if that block is locked. If the
* current block is not locked, it removes the class name. The appended body class is used to hide UI elements to prevent
* the block from being deleted.
*
* We use a component so we can react to changes in the store.
*/
export const addClassToBody = (): void => {
if ( ! hasFilter( 'blocks.registerBlockType', 'core/lock/addAttribute' ) ) {
subscribe( () => {
const blockEditorSelect = _select( blockEditorStore );
if ( ! blockEditorSelect ) {
return;
}
const selectedBlock = blockEditorSelect.getSelectedBlock();
if ( ! selectedBlock ) {
return;
}
toggleBodyClass(
'wc-lock-selected-block--remove',
!! selectedBlock?.attributes?.lock?.remove
);
toggleBodyClass(
'wc-lock-selected-block--move',
!! selectedBlock?.attributes?.lock?.move
);
} );
}
};
const isBlockLocked = ( clientId: string ): boolean => {
if ( ! clientId ) {
return false;
}
const { getBlock } = _select( blockEditorStore );
const block = getBlock( clientId );
// If lock.remove is defined at the block instance (not using the default value)
// Then we use it.
if ( typeof block?.attributes?.lock?.remove === 'boolean' ) {
return block.attributes.lock.remove;
}
// If we don't have lock on the block instance, we check the type
const blockType = getBlockType( block.name );
if ( typeof blockType?.attributes?.lock?.default?.remove === 'boolean' ) {
return blockType?.attributes?.lock?.default?.remove;
}
// If nothing is defined, return false
return false;
};
/**
* This is a hook we use in conjunction with useBlockProps. Its goal is to check if of the block's children is locked and being deleted.
* It will stop the keydown event from propagating to stop it from being deleted via the keyboard.
*
*/
const useLockedChildren = ( {
ref,
}: {
ref: MutableRefObject< HTMLElement | undefined >;
} ): void => {
const lockInCore = hasFilter(
'blocks.registerBlockType',
'core/lock/addAttribute'
);
const node = ref.current;
return useEffect( () => {
if ( ! node || lockInCore ) {
return;
}
function onKeyDown( event: KeyboardEvent ) {
const { keyCode, target } = event;
if ( ! ( target instanceof HTMLElement ) ) {
return;
}
// We're not trying to delete something here.
if ( keyCode !== BACKSPACE && keyCode !== DELETE ) {
return;
}
// We're in a field, so we should let text be deleted.
if ( isTextField( target ) ) {
return;
}
// Typecast to fix issue with isTextField.
const targetNode = target as HTMLElement;
// Our target isn't a block.
if ( targetNode.dataset.block === undefined ) {
return;
}
const clientId = targetNode.dataset.block;
const isLocked = isBlockLocked( clientId );
// Prevent the keyboard event from propogating if it supports locking.
if ( isLocked ) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
}
node.addEventListener( 'keydown', onKeyDown, {
capture: true,
passive: false,
} );
return () => {
node.removeEventListener( 'keydown', onKeyDown, {
capture: true,
} );
};
}, [ node, lockInCore ] );
};
/**
* This hook is a light wrapper to useBlockProps, it wraps that hook plus useLockBlock to pass data between them.
*/
export const useBlockPropsWithLocking = (
props: Record< string, unknown > = {}
): Record< string, unknown > => {
const ref = useRef< HTMLElement >();
const blockProps = useBlockProps( { ref, ...props } );
useLockedChildren( {
ref,
} );
return blockProps;
};

View File

@@ -0,0 +1,24 @@
const expressIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="#1E1E1E"
strokeLinejoin="round"
strokeWidth="1.5"
d="M18.25 12a6.25 6.25 0 1 1-12.5 0 6.25 6.25 0 0 1 12.5 0Z"
/>
<path fill="#1E1E1E" d="M10 3h4v3h-4z" />
<rect width="1.5" height="5" x="11.25" y="8" fill="#1E1E1E" rx=".75" />
<path
fill="#1E1E1E"
d="m15.7 4.816 1.66 1.078-1.114 1.718-1.661-1.078z"
/>
</svg>
);
export default expressIcon;

View File

@@ -0,0 +1,5 @@
export * from './hacks';
export * from './use-forced-layout';
export * from './editor-utils';
export * from './sidebar-notices';
export * from './block-settings';

View File

@@ -0,0 +1,175 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useExpressPaymentMethods,
usePaymentMethodInterface,
} from '@woocommerce/base-context/hooks';
import {
cloneElement,
isValidElement,
useCallback,
useRef,
} from '@wordpress/element';
import { useEditorContext } from '@woocommerce/base-context';
import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import PaymentMethodErrorBoundary from './payment-method-error-boundary';
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../../data/payment/constants';
const ExpressPaymentMethods = () => {
const { isEditor } = useEditorContext();
const { activePaymentMethod, paymentMethodData } = useSelect(
( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
paymentMethodData: store.getPaymentMethodData(),
};
}
);
const {
__internalSetActivePaymentMethod,
__internalSetExpressPaymentStarted,
__internalSetPaymentIdle,
__internalSetPaymentError,
__internalSetPaymentMethodData,
__internalSetExpressPaymentError,
} = useDispatch( PAYMENT_STORE_KEY );
const { paymentMethods } = useExpressPaymentMethods();
const paymentMethodInterface = usePaymentMethodInterface();
const previousActivePaymentMethod = useRef( activePaymentMethod );
const previousPaymentMethodData = useRef( paymentMethodData );
/**
* onExpressPaymentClick should be triggered when the express payment button is clicked.
*
* This will store the previous active payment method, set the express method as active, and set the payment status
* to started.
*/
const onExpressPaymentClick = useCallback(
( paymentMethodId ) => () => {
previousActivePaymentMethod.current = activePaymentMethod;
previousPaymentMethodData.current = paymentMethodData;
__internalSetExpressPaymentStarted();
__internalSetActivePaymentMethod( paymentMethodId );
},
[
activePaymentMethod,
paymentMethodData,
__internalSetActivePaymentMethod,
__internalSetExpressPaymentStarted,
]
);
/**
* onExpressPaymentClose should be triggered when the express payment process is cancelled or closed.
*
* This restores the active method and returns the state to pristine.
*/
const onExpressPaymentClose = useCallback( () => {
__internalSetPaymentIdle();
__internalSetActivePaymentMethod(
previousActivePaymentMethod.current,
previousPaymentMethodData.current
);
}, [ __internalSetActivePaymentMethod, __internalSetPaymentIdle ] );
/**
* onExpressPaymentError should be triggered when the express payment process errors.
*
* This shows an error message then restores the active method and returns the state to pristine.
*/
const onExpressPaymentError = useCallback(
( errorMessage ) => {
__internalSetPaymentError();
__internalSetPaymentMethodData( errorMessage );
__internalSetExpressPaymentError( errorMessage );
__internalSetActivePaymentMethod(
previousActivePaymentMethod.current,
previousPaymentMethodData.current
);
},
[
__internalSetActivePaymentMethod,
__internalSetPaymentError,
__internalSetPaymentMethodData,
__internalSetExpressPaymentError,
]
);
/**
* Calling setExpressPaymentError directly is deprecated.
*/
const deprecatedSetExpressPaymentError = useCallback(
( errorMessage = '' ) => {
deprecated(
'Express Payment Methods should use the provided onError handler instead.',
{
alternative: 'onError',
plugin: 'woocommerce-gutenberg-products-block',
link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
}
);
if ( errorMessage ) {
onExpressPaymentError( errorMessage );
} else {
__internalSetExpressPaymentError( '' );
}
},
[ __internalSetExpressPaymentError, onExpressPaymentError ]
);
/**
* @todo Find a way to Memoize Express Payment Method Content
*
* Payment method content could potentially become a bottleneck if lots of logic is ran in the content component. It
* Currently re-renders excessively but is not easy to useMemo because paymentMethodInterface could become stale.
* paymentMethodInterface itself also updates on most renders.
*/
const entries = Object.entries( paymentMethods );
const content =
entries.length > 0 ? (
entries.map( ( [ id, paymentMethod ] ) => {
const expressPaymentMethod = isEditor
? paymentMethod.edit
: paymentMethod.content;
return isValidElement( expressPaymentMethod ) ? (
<li key={ id } id={ `express-payment-method-${ id }` }>
{ cloneElement( expressPaymentMethod, {
...paymentMethodInterface,
onClick: onExpressPaymentClick( id ),
onClose: onExpressPaymentClose,
onError: onExpressPaymentError,
setExpressPaymentError:
deprecatedSetExpressPaymentError,
} ) }
</li>
) : null;
} )
) : (
<li key="noneRegistered">
{ __(
'No registered Payment Methods',
'woocommerce'
) }
</li>
);
return (
<PaymentMethodErrorBoundary isEditor={ isEditor }>
<ul className="wc-block-components-express-payment__event-buttons">
{ content }
</ul>
</PaymentMethodErrorBoundary>
);
};
export default ExpressPaymentMethods;

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useExpressPaymentMethods } from '@woocommerce/base-context/hooks';
import { noticeContexts } from '@woocommerce/base-context';
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import ExpressPaymentMethods from '../express-payment-methods';
import './style.scss';
const CartExpressPayment = () => {
const { paymentMethods, isInitialized } = useExpressPaymentMethods();
const {
isCalculating,
isProcessing,
isAfterProcessing,
isBeforeProcessing,
isComplete,
hasError,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isCalculating: store.isCalculating(),
isProcessing: store.isProcessing(),
isAfterProcessing: store.isAfterProcessing(),
isBeforeProcessing: store.isBeforeProcessing(),
isComplete: store.isComplete(),
hasError: store.hasError(),
};
} );
const isExpressPaymentMethodActive = useSelect( ( select ) =>
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive()
);
if (
! isInitialized ||
( isInitialized && Object.keys( paymentMethods ).length === 0 )
) {
return null;
}
// Set loading state for express payment methods when payment or checkout is in progress.
const checkoutProcessing =
isProcessing ||
isAfterProcessing ||
isBeforeProcessing ||
( isComplete && ! hasError );
return (
<>
<LoadingMask
isLoading={
isCalculating ||
checkoutProcessing ||
isExpressPaymentMethodActive
}
>
<div className="wc-block-components-express-payment wc-block-components-express-payment--cart">
<div className="wc-block-components-express-payment__content">
<StoreNoticesContainer
context={ noticeContexts.EXPRESS_PAYMENTS }
/>
<ExpressPaymentMethods />
</div>
</div>
</LoadingMask>
<div className="wc-block-components-express-payment-continue-rule wc-block-components-express-payment-continue-rule--cart">
{ /* translators: Shown in the Cart block between the express payment methods and the Proceed to Checkout button */ }
{ __( 'Or', 'woocommerce' ) }
</div>
</>
);
};
export default CartExpressPayment;

View File

@@ -0,0 +1,113 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEditorContext, noticeContexts } from '@woocommerce/base-context';
import { Title, StoreNoticesContainer } from '@woocommerce/blocks-components';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import ExpressPaymentMethods from '../express-payment-methods';
import './style.scss';
const CheckoutExpressPayment = () => {
const {
isCalculating,
isProcessing,
isAfterProcessing,
isBeforeProcessing,
isComplete,
hasError,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isCalculating: store.isCalculating(),
isProcessing: store.isProcessing(),
isAfterProcessing: store.isAfterProcessing(),
isBeforeProcessing: store.isBeforeProcessing(),
isComplete: store.isComplete(),
hasError: store.hasError(),
};
} );
const {
availableExpressPaymentMethods,
expressPaymentMethodsInitialized,
isExpressPaymentMethodActive,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
availableExpressPaymentMethods:
store.getAvailableExpressPaymentMethods(),
expressPaymentMethodsInitialized:
store.expressPaymentMethodsInitialized(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
};
} );
const { isEditor } = useEditorContext();
if (
! expressPaymentMethodsInitialized ||
( expressPaymentMethodsInitialized &&
Object.keys( availableExpressPaymentMethods ).length === 0 )
) {
// Make sure errors are shown in the editor and for admins. For example,
// when a payment method fails to register.
if ( isEditor || CURRENT_USER_IS_ADMIN ) {
return (
<StoreNoticesContainer
context={ noticeContexts.EXPRESS_PAYMENTS }
/>
);
}
return null;
}
// Set loading state for express payment methods when payment or checkout is in progress.
const checkoutProcessing =
isProcessing ||
isAfterProcessing ||
isBeforeProcessing ||
( isComplete && ! hasError );
return (
<>
<LoadingMask
isLoading={
isCalculating ||
checkoutProcessing ||
isExpressPaymentMethodActive
}
>
<div className="wc-block-components-express-payment wc-block-components-express-payment--checkout">
<div className="wc-block-components-express-payment__title-container">
<Title
className="wc-block-components-express-payment__title"
headingLevel="2"
>
{ __(
'Express Checkout',
'woocommerce'
) }
</Title>
</div>
<div className="wc-block-components-express-payment__content">
<StoreNoticesContainer
context={ noticeContexts.EXPRESS_PAYMENTS }
/>
<ExpressPaymentMethods />
</div>
</div>
</LoadingMask>
<div className="wc-block-components-express-payment-continue-rule wc-block-components-express-payment-continue-rule--checkout">
{ __( 'Or continue below', 'woocommerce' ) }
</div>
</>
);
};
export default CheckoutExpressPayment;

View File

@@ -0,0 +1,2 @@
export { default as CartExpressPayment } from './cart-express-payment.js';
export { default as CheckoutExpressPayment } from './checkout-express-payment.js';

View File

@@ -0,0 +1,152 @@
$border-width: 1px;
.wc-block-components-express-payment {
margin: auto;
position: relative;
// nested class to avoid conflict with .editor-styles-wrapper ul
.wc-block-components-express-payment__event-buttons {
width: 100%;
padding: 0;
margin: 0;
overflow: hidden;
text-align: center;
> li {
margin: 0;
width: 100%;
> img {
width: 100%;
height: 48px;
}
}
}
}
.wc-block-components-express-payment--checkout {
/* stylelint-disable-next-line function-calc-no-unspaced-operator */
margin-top: calc($universal-border-radius * 3);
.wc-block-components-express-payment__event-buttons {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(33% - 10px), 1fr));
grid-gap: 10px;
@include breakpoint("<782px") {
grid-template-columns: 1fr;
}
}
.wc-block-components-express-payment__title-container {
display: flex;
flex-direction: row;
left: 0;
position: absolute;
right: 0;
top: -$universal-border-radius;
vertical-align: middle;
// Pseudo-elements used to show the border before and after the title.
&::before {
border-left: $border-width solid currentColor;
border-top: $border-width solid currentColor;
border-radius: $universal-border-radius 0 0 0;
content: "";
display: block;
height: $universal-border-radius - $border-width;
margin-right: $gap-small;
opacity: 0.3;
pointer-events: none;
width: #{$gap-large - $gap-small - $border-width * 2};
}
&::after {
border-right: $border-width solid currentColor;
border-top: $border-width solid currentColor;
border-radius: 0 $universal-border-radius 0 0;
content: "";
display: block;
height: $universal-border-radius - $border-width;
margin-left: $gap-small;
opacity: 0.3;
pointer-events: none;
flex-grow: 1;
}
}
.wc-block-components-express-payment__title {
flex-grow: 0;
transform: translateY(-50%);
}
.wc-block-components-express-payment__content {
@include with-translucent-border(0 $border-width $border-width);
padding: #{$gap-large - $universal-border-radius} $gap-large $gap-large;
&::after {
border-radius: 0 0 $universal-border-radius $universal-border-radius;
}
> p {
margin-bottom: em($gap);
}
}
}
.wc-block-components-express-payment--cart {
.wc-block-components-express-payment__event-buttons {
> li {
padding-bottom: $gap;
text-align: center;
width: 100%;
&:last-child {
padding-bottom: 0;
}
}
}
}
.wc-block-components-express-payment-continue-rule {
display: flex;
align-items: center;
text-align: center;
padding: 0 $gap-large;
margin: $gap-large 0;
&::before {
margin-right: 10px;
}
&::after {
margin-left: 10px;
}
&::before,
&::after {
content: " ";
flex: 1;
border-bottom: 1px solid;
opacity: 0.3;
}
}
.wc-block-components-express-payment-continue-rule--cart {
margin: $gap 0;
text-transform: uppercase;
}
.theme-twentynineteen {
.wc-block-components-express-payment__title::before {
display: none;
}
}
// For Twenty Twenty we need to increase specificity of the title.
.theme-twentytwenty {
.wc-block-components-express-payment .wc-block-components-express-payment__title {
padding-left: $gap-small;
padding-right: $gap-small;
}
}

View File

@@ -0,0 +1,4 @@
export { default as PaymentMethods } from './payment-methods';
export { default as ExpressPaymentMethods } from './express-payment-methods';
export { CartExpressPayment, CheckoutExpressPayment } from './express-payment';
export { default as SavedPaymentMethodOptions } from './saved-payment-method-options';

View File

@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
/**
* Internal dependencies
*/
import './style.scss';
/**
* Render content when no payment methods are found depending on context.
*/
const NoPaymentMethods = () => {
return (
<NoticeBanner
isDismissible={ false }
className="wc-block-checkout__no-payment-methods-notice"
status="error"
>
{ __(
'There are no payment methods available. This may be an error on our side. Please contact us if you need any help placing your order.',
'woocommerce'
) }
</NoticeBanner>
);
};
export default NoPaymentMethods;

View File

@@ -0,0 +1,3 @@
.components-notice.wc-block-checkout__no-payment-methods-notice {
margin: 0 0 $gap;
}

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEditorContext } from '@woocommerce/base-context';
import { CheckboxControl } from '@woocommerce/blocks-checkout';
import { useSelect, useDispatch } from '@wordpress/data';
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import PaymentMethodErrorBoundary from './payment-method-error-boundary';
/**
* Component used to render the contents of a payment method card.
*
* @param {Object} props Incoming props for the component.
* @param {boolean} props.showSaveOption Whether that payment method allows saving
* the data for future purchases.
* @param {Object} props.children Content of the payment method card.
*
* @return {*} The rendered component.
*/
interface PaymentMethodCardProps {
showSaveOption: boolean;
children: React.ReactNode;
}
const PaymentMethodCard = ( {
children,
showSaveOption,
}: PaymentMethodCardProps ) => {
const { isEditor } = useEditorContext();
const { shouldSavePaymentMethod, customerId } = useSelect( ( select ) => {
const paymentMethodStore = select( PAYMENT_STORE_KEY );
const checkoutStore = select( CHECKOUT_STORE_KEY );
return {
shouldSavePaymentMethod:
paymentMethodStore.getShouldSavePaymentMethod(),
customerId: checkoutStore.getCustomerId(),
};
} );
const { __internalSetShouldSavePaymentMethod } =
useDispatch( PAYMENT_STORE_KEY );
return (
<PaymentMethodErrorBoundary isEditor={ isEditor }>
{ children }
{ customerId > 0 && showSaveOption && (
<CheckboxControl
className="wc-block-components-payment-methods__save-card-info"
label={ __(
'Save payment information to my account for future purchases.',
'woo-gutenberg-products-block'
) }
checked={ shouldSavePaymentMethod }
onChange={ () =>
__internalSetShouldSavePaymentMethod(
! shouldSavePaymentMethod
)
}
/>
) }
</PaymentMethodErrorBoundary>
);
};
export default PaymentMethodCard;

View File

@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import { noticeContexts } from '@woocommerce/base-context';
import { NoticeType } from '@woocommerce/types';
interface PaymentMethodErrorBoundaryProps {
isEditor: boolean;
children: React.ReactNode;
}
const PaymentMethodErrorBoundary = ( {
isEditor,
children,
}: PaymentMethodErrorBoundaryProps ) => {
const [ errorMessage ] = useState( '' );
const [ hasError ] = useState( false );
if ( hasError ) {
let errorText = __(
'We are experiencing difficulties with this payment method. Please contact us for assistance.',
'woo-gutenberg-products-block'
);
if ( isEditor || CURRENT_USER_IS_ADMIN ) {
if ( errorMessage ) {
errorText = errorMessage;
} else {
errorText = __(
"There was an error with this payment method. Please verify it's configured correctly.",
'woo-gutenberg-products-block'
);
}
}
const notices: NoticeType[] = [
{
id: '0',
content: errorText,
isDismissible: false,
status: 'error',
},
];
return (
<StoreNoticesContainer
additionalNotices={ notices }
context={ noticeContexts.PAYMENTS }
/>
);
}
return <>{ children }</>;
};
export default PaymentMethodErrorBoundary;

View File

@@ -0,0 +1,108 @@
/**
* External dependencies
*/
import {
usePaymentMethodInterface,
useStoreEvents,
} from '@woocommerce/base-context/hooks';
import { cloneElement, useCallback } from '@wordpress/element';
import { useEditorContext } from '@woocommerce/base-context';
import classNames from 'classnames';
import { RadioControlAccordion } from '@woocommerce/blocks-components';
import { useDispatch, useSelect } from '@wordpress/data';
import { getPaymentMethods } from '@woocommerce/blocks-registry';
/**
* Internal dependencies
*/
import PaymentMethodCard from './payment-method-card';
import { noticeContexts } from '../../../base/context/event-emit';
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../../data/payment/constants';
/**
* Component used to render all non-saved payment method options.
*
* @return {*} The rendered component.
*/
const PaymentMethodOptions = () => {
const {
activeSavedToken,
activePaymentMethod,
isExpressPaymentMethodActive,
savedPaymentMethods,
availablePaymentMethods,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activeSavedToken: store.getActiveSavedToken(),
activePaymentMethod: store.getActivePaymentMethod(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
savedPaymentMethods: store.getSavedPaymentMethods(),
availablePaymentMethods: store.getAvailablePaymentMethods(),
};
} );
const { __internalSetActivePaymentMethod } =
useDispatch( PAYMENT_STORE_KEY );
const paymentMethods = getPaymentMethods();
const { ...paymentMethodInterface } = usePaymentMethodInterface();
const { removeNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const { isEditor } = useEditorContext();
const options = Object.keys( availablePaymentMethods ).map( ( name ) => {
const { edit, content, label, supports } = paymentMethods[ name ];
const component = isEditor ? edit : content;
return {
value: name,
label:
typeof label === 'string'
? label
: cloneElement( label, {
components: paymentMethodInterface.components,
} ),
name: `wc-saved-payment-method-token-${ name }`,
content: (
<PaymentMethodCard showSaveOption={ supports.showSaveOption }>
{ cloneElement( component, {
__internalSetActivePaymentMethod,
...paymentMethodInterface,
} ) }
</PaymentMethodCard>
),
};
} );
const onChange = useCallback(
( value ) => {
__internalSetActivePaymentMethod( value );
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
dispatchCheckoutEvent( 'set-active-payment-method', {
value,
} );
},
[
dispatchCheckoutEvent,
removeNotice,
__internalSetActivePaymentMethod,
]
);
const isSinglePaymentMethod =
Object.keys( savedPaymentMethods ).length === 0 &&
Object.keys( paymentMethods ).length === 1;
const singleOptionClass = classNames( {
'disable-radio-control': isSinglePaymentMethod,
} );
return isExpressPaymentMethodActive ? null : (
<RadioControlAccordion
id={ 'wc-payment-method-options' }
className={ singleOptionClass }
selected={ activeSavedToken ? null : activePaymentMethod }
onChange={ onChange }
options={ options }
/>
);
};
export default PaymentMethodOptions;

View File

@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Label } from '@woocommerce/blocks-components';
import { useSelect } from '@wordpress/data';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import NoPaymentMethods from './no-payment-methods';
import PaymentMethodOptions from './payment-method-options';
import SavedPaymentMethodOptions from './saved-payment-method-options';
import './style.scss';
/**
* PaymentMethods component.
*
* @return {*} The rendered component.
*/
const PaymentMethods = () => {
const {
paymentMethodsInitialized,
availablePaymentMethods,
savedPaymentMethods,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
paymentMethodsInitialized: store.paymentMethodsInitialized(),
availablePaymentMethods: store.getAvailablePaymentMethods(),
savedPaymentMethods: store.getSavedPaymentMethods(),
};
} );
if (
paymentMethodsInitialized &&
Object.keys( availablePaymentMethods ).length === 0
) {
return <NoPaymentMethods />;
}
return (
<>
<SavedPaymentMethodOptions />
{ Object.keys( savedPaymentMethods ).length > 0 && (
<Label
label={ __(
'Use another payment method.',
'woocommerce'
) }
screenReaderLabel={ __(
'Other available payment methods',
'woocommerce'
) }
wrapperElement="p"
wrapperProps={ {
className: [
'wc-block-components-checkout-step__description wc-block-components-checkout-step__description-payments-aligned',
],
} }
/>
) }
<PaymentMethodOptions />
</>
);
};
export default PaymentMethods;

View File

@@ -0,0 +1,186 @@
/**
* External dependencies
*/
import { useMemo, cloneElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { noticeContexts } from '@woocommerce/base-context';
import {
RadioControl,
RadioControlOptionType,
} from '@woocommerce/blocks-components';
import {
usePaymentMethodInterface,
useStoreEvents,
} from '@woocommerce/base-context/hooks';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { getPaymentMethods } from '@woocommerce/blocks-registry';
import { isNull } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { getCanMakePaymentArg } from '../../../data/payment/utils/check-payment-methods';
import { CustomerPaymentMethodConfiguration } from '../../../data/payment/types';
/**
* Returns the option object for a cc or echeck saved payment method token.
*/
const getCcOrEcheckLabel = ( {
method,
expires,
}: {
method: CustomerPaymentMethodConfiguration;
expires: string;
} ): string => {
return sprintf(
/* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card, %3$s is referring to the expiry date. */
__(
'%1$s ending in %2$s (expires %3$s)',
'woo-gutenberg-products-block'
),
method.brand,
method.last4,
expires
);
};
/**
* Returns the option object for any non specific saved payment method.
*/
const getDefaultLabel = ( {
method,
}: {
method: CustomerPaymentMethodConfiguration;
} ): string => {
/* For saved payment methods with brand & last 4 */
if ( method.brand && method.last4 ) {
return sprintf(
/* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card. */
__( '%1$s ending in %2$s', 'woo-gutenberg-products-block' ),
method.brand,
method.last4
);
}
/* For saved payment methods without brand & last 4 */
return sprintf(
/* translators: %s is the name of the payment method gateway. */
__( 'Saved token for %s', 'woo-gutenberg-products-block' ),
method.gateway
);
};
const SavedPaymentMethodOptions = () => {
const { activeSavedToken, activePaymentMethod, savedPaymentMethods } =
useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activeSavedToken: store.getActiveSavedToken(),
activePaymentMethod: store.getActivePaymentMethod(),
savedPaymentMethods: store.getSavedPaymentMethods(),
};
} );
const { __internalSetActivePaymentMethod } =
useDispatch( PAYMENT_STORE_KEY );
const canMakePaymentArg = getCanMakePaymentArg();
const paymentMethods = getPaymentMethods();
const paymentMethodInterface = usePaymentMethodInterface();
const { removeNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const options = useMemo< RadioControlOptionType[] >( () => {
const types = Object.keys( savedPaymentMethods );
// Get individual payment methods from saved payment methods and put them into a unique array.
const individualPaymentGateways = new Set(
types.flatMap( ( type ) =>
savedPaymentMethods[ type ].map(
( paymentMethod ) => paymentMethod.method.gateway
)
)
);
const gatewaysThatCanMakePayment = Array.from(
individualPaymentGateways
).filter( ( method ) => {
return paymentMethods[ method ]?.canMakePayment(
canMakePaymentArg
);
} );
const mappedOptions = types.flatMap( ( type ) => {
const typeMethods = savedPaymentMethods[ type ];
return typeMethods.map( ( paymentMethod ) => {
const canMakePayment = gatewaysThatCanMakePayment.includes(
paymentMethod.method.gateway
);
if ( ! canMakePayment ) {
return void 0;
}
const isCC = type === 'cc' || type === 'echeck';
const paymentMethodSlug = paymentMethod.method.gateway;
return {
name: `wc-saved-payment-method-token-${ paymentMethodSlug }`,
label: isCC
? getCcOrEcheckLabel( paymentMethod )
: getDefaultLabel( paymentMethod ),
value: paymentMethod.tokenId.toString(),
onChange: ( token: string ) => {
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
__internalSetActivePaymentMethod( paymentMethodSlug, {
token,
payment_method: paymentMethodSlug,
[ savedTokenKey ]: token.toString(),
isSavedToken: true,
} );
removeNotice(
'wc-payment-error',
noticeContexts.PAYMENTS
);
dispatchCheckoutEvent( 'set-active-payment-method', {
paymentMethodSlug,
} );
},
};
} );
} );
return mappedOptions.filter(
( option ) => typeof option !== 'undefined'
) as RadioControlOptionType[];
}, [
savedPaymentMethods,
paymentMethods,
__internalSetActivePaymentMethod,
removeNotice,
dispatchCheckoutEvent,
canMakePaymentArg,
] );
const savedPaymentMethodHandler =
!! activeSavedToken &&
paymentMethods[ activePaymentMethod ] &&
typeof paymentMethods[ activePaymentMethod ]?.savedTokenComponent !==
'undefined' &&
! isNull( paymentMethods[ activePaymentMethod ].savedTokenComponent )
? cloneElement(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - we know for sure that the savedTokenComponent is not null or undefined at this point.
paymentMethods[ activePaymentMethod ].savedTokenComponent,
{ token: activeSavedToken, ...paymentMethodInterface }
)
: null;
return options.length > 0 ? (
<>
<RadioControl
id={ 'wc-payment-method-saved-tokens' }
selected={ activeSavedToken }
options={ options }
onChange={ () => void 0 }
/>
{ savedPaymentMethodHandler }
</>
) : null;
};
export default SavedPaymentMethodOptions;

View File

@@ -0,0 +1,265 @@
.wc-block-card-elements {
display: flex;
width: 100%;
.wc-block-components-validation-error {
position: static;
}
}
.wc-block-gateway-container {
position: relative;
margin-bottom: em($gap-large);
white-space: nowrap;
&.wc-card-number-element {
flex-basis: 15em;
flex-grow: 1;
// Currently, min() CSS function calls need to be wrapped with unquote.
min-width: string.unquote("min(15em, 60%)");
}
&.wc-card-expiry-element {
flex-basis: 7em;
margin-left: $gap-small;
min-width: string.unquote("min(7em, calc(24% - #{$gap-small}))");
}
&.wc-card-cvc-element {
flex-basis: 7em;
margin-left: $gap-small;
// Notice the min width ems value is smaller than flex-basis. That's because
// by default we want it to have the same width as `expiry-element`, but
// if available space is scarce, `cvc-element` should get smaller faster.
min-width: string.unquote("min(5em, calc(16% - #{$gap-small}))");
}
.wc-block-gateway-input {
@include font-size(regular);
line-height: 1.375; // =22px when font-size is 16px.
background-color: #fff;
padding: em($gap-small) 0 em($gap-small) $gap;
border-radius: $universal-border-radius;
border: 1px solid $input-border-gray;
width: 100%;
font-family: inherit;
margin: 0;
box-sizing: border-box;
height: 3em;
color: $input-text-active;
cursor: text;
&:focus {
background-color: #fff;
}
}
&:focus {
background-color: #fff;
}
label {
@include reset-color();
@include reset-typography();
@include font-size(regular);
line-height: 1.375; // =22px when font-size is 16px.
position: absolute;
transform: translateY(0.75em);
left: 0;
top: 0;
transform-origin: top left;
color: $gray-700;
transition: transform 200ms ease;
margin: 0 0 0 #{$gap + 1px};
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - #{$gap + $gap-smaller});
cursor: text;
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.wc-inline-card-element {
label {
// $gap is the padding of the input box, 1.5em the width of the card
// icon and $gap-smaller the space between the card
// icon and the label.
margin-left: calc(#{$gap + $gap-smaller} + 1.5em);
}
.wc-block-gateway-input.focused.empty,
.wc-block-gateway-input:not(.empty) {
+ label {
margin-left: $gap;
transform: translateY(#{$gap-smallest}) scale(0.75);
}
}
+ .wc-block-components-validation-error {
position: static;
margin-top: -$gap-large;
}
}
.wc-block-gateway-input.focused.empty,
.wc-block-gateway-input:not(.empty) {
padding: em($gap-large) 0 em($gap-smallest) $gap;
+ label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
}
.wc-block-gateway-input.has-error {
border-color: $alert-red;
&:focus {
outline-color: $alert-red;
}
}
.wc-block-gateway-input.has-error + label {
color: $alert-red;
}
}
// These elements have available space below, so we can display errors with a
// larger line height.
.is-medium,
.is-large {
.wc-card-expiry-element,
.wc-card-cvc-element {
.wc-block-components-validation-error > p {
line-height: 16px;
padding-top: 4px;
}
}
}
.is-mobile,
.is-small {
.wc-card-expiry-element,
.wc-card-cvc-element {
.wc-block-components-validation-error > p {
min-height: 28px;
}
}
}
.wc-block-components-checkout-payment-methods * {
pointer-events: all; // Overrides parent disabled component in editor context
}
.is-mobile,
.is-small {
.wc-block-card-elements {
flex-wrap: wrap;
}
.wc-block-gateway-container.wc-card-number-element {
flex-basis: 100%;
}
.wc-block-gateway-container.wc-card-expiry-element {
flex-basis: calc(50% - #{$gap-smaller});
margin-left: 0;
margin-right: $gap-smaller;
}
.wc-block-gateway-container.wc-card-cvc-element {
flex-basis: calc(50% - #{$gap-smaller});
margin-left: $gap-smaller;
}
}
.wc-block-checkout__payment-method {
.wc-block-components-radio-control__option {
padding-left: 56px;
&::after {
content: none;
}
.wc-block-components-radio-control__input {
left: 16px;
}
}
// We need to add the first-child and last-child pseudoclasses for specificity.
.wc-block-components-radio-control__option,
.wc-block-components-radio-control__option:first-child,
.wc-block-components-radio-control__option:last-child {
margin: 0;
padding-bottom: em($gap);
padding-top: em($gap);
}
.wc-block-components-radio-control__option-checked {
font-weight: bold;
}
.wc-block-components-radio-control-accordion-option,
.wc-block-components-radio-control__option {
@include with-translucent-border(1px 1px 0 1px);
}
.wc-block-components-radio-control-accordion-option:first-child::after {
border-top-left-radius: $universal-border-radius;
border-top-right-radius: $universal-border-radius;
}
.wc-block-components-radio-control-accordion-option:last-child::after {
border-bottom-left-radius: $universal-border-radius;
border-bottom-right-radius: $universal-border-radius;
}
.wc-block-components-radio-control__option:last-child::after,
.wc-block-components-radio-control-accordion-option:last-child::after {
border-width: 1px;
}
.wc-block-components-radio-control-accordion-option {
.wc-block-components-radio-control__option::after {
border-width: 0;
}
.wc-block-components-radio-control__label {
display: flex;
align-items: center;
justify-content: flex-start;
}
.wc-block-components-radio-control__label img {
height: 24px;
max-height: 24px;
object-fit: contain;
object-position: left;
}
}
.wc-block-components-radio-control.disable-radio-control {
.wc-block-components-radio-control__option {
padding-left: 16px;
}
.wc-block-components-radio-control__input {
display: none;
}
}
.wc-block-components-checkout-step__description-payments-aligned {
padding-top: 14px;
height: 28px;
}
}
.wc-block-components-radio-control-accordion-content {
padding: 0 $gap em($gap) $gap;
&:empty {
display: none;
}
}
.wc-block-checkout__order-notes {
.wc-block-components-checkout-step__content {
padding-bottom: 0;
}
}

View File

@@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { act, render, screen, waitFor } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
import * as wpDataFunctions from '@wordpress/data';
import { CART_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { default as fetchMock } from 'jest-fetch-mock';
import {
registerPaymentMethod,
__experimentalDeRegisterPaymentMethod,
} from '@woocommerce/blocks-registry';
import userEvent from '@testing-library/user-event';
import { dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import PaymentMethods from '../payment-methods';
jest.mock( '../saved-payment-method-options', () => ( { onChange } ) => {
return (
<>
<span>Saved payment method options</span>
<button onClick={ () => onChange( '0' ) }>Select saved</button>
</>
);
} );
jest.mock( '@woocommerce/blocks-components', () => {
const originalModule = jest.requireActual(
'@woocommerce/blocks-components'
);
return {
__esModule: true,
...originalModule,
RadioControlAccordion: ( { onChange } ) => (
<>
<span>Payment method options</span>
<button onClick={ () => onChange( 'credit-card' ) }>
Select new payment
</button>
</>
),
};
} );
const originalSelect = jest.requireActual( '@wordpress/data' ).select;
const selectMock = jest
.spyOn( wpDataFunctions, 'select' )
.mockImplementation( ( storeName ) => {
const originalStore = originalSelect( storeName );
if ( storeName === PAYMENT_STORE_KEY ) {
return {
...originalStore,
getState: () => {
const originalState = originalStore.getState();
return {
...originalState,
savedPaymentMethods: {},
availablePaymentMethods: {},
paymentMethodsInitialized: true,
};
},
};
}
return originalStore;
} );
const registerMockPaymentMethods = () => {
[ 'cod', 'credit-card' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
dispatch( PAYMENT_STORE_KEY ).__internalUpdateAvailablePaymentMethods();
};
const resetMockPaymentMethods = () => {
[ 'cod', 'credit-card' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
};
describe( 'PaymentMethods', () => {
beforeEach( () => {
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
wpDataFunctions
.dispatch( CART_STORE_KEY )
.invalidateResolutionForStore();
wpDataFunctions.dispatch( CART_STORE_KEY ).receiveCart( {
...previewCart,
payment_methods: [ 'cod', 'credit-card' ],
} );
} );
afterEach( () => {
fetchMock.resetMocks();
} );
test( 'should show no payment methods component when there are no payment methods', async () => {
render( <PaymentMethods /> );
await waitFor( () => {
const noPaymentMethods = screen.queryAllByText(
/no payment methods available/
);
// We might get more than one match because the `speak()` function
// creates an extra `div` with the notice contents used for a11y.
expect( noPaymentMethods.length ).toBeGreaterThanOrEqual( 1 );
// Reset the mock back to how it was because we don't need it anymore after this test.
selectMock.mockRestore();
} );
} );
test( 'selecting new payment method', async () => {
const ShowActivePaymentMethod = () => {
const { activePaymentMethod, activeSavedToken } =
wpDataFunctions.useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
activeSavedToken: store.getActiveSavedToken(),
};
} );
return (
<>
<div>
{ 'Active Payment Method: ' + activePaymentMethod }
</div>
<div>{ 'Active Saved Token: ' + activeSavedToken }</div>
</>
);
};
act( () => {
registerMockPaymentMethods();
} );
// Wait for the payment methods to finish loading before rendering.
await waitFor( () => {
expect(
wpDataFunctions
.select( PAYMENT_STORE_KEY )
.getActivePaymentMethod()
).toBe( 'cod' );
} );
render(
<>
<PaymentMethods />
<ShowActivePaymentMethod />
</>
);
await waitFor( () => {
const savedPaymentMethodOptions = screen.queryByText(
/Saved payment method options/
);
expect( savedPaymentMethodOptions ).not.toBeNull();
} );
await waitFor( () => {
const paymentMethodOptions = screen.queryByText(
/Payment method options/
);
expect( paymentMethodOptions ).not.toBeNull();
} );
await waitFor( () => {
const savedToken = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( savedToken ).toBeNull();
} );
userEvent.click( screen.getByText( 'Select new payment' ) );
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
act( () => resetMockPaymentMethods() );
} );
} );

View File

@@ -0,0 +1,138 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import * as wpData from '@wordpress/data';
/**
* Internal dependencies
*/
import SavedPaymentMethodOptions from '../saved-payment-method-options';
jest.mock( '@wordpress/data', () => ( {
__esModule: true,
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
} ) );
const mockedUseSelect = wpData.useSelect as jest.Mock;
// Mock use select so we can override it when wc/store/checkout is accessed, but return the original select function if any other store is accessed.
mockedUseSelect.mockImplementation(
jest.fn().mockImplementation( ( passedMapSelect ) => {
const mockedSelect = jest.fn().mockImplementation( ( storeName ) => {
if ( storeName === 'wc/store/payment' ) {
return {
...jest
.requireActual( '@wordpress/data' )
.select( storeName ),
getActiveSavedToken: () => 1,
getSavedPaymentMethods: () => {
return {
cc: [
{
tokenId: 1,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-test-payment-method',
last4: '1234',
},
},
{
tokenId: 2,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-test-payment-method',
last4: '2345',
},
},
{
tokenId: 3,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-first-false-second-test-payment-method',
last4: '3456',
},
},
],
};
},
};
}
return jest.requireActual( '@wordpress/data' ).select( storeName );
} );
return passedMapSelect( mockedSelect, {
dispatch: jest.requireActual( '@wordpress/data' ).dispatch,
} );
} )
);
describe( 'SavedPaymentMethodOptions', () => {
it( 'renders saved methods when a registered method exists', () => {
registerPaymentMethod( {
name: 'can-pay-true-test-payment-method',
label: 'Can Pay True Test Payment Method',
edit: <div>edit</div>,
ariaLabel: 'Can Pay True Test Payment Method',
canMakePayment: () => true,
content: <div>content</div>,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
} );
render( <SavedPaymentMethodOptions /> );
// First saved token for can-pay-true-test-payment-method.
expect(
screen.getByText( 'Visa ending in 1234 (expires 1/2099)' )
).toBeInTheDocument();
// Second saved token for can-pay-true-test-payment-method.
expect(
screen.getByText( 'Visa ending in 2345 (expires 1/2099)' )
).toBeInTheDocument();
// Third saved token for can-pay-false-test-payment-method - this should not show because the method is not registered.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).not.toBeInTheDocument();
} );
it( "does not show saved methods when the method's canPay function returns false", () => {
registerPaymentMethod( {
name: 'can-pay-true-first-false-second-test-payment-method',
label: 'Can Pay True First False Second Test Payment Method',
edit: <div>edit</div>,
ariaLabel: 'Can Pay True First False Second Test Payment Method',
// This mock will return true the first time it runs, then false on subsequent calls.
canMakePayment: jest
.fn()
.mockReturnValueOnce( true )
.mockReturnValue( false ),
content: <div>content</div>,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
} );
const { rerender } = render( <SavedPaymentMethodOptions /> );
// Saved token for can-pay-true-first-false-second-test-payment-method - this should show because canPay is true on first call.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).toBeInTheDocument();
rerender( <SavedPaymentMethodOptions /> );
// Saved token for can-pay-true-first-false-second-test-payment-method - this should not show because canPay is false on subsequent calls.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).not.toBeInTheDocument();
} );
} );

View File

@@ -0,0 +1,163 @@
/**
* External dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import {
InspectorControls,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { addFilter, hasFilter } from '@wordpress/hooks';
import type { StoreDescriptor } from '@wordpress/data';
import { CartCheckoutSidebarCompatibilityNotice } from '@woocommerce/editor-components/sidebar-compatibility-notice';
import { NoPaymentMethodsNotice } from '@woocommerce/editor-components/no-payment-methods-notice';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { DefaultNotice } from '@woocommerce/editor-components/default-notice';
import { IncompatibleExtensionsNotice } from '@woocommerce/editor-components/incompatible-extension-notice';
import { useSelect } from '@wordpress/data';
import { CartCheckoutFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
import { useState } from '@wordpress/element';
declare module '@wordpress/editor' {
let store: StoreDescriptor;
}
declare module '@wordpress/core-data' {
let store: StoreDescriptor;
}
declare module '@wordpress/block-editor' {
let store: StoreDescriptor;
}
const withSidebarNotices = createHigherOrderComponent(
( BlockEdit ) => ( props ) => {
const {
clientId,
name: blockName,
isSelected: isBlockSelected,
} = props;
const [
isIncompatibleExtensionsNoticeDismissed,
setIsIncompatibleExtensionsNoticeDismissed,
] = useState( true );
const toggleIncompatibleExtensionsNoticeDismissedStatus = (
isDismissed: boolean
) => {
setIsIncompatibleExtensionsNoticeDismissed( isDismissed );
};
const {
isCart,
isCheckout,
isPaymentMethodsBlock,
hasPaymentMethods,
parentId,
} = useSelect( ( select ) => {
const { getBlockParentsByBlockName, getBlockName } =
select( blockEditorStore );
const parents = getBlockParentsByBlockName( clientId, [
'woocommerce/cart',
'woocommerce/checkout',
] ).reduce(
(
accumulator: Record< string, string >,
parentClientId: string
) => {
const parentName = getBlockName( parentClientId );
accumulator[ parentName ] = parentClientId;
return accumulator;
},
{}
);
const currentBlockName = getBlockName( clientId );
const parentBlockIsCart =
Object.keys( parents ).includes( 'woocommerce/cart' );
const parentBlockIsCheckout = Object.keys( parents ).includes(
'woocommerce/checkout'
);
const currentBlockIsCart =
currentBlockName === 'woocommerce/cart' || parentBlockIsCart;
const currentBlockIsCheckout =
currentBlockName === 'woocommerce/checkout' ||
parentBlockIsCheckout;
const targetParentBlock = currentBlockIsCart
? 'woocommerce/cart'
: 'woocommerce/checkout';
return {
isCart: currentBlockIsCart,
isCheckout: currentBlockIsCheckout,
parentId:
currentBlockName === targetParentBlock
? clientId
: parents[ targetParentBlock ],
isPaymentMethodsBlock:
currentBlockName === 'woocommerce/checkout-payment-block',
hasPaymentMethods:
select( PAYMENT_STORE_KEY ).paymentMethodsInitialized() &&
Object.keys(
select( PAYMENT_STORE_KEY ).getAvailablePaymentMethods()
).length > 0,
};
} );
// Show sidebar notices only when a WooCommerce block is selected.
if (
! blockName.startsWith( 'woocommerce/' ) ||
! isBlockSelected ||
! ( isCart || isCheckout )
) {
return <BlockEdit key="edit" { ...props } />;
}
return (
<>
<InspectorControls>
<IncompatibleExtensionsNotice
toggleDismissedStatus={
toggleIncompatibleExtensionsNoticeDismissedStatus
}
block={
isCart ? 'woocommerce/cart' : 'woocommerce/checkout'
}
clientId={ parentId }
/>
<DefaultNotice block={ isCheckout ? 'checkout' : 'cart' } />
{ isIncompatibleExtensionsNoticeDismissed ? (
<CartCheckoutSidebarCompatibilityNotice
block={ isCheckout ? 'checkout' : 'cart' }
/>
) : null }
{ isPaymentMethodsBlock && ! hasPaymentMethods && (
<NoPaymentMethodsNotice />
) }
<CartCheckoutFeedbackPrompt />
</InspectorControls>
<BlockEdit key="edit" { ...props } />
</>
);
},
'withSidebarNotices'
);
if (
! hasFilter(
'editor.BlockEdit',
'woocommerce/add/sidebar-compatibility-notice'
)
) {
addFilter(
'editor.BlockEdit',
'woocommerce/add/sidebar-compatibility-notice',
withSidebarNotices,
11
);
}

View File

@@ -0,0 +1,122 @@
/**
* External dependencies
*/
import { useRef, useEffect } from '@wordpress/element';
import { useRegistry, dispatch } from '@wordpress/data';
import {
createBlock,
getBlockType,
createBlocksFromInnerBlocksTemplate,
TemplateArray,
} from '@wordpress/blocks';
import { useEditorContext } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { getMissingBlocks, findBlockPosition } from './utils';
/**
* Hook to ensure FORCED blocks are rendered in the correct place.
*/
export const useForcedLayout = ( {
clientId,
registeredBlocks,
defaultTemplate = [],
}: {
// Client ID of the parent block.
clientId: string;
// An array of registered blocks that may be forced in this particular layout.
registeredBlocks: Array< string >;
// The default template for the inner blocks in this layout.
defaultTemplate: TemplateArray;
} ) => {
const currentRegisteredBlocks = useRef( registeredBlocks );
const currentDefaultTemplate = useRef( defaultTemplate );
const registry = useRegistry();
const { isPreview } = useEditorContext();
useEffect( () => {
let templateSynced = false;
if ( isPreview ) {
return;
}
const { replaceInnerBlocks } = dispatch( 'core/block-editor' );
return registry.subscribe( () => {
const currentBlock = registry
.select( 'core/block-editor' )
.getBlock( clientId );
// If the block is removed we shouldn't reinsert its inner blocks.
if ( ! currentBlock ) {
return;
}
const innerBlocks = registry
.select( 'core/block-editor' )
.getBlocks( clientId );
// If there are NO inner blocks, sync with the given template.
if (
innerBlocks.length === 0 &&
currentDefaultTemplate.current.length > 0 &&
! templateSynced
) {
const nextBlocks = createBlocksFromInnerBlocksTemplate(
currentDefaultTemplate.current
);
if ( nextBlocks.length !== 0 ) {
templateSynced = true;
replaceInnerBlocks( clientId, nextBlocks );
return;
}
}
const registeredBlockTypes = currentRegisteredBlocks.current.map(
( blockName: string ) => getBlockType( blockName )
);
const missingBlocks = getMissingBlocks(
innerBlocks,
registeredBlockTypes
);
if ( missingBlocks.length === 0 ) {
return;
}
// Initially set as -1, so we can skip checking the position multiple times. Later on in the map callback,
// we check where the forced blocks should be inserted. This gets set to >= 0 if we find a missing block,
// so we know we can skip calculating it.
let insertAtPosition = -1;
const blockConfig = missingBlocks.map( ( block ) => {
const defaultTemplatePosition =
currentDefaultTemplate.current.findIndex(
( [ blockName ] ) => blockName === block.name
);
const createdBlock = createBlock( block.name );
// As mentioned above, if this is not -1, this is the first time we're calculating the position, if it's
// already been calculated we can skip doing so.
if ( insertAtPosition === -1 ) {
insertAtPosition = findBlockPosition( {
defaultTemplatePosition,
innerBlocks,
currentDefaultTemplate,
} );
}
return createdBlock;
} );
registry.batch( () => {
registry
.dispatch( 'core/block-editor' )
.insertBlocks( blockConfig, insertAtPosition, clientId );
} );
}, 'core/block-editor' );
}, [ clientId, isPreview, registry ] );
};

View File

@@ -0,0 +1,18 @@
/**
* External dependencies
*/
import type { Block } from '@wordpress/blocks';
export interface LockableBlock extends Block {
attributes: {
lock?: {
type: 'object';
remove?: boolean;
move: boolean;
default?: {
remove?: boolean;
move?: boolean;
};
};
};
}

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import type { BlockInstance, TemplateArray } from '@wordpress/blocks';
import type { MutableRefObject } from 'react';
/**
* Internal dependencies
*/
import { LockableBlock } from './types';
export const isBlockLocked = ( {
attributes,
}: {
attributes: LockableBlock[ 'attributes' ];
} ) => Boolean( attributes.lock?.remove || attributes.lock?.default?.remove );
/**
* This hook is used to determine which blocks are missing from a block. Given the list of inner blocks of a block, we
* can check for any registered blocks that:
* a) Are locked,
* b) Have the parent set as the current block, and
* c) Are not present in the list of inner blocks.
*/
export const getMissingBlocks = (
innerBlocks: BlockInstance[],
registeredBlockTypes: ( LockableBlock | undefined )[]
) => {
const lockedBlockTypes = registeredBlockTypes.filter(
( block: LockableBlock | undefined ) => block && isBlockLocked( block )
);
const missingBlocks: LockableBlock[] = [];
lockedBlockTypes.forEach( ( lockedBlock ) => {
if ( typeof lockedBlock === 'undefined' ) {
return;
}
const existingBlock = innerBlocks.find(
( block ) => block.name === lockedBlock.name
);
if ( ! existingBlock ) {
missingBlocks.push( lockedBlock );
}
} );
return missingBlocks;
};
/**
* This hook is used to determine the position that a missing block should be inserted at.
*
* @return The index to insert the missing block at.
*/
export const findBlockPosition = ( {
defaultTemplatePosition,
innerBlocks,
currentDefaultTemplate,
}: {
defaultTemplatePosition: number;
innerBlocks: BlockInstance[];
currentDefaultTemplate: MutableRefObject< TemplateArray >;
} ) => {
switch ( defaultTemplatePosition ) {
case -1:
// The block is not part of the default template, so we append it to the current layout.
return innerBlocks.length;
// defaultTemplatePosition defaults to 0, so if this happens we can just return, this is because the block was
// the first block in the default layout, so we can prepend it to the current layout.
case 0:
return 0;
default:
// The new layout may have extra blocks compared to the default template, so rather than insert
// at the default position, we should append it after another default block.
const adjacentBlock =
currentDefaultTemplate.current[ defaultTemplatePosition - 1 ];
const position = innerBlocks.findIndex(
( { name: blockName } ) => blockName === adjacentBlock[ 0 ]
);
return position === -1 ? defaultTemplatePosition : position + 1;
}
};

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { addFilter, hasFilter } from '@wordpress/hooks';
import type { EditorBlock } from '@woocommerce/types';
/**
* Internal dependencies
*/
import Switcher from './switcher';
import { findParentBlockEditorViews } from './utils';
const withViewSwitcher =
< T extends EditorBlock< T > >( BlockEdit: React.ElementType ) =>
( props: Record< string, unknown > ) => {
const { clientId } = props as { clientId: string };
const { views, currentView, viewClientId } = useSelect( ( select ) => {
const blockAttributes =
select( 'core/block-editor' ).getBlockAttributes( clientId );
return blockAttributes?.editorViews
? {
views: blockAttributes.editorViews,
currentView: blockAttributes.currentView,
viewClientId: clientId,
}
: findParentBlockEditorViews( clientId );
} );
if ( views.length === 0 ) {
return <BlockEdit { ...props } />;
}
return (
<>
<Switcher
currentView={ currentView }
views={ views }
clientId={ viewClientId }
/>
<BlockEdit { ...props } />
</>
);
};
if ( ! hasFilter( 'editor.BlockEdit', 'woocommerce/with-view-switcher' ) ) {
addFilter(
'editor.BlockEdit',
'woocommerce/with-view-switcher',
withViewSwitcher,
11
);
}

View File

@@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useLayoutEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { ToolbarGroup, ToolbarDropdownMenu } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import { Icon } from '@wordpress/icons';
import { eye } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import type { View } from './types';
import { getView, selectView } from './utils';
export const Switcher = ( {
currentView,
views,
clientId,
}: {
currentView: string;
views: View[];
clientId: string;
} ): JSX.Element | null => {
const {
getBlockName,
getSelectedBlockClientId,
getBlockParentsByBlockName,
} = useSelect( ( select ) => {
const blockEditor = select( 'core/block-editor' );
return {
getBlockName: blockEditor.getBlockName,
getSelectedBlockClientId: blockEditor.getSelectedBlockClientId,
getBlockParentsByBlockName: blockEditor.getBlockParentsByBlockName,
};
}, [] );
const selectedBlockClientId = getSelectedBlockClientId();
const currentViewObject = getView( currentView, views ) || views[ 0 ];
const currentViewLabel = currentViewObject.label;
useLayoutEffect( () => {
const selectedBlock = selectedBlockClientId
? getBlockName( selectedBlockClientId )
: null;
// If there is no selected block, or the selected block is the current view, do nothing.
if ( ! selectedBlock || currentView === selectedBlock ) {
return;
}
const viewNames = views.map( ( view ) => view.view );
if ( viewNames.includes( selectedBlock ) ) {
selectView( clientId, selectedBlock );
return;
}
// Look at the parent blocks to see if any of them are a view we can select.
const parentBlockClientIds = getBlockParentsByBlockName(
selectedBlockClientId,
viewNames
);
const parentBlock =
parentBlockClientIds.length === 1
? getBlockName( parentBlockClientIds[ 0 ] )
: null;
// If there is no parent block, or the parent block is the current view, do nothing.
if ( ! parentBlock || currentView === parentBlock ) {
return;
}
selectView( clientId, parentBlock, false );
}, [
clientId,
currentView,
getBlockName,
getBlockParentsByBlockName,
selectedBlockClientId,
views,
] );
return (
<BlockControls>
<ToolbarGroup>
<ToolbarDropdownMenu
label={ __(
'Switch view',
'woo-gutenberg-products-block'
) }
text={ currentViewLabel }
icon={
<Icon icon={ eye } style={ { marginRight: '8px' } } />
}
controls={ views.map( ( view ) => ( {
...view,
title: (
<span style={ { marginLeft: '8px' } }>
{ view.label }
</span>
),
isActive: view.view === currentView,
onClick: () => {
selectView( clientId, view.view );
},
} ) ) }
/>
</ToolbarGroup>
</BlockControls>
);
};
export default Switcher;

View File

@@ -0,0 +1,5 @@
export interface View {
view: string;
label: string;
icon: string | JSX.Element;
}

View File

@@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { select, dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import type { View } from './types';
export const getView = (
viewName: string,
views: View[]
): View | undefined => {
return views.find( ( view ) => view.view === viewName );
};
export const selectView = (
clientId: string,
viewName: string,
selectParent = true
) => {
const { updateBlockAttributes, selectBlock } =
dispatch( 'core/block-editor' );
updateBlockAttributes( clientId, {
currentView: viewName,
} );
if ( selectParent ) {
selectBlock(
select( 'core/block-editor' )
.getBlock( clientId )
?.innerBlocks.find(
( block: { name: string } ) => block.name === viewName
)?.clientId || clientId
);
}
};
const defaultView = {
views: [],
currentView: '',
viewClientId: '',
};
export const findParentBlockEditorViews = (
clientId: string,
maxDepth = 10,
currentDepth = 0
): {
views: View[];
currentView: string;
viewClientId: string;
} => {
const depth = currentDepth + 1;
if ( depth > maxDepth ) {
return defaultView;
}
const { getBlockAttributes, getBlockRootClientId } =
select( 'core/block-editor' );
const rootId = getBlockRootClientId( clientId );
if ( rootId === null || rootId === '' ) {
return defaultView;
}
const rootAttributes = getBlockAttributes( rootId );
if ( ! rootAttributes ) {
return defaultView;
}
if ( rootAttributes.editorViews !== undefined ) {
return {
views: rootAttributes.editorViews,
currentView:
rootAttributes.currentView ||
rootAttributes.editorViews[ 0 ].view,
viewClientId: rootId,
};
}
return findParentBlockEditorViews( rootId, maxDepth, depth );
};