From 63f417d7d29a04ad4143d6c2acaff562d97ec053 Mon Sep 17 00:00:00 2001
From: Philipp Stracker
Date: Mon, 13 Jan 2025 17:06:05 +0100
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Brand=20new,=20empty=20=E2=80=9CSty?=
=?UTF-8?q?ling=E2=80=9D=20store=20in=20Redux?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ppcp-settings/resources/js/data/index.js | 6 +-
.../resources/js/data/styling/action-types.js | 18 +++
.../resources/js/data/styling/actions.js | 70 ++++++++++
.../resources/js/data/styling/constants.js | 28 ++++
.../resources/js/data/styling/controls.js | 23 ++++
.../resources/js/data/styling/hooks.js | 48 +++++++
.../resources/js/data/styling/index.js | 24 ++++
.../resources/js/data/styling/reducer.js | 55 ++++++++
.../resources/js/data/styling/resolvers.js | 36 +++++
.../resources/js/data/styling/selectors.js | 21 +++
modules/ppcp-settings/services.php | 8 ++
.../src/Data/StylingSettings.php | 14 +-
.../src/Endpoint/StylingRestEndpoint.php | 126 ++++++++++++++++++
modules/ppcp-settings/src/SettingsModule.php | 1 +
14 files changed, 471 insertions(+), 7 deletions(-)
create mode 100644 modules/ppcp-settings/resources/js/data/styling/action-types.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/actions.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/constants.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/controls.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/hooks.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/index.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/reducer.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/resolvers.js
create mode 100644 modules/ppcp-settings/resources/js/data/styling/selectors.js
create mode 100644 modules/ppcp-settings/src/Endpoint/StylingRestEndpoint.php
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' ),