diff --git a/.eslintrc.js b/.eslintrc.js index e878037..6fafeb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -81,6 +81,7 @@ module.exports = { definedTypes: [ ...jsdocConfig.rules[ 'jsdoc/no-undefined-types' ][ 1 ] .definedTypes, + 'rfw_documentation_link_click', ], }, ], diff --git a/.externalized.json b/.externalized.json index 18ff28b..604b041 100644 --- a/.externalized.json +++ b/.externalized.json @@ -1 +1 @@ -["@woocommerce/components","@woocommerce/navigation","@woocommerce/settings","@wordpress/api-fetch","@wordpress/components","@wordpress/compose","@wordpress/data","@wordpress/element","@wordpress/hooks","@wordpress/i18n","@wordpress/url","lodash","react","react/jsx-runtime"] \ No newline at end of file +["@woocommerce/components","@woocommerce/currency","@woocommerce/navigation","@woocommerce/number","@woocommerce/settings","@wordpress/api-fetch","@wordpress/components","@wordpress/compose","@wordpress/data","@wordpress/element","@wordpress/hooks","@wordpress/i18n","@wordpress/primitives","@wordpress/url","lodash","react","react/jsx-runtime"] \ No newline at end of file diff --git a/includes/API/AdPartner/AdAccountsApi.php b/includes/API/AdPartner/AdAccountsApi.php index ee3e337..dc6ee6e 100644 --- a/includes/API/AdPartner/AdAccountsApi.php +++ b/includes/API/AdPartner/AdAccountsApi.php @@ -85,7 +85,7 @@ public function get() { } return $this->wcs->proxy_get( - '/v3/ad_accounts/' . $ad_account_id + '/ads/ad_accounts/' . $ad_account_id ); } } diff --git a/includes/API/Site/Controllers/RedditConnectionController.php b/includes/API/Site/Controllers/RedditConnectionController.php index b1d62fc..cc756b1 100644 --- a/includes/API/Site/Controllers/RedditConnectionController.php +++ b/includes/API/Site/Controllers/RedditConnectionController.php @@ -321,6 +321,7 @@ public function delete_connection() { Options::delete( OptionDefaults::CATALOG_ID ); Options::delete( OptionDefaults::FEED_STATUS ); Options::delete( OptionDefaults::WCS_PRODUCTS_TOKEN ); + Options::delete( OptionDefaults::ADS_ACCOUNT_CURRENCY ); Transients::delete( TransientDefaults::REDDIT_ACCOUNT_EMAIL ); Transients::delete( TransientDefaults::PIXEL_SCRIPT ); @@ -401,6 +402,14 @@ public function do_config( WP_REST_Request $request ) { } } + // Set the ad account currency. + $ad_account = $this->ad_partner_api->ad_accounts->get(); + if ( ! is_wp_error( $ad_account ) ) { + $ad_account_data = $ad_account->get_data(); + $ad_account_currency = $ad_account_data['data']['currency'] ?? ''; + Options::set( OptionDefaults::ADS_ACCOUNT_CURRENCY, $ad_account_currency ); + } + /** * Triggers when the Reddit onboarding process is completed. * @@ -420,6 +429,9 @@ public function do_config( WP_REST_Request $request ) { * @return WP_REST_Response */ public function get_connection_details() { + $currency = Options::get( OptionDefaults::ADS_ACCOUNT_CURRENCY ); + $symbol = html_entity_decode( get_woocommerce_currency_symbol( $currency ), ENT_QUOTES ); + return rest_ensure_response( array( 'business_id' => Options::get( OptionDefaults::BUSINESS_ID ), @@ -427,6 +439,8 @@ public function get_connection_details() { 'ad_account_id' => Options::get( OptionDefaults::AD_ACCOUNT_ID ), 'ad_account_name' => Options::get( OptionDefaults::AD_ACCOUNT_NAME ), 'pixel_id' => Options::get( OptionDefaults::PIXEL_ID ), + 'currency' => $currency, + 'symbol' => $symbol, ) ); } diff --git a/includes/Utils/Storage/OptionDefaults.php b/includes/Utils/Storage/OptionDefaults.php index 96ab96f..03012cf 100644 --- a/includes/Utils/Storage/OptionDefaults.php +++ b/includes/Utils/Storage/OptionDefaults.php @@ -168,6 +168,13 @@ final class OptionDefaults { */ public const WCS_PRODUCTS_TOKEN = 'wcs_products_token'; + /** + * Option key to store the Ad Partner's currency. + * + * @since 0.1.0 + */ + public const ADS_ACCOUNT_CURRENCY = 'ad_account_currency'; + /** * Returns default values for all known Ad Partner options. * @@ -198,6 +205,7 @@ public static function get_all(): array { self::EXPORT_PRODUCT_IDS => array(), self::LAST_EXPORT_TIMESTAMP => 0, self::WCS_PRODUCTS_TOKEN => '', + self::ADS_ACCOUNT_CURRENCY => get_woocommerce_currency(), ); } } diff --git a/js/src/components/adaptive-form/adaptive-form-context.js b/js/src/components/adaptive-form/adaptive-form-context.js new file mode 100644 index 0000000..9bd01dd --- /dev/null +++ b/js/src/components/adaptive-form/adaptive-form-context.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * @typedef {import('react').React} React + */ + +/** + * @typedef {Object} InputProps + * @property {*} value Form value. + * @property {boolean} checked Form value converted to boolean. + * @property {*} selected Form value. + * @property {(value: Event | *) => void} onChange Function to handle onChange event. + * @property {() => void} onBlur Function to handle onBlur event. + * @property {'has-error' | undefined} className 'has-error' if the form value is invalid and marked as touched. `undefined` otherwise. + * @property {string | undefined | null} help The corresponding value in form `errors` if the form value is marked as touched. `null` otherwise. + */ + +/** + * @typedef {Object} AdaptiveFormContextAdapter + * @property {boolean} isSubmitting `true` if the form is currently being submitted. + * @property {boolean} isSubmitted Set to `true` after the form is submitted. Initial value and during submission are set to `false`. + * @property { HTMLElement | null} submitter Set to the element triggering the `handleSubmit` callback until the processing of `onSubmit` is completed. `null` otherwise. + * @property {number} validationRequestCount The current validation request count. + * @property {boolean} requestedShowValidation Whether have requested verification. It will be reset to false after calling hideValidation. + * @property {() => void} showValidation Increase the validation request count by 1. + * @property {() => void} hideValidation Reset the validation request count to 0. + */ + +/** + * @typedef {Object} AdaptiveFormContext + * @property {Object} values Form values, e.g. `{ nickname: '' age: 0 }`. + * @property {Object} errors Object with key-value pairs representing errors for form values. Empty object if no errors. For example, `{ nickname: 'Nickname is required.' }`. + * @property {Object} touched Object with key-value pairs representing the corresponding input fields of the form values have received focus, e.g. `{ nickname: true }`. + * @property {boolean} isValidForm `true` if form values pass the validation. + * @property {boolean} isDirty `true` after any of the form values is modified. + * @property {(name: string, value: *) => void} setValue Function to set a form value. + * @property {(name: string) => InputProps} getInputProps Function to get the corresponding input props by a name of the form `values`. The returned props is usually used to assign to input field. + * @property {() => Promise} handleSubmit Function to trigger form submission. + * @property {(initialValues: Object) => void} resetForm Function to reset form with given initial values. + * @property {React.Dispatch>} setTouched Function to update the `touched` state, e.g. `setTouched( { nickname: false } )`. + * @property {AdaptiveFormContextAdapter} adapter Additional enhancements to AdaptiveForm. + */ + +export const AdaptiveFormContext = createContext( null ); + +/** + * AdaptiveForm's context hook. + * + * @return {AdaptiveFormContext} AdaptiveForm's context. + * @throws Will throw an error if its context provider is not existing in its parents. + */ +export function useAdaptiveFormContext() { + const adaptiveFormContext = useContext( AdaptiveFormContext ); + + if ( adaptiveFormContext === null ) { + throw new Error( + 'useAdaptiveFormContext was used outside of its context provider AdaptiveForm.' + ); + } + + return adaptiveFormContext; +} + +/** + * AdaptiveForm's input props hook. + * + * @param {string} key Key of the form value. + * @param {string} [validationKey=key] Key of the form value to be used for validation. + * + * @return {Object} Props for an adaptive form input. + */ +export function useAdaptiveFormInputProps( key, validationKey = key ) { + const { getInputProps, adapter } = useAdaptiveFormContext(); + + return { + ...getInputProps( key ), + helper: adapter.renderRequestedValidation( validationKey ), + }; +} diff --git a/js/src/components/adaptive-form/adaptive-form.js b/js/src/components/adaptive-form/adaptive-form.js new file mode 100644 index 0000000..9d85079 --- /dev/null +++ b/js/src/components/adaptive-form/adaptive-form.js @@ -0,0 +1,244 @@ +/** + * External dependencies + */ +import { + useRef, + useState, + useEffect, + useCallback, + useImperativeHandle, + forwardRef, +} from '@wordpress/element'; +import { Form } from '@woocommerce/components'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import useIsMounted from '~/hooks/useIsMounted'; +import { AdaptiveFormContext } from './adaptive-form-context'; + +function isEvent( value ) { + return ( value?.nativeEvent || value ) instanceof Event; +} + +/** + * @typedef {Object} AdaptiveFormHandler + * @property {(initialValues: Object) => void} resetForm Reset form with given initial values. + * @property {(name: string, value: *) => void} setValue Set the `name` field of the form states to the given `value`. + */ + +/** + * @typedef {Object} AdaptiveFormSubmissionEnhancer + * @property {HTMLElement} submitter The element triggering the `handleSubmit` callback. + * @property {() => void} signalFailedSubmission Function to signal AdaptiveForm that the submission is failed so that the AdaptiveForm will reset the submission states. + */ + +const SUBMITTING = 'submitting'; +const SUBMITTED = 'submitted'; + +/** + * Renders an adapted form component that wraps the `Form` of `@woocommerce/components` with + * several workarounds in order to be compatible with WC 6.9 to 7.1. + * + * This component includes additional enhancements to make AdaptiveForm have more useful or + * reusable features. It could also be the playground of the practical instances before pushing + * them upstream. + * + * @param {Object} props React props. + * @param {(values: Object, submissionEnhancer: AdaptiveFormSubmissionEnhancer) => void} [props.onSubmit] Function to call when a form is requesting submission. + * @param {(formContext: AdaptiveFormContext) => Object} [props.extendAdapter] Function to get the custom adapter to be appended to form's adapter so that they can be accessed via formContext.adapter. + * @param {(formContext: AdaptiveFormContext) => JSX.Element | JSX.Element} props.children Children to be rendered. Could be a render prop function. + * @param {import('react').MutableRefObject} ref React ref to be attached to the handler of this component. + */ +function AdaptiveForm( { onSubmit, extendAdapter, children, ...props }, ref ) { + const formRef = useRef(); + const adapterRef = useRef( { submitter: null } ); + const [ batchQueue, setBatchQueue ] = useState( [] ); + const [ delegation, setDelegation ] = useState(); + + const queueSetValue = useCallback( ( ...args ) => { + setBatchQueue( ( items ) => [ ...items, args ] ); + }, [] ); + + useEffect( () => { + if ( delegation ) { + adapterRef.current.setValueCompatibly( ...delegation ); + } + }, [ delegation ] ); + + // Since WC 6.9, the exposed interfaces were completely changed. Given that + // there is no longer a regular interface for updating Form values externally, + // this is a workaround to add the access of `setValue` for external use. + // Ref: https://github.com/woocommerce/woocommerce/blob/6.9.0/packages/js/components/src/form/form.tsx#L125-L127 + useImperativeHandle( ref, () => ( { + // Placing `setValue` before object spreading is for compatibility <= 6.8 + setValue: queueSetValue, + ...formRef.current, + } ) ); + + /* === Start of enhancement-related codes === */ + + const isMounted = useIsMounted(); + + // Add states for form user sides to determine whether to show validation results. + const [ validationRequestCount, setValidationRequestCount ] = useState( 0 ); + const showValidation = useCallback( () => { + setValidationRequestCount( ( count ) => count + 1 ); + }, [] ); + const hideValidation = useCallback( () => { + setValidationRequestCount( 0 ); + }, [] ); + + // Add `isSubmitting` and `isSubmitted` states for facilitating across multiple layers of + // component controlling, such as disabling inputs or buttons. + const [ submission, setSubmission ] = useState( null ); + const isSubmitting = submission === SUBMITTING; + const isSubmitted = submission === SUBMITTED; + + if ( onSubmit ) { + props.onSubmit = async function ( values ) { + setSubmission( SUBMITTING ); + + let shouldResetSubmission = false; + const submissionEnhancer = { + submitter: adapterRef.current.submitter, + signalFailedSubmission() { + shouldResetSubmission = true; + }, + }; + + await onSubmit.call( this, values, submissionEnhancer ); + + if ( isMounted() ) { + adapterRef.current.submitter = null; + + if ( shouldResetSubmission ) { + setSubmission( null ); + } else { + setSubmission( SUBMITTED ); + } + } + }; + } + + /* === End of enhancement-related codes === */ + + return ( +
+ { ( { setValue, getInputProps, handleSubmit, ...formContext } ) => { + // Since WC 6.9, the original Form is re-implemented as Functional component from + // Class component. But when `setValue` is called, the closure of `values` is + // referenced to the currently rendered snapshot states instead of a reference + // that is continuously kept up to date to handle batch updates. + // + // Therefore, if the `setValue` is called more than once synchronously, the later call + // will overwrite the previous update one by one, so that only the last call is updated + // in the end. + // + // Ref: + // - https://github.com/woocommerce/woocommerce/blob/6.8.2/packages/js/components/src/form/index.js#L42-L46 + // - https://github.com/woocommerce/woocommerce/blob/6.9.0/packages/js/components/src/form/form.tsx#L134-L138 + adapterRef.current.setValueCompatibly = ( name, value ) => { + // WC 7.1 workaround handles the issue that after calling `setValue` to update + // a single value, all form `values` will be triggered `onChange` individually, + // even if those values don't actually change. + // + // Ref: + // - https://github.com/woocommerce/woocommerce/blob/7.1.0/packages/js/components/src/form/form.tsx#L209-L211 + // - https://github.com/woocommerce/woocommerce/blob/7.1.0/packages/js/components/src/form/form.tsx#L182-L197 + if ( formContext.setValues ) { + formContext.setValues( { [ name ]: value } ); + } else { + // WC < 7.1 goes here as `setValues` was introduced in 7.1. + setValue( name, value ); + } + }; + + // WC 6.9 workaround makes the reference of `formContext.setValue` stable to prevent + // an infinite re-rendering loop when using `setValue` within `useEffect`. + // Ref: https://github.com/woocommerce/woocommerce/blob/6.9.0/packages/js/components/src/form/form.tsx#L177 + formContext.setValue = queueSetValue; + + // The same WC 7.1 workaround as `setValueCompatibly` above, avoiding the + // `getInputProps(name).onChange` calling the problematic `setValue`. + // + // Ref: + // - https://github.com/woocommerce/woocommerce/blob/7.1.0/packages/js/components/src/form/form.tsx#L291-L293 + // - https://github.com/woocommerce/woocommerce/blob/7.1.0/packages/js/components/src/form/form.tsx#L215-L232 + formContext.getInputProps = ( name ) => { + const inputProps = getInputProps( name ); + + function onChange( value ) { + // Get value from SyntheticEvent or native Event. + if ( isEvent( value ) ) { + if ( value.target.type === 'checkbox' ) { + value = ! get( inputProps.values, name ); + } else { + value = value.target.value; + } + } + + adapterRef.current.setValueCompatibly( name, value ); + } + return { + ...inputProps, + onChange, + }; + }; + + // Related to WC 6.9. Only one delegate can be consumed at a time in this render prop to + // ensure the updating states will always be the latest when calling. + if ( batchQueue.length ) { + // Use `setTimeout` to avoid the warning of request state updates while rendering. + // Mutating a React hook state is an anti-pattern in most cases. Here is done intentionally + // because it's necessary to ensure this component will be triggered re-rendering through + // `setBatchQueue`, but also to avoid calling `setBatchQueue` here and triggering additional + // rendering again. + setTimeout( () => setDelegation( batchQueue.shift() ) ); + } + + /* === Start of enhancement-related codes === */ + + // Keep the target element for identifying which one triggered the submission when + // there are multiple submit buttons. + formContext.handleSubmit = function ( event ) { + adapterRef.current.submitter = event?.currentTarget || null; + return handleSubmit.call( this, event ); + }; + + formContext.adapter = { + isSubmitting, + isSubmitted, + submitter: adapterRef.current.submitter, + validationRequestCount, + requestedShowValidation: validationRequestCount > 0, + showValidation, + hideValidation, + }; + + if ( typeof extendAdapter === 'function' ) { + const customAdapter = extendAdapter( formContext ); + Object.assign( formContext.adapter, customAdapter ); + } + + /* === End of enhancement-related codes === */ + + // Since WC 6.9, it added the ability to obtain Form context via `useFormContext` hook. + // However, if a component uses that hook, the compatible fixes in this component won't + // be applied to form context. Therefore, here creates a context as well to make + // AdaptiveForm's context also apply compatible fixes. + // Ref: https://github.com/woocommerce/woocommerce/blob/6.9.0/packages/js/components/src/form/form.tsx#L277-L317 + return ( + + { typeof children === 'function' + ? children( formContext ) + : children } + + ); + } } +
+ ); +} + +export default forwardRef( AdaptiveForm ); diff --git a/js/src/components/adaptive-form/adaptive-form.test.js b/js/src/components/adaptive-form/adaptive-form.test.js new file mode 100644 index 0000000..fc9bdb8 --- /dev/null +++ b/js/src/components/adaptive-form/adaptive-form.test.js @@ -0,0 +1,350 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import AdaptiveForm from './adaptive-form'; + +const alwaysValid = () => ( {} ); + +const delayOneSecond = () => new Promise( ( r ) => setTimeout( r, 1000 ) ); + +const setupUserWithFakeTimers = () => { + jest.useFakeTimers(); + return userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); +}; + +describe( 'AdaptiveForm', () => { + afterEach( () => { + jest.useRealTimers(); + jest.clearAllTimers(); + } ); + + it( 'Should have `formContext.adapter` with functions and initial states', () => { + const children = jest.fn(); + + render( + { children } + ); + + const formContextSchema = expect.objectContaining( { + adapter: expect.objectContaining( { + isSubmitting: false, + isSubmitted: false, + submitter: null, + validationRequestCount: 0, + requestedShowValidation: false, + showValidation: expect.any( Function ), + hideValidation: expect.any( Function ), + } ), + } ); + + expect( children ).toHaveBeenLastCalledWith( formContextSchema ); + } ); + + it( 'Should provide `isSubmitting` and `isSubmitted` states via adapter', async () => { + const user = setupUserWithFakeTimers(); + const inspect = jest.fn(); + + render( + + { ( formContext ) => { + const { isSubmitting, isSubmitted } = formContext.adapter; + inspect( isSubmitting, isSubmitted ); + + return + + + + + ); + } } + + ); + + const [ buttonA, buttonB, buttonC ] = screen.getAllByRole( 'button' ); + + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 0 ); + + // Click button A to test if the form indicates that button A triggered a submission. + await user.click( buttonA ); + await act( async () => { + jest.runOnlyPendingTimers(); + } ); + + expect( inspectSubmitter ).toHaveBeenCalledWith( buttonA ); + expect( inspectSubmitter ).toHaveBeenLastCalledWith( null ); + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 1 ); + expect( inspectOnSubmit ).toHaveBeenLastCalledWith( + {}, + expect.objectContaining( { submitter: buttonA } ) + ); + + // Click button B to test if the form indicates that button B triggered another submission. + inspectSubmitter.mockClear(); + + await user.click( buttonB ); + await act( async () => { + jest.runOnlyPendingTimers(); + } ); + + expect( inspectSubmitter ).toHaveBeenCalledWith( buttonB ); + expect( inspectSubmitter ).toHaveBeenLastCalledWith( null ); + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 2 ); + expect( inspectOnSubmit ).toHaveBeenLastCalledWith( + {}, + expect.objectContaining( { submitter: buttonB } ) + ); + + // Click button C to test if the form stays `submitter` as `null` if `handleSubmit` + // is triggered without a corresponding event. + inspectSubmitter.mockClear(); + + await user.click( buttonC ); + + expect( inspectSubmitter ).toHaveBeenCalled(); + inspectSubmitter.mock.calls.forEach( ( args ) => { + expect( args ).toEqual( [ null ] ); + } ); + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 3 ); + expect( inspectOnSubmit ).toHaveBeenLastCalledWith( + {}, + expect.objectContaining( { submitter: null } ) + ); + } ); + + it( 'Should be able to accumulate and reset the validation request count and requested state', async () => { + const user = userEvent.setup(); + const inspect = jest.fn(); + + render( + + { ( { adapter } ) => { + inspect( + adapter.requestedShowValidation, + adapter.validationRequestCount + ); + + return ( + <> + + + + + ); + } } + + ); + + const requestButton = screen.getByRole( 'button', { name: 'request' } ); + const resetButton = screen.getByRole( 'button', { name: 'reset' } ); + + expect( inspect ).toHaveBeenLastCalledWith( false, 0 ); + + await user.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( true, 1 ); + + await user.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( true, 2 ); + + await user.click( resetButton ); + + expect( inspect ).toHaveBeenLastCalledWith( false, 0 ); + } ); + + describe( 'Compatibility patches', () => { + it( 'Should update all changes to values for the synchronous multiple calls to `setValue`', async () => { + const user = userEvent.setup(); + + render( + + { ( { setValue, values } ) => { + return ( + <> +