diff --git a/modules/ppcp-settings/resources/js/data/index.js b/modules/ppcp-settings/resources/js/data/index.js index 274aac790..e447ff770 100644 --- a/modules/ppcp-settings/resources/js/data/index.js +++ b/modules/ppcp-settings/resources/js/data/index.js @@ -1,16 +1,20 @@ import { addDebugTools } from './debug'; import * as Onboarding from './onboarding'; import * as Common from './common'; +import * as Styling from './styling'; Onboarding.initStore(); Common.initStore(); +Styling.initStore(); export const OnboardingHooks = Onboarding.hooks; export const CommonHooks = Common.hooks; +export const StylingHooks = Styling.hooks; export const OnboardingStoreName = Onboarding.STORE_NAME; export const CommonStoreName = Common.STORE_NAME; +export const StylingStoreName = Styling.STORE_NAME; export * from './constants'; -addDebugTools( window.ppcpSettings, [ Onboarding, Common ] ); +addDebugTools( window.ppcpSettings, [ Onboarding, Common, Styling ] ); diff --git a/modules/ppcp-settings/resources/js/data/styling/action-types.js b/modules/ppcp-settings/resources/js/data/styling/action-types.js new file mode 100644 index 000000000..d487b1f5f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/action-types.js @@ -0,0 +1,18 @@ +/** + * Action Types: Define unique identifiers for actions across all store modules. + * + * @file + */ + +export default { + // Transient data. + SET_TRANSIENT: 'STYLE:SET_TRANSIENT', + + // Persistent data. + SET_PERSISTENT: 'STYLE:SET_PERSISTENT', + RESET: 'STYLE:RESET', + HYDRATE: 'STYLE:HYDRATE', + + // Controls - always start with "DO_". + DO_PERSIST_DATA: 'STYLE:DO_PERSIST_DATA', +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/actions.js b/modules/ppcp-settings/resources/js/data/styling/actions.js new file mode 100644 index 000000000..25cf6f04b --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/actions.js @@ -0,0 +1,70 @@ +/** + * Action Creators: Define functions to create action objects. + * + * These functions update state or trigger side effects (e.g., async operations). + * Actions are categorized as Transient, Persistent, or Side effect. + * + * @file + */ + +import { select } from '@wordpress/data'; + +import ACTION_TYPES from './action-types'; +import { STORE_NAME } from './constants'; + +/** + * @typedef {Object} Action An action object that is handled by a reducer or control. + * @property {string} type - The action type. + * @property {Object?} payload - Optional payload for the action. + */ + +/** + * Special. Resets all values in the store to initial defaults. + * + * @return {Action} The action. + */ +export const reset = () => ( { type: ACTION_TYPES.RESET } ); + +/** + * Persistent. Set the full store details during app initialization. + * + * @param {{data: {}, flags?: {}}} payload + * @return {Action} The action. + */ +export const hydrate = ( payload ) => ( { + type: ACTION_TYPES.HYDRATE, + payload, +} ); + +/** + * Transient. Marks the store as "ready", i.e., fully initialized. + * + * @param {boolean} isReady + * @return {Action} The action. + */ +export const setIsReady = ( isReady ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { isReady }, +} ); + +/** + * Persistent. + * + * @param {string} shape + * @return {Action} The action. + */ +export const setShape = ( shape ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { shape }, +} ); + +/** + * Side effect. Triggers the persistence of store data to the server. + * + * @return {Action} The action. + */ +export const persist = function* () { + const data = yield select( STORE_NAME ).persistentData(); + + yield { type: ACTION_TYPES.DO_PERSIST_DATA, data }; +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/constants.js b/modules/ppcp-settings/resources/js/data/styling/constants.js new file mode 100644 index 000000000..db1082f33 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/constants.js @@ -0,0 +1,28 @@ +/** + * Name of the Redux store module. + * + * Used by: Reducer, Selector, Index + * + * @type {string} + */ +export const STORE_NAME = 'wc/paypal/style'; + +/** + * REST path to hydrate data of this module by loading data from the WP DB. + * + * Used by: Resolvers + * See: StylingRestEndpoint.php + * + * @type {string} + */ +export const REST_HYDRATE_PATH = '/wc/v3/wc_paypal/styling'; + +/** + * REST path to persist data of this module to the WP DB. + * + * Used by: Controls + * See: StylingRestEndpoint.php + * + * @type {string} + */ +export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/styling'; diff --git a/modules/ppcp-settings/resources/js/data/styling/controls.js b/modules/ppcp-settings/resources/js/data/styling/controls.js new file mode 100644 index 000000000..9295b62bc --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/controls.js @@ -0,0 +1,23 @@ +/** + * Controls: Implement side effects, typically asynchronous operations. + * + * Controls use ACTION_TYPES keys as identifiers. + * They are triggered by corresponding actions and handle external interactions. + * + * @file + */ + +import apiFetch from '@wordpress/api-fetch'; + +import { REST_PERSIST_PATH } from './constants'; +import ACTION_TYPES from './action-types'; + +export const controls = { + async [ ACTION_TYPES.DO_PERSIST_DATA ]( { data } ) { + return await apiFetch( { + path: REST_PERSIST_PATH, + method: 'POST', + data, + } ); + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/hooks.js b/modules/ppcp-settings/resources/js/data/styling/hooks.js new file mode 100644 index 000000000..de08f1124 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/hooks.js @@ -0,0 +1,48 @@ +/** + * Hooks: Provide the main API for components to interact with the store. + * + * These encapsulate store interactions, offering a consistent interface. + * Hooks simplify data access and manipulation for components. + * + * @file + */ + +import { useSelect, useDispatch } from '@wordpress/data'; + +import { STORE_NAME } from './constants'; + +const useTransient = ( key ) => + useSelect( + ( select ) => select( STORE_NAME ).transientData()?.[ key ], + [ key ] + ); + +const usePersistent = ( key ) => + useSelect( + ( select ) => select( STORE_NAME ).persistentData()?.[ key ], + [ key ] + ); + +const useHooks = () => { + const { persist, setShape } = useDispatch( STORE_NAME ); + + // Read-only flags and derived state. + + // Transient accessors. + const isReady = useTransient( 'isReady' ); + + // Persistent accessors. + const shape = usePersistent( 'shape' ); + + return { + persist, + isReady, + shape, + setShape, + }; +}; + +export const useState = () => { + const { persist, isReady } = useHooks(); + return { persist, isReady }; +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/index.js b/modules/ppcp-settings/resources/js/data/styling/index.js new file mode 100644 index 000000000..28c162f98 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/index.js @@ -0,0 +1,24 @@ +import { createReduxStore, register } from '@wordpress/data'; +import { controls as wpControls } from '@wordpress/data-controls'; + +import { STORE_NAME } from './constants'; +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as hooks from './hooks'; +import { resolvers } from './resolvers'; +import { controls } from './controls'; + +export const initStore = () => { + const store = createReduxStore( STORE_NAME, { + reducer, + controls: { ...wpControls, ...controls }, + actions, + selectors, + resolvers, + } ); + + register( store ); +}; + +export { hooks, selectors, STORE_NAME }; diff --git a/modules/ppcp-settings/resources/js/data/styling/reducer.js b/modules/ppcp-settings/resources/js/data/styling/reducer.js new file mode 100644 index 000000000..9e505dd24 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/reducer.js @@ -0,0 +1,55 @@ +/** + * Reducer: Defines store structure and state updates for this module. + * + * Manages both transient (temporary) and persistent (saved) state. + * The initial state must define all properties, as dynamic additions are not supported. + * + * @file + */ + +import { createReducer, createSetters } from '../utils'; +import ACTION_TYPES from './action-types'; + +// Store structure. + +// Transient: Values that are _not_ saved to the DB (like app lifecycle-flags). +const defaultTransient = Object.freeze( { + isReady: false, +} ); + +// Persistent: Values that are loaded from the DB. +const defaultPersistent = Object.freeze( { + shape: 'rect', +} ); + +// Reducer logic. + +const [ setTransient, setPersistent ] = createSetters( + defaultTransient, + defaultPersistent +); + +const reducer = createReducer( defaultTransient, defaultPersistent, { + [ ACTION_TYPES.SET_TRANSIENT ]: ( state, payload ) => + setTransient( state, payload ), + + [ ACTION_TYPES.SET_PERSISTENT ]: ( state, payload ) => + setPersistent( state, payload ), + + [ ACTION_TYPES.RESET ]: ( state ) => { + const cleanState = setTransient( + setPersistent( state, defaultPersistent ), + defaultTransient + ); + + // Keep "read-only" details and initialization flags. + cleanState.isReady = true; + + return cleanState; + }, + + [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => + setPersistent( state, payload.data ), +} ); + +export default reducer; diff --git a/modules/ppcp-settings/resources/js/data/styling/resolvers.js b/modules/ppcp-settings/resources/js/data/styling/resolvers.js new file mode 100644 index 000000000..e59794746 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/resolvers.js @@ -0,0 +1,36 @@ +/** + * Resolvers: Handle asynchronous data fetching for the store. + * + * These functions update store state with data from external sources. + * Each resolver corresponds to a specific selector (selector with same name must exist). + * Resolvers are called automatically when selectors request unavailable data. + * + * @file + */ + +import { dispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { apiFetch } from '@wordpress/data-controls'; + +import { STORE_NAME, REST_HYDRATE_PATH } from './constants'; + +export const resolvers = { + /** + * Retrieve settings from the site's REST API. + */ + *persistentData() { + try { + const result = yield apiFetch( { path: REST_HYDRATE_PATH } ); + + yield dispatch( STORE_NAME ).hydrate( result ); + yield dispatch( STORE_NAME ).setIsReady( true ); + } catch ( e ) { + yield dispatch( 'core/notices' ).createErrorNotice( + __( + 'Error retrieving style-details.', + 'woocommerce-paypal-payments' + ) + ); + } + }, +}; diff --git a/modules/ppcp-settings/resources/js/data/styling/selectors.js b/modules/ppcp-settings/resources/js/data/styling/selectors.js new file mode 100644 index 000000000..14334fcf3 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/styling/selectors.js @@ -0,0 +1,21 @@ +/** + * Selectors: Extract specific pieces of state from the store. + * + * These functions provide a consistent interface for accessing store data. + * They allow components to retrieve data without knowing the store structure. + * + * @file + */ + +const EMPTY_OBJ = Object.freeze( {} ); + +const getState = ( state ) => state || EMPTY_OBJ; + +export const persistentData = ( state ) => { + return getState( state ).data || EMPTY_OBJ; +}; + +export const transientData = ( state ) => { + const { data, ...transientState } = getState( state ); + return transientState || EMPTY_OBJ; +}; diff --git a/modules/ppcp-settings/services.php b/modules/ppcp-settings/services.php index 6fc9d67e3..50afb3fdb 100644 --- a/modules/ppcp-settings/services.php +++ b/modules/ppcp-settings/services.php @@ -24,6 +24,8 @@ use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\Settings\Endpoint\StylingRestEndpoint; +use WooCommerce\PayPalCommerce\Settings\Data\StylingSettings; return array( 'settings.url' => static function ( ContainerInterface $container ) : string { @@ -64,12 +66,18 @@ return array( $container->get( 'wcgateway.is-send-only-country' ) ); }, + 'settings.data.styling' => static function ( ContainerInterface $container ) : StylingSettings { + return new StylingSettings(); + }, 'settings.rest.onboarding' => static function ( ContainerInterface $container ) : OnboardingRestEndpoint { return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) ); }, 'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint { return new CommonRestEndpoint( $container->get( 'settings.data.general' ) ); }, + 'settings.rest.styling' => static function ( ContainerInterface $container ) : StylingRestEndpoint { + return new StylingRestEndpoint( $container->get( 'settings.data.styling' ) ); + }, 'settings.rest.refresh_feature_status' => static function ( ContainerInterface $container ) : RefreshFeatureStatusEndpoint { return new RefreshFeatureStatusEndpoint( $container->get( 'wcgateway.settings' ), diff --git a/modules/ppcp-settings/src/Data/StylingSettings.php b/modules/ppcp-settings/src/Data/StylingSettings.php index 481625ca7..ee942693c 100644 --- a/modules/ppcp-settings/src/Data/StylingSettings.php +++ b/modules/ppcp-settings/src/Data/StylingSettings.php @@ -1,6 +1,6 @@ 'rect', + ); } } diff --git a/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php new file mode 100644 index 000000000..97b1158f2 --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php @@ -0,0 +1,126 @@ + array( + 'js_name' => 'shape', + ), + ); + + /** + * Constructor. + * + * @param StylingSettings $settings The settings instance. + */ + public function __construct( StylingSettings $settings ) { + $this->settings = $settings; + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + /** + * GET wc/v3/wc_paypal/styling + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + /** + * POST wc/v3/wc_paypal/styling + * { + * // Fields mentioned in $field_map[]['js_name'] + * } + */ + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Returns all styling details. + * + * @return WP_REST_Response The current styling details. + */ + public function get_details() : WP_REST_Response { + $js_data = $this->sanitize_for_javascript( + $this->settings->to_array(), + $this->field_map + ); + + return $this->return_success( + $js_data + ); + } + + /** + * Updates styling details based on the request. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return WP_REST_Response The updated styling details. + */ + public function update_details( WP_REST_Request $request ) : WP_REST_Response { + $wp_data = $this->sanitize_for_wordpress( + $request->get_params(), + $this->field_map + ); + + $this->settings->from_array( $wp_data ); + $this->settings->save(); + + return $this->get_details(); + } +} diff --git a/modules/ppcp-settings/src/SettingsModule.php b/modules/ppcp-settings/src/SettingsModule.php index 59f752545..5bc032dfb 100644 --- a/modules/ppcp-settings/src/SettingsModule.php +++ b/modules/ppcp-settings/src/SettingsModule.php @@ -180,6 +180,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { $endpoints = array( $container->get( 'settings.rest.onboarding' ), $container->get( 'settings.rest.common' ), + $container->get( 'settings.rest.styling' ), $container->get( 'settings.rest.connect_manual' ), $container->get( 'settings.rest.login_link' ), $container->get( 'settings.rest.webhooks' ),