Merge remote-tracking branch 'origin/trunk' into PCP-3814-Create-dashboard-placeholder-page-in-new-settings-module

# Conflicts:
#	modules/ppcp-settings/resources/js/Components/Screens/Onboarding/StepWelcome.js
This commit is contained in:
inpsyde-maticluznar 2024-10-28 12:28:07 +01:00
commit 3a9f2a4a1f
No known key found for this signature in database
GPG key ID: D005973F231309F6
14 changed files with 640 additions and 112 deletions

View file

@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce';
/**
* Approach 1: Component Injection
*
* A generic wrapper that adds debounced store updates to any controlled component.
*
* @param {Object} props
* @param {React.ComponentType} props.control The controlled component to render
* @param {string|number} props.value The controlled value
* @param {Function} props.onChange Change handler
* @param {number} [props.delay=300] Debounce delay in milliseconds
*/
const DataStoreControl = ( {
control: ControlComponent,
value: externalValue,
onChange,
delay = 300,
...props
} ) => {
const [ internalValue, setInternalValue ] = useState( externalValue );
const onChangeRef = useRef( onChange );
onChangeRef.current = onChange;
const debouncedUpdate = useRef(
debounce( ( value ) => {
onChangeRef.current( value );
}, delay )
).current;
useEffect( () => {
setInternalValue( externalValue );
debouncedUpdate?.cancel();
}, [ externalValue ] );
useEffect( () => {
return () => debouncedUpdate?.cancel();
}, [ debouncedUpdate ] );
const handleChange = useCallback(
( newValue ) => {
setInternalValue( newValue );
debouncedUpdate( newValue );
},
[ debouncedUpdate ]
);
return (
<ControlComponent
{ ...props }
value={ internalValue }
onChange={ handleChange }
/>
);
};
export default DataStoreControl;

View file

@ -1,9 +1,6 @@
import { useState } from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
const SettingsToggleBlock = ( props ) => {
const [ isToggled, setToggled ] = useState( false );
const SettingsToggleBlock = ( { isToggled, setToggled, ...props } ) => {
return (
<div className="ppcp-r-toggle-block">
<div className="ppcp-r-toggle-block__wrapper">

View file

@ -4,6 +4,8 @@ import { Button, TextControl } from '@wordpress/components';
import PaymentMethodIcons from '../../ReusableComponents/PaymentMethodIcons';
import SettingsToggleBlock from '../../ReusableComponents/SettingsToggleBlock';
import Separator from '../../ReusableComponents/Separator';
import { useOnboardingDetails } from '../../../data';
import DataStoreControl from '../../ReusableComponents/DataStoreControl';
const StepWelcome = ( { setStep, currentStep } ) => {
return (
@ -73,6 +75,17 @@ const WelcomeFeatures = () => {
};
const WelcomeForm = () => {
const {
isSandboxMode,
setSandboxMode,
isManualConnectionMode,
setManualConnectionMode,
clientId,
setClientId,
clientSecret,
setClientSecret,
} = useOnboardingDetails();
const advancedUsersDescription = sprintf(
// translators: %s: Link to PayPal REST application guide
__(
@ -93,8 +106,10 @@ const WelcomeForm = () => {
'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.',
'woocommerce-paypal-payments'
) }
isToggled={ !! isSandboxMode }
setToggled={ setSandboxMode }
>
<Button variant="primary">
<Button variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>
@ -105,22 +120,29 @@ const WelcomeForm = () => {
'woocommerce-paypal-payments'
) }
description={ advancedUsersDescription }
isToggled={ !! isManualConnectionMode }
setToggled={ setManualConnectionMode }
>
<TextControl
<DataStoreControl
control={ TextControl }
label={ __(
'Sandbox Client ID',
'woocommerce-paypal-payments'
) }
></TextControl>
<TextControl
value={ clientId }
onChange={ setClientId }
/>
<DataStoreControl
control={ TextControl }
label={ __(
'Sandbox Secret Key',
'woocommerce-paypal-payments'
) }
value={ clientSecret }
onChange={ setClientSecret }
type="password"
></TextControl>
<Button variant="primary">
/>
<Button variant="secondary">
{ __( 'Connect Account', 'woocommerce-paypal-payments' ) }
</Button>
</SettingsToggleBlock>

View file

@ -1,5 +1,12 @@
export default {
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS',
// Transient data.
SET_IS_SAVING_ONBOARDING_DETAILS: 'SET_IS_SAVING_ONBOARDING_DETAILS',
// Persistent data.
SET_ONBOARDING_DETAILS: 'SET_ONBOARDING_DETAILS',
SET_ONBOARDING_STEP: 'SET_ONBOARDING_STEP',
SET_SANDBOX_MODE: 'SET_SANDBOX_MODE',
SET_MANUAL_CONNECTION_MODE: 'SET_MANUAL_CONNECTION_MODE',
SET_CLIENT_ID: 'SET_CLIENT_ID',
SET_CLIENT_SECRET: 'SET_CLIENT_SECRET',
};

View file

@ -1,16 +1,28 @@
import { dispatch, select } from '@wordpress/data';
import { select } from '@wordpress/data';
import { apiFetch } from '@wordpress/data-controls';
import { __ } from '@wordpress/i18n';
import ACTION_TYPES from './action-types';
import { NAMESPACE, STORE_NAME } from '../constants';
/**
* Non-persistent. Changes the "saving" flag.
*
* @param {boolean} isSaving
* @return {{type: string, isSaving}} The action.
*/
export const setIsSaving = ( isSaving ) => {
return {
type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS,
isSaving,
};
};
/**
* Persistent. Set the full onboarding details, usually during app initialization.
*
* @param {Object} payload
* @return {{payload, type: string}} The action.
* @return {{type: string, payload}} The action.
*/
export const updateOnboardingDetails = ( payload ) => {
export const setOnboardingDetails = ( payload ) => {
return {
type: ACTION_TYPES.SET_ONBOARDING_DETAILS,
payload,
@ -31,48 +43,81 @@ export const setOnboardingStep = ( step ) => {
};
/**
* Non-persistent. Changes the "saving" flag.
* Persistent. Sets the sandbox mode on or off.
*
* @param {boolean} isSaving
* @return {{type: string, isSaving}} The action.
* @param {boolean} sandboxMode
* @return {{type: string, useSandbox}} An action.
*/
export const updateIsSaving = ( isSaving ) => {
export const setSandboxMode = ( sandboxMode ) => {
return {
type: ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS,
isSaving,
type: ACTION_TYPES.SET_SANDBOX_MODE,
useSandbox: sandboxMode,
};
};
/**
* Persistent. Toggles the "Manual Connection" mode on or off.
*
* @param {boolean} manualConnectionMode
* @return {{type: string, useManualConnection}} An action.
*/
export const setManualConnectionMode = ( manualConnectionMode ) => {
return {
type: ACTION_TYPES.SET_MANUAL_CONNECTION_MODE,
useManualConnection: manualConnectionMode,
};
};
/**
* Persistent. Changes the "client ID" value.
*
* @param {string} clientId
* @return {{type: string, clientId}} The action.
*/
export const setClientId = ( clientId ) => {
return {
type: ACTION_TYPES.SET_CLIENT_ID,
clientId,
};
};
/**
* Persistent. Changes the "client secret" value.
*
* @param {string} clientSecret
* @return {{type: string, clientSecret}} The action.
*/
export const setClientSecret = ( clientSecret ) => {
return {
type: ACTION_TYPES.SET_CLIENT_SECRET,
clientSecret,
};
};
/**
* Saves the persistent details to the WP database.
*
* @return {Generator<any>} A generator function that handles the saving process.
* @return {any} A generator function that handles the saving process.
*/
export function* persist() {
let error = null;
try {
const path = `${ NAMESPACE }/onboarding`;
const data = select( STORE_NAME ).getOnboardingData();
const data = select( STORE_NAME ).getPersistentData();
yield updateIsSaving( true );
yield setIsSaving( true );
yield apiFetch( {
path,
method: 'post',
data,
} );
yield dispatch( 'core/notices' ).createSuccessNotice(
__( 'Progress saved.', 'woocommerce-paypal-payments' )
);
} catch ( e ) {
error = e;
yield dispatch( 'core/notices' ).createErrorNotice(
__( 'Error saving progress.', 'woocommerce-paypal-payments' )
);
console.error( 'Error saving progress.', e );
} finally {
yield updateIsSaving( false );
yield setIsSaving( false );
}
return error === null;

View file

@ -2,22 +2,61 @@ import { useSelect, useDispatch } from '@wordpress/data';
import { STORE_NAME } from '../constants';
export const useOnboardingDetails = () => {
const { setOnboardingStep, persist } = useDispatch( STORE_NAME );
const {
setOnboardingStep,
setSandboxMode,
setManualConnectionMode,
persist,
setClientId,
setClientSecret,
} = useDispatch( STORE_NAME );
// Transient accessors.
const isSaving = useSelect( ( select ) => {
return select( STORE_NAME ).getTransientData().isSaving;
}, [] );
// Persistent accessors.
const clientId = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientId;
}, [] );
const clientSecret = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().clientSecret;
}, [] );
const onboardingStep = useSelect( ( select ) => {
return select( STORE_NAME ).getOnboardingStep();
return select( STORE_NAME ).getPersistentData().step || 0;
}, [] );
const isSaving = useSelect( ( select ) => {
return select( STORE_NAME ).isSaving();
const isSandboxMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useSandbox;
}, [] );
const isManualConnectionMode = useSelect( ( select ) => {
return select( STORE_NAME ).getPersistentData().useManualConnection;
}, [] );
const setDetailAndPersist = async ( setter, value ) => {
setter( value );
await persist();
};
return {
onboardingStep,
isSaving,
setOnboardingStep: async ( step ) => {
setOnboardingStep( step );
await persist();
},
isSandboxMode,
isManualConnectionMode,
clientId,
setClientId: ( value ) => setDetailAndPersist( setClientId, value ),
clientSecret,
setClientSecret: ( value ) =>
setDetailAndPersist( setClientSecret, value ),
setOnboardingStep: ( step ) =>
setDetailAndPersist( setOnboardingStep, step ),
setSandboxMode: ( state ) =>
setDetailAndPersist( setSandboxMode, state ),
setManualConnectionMode: ( state ) =>
setDetailAndPersist( setManualConnectionMode, state ),
};
};

View file

@ -4,6 +4,10 @@ const defaultState = {
isSaving: false,
data: {
step: 0,
useSandbox: false,
useManualConnection: false,
clientId: '',
clientSecret: '',
},
};
@ -11,32 +15,54 @@ export const onboardingReducer = (
state = defaultState,
{ type, ...action }
) => {
switch ( type ) {
case ACTION_TYPES.SET_ONBOARDING_DETAILS:
return {
...state,
data: action.payload,
};
const setTransient = ( changes ) => {
const { data, ...transientChanges } = changes;
return { ...state, ...transientChanges };
};
const setPersistent = ( changes ) => {
const validChanges = Object.keys( changes ).reduce( ( acc, key ) => {
if ( key in defaultState.data ) {
acc[ key ] = changes[ key ];
}
return acc;
}, {} );
return {
...state,
data: { ...state.data, ...validChanges },
};
};
switch ( type ) {
// Transient data.
case ACTION_TYPES.SET_IS_SAVING_ONBOARDING_DETAILS:
return {
...state,
isSaving: action.isSaving,
};
return setTransient( { isSaving: action.isSaving } );
// Persistent data.
case ACTION_TYPES.SET_CLIENT_ID:
return setPersistent( { clientId: action.clientId } );
case ACTION_TYPES.SET_CLIENT_SECRET:
return setPersistent( { clientSecret: action.clientSecret } );
case ACTION_TYPES.SET_ONBOARDING_DETAILS:
return setPersistent( action.payload );
case ACTION_TYPES.SET_ONBOARDING_STEP:
return {
...state,
data: {
...( state.data || {} ),
step: action.step,
},
};
return setPersistent( { step: action.step } );
case ACTION_TYPES.SET_SANDBOX_MODE:
return setPersistent( { useSandbox: action.useSandbox } );
case ACTION_TYPES.SET_MANUAL_CONNECTION_MODE:
return setPersistent( {
useManualConnection: action.useManualConnection,
} );
default:
return state;
}
return state;
};
export default onboardingReducer;

View file

@ -2,17 +2,17 @@ import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { apiFetch } from '@wordpress/data-controls';
import { NAMESPACE } from '../constants';
import { updateOnboardingDetails } from './actions';
import { setOnboardingDetails } from './actions';
/**
* Retrieve settings from the site's REST API.
*/
export function* getOnboardingData() {
export function* getPersistentData() {
const path = `${ NAMESPACE }/onboarding`;
try {
const result = yield apiFetch( { path } );
yield updateOnboardingDetails( result );
yield setOnboardingDetails( result );
} catch ( e ) {
yield dispatch( 'core/notices' ).createErrorNotice(
__(

View file

@ -1,4 +1,4 @@
const EMPTY_OBJ = {};
const EMPTY_OBJ = Object.freeze( {} );
const getOnboardingState = ( state ) => {
if ( ! state ) {
@ -8,14 +8,11 @@ const getOnboardingState = ( state ) => {
return state.onboarding || EMPTY_OBJ;
};
export const getOnboardingData = ( state ) => {
export const getPersistentData = ( state ) => {
return getOnboardingState( state ).data || EMPTY_OBJ;
};
export const isSaving = ( state ) => {
return getOnboardingState( state ).isSaving || false;
};
export const getOnboardingStep = ( state ) => {
return getOnboardingData( state ).step || 0;
export const getTransientData = ( state ) => {
const { data, ...transientState } = getOnboardingState( state );
return transientState || EMPTY_OBJ;
};

View file

@ -0,0 +1,99 @@
<?php
/**
* Abstract Data Model Base Class
*
* @package WooCommerce\PayPalCommerce\Settings\Data
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data;
use RuntimeException;
/**
* Abstract class AbstractDataModel
*
* Provides a base implementation for data models that can be serialized to and from arrays,
* and provide persistence capabilities.
*/
abstract class AbstractDataModel {
/**
* Stores the model data.
*
* @var array
*/
protected array $data = array();
/**
* Option key for WordPress storage.
* Must be overridden by the child class!
*/
protected const OPTION_KEY = '';
/**
* Default values for the model.
* Child classes should override this method to define their default structure.
*
* @return array
*/
abstract protected function get_defaults() : array;
/**
* Constructor.
*
* @throws RuntimeException If the OPTION_KEY is not defined in the child class.
*/
public function __construct() {
if ( empty( static::OPTION_KEY ) ) {
throw new RuntimeException( 'OPTION_KEY must be defined in child class.' );
}
$this->data = $this->get_defaults();
$this->load();
}
/**
* Loads the model data from WordPress options.
*/
public function load() : void {
$saved_data = get_option( static::OPTION_KEY, array() );
$this->data = array_merge( $this->data, $saved_data );
}
/**
* Saves the model data to WordPress options.
*/
public function save() : void {
update_option( static::OPTION_KEY, $this->data );
}
/**
* Gets all model data as an array.
*
* @return array
*/
public function to_array() : array {
return array_merge( array(), $this->data );
}
/**
* Sets all model data from an array.
*
* @param array $data The model data.
*/
public function from_array( array $data ) : void {
foreach ( $data as $key => $value ) {
if ( ! array_key_exists( $key, $this->data ) ) {
continue;
}
$setter = "set_$key";
if ( method_exists( $this, $setter ) ) {
$this->$setter( $value );
} else {
$this->data[ $key ] = $value;
}
}
}
}

View file

@ -1,6 +1,6 @@
<?php
/**
* Settings container class
* Onboarding Profile class
*
* @package WooCommerce\PayPalCommerce\Settings\Data
*/
@ -10,33 +10,125 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data;
/**
* Class OnboardingProfile
*
* This class serves as a container for managing the onboarding profile details
* within the WooCommerce PayPal Commerce plugin. It provides methods to retrieve
* and save the onboarding profile data using WordPress options.
*/
class OnboardingProfile {
class OnboardingProfile extends AbstractDataModel {
/**
* Options key where profile details are stored.
* Option key where profile details are stored.
*
* @var string
*/
private const KEY = 'woocommerce-ppcp-data-onboarding';
protected const OPTION_KEY = 'woocommerce-ppcp-data-onboarding';
/**
* Returns the current onboarding profile details.
* Get default values for the model.
*
* @return array
*/
public function get_data() : array {
return get_option( self::KEY, array() );
protected function get_defaults() : array {
return array(
'step' => 0,
'use_sandbox' => false,
'use_manual_connection' => false,
'client_id' => '',
'client_secret' => '',
);
}
// -----
/**
* Gets the 'step' setting.
*
* @return int
*/
public function get_step() : int {
return (int) $this->data['step'];
}
/**
* Saves the onboarding profile details.
* Sets the 'step' setting.
*
* @param array $data The profile details to save.
* @param int $step Whether to use sandbox mode.
*/
public function save_data( array $data ) : void {
update_option( self::KEY, $data );
public function set_step( int $step ) : void {
$this->data['step'] = $step;
}
/**
* Gets the 'use sandbox' setting.
*
* @return bool
*/
public function get_use_sandbox() : bool {
return (bool) $this->data['use_sandbox'];
}
/**
* Sets the 'use sandbox' setting.
*
* @param bool $use_sandbox Whether to use sandbox mode.
*/
public function set_use_sandbox( bool $use_sandbox ) : void {
$this->data['use_sandbox'] = $use_sandbox;
}
/**
* Gets the 'use manual connection' setting.
*
* @return bool
*/
public function get_use_manual_connection() : bool {
return (bool) $this->data['use_manual_connection'];
}
/**
* Sets the 'use manual connection' setting.
*
* @param bool $use_manual_connection Whether to use manual connection.
*/
public function set_use_manual_connection( bool $use_manual_connection ) : void {
$this->data['use_manual_connection'] = $use_manual_connection;
}
/**
* Gets the client ID.
*
* @return string
*/
public function get_client_id() : string {
return $this->data['client_id'];
}
/**
* Sets the client ID.
*
* @param string $client_id The client ID.
*/
public function set_client_id( string $client_id ) : void {
$this->data['client_id'] = sanitize_text_field( $client_id );
}
/**
* Gets the client secret.
*
* @return string
*/
public function get_client_secret() : string {
return $this->data['client_secret'];
}
/**
* Sets the client secret.
*
* @param string $client_secret The client secret.
*/
public function set_client_secret( string $client_secret ) : void {
$this->data['client_secret'] = sanitize_text_field( $client_secret );
}
}

View file

@ -17,7 +17,8 @@ use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
/**
* REST controller for the onboarding module.
*
* Responsible for persisting and loading the state of the onboarding wizard.
* This API acts as the intermediary between the "external world" and our
* internal data model.
*/
class OnboardingRestEndpoint extends RestEndpoint {
/**
@ -32,7 +33,35 @@ class OnboardingRestEndpoint extends RestEndpoint {
*
* @var OnboardingProfile
*/
protected $profile;
protected OnboardingProfile $profile;
/**
* Field mapping for request to profile transformation.
*
* @var array
*/
private array $field_map = array(
'step' => array(
'js_name' => 'step',
'sanitize' => 'to_number',
),
'use_sandbox' => array(
'js_name' => 'useSandbox',
'sanitize' => 'to_boolean',
),
'use_manual_connection' => array(
'js_name' => 'useManualConnection',
'sanitize' => 'to_boolean',
),
'client_id' => array(
'js_name' => 'clientId',
'sanitize' => 'sanitize_text_field',
),
'client_secret' => array(
'js_name' => 'clientSecret',
'sanitize' => 'sanitize_text_field',
),
);
/**
* Constructor.
@ -73,42 +102,35 @@ class OnboardingRestEndpoint extends RestEndpoint {
}
/**
* Returns an object with all details of the current onboarding wizard
* progress.
* Returns all details of the current onboarding wizard progress.
*
* @return WP_REST_Response The current state of the onboarding wizard.
*/
public function get_details() : WP_REST_Response {
$details = $this->profile->get_data();
$js_data = $this->sanitize_for_javascript(
$this->profile->to_array(),
$this->field_map
);
return rest_ensure_response( $details );
return rest_ensure_response( $js_data );
}
/**
* Receives an object with onboarding details and persists it in the DB.
* Updates onboarding details based on the request.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response The current state of the onboarding wizard.
* @return WP_REST_Response The updated state of the onboarding wizard.
*/
public function update_details( WP_REST_Request $request ) : WP_REST_Response {
$details = $this->profile->get_data();
$wp_data = $this->sanitize_for_wordpress(
$request->get_params(),
$this->field_map
);
$get_param = fn( $key ) => wc_clean( wp_unslash( $request->get_param( $key ) ) );
$this->profile->from_array( $wp_data );
$this->profile->save();
$raw_step = $get_param( 'step' );
$raw_completed = $get_param( 'completed' );
if ( is_numeric( $raw_step ) ) {
$details['step'] = intval( $raw_step );
}
if ( null !== $raw_completed ) {
$details['completed'] = (bool) $raw_completed;
}
$this->profile->save_data( $details );
return rest_ensure_response( $details );
return $this->get_details();
}
}

View file

@ -33,4 +33,93 @@ class RestEndpoint extends WC_REST_Controller {
public function check_permission() : bool {
return current_user_can( 'manage_woocommerce' );
}
/**
* Sanitizes parameters based on a field mapping.
*
* This method iterates through a field map, applying sanitization methods
* to the corresponding values in the input parameters array.
*
* @param array $params The input parameters to sanitize.
* @param array $field_map An associative array mapping profile keys to sanitization rules.
* Each rule should have 'js_name' and 'sanitize' keys.
*
* @return array An array of sanitized parameters.
*/
protected function sanitize_for_wordpress( array $params, array $field_map ) : array {
$sanitized = array();
foreach ( $field_map as $key => $details ) {
$source_key = $details['js_name'] ?? '';
$sanitation_cb = $details['sanitize'] ?? null;
if ( ! $source_key || ! isset( $params[ $source_key ] ) ) {
continue;
}
$value = $params[ $source_key ];
if ( null === $sanitation_cb ) {
$sanitized[ $key ] = $value;
} elseif ( method_exists( $this, $sanitation_cb ) ) {
$sanitized[ $key ] = $this->{$sanitation_cb}( $value );
} elseif ( is_callable( $sanitation_cb ) ) {
$sanitized[ $key ] = $sanitation_cb( $value );
}
}
return $sanitized;
}
/**
* Sanitizes data for JavaScript based on a field mapping.
*
* This method transforms the input data array according to the provided field map,
* renaming keys to their JavaScript equivalents as specified in the mapping.
*
* @param array $data The input data array to be sanitized.
* @param array $field_map An associative array mapping PHP keys to JavaScript key names.
* Each element should have a 'js_name' key specifying the JavaScript
* name.
*
* @return array An array of sanitized data with keys renamed for JavaScript use.
*/
protected function sanitize_for_javascript( array $data, array $field_map ) : array {
$sanitized = array();
foreach ( $field_map as $key => $details ) {
$output_key = $details['js_name'] ?? '';
if ( ! $output_key || ! isset( $data[ $key ] ) ) {
continue;
}
$sanitized[ $output_key ] = $data[ $key ];
}
return $sanitized;
}
/**
* Convert a value to a boolean.
*
* @param mixed $value The value to convert.
*
* @return bool|null The boolean value, or null if not set.
*/
protected function to_boolean( $value ) : ?bool {
return $value !== null ? (bool) $value : null;
}
/**
* Convert a value to a number.
*
* @param mixed $value The value to convert.
*
* @return int|float|null The numeric value, or null if not set.
*/
protected function to_number( $value ) {
return $value !== null ? ( is_numeric( $value ) ? $value + 0 : null ) : null;
}
}