diff --git a/.eslintrc.js b/.eslintrc.js index 5f5aec8b55..d7456a8872 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,6 +38,7 @@ module.exports = { }, globals: { getComputedStyle: 'readonly', + navigator: 'readonly', }, rules: { '@wordpress/i18n-text-domain': [ diff --git a/changelog.txt b/changelog.txt index 732df84baf..5d99eb8d33 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,9 @@ *** Google for WooCommerce Changelog *** += Unreleased = +* Migrate Merchant Center reporting to the Merchant API +* The `gla_merchant_query_response` filter is removed as part of dropping the Content API report layer. + = 3.7.3 - 2026-07-08 = * Add - Added update notification when plugin version 3.8.0 is available diff --git a/js/src/components/app-searchable-select-control/index.js b/js/src/components/app-searchable-select-control/index.js new file mode 100644 index 0000000000..3209efa0d9 --- /dev/null +++ b/js/src/components/app-searchable-select-control/index.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import SearchableSelectControl from '../searchable-select-control'; +import './index.scss'; + +/** + * Wrapper around SearchableSelectControl to apply consistent styling across the app. + */ +const AppSearchableSelectControl = ( props ) => { + const { className, ...rest } = props; + + return ( + + ); +}; + +export default AppSearchableSelectControl; diff --git a/js/src/components/app-searchable-select-control/index.scss b/js/src/components/app-searchable-select-control/index.scss new file mode 100644 index 0000000000..5121c10898 --- /dev/null +++ b/js/src/components/app-searchable-select-control/index.scss @@ -0,0 +1,66 @@ +.gla-app-searchable-select-control { + + .gla-searchable-select-control__label { + color: $gray-900; + font-size: $gla-font-smallest; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + } + + .gla-searchable-select-control__input { + position: relative; + + &::after { + background-color: currentcolor; + content: ""; + display: block; + height: 1.5rem; + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCc+PHBhdGggZmlsbD0nd2hpdGUnIGQ9J00xNS45OSAxMC44OSAxMiAxNC4zbC0zLjk5LTMuNDJMOSA5Ljc1bDMgMi41OCAzLjAxLTIuNTh6Jy8+PC9zdmc+); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + pointer-events: none; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 1.5rem; + } + } + + .woocommerce-select-control .components-base-control { + border-color: $gray-600; + border-radius: 2px; + padding: 4px 44px 4px 8px; // 44px right padding for the chevron icon. + } + + .woocommerce-select-control .woocommerce-select-control__listbox { + border: 1.5px solid var(--wp-admin-theme-color, #3858e9); + border-radius: 2px; + box-shadow: none; + top: calc(100% + 8px); + } + + .gla-searchable-select-control__helper-text { + font-style: normal; + color: $gray-700; + } + + .woocommerce-select-control__option { + font-size: $gla-font-base; + font-weight: 400; + height: unset; + line-height: $gla-line-height-medium; + min-height: unset; + padding: 6px 12px; + } + + .woocommerce-select-control__control-icon { + display: none; + } + + .woocommerce-select-control .components-base-control .woocommerce-select-control__control-input { + min-height: 26px; + } +} diff --git a/js/src/components/countries-time-input/index.js b/js/src/components/countries-time-input/index.js new file mode 100644 index 0000000000..3ed601846f --- /dev/null +++ b/js/src/components/countries-time-input/index.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { Flex, FlexBlock } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { useAdaptiveFormInputProps } from '~/components/adaptive-form'; +import MinMaxShippingTimes from './min-max-shipping-times'; +import './index.scss'; + +/** + * Input component for the estimated flat shipping time (min and max days) applied to all target countries. + */ +const CountriesTimeInput = () => { + const { value: time, onChange: onMinTimeChange } = + useAdaptiveFormInputProps( 'flat_shipping_min_time' ); + const { value: maxTime, onChange: onMaxTimeChange } = + useAdaptiveFormInputProps( 'flat_shipping_max_time' ); + + const handleBlur = ( numberValue, field ) => { + if ( field === 'time' ) { + if ( time !== numberValue ) { + onMinTimeChange( numberValue ); + } + } else if ( field === 'maxTime' && maxTime !== numberValue ) { + onMaxTimeChange( numberValue ); + } + }; + + const handleIncrement = ( numberValue, field ) => { + if ( field === 'time' ) { + onMinTimeChange( numberValue ); + } else if ( field === 'maxTime' ) { + onMaxTimeChange( numberValue ); + } + }; + + return ( + + + + + + ); +}; + +export default CountriesTimeInput; diff --git a/js/src/components/countries-time-input/index.scss b/js/src/components/countries-time-input/index.scss new file mode 100644 index 0000000000..adb716cf4e --- /dev/null +++ b/js/src/components/countries-time-input/index.scss @@ -0,0 +1,3 @@ +.gla-countries-time-input-container { + max-width: $gla-width-medium-large; +} diff --git a/js/src/components/countries-time-input/index.test.js b/js/src/components/countries-time-input/index.test.js new file mode 100644 index 0000000000..4cd7ba30f1 --- /dev/null +++ b/js/src/components/countries-time-input/index.test.js @@ -0,0 +1,157 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { render, fireEvent, waitFor } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import CountriesTimeInput from './'; +import { useAdaptiveFormInputProps } from '~/components/adaptive-form'; + +jest.mock( '~/components/adaptive-form', () => ( { + useAdaptiveFormInputProps: jest.fn(), +} ) ); + +function setupMocks( { time = 1, maxTime = 32 } = {} ) { + const onMinTimeChange = jest.fn(); + const onMaxTimeChange = jest.fn(); + + useAdaptiveFormInputProps.mockImplementation( ( key ) => { + if ( key === 'flat_shipping_min_time' ) { + return { value: time, onChange: onMinTimeChange }; + } + return { value: maxTime, onChange: onMaxTimeChange }; + } ); + + return { onMinTimeChange, onMaxTimeChange }; +} + +describe( 'CountriesTimeInput', () => { + it( 'renders two inputs with values from form context', () => { + setupMocks( { time: 3, maxTime: 7 } ); + + const { getAllByRole } = render( ); + const inputs = getAllByRole( 'textbox' ); + + expect( inputs ).toHaveLength( 2 ); + expect( inputs[ 0 ] ).toHaveValue( '3' ); + expect( inputs[ 1 ] ).toHaveValue( '7' ); + } ); + + it( 'shows an empty input for time value of 0 (Same Day placeholder)', () => { + setupMocks( { time: 0, maxTime: 5 } ); + + const { getAllByRole } = render( ); + const [ minInput ] = getAllByRole( 'textbox' ); + + expect( minInput ).toHaveValue( '' ); + } ); + + describe( 'handleBlur', () => { + it( 'calls onMinTimeChange with the new value when min input blurs with a different value', () => { + const { onMinTimeChange } = setupMocks( { time: 1 } ); + const { getAllByRole } = render( ); + const [ minInput ] = getAllByRole( 'textbox' ); + + fireEvent.blur( minInput, { target: { value: '5' } } ); + + expect( onMinTimeChange ).toHaveBeenCalledTimes( 1 ); + expect( onMinTimeChange ).toHaveBeenCalledWith( 5 ); + } ); + + it( 'does not call onMinTimeChange when min input blurs with the same value', () => { + const { onMinTimeChange } = setupMocks( { time: 1 } ); + const { getAllByRole } = render( ); + const [ minInput ] = getAllByRole( 'textbox' ); + + fireEvent.blur( minInput, { target: { value: '1' } } ); + + expect( onMinTimeChange ).not.toHaveBeenCalled(); + } ); + + it( 'calls onMaxTimeChange with the new value when max input blurs with a different value', () => { + const { onMaxTimeChange } = setupMocks( { maxTime: 32 } ); + const { getAllByRole } = render( ); + const [ , maxInput ] = getAllByRole( 'textbox' ); + + fireEvent.blur( maxInput, { target: { value: '10' } } ); + + expect( onMaxTimeChange ).toHaveBeenCalledTimes( 1 ); + expect( onMaxTimeChange ).toHaveBeenCalledWith( 10 ); + } ); + + it( 'does not call onMaxTimeChange when max input blurs with the same value', () => { + const { onMaxTimeChange } = setupMocks( { maxTime: 32 } ); + const { getAllByRole } = render( ); + const [ , maxInput ] = getAllByRole( 'textbox' ); + + fireEvent.blur( maxInput, { target: { value: '32' } } ); + + expect( onMaxTimeChange ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'handleIncrement (stepper buttons)', () => { + it( 'calls onMinTimeChange when the min increment button is pressed', async () => { + const { onMinTimeChange } = setupMocks( { time: 1 } ); + const { getAllByRole } = render( ); + const incrementButtons = getAllByRole( 'button', { + name: 'Increment', + } ); + + fireEvent.mouseDown( incrementButtons[ 0 ] ); + + await waitFor( () => { + expect( onMinTimeChange ).toHaveBeenCalledTimes( 1 ); + } ); + expect( onMinTimeChange ).toHaveBeenCalledWith( 2 ); + } ); + + it( 'calls onMaxTimeChange when the max increment button is pressed', async () => { + const { onMaxTimeChange } = setupMocks( { maxTime: 32 } ); + const { getAllByRole } = render( ); + const incrementButtons = getAllByRole( 'button', { + name: 'Increment', + } ); + + fireEvent.mouseDown( incrementButtons[ 1 ] ); + + await waitFor( () => { + expect( onMaxTimeChange ).toHaveBeenCalledTimes( 1 ); + } ); + expect( onMaxTimeChange ).toHaveBeenCalledWith( 33 ); + } ); + + it( 'calls onMinTimeChange when the min decrement button is pressed', async () => { + const { onMinTimeChange } = setupMocks( { time: 3 } ); + const { getAllByRole } = render( ); + const decrementButtons = getAllByRole( 'button', { + name: 'Decrement', + } ); + + fireEvent.mouseDown( decrementButtons[ 0 ] ); + + await waitFor( () => { + expect( onMinTimeChange ).toHaveBeenCalledTimes( 1 ); + } ); + expect( onMinTimeChange ).toHaveBeenCalledWith( 2 ); + } ); + + it( 'calls onMaxTimeChange when the max decrement button is pressed', async () => { + const { onMaxTimeChange } = setupMocks( { maxTime: 32 } ); + const { getAllByRole } = render( ); + const decrementButtons = getAllByRole( 'button', { + name: 'Decrement', + } ); + + fireEvent.mouseDown( decrementButtons[ 1 ] ); + + await waitFor( () => { + expect( onMaxTimeChange ).toHaveBeenCalledTimes( 1 ); + } ); + expect( onMaxTimeChange ).toHaveBeenCalledWith( 31 ); + } ); + } ); +} ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/min-max-shipping-times.js b/js/src/components/countries-time-input/min-max-shipping-times.js similarity index 100% rename from js/src/components/free-listings/configure-product-listings/shipping-time-setup/min-max-shipping-times.js rename to js/src/components/countries-time-input/min-max-shipping-times.js diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.js b/js/src/components/countries-time-input/time-stepper/index.js similarity index 100% rename from js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.js rename to js/src/components/countries-time-input/time-stepper/index.js diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.scss b/js/src/components/countries-time-input/time-stepper/index.scss similarity index 71% rename from js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.scss rename to js/src/components/countries-time-input/time-stepper/index.scss index 28194bf526..d9d098a7a7 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.scss +++ b/js/src/components/countries-time-input/time-stepper/index.scss @@ -1,6 +1,7 @@ .gla-countries-time-stepper { width: $gla-width-smaller; + .gla-countries-time-suffix { - margin-right: $grid-unit-05; + margin-inline-end: $grid-unit-05; } } diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.test.js b/js/src/components/countries-time-input/time-stepper/index.test.js similarity index 100% rename from js/src/components/free-listings/configure-product-listings/shipping-time-setup/time-stepper/index.test.js rename to js/src/components/countries-time-input/time-stepper/index.test.js diff --git a/js/src/components/free-listings/configure-product-listings/__snapshots__/checkErrors.test.js.snap b/js/src/components/free-listings/configure-product-listings/__snapshots__/checkErrors.test.js.snap index ef8aabd66c..c7498639c2 100644 --- a/js/src/components/free-listings/configure-product-listings/__snapshots__/checkErrors.test.js.snap +++ b/js/src/components/free-listings/configure-product-listings/__snapshots__/checkErrors.test.js.snap @@ -8,9 +8,9 @@ exports[`checkErrors Audience When the audience location option is an invalid va exports[`checkErrors Offer free shipping With flat shipping rate option When there are some non-free shipping rates, and offer free shipping is checked, and there is no minimum order amount for non-free shipping rates, should not pass 1`] = `"Please enter minimum order for free shipping."`; -exports[`checkErrors Shipping rates For flat type When there are any selected countries with shipping rates not set, should not pass 1`] = `"Please specify estimated shipping rates for all the countries, and the rate cannot be less than 0."`; +exports[`checkErrors Shipping rates For flat type When flat_shipping_rate is a negative number, should not pass 1`] = `"Please specify an estimated shipping rate."`; -exports[`checkErrors Shipping rates For flat type When there are any shipping rates is < 0, should not pass 1`] = `"Please specify estimated shipping rates for all the countries, and the rate cannot be less than 0."`; +exports[`checkErrors Shipping rates For flat type When flat_shipping_rate is undefined, should not pass 1`] = `"Please specify an estimated shipping rate."`; exports[`checkErrors Shipping rates When the type of shipping rate is an invalid value or missing, should not pass 1`] = `"Please select a shipping rate option."`; @@ -18,17 +18,13 @@ exports[`checkErrors Shipping rates When the type of shipping rate is an invalid exports[`checkErrors Shipping rates When the type of shipping rate is an invalid value or missing, should not pass 3`] = `"Please select a shipping rate option."`; -exports[`checkErrors Shipping times For flat type When minimum max_time is < 0, should not pass 1`] = `"The minimum shipping time must not be more than the maximum shipping time."`; +exports[`checkErrors Shipping times For flat type When flat_shipping_max_time is null or undefined, should not pass 1`] = `"Please specify an estimated maximum shipping time."`; -exports[`checkErrors Shipping times For flat type When minimum times is < 0, should not pass 1`] = `"Please specify estimated shipping times for all the countries, and the time cannot be less than 0."`; +exports[`checkErrors Shipping times For flat type When flat_shipping_min_time > flat_shipping_max_time, should not pass 1`] = `"The minimum shipping time must not be more than the maximum shipping time."`; -exports[`checkErrors Shipping times For flat type When there are any selected countries' shipping times is not set, should not pass 1`] = `"Please specify estimated shipping times for all the countries, and the time cannot be less than 0."`; +exports[`checkErrors Shipping times For flat type When flat_shipping_min_time is null or undefined, should not pass 1`] = `"Please specify an estimated minimum shipping time."`; -exports[`checkErrors Shipping times For flat type When there are any shipping times are < 0, should not pass 1`] = `"Please specify estimated shipping times for all the countries, and the time cannot be less than 0."`; - -exports[`checkErrors Shipping times For flat type shouldnt pass if min or max time is null 1`] = `"The minimum shipping time must not be more than the maximum shipping time."`; - -exports[`checkErrors Shipping times For flat type shouldnt pass if min time is bigger than max time 1`] = `"The minimum shipping time must not be more than the maximum shipping time."`; +exports[`checkErrors Shipping times For flat type When flat_shipping_min_time or flat_shipping_max_time is < 0, should not pass 1`] = `"The shipping time cannot be less than 0."`; exports[`checkErrors Shipping times When the type of shipping time is an invalid value or missing, should not pass 1`] = `"Please select a shipping time option."`; diff --git a/js/src/components/free-listings/configure-product-listings/checkErrors.js b/js/src/components/free-listings/configure-product-listings/checkErrors.js index 2f3589c8d6..cc90110b3a 100644 --- a/js/src/components/free-listings/configure-product-listings/checkErrors.js +++ b/js/src/components/free-listings/configure-product-listings/checkErrors.js @@ -7,7 +7,7 @@ const validlocationSet = new Set( [ 'all', 'selected' ] ); const validShippingRateSet = new Set( [ 'automatic', 'flat', 'manual' ] ); const validShippingTimeSet = new Set( [ 'flat', 'manual' ] ); -const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { +const checkErrors = ( values ) => { const errors = {}; // Check audience. @@ -37,11 +37,11 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { if ( values.shipping_rate === 'flat' && - ( values.shipping_country_rates.length < finalCountryCodes.length || - values.shipping_country_rates.some( ( el ) => el.rate < 0 ) ) + ( values.flat_shipping_rate === undefined || + values.flat_shipping_rate < 0 ) ) { - errors.shipping_country_rates = __( - 'Please specify estimated shipping rates for all the countries, and the rate cannot be less than 0.', + errors.flat_shipping_rate = __( + 'Please specify an estimated shipping rate.', 'google-listings-and-ads' ); } @@ -52,7 +52,7 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { if ( values.shipping_rate === 'flat' ) { if ( values.offer_free_shipping === true && - values.shipping_country_rates.every( + ( values.shipping_country_rates ?? [] ).every( ( shippingRate ) => shippingRate.options.free_shipping_threshold === undefined ) @@ -74,31 +74,39 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { ); } - if ( - values.shipping_time === 'flat' && - ( shippingTimes.length < finalCountryCodes.length || - shippingTimes.some( - ( el ) => - el.time < 0 || - el.time === null || - el.maxTime < 0 || - el.maxTime === null - ) ) - ) { - errors.shipping_country_times = __( - 'Please specify estimated shipping times for all the countries, and the time cannot be less than 0.', - 'google-listings-and-ads' - ); - } - - if ( - values.shipping_time === 'flat' && - shippingTimes.some( ( el ) => el.time > el.maxTime ) - ) { - errors.shipping_country_times = __( - 'The minimum shipping time must not be more than the maximum shipping time.', - 'google-listings-and-ads' - ); + if ( values.shipping_time === 'flat' ) { + if ( + values.flat_shipping_min_time === null || + values.flat_shipping_min_time === undefined + ) { + errors.flat_shipping_times = __( + 'Please specify an estimated minimum shipping time.', + 'google-listings-and-ads' + ); + } else if ( + values.flat_shipping_max_time === null || + values.flat_shipping_max_time === undefined + ) { + errors.flat_shipping_times = __( + 'Please specify an estimated maximum shipping time.', + 'google-listings-and-ads' + ); + } else if ( + values.flat_shipping_min_time < 0 || + values.flat_shipping_max_time < 0 + ) { + errors.flat_shipping_times = __( + 'The shipping time cannot be less than 0.', + 'google-listings-and-ads' + ); + } else if ( + values.flat_shipping_min_time > values.flat_shipping_max_time + ) { + errors.flat_shipping_times = __( + 'The minimum shipping time must not be more than the maximum shipping time.', + 'google-listings-and-ads' + ); + } } return errors; diff --git a/js/src/components/free-listings/configure-product-listings/checkErrors.test.js b/js/src/components/free-listings/configure-product-listings/checkErrors.test.js index f6322f3c4a..7e6f6bf7bf 100644 --- a/js/src/components/free-listings/configure-product-listings/checkErrors.test.js +++ b/js/src/components/free-listings/configure-product-listings/checkErrors.test.js @@ -14,14 +14,6 @@ function toRates( ...tuples ) { } ) ); } -function toTimes( ...tuples ) { - return tuples.map( ( [ countryCode, time, maxTime ] ) => ( { - countryCode, - time, - maxTime, - } ) ); -} - const defaultFormValues = { shipping_country_rates: [], }; @@ -40,20 +32,21 @@ describe( 'checkErrors', () => { location: 'selected', countries: [ 'US', 'JP' ], shipping_rate: 'flat', + flat_shipping_rate: 10, shipping_time: 'flat', + flat_shipping_min_time: 3, + flat_shipping_max_time: 5, shipping_country_rates: toRates( [ 'US', 10 ], [ 'JP', 30, 88 ] ), offer_free_shipping: true, }; - const times = toTimes( [ 'US', 3, 3 ], [ 'JP', 10, 10 ] ); - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, times, codes ); + const errors = checkErrors( values ); expect( errors ).toStrictEqual( {} ); } ); it( 'should indicate multiple unpassed checks by setting properties in the returned object', () => { - const errors = checkErrors( defaultFormValues, [], [] ); + const errors = checkErrors( defaultFormValues ); expect( errors ).toHaveProperty( 'shipping_rate' ); expect( errors ).toHaveProperty( 'shipping_time' ); @@ -62,13 +55,13 @@ describe( 'checkErrors', () => { describe( 'Audience', () => { it( 'When the audience location option is an invalid value or missing, should not pass', () => { // Not set yet - let errors = checkErrors( {}, [], [] ); + let errors = checkErrors( {} ); expect( errors ).toHaveProperty( 'location' ); expect( errors.location ).toMatchSnapshot(); // Invalid value - errors = checkErrors( { location: true }, [], [] ); + errors = checkErrors( { location: true } ); expect( errors ).toHaveProperty( 'location' ); expect( errors.location ).toMatchSnapshot(); @@ -76,7 +69,7 @@ describe( 'checkErrors', () => { it( 'When the audience location option is a valid value, should pass', () => { // Selected all countries - let errors = checkErrors( { location: 'all' }, [], [] ); + let errors = checkErrors( { location: 'all' } ); expect( errors ).not.toHaveProperty( 'location' ); @@ -85,7 +78,7 @@ describe( 'checkErrors', () => { location: 'selected', countries: [], }; - errors = checkErrors( values, [], [] ); + errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'location' ); } ); @@ -95,7 +88,7 @@ describe( 'checkErrors', () => { location: 'selected', countries: [], }; - const errors = checkErrors( values, [], [] ); + const errors = checkErrors( values ); expect( errors ).toHaveProperty( 'countries' ); expect( errors.countries ).toMatchSnapshot(); @@ -106,7 +99,7 @@ describe( 'checkErrors', () => { location: 'selected', countries: [ '' ], }; - const errors = checkErrors( values, [], [] ); + const errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'countries' ); } ); @@ -131,27 +124,25 @@ describe( 'checkErrors', () => { it( 'When the type of shipping rate is an invalid value or missing, should not pass', () => { // Not set yet - let errors = checkErrors( defaultFormValues, [], [] ); + let errors = checkErrors( defaultFormValues ); expect( errors ).toHaveProperty( 'shipping_rate' ); expect( errors.shipping_rate ).toMatchSnapshot(); // Invalid value - errors = checkErrors( - { ...defaultFormValues, shipping_rate: true }, - [], - [] - ); + errors = checkErrors( { + ...defaultFormValues, + shipping_rate: true, + } ); expect( errors ).toHaveProperty( 'shipping_rate' ); expect( errors.shipping_rate ).toMatchSnapshot(); // Invalid value - errors = checkErrors( - { ...defaultFormValues, shipping_rate: 'invalid' }, - [], - [] - ); + errors = checkErrors( { + ...defaultFormValues, + shipping_rate: 'invalid', + } ); expect( errors ).toHaveProperty( 'shipping_rate' ); expect( errors.shipping_rate ).toMatchSnapshot(); @@ -159,79 +150,49 @@ describe( 'checkErrors', () => { it( 'When the type of shipping rate is a valid value, should pass', () => { // Selected automatic - let errors = checkErrors( automaticShipping, [], [] ); + let errors = checkErrors( automaticShipping ); expect( errors ).not.toHaveProperty( 'shipping_rate' ); // Selected flat - errors = checkErrors( flatShipping, [], [] ); + errors = checkErrors( flatShipping ); expect( errors ).not.toHaveProperty( 'shipping_rate' ); // Selected manual - errors = checkErrors( manualShipping, [], [] ); + errors = checkErrors( manualShipping ); expect( errors ).not.toHaveProperty( 'shipping_rate' ); } ); describe( 'For flat type', () => { - it( `When there are any selected countries with shipping rates not set, should not pass`, () => { + it( 'When flat_shipping_rate is undefined, should not pass', () => { const values = { ...flatShipping, - shipping_country_rates: toRates( - [ 'US', 10.5 ], - [ 'FR', 12.8 ] - ), - }; - const codes = [ 'US', 'JP', 'FR' ]; - - const errors = checkErrors( values, [], codes ); - - expect( errors ).toHaveProperty( 'shipping_country_rates' ); - expect( errors.shipping_country_rates ).toMatchSnapshot(); - } ); - - it( `When all selected countries' shipping rates are set, should pass`, () => { - const values = { - ...flatShipping, - shipping_country_rates: toRates( - [ 'US', 10.5 ], - [ 'FR', 12.8 ] - ), + flat_shipping_rate: undefined, }; - const codes = [ 'US', 'FR' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); - expect( errors ).not.toHaveProperty( 'shipping_rate' ); + expect( errors ).toHaveProperty( 'flat_shipping_rate' ); + expect( errors.flat_shipping_rate ).toMatchSnapshot(); } ); - it( `When there are any shipping rates is < 0, should not pass`, () => { - const values = { - ...flatShipping, - shipping_country_rates: toRates( - [ 'US', 10.5 ], - [ 'JP', -0.01 ] - ), - }; - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( values, [], codes ); + it( 'When flat_shipping_rate is defined (including 0 for free shipping), should pass', () => { + let values = { ...flatShipping, flat_shipping_rate: 10 }; + let errors = checkErrors( values ); + expect( errors ).not.toHaveProperty( 'flat_shipping_rate' ); - expect( errors ).toHaveProperty( 'shipping_country_rates' ); - expect( errors.shipping_country_rates ).toMatchSnapshot(); + values = { ...flatShipping, flat_shipping_rate: 0 }; + errors = checkErrors( values ); + expect( errors ).not.toHaveProperty( 'flat_shipping_rate' ); } ); - it( `When all shipping rates are ≥ 0, should pass`, () => { - const values = { - ...flatShipping, - shipping_country_rates: toRates( [ 'US', 1 ], [ 'JP', 0 ] ), - }; - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( values, [], codes ); - - expect( errors ).not.toHaveProperty( 'shipping_rate' ); + it( 'When flat_shipping_rate is a negative number, should not pass', () => { + const values = { ...flatShipping, flat_shipping_rate: -1 }; + const errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_rate' ); + expect( errors.flat_shipping_rate ).toMatchSnapshot(); } ); } ); } ); @@ -245,9 +206,8 @@ describe( 'checkErrors', () => { shipping_country_rates: toRates( [ 'US', 0 ], [ 'JP', 0 ] ), offer_free_shipping: undefined, }; - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'offer_free_shipping' ); } ); @@ -262,9 +222,8 @@ describe( 'checkErrors', () => { ), offer_free_shipping: true, }; - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'offer_free_shipping' ); } ); @@ -276,9 +235,8 @@ describe( 'checkErrors', () => { shipping_country_rates: toRates( [ 'US', 0 ], [ 'JP', 1 ] ), offer_free_shipping: true, }; - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); expect( errors ).toHaveProperty( 'free_shipping_threshold' ); expect( errors.free_shipping_threshold ).toMatchSnapshot(); @@ -293,9 +251,8 @@ describe( 'checkErrors', () => { shipping_country_rates: toRates( [ 'US', 0 ], [ 'JP', 1 ] ), offer_free_shipping: undefined, }; - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'offer_free_shipping' ); } ); @@ -307,9 +264,8 @@ describe( 'checkErrors', () => { shipping_country_rates: toRates( [ 'US', 0 ], [ 'JP', 1 ] ), offer_free_shipping: true, }; - const codes = [ 'US', 'JP' ]; - const errors = checkErrors( values, [], codes ); + const errors = checkErrors( values ); expect( errors ).not.toHaveProperty( 'offer_free_shipping' ); } ); @@ -327,27 +283,25 @@ describe( 'checkErrors', () => { it( 'When the type of shipping time is an invalid value or missing, should not pass', () => { // Not set yet - let errors = checkErrors( defaultFormValues, [], [] ); + let errors = checkErrors( defaultFormValues ); expect( errors ).toHaveProperty( 'shipping_time' ); expect( errors.shipping_time ).toMatchSnapshot(); // Invalid value - errors = checkErrors( - { ...defaultFormValues, shipping_time: true }, - [], - [] - ); + errors = checkErrors( { + ...defaultFormValues, + shipping_time: true, + } ); expect( errors ).toHaveProperty( 'shipping_time' ); expect( errors.shipping_time ).toMatchSnapshot(); // Invalid value - errors = checkErrors( - { ...defaultFormValues, shipping_time: 'invalid' }, - [], - [] - ); + errors = checkErrors( { + ...defaultFormValues, + shipping_time: 'invalid', + } ); expect( errors ).toHaveProperty( 'shipping_time' ); expect( errors.shipping_time ).toMatchSnapshot(); @@ -355,93 +309,102 @@ describe( 'checkErrors', () => { it( 'When the type of shipping time is a valid value, should pass', () => { // Selected flat - let errors = checkErrors( flatShipping, [], [] ); + let errors = checkErrors( flatShipping ); expect( errors ).not.toHaveProperty( 'shipping_time' ); // Selected manual - errors = checkErrors( manualShipping, [], [] ); + errors = checkErrors( manualShipping ); expect( errors ).not.toHaveProperty( 'shipping_time' ); } ); describe( 'For flat type', () => { - it( `When there are any selected countries' shipping times is not set, should not pass`, () => { - const times = toTimes( [ 'US', 7, 7 ], [ 'FR', 16, 16 ] ); - const codes = [ 'US', 'JP', 'FR' ]; - - const errors = checkErrors( flatShipping, times, codes ); - - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); - } ); - - it( `When all selected countries' shipping times are set, should pass`, () => { - const times = toTimes( [ 'US', 7, 7 ], [ 'FR', 16, 16 ] ); - const codes = [ 'US', 'FR' ]; - - const errors = checkErrors( flatShipping, times, codes ); - - expect( errors ).not.toHaveProperty( 'shipping_time' ); - } ); - - it( 'When there are any shipping times are < 0, should not pass', () => { - const times = toTimes( [ 'US', 10, 10 ], [ 'JP', -1, -1 ] ); - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( flatShipping, times, codes ); - - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); - } ); - - it( 'When minimum times is < 0, should not pass', () => { - const times = toTimes( [ 'US', 10, 10 ], [ 'JP', -1, 10 ] ); - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( flatShipping, times, codes ); + it( 'When flat_shipping_min_time is null or undefined, should not pass', () => { + let values = { + ...flatShipping, + flat_shipping_min_time: null, + }; + let errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); + expect( errors.flat_shipping_times ).toMatchSnapshot(); - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); + values = { + ...flatShipping, + flat_shipping_min_time: undefined, + }; + errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); } ); - it( 'When minimum max_time is < 0, should not pass', () => { - const times = toTimes( [ 'US', 10, 10 ], [ 'JP', 1, -10 ] ); - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( flatShipping, times, codes ); + it( 'When flat_shipping_max_time is null or undefined, should not pass', () => { + let values = { + ...flatShipping, + flat_shipping_min_time: 3, + flat_shipping_max_time: null, + }; + let errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); + expect( errors.flat_shipping_times ).toMatchSnapshot(); - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); + values = { + ...flatShipping, + flat_shipping_min_time: 3, + flat_shipping_max_time: undefined, + }; + errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); } ); - it( 'When all shipping times are ≥ 0, should pass', () => { - const times = toTimes( [ 'US', 1, 1 ], [ 'JP', 0, 0 ] ); - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( flatShipping, times, codes ); + it( 'When flat_shipping_min_time or flat_shipping_max_time is < 0, should not pass', () => { + let values = { + ...flatShipping, + flat_shipping_min_time: -1, + flat_shipping_max_time: 5, + }; + let errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); + expect( errors.flat_shipping_times ).toMatchSnapshot(); - expect( errors ).not.toHaveProperty( 'shipping_time' ); + values = { + ...flatShipping, + flat_shipping_min_time: 3, + flat_shipping_max_time: -1, + }; + errors = checkErrors( values ); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); } ); - it( 'shouldnt pass if min time is bigger than max time', () => { - const times = toTimes( [ 'US', 1, 0 ], [ 'JP', 1, 1 ] ); - const codes = [ 'US', 'JP' ]; + it( 'When flat_shipping_min_time > flat_shipping_max_time, should not pass', () => { + const values = { + ...flatShipping, + flat_shipping_min_time: 5, + flat_shipping_max_time: 3, + }; - const errors = checkErrors( flatShipping, times, codes ); + const errors = checkErrors( values ); - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); + expect( errors ).toHaveProperty( 'flat_shipping_times' ); + expect( errors.flat_shipping_times ).toMatchSnapshot(); } ); - it( 'shouldnt pass if min or max time is null', () => { - const times = toTimes( [ 'US', null, 1 ], [ 'JP', 1, null ] ); - const codes = [ 'US', 'JP' ]; - - const errors = checkErrors( flatShipping, times, codes ); + it( 'When both times are valid and min <= max, should pass', () => { + let values = { + ...flatShipping, + flat_shipping_min_time: 3, + flat_shipping_max_time: 5, + }; + let errors = checkErrors( values ); + expect( errors ).not.toHaveProperty( 'flat_shipping_times' ); - expect( errors ).toHaveProperty( 'shipping_country_times' ); - expect( errors.shipping_country_times ).toMatchSnapshot(); + // min === max (same day) + values = { + ...flatShipping, + flat_shipping_min_time: 0, + flat_shipping_max_time: 0, + }; + errors = checkErrors( values ); + expect( errors ).not.toHaveProperty( 'flat_shipping_times' ); } ); } ); } ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/index.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup.js similarity index 53% rename from js/src/components/free-listings/configure-product-listings/shipping-time-setup/index.js rename to js/src/components/free-listings/configure-product-listings/shipping-time-setup.js index 46cd25ee1d..e2a098afb2 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/index.js +++ b/js/src/components/free-listings/configure-product-listings/shipping-time-setup.js @@ -8,22 +8,16 @@ import { __ } from '@wordpress/i18n'; */ import { useAdaptiveFormContext } from '~/components/adaptive-form'; import Section from '~/components/section'; -import AppSpinner from '~/components/app-spinner'; -import ShippingCountriesForm from './shipping-countries-form'; +import CountriesTimeInput from '~/components/countries-time-input'; /** - * Form control to edit shipping rate settings. + * Form control to edit shipping time settings. */ const ShippingTimeSetup = () => { const { - getInputProps, - adapter: { audienceCountries, renderRequestedValidation }, + adapter: { renderRequestedValidation }, } = useAdaptiveFormContext(); - if ( ! audienceCountries ) { - return ; - } - return ( @@ -33,11 +27,10 @@ const ShippingTimeSetup = () => { 'google-listings-and-ads' ) } - - { renderRequestedValidation( 'shipping_country_times' ) } + + + + { renderRequestedValidation( 'flat_shipping_times' ) } ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.js deleted file mode 100644 index 12517bec14..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Form } from '@woocommerce/components'; -import { Flex, FlexItem } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import AppModal from '~/components/app-modal'; -import VerticalGapLayout from '~/components/vertical-gap-layout'; -import SupportedCountrySelect from '~/components/supported-country-select'; -import validateShippingTimeGroup from '~/utils/validateShippingTimeGroup'; -import MinMaxShippingTimes from '../min-max-shipping-times'; - -/** - * Form to add a new time for selected country(-ies). - * - * @param {Object} props - * @param {Array} props.countries A list of country codes to choose from. - * @param {Function} props.onRequestClose - * @param {function(AggregatedShippingTime): void} props.onSubmit Called with submitted value. - */ -const AddTimeModal = ( { countries, onRequestClose, onSubmit } ) => { - const [ dropdownVisible, setDropdownVisible ] = useState( false ); - - const handleSubmitCallback = ( values ) => { - onSubmit( values ); - onRequestClose(); - }; - - return ( -
- { ( formProps ) => { - const { getInputProps, isValidForm, handleSubmit, errors } = - formProps; - - const handleIncrement = ( numberValue, field ) => { - getInputProps( field ).onChange( numberValue ); - }; - - return ( - - { __( - 'Add shipping time', - 'google-listings-and-ads' - ) } - , - ] } - onRequestClose={ onRequestClose } - > - - -
- { __( - 'Then the estimated shipping times displayed in the product listing are:', - 'google-listings-and-ads' - ) } -
- - - - -
    -
  • { errors.time }
  • -
-
-
-
-
- ); - } } -
- ); -}; - -export default AddTimeModal; - -/** - * @typedef { import("~/data/actions").AggregatedShippingTime } AggregatedShippingTime - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.test.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.test.js deleted file mode 100644 index 1d16c034cb..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/add-time-modal.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * External dependencies - */ -import '@testing-library/jest-dom'; -import { render, fireEvent, waitFor } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import AddTimeModal from './add-time-modal'; -import useMCCountryTreeOptions from '~/components/supported-country-select/useMCCountryTreeOptions'; - -jest.mock( '~/components/supported-country-select/useMCCountryTreeOptions' ); - -describe( 'Add time Modal', () => { - it( 'The min and max shipping time inputs should be displayed, and both values should be submitted', async () => { - const onSubmit = jest.fn(); - - useMCCountryTreeOptions.mockImplementation( () => { - return [ - { - value: 'EU', - label: 'Europe', - children: [ - { - value: 'ES', - label: 'Spain', - parent: 'EU', - }, - ], - }, - ]; - } ); - - const { getByRole, getAllByRole } = render( - - ); - - const inputs = getAllByRole( 'textbox' ); - // it should render 2 inputs ( the min and max shipping time inputs ) - expect( inputs ).toHaveLength( 2 ); - - const [ minInput, maxInput ] = inputs; - - expect( minInput ).toHaveValue( '1' ); - expect( maxInput ).toHaveValue( '5' ); - - fireEvent.blur( minInput, { target: { value: '2' } } ); - expect( minInput ).toHaveValue( '2' ); - - fireEvent.blur( maxInput, { target: { value: '11' } } ); - expect( maxInput ).toHaveValue( '11' ); - - const submitButton = getByRole( 'button', { - name: 'Add shipping time', - } ); - - expect( submitButton ).toBeEnabled(); - - fireEvent.click( submitButton ); - - await waitFor( () => { - expect( onSubmit ).toHaveBeenCalledTimes( 1 ); - } ); - - expect( onSubmit ).toHaveBeenCalledWith( { - countries: [ 'ES' ], - time: 2, - maxTime: 11, - } ); - } ); -} ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/index.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/index.js deleted file mode 100644 index 2b39fb582d..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/add-time-button/index.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import GridiconPlusSmall from 'gridicons/dist/plus-small'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import AddTimeModal from './add-time-modal'; - -/** - * Triggering button and modal with the - * form to add a new time for selected country(-ies). - * - * @param {Object} props Props to be forwarded to `AddTimeModal`. - */ -const AddTimeButton = ( props ) => { - const [ isOpen, setOpen ] = useState( false ); - - const handleClick = () => { - setOpen( true ); - }; - - const handleModalRequestClose = () => { - setOpen( false ); - }; - - return ( - <> - } - onClick={ handleClick } - > - { __( 'Add another time', 'google-listings-and-ads' ) } - - { isOpen && ( - - ) } - - ); -}; - -export default AddTimeButton; diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.js deleted file mode 100644 index e097b3ee74..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import EditTimeModal from './edit-time-modal'; -import './edit-time-button.scss'; - -/** - * Triggering button and modal with the - * form to edit time for selected country(-ies). - * - * @param {Object} props - * @param {Array} props.audienceCountries List of all audience countries. - * @param {AggregatedShippingTime} props.time - * @param {(newTime: AggregatedShippingTime, deletedCountries: Array) => void} props.onChange Called once the time is submitted. - * @param {(deletedCountries: Array) => void} props.onDelete Called with list of countries once Delete was requested. - */ -const EditTimeButton = ( { audienceCountries, time, onChange, onDelete } ) => { - const [ isOpen, setOpen ] = useState( false ); - - const handleClick = () => { - setOpen( true ); - }; - - const handleModalRequestClose = () => { - setOpen( false ); - }; - - const handleChange = ( ...args ) => { - onChange( ...args ); - setOpen( false ); - }; - const handleDelete = ( value ) => { - onDelete( value ); - setOpen( false ); - }; - - return ( - <> - - { __( 'Edit', 'google-listings-and-ads' ) } - - { isOpen && ( - - ) } - - ); -}; - -export default EditTimeButton; - -/** - * @typedef { import("~/data/actions").AggregatedShippingTime } AggregatedShippingTime - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.scss b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.scss deleted file mode 100644 index fc84ba6563..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-button.scss +++ /dev/null @@ -1,7 +0,0 @@ -.gla-edit-time-button { - &.components-button.is-tertiary { - height: fit-content; - line-height: 1.4em; - padding: 0; - } -} diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.js deleted file mode 100644 index 8d54f50304..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Form } from '@woocommerce/components'; -import { Flex, FlexItem } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import AppModal from '~/components/app-modal'; -import VerticalGapLayout from '~/components/vertical-gap-layout'; -import SupportedCountrySelect from '~/components/supported-country-select'; -import validateShippingTimeGroup from '~/utils/validateShippingTimeGroup'; -import MinMaxShippingTimes from '../min-max-shipping-times'; - -/** - *Form to edit time for selected country(-ies). - * - * @param {Object} props - * @param {Array} props.audienceCountries List of all audience countries. - * @param {AggregatedShippingTime} props.time - * @param {(newTime: AggregatedShippingTime, deletedCountries: Array) => void} props.onSubmit Called once the time is submitted. - * @param {(deletedCountries: Array) => void} props.onDelete Called with list of countries once Delete was requested. - * @param {Function} props.onRequestClose Called when the form is requested to be closed. - */ -const EditTimeModal = ( { - audienceCountries, - time, - onDelete, - onSubmit, - onRequestClose, -} ) => { - const [ dropdownVisible, setDropdownVisible ] = useState( false ); - - // We actually may have times for more countries than the audience ones. - const availableCountries = Array.from( - new Set( [ ...time.countries, ...audienceCountries ] ) - ); - - const handleDeleteClick = () => { - onDelete( time.countries ); - }; - - const handleSubmitCallback = ( values ) => { - const remainingCountries = new Set( values.countries ); - const removedCountries = time.countries.filter( - ( el ) => ! remainingCountries.has( el ) - ); - - onSubmit( values, removedCountries ); - }; - - return ( -
- { ( formProps ) => { - const { getInputProps, isValidForm, handleSubmit, errors } = - formProps; - - const handleIncrement = ( numberValue, field ) => { - getInputProps( field ).onChange( numberValue ); - }; - - return ( - - { __( 'Delete', 'google-listings-and-ads' ) } - , - - { __( - 'Update shipping time', - 'google-listings-and-ads' - ) } - , - ] } - onRequestClose={ onRequestClose } - > - - - -
- { __( - 'Then the estimated shipping times displayed in the product listing are', - 'google-listings-and-ads' - ) } -
- - - - -
    -
  • { errors.time }
  • -
-
-
-
-
- ); - } } -
- ); -}; - -export default EditTimeModal; - -/** - * @typedef { import("~/data/actions").AggregatedShippingTime } AggregatedShippingTime - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.test.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.test.js deleted file mode 100644 index 4fc579bfb5..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/edit-time-modal.test.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * External dependencies - */ -import '@testing-library/jest-dom'; -import { render, fireEvent, waitFor } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import EditTimeModal from './edit-time-modal'; -import useMCCountryTreeOptions from '~/components/supported-country-select/useMCCountryTreeOptions'; - -jest.mock( '~/components/supported-country-select/useMCCountryTreeOptions' ); - -describe( 'Edit time Modal', () => { - it( 'The min and max shipping time inputs should be displayed, and both values should be submitted', async () => { - const onSubmit = jest.fn(); - - useMCCountryTreeOptions.mockImplementation( () => { - return [ - { - value: 'EU', - label: 'Europe', - children: [ - { - value: 'ES', - label: 'Spain', - parent: 'EU', - }, - ], - }, - ]; - } ); - - const { getByRole, getAllByRole } = render( - - ); - - const inputs = getAllByRole( 'textbox' ); - // it should render 2 inputs ( the min and max shipping time inputs ) - expect( inputs ).toHaveLength( 2 ); - - const [ minInput, maxInput ] = inputs; - - expect( minInput ).toHaveValue( '1' ); - expect( maxInput ).toHaveValue( '9' ); - - fireEvent.blur( minInput, { target: { value: '2' } } ); - expect( minInput ).toHaveValue( '2' ); - - fireEvent.blur( maxInput, { target: { value: '11' } } ); - expect( maxInput ).toHaveValue( '11' ); - - const submitButton = getByRole( 'button', { - name: 'Update shipping time', - } ); - - expect( submitButton ).toBeEnabled(); - - fireEvent.click( submitButton ); - - await waitFor( () => { - expect( onSubmit ).toHaveBeenCalledTimes( 1 ); - } ); - - expect( onSubmit ).toHaveBeenCalledWith( - { countries: [ 'ES' ], time: 2, maxTime: 11 }, - [] - ); - } ); -} ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.js deleted file mode 100644 index 41c6cc61c2..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * External dependencies - */ -import { Flex, FlexItem } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import AppSpinner from '~/components/app-spinner'; -import ShippingTimeInputControlLabelText from '~/components/shipping-time-input-control-label-text'; -import EditTimeButton from './edit-time-button'; -import MinMaxShippingTimes from '../min-max-shipping-times'; -import './index.scss'; - -/** - * Input control to edit a shipping time. - * Consists of a simple input field to adjust the time - * and with a modal with a more advanced form to select countries. - * - * @param {Object} props - * @param {AggregatedShippingTime} props.value Aggregate, rat: Array object to be used as the initial value. - * @param {Array} props.audienceCountries List of all audience countries. - * @param {(newTime: AggregatedShippingTime, deletedCountries: Array|undefined) => void} props.onChange Called when time changes. - * @param {(deletedCountries: Array) => void} props.onDelete Called with list of countries once Delete was requested. - */ -const CountriesTimeInput = ( { - value, - audienceCountries, - onChange, - onDelete, -} ) => { - const { countries, time, maxTime } = value; - - if ( ! audienceCountries ) { - return ; - } - - /** - * @param {number} numberValue The string value of the input field converted to a number - * @param {string} field The field name: time or maxTime - */ - const handleBlur = ( numberValue, field ) => { - if ( value[ field ] === numberValue ) { - return; - } - - onChange( { - ...value, - [ field ]: numberValue, - } ); - }; - - /** - * - * @param {number} numberValue The string value of the input field converted to a number - * @param {string} field The field name: time or maxTime - */ - const handleIncrement = ( numberValue, field ) => { - onChange( { - ...value, - [ field ]: numberValue, - } ); - }; - - return ( - - -
- - -
-
- - - - -
- ); -}; - -export default CountriesTimeInput; - -/** - * @typedef { import("~/data/actions").AggregatedShippingTime } AggregatedShippingTime - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.scss b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.scss deleted file mode 100644 index 77c4797bf3..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.scss +++ /dev/null @@ -1,11 +0,0 @@ -.gla-countries-time-input-container { - max-width: $gla-width-medium-large; - .label { - display: flex; - justify-content: space-between; - gap: $grid-unit-05; - } - .gla-validation-errors { - min-height: 2lh; - } -} diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.test.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.test.js deleted file mode 100644 index 9b2a498f5f..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/countries-time-input/index.test.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * External dependencies - */ -import '@testing-library/jest-dom'; -import { render, fireEvent, waitFor } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import CountriesTimeInput from './'; - -describe( 'CountriesTimeInput', () => { - describe( 'Test Same delivery placeholder', () => { - it( 'Shouldnt have placeholder if times are null', () => { - const { getByText, getByRole, getAllByRole } = render( - - ); - - expect( getByText( 'Shipping time for' ) ).toBeInTheDocument(); - - expect( - getByRole( 'button', { name: 'Edit' } ) - ).toBeInTheDocument(); - - const inputs = getAllByRole( 'textbox' ); - - expect( inputs ).toHaveLength( 2 ); - - for ( const input of inputs ) { - expect( input ).toHaveAttribute( 'placeholder', '' ); - } - } ); - - it( 'Should set placeholders if times are different than 0', () => { - const { getByDisplayValue, queryAllByPlaceholderText } = render( - - ); - - expect( queryAllByPlaceholderText( 'Same Day' ) ).toHaveLength( 2 ); - - expect( getByDisplayValue( '32' ) ).toBeInTheDocument(); - - // The 0 is changed to an empty string, allowing the placeholder/default value to be displayed. - expect( getByDisplayValue( '' ) ).toBeInTheDocument(); - } ); - } ); - - describe( 'Test TimeStepper', () => { - it( 'Should call onChange when increasing an decreasing the days', async () => { - const onChange = jest.fn(); - const { queryAllByRole } = render( - - ); - - const incrementButtons = queryAllByRole( 'button', { - name: 'Increment', - } ); - const decrementButtons = queryAllByRole( 'button', { - name: 'Decrement', - } ); - - // One for the min and one for the max = 2 increment buttons and 2 decrement buttons - expect( incrementButtons ).toHaveLength( 2 ); - expect( decrementButtons ).toHaveLength( 2 ); - - //Increasing - for ( const button of incrementButtons ) { - fireEvent.mouseDown( button ); - } - - await waitFor( () => { - expect( onChange ).toHaveBeenCalledTimes( 2 ); - } ); - - expect( onChange.mock.calls[ 0 ][ 0 ].time ).toBe( 2 ); - expect( onChange.mock.calls[ 1 ][ 0 ].maxTime ).toBe( 33 ); - - //Decreasing - for ( const button of decrementButtons ) { - fireEvent.mouseDown( button ); - } - - await waitFor( () => { - expect( onChange ).toHaveBeenCalledTimes( 4 ); - } ); - - expect( onChange.mock.calls[ 2 ][ 0 ].time ).toBe( 1 ); - expect( onChange.mock.calls[ 3 ][ 0 ].maxTime ).toBe( 32 ); - } ); - } ); - - describe( 'Test handleBlur', () => { - it( 'Test onChange when handleBlur is called', async () => { - const onChange = jest.fn(); - const onDelete = jest.fn(); - const { queryAllByRole } = render( - - ); - - const inputs = queryAllByRole( 'textbox' ); - - expect( inputs ).toHaveLength( 2 ); - - const [ timeInput, maxTimeInput ] = inputs; - - // The value is the same, so the onChange function shouldnt be called - fireEvent.blur( timeInput, { target: { value: '1' } } ); - expect( onChange ).toHaveBeenCalledTimes( 0 ); - - // The value is different, so the onChange function should be called - fireEvent.blur( timeInput, { target: { value: '2' } } ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); - // It should update the time property. - expect( onChange.mock.calls[ 0 ][ 0 ].time ).toBe( 2 ); - - onChange.mockClear(); - - // The value is the same, so the onChange function shouldnt be called - fireEvent.blur( maxTimeInput, { target: { value: '32' } } ); - expect( onChange ).toHaveBeenCalledTimes( 0 ); - - // The value is different, so the onChange function should be called - fireEvent.blur( maxTimeInput, { target: { value: '10' } } ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); - // It should update the maxTime property. - expect( onChange.mock.calls[ 0 ][ 0 ].maxTime ).toBe( 10 ); - } ); - } ); -} ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/shipping-countries-form.js b/js/src/components/free-listings/configure-product-listings/shipping-time-setup/shipping-countries-form.js deleted file mode 100644 index e8b200e67b..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-setup/shipping-countries-form.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * External dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import VerticalGapLayout from '~/components/vertical-gap-layout'; -import AddTimeButton from './add-time-button'; -import CountriesTimeInput from './countries-time-input'; -import getShippingTimesGroups from '~/utils/getShippingTimesGroups'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("~/data/actions").ShippingTime } ShippingTime - */ - -/** - * Partial form to provide shipping times for individual countries, - * with an UI, that allows to aggregate countries with the same time. - * - * @param {Object} props - * @param {Array} props.value Array of individual shipping times to be used as the initial values of the form. - * @param {Array} props.audienceCountries Array of selected audience country codes. - * @param {(newValue: Object) => void} props.onChange Callback called with new data once shipping times are changed. - */ -export default function ShippingCountriesForm( { - value: shippingTimes, - audienceCountries, - onChange, -} ) { - const mountedRef = useRef( false ); - const actualCountryCount = shippingTimes.length; - const actualCountries = new Map( - shippingTimes.map( ( time ) => [ time.countryCode, time ] ) - ); - const remainingCountryCodes = audienceCountries.filter( - ( el ) => ! actualCountries.has( el ) - ); - const remainingCount = remainingCountryCodes.length; - - // Group countries with the same time. - const countriesTimeArray = getShippingTimesGroups( shippingTimes ); - - useEffect( () => { - if ( mountedRef.current ) { - return; - } - mountedRef.current = true; - - // Prefill to-be-added time if there are selected audience countries, but no times provided yet. - if ( actualCountryCount === 0 && audienceCountries.length > 0 ) { - onChange( - audienceCountries.map( ( countryCode ) => ( { - countryCode, - time: 1, - maxTime: 5, - } ) ) - ); - } - } ); - - // Given the limitations of `
` component we can communicate up only onChange. - // Therefore we loose the information whether it was add, change, delete. - // In autosave/setup MC case, we would have to either re-calculate to deduct that information, - // or fix that in `` component. - function handleDelete( deletedCountries ) { - onChange( - shippingTimes.filter( - ( time ) => ! deletedCountries.includes( time.countryCode ) - ) - ); - } - function handleAdd( { countries, time, maxTime } ) { - // Split aggregated time, to individual times per country. - const addedIndividualTimes = countries.map( ( countryCode ) => ( { - countryCode, - time, - maxTime, - } ) ); - - onChange( shippingTimes.concat( addedIndividualTimes ) ); - } - function handleChange( - { countries, time, maxTime }, - deletedCountries = [] - ) { - deletedCountries.forEach( ( countryCode ) => - actualCountries.delete( countryCode ) - ); - - // Upsert times. - countries.forEach( ( countryCode ) => { - actualCountries.set( countryCode, { - countryCode, - time, - maxTime, - } ); - } ); - onChange( Array.from( actualCountries.values() ) ); - } - - return ( -
- - { countriesTimeArray.map( ( el ) => { - return ( -
- -
- ); - } ) } - { actualCountryCount >= 1 && remainingCount >= 1 && ( -
- -
- ) } -
-
- ); -} diff --git a/js/src/components/free-listings/setup-free-listings/form-content.js b/js/src/components/free-listings/setup-free-listings/form-content.js index 04afbe9f97..067ee91175 100644 --- a/js/src/components/free-listings/setup-free-listings/form-content.js +++ b/js/src/components/free-listings/setup-free-listings/form-content.js @@ -2,6 +2,7 @@ * Internal dependencies */ import { useAdaptiveFormContext } from '~/components/adaptive-form'; +import { SHIPPING_RATE_METHOD, SHIPPING_TIME_METHOD } from '~/constants'; import ChooseAudienceSection from '~/components/free-listings/choose-audience-section'; import ShippingRateSection from '~/components/shipping-rate-section'; import ShippingTimeSection from '~/components/free-listings/configure-product-listings/shipping-time-section'; @@ -14,9 +15,10 @@ import isNonFreeShippingRate from '~/utils/isNonFreeShippingRate'; const FormContent = () => { const { values } = useAdaptiveFormContext(); - const shouldDisplayShippingTime = values.shipping_time === 'flat'; + const shouldDisplayShippingTime = + values.shipping_time === SHIPPING_TIME_METHOD.FLAT; const shouldDisplayOrderValueCondition = - values.shipping_rate === 'flat' && + values.shipping_rate === SHIPPING_RATE_METHOD.FLAT && values.shipping_country_rates.some( isNonFreeShippingRate ); return ( diff --git a/js/src/components/free-listings/setup-free-listings/index.js b/js/src/components/free-listings/setup-free-listings/index.js index 52c571d808..68028a3e7e 100644 --- a/js/src/components/free-listings/setup-free-listings/index.js +++ b/js/src/components/free-listings/setup-free-listings/index.js @@ -3,12 +3,15 @@ */ import { useRef } from '@wordpress/element'; import { createSlotFill } from '@wordpress/components'; -import { Form } from '@woocommerce/components'; import { pick, noop } from 'lodash'; /** * Internal dependencies */ +import { + DEFAULT_SHIPPING_MIN_TIME, + DEFAULT_SHIPPING_MAX_TIME, +} from '~/constants'; import AppSpinner from '~/components/app-spinner'; import AppButton from '~/components/app-button'; import AdaptiveForm from '~/components/adaptive-form'; @@ -17,6 +20,7 @@ import checkErrors from '~/components/free-listings/configure-product-listings/c import getOfferFreeShippingInitialValue from '~/utils/getOfferFreeShippingInitialValue'; import isNonFreeShippingRate from '~/utils/isNonFreeShippingRate'; import FormContent from './form-content'; +import useStoreCurrency from '~/hooks/useStoreCurrency'; import { TARGET_AUDIENCE_FIELDS } from '../choose-audience-section/constants'; /** @@ -65,13 +69,13 @@ const { Fill, Slot } = createSlotFill( 'gla/SetupFreeListings/SubmitButton' ); * @param {Object} props * @param {TargetAudienceData} props.targetAudience Target audience value data to be initialed the form, if not given AppSpinner will be rendered. * @param {(targetAudience: TargetAudienceData) => Array} props.resolveFinalCountries Callback for this component to resolve the given `targetAudience` to the final list of countries. - * @param {(targetAudience: TargetAudienceData) => void} [props.onTargetAudienceChange] Callback called with new data once target audience data is changed. Forwarded from and {@link Form.Props.onChange}. + * @param {(targetAudience: TargetAudienceData) => void} [props.onTargetAudienceChange] Callback called with new data once target audience data is changed. * @param {Object} props.settings Settings data, if not given AppSpinner will be rendered. - * @param {(newValue: Object) => void} [props.onSettingsChange] Callback called with new data once form data is changed. Forwarded from and {@link Form.Props.onChange}. + * @param {(newValue: Object) => void} [props.onSettingsChange] Callback called with new data once form data is changed. * @param {Array} props.shippingRates Shipping rates data, if not given AppSpinner will be rendered. - * @param {(newValue: Object) => void} [props.onShippingRatesChange] Callback called with new data once shipping rates are changed. Forwarded from {@link Form.Props.onChange}. + * @param {(newValue: Object) => void} [props.onShippingRatesChange] Callback called with new data once shipping rates are changed. * @param {Array} props.shippingTimes Shipping times data, if not given AppSpinner will be rendered. - * @param {(newValue: Object) => void} [props.onShippingTimesChange] Callback called with new data once shipping times are changed. Forwarded from {@link Form.Props.onChange}. + * @param {(newValue: Object) => void} [props.onShippingTimesChange] Callback called with new data once shipping times are changed. * @param {() => boolean | Promise} [props.onRequestSubmit] Callback called before the form is submitted. If it returns false, the form will not be submitted. * @param {() => void} [props.onContinue] Callback called once continue button is clicked. Could be async. While it's being resolved the form would turn into a saving state. * @param {string} props.submitLabel Submit button label. @@ -91,22 +95,42 @@ const SetupFreeListings = ( { submitLabel, } ) => { const formRef = useRef(); + const { code: currencyCode } = useStoreCurrency(); if ( ! ( targetAudience && settings && shippingRates && shippingTimes ) ) { return ; } const handleValidate = ( values ) => { - const countries = resolveFinalCountries( values ); - const { shipping_country_times: shippingTimesData } = values; - - return checkErrors( values, shippingTimesData, countries ); + return checkErrors( values ); }; const handleChange = ( change, values ) => { const { setValue } = formRef.current; - if ( change.name === 'shipping_country_rates' ) { + if ( change.name === 'flat_shipping_rate' ) { + // Translate the single flat rate into the per-country array the API expects. + // Preserve any existing free_shipping_threshold per country, unless the + // new rate is free (0), in which case clear the threshold. + const isFree = change.value === 0; + const countries = resolveFinalCountries( values ); + const existingByCountry = new Map( + values.shipping_country_rates.map( ( r ) => [ r.country, r ] ) + ); + const rates = countries.map( ( country ) => ( { + options: { + free_shipping_threshold: isFree + ? undefined + : existingByCountry.get( country )?.options + ?.free_shipping_threshold, + }, + country, + currency: currencyCode, + rate: change.value, + } ) ); + + setValue( 'shipping_country_rates', rates ); + } else if ( change.name === 'shipping_country_rates' ) { onShippingRatesChange( values.shipping_country_rates ); // If all the shipping rates are free shipping, @@ -132,10 +156,30 @@ const SetupFreeListings = ( { setValue( 'shipping_country_rates', nextValue ); } + } else if ( + change.name === 'flat_shipping_min_time' || + change.name === 'flat_shipping_max_time' + ) { + const countries = resolveFinalCountries( values ); + const minTime = + change.name === 'flat_shipping_min_time' + ? change.value + : values.flat_shipping_min_time; + const maxTime = + change.name === 'flat_shipping_max_time' + ? change.value + : values.flat_shipping_max_time; + const times = countries.map( ( countryCode ) => ( { + countryCode, + time: minTime, + maxTime, + } ) ); + + setValue( 'shipping_country_times', times ); } else if ( change.name === 'shipping_country_times' ) { // Skip the call of `onShippingTimesChange` if any shipping times are invalid. const error = handleValidate( values ); - const isValid = ! error.hasOwnProperty( change.name ); + const isValid = ! error.hasOwnProperty( 'flat_shipping_times' ); // Skip the call of `onShippingTimesChange` if there are incomplete shipping times. // This should only happen during onboarding when the shipping times haven't been stored yet. @@ -171,19 +215,68 @@ const SetupFreeListings = ( { } else if ( TARGET_AUDIENCE_FIELDS.includes( change.name ) ) { onTargetAudienceChange( pick( values, TARGET_AUDIENCE_FIELDS ) ); - // Only keep shipping data with selected countries. - [ 'shipping_country_rates', 'shipping_country_times' ].forEach( - ( field ) => { - const countries = resolveFinalCountries( values ); - const currentValues = values[ field ]; - const nextValues = currentValues.filter( ( el ) => - countries.includes( el.country || el.countryCode ) - ); - if ( nextValues.length !== currentValues.length ) { - setValue( field, nextValues ); - } - } + // Sync shipping_country_rates with the updated audience countries. + const audienceCountries = resolveFinalCountries( values ); + + // Filter removed countries AND fill in newly added countries using the current flat rate. + const filteredRates = values.shipping_country_rates.filter( + ( shippingCountryRate ) => + audienceCountries.includes( shippingCountryRate.country ) ); + const missingCountries = audienceCountries.filter( + ( country ) => + ! filteredRates.some( ( rate ) => rate.country === country ) + ); + const existingThreshold = filteredRates.find( + isNonFreeShippingRate + )?.options?.free_shipping_threshold; + const nextRates = + values.flat_shipping_rate !== undefined && + missingCountries.length > 0 + ? [ + ...filteredRates, + ...missingCountries.map( ( country ) => ( { + options: { + free_shipping_threshold: existingThreshold, + }, + country, + currency: currencyCode, + rate: values.flat_shipping_rate, + } ) ), + ] + : filteredRates; + if ( nextRates.length !== values.shipping_country_rates.length ) { + setValue( 'shipping_country_rates', nextRates ); + } + + // For times: filter removed countries AND add newly added countries. + const filteredTimes = values.shipping_country_times.filter( + ( shippingTime ) => + audienceCountries.includes( shippingTime.countryCode ) + ); + const missingTimesCountries = audienceCountries.filter( + ( country ) => + ! filteredTimes.some( + ( shippingTime ) => shippingTime.countryCode === country + ) + ); + const nextTimes = + values.flat_shipping_min_time !== null && + values.flat_shipping_max_time !== null && + missingTimesCountries.length > 0 + ? [ + ...filteredTimes, + ...missingTimesCountries.map( ( countryCode ) => ( { + countryCode, + time: values.flat_shipping_min_time, + maxTime: values.flat_shipping_max_time, + } ) ), + ] + : filteredTimes; + + if ( nextTimes.length !== values.shipping_country_times.length ) { + setValue( 'shipping_country_times', nextTimes ); + } } }; @@ -219,6 +312,15 @@ const SetupFreeListings = ( { // This is used in UI only, not used in API. offer_free_shipping: getOfferFreeShippingInitialValue( shippingRates ), + // UI-only scalar; assumes all countries share the same rate (flat rate mode). + // Derived from the first entry; the full per-country array is in shipping_country_rates. + flat_shipping_rate: shippingRates?.[ 0 ]?.rate, + // Simple flat time values for all countries (UI only, derived from shippingTimes). + flat_shipping_min_time: + shippingTimes?.[ 0 ]?.time ?? DEFAULT_SHIPPING_MIN_TIME, + flat_shipping_max_time: + shippingTimes?.[ 0 ]?.maxTime ?? + DEFAULT_SHIPPING_MAX_TIME, // Glue shipping rates and times together, as the Form does not support nested structures. shipping_country_rates: shippingRates, shipping_country_times: shippingTimes, diff --git a/js/src/components/free-shipping-threshold-control/index.js b/js/src/components/free-shipping-threshold-control/index.js new file mode 100644 index 0000000000..4dd72e1c0f --- /dev/null +++ b/js/src/components/free-shipping-threshold-control/index.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + useAdaptiveFormContext, + useAdaptiveFormInputProps, +} from '~/components/adaptive-form'; +import AppInputPriceControl from '~/components/app-input-price-control'; +import OfferFreeShippingCheckbox from '~/components/order-value-condition-section/offer-free-shipping-checkbox'; + +/** + * @typedef { import("~/data/actions").ShippingRate } ShippingRate + */ + +/** + * Renders controls to set free shipping threshold for minimum order value condition. + * + * @param {Object} props React props. + * @param {number} props.threshold The current free shipping threshold. + * @param {string} props.currency The currency to display for the threshold. + * @param {(nextValue: number) => void} props.onChange Callback called with the updated threshold once it changes. + */ +const FreeShippingThresholdControl = ( { onChange, threshold, currency } ) => { + const offerFreeShippingInputProps = useAdaptiveFormInputProps( + 'offer_free_shipping' + ); + const { values } = useAdaptiveFormContext(); + + const handleBlur = ( _event, numberValue ) => { + if ( + numberValue === threshold || + isNaN( numberValue ) || + numberValue < 0 + ) { + return; + } + + onChange( numberValue ); + }; + + return ( + <> + + + { values.offer_free_shipping && ( + + ) } + + ); +}; + +export default FreeShippingThresholdControl; diff --git a/js/src/components/main-tab-nav/main-tab-nav.js b/js/src/components/main-tab-nav/main-tab-nav.js index 3f24ddb916..8325bd9a01 100644 --- a/js/src/components/main-tab-nav/main-tab-nav.js +++ b/js/src/components/main-tab-nav/main-tab-nav.js @@ -12,7 +12,6 @@ import useGoogleMCAccount from '~/hooks/useGoogleMCAccount'; import AppTabNav from '~/components/app-tab-nav'; import useMenuEffect from '~/hooks/useMenuEffect'; import GtinMigrationBanner from '~/components/gtin-migration-banner'; -import { getShippingUrl } from '~/utils/urls'; export const ALL_TABS = [ { @@ -40,16 +39,16 @@ export const ALL_TABS = [ title: __( 'Attributes', 'google-listings-and-ads' ), href: getNewPath( {}, '/google/attribute-mapping', {} ), }, + { + key: 'markets', + title: __( 'Markets', 'google-listings-and-ads' ), + href: getNewPath( {}, '/google/markets', {} ), + }, { key: 'settings', title: __( 'Settings', 'google-listings-and-ads' ), href: getNewPath( {}, '/google/settings', {} ), }, - { - key: 'shipping', - title: __( 'Shipping', 'google-listings-and-ads' ), - href: getShippingUrl(), - }, ]; const MC_GATED_TAB_KEYS = [ 'dashboard', 'settings' ]; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.js b/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.js deleted file mode 100644 index 382e7685b6..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate - * @typedef { import("./typedefs").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Returns new shipping rates value, with updated group. - * - * Will set `shippingRate.options.free_shipping_threshold` to `newGroup.threshold` - * for the shipping rate countries that are listed in `newGroup.countries` (if provided), - * and set `threshold` to `undefined` for the countries which were in `oldGroup.countries` (if provided), - * but not in the new. - * - * @param {Array} value Shipping rates. - * @param {MinimumOrderGroup} [oldGroup] Old minimum order group. - * @param {MinimumOrderGroup} [newGroup] New minimum order group. - * @return {Array} New, updated value. - */ -export const calculateValueFromGroupChange = ( value, oldGroup, newGroup ) => { - return value.map( ( shippingRate ) => { - const newShippingRate = { - ...shippingRate, - options: { - ...shippingRate.options, - }, - }; - - if ( newGroup?.countries.includes( newShippingRate.country ) ) { - /** - * Shipping rate's country exists in the new value countries, - * so we just assign the new value threshold. - */ - newShippingRate.options.free_shipping_threshold = - newGroup.threshold; - } else if ( oldGroup?.countries.includes( newShippingRate.country ) ) { - /** - * Shipping rate's country does not exist in the new value countries, - * but it exists in the old value countries. - * This means users removed the country in the edit modal, - * so we set the threshold value to undefined. - */ - newShippingRate.options.free_shipping_threshold = undefined; - } - - return newShippingRate; - } ); -}; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.test.js b/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.test.js deleted file mode 100644 index a138e9399f..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/calculateValueFromGroupChange.test.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Internal dependencies - */ -import { calculateValueFromGroupChange } from './calculateValueFromGroupChange'; - -describe( 'calculateValueFromGroupChange', () => { - const value = Object.freeze( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'ES', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 50, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ] ); - // Pure add. - it( "returns new value with `newGroup.country`'s `shippingRate.options.free_shipping_threshold` updated to `newGroup.threshold`, when falsy `oldGroup` is given", () => { - const newGroup = { - countries: [ 'US' ], - currency: 'USD', - threshold: 30, - }; - - // Expect US threshold to be set to 30. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 30; - expect( - calculateValueFromGroupChange( value, null, newGroup ) - ).toStrictEqual( expectedValue ); - } ); - // Pure delete. - it( 'returns a new value with `free_shipping_threshold=undefined` for the countries in `oldGroup` when falsy `newGroup` is given', () => { - const oldGroup = { - countries: [ 'CN', 'ES' ], - currency: 'USD', - threshold: 50, - }; - - // Expect ES & CN threshold to be set to `undefined`. - const expectedValue = structuredClone( value ); - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expectedValue[ 2 ].options.free_shipping_threshold = undefined; - expect( - calculateValueFromGroupChange( value, oldGroup ) - ).toStrictEqual( expectedValue ); - } ); - // Update. - it( 'returns a new value updated based on changed group threshold', () => { - const oldGroup = { - countries: [ 'ES', 'CN' ], - currency: 'USD', - threshold: 50, - }; - const newGroup = { - ...oldGroup, - threshold: 507, - }; - - // Expect ES, CN threshold to be updated to 507. - const expectedValue = structuredClone( value ); - expectedValue[ 1 ].options.free_shipping_threshold = 507; - expectedValue[ 2 ].options.free_shipping_threshold = 507; - expect( - calculateValueFromGroupChange( value, oldGroup, newGroup ) - ).toStrictEqual( expectedValue ); - } ); - - it( 'returns a new value updated based on removed and added countries', () => { - const oldGroup = { - countries: [ 'ES', 'CN' ], - currency: 'USD', - threshold: 50, - }; - // country ES is removed, and country US is added. - const newGroup = { - ...oldGroup, - countries: [ 'CN', 'US' ], - }; - - // Expect US threshold to be set to 50, - // and ES threshold to be set to `undefined. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 50; - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expect( - calculateValueFromGroupChange( value, oldGroup, newGroup ) - ).toStrictEqual( expectedValue ); - } ); - - it( 'returns a new value updated based on all changed countries and threshold', () => { - const oldGroup = { - countries: [ 'ES', 'CN' ], - currency: 'USD', - threshold: 50, - }; - // countries and threshold are all changed. - const newGroup = { - ...oldGroup, - countries: [ 'CN', 'US' ], - threshold: 507, - }; - - // Expect US threshold to be set to 507, - // ES threshold to be set to `undefined`, - // and CN to be changed to 507. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 507; - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expectedValue[ 2 ].options.free_shipping_threshold = 507; - expect( - calculateValueFromGroupChange( value, oldGroup, newGroup ) - ).toStrictEqual( expectedValue ); - } ); -} ); diff --git a/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.js b/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.js deleted file mode 100644 index 04b8a1672f..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate - * @typedef { import("./typedefs").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Group shipping rates by currency and free shipping threshold into minimum order groups. - * - * @param {Array} shippingRates Array of shipping rates. - * @return {Array} Array of minimum order groups. - */ -const groupShippingRatesByCurrencyFreeShippingThreshold = ( shippingRates ) => { - const map = new Map(); - - shippingRates.forEach( ( shippingRate ) => { - const { - options: { free_shipping_threshold: threshold }, - currency, - } = shippingRate; - const key = `${ threshold } ${ currency }`; - const group = map.get( key ) || { - countries: [], - threshold, - currency, - }; - group.countries.push( shippingRate.country ); - map.set( key, group ); - } ); - - return Array.from( map.values() ); -}; - -export default groupShippingRatesByCurrencyFreeShippingThreshold; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.test.js b/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.test.js deleted file mode 100644 index 6b72cb28cc..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/groupShippingRatesByCurrencyFreeShippingThreshold.test.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Internal dependencies - */ -import groupShippingRatesByCurrencyFreeShippingThreshold from './groupShippingRatesByCurrencyFreeShippingThreshold'; - -describe( 'groupShippingRatesByCurrencyFreeShippingThreshold', () => { - it( 'should group the shipping rates based on currency and rate', () => { - const shippingRates = [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 100, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 100, - }, - }, - { - id: '4', - country: 'BR', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 250, - }, - }, - ]; - - const result = - groupShippingRatesByCurrencyFreeShippingThreshold( shippingRates ); - - expect( result.length ).toEqual( 3 ); - expect( result[ 0 ] ).toStrictEqual( { - countries: [ 'US' ], - currency: 'USD', - threshold: undefined, - } ); - expect( result[ 1 ] ).toStrictEqual( { - countries: [ 'AU', 'CN' ], - currency: 'USD', - threshold: 100, - } ); - expect( result[ 2 ] ).toStrictEqual( { - countries: [ 'BR' ], - currency: 'USD', - threshold: 250, - } ); - } ); - - it( 'should group the shipping rates based on currency and rate - rare edge case with different currencies', () => { - const shippingRates = [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 100, - }, - }, - { - id: '3', - country: 'CN', - currency: 'MYR', - rate: 25, - options: { - free_shipping_threshold: 100, - }, - }, - { - id: '4', - country: 'BR', - currency: 'MYR', - rate: 20, - options: { - free_shipping_threshold: 100, - }, - }, - ]; - - const result = - groupShippingRatesByCurrencyFreeShippingThreshold( shippingRates ); - - expect( result.length ).toEqual( 3 ); - expect( result[ 0 ] ).toStrictEqual( { - countries: [ 'US' ], - currency: 'USD', - threshold: undefined, - } ); - expect( result[ 1 ] ).toStrictEqual( { - countries: [ 'AU' ], - currency: 'USD', - threshold: 100, - } ); - expect( result[ 2 ] ).toStrictEqual( { - countries: [ 'CN', 'BR' ], - currency: 'MYR', - threshold: 100, - } ); - } ); -} ); diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js index 131d2b6f35..afcd107c91 100644 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js +++ b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js @@ -2,134 +2,49 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import GridiconPlusSmall from 'gridicons/dist/plus-small'; - -/** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate - */ /** * Internal dependencies */ import Section from '~/components/section'; -import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import VerticalGapLayout from '~/components/vertical-gap-layout'; -import { useAdaptiveFormInputProps } from '~/components/adaptive-form'; -import OfferFreeShippingCheckbox from '~/components/order-value-condition-section/offer-free-shipping-checkbox'; +import FreeShippingThresholdControl from '~/components/free-shipping-threshold-control'; import isNonFreeShippingRate from '~/utils/isNonFreeShippingRate'; -import MinimumOrderInputControl from './minimum-order-input-control'; -import { AddMinimumOrderFormModal } from './minimum-order-form-modals'; -import groupShippingRatesByCurrencyFreeShippingThreshold from './groupShippingRatesByCurrencyFreeShippingThreshold'; -import { calculateValueFromGroupChange } from './calculateValueFromGroupChange'; import './minimum-order-card.scss'; /** - * Renders a Card UI to provide the free shipping threshold for individual countries. + * @typedef { import("~/data/actions").ShippingRate } ShippingRate + */ + +/** + * Renders a Card UI to set a single free shipping threshold applied to all countries. * * @param {Object} props React props. - * @param {Array} [props.value=[]] Array of individual shipping rates to be used as the initial values of the form. + * @param {Array} [props.value=[]] Array of shipping rates; the threshold is read from the first non-free rate and written back to all rates. * @param {JSX.Element} [props.helper] Helper content to be rendered at the bottom of the card body. - * @param {(nextValue: Array) => void} props.onChange Callback called with the next data once shipping rates are changed. + * @param {(nextValue: Array) => void} props.onChange Callback called with the updated rates once the threshold changes. */ const MinimumOrderCard = ( { value = [], helper, onChange } ) => { - const offerFreeShippingCardInputProps = useAdaptiveFormInputProps( - 'offer_free_shipping' - ); - - const renderGroups = () => { - const nonZeroShippingRates = value.filter( isNonFreeShippingRate ); - const groups = - groupShippingRatesByCurrencyFreeShippingThreshold( - nonZeroShippingRates - ); - const countryOptions = nonZeroShippingRates.map( - ( shippingRate ) => shippingRate.country - ); - - // Event handlers for add, update, delete operations. - const addHandler = ( newGroup ) => { - onChange( calculateValueFromGroupChange( value, null, newGroup ) ); - }; - const getChangeHandler = ( oldGroup ) => ( newGroup ) => { - onChange( - calculateValueFromGroupChange( value, oldGroup, newGroup ) - ); - }; - const getDeleteHandler = ( oldGroup ) => () => { - onChange( calculateValueFromGroupChange( value, oldGroup ) ); - }; + const nonFreeRates = value.filter( isNonFreeShippingRate ); + const threshold = nonFreeRates[ 0 ]?.options?.free_shipping_threshold; + const currency = value[ 0 ]?.currency; - // If group length is 1, we render the group, - // regardless of threshold is defined or not. - if ( groups.length === 1 ) { - return ( - - ); - } - - /** - * Groups with defined threshold. This is used - * to render MinimumOrderInputControl. - */ - const thresholdGroups = groups.filter( - ( group ) => group.threshold !== undefined - ); - - /** - * The first group with undefined threshold. This is used - * to render the "Add another minimum order" button - * after all the groups with defined threshold. - */ - const emptyThresholdGroup = groups.find( - ( group ) => group.threshold === undefined - ); + const handleChange = ( numberValue ) => { + onChange( + value.map( ( rate ) => { + if ( ! isNonFreeShippingRate( rate ) ) { + return rate; + } - return ( - <> - { thresholdGroups.map( ( group ) => { - return ( - - ); - } ) } - { emptyThresholdGroup && ( -
- } - > - { __( - 'Add another condition', - 'google-listings-and-ads' - ) } - - } - modal={ - - } - /> -
- ) } - + return { + ...rate, + options: { + ...rate.options, + free_shipping_threshold: + numberValue > 0 ? numberValue : undefined, + }, + }; + } ) ); }; @@ -143,10 +58,11 @@ const MinimumOrderCard = ( { value = [], helper, onChange } ) => { ) } - - { renderGroups() } { helper } diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.test.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.test.js index af0bcfe331..bc0dfab50c 100644 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.test.js +++ b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.test.js @@ -2,340 +2,172 @@ * External dependencies */ import '@testing-library/jest-dom'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; /** * Internal dependencies */ import MinimumOrderCard from './minimum-order-card'; -import * as adaptiveForm from '~/components/adaptive-form'; - -jest.mock( '~/hooks/useAppSelectDispatch' ); -jest.mock( '~/hooks/useCountryKeyNameMap' ); -jest.mock( '~/hooks/useStoreCurrency' ); -jest.mock( '~/components/adaptive-form', () => { - return { - __esModule: true, - ...jest.requireActual( '~/components/adaptive-form' ), - }; -} ); - -const mockGetInputProps = jest.fn().mockImplementation( () => { - return { - checked: true, - className: '', - help: null, - onBlur: () => {}, - onChange: () => {}, - selected: true, - value: true, - }; -} ); - -const adaptiveFormContextDefaultValues = { - countries: [ 'ES' ], - language: 'English', - locale: 'en_US', - location: 'selected', - offer_free_shipping: true, - shipping_country_rates: [], - shipping_country_times: [], - shipping_rate: true, - shipping_time: true, -}; - -const adaptiveFormInputPropsDefaultValues = { - checked: true, - className: '', - help: null, - onBlur: () => {}, - onChange: () => {}, - selected: true, - value: true, -}; - -const getMinimumOrderCardDefaultValue = () => { - return Object.freeze( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'ES', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 50, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ] ); -}; - -const spyAdaptiveFormFunction = ( functionName ) => { - return jest.spyOn( adaptiveForm, functionName ); -}; - -const mockAdaptiveFormContextImplementation = ( spy, values = {} ) => { - spy.mockImplementation( () => ( { - getInputProps: mockGetInputProps(), - values: { - ...adaptiveFormContextDefaultValues, - ...values, - }, - } ) ); -}; - -const mockAdaptiveFormInputPropsImplementation = ( spy, values = {} ) => { - spy.mockImplementation( () => ( { - ...adaptiveFormInputPropsDefaultValues, - ...values, - } ) ); -}; +import { + useAdaptiveFormContext, + useAdaptiveFormInputProps, +} from '~/components/adaptive-form'; + +jest.mock( '~/components/adaptive-form', () => ( { + useAdaptiveFormContext: jest.fn(), + useAdaptiveFormInputProps: jest.fn(), +} ) ); + +const defaultRates = Object.freeze( [ + { + id: '1', + country: 'US', + currency: 'USD', + rate: 20, + options: { free_shipping_threshold: 50 }, + }, + { + id: '2', + country: 'ES', + currency: 'USD', + rate: 20, + options: { free_shipping_threshold: 50 }, + }, +] ); + +function mockFormContext( { offerFreeShipping = true } = {} ) { + useAdaptiveFormContext.mockReturnValue( { + values: { offer_free_shipping: offerFreeShipping }, + } ); + useAdaptiveFormInputProps.mockReturnValue( { + checked: offerFreeShipping, + onChange: jest.fn(), + onBlur: jest.fn(), + value: offerFreeShipping, + } ); +} describe( 'MinimumOrderCard', () => { - let value, - onChange, - rendered, - spyAdaptiveFormContext, - spyAdaptiveFormInputProps; + let onChange; - describe( 'onChange callback property', () => { - beforeEach( () => { - spyAdaptiveFormContext = spyAdaptiveFormFunction( - 'useAdaptiveFormContext' - ); - mockAdaptiveFormContextImplementation( spyAdaptiveFormContext ); + beforeEach( () => { + onChange = jest.fn(); + } ); - spyAdaptiveFormInputProps = spyAdaptiveFormFunction( - 'useAdaptiveFormInputProps' - ); - mockAdaptiveFormInputPropsImplementation( - spyAdaptiveFormInputProps - ); + afterEach( () => { + jest.clearAllMocks(); + } ); - value = getMinimumOrderCardDefaultValue(); - onChange = jest.fn().mockName( 'onChange callback' ); + describe( 'rendering', () => { + it( 'renders a single threshold input when offer_free_shipping is true', () => { + mockFormContext( { offerFreeShipping: true } ); - rendered = render( - + render( + ); - } ); - afterEach( () => { - jest.clearAllMocks(); + expect( screen.getByRole( 'textbox' ) ).toHaveValue( '50.00' ); } ); - test( 'When the new minimum order value is provided for a remaining country, calls the `onChange` callback with the new value containing `shippingRate.options.free_shipping_threshold` set to the given value', async () => { - const user = userEvent.setup(); + it( 'does not render the threshold input when offer_free_shipping is false', () => { + mockFormContext( { offerFreeShipping: false } ); - // Open "Add another…" modal. - await user.click( rendered.getByRole( 'button', { name: /Add/ } ) ); - // Input some value. - const input = screen.getByRole( 'textbox' ); - await user.type( input, '30' ); - // Confirm. - await user.click( - screen.getByRole( 'button', { - name: /Add minimum order/, - } ) + render( + ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); - - // Expect US threshold to be set to 30. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 30; - expect( onChange ).toHaveBeenCalledWith( expectedValue ); + expect( screen.queryByRole( 'textbox' ) ).toBeNull(); } ); + } ); - test( 'When a minimum order value is changed for an existing group, calls the `onChange` callback with the new value containing `shippingRate.options.free_shipping_threshold`s set to the given value', async () => { + describe( 'handleBlur', () => { + it( 'calls onChange with all rates updated when threshold changes', async () => { + mockFormContext( { offerFreeShipping: true } ); const user = userEvent.setup(); - // Input some value. - const input = rendered.getByRole( 'textbox' ); - await user.type( input, '7' ); - // Blur away. + const rates = [ + { + id: '1', + country: 'US', + currency: 'USD', + rate: 20, + options: { free_shipping_threshold: undefined }, + }, + { + id: '2', + country: 'ES', + currency: 'USD', + rate: 15, + options: { free_shipping_threshold: undefined }, + }, + ]; + + render( + + ); + + const input = screen.getByRole( 'textbox' ); + await user.type( input, '30' ); await user.tab(); expect( onChange ).toHaveBeenCalledTimes( 1 ); - - // Expect ES, CN threshold to be updated to 507. - const expectedValue = structuredClone( value ); - expectedValue[ 1 ].options.free_shipping_threshold = 507; - expectedValue[ 2 ].options.free_shipping_threshold = 507; - expect( onChange ).toHaveBeenCalledWith( expectedValue ); + const nextValue = onChange.mock.calls[ 0 ][ 0 ]; + expect( + nextValue.every( + ( r ) => r.options.free_shipping_threshold === 30 + ) + ).toBe( true ); } ); - test( 'When a set of countries is changed for a minimum order value in an existing group, calls the `onChange` callback with the new value containing `shippingRate.options.free_shipping_threshold`s set to the given value for new countries, and `undefined` for old ones', async () => { - const user = userEvent.setup(); - - // Open group/"Edit" modal. - await user.click( - rendered.getByRole( 'button', { name: /Edit/ } ) - ); - // Input some value. - const countriesSelect = rendered.getByRole( 'combobox' ); - await fireEvent.click( countriesSelect ); - // Find and de-select Spain. - fireEvent.change( countriesSelect, { target: { value: 'Spain' } } ); - fireEvent.click( screen.queryByLabelText( 'Spain' ) ); - // Find and select States. - fireEvent.change( countriesSelect, { target: { value: 'State' } } ); - fireEvent.click( screen.queryByLabelText( 'United States' ) ); - // Confirm. - await user.click( - screen.getByRole( 'button', { - name: /Update/, - } ) - ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); - - // Expect US threshold to be set to 50, - // and ES threshold to be set to `undefined. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 50; - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expect( onChange ).toHaveBeenCalledWith( expectedValue ); - } ); - test( 'When a set of countries and threshold are changed for an existing group, calls the `onChange` callback with the new value containing `shippingRate.options.free_shipping_threshold`s set to the given value for new countries, and `undefined` for old ones', async () => { + it( 'does not call onChange when blur with the same value', async () => { + mockFormContext( { offerFreeShipping: true } ); const user = userEvent.setup(); - // Open group/"Edit" modal. - await user.click( - rendered.getByRole( 'button', { name: /Edit/ } ) - ); - // Input some value. - const countriesSelect = rendered.getByRole( 'combobox' ); - await fireEvent.click( countriesSelect ); - // Find and de-select Spain. - fireEvent.change( countriesSelect, { target: { value: 'Spain' } } ); - fireEvent.click( screen.queryByLabelText( 'Spain' ) ); - // Find and select States. - fireEvent.change( countriesSelect, { target: { value: 'State' } } ); - fireEvent.click( screen.queryByLabelText( 'United States' ) ); - // Input some value. - const input = rendered.getByRole( 'textbox' ); - await user.type( input, '7' ); - // Confirm. - await user.click( - screen.getByRole( 'button', { - name: /Update/, - } ) + render( + ); - expect( onChange ).toHaveBeenCalledTimes( 1 ); + const input = screen.getByRole( 'textbox' ); + // Clear then retype the same value. + await user.clear( input ); + await user.type( input, '50' ); + await user.tab(); - // Expect US threshold to be set to 507, - // ES threshold to be set to `undefined`, - // and CN to be changed to 507. - const expectedValue = structuredClone( value ); - expectedValue[ 0 ].options.free_shipping_threshold = 507; - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expectedValue[ 2 ].options.free_shipping_threshold = 507; - expect( onChange ).toHaveBeenCalledWith( expectedValue ); + expect( onChange ).not.toHaveBeenCalled(); } ); - test( 'When a minimum order value is removed for a group of countries, calls the `onChange` callback with the new value containing `shippingRate.options.free_shipping_threshold`s set to `undefined`', async () => { + it( 'sets threshold to undefined when value is 0', async () => { + mockFormContext( { offerFreeShipping: true } ); const user = userEvent.setup(); - // Open group/"Edit" modal. - await user.click( - rendered.getByRole( 'button', { name: /Edit/ } ) - ); - // Click delete. - await user.click( - screen.getByRole( 'button', { name: /Delete/ } ) + render( + ); + + const input = screen.getByRole( 'textbox' ); + await user.clear( input ); await user.tab(); expect( onChange ).toHaveBeenCalledTimes( 1 ); - - // Expect ES & CN threshold to be set to `undefined`. - const expectedValue = structuredClone( value ); - expectedValue[ 1 ].options.free_shipping_threshold = undefined; - expectedValue[ 2 ].options.free_shipping_threshold = undefined; - expect( onChange ).toHaveBeenCalledWith( expectedValue ); - } ); - } ); - - describe( 'Free shipping order value condition', () => { - beforeEach( () => { - value = getMinimumOrderCardDefaultValue(); - onChange = jest.fn().mockName( 'onChange callback' ); - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - test( 'When no free shipping is offered, hides the input field', async () => { - spyAdaptiveFormContext = spyAdaptiveFormFunction( - 'useAdaptiveFormContext' - ); - mockAdaptiveFormContextImplementation( spyAdaptiveFormContext, { - offer_free_shipping: false, - } ); - - spyAdaptiveFormInputProps = spyAdaptiveFormFunction( - 'useAdaptiveFormInputProps' - ); - mockAdaptiveFormInputPropsImplementation( - spyAdaptiveFormInputProps - ); - - rendered = render( - - ); - - const { container } = rendered; - - const inputControl = container.querySelector( - '.gla-minimum-order-input-control' - ); - - expect( inputControl ).toBeNull(); - } ); - - test( 'When free shipping is offered, do not hide the input field', async () => { - spyAdaptiveFormContext = spyAdaptiveFormFunction( - 'useAdaptiveFormContext' - ); - mockAdaptiveFormContextImplementation( spyAdaptiveFormContext, { - offer_free_shipping: true, - } ); - - spyAdaptiveFormInputProps = spyAdaptiveFormFunction( - 'useAdaptiveFormInputProps' - ); - mockAdaptiveFormInputPropsImplementation( - spyAdaptiveFormInputProps - ); - - rendered = render( - - ); - - const { container } = rendered; - - const inputControl = container.querySelector( - '.gla-minimum-order-input-control' - ); - - expect( inputControl ).not.toBeNull(); + const nextValue = onChange.mock.calls[ 0 ][ 0 ]; + expect( + nextValue.every( + ( r ) => r.options.free_shipping_threshold === undefined + ) + ).toBe( true ); } ); } ); } ); diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/add-minimum-order-form-modal.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/add-minimum-order-form-modal.js deleted file mode 100644 index 0ab5659d5d..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/add-minimum-order-form-modal.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import MinimumOrderFormModal from './minimum-order-form-modal'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Display the add minimum order modal that is wrapped in a Form. - * - * When users submit the form, `props.onRequestClose` will be called first, and then followed by `props.onSubmit`. - * If we were to call `props.onSubmit` first, it may cause some state change in the parent component and causing this component not to be rendered, - * and when `props.onRequestClose` is called later, there would be a runtime React error because the component is no longer there. - * - * @param {Object} props Props. - * @param {Array} props.countryOptions Array of country codes options, to be used as options in SupportedCountrySelect. - * @param {MinimumOrderGroup} props.initialValues Initial values for the form. - * @param {(values: MinimumOrderGroup) => void} props.onSubmit Callback when the form is submitted, with the form value. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const AddMinimumOrderFormModal = ( { - countryOptions, - initialValues, - onSubmit, - onRequestClose, -} ) => { - return ( - { - const { isValidForm, handleSubmit } = formProps; - - const handleAddClick = () => { - onRequestClose(); - handleSubmit(); - }; - - return [ - - { __( 'Add minimum order', 'google-listings-and-ads' ) } - , - ]; - } } - onSubmit={ onSubmit } - onRequestClose={ onRequestClose } - /> - ); -}; - -export default AddMinimumOrderFormModal; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/edit-minimum-order-form-modal.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/edit-minimum-order-form-modal.js deleted file mode 100644 index 5393dc5016..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/edit-minimum-order-form-modal.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import MinimumOrderFormModal from './minimum-order-form-modal'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Display the edit minimum order modal that is wrapped in a Form. - * - * When users submit the form, `props.onRequestClose` will be called first, and then followed by `props.onSubmit`. - * If we were to call `props.onSubmit` first, it may cause some state change in the parent component and causing this component not to be rendered, - * and when `props.onRequestClose` is called later, there would be a runtime React error because the component is no longer there. - * - * @param {Object} props Props. - * @param {Array} props.countryOptions Array of country codes options, to be used as options in SupportedCountrySelect. - * @param {MinimumOrderGroup} props.initialValues Initial values for the form. - * @param {(values: MinimumOrderGroup) => void} props.onSubmit Callback when the form is submitted, with the form value. - * @param {() => void} props.onDelete Callback when delete button is clicked. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const EditMinimumOrderFormModal = ( { - countryOptions, - initialValues, - onSubmit, - onRequestClose, - onDelete, -} ) => { - const handleDeleteClick = () => { - onRequestClose(); - onDelete(); - }; - - return ( - { - const { isValidForm, handleSubmit } = formProps; - - const handleUpdateClick = () => { - onRequestClose(); - handleSubmit(); - }; - - return [ - - { __( 'Delete', 'google-listings-and-ads' ) } - , - - { __( - 'Update minimum order', - 'google-listings-and-ads' - ) } - , - ]; - } } - onSubmit={ onSubmit } - onRequestClose={ onRequestClose } - /> - ); -}; - -export default EditMinimumOrderFormModal; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/index.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/index.js deleted file mode 100644 index 3359cbda0f..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as AddMinimumOrderFormModal } from './add-minimum-order-form-modal'; -export { default as EditMinimumOrderFormModal } from './edit-minimum-order-form-modal'; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/minimum-order-form-modal.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/minimum-order-form-modal.js deleted file mode 100644 index 8244e9a2c8..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/minimum-order-form-modal.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Form } from '@woocommerce/components'; - -/** - * Internal dependencies - */ -import AppModal from '~/components/app-modal'; -import AppInputPriceControl from '~/components/app-input-price-control'; -import VerticalGapLayout from '~/components/vertical-gap-layout'; -import SupportedCountrySelect from '~/components/supported-country-select'; -import validateMinimumOrder from './validateMinimumOrder'; - -/** - * @typedef { import("~/components/app-button").default } AppButton - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Minimum order modal that is wrapped in a form. - * - * Buttons can be customized by using the `renderButtons` prop. - * The render function will be passed `formProps`, - * allowing the buttons to have access to form's `isValidForm` and `handleSubmit`. - * - * @param {Object} props Props. - * @param {Array} props.countryOptions Array of country codes options, to be used as options in SupportedCountrySelect. - * @param {(formProps: Object) => Array} props.renderButtons Function to render buttons for the modal. `formProps` will be passed into this render function. - * @param {MinimumOrderGroup} props.initialValues Initial values for the form. - * @param {(values: MinimumOrderGroup) => void} props.onSubmit Callback when the form is submitted, with the form value. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const MinimumOrderFormModal = ( { - countryOptions, - renderButtons, - initialValues, - onSubmit, - onRequestClose, -} ) => { - const [ dropdownVisible, setDropdownVisible ] = useState( false ); - - return ( - - { ( formProps ) => { - const { getInputProps, values, setValue } = formProps; - - return ( - - - - { - getInputProps( 'threshold' ).onBlur( - event - ); - setValue( - 'threshold', - numberValue > 0 - ? numberValue - : undefined - ); - } } - /> - - - ); - } } - - ); -}; - -export default MinimumOrderFormModal; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.js deleted file mode 100644 index e1cedc9152..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * @typedef { import("../typedefs.js").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * @param {MinimumOrderGroup} values - */ -const validateMinimumOrder = ( values ) => { - const errors = {}; - - if ( values.countries.length === 0 ) { - errors.countries = __( - 'Please specify at least one country.', - 'google-listings-and-ads' - ); - } - - if ( ! ( values.threshold > 0 ) ) { - errors.threshold = __( - 'The minimum order amount must be greater than 0.', - 'google-listings-and-ads' - ); - } - - return errors; -}; - -export default validateMinimumOrder; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.test.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.test.js deleted file mode 100644 index 7acfd49d99..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-form-modals/validateMinimumOrder.test.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Internal dependencies - */ -import validateMinimumOrder from './validateMinimumOrder'; - -const validValues = { - countries: [ 'US' ], - currency: 'USD', - threshold: 50, -}; - -describe( 'validateMinimumOrder', () => { - it( 'has no errors properties with validValues', () => { - const values = { - ...validValues, - }; - - const errors = validateMinimumOrder( values ); - - expect( errors ).toEqual( {} ); - } ); - - it( 'has errors.countries when values.countries.length is 0', () => { - const values = { - ...validValues, - countries: [], - }; - - const errors = validateMinimumOrder( values ); - - expect( errors ).toHaveProperty( 'countries' ); - } ); - - it( 'has errors.threshold when values.threshold is less than 0', () => { - const values = { - ...validValues, - threshold: -1, - }; - - const errors = validateMinimumOrder( values ); - - expect( errors ).toHaveProperty( 'threshold' ); - } ); - - it( 'has errors.threshold when values.threshold is not entered', () => { - const values = { - ...validValues, - threshold: undefined, - }; - - const errors = validateMinimumOrder( values ); - - expect( errors ).toHaveProperty( 'threshold' ); - } ); -} ); diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control-label-text.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control-label-text.js deleted file mode 100644 index 071ad4de9a..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control-label-text.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ - -import CountryNamesPlusMore from '~/components/country-names-plus-more'; - -const MinimumOrderInputControlLabelText = ( props ) => { - const { countries } = props; - - return ( -
- %1$s + %2$d more`, - 'google-listings-and-ads' - ) - } - textWithoutMore={ - // translators: 1: list of country names separated by comma. - __( - `Minimum order for %1$s`, - 'google-listings-and-ads' - ) - } - /> -
- ); -}; - -export default MinimumOrderInputControlLabelText; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js deleted file mode 100644 index 6de5e31ec5..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; -import AppInputPriceControl from '~/components/app-input-price-control'; -import { useAdaptiveFormContext } from '~/components/adaptive-form'; -import { EditMinimumOrderFormModal } from './minimum-order-form-modals'; -import MinimumOrderInputControlLabelText from './minimum-order-input-control-label-text'; -import './minimum-order-input-control.scss'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("./typedefs").MinimumOrderGroup } MinimumOrderGroup - */ - -/** - * Input control to edit a minimum order group. - * - * The input control label contains a placeholder area for `button`, after the label text. - * This is meant to display an "Edit" button. - * - * @param {Object} props - * @param {Array} props.countryOptions Country options to be passed to EditMinimumOrderFormModal. - * @param {MinimumOrderGroup} props.value Minimum order group value. - * @param {(newGroup: MinimumOrderGroup) => void} props.onChange Called when minimum order group changes. - * @param {() => void} props.onDelete Called when delete button in EditMinimumOrderFormModal is clicked. - */ -const MinimumOrderInputControl = ( props ) => { - const { countryOptions, value, onChange, onDelete } = props; - const { countries, threshold, currency } = value; - const { values } = useAdaptiveFormContext(); - - const handleBlur = ( event, numberValue ) => { - if ( numberValue === value.threshold ) { - return; - } - - onChange( { - countries, - threshold: numberValue > 0 ? numberValue : undefined, - currency, - } ); - }; - - const shouldHideInput = ! values.offer_free_shipping; - - if ( shouldHideInput ) { - return null; - } - - return ( - -
- - - { __( 'Edit', 'google-listings-and-ads' ) } - - } - modal={ - - } - /> -
- <>{ `Cost (${ currency })` } - - } - value={ threshold } - onBlur={ handleBlur } - /> - ); -}; - -export default MinimumOrderInputControl; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.scss b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.scss deleted file mode 100644 index c4197c1feb..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.scss +++ /dev/null @@ -1,20 +0,0 @@ -.gla-minimum-order-input-control { - & &__label { - display: flex; - flex-direction: column; - justify-content: space-between; - gap: $grid-unit-10; - } - - & &__label_country { - display: flex; - justify-content: space-between; - gap: $grid-unit-05; - - button { - height: fit-content; - line-height: 1.4em; - padding: 0; - } - } -} diff --git a/js/src/components/order-value-condition-section/minimum-order-card/typedefs.js b/js/src/components/order-value-condition-section/minimum-order-card/typedefs.js deleted file mode 100644 index e538b3e5b8..0000000000 --- a/js/src/components/order-value-condition-section/minimum-order-card/typedefs.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @typedef {Object} MinimumOrderGroup - * @property {Array} countries Array of selected country codes. - * @property {string} currency Selected currency. - * @property {number} threshold Threshold value. - */ - -export default {}; diff --git a/js/src/components/shipping-rate-input-control/index.js b/js/src/components/shipping-rate-input-control/index.js new file mode 100644 index 0000000000..a0a26d8812 --- /dev/null +++ b/js/src/components/shipping-rate-input-control/index.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Pill } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import AppInputPriceControl from '~/components/app-input-price-control'; +import useStoreCurrency from '~/hooks/useStoreCurrency'; +import './index.scss'; + +/** + * @typedef { import("~/data/actions").CountryCode } CountryCode + */ + +/** + * Input control to edit a shipping rate. + * + * @param {Object} props + * @param {JSX.Element|string} props.label Label content for the input control. + * @param {number} props.value The shipping rate this control is responsible for. + * @param {(newRate: number) => void} props.onChange Callback called with the new rate when the rate is changed. + * @param {boolean} [props.hideLabelFromVision] Whether the label should be hidden from vision. Default to true. + */ +const ShippingRateInputControl = ( { + label, + value, + onChange, + hideLabelFromVision = true, +} ) => { + const { code: currencyCode } = useStoreCurrency(); + + const handleBlur = ( event, numberValue ) => { + if ( + value === numberValue || + isNaN( numberValue ) || + numberValue < 0 + ) { + return; + } + + onChange( numberValue ); + }; + + return ( +
+ + + { value === 0 && ( +
+ + { __( + 'Free shipping for all orders', + 'google-listings-and-ads' + ) } + +
+ ) } +
+ ); +}; + +export default ShippingRateInputControl; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.scss b/js/src/components/shipping-rate-input-control/index.scss similarity index 100% rename from js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.scss rename to js/src/components/shipping-rate-input-control/index.scss diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js index 28ca774c59..18f8daa44a 100644 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js +++ b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js @@ -2,35 +2,27 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import GridiconPlusSmall from 'gridicons/dist/plus-small'; /** * Internal dependencies */ import Section from '~/components/section'; -import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import VerticalGapLayout from '~/components/vertical-gap-layout'; -import useStoreCurrency from '~/hooks/useStoreCurrency'; -import groupShippingRatesByCurrencyRate from './groupShippingRatesByCurrencyRate'; -import ShippingRateInputControl from './shipping-rate-input-control'; -import { AddRateFormModal } from './rate-form-modals'; -import getHandlers from './getHandlers'; +import ShippingRateInputControl from '~/components/shipping-rate-input-control'; +import ShippingRateInputControlLabelText from './shipping-rate-input-control-label-text'; /** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate * @typedef { import("~/data/actions").CountryCode } CountryCode */ /** - * The "Estimated shipping rates" card to provide shipping rates for individual countries, - * with an UI, that allows to aggregate countries with the same rate. + * The "Estimated shipping rates" card with a single flat rate input applied to all audience countries. * * @param {Object} props - * @param {Array} props.value Array of individual shipping rates to be used as the initial values of the form. * @param {Array} props.audienceCountries Array of country codes of all audience countries. + * @param {number} props.value The shipping rate this control is responsible for. * @param {JSX.Element} [props.helper] Helper content to be rendered at the bottom of the card body. - * @param {(newValue: Array) => void} props.onChange Callback called with new data once shipping rates are changed. + * @param {(newValue: number) => void} props.onChange Callback called with the new rate once it is changed. */ export default function EstimatedShippingRatesCard( { audienceCountries, @@ -38,97 +30,6 @@ export default function EstimatedShippingRatesCard( { helper, onChange, } ) { - const { code: currencyCode } = useStoreCurrency(); - const { handleAddSubmit, getChangeHandler, getDeleteHandler } = getHandlers( - { value, onChange } - ); - - /** - * Function to render the shipping rate groups from `value`. - * - * If there is no group, we render a `ShippingRateInputControl` - * with a pre-filled group, so that users can straight away - * key in shipping rate for all countries immediately. - * - * If there are groups, we render `ShippingRateInputControl` for each group, - * and render an "Add rate button" if there are remaining countries. - */ - const renderGroups = () => { - const groups = groupShippingRatesByCurrencyRate( value ); - - if ( groups.length === 0 ) { - const prefilledGroup = { - countries: audienceCountries, - currency: currencyCode, - rate: undefined, - }; - - return ( - - ); - } - - /** - * The remaining countries that do not have a shipping rate value yet. - */ - const remainingCountries = audienceCountries.filter( ( country ) => { - const exist = value.some( - ( shippingRate ) => shippingRate.country === country - ); - - return ! exist; - } ); - - return ( - <> - { groups.map( ( group ) => { - return ( - - ); - } ) } - { remainingCountries.length >= 1 && ( -
- } - > - { __( - 'Add another rate', - 'google-listings-and-ads' - ) } - - } - modal={ - - } - /> -
- ) } - - ); - }; - return ( @@ -139,7 +40,15 @@ export default function EstimatedShippingRatesCard( { ) } - { renderGroups() } + + } + value={ value } + onChange={ onChange } + /> { helper } diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.js deleted file mode 100644 index 0d325b45dc..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Internal dependencies - */ -import isNonFreeShippingRate from '~/utils/isNonFreeShippingRate'; - -/** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("./typedefs").ShippingRateGroup } ShippingRateGroup - */ - -const defaultShippingRate = { - options: {}, -}; - -/** - * Get handlers for EstimatedShippingRatesCard. - * - * The handlers are to convert shipping rate group into shipping rates that can be propagated up via `onChange`. - * - * @param {Object} props - * @param {Array} props.value Array of individual shipping rates to be used as the initial values of the form. - * @param {(newValue: Array) => void} props.onChange Callback called with new data once shipping rates are changed. - */ -const getHandlers = ( { value, onChange } ) => { - /** - * Event handler for adding new shipping rate group. - * - * Shipping rate group will be converted into shipping rates, and propagate up via `onChange`. - * - * @param {ShippingRateGroup} newGroup Shipping rate group. - */ - const handleAddSubmit = ( { countries, currency, rate } ) => { - const newShippingRates = countries.map( ( country ) => ( { - ...defaultShippingRate, - country, - currency, - rate, - } ) ); - - onChange( value.concat( newShippingRates ) ); - }; - - /** - * Get the `onChange` event handler for shipping rate group. - * - * @param {ShippingRateGroup} oldGroup Old shipping rate group. - */ - const getChangeHandler = ( oldGroup ) => { - /** - * @param {ShippingRateGroup} newGroup New shipping rate group from `onChange` event. - */ - const handleChange = ( newGroup ) => { - /* - * Create new shipping rates value by filtering out deleted countries first. - * - * A country is deleted when it exists in `oldGroup` and not exists in `newGroup`. - */ - const newValue = value.filter( ( shippingRate ) => { - const isDeleted = - oldGroup.countries.includes( shippingRate.country ) && - ! newGroup.countries.includes( shippingRate.country ); - return ! isDeleted; - } ); - - /* - * Upsert shipping rates in `newValue` by looping through `newGroup.countries`. - */ - newGroup.countries.forEach( ( country ) => { - const existingIndex = newValue.findIndex( - ( shippingRate ) => shippingRate.country === country - ); - const oldShippingRate = newValue[ existingIndex ]; - const newShippingRate = { - ...defaultShippingRate, - ...oldShippingRate, - country, - currency: newGroup.currency, - rate: newGroup.rate, - }; - - /* - * If the shipping rate is free, - * we remove the free_shipping_threshold. - */ - if ( ! isNonFreeShippingRate( newShippingRate ) ) { - newShippingRate.options.free_shipping_threshold = undefined; - } - - if ( existingIndex >= 0 ) { - newValue[ existingIndex ] = newShippingRate; - } else { - newValue.push( newShippingRate ); - } - } ); - - onChange( newValue ); - }; - - return handleChange; - }; - - /** - * Get the `onDelete` event handler for shipping rate group. - * - * @param {ShippingRateGroup} oldGroup Shipping rate group. - */ - const getDeleteHandler = ( oldGroup ) => () => { - const newValue = value.filter( - ( shippingRate ) => - ! oldGroup.countries.includes( shippingRate.country ) - ); - onChange( newValue ); - }; - - return { handleAddSubmit, getChangeHandler, getDeleteHandler }; -}; - -export default getHandlers; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.test.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.test.js deleted file mode 100644 index a454dd016e..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/getHandlers.test.js +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Internal dependencies - */ -import getHandlers from './getHandlers'; - -describe( 'getHandlers', () => { - let mockOnChange; - let handlers; - - beforeEach( () => { - const value = [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 50, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ]; - - mockOnChange = jest.fn(); - handlers = getHandlers( { - value, - onChange: mockOnChange, - } ); - } ); - - describe( 'handleAddSubmit', () => { - it( 'returns value with the newly added group', () => { - const newGroup = { - countries: [ 'MY', 'SG' ], - currency: 'USD', - rate: 30, - }; - - const { handleAddSubmit } = handlers; - handleAddSubmit( newGroup ); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 20, - options: { - free_shipping_threshold: 50, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - { - country: 'MY', - currency: 'USD', - rate: 30, - options: {}, - }, - { - country: 'SG', - currency: 'USD', - rate: 30, - options: {}, - }, - ] ); - } ); - } ); - - describe( 'getChangeHandler', () => { - it( 'returns value updated based on changed group rate', () => { - const oldGroup = { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - }; - const newGroup = { - ...oldGroup, - rate: 25, - }; - - const { getChangeHandler } = handlers; - getChangeHandler( oldGroup )( newGroup ); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 25, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ] ); - } ); - - it( 'returns value with no free_shipping_threshold if the group rate is updated to 0', () => { - const oldGroup = { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - }; - const newGroup = { - ...oldGroup, - rate: 0, - }; - - const { getChangeHandler } = handlers; - getChangeHandler( oldGroup )( newGroup ); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 0, - options: { - free_shipping_threshold: undefined, - }, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 0, - options: { - free_shipping_threshold: undefined, - }, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ] ); - } ); - - it( 'returns value updated based on removed and added countries', () => { - const oldGroup = { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - }; - // country AU is removed, and country LK is added. - const newGroup = { - ...oldGroup, - countries: [ 'US', 'LK' ], - }; - - const { getChangeHandler } = handlers; - getChangeHandler( oldGroup )( newGroup ); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - { - country: 'LK', - currency: 'USD', - rate: 20, - options: {}, - }, - ] ); - } ); - - it( 'returns value updated based on all changed countries and rate', () => { - const oldGroup = { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - }; - // countries and rate are all changed. - const newGroup = { - ...oldGroup, - countries: [ 'VN', 'TH' ], - rate: 35, - }; - - const { getChangeHandler } = handlers; - getChangeHandler( oldGroup )( newGroup ); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - { - country: 'VN', - currency: 'USD', - rate: 35, - options: {}, - }, - { - country: 'TH', - currency: 'USD', - rate: 35, - options: {}, - }, - ] ); - } ); - } ); - - describe( 'getDeleteHandler', () => { - it( 'returns value without the deleted group', () => { - const oldGroup = { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - }; - - const { getDeleteHandler } = handlers; - getDeleteHandler( oldGroup )(); - - expect( mockOnChange.mock.calls.length ).toBe( 1 ); - expect( mockOnChange.mock.calls[ 0 ][ 0 ] ).toStrictEqual( [ - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: { - free_shipping_threshold: 50, - }, - }, - ] ); - } ); - } ); -} ); diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.js deleted file mode 100644 index 49d70c780c..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @typedef { import("~/data/actions").ShippingRate } ShippingRate - * @typedef { import("./typedefs").ShippingRateGroup } ShippingRateGroup - */ - -/** - * Groups shipping rates by currency and rate into shipping rate groups. - * - * @param {Array} shippingRates Array of shipping rates. - * @return {Array} Array of shipping rate groups. - */ -const groupShippingRatesByCurrencyRate = ( shippingRates ) => { - const map = new Map(); - - shippingRates.forEach( ( shippingRate ) => { - const { country, currency, rate } = shippingRate; - const key = `${ currency } ${ rate } `; - const group = map.get( key ) || { - countries: [], - currency, - rate, - }; - group.countries.push( country ); - map.set( key, group ); - } ); - - return Array.from( map.values() ); -}; - -export default groupShippingRatesByCurrencyRate; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.test.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.test.js deleted file mode 100644 index a2ec2dd7b8..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/groupShippingRatesByCurrencyRate.test.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Internal dependencies - */ -import groupShippingRatesByCurrencyRate from './groupShippingRatesByCurrencyRate'; - -describe( 'groupShippingRatesByCurrencyRate', () => { - it( 'should group the shipping rates based on currency and rate', () => { - const shippingRates = [ - { - id: '1', - country: 'US', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '2', - country: 'AU', - currency: 'USD', - rate: 20, - options: {}, - }, - { - id: '3', - country: 'CN', - currency: 'USD', - rate: 25, - options: {}, - }, - { - id: '4', - country: 'BR', - currency: 'BRL', - rate: 20, - options: {}, - }, - ]; - - const result = groupShippingRatesByCurrencyRate( shippingRates ); - - expect( result.length ).toEqual( 3 ); - expect( result[ 0 ] ).toStrictEqual( { - countries: [ 'US', 'AU' ], - currency: 'USD', - rate: 20, - } ); - expect( result[ 1 ] ).toStrictEqual( { - countries: [ 'CN' ], - currency: 'USD', - rate: 25, - } ); - expect( result[ 2 ] ).toStrictEqual( { - countries: [ 'BR' ], - currency: 'BRL', - rate: 20, - } ); - } ); -} ); diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/add-rate-form-modal.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/add-rate-form-modal.js deleted file mode 100644 index 03d0d2d28f..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/add-rate-form-modal.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { noop } from 'lodash'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import RateFormModal from './rate-form-modal'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").ShippingRateGroup } ShippingRateGroup - */ - -/** - * Form to add a new rate for selected country(-ies). - * - * @param {Object} props - * @param {Array} props.countryOptions Array of country codes, to be used as options in SupportedCountrySelect. - * @param {ShippingRateGroup} props.initialValues Initial values for the form. - * @param {(values: ShippingRateGroup) => void} props.onSubmit Called with submitted value. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const AddRateFormModal = ( { - countryOptions, - initialValues, - onSubmit, - onRequestClose = noop, -} ) => { - return ( - { - const { isValidForm, handleSubmit } = formProps; - - const handleAddClick = () => { - onRequestClose(); - handleSubmit(); - }; - - return [ - - { __( 'Add shipping rate', 'google-listings-and-ads' ) } - , - ]; - } } - onSubmit={ onSubmit } - onRequestClose={ onRequestClose } - /> - ); -}; - -export default AddRateFormModal; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/edit-rate-form-modal.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/edit-rate-form-modal.js deleted file mode 100644 index 87201c4cdc..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/edit-rate-form-modal.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { noop } from 'lodash'; - -/** - * Internal dependencies - */ -import AppButton from '~/components/app-button'; -import RateFormModal from './rate-form-modal'; - -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").ShippingRateGroup } ShippingRateGroup - */ - -/** - * Form modal to edit or delete shipping rate group. - * - * @param {Object} props - * @param {Array} props.countryOptions Array of country codes, to be used as options in SupportedCountrySelect. - * @param {ShippingRateGroup} props.initialValues Initial values for the form. - * @param {(values: ShippingRateGroup) => void} props.onSubmit Called when the shipping rate group is submitted. - * @param {() => void} props.onDelete Called when users clicked on the Delete button. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const EditRateFormModal = ( { - countryOptions, - initialValues, - onSubmit, - onRequestClose = noop, - onDelete = noop, -} ) => { - const handleDeleteClick = () => { - onRequestClose(); - onDelete(); - }; - - return ( - { - const { isValidForm, handleSubmit } = formProps; - - const handleUpdateClick = () => { - onRequestClose(); - handleSubmit(); - }; - - return [ - - { __( 'Delete', 'google-listings-and-ads' ) } - , - - { __( - 'Update shipping rate', - 'google-listings-and-ads' - ) } - , - ]; - } } - onSubmit={ onSubmit } - onRequestClose={ onRequestClose } - /> - ); -}; - -export default EditRateFormModal; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/index.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/index.js deleted file mode 100644 index 88f604d467..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as AddRateFormModal } from './add-rate-form-modal'; -export { default as EditRateFormModal } from './edit-rate-form-modal'; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/rate-form-modal.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/rate-form-modal.js deleted file mode 100644 index d4be53897f..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/rate-form-modal.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * External dependencies - */ -import { useState } from '@wordpress/element'; -import { Form } from '@woocommerce/components'; -import { __ } from '@wordpress/i18n'; -import { noop } from 'lodash'; - -/** - * Internal dependencies - */ -import validateShippingRateGroup from './validateShippingRateGroup'; -import AppModal from '~/components/app-modal'; -import AppInputPriceControl from '~/components/app-input-price-control'; -import VerticalGapLayout from '~/components/vertical-gap-layout'; -import SupportedCountrySelect from '~/components/supported-country-select'; - -/** - * @typedef { import("~/components/app-button").default } AppButton - * @typedef { import("~/data/actions").CountryCode } CountryCode - * @typedef { import("../typedefs.js").ShippingRateGroup } ShippingRateGroup - */ - -/** - * Base rate form modal. - * - * This is used by AddRateFormModal and EditRateFormModal. - * - * @param {Object} props - * @param {Array} props.countryOptions Array of country codes, to be used as options in SupportedCountrySelect. - * @param {ShippingRateGroup} props.initialValues Initial values for the form. - * @param {(formProps: Object) => Array} props.renderButtons Function to render buttons for the modal. `formProps` will be passed into this render function. - * @param {(values: ShippingRateGroup) => void} props.onSubmit Called with submitted value. - * @param {() => void} props.onRequestClose Callback to close the modal. - */ -const RateFormModal = ( { - countryOptions, - initialValues, - renderButtons = noop, - onSubmit, - onRequestClose, -} ) => { - const [ dropdownVisible, setDropdownVisible ] = useState( false ); - - return ( -
- { ( formProps ) => { - const { values, getInputProps } = formProps; - - return ( - - - - - - - ); - } } -
- ); -}; - -export default RateFormModal; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.js deleted file mode 100644 index 0fd9b2789b..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * @typedef { import("../typedefs.js").ShippingRateGroup } ShippingRateGroup - */ - -/** - * @param {ShippingRateGroup} values - */ -const validateShippingRateGroup = ( values ) => { - const errors = {}; - - if ( values.countries.length === 0 ) { - errors.countries = __( - 'Please specify at least one country.', - 'google-listings-and-ads' - ); - } - - if ( ! Number.isFinite( values.rate ) ) { - errors.rate = __( - 'Please enter the estimated shipping rate.', - 'google-listings-and-ads' - ); - } - - if ( values.rate < 0 ) { - errors.rate = __( - 'The estimated shipping rate cannot be less than 0.', - 'google-listings-and-ads' - ); - } - - return errors; -}; - -export default validateShippingRateGroup; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.test.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.test.js deleted file mode 100644 index 10f7170f0d..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/rate-form-modals/validateShippingRateGroup.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Internal dependencies - */ -import validateShippingRateGroup from './validateShippingRateGroup'; - -const validValues = { - countries: [ 'US' ], - rate: 0, -}; - -describe( 'validateShippingRateGroup', () => { - it( 'has no errors properties with validValues', () => { - const values = { - ...validValues, - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toEqual( {} ); - } ); - - it( 'has errors.countries when values.countries.length is 0', () => { - const values = { - ...validValues, - countries: [], - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toHaveProperty( 'countries' ); - } ); - - it( 'has errors.rate when values.rate is null', () => { - const values = { - ...validValues, - rate: null, - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toHaveProperty( 'rate' ); - } ); - - it( 'has errors.rate when values.rate is undefined', () => { - const values = { - ...validValues, - rate: undefined, - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toHaveProperty( 'rate' ); - } ); - - it( `has errors.rate when values.rate is ''`, () => { - const values = { - ...validValues, - rate: '', - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toHaveProperty( 'rate' ); - } ); - - it( 'has errors.rate when values.rate is less than 0', () => { - const values = { - ...validValues, - rate: -1, - }; - - const errors = validateShippingRateGroup( values ); - - expect( errors ).toHaveProperty( 'rate' ); - } ); -} ); diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js deleted file mode 100644 index 3ec6c19de5..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { Pill } from '@woocommerce/components'; - -/** - * Internal dependencies - */ -import AppInputPriceControl from '~/components/app-input-price-control'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; -import AppButton from '~/components/app-button'; -import { EditRateFormModal } from './rate-form-modals'; -import ShippingRateInputControlLabelText from './shipping-rate-input-control-label-text'; -import './shipping-rate-input-control.scss'; - -/** - * @typedef { import("./typedefs").ShippingRateGroup } ShippingRateGroup - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ - -/** - * Input control to edit a shipping rate group. - * - * The input control label contains a placeholder area for `button`, after the label text. - * This is meant to display an "Edit" button. - * - * @param {Object} props - * @param {Array} props.countryOptions Country options to be passed to EditRateFormModal. - * @param {ShippingRateGroup} props.value Shipping rate group value. - * @param {(newGroup: ShippingRateGroup) => void} props.onChange Called when shipping rate group changes. - * @param {() => void} props.onDelete Called when delete button in EditRateFormModal is clicked. - */ -const ShippingRateInputControl = ( { - countryOptions, - value, - onChange, - onDelete, -} ) => { - const { countries, currency, rate } = value; - - const handleBlur = ( event, numberValue ) => { - if ( rate === numberValue ) { - return; - } - - onChange( { - ...value, - rate: numberValue, - } ); - }; - - return ( -
- - - - { __( 'Edit', 'google-listings-and-ads' ) } - - } - modal={ - - } - /> -
- } - suffix={ currency } - value={ rate } - onBlur={ handleBlur } - /> - { rate === 0 && ( -
- - { __( - 'Free shipping for all orders', - 'google-listings-and-ads' - ) } - -
- ) } - - ); -}; - -export default ShippingRateInputControl; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/typedefs.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/typedefs.js deleted file mode 100644 index ce1e72e30b..0000000000 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/typedefs.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @typedef { import("~/data/actions").CountryCode } CountryCode - */ - -/** - * @typedef {Object} ShippingRateGroup - * @property {Array} countries Array of selected country codes. - * @property {string} currency Currency. - * @property {number|undefined} rate Rate value. - */ - -export default {}; diff --git a/js/src/components/shipping-rate-section/flat-shipping-rates-input-cards.js b/js/src/components/shipping-rate-section/flat-shipping-rates-input-cards.js index 871f46a65f..267b9dfe02 100644 --- a/js/src/components/shipping-rate-section/flat-shipping-rates-input-cards.js +++ b/js/src/components/shipping-rate-section/flat-shipping-rates-input-cards.js @@ -9,12 +9,15 @@ import EstimatedShippingRatesCard from './estimated-shipping-rates-card'; const FlatShippingRatesInputCards = () => { const { adapter } = useAdaptiveFormContext(); - const inputProps = useAdaptiveFormInputProps( 'shipping_country_rates' ); + const { value, onChange, helper } = + useAdaptiveFormInputProps( 'flat_shipping_rate' ); return ( ); }; diff --git a/js/src/components/shipping-rate-section/shipping-rate-method-section.js b/js/src/components/shipping-rate-section/shipping-rate-method-section.js new file mode 100644 index 0000000000..2d1e99fc3a --- /dev/null +++ b/js/src/components/shipping-rate-section/shipping-rate-method-section.js @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createInterpolateElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useAdaptiveFormContext } from '~/components/adaptive-form'; +import { glaData, SHIPPING_RATE_METHOD } from '~/constants'; +import Section from '~/components/section'; +import AppRadioContentControl from '~/components/app-radio-content-control'; +import RadioHelperText from '~/components/radio-helper-text'; +import AppDocumentationLink from '~/components/app-documentation-link'; +import VerticalGapLayout from '~/components/vertical-gap-layout'; +import useSettings from '~/hooks/useSettings'; +import useMCSetup from '~/hooks/useMCSetup'; + +/** + * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-read-more', href: 'https://support.google.com/merchants/answer/7050921' }` + * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` + * + * @param {Object} props + * @param {JSX.Element} props.children Optional children to render below the shipping rate method options. + */ +const ShippingRateMethodSection = ( { children } ) => { + const { getInputProps } = useAdaptiveFormContext(); + const { settings } = useSettings(); + const { hasFinishedResolution, data: mcSetup } = useMCSetup(); + const inputProps = getInputProps( 'shipping_rate' ); + const { isMultiLingualStore } = glaData; + + // Take a one-time snapshot once settings have resolved. + // Using a ref so mid-session auto-saves (which flip isFlatShippingRate to + // false) don't hide the option after it was already shown. + // For multilingual stores the flat option is shown only when the stored + // value is already 'flat' (i.e. the merchant previously configured it). + // The SavedSetupStepper auto-save initialises multilingual stores to + // 'manual', never 'flat', so isFlatShippingRate is false on a clean + // onboarding and the option is correctly hidden. + const showFlatOptionRef = useRef( null ); + if ( showFlatOptionRef.current === null && settings !== undefined ) { + const isFlatShippingRate = + settings.shipping_rate === SHIPPING_RATE_METHOD.FLAT; + showFlatOptionRef.current = ! isMultiLingualStore || isFlatShippingRate; + } + const showFlatOption = showFlatOptionRef.current ?? ! isMultiLingualStore; + + // Hide the automatic shipping rate option if there are no shipping rates and the merchant is onboarding. + const hideAutomaticShippingRate = + ! settings?.shipping_rates_count && + hasFinishedResolution && + mcSetup?.status === 'incomplete'; + + return ( +
+

+ { __( + 'Your estimated shipping rates and times will be shown to potential customers on Google.', + 'google-listings-and-ads' + ) } +

+

+ + { __( 'Read more', 'google-listings-and-ads' ) } + +

+ + } + > + + + + { ! hideAutomaticShippingRate && ( + + + { __( + 'My current settings and any future changes to my store’s shipping rates and classes will be automatically synced to Google Merchant Center.', + 'google-listings-and-ads' + ) } + + + ) } + + { showFlatOption && ( + + ) } + + + + { createInterpolateElement( + __( + 'I understand that if I don’t set this up manually in Google Merchant Center, my products will be disapproved by Google.', + 'google-listings-and-ads' + ), + { + link: ( + + ), + } + ) } + + + + + + { children } +
+ ); +}; + +export default ShippingRateMethodSection; diff --git a/js/src/components/shipping-rate-section/shipping-rate-section.js b/js/src/components/shipping-rate-section/shipping-rate-section.js index ea7ba873db..768297ebc7 100644 --- a/js/src/components/shipping-rate-section/shipping-rate-section.js +++ b/js/src/components/shipping-rate-section/shipping-rate-section.js @@ -1,126 +1,25 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { createInterpolateElement } from '@wordpress/element'; - /** * Internal dependencies */ import { useAdaptiveFormContext } from '~/components/adaptive-form'; -import Section from '~/components/section'; -import AppRadioContentControl from '~/components/app-radio-content-control'; -import RadioHelperText from '~/components/radio-helper-text'; -import AppDocumentationLink from '~/components/app-documentation-link'; -import VerticalGapLayout from '~/components/vertical-gap-layout'; +import { SHIPPING_RATE_METHOD } from '~/constants'; import FlatShippingRatesInputCards from './flat-shipping-rates-input-cards'; -import useSettings from '~/hooks/useSettings'; -import useMCSetup from '~/hooks/useMCSetup'; +import ShippingRateMethodSection from './shipping-rate-method-section'; /** - * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-read-more', href: 'https://support.google.com/merchants/answer/7050921' }` - * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` + * Renders the shipping rate method section on the Settings page, including the + * flat shipping rates input cards if the store is not multilingual and the + * selected shipping rate method is flat. */ - const ShippingRateSection = () => { - const { getInputProps, values } = useAdaptiveFormContext(); - const { settings } = useSettings(); - const { hasFinishedResolution, data: mcSetup } = useMCSetup(); - const inputProps = getInputProps( 'shipping_rate' ); - - // Hide the automatic shipping rate option if there are no shipping rates and the merchant is onboarding. - const hideAutomatticShippingRate = - ! settings?.shipping_rates_count && - hasFinishedResolution && - mcSetup?.status === 'incomplete'; + const { values } = useAdaptiveFormContext(); return ( -
-

- { __( - 'Your estimated shipping rates and times will be shown to potential customers on Google.', - 'google-listings-and-ads' - ) } -

-

- - { __( 'Read more', 'google-listings-and-ads' ) } - -

- - } - > - - - - { ! hideAutomatticShippingRate && ( - - - { __( - 'My current settings and any future changes to my store’s shipping rates and classes will be automatically synced to Google Merchant Center.', - 'google-listings-and-ads' - ) } - - - ) } - - - - { createInterpolateElement( - __( - 'I understand that if I don’t set this up manually in Google Merchant Center, my products will be disapproved by Google.', - 'google-listings-and-ads' - ), - { - link: ( - - ), - } - ) } - - - - - - { values.shipping_rate === 'flat' && ( + + { values.shipping_rate === SHIPPING_RATE_METHOD.FLAT && ( ) } -
+ ); }; diff --git a/js/src/components/shipping-rate-section/shipping-rate-section.test.js b/js/src/components/shipping-rate-section/shipping-rate-section.test.js index 9d54c2cc1b..8aa2ea3c2a 100644 --- a/js/src/components/shipping-rate-section/shipping-rate-section.test.js +++ b/js/src/components/shipping-rate-section/shipping-rate-section.test.js @@ -49,6 +49,14 @@ jest.mock( '~/hooks/useSettings' ); jest.mock( '~/hooks/useMCSetup' ); describe( 'ShippingRateSection', () => { + beforeEach( () => { + global.glaData.isMultiLingualStore = false; + } ); + + afterEach( () => { + delete global.glaData.isMultiLingualStore; + } ); + it( 'shouldnt render automatic rates if there are not shipping rates and it is onboarding', () => { useMCSetup.mockImplementation( () => { return { @@ -204,4 +212,93 @@ describe( 'ShippingRateSection', () => { ) ).toBeInTheDocument(); } ); + + describe( 'when glaData.isMultiLingualStore is true', () => { + beforeEach( () => { + global.glaData.isMultiLingualStore = true; + + useMCSetup.mockImplementation( () => { + return { + hasFinishedResolution: true, + data: { status: 'completed' }, + }; + } ); + + useSettings.mockImplementation( () => { + return { + settings: { shipping_rates_count: 1 }, + }; + } ); + } ); + + it( 'should not render the flat shipping rate option', () => { + const { queryByText } = render( ); + + expect( + queryByText( + 'My shipping settings are simple. I can manually estimate flat shipping rates.' + ) + ).not.toBeInTheDocument(); + } ); + + it( 'should still render the automatic and manual shipping rate options', () => { + const { getByText } = render( ); + + expect( + getByText( + 'Automatically sync my store’s shipping settings to Google.' + ) + ).toBeInTheDocument(); + + expect( + getByText( + 'My shipping settings are complex. I will enter my shipping rates and times manually in Google Merchant Center.' + ) + ).toBeInTheDocument(); + } ); + it( 'should render the flat shipping rate option when the stored shipping_rate is flat', () => { + useSettings.mockImplementation( () => { + return { + settings: { + shipping_rates_count: 1, + shipping_rate: 'flat', + }, + }; + } ); + + const { getByText } = render( ); + + expect( + getByText( + 'My shipping settings are simple. I can manually estimate flat shipping rates.' + ) + ).toBeInTheDocument(); + } ); + + it( 'should render the flat shipping rate option when onboarding and the stored shipping_rate is flat', () => { + useMCSetup.mockImplementation( () => { + return { + hasFinishedResolution: true, + data: { status: 'incomplete' }, + }; + } ); + + useSettings.mockImplementation( () => { + return { + settings: { + shipping_rates_count: 1, + shipping_rate: 'flat', + }, + }; + } ); + + const { getByText } = render( ); + + expect( + getByText( + 'My shipping settings are simple. I can manually estimate flat shipping rates.' + ) + ).toBeInTheDocument(); + } ); + } ); } ); diff --git a/js/src/constants.js b/js/src/constants.js index 305d374e31..7225f206ff 100644 --- a/js/src/constants.js +++ b/js/src/constants.js @@ -39,9 +39,19 @@ export const API_RESPONSE_CODES = { }; export const SHIPPING_RATE_METHOD = { - FLAT_RATE: 'flat_rate', + FLAT: 'flat', + MANUAL: 'manual', + AUTOMATIC: 'automatic', }; +export const SHIPPING_TIME_METHOD = { + FLAT: 'flat', + MANUAL: 'manual', +}; + +export const DEFAULT_SHIPPING_MIN_TIME = 1; +export const DEFAULT_SHIPPING_MAX_TIME = 5; + // Stepper key related const campaignStepEntries = [ [ 'CAMPAIGN', 'campaign' ], diff --git a/js/src/css/shared/_gutenberg-components.scss b/js/src/css/shared/_gutenberg-components.scss index 5694c88efc..40c2aa8845 100644 --- a/js/src/css/shared/_gutenberg-components.scss +++ b/js/src/css/shared/_gutenberg-components.scss @@ -52,8 +52,8 @@ } } - // Adjust InputControl suffix's empty right margin. + // Adjust InputControl suffix's empty inline-end margin. .components-input-control__suffix { - margin-right: $grid-unit; + margin-inline-end: $grid-unit; } } diff --git a/js/src/data/action-types.js b/js/src/data/action-types.js index b4f5d92332..3cfbd1dd6a 100644 --- a/js/src/data/action-types.js +++ b/js/src/data/action-types.js @@ -67,6 +67,8 @@ const TYPES = { RECEIVE_CYO_INCENTIVES: 'RECEIVE_CYO_INCENTIVES', RECEIVE_GEN_AI_MEDIA_ASSETS: 'RECEIVE_GEN_AI_MEDIA_ASSETS', RECEIVE_GEN_AI_TEXT_ASSETS: 'RECEIVE_GEN_AI_TEXT_ASSETS', + RECEIVE_MARKETS: 'RECEIVE_MARKETS', + RECEIVE_MC_LANGUAGES_CURRENCIES: 'RECEIVE_MC_LANGUAGES_CURRENCIES', }; export default TYPES; diff --git a/js/src/data/actions.js b/js/src/data/actions.js index 2cbd2ffcc0..5388a0d082 100644 --- a/js/src/data/actions.js +++ b/js/src/data/actions.js @@ -120,6 +120,17 @@ import { convertKeysFromSnakeCaseToCamelCase } from './utils'; * @property {ProductStatisticsDetails | null } statistics Statistics information of product status on Google Merchant Center or null if the stats are loading. */ +/** + * @typedef {Object} Market + * @property {string} id The market ID. + * @property {string} label The market label. + * @property {Array} countries Array of audience countries. + * @property {string[]} language Language codes in ISO 639-1 format. Example: ['en']. + * @property {string[]} currency Currency codes in ISO 4217 format. Example: ['USD']. + * @property {'automatic'|'flat'|'manual'} shipping_rate Shipping rate type. + * @property {'flat'|'manual'} shipping_time Shipping time type. + */ + /** * Hydrate the prefetched data to store. * @@ -1509,3 +1520,96 @@ export function* disconnectYouTubeAccount() { throw error; } } + +/** + * Fetch the list of markets. + * + * @return {Object} Action object to receive the markets. + * @throws Will throw an error if the request failed. + */ +export function* fetchMarkets() { + try { + const response = yield apiFetch( { + path: `${ API_NAMESPACE }/mc/markets`, + } ); + + return { type: TYPES.RECEIVE_MARKETS, markets: response }; + } catch ( error ) { + handleApiError( error ); + } +} + +/** + * Create a new market. + * + * @param {Market} args The market data to create. + * @return {Object} Action object to receive the markets after creation. + * @throws Will throw an error if the request failed. + */ +export function* createMarket( args ) { + try { + yield apiFetch( { + path: `${ API_NAMESPACE }/mc/markets`, + method: 'POST', + data: args, + } ); + return yield fetchMarkets(); + } catch ( error ) { + handleApiError( error ); + throw error; + } +} + +/** + * Update an existing market. + * + * @param {string} id The ID of the market to update. + * @param {Partial} data The market fields to update (all fields optional). + * @return {Object} Action object to receive the markets after update. + * @throws Will throw an error if the request failed. + */ +export function* updateMarket( id, data ) { + try { + yield apiFetch( { + path: `${ API_NAMESPACE }/mc/markets/${ id }`, + method: 'PUT', + data, + } ); + return yield fetchMarkets(); + } catch ( error ) { + handleApiError( error ); + throw error; + } +} + +/** + * Delete a market. + * + * @param {string|number} id The ID of the market to delete. + * @return {Object} Action object to receive the markets after deletion. + * @throws Will throw an error if the request failed. + */ +export function* deleteMarket( id ) { + try { + yield apiFetch( { + path: `${ API_NAMESPACE }/mc/markets/${ id }`, + method: 'DELETE', + } ); + return yield fetchMarkets(); + } catch ( error ) { + handleApiError( error ); + throw error; + } +} + +/** + * Returns an action object to receive supported languages and currencies data. + * + * @param {Object} data Response from the languages-currencies endpoint. + * @param {Array} data.languages Available languages. + * @param {Array} data.currencies Available currencies. + * @return {Object} Action object. + */ +export function receiveMcLanguagesCurrencies( data ) { + return { type: TYPES.RECEIVE_MC_LANGUAGES_CURRENCIES, data }; +} diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js index 967f16cf1c..bb60c5fd62 100644 --- a/js/src/data/reducer.js +++ b/js/src/data/reducer.js @@ -45,6 +45,9 @@ const DEFAULT_STATE = { pages: null, }, }, + markets: [], + languages: null, + currencies: null, }, ads_campaigns: null, all_ads_campaigns: null, @@ -738,6 +741,20 @@ const reducer = ( state = DEFAULT_STATE, action ) => { return setIn( state, 'mc.accounts.youtube', null ); } + case TYPES.RECEIVE_MARKETS: { + const { markets } = action; + + return setIn( state, 'mc.markets', markets ); + } + + case TYPES.RECEIVE_MC_LANGUAGES_CURRENCIES: { + const { data } = action; + return chainState( state, 'mc' ) + .setIn( 'languages', data.languages ) + .setIn( 'currencies', data.currencies ) + .end(); + } + case TYPES.DISCONNECT_ACCOUNTS_ALL: default: return state; diff --git a/js/src/data/resolvers.js b/js/src/data/resolvers.js index f6fa29f061..73a0fcc7e9 100644 --- a/js/src/data/resolvers.js +++ b/js/src/data/resolvers.js @@ -47,6 +47,7 @@ import { fetchTargetAudience, fetchMCSetup, fetchYouTubeAccount, + fetchMarkets, receiveGoogleAccountAccess, receiveReport, receiveMCProductStatistics, @@ -62,6 +63,7 @@ import { receiveAdsRecommendations, receiveCYOIncentives, receiveEnhancedConversionsStatus, + receiveMcLanguagesCurrencies, receiveAdsSettings, } from './actions'; @@ -847,3 +849,24 @@ getYouTubeAccount.shouldInvalidate = ( action ) => { action.invalidateRelatedState ); }; + +export function* getMarkets() { + yield fetchMarkets(); +} + +export function* getAvailableLanguagesCurrencies() { + try { + const data = yield apiFetch( { + path: `${ API_NAMESPACE }/mc/markets/languages-currencies`, + } ); + return receiveMcLanguagesCurrencies( data ); + } catch ( error ) { + handleApiError( + error, + __( + 'There was an error loading supported languages and currencies.', + 'google-listings-and-ads' + ) + ); + } +} diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js index 8237d89faf..f2b81b9685 100644 --- a/js/src/data/selectors.js +++ b/js/src/data/selectors.js @@ -606,3 +606,66 @@ export const getGenAITextAssets = ( state, url, assetType ) => { return textAssets; }; + +/** + * Retrieves all markets from the state. + * + * @param {Object} state - The Redux state object containing markets data. + * @return {Array} The list of markets. + */ +export const getMarkets = ( state ) => { + return state.mc.markets; +}; + +/** + * Retrieves a specific market from the state by its ID. + * + * @param {Object} state - The Redux state object containing markets data. + * @param {string|number} id - The unique identifier of the market. + * @return {Object|undefined} The market with the specified ID, or undefined if not found. + */ +export const getMarket = ( state, id ) => { + return state.mc.markets.find( ( market ) => market.id === id ); +}; + +/** + * @typedef {Object} MCLanguage + * @property {string} code BCP 47 language code (e.g. `"en"`). + * @property {string} label Human-readable language name (e.g. `"English"`). + */ + +/** + * @typedef {Object} MCCurrency + * @property {string} code ISO 4217 currency code (e.g. `"USD"`). + * @property {string} symbol Currency symbol (e.g. `"$"`). + */ + +/** + * Select available languages and currencies from the store's local installation (e.g. WPML). + * Triggers the single resolver that populates both state properties. + * + * @param {Object} state The current store state. + * @return {{languages: Array|null, currencies: Array|null}} Available languages and currencies, or null values before data is fetched. + */ +export const getAvailableLanguagesCurrencies = ( state ) => { + const { languages, currencies } = state.mc; + return { languages, currencies }; +}; + +/** + * Select the available languages from the store's local installation (e.g. WPML). + * Call getAvailableLanguagesCurrencies to trigger the data fetch. + * + * @param {Object} state The current store state. + * @return {Array|null} Available languages, or null before data is fetched. + */ +export const getAvailableLanguages = ( state ) => state.mc.languages; + +/** + * Select the available currencies from the store's local installation (e.g. WPML/WCML). + * Call getAvailableLanguagesCurrencies to trigger the data fetch. + * + * @param {Object} state The current store state. + * @return {Array|null} Available store currencies, or null before data is fetched. + */ +export const getAvailableStoreCurrencies = ( state ) => state.mc.currencies; diff --git a/js/src/data/test/reducer.test.js b/js/src/data/test/reducer.test.js index 64efbccf29..7332584741 100644 --- a/js/src/data/test/reducer.test.js +++ b/js/src/data/test/reducer.test.js @@ -47,6 +47,9 @@ describe( 'reducer', () => { sources: {}, // Todo: Change to [] after finishing the fix in backend }, contact: null, + markets: [], + languages: null, + currencies: null, }, ads_campaigns: null, all_ads_campaigns: null, diff --git a/js/src/hooks/useAvailableLanguagesCurrencies.js b/js/src/hooks/useAvailableLanguagesCurrencies.js new file mode 100644 index 0000000000..f62e4d21ce --- /dev/null +++ b/js/src/hooks/useAvailableLanguagesCurrencies.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from '~/data/constants'; + +const selectorName = 'getAvailableLanguagesCurrencies'; + +/** + * @typedef {import('~/data/selectors').MCLanguage} MCLanguage + * @typedef {import('~/data/selectors').MCCurrency} MCCurrency + */ + +/** + * @typedef {Object} AvailableLanguagesCurrencies + * @property {Array|null} languages Available languages from the multilingual integration, or null before data is fetched. + * @property {Array|null} currencies Available currencies from the multilingual integration, or null before data is fetched. + * @property {boolean} hasFinishedResolution Whether the shared resolver has completed its request. + */ + +/** + * Returns available languages and currencies from the store's multilingual + * integration (e.g. WPML/WCML). Both values are populated by a single resolver, + * so this hook fires one request for both. + * + * @return {AvailableLanguagesCurrencies} Available languages, currencies, and resolution status. + */ +const useAvailableLanguagesCurrencies = () => { + return useSelect( ( select ) => { + const selector = select( STORE_KEY ); + + // Trigger the shared resolver that populates both languages and currencies. + selector[ selectorName ](); + + return { + languages: selector.getAvailableLanguages(), + currencies: selector.getAvailableStoreCurrencies(), + hasFinishedResolution: selector.hasFinishedResolution( + selectorName, + [] + ), + }; + }, [] ); +}; + +export default useAvailableLanguagesCurrencies; diff --git a/js/src/hooks/useAvailableStoreCurrencies.js b/js/src/hooks/useAvailableStoreCurrencies.js new file mode 100644 index 0000000000..4fc028a844 --- /dev/null +++ b/js/src/hooks/useAvailableStoreCurrencies.js @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import useAvailableLanguagesCurrencies from './useAvailableLanguagesCurrencies'; + +/** + * @typedef {import('~/data/selectors').MCCurrency} MCCurrency + */ + +/** + * @typedef {Object} AvailableStoreCurrencies + * @property {Array|null} currencies Available currencies from the multilingual integration, or null before data is fetched. + * @property {boolean} hasFinishedResolution Whether the shared resolver has completed its request. + */ + +/** + * Returns available store currencies from the multilingual integration (e.g. WCML). + * Delegates to useAvailableLanguagesCurrencies so both values share one resolver call. + * + * @return {AvailableStoreCurrencies} Available currencies and resolution status. + */ +const useAvailableStoreCurrencies = () => { + const { currencies, hasFinishedResolution } = + useAvailableLanguagesCurrencies(); + + return { currencies, hasFinishedResolution }; +}; + +export default useAvailableStoreCurrencies; diff --git a/js/src/hooks/useAvailableStoreLanguages.js b/js/src/hooks/useAvailableStoreLanguages.js new file mode 100644 index 0000000000..8cc4f51f85 --- /dev/null +++ b/js/src/hooks/useAvailableStoreLanguages.js @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import useAvailableLanguagesCurrencies from './useAvailableLanguagesCurrencies'; + +/** + * @typedef {import('~/data/selectors').MCLanguage} MCLanguage + */ + +/** + * @typedef {Object} AvailableStoreLanguages + * @property {Array|null} languages Available languages from the multilingual integration, or null before data is fetched. + * @property {boolean} hasFinishedResolution Whether the shared resolver has completed its request. + */ + +/** + * Returns available languages from the store's multilingual integration (e.g. WPML). + * Delegates to useAvailableLanguagesCurrencies so both values share one resolver call. + * + * @return {AvailableStoreLanguages} Available languages and resolution status. + */ +const useAvailableStoreLanguages = () => { + const { languages, hasFinishedResolution } = + useAvailableLanguagesCurrencies(); + + return { languages, hasFinishedResolution }; +}; + +export default useAvailableStoreLanguages; diff --git a/js/src/hooks/useDataViewsScript.js b/js/src/hooks/useDataViewsScript.js new file mode 100644 index 0000000000..12a34a197b --- /dev/null +++ b/js/src/hooks/useDataViewsScript.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { glaData } from '~/constants'; + +const isDataViewsReady = () => + typeof window.wp?.dataviews?.filterSortAndPaginate === 'function'; + +/** + * Module-level singleton: deduplicates concurrent loads across hook instances + * and across tab switches that happen mid-load. + */ +let loadPromise; + +const ensureDataViewsScript = ( url ) => { + if ( isDataViewsReady() ) { + return Promise.resolve( true ); + } + + if ( loadPromise ) { + return loadPromise; + } + + if ( ! url ) { + return Promise.resolve( false ); + } + + loadPromise = new Promise( ( resolve ) => { + const script = document.createElement( 'script' ); + script.src = url; + script.async = true; + script.onload = () => resolve( isDataViewsReady() ); + script.onerror = () => { + loadPromise = undefined; // Reset so a future mount can retry. + resolve( false ); + }; + document.head.appendChild( script ); + } ); + + return loadPromise; +}; + +/** + * Ensures `@wordpress/dataviews` is available: uses an existing global or loads + * the script from `glaData.dataViewsScriptUrl`. The script URL is expected to + * match the `wp-dataviews` version shipped by the host WordPress instance. + * + * @return {'loading' | 'ready' | 'failed'} Current load status. + */ +const useDataViewsScript = () => { + const [ status, setStatus ] = useState( () => + isDataViewsReady() ? 'ready' : 'loading' + ); + const { dataViewsScriptUrl } = glaData; + + useEffect( () => { + if ( status === 'ready' ) { + return; + } + + let isMounted = true; + + ensureDataViewsScript( dataViewsScriptUrl ).then( ( ok ) => { + if ( ! isMounted ) { + return; + } + + setStatus( ok ? 'ready' : 'failed' ); + } ); + + return () => { + isMounted = false; + }; + }, [ status, dataViewsScriptUrl ] ); + + return status; +}; + +export default useDataViewsScript; diff --git a/js/src/hooks/useDataViewsScript.test.js b/js/src/hooks/useDataViewsScript.test.js new file mode 100644 index 0000000000..44d797bd13 --- /dev/null +++ b/js/src/hooks/useDataViewsScript.test.js @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { renderHook, act } from '@testing-library/react'; + +const SCRIPT_URL = 'https://example.test/dataviews.js'; + +// Pin `@wordpress/element` to the same React instance that +// `@testing-library/react` uses, so that re-importing the hook via +// `jest.isolateModules` (to reset its module-level singleton) doesn't +// produce a second React copy and trigger "Invalid hook call" errors. +// `mock`-prefixed identifiers are exempt from jest.mock's hoist guard. +const mockReact = jest.requireActual( 'react' ); +jest.mock( '@wordpress/element', () => mockReact ); + +let mockGlaData; + +jest.mock( '~/constants', () => ( { + get glaData() { + return mockGlaData; + }, +} ) ); + +const flushPromises = () => + new Promise( ( resolve ) => setImmediate( resolve ) ); + +const setReadyGlobal = () => { + window.wp = { + dataviews: { filterSortAndPaginate: jest.fn() }, + }; +}; + +const loadHook = () => { + let hook; + jest.isolateModules( () => { + hook = require( './useDataViewsScript' ).default; + } ); + return hook; +}; + +describe( 'useDataViewsScript', () => { + let appendedScripts; + let appendChildSpy; + + beforeEach( () => { + mockGlaData = { dataViewsScriptUrl: SCRIPT_URL }; + appendedScripts = []; + + appendChildSpy = jest + .spyOn( document.head, 'appendChild' ) + .mockImplementation( ( node ) => { + appendedScripts.push( node ); + return node; + } ); + } ); + + afterEach( () => { + delete window.wp; + appendChildSpy.mockRestore(); + } ); + + it( 'returns "ready" synchronously when window.wp.dataviews is already available', () => { + setReadyGlobal(); + const useDataViewsScript = loadHook(); + + const { result } = renderHook( () => useDataViewsScript() ); + + expect( result.current ).toBe( 'ready' ); + expect( appendedScripts ).toHaveLength( 0 ); + } ); + + it( 'injects exactly one