Merge pull request #3139 from woocommerce/PCP-4244-add-logic-and-ui-visuals-for-conditional-disabled-state-for-payment-methods

Payment Methods: Add Dependency-Based Status Sync (4244)
This commit is contained in:
Emili Castells 2025-02-21 14:45:49 +01:00 committed by GitHub
commit 8f9e305fa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 748 additions and 112 deletions

View file

@ -11,6 +11,8 @@ const PaymentMethodItemBlock = ( {
onTriggerModal,
onSelect,
isSelected,
isDisabled,
disabledMessage,
} ) => {
const { activeHighlight, setActiveHighlight } = useActiveHighlight();
const isHighlighted = activeHighlight === paymentMethod.id;
@ -31,9 +33,16 @@ const PaymentMethodItemBlock = ( {
id={ paymentMethod.id }
className={ `ppcp--method-item ${
isHighlighted ? 'ppcp-highlight' : ''
}` }
} ${ isDisabled ? 'ppcp--method-item--disabled' : '' }` }
separatorAndGap={ false }
>
{ isDisabled && (
<div className="ppcp--method-disabled-overlay">
<p className="ppcp--method-disabled-message">
{ disabledMessage }
</p>
</div>
) }
<div className="ppcp--method-inner">
<div className="ppcp--method-title-wrapper">
{ paymentMethod?.icon && (

View file

@ -19,12 +19,14 @@ const PaymentMethodsBlock = ( { paymentMethods = [], onTriggerModal } ) => {
<SettingsBlock className="ppcp--grid ppcp-r-settings-block__payment-methods">
{ paymentMethods
// Remove empty/invalid payment method entries.
.filter( ( m ) => m.id )
.filter( ( m ) => m && m.id )
.map( ( paymentMethod ) => (
<PaymentMethodItemBlock
key={ paymentMethod.id }
paymentMethod={ paymentMethod }
isSelected={ paymentMethod.enabled }
isDisabled={ paymentMethod.isDisabled }
disabledMessage={ paymentMethod.disabledMessage }
onSelect={ ( checked ) =>
handleSelect( paymentMethod.id, checked )
}

View file

@ -0,0 +1,39 @@
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { scrollAndHighlight } from '../../../../../utils/scrollAndHighlight';
/**
* Component to display a payment method dependency message
*
* @param {Object} props - Component props
* @param {string} props.parentId - ID of the parent payment method
* @param {string} props.parentName - Display name of the parent payment method
* @return {JSX.Element} The formatted message with link
*/
const DependencyMessage = ( { parentId, parentName } ) => {
// Using WordPress createInterpolateElement with proper React elements
return createInterpolateElement(
/* translators: %s: payment method name */
__(
'This payment method requires <methodLink /> to be enabled.',
'woocommerce-paypal-payments'
),
{
methodLink: (
<strong>
<a
href="#"
onClick={ ( e ) => {
e.preventDefault();
scrollAndHighlight( parentId );
} }
>
{ parentName }
</a>
</strong>
),
}
);
};
export default DependencyMessage;

View file

@ -0,0 +1,71 @@
import SettingsCard from '../../../../ReusableComponents/SettingsCard';
import { PaymentMethodsBlock } from '../../../../ReusableComponents/SettingsBlocks';
import usePaymentDependencyState from '../../../../../hooks/usePaymentDependencyState';
import DependencyMessage from './DependencyMessage';
/**
* Renders a payment method card with dependency handling
*
* @param {Object} props - Component props
* @param {string} props.id - Unique identifier for the card
* @param {string} props.title - Title of the payment method card
* @param {string} props.description - Description of the payment method
* @param {string} props.icon - Icon path for the payment method
* @param {Array} props.methods - List of payment methods to display
* @param {Object} props.methodsMap - Map of all payment methods by ID
* @param {Function} props.onTriggerModal - Callback when a method is clicked
* @param {boolean} props.isDisabled - Whether the entire card is disabled
* @param {(string|JSX.Element)} props.disabledMessage - Message to show when disabled
* @return {JSX.Element} The rendered component
*/
const PaymentMethodCard = ( {
id,
title,
description,
icon,
methods,
methodsMap = {},
onTriggerModal,
isDisabled = false,
disabledMessage,
} ) => {
const dependencyState = usePaymentDependencyState( methods, methodsMap );
return (
<SettingsCard
id={ id }
title={ title }
description={ description }
icon={ icon }
contentContainer={ false }
>
<PaymentMethodsBlock
paymentMethods={ methods.map( ( method ) => {
const dependency = dependencyState[ method.id ];
const dependencyMessage = dependency ? (
<DependencyMessage
parentId={ dependency.parentId }
parentName={ dependency.parentName }
/>
) : null;
return {
...method,
isDisabled:
method.isDisabled ||
isDisabled ||
Boolean( dependency?.isDisabled ),
disabledMessage:
method.disabledMessage ||
dependencyMessage ||
disabledMessage,
};
} ) }
onTriggerModal={ onTriggerModal }
/>
</SettingsCard>
);
};
export default PaymentMethodCard;

View file

@ -1,17 +1,23 @@
import { __ } from '@wordpress/i18n';
import { useCallback } from '@wordpress/element';
import SettingsCard from '../../../ReusableComponents/SettingsCard';
import { PaymentMethodsBlock } from '../../../ReusableComponents/SettingsBlocks';
import { CommonHooks, OnboardingHooks, PaymentHooks } from '../../../../data';
import { useActiveModal } from '../../../../data/common/hooks';
import Modal from '../Components/Payment/Modal';
import PaymentMethodCard from '../Components/Payment/PaymentMethodCard';
const TabPaymentMethods = () => {
const methods = PaymentHooks.usePaymentMethods();
const { setPersistent, changePaymentSettings } = PaymentHooks.useStore();
const store = PaymentHooks.useStore();
const { setPersistent, changePaymentSettings } = store;
const { activeModal, setActiveModal } = useActiveModal();
// Get all methods as a map for dependency checking
const methodsMap = {};
methods.all.forEach( ( method ) => {
methodsMap[ method.id ] = method;
} );
const getActiveMethod = () => {
if ( ! activeModal ) {
return null;
@ -60,6 +66,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-standard.svg"
methods={ methods.paypal }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
{ merchant.isBusinessSeller && canUseCardPayments && (
<PaymentMethodCard
@ -75,6 +82,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-online-methods.svg"
methods={ methods.cardPayment }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
) }
<PaymentMethodCard
@ -90,6 +98,7 @@ const TabPaymentMethods = () => {
icon="icon-checkout-alternative-methods.svg"
methods={ methods.apm }
onTriggerModal={ setActiveModal }
methodsMap={ methodsMap }
/>
{ activeModal && (
@ -104,25 +113,3 @@ const TabPaymentMethods = () => {
};
export default TabPaymentMethods;
const PaymentMethodCard = ( {
id,
title,
description,
icon,
methods,
onTriggerModal,
} ) => (
<SettingsCard
id={ id }
title={ title }
description={ description }
icon={ icon }
contentContainer={ false }
>
<PaymentMethodsBlock
paymentMethods={ methods }
onTriggerModal={ onTriggerModal }
/>
</SettingsCard>
);

View file

@ -7,6 +7,8 @@
export default {
// Transient data.
SET_TRANSIENT: 'PAYMENT:SET_TRANSIENT',
SET_DISABLED_BY_DEPENDENCY: 'PAYMENT:SET_DISABLED_BY_DEPENDENCY',
RESTORE_DEPENDENCY_STATE: 'PAYMENT:RESTORE_DEPENDENCY_STATE',
// Persistent data.
SET_PERSISTENT: 'PAYMENT:SET_PERSISTENT',

View file

@ -7,6 +7,7 @@ import * as actions from './actions';
import * as hooks from './hooks';
import * as resolvers from './resolvers';
import { initTodoSync } from '../sync/todo-state-sync';
import { initPaymentDependencySync } from '../sync/payment-methods-sync';
/**
* Initializes and registers the settings store with WordPress data layer.
@ -24,9 +25,12 @@ export const initStore = () => {
register( store );
// Initialize todo sync after store registration. Potentially should be moved elsewhere.
// Initialize todo sync after store registration.
initTodoSync();
// Initialize payment method dependency sync.
initPaymentDependencySync();
return Boolean( wp.data.select( STORE_NAME ) );
};

View file

@ -88,6 +88,56 @@ const reducer = createReducer( defaultTransient, defaultPersistent, {
[ ACTION_TYPES.HYDRATE ]: ( state, payload ) =>
changePersistent( state, payload.data ),
[ ACTION_TYPES.SET_DISABLED_BY_DEPENDENCY ]: ( state, payload ) => {
const { methodId } = payload;
const method = state.data[ methodId ];
if ( ! method ) {
return state;
}
// Create a new state with the method disabled due to dependency
const updatedData = {
...state.data,
[ methodId ]: {
...method,
enabled: false,
_disabledByDependency: true,
_originalState: method.enabled,
},
};
return {
...state,
data: updatedData,
};
},
[ ACTION_TYPES.RESTORE_DEPENDENCY_STATE ]: ( state, payload ) => {
const { methodId } = payload;
const method = state.data[ methodId ];
if ( ! method || ! method._disabledByDependency ) {
return state;
}
// Restore the method to its original state
const updatedData = {
...state.data,
[ methodId ]: {
...method,
enabled: method._originalState === true,
_disabledByDependency: false,
_originalState: undefined,
},
};
return {
...state,
data: updatedData,
};
},
} );
export default reducer;

View file

@ -0,0 +1,126 @@
import { subscribe, select } from '@wordpress/data';
// Store name
const PAYMENT_STORE = 'wc/paypal/payment';
// Track original states of dependent methods
const originalStates = {};
/**
* Initialize payment method dependency synchronization
*/
export const initPaymentDependencySync = () => {
let previousPaymentState = null;
let isProcessing = false;
const unsubscribe = subscribe( () => {
if ( isProcessing ) {
return;
}
isProcessing = true;
try {
const paymentHooks = select( PAYMENT_STORE );
if ( ! paymentHooks ) {
isProcessing = false;
return;
}
const methods = paymentHooks.persistentData();
if ( ! methods ) {
isProcessing = false;
return;
}
if ( ! previousPaymentState ) {
previousPaymentState = { ...methods };
isProcessing = false;
return;
}
const changedMethods = Object.keys( methods )
.filter(
( key ) =>
key !== '__meta' &&
methods[ key ] &&
previousPaymentState[ key ]
)
.filter(
( methodId ) =>
methods[ methodId ].enabled !==
previousPaymentState[ methodId ].enabled
);
if ( changedMethods.length > 0 ) {
changedMethods.forEach( ( changedId ) => {
const isNowEnabled = methods[ changedId ].enabled;
const dependents = Object.entries( methods )
.filter(
( [ key, method ] ) =>
key !== '__meta' &&
method &&
method.depends_on &&
method.depends_on.includes( changedId )
)
.map( ( [ key ] ) => key );
if ( dependents.length > 0 ) {
if ( ! isNowEnabled ) {
handleDisableDependents( dependents, methods );
} else {
handleRestoreDependents( dependents, methods );
}
}
} );
}
previousPaymentState = { ...methods };
} catch ( error ) {
// Keep error handling without the console.error
} finally {
isProcessing = false;
}
} );
return unsubscribe;
};
const handleDisableDependents = ( dependentIds, methods ) => {
dependentIds.forEach( ( methodId ) => {
if ( methods[ methodId ] ) {
if ( ! ( methodId in originalStates ) ) {
originalStates[ methodId ] = methods[ methodId ].enabled;
}
methods[ methodId ].enabled = false;
methods[ methodId ].isDisabled = true;
}
} );
};
const handleRestoreDependents = ( dependentIds, methods ) => {
dependentIds.forEach( ( methodId ) => {
if (
methods[ methodId ] &&
methodId in originalStates &&
checkAllDependenciesSatisfied( methodId, methods )
) {
methods[ methodId ].enabled = originalStates[ methodId ];
methods[ methodId ].isDisabled = false;
delete originalStates[ methodId ];
}
} );
};
const checkAllDependenciesSatisfied = ( methodId, methods ) => {
const method = methods[ methodId ];
if ( ! method || ! method.depends_on ) {
return true;
}
return ! method.depends_on.some( ( parentId ) => {
const parent = methods[ parentId ];
return ! parent || parent.enabled === false;
} );
};

View file

@ -0,0 +1,81 @@
import { useSelect } from '@wordpress/data';
/**
* Gets the display name for a parent payment method
*
* @param {string} parentId - ID of the parent payment method
* @param {Object} methodsMap - Map of all payment methods by ID
* @return {string} The display name to use for the parent method
*/
const getParentMethodName = ( parentId, methodsMap ) => {
const parentMethod = methodsMap[ parentId ];
return parentMethod
? parentMethod.itemTitle || parentMethod.title || ''
: '';
};
/**
* Finds disabled parent dependencies for a method
*
* @param {Object} method - The payment method to check
* @param {Object} methodsMap - Map of all payment methods by ID
* @return {Array} List of disabled parent IDs, empty if none
*/
const findDisabledParents = ( method, methodsMap ) => {
if ( ! method.depends_on?.length && ! method._disabledByDependency ) {
return [];
}
const parents = method.depends_on || [];
return parents.filter( ( parentId ) => {
const parent = methodsMap[ parentId ];
return parent && ! parent.enabled;
} );
};
/**
* Custom hook to handle payment method dependencies
*
* @param {Array} methods - List of payment methods
* @param {Object} methodsMap - Map of payment methods by ID
* @return {Object} Dependency state object with methods that should be disabled
*/
const usePaymentDependencyState = ( methods, methodsMap ) => {
return useSelect(
( select ) => {
const paymentStore = select( 'wc/paypal/payment' );
if ( ! paymentStore ) {
return {};
}
const result = {};
methods.forEach( ( method ) => {
const disabledParents = findDisabledParents(
method,
methodsMap
);
if ( disabledParents.length > 0 ) {
const parentId = disabledParents[ 0 ];
const parentName = getParentMethodName(
parentId,
methodsMap
);
result[ method.id ] = {
isDisabled: true,
parentId,
parentName,
};
}
} );
return result;
},
[ methods, methodsMap ]
);
};
export default usePaymentDependencyState;

View file

@ -0,0 +1,49 @@
/**
* Scroll to a specific element and highlight it
*
* @param {string} elementId - ID of the element to scroll to
* @param {boolean} [highlight=true] - Whether to highlight the element
* @return {Promise} - Resolves when scroll and highlight are complete
*/
export const scrollAndHighlight = ( elementId, highlight = true ) => {
return new Promise( ( resolve ) => {
const scrollTarget = document.getElementById( elementId );
if ( scrollTarget ) {
const navContainer = document.querySelector(
'.ppcp-r-navigation-container'
);
const navHeight = navContainer ? navContainer.offsetHeight : 0;
// Get the current scroll position and element's position relative to viewport
const rect = scrollTarget.getBoundingClientRect();
// Calculate the final position with offset
const scrollPosition =
rect.top + window.scrollY - ( navHeight + 55 );
window.scrollTo( {
top: scrollPosition,
behavior: 'smooth',
} );
// Add highlight if requested
if ( highlight ) {
scrollTarget.classList.add( 'ppcp-highlight' );
// Remove highlight after animation
setTimeout( () => {
scrollTarget.classList.remove( 'ppcp-highlight' );
}, 2000 );
}
// Resolve after scroll animation
setTimeout( resolve, 300 );
} else {
console.error(
`Failed to scroll: Element with ID "${ elementId }" not found`
);
resolve();
}
} );
};

View file

@ -7,6 +7,8 @@ export const TAB_IDS = {
PAY_LATER_MESSAGING: 'tab-panel-0-pay-later-messaging',
};
import { scrollAndHighlight } from './scrollAndHighlight';
/**
* Select a tab by simulating a click event and scroll to specified element,
* accounting for navigation container height
@ -23,40 +25,8 @@ export const selectTab = ( tabId, scrollToId ) => {
if ( tab ) {
tab.click();
setTimeout( () => {
const scrollTarget = scrollToId
? document.getElementById( scrollToId )
: document.getElementById( 'ppcp-settings-container' );
if ( scrollTarget ) {
const navContainer = document.querySelector(
'.ppcp-r-navigation-container'
);
const navHeight = navContainer
? navContainer.offsetHeight
: 0;
// Get the current scroll position and element's position relative to viewport
const rect = scrollTarget.getBoundingClientRect();
// Calculate the final position with offset
const scrollPosition =
rect.top + window.scrollY - ( navHeight + 55 );
window.scrollTo( {
top: scrollPosition,
behavior: 'smooth',
} );
// Resolve after scroll animation
setTimeout( resolve, 300 );
} else {
console.error(
`Failed to scroll: Element with ID "${
scrollToId || 'ppcp-settings-container'
}" not found`
);
resolve();
}
const targetId = scrollToId || 'ppcp-settings-container';
scrollAndHighlight( targetId, false ).then( resolve );
}, 100 );
} else {
console.error(