diff --git a/.github/workflows/php-unit-tests.yml b/.github/workflows/php-unit-tests.yml index dbc46fa508..27dab3027a 100644 --- a/.github/workflows/php-unit-tests.yml +++ b/.github/workflows/php-unit-tests.yml @@ -51,6 +51,8 @@ jobs: wp-version: ${{ needs.GetMatrix.outputs.latest-wp-version }} - php: 8.1 wp-version: ${{ needs.GetMatrix.outputs.latest-wp-version }} + - php: 8.2 + wp-version: ${{ needs.GetMatrix.outputs.latest-wp-version }} steps: - name: Checkout repository diff --git a/.github/workflows/post-release-automerge.yml b/.github/workflows/post-release-automerge.yml new file mode 100644 index 0000000000..8555f5c4fa --- /dev/null +++ b/.github/workflows/post-release-automerge.yml @@ -0,0 +1,19 @@ +name: 'Merge the release to develop' +run-name: Merge the released `${{ github.head_ref }}` from `trunk` to `develop` + +# **What it does**: Merges trunk to develop after `release/*` is merged to `trunk`. +# **Why we have it**: To automate the release process and follow git-flow. + +on: + pull_request: + types: + - closed + branches: + - trunk + +jobs: + automerge_trunk: + name: Automerge released trunk + runs-on: ubuntu-latest + steps: + - uses: woocommerce/grow/automerge-released-trunk@actions-v1 diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 0000000000..1476873851 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,12 @@ +{ + "phpVersion": "8.0", + "plugins": [ + "https://downloads.wordpress.org/plugin/woocommerce.latest-stable.zip", + "https://github.com/WP-API/Basic-Auth/archive/master.zip", + "." + ], + "lifecycleScripts": { + "afterStart": "./tests/e2e/bin/test-env-setup.sh", + "afterClean": "./tests/e2e/bin/test-env-setup.sh" + } +} diff --git a/changelog.txt b/changelog.txt index 7901ac5a41..b957b5a935 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,13 @@ *** WooCommerce Google Listings and Ads Changelog *** += 2.5.1 - 2023-08-01 = +* Dev - Setup wp-env for E2E tests. +* Dev - automate merging trunk to develop after a release. +* Fix - Fix support for "add_to_cart" event in Products (Beta) block. +* Fix - Prevent PHP 8.2 deprecation messages. +* Tweak - Ability to filter products for syncing via `gla_filter_product_query_args` apply_filters hook. +* Update - Show validation errors on steps 2 and 3 of the onboarding flow when unable to continue. + = 2.5.0 - 2023-07-18 = * Tweak - Add Tip with information with Campaign assets are imported. * Tweak - Provide more detailed error reasons when unable to complete site verification for the Google Merchant Center account being connected in the onboarding flow. diff --git a/google-listings-and-ads.php b/google-listings-and-ads.php index 4fcc3c288b..5ea08fc894 100644 --- a/google-listings-and-ads.php +++ b/google-listings-and-ads.php @@ -3,7 +3,7 @@ * Plugin Name: Google Listings and Ads * Plugin URL: https://wordpress.org/plugins/google-listings-and-ads/ * Description: Native integration with Google that allows merchants to easily display their products across Google’s network. - * Version: 2.5.0 + * Version: 2.5.1 * Author: WooCommerce * Author URI: https://woocommerce.com/ * Text Domain: google-listings-and-ads @@ -30,7 +30,7 @@ defined( 'ABSPATH' ) || exit; -define( 'WC_GLA_VERSION', '2.5.0' ); // WRCS: DEFINED_VERSION. +define( 'WC_GLA_VERSION', '2.5.1' ); // WRCS: DEFINED_VERSION. define( 'WC_GLA_MIN_PHP_VER', '7.4' ); define( 'WC_GLA_MIN_WC_VER', '6.9' ); diff --git a/js/src/components/adaptive-form/adaptive-form-context.js b/js/src/components/adaptive-form/adaptive-form-context.js index 268f9dca40..70f7313f27 100644 --- a/js/src/components/adaptive-form/adaptive-form-context.js +++ b/js/src/components/adaptive-form/adaptive-form-context.js @@ -23,6 +23,10 @@ import { createContext, useContext } from '@wordpress/element'; * @property {boolean} isSubmitting `true` if the form is currently being submitted. * @property {boolean} isSubmitted Set to `true` after the form is submitted. Initial value and during submission are set to `false`. * @property { HTMLElement | null} submitter Set to the element triggering the `handleSubmit` callback until the processing of `onSubmit` is completed. `null` otherwise. + * @property {number} validationRequestCount The current validation request count. + * @property {boolean} requestedShowValidation Whether have requested verification. It will be reset to false after calling hideValidation. + * @property {() => void} showValidation Increase the validation request count by 1. + * @property {() => void} hideValidation Reset the validation request count to 0. */ /** diff --git a/js/src/components/adaptive-form/adaptive-form.js b/js/src/components/adaptive-form/adaptive-form.js index a6faa21232..7e72248acc 100644 --- a/js/src/components/adaptive-form/adaptive-form.js +++ b/js/src/components/adaptive-form/adaptive-form.js @@ -81,6 +81,15 @@ function AdaptiveForm( { onSubmit, extendAdapter, children, ...props }, ref ) { const isMounted = useIsMounted(); + // Add states for form user sides to determine whether to show validation results. + const [ validationRequestCount, setValidationRequestCount ] = useState( 0 ); + const showValidation = useCallback( () => { + setValidationRequestCount( ( count ) => count + 1 ); + }, [] ); + const hideValidation = useCallback( () => { + setValidationRequestCount( 0 ); + }, [] ); + // Add `isSubmitting` and `isSubmitted` states for facilitating across multiple layers of // component controlling, such as disabling inputs or buttons. const [ submission, setSubmission ] = useState( null ); @@ -208,6 +217,10 @@ function AdaptiveForm( { onSubmit, extendAdapter, children, ...props }, ref ) { isSubmitting, isSubmitted, submitter: adapterRef.current.submitter, + validationRequestCount, + requestedShowValidation: validationRequestCount > 0, + showValidation, + hideValidation, }; if ( typeof extendAdapter === 'function' ) { diff --git a/js/src/components/adaptive-form/adaptive-form.test.js b/js/src/components/adaptive-form/adaptive-form.test.js new file mode 100644 index 0000000000..3b343535c6 --- /dev/null +++ b/js/src/components/adaptive-form/adaptive-form.test.js @@ -0,0 +1,199 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import AdaptiveForm from './adaptive-form'; + +const alwaysValid = () => ( {} ); + +const delayOneSecond = () => new Promise( ( r ) => setTimeout( r, 1000 ) ); + +describe( 'AdaptiveForm', () => { + it( 'Should have `formContext.adapter` with functions and initial states', () => { + const children = jest.fn(); + + render( + { children } + ); + + const formContextSchema = expect.objectContaining( { + adapter: expect.objectContaining( { + isSubmitting: false, + isSubmitted: false, + submitter: null, + validationRequestCount: 0, + requestedShowValidation: false, + showValidation: expect.any( Function ), + hideValidation: expect.any( Function ), + } ), + } ); + + expect( children ).toHaveBeenLastCalledWith( formContextSchema ); + } ); + + it( 'Should provide `isSubmitting` and `isSubmitted` states via adapter', async () => { + const inspect = jest.fn(); + + render( + + { ( formContext ) => { + const { isSubmitting, isSubmitted } = formContext.adapter; + inspect( isSubmitting, isSubmitted ); + + return + + + + ); + } } + + ); + + const [ buttonA, buttonB ] = screen.getAllByRole( 'button' ); + + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 0 ); + + await act( async () => { + return userEvent.click( buttonA ); + } ); + + expect( inspectSubmitter ).toHaveBeenCalledWith( buttonA ); + expect( inspectSubmitter ).toHaveBeenLastCalledWith( null ); + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 1 ); + expect( inspectOnSubmit ).toHaveBeenLastCalledWith( + {}, + expect.objectContaining( { submitter: buttonA } ) + ); + + inspectSubmitter.mockClear(); + + await act( async () => { + return userEvent.click( buttonB ); + } ); + + expect( inspectSubmitter ).toHaveBeenCalledWith( buttonB ); + expect( inspectSubmitter ).toHaveBeenLastCalledWith( null ); + expect( inspectOnSubmit ).toHaveBeenCalledTimes( 2 ); + expect( inspectOnSubmit ).toHaveBeenLastCalledWith( + {}, + expect.objectContaining( { submitter: buttonB } ) + ); + } ); + + it( 'Should be able to accumulate and reset the validation request count and requested state', async () => { + const inspect = jest.fn(); + + render( + + { ( { adapter } ) => { + inspect( + adapter.requestedShowValidation, + adapter.validationRequestCount + ); + + return ( + <> + + + + + ); + } } + + ); + + const requestButton = screen.getByRole( 'button', { name: 'request' } ); + const resetButton = screen.getByRole( 'button', { name: 'reset' } ); + + expect( inspect ).toHaveBeenLastCalledWith( false, 0 ); + + await userEvent.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( true, 1 ); + + await userEvent.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( true, 2 ); + + await userEvent.click( resetButton ); + + expect( inspect ).toHaveBeenLastCalledWith( false, 0 ); + } ); +} ); diff --git a/js/src/components/app-input-number-control/index.js b/js/src/components/app-input-number-control/index.js index 505ebea122..40839d0494 100644 --- a/js/src/components/app-input-number-control/index.js +++ b/js/src/components/app-input-number-control/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { useReducer } from '@wordpress/element'; + /** * Internal dependencies */ @@ -36,6 +41,17 @@ const AppInputNumberControl = ( props ) => { const numberSettings = useStoreNumberSettings( settings ); const numberFormat = useNumberFormat( settings ); + // The `InputControl` in `@wordpress/components` doesn't take into account the context + // in which the `value` of a controlled component is updated via `onBlur`, resulting in + // the incoming `value` prop may not syncing to the element's display value. + // Therefore, an updater of `useReducer` is used to make the value synchronized via the + // onChange callback. + // + // Ref: + // - https://github.com/WordPress/gutenberg/blob/%40wordpress/components%4019.17.0/packages/components/src/input-control/index.tsx#L59-L62 + // - https://github.com/WordPress/gutenberg/blob/%40wordpress/components%4019.17.0/packages/components/src/input-control/utils.ts#L71-L108 + const [ , forceUpdate ] = useReducer( ( x ) => x + 1, 0 ); + /** * Value to be displayed to the user in the UI. */ @@ -74,6 +90,7 @@ const AppInputNumberControl = ( props ) => { const handleChange = ( val ) => { const numberValue = getNumberFromString( val ); onChange( numberValue ); + forceUpdate(); }; const handleBlur = ( e ) => { diff --git a/js/src/components/app-input-number-control/index.test.js b/js/src/components/app-input-number-control/index.test.js new file mode 100644 index 0000000000..2a00527158 --- /dev/null +++ b/js/src/components/app-input-number-control/index.test.js @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import AppInputNumberControl from './'; + +jest.mock( '@woocommerce/settings', () => ( { + getSetting: jest + .fn() + .mockName( "getSetting( 'currency' )" ) + .mockReturnValue( { + decimalSeparator: '.', + thousandSeparator: ',', + } ), +} ) ); + +describe( 'AppInputNumberControl', () => { + let ControlledInput; + let onChange; + let onBlur; + + function getInput() { + return screen.getByRole( 'textbox' ); + } + + beforeEach( () => { + onChange = jest.fn(); + onBlur = jest.fn(); + + ControlledInput = ( props ) => { + const [ value, setValue ] = useState( 1 ); + + return ( + { + onBlur( e, nextValue ); + setValue( nextValue ); + } } + { ...props } + /> + ); + }; + } ); + + it( 'Should format the number according to the setting', () => { + const { rerender } = render( + + ); + + expect( getInput().value ).toBe( '1.00' ); + + userEvent.clear( getInput() ); + userEvent.type( getInput(), '1234.56789' ); + userEvent.click( document.body ); + + expect( getInput().value ).toBe( '1,234.57' ); + + rerender( + + ); + + expect( getInput().value ).toBe( '1.234,5700' ); + + userEvent.clear( getInput() ); + userEvent.type( getInput(), '9876,54321' ); + userEvent.click( document.body ); + + expect( getInput().value ).toBe( '9.876,5432' ); + } ); + + it( 'Should callback the value as a number type', () => { + render( ); + + userEvent.clear( getInput() ); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenLastCalledWith( 0 ); + expect( onBlur ).toHaveBeenCalledTimes( 0 ); + + userEvent.type( getInput(), '123' ); + + expect( onChange ).toHaveBeenCalledTimes( 4 ); + expect( onChange ).toHaveBeenCalledWith( 1 ); + expect( onChange ).toHaveBeenCalledWith( 12 ); + expect( onChange ).toHaveBeenLastCalledWith( 123 ); + expect( onBlur ).toHaveBeenCalledTimes( 0 ); + + userEvent.type( getInput(), '4' ); + + expect( onChange ).toHaveBeenCalledTimes( 5 ); + expect( onChange ).toHaveBeenLastCalledWith( 1234 ); + expect( onBlur ).toHaveBeenCalledTimes( 0 ); + + userEvent.type( getInput(), '.567' ); + + expect( onChange ).toHaveBeenCalledTimes( 9 ); + expect( onChange ).toHaveBeenCalledWith( 1234 ); + expect( onChange ).toHaveBeenCalledWith( 1234.5 ); + expect( onChange ).toHaveBeenCalledWith( 1234.56 ); + expect( onChange ).toHaveBeenLastCalledWith( 1234.57 ); + expect( onBlur ).toHaveBeenCalledTimes( 0 ); + + userEvent.click( document.body ); + + expect( onChange ).toHaveBeenCalledTimes( 9 ); + expect( onBlur ).toHaveBeenCalledTimes( 1 ); + expect( onBlur ).toHaveBeenCalledWith( expect.any( Object ), 1234.57 ); + } ); + + it( 'Should treat the cleared input value as number 0 after losing focus', () => { + render( ); + + expect( getInput().value ).toBe( '1.000' ); + + userEvent.clear( getInput() ); + + expect( getInput().value ).toBe( '' ); + + userEvent.click( document.body ); + + expect( getInput().value ).toBe( '0.000' ); + + // Clear again to test the case that it resumes the displayed value to '0.000' + // after leaving a string value equivalent to number 0. + userEvent.clear( getInput() ); + + expect( getInput().value ).toBe( '' ); + + userEvent.click( document.body ); + + expect( getInput().value ).toBe( '0.000' ); + } ); +} ); diff --git a/js/src/components/contact-information/__snapshots__/mapStoreAddressErrors.test.js.snap b/js/src/components/contact-information/__snapshots__/mapStoreAddressErrors.test.js.snap new file mode 100644 index 0000000000..a814d0487f --- /dev/null +++ b/js/src/components/contact-information/__snapshots__/mapStoreAddressErrors.test.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`mapStoreAddressErrors When newly added required fields are missing in store address, should map them to fallback error messages 1`] = ` +Array [ + "The postcode/zip of store address is required.", + "The other newly added field of store address is required.", + "The another newly added field of store address is required.", +] +`; + +exports[`mapStoreAddressErrors When required store address fields are missing, should map them to corresponding error messages 1`] = ` +Array [ + "The address line of store address is required.", + "The city of store address is required.", + "The country/state of store address is required.", + "The postcode/zip of store address is required.", +] +`; diff --git a/js/src/components/contact-information/index.js b/js/src/components/contact-information/index.js index 8edd29d5c0..d3c4dfd81d 100644 --- a/js/src/components/contact-information/index.js +++ b/js/src/components/contact-information/index.js @@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { getEditPhoneNumberUrl, getEditStoreAddressUrl } from '.~/utils/urls'; +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import useGoogleMCPhoneNumber from '.~/hooks/useGoogleMCPhoneNumber'; import Section from '.~/wcdl/section'; import VerticalGapLayout from '.~/components/vertical-gap-layout'; @@ -88,6 +89,7 @@ export function ContactInformationPreview() { * @fires gla_documentation_link_click with `{ context: 'settings-no-store-address-notice', link_id: 'contact-information-read-more', href: 'https://docs.woocommerce.com/document/google-listings-and-ads/#contact-information' }` */ const ContactInformation = ( { onPhoneNumberVerified } ) => { + const { adapter } = useAdaptiveFormContext(); const phone = useGoogleMCPhoneNumber(); const title = mcTitle; const trackContext = 'setup-mc-contact-information'; @@ -116,9 +118,12 @@ const ContactInformation = ( { onPhoneNumberVerified } ) => { - + ); diff --git a/js/src/components/contact-information/mapStoreAddressErrors.js b/js/src/components/contact-information/mapStoreAddressErrors.js new file mode 100644 index 0000000000..d9d3cba029 --- /dev/null +++ b/js/src/components/contact-information/mapStoreAddressErrors.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * @typedef {import('.~/hooks/types.js').StoreAddress} StoreAddress + */ + +/** + * Maps the missing fields of store address to corresponding error messages. + * + * @param {StoreAddress} storeAddress [description] + * @return {string[]} Error massages of store address. + */ +export default function mapStoreAddressErrors( storeAddress ) { + // The possible fields to be mapped are defined in the first file, + // and their specs come from the second file: + // 1. https://github.com/woocommerce/google-listings-and-ads/blob/2.5.0/src/API/Google/Settings.php#L322-L339 + // 2. https://github.com/woocommerce/woocommerce/blob/7.9.0/plugins/woocommerce/includes/class-wc-countries.php#L841-L1654 + const fieldNameDict = { + address_1: _x( + 'address line', + 'The field name of the address line in store address', + 'google-listings-and-ads' + ), + city: _x( + 'city', + 'The field name of the city in store address', + 'google-listings-and-ads' + ), + country: _x( + 'country/state', + 'The field name of the country in store address', + 'google-listings-and-ads' + ), + postcode: _x( + 'postcode/zip', + 'The field name of the postcode in store address', + 'google-listings-and-ads' + ), + }; + + return storeAddress.missingRequiredFields.map( ( field ) => { + const fieldName = fieldNameDict[ field ] || field; + return sprintf( + // translators: %s: The missing field name of store address. + __( + 'The %s of store address is required.', + 'google-listings-and-ads' + ), + fieldName + ); + } ); +} diff --git a/js/src/components/contact-information/mapStoreAddressErrors.test.js b/js/src/components/contact-information/mapStoreAddressErrors.test.js new file mode 100644 index 0000000000..e72be871cb --- /dev/null +++ b/js/src/components/contact-information/mapStoreAddressErrors.test.js @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ +import mapStoreAddressErrors from './mapStoreAddressErrors'; + +describe( 'mapStoreAddressErrors', () => { + let storeAddress; + + beforeEach( () => { + storeAddress = { + isAddressFilled: true, + missingRequiredFields: [], + }; + } ); + + it( 'When all fields are valid, should return an empty array.', () => { + expect( mapStoreAddressErrors( storeAddress ) ).toEqual( [] ); + } ); + + it( 'When required store address fields are missing, should map them to corresponding error messages', () => { + storeAddress.isAddressFilled = false; + storeAddress.missingRequiredFields = [ + 'address_1', + 'city', + 'country', + 'postcode', + ]; + const errors = mapStoreAddressErrors( storeAddress ); + + expect( errors.length ).toBe( 4 ); + expect( errors ).toMatchSnapshot(); + } ); + + it( 'When newly added required fields are missing in store address, should map them to fallback error messages', () => { + storeAddress.isAddressFilled = false; + storeAddress.missingRequiredFields = [ + // Known field + 'postcode', + // Newly added fields + 'other newly added field', + 'another newly added field', + ]; + const errors = mapStoreAddressErrors( storeAddress ); + + expect( errors.length ).toBe( 3 ); + expect( errors ).toMatchSnapshot(); + } ); +} ); diff --git a/js/src/components/contact-information/phone-number-card/edit-phone-number-content.test.js b/js/src/components/contact-information/phone-number-card/edit-phone-number-content.test.js new file mode 100644 index 0000000000..15e36a07fc --- /dev/null +++ b/js/src/components/contact-information/phone-number-card/edit-phone-number-content.test.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import EditPhoneNumberContent from './edit-phone-number-content'; + +jest.mock( '.~/hooks/useCountryKeyNameMap', () => + jest + .fn() + .mockReturnValue( { US: 'United States', JP: 'Japan' } ) + .mockName( 'useCountryKeyNameMap' ) +); + +describe( 'PhoneNumberCard', () => { + it( 'Should take the initial country and national number as the initial values of inputs', () => { + render( + + ); + + const country = screen.getByRole( 'combobox' ); + const phone = screen.getByRole( 'textbox' ); + + expect( country.value ).toBe( 'United States (+1)' ); + expect( phone.value ).toBe( '2133734253' ); + } ); + + it( 'When an entered phone number is invalid, should disable "Send verification code" button', () => { + render( + + ); + + const phone = screen.getByRole( 'textbox' ); + const submit = screen.getByRole( 'button' ); + + expect( submit ).toBeEnabled(); + + userEvent.type( phone, '{backspace}' ); + + expect( submit ).toBeDisabled(); + + userEvent.type( phone, '1' ); + + expect( submit ).toBeEnabled(); + + userEvent.type( phone, '2' ); + + expect( submit ).toBeDisabled(); + } ); + + it( 'Should call back `onSendVerificationCodeClick` with input values and verification method when clicking on "Send verification code" button', async () => { + const onSendVerificationCodeClick = jest + .fn() + .mockName( 'onSendVerificationCodeClick' ); + + render( + + ); + + const country = screen.getByRole( 'combobox' ); + const phone = screen.getByRole( 'textbox' ); + const submit = screen.getByRole( 'button' ); + + expect( onSendVerificationCodeClick ).toHaveBeenCalledTimes( 0 ); + + // Select and enter a U.S. phone number + userEvent.type( country, 'uni' ); + userEvent.click( await screen.findByRole( 'option' ) ); + userEvent.clear( phone ); + userEvent.type( phone, '2133734253' ); + + userEvent.click( submit ); + + expect( onSendVerificationCodeClick ).toHaveBeenCalledTimes( 1 ); + expect( onSendVerificationCodeClick ).toHaveBeenCalledWith( + expect.objectContaining( { + country: 'US', + countryCallingCode: '1', + nationalNumber: '2133734253', + isValid: true, + display: '+1 213 373 4253', + number: '+12133734253', + verificationMethod: 'SMS', + } ) + ); + + // Select and enter a Japanese phone number + userEvent.clear( country ); + userEvent.type( country, 'jap' ); + userEvent.click( await screen.findByRole( 'option' ) ); + userEvent.clear( phone ); + userEvent.type( phone, '570550634' ); + + // Select verification method to PHONE_CALL + userEvent.click( screen.getByRole( 'radio', { name: 'Phone call' } ) ); + + userEvent.click( submit ); + + expect( onSendVerificationCodeClick ).toHaveBeenCalledTimes( 2 ); + expect( onSendVerificationCodeClick ).toHaveBeenLastCalledWith( + expect.objectContaining( { + country: 'JP', + countryCallingCode: '81', + nationalNumber: '570550634', + isValid: true, + display: '+81 570 550 634', + number: '+81570550634', + verificationMethod: 'PHONE_CALL', + } ) + ); + } ); +} ); diff --git a/js/src/components/contact-information/phone-number-card/phone-number-card.js b/js/src/components/contact-information/phone-number-card/phone-number-card.js index 887b7363f0..8c9cd3faf5 100644 --- a/js/src/components/contact-information/phone-number-card/phone-number-card.js +++ b/js/src/components/contact-information/phone-number-card/phone-number-card.js @@ -12,6 +12,7 @@ import { Spinner } from '@woocommerce/components'; import AccountCard, { APPEARANCE } from '.~/components/account-card'; import AppButton from '.~/components/app-button'; import AppSpinner from '.~/components/app-spinner'; +import ValidationErrors from '.~/components/validation-errors'; import VerifyPhoneNumberContent from './verify-phone-number-content'; import EditPhoneNumberContent from './edit-phone-number-content'; import './phone-number-card.scss'; @@ -27,7 +28,11 @@ const basePhoneNumberCardProps = { * @typedef { import(".~/hooks/useGoogleMCPhoneNumber").PhoneNumber } PhoneNumber */ -function EditPhoneNumberCard( { phoneNumber, onPhoneNumberVerified } ) { +function EditPhoneNumberCard( { + phoneNumber, + showValidation, + onPhoneNumberVerified, +} ) { const { loaded, data } = phoneNumber; const [ verifying, setVerifying ] = useState( false ); const [ unverifiedPhoneNumber, setUnverifiedPhoneNumber ] = useState( @@ -73,11 +78,22 @@ function EditPhoneNumberCard( { phoneNumber, onPhoneNumberVerified } ) { /> ) : null; + const helper = + showValidation && ! data.isVerified ? ( + + ) : null; + return ( { cardContent } @@ -102,6 +118,7 @@ function EditPhoneNumberCard( { phoneNumber, onPhoneNumberVerified } ) { * `true`: initialize with the editing UI for entering the phone number and proceeding with verification. * `false`: initialize with the non-editing UI viewing the phone number and a button for switching to the editing UI. * `null`: determine the initial UI state according to the `data.isVerified` after the `phoneNumber` loaded. + * @param {boolean} [props.showValidation=false] Whether to show validation error messages. * @param {Function} [props.onEditClick] Called when clicking on "Edit" button. * If this callback is omitted, it will enter edit mode when clicking on "Edit" button. * @param {Function} [props.onPhoneNumberVerified] Called when the phone number is verified or has been verified. @@ -112,6 +129,7 @@ const PhoneNumberCard = ( { view, phoneNumber, initEditing = null, + showValidation = false, onEditClick, onPhoneNumberVerified = noop, } ) => { @@ -157,6 +175,7 @@ const PhoneNumberCard = ( { return ( ); diff --git a/js/src/components/contact-information/phone-number-card/phone-number-card.scss b/js/src/components/contact-information/phone-number-card/phone-number-card.scss index daf672088f..80404bdb7f 100644 --- a/js/src/components/contact-information/phone-number-card/phone-number-card.scss +++ b/js/src/components/contact-information/phone-number-card/phone-number-card.scss @@ -3,6 +3,10 @@ color: $gray-700; } + .gla-account-card__helper { + font-style: normal; + } + // Country code selector .wcdl-select-control { .wcdl-select-control__input { diff --git a/js/src/components/contact-information/phone-number-card/phone-number-card.test.js b/js/src/components/contact-information/phone-number-card/phone-number-card.test.js new file mode 100644 index 0000000000..ed8d9d6f29 --- /dev/null +++ b/js/src/components/contact-information/phone-number-card/phone-number-card.test.js @@ -0,0 +1,262 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { parsePhoneNumberFromString as parsePhoneNumber } from 'libphonenumber-js'; + +/** + * Internal dependencies + */ +import PhoneNumberCard from './phone-number-card'; + +jest.mock( '@woocommerce/components', () => ( { + ...jest.requireActual( '@woocommerce/components' ), + Spinner: jest + .fn( () =>
) + .mockName( 'Spinner' ), +} ) ); + +jest.mock( '.~/hooks/useCountryKeyNameMap', () => + jest + .fn() + .mockReturnValue( { US: 'United States' } ) + .mockName( 'useCountryKeyNameMap' ) +); + +jest.mock( '.~/data', () => ( { + ...jest.requireActual( '.~/data' ), + useAppDispatch() { + return { + requestPhoneVerificationCode: jest + .fn( () => Promise.resolve( { verificationId: 123 } ) ) + .mockName( 'requestPhoneVerificationCode' ), + + verifyPhoneNumber: jest + .fn( () => Promise.resolve( {} ) ) + .mockName( 'verifyPhoneNumber' ), + }; + }, +} ) ); + +describe( 'PhoneNumberCard', () => { + let phoneData; + let phoneNumber; + + function mockPhoneData( fullPhoneNumber ) { + const parsed = parsePhoneNumber( fullPhoneNumber ); + + phoneData = { + ...parsed, + display: parsed.formatInternational(), + isValid: parsed.isValid(), + }; + + phoneNumber = { + ...phoneNumber, + data: phoneData, + }; + } + + function mockVerified( isVerified ) { + phoneNumber = { + ...phoneNumber, + data: { + ...phoneData, + isVerified, + }, + }; + } + + function mockLoaded( loaded ) { + phoneNumber = { + ...phoneNumber, + loaded, + }; + } + + beforeEach( () => { + mockPhoneData( '+12133734253' ); + mockVerified( true ); + mockLoaded( true ); + } ); + + it( 'When not yet loaded, should render a loading spinner', () => { + mockLoaded( false ); + + render( ); + + const spinner = screen.getByRole( 'status', { name: 'spinner' } ); + const display = screen.queryByText( phoneData.display ); + const button = screen.queryByRole( 'button' ); + + expect( spinner ).toBeInTheDocument(); + expect( display ).not.toBeInTheDocument(); + expect( button ).not.toBeInTheDocument(); + } ); + + it( 'When `initEditing` is not specified, should render in non-editing mode after loading a verified phone number', () => { + mockLoaded( false ); + const { rerender } = render( + + ); + + mockLoaded( true ); + rerender( ); + + const button = screen.getByRole( 'button', { name: 'Edit' } ); + + expect( button ).toBeInTheDocument(); + } ); + + it( 'When `initEditing` is not specified, should render in editing mode after loading an unverified phone number', () => { + mockLoaded( false ); + const { rerender } = render( + + ); + + mockVerified( false ); + mockLoaded( true ); + rerender( ); + + const button = screen.getByRole( 'button', { name: /Send/ } ); + + expect( button ).toBeInTheDocument(); + } ); + + it( 'When `initEditing` is true, should directly render in editing mode', () => { + render( ); + + const button = screen.getByRole( 'button', { name: /Send/ } ); + + expect( button ).toBeInTheDocument(); + } ); + + it( 'When `initEditing` is false, should render in non-editing mode regardless of verified or not', () => { + // Start with a verified and valid phone number + const { rerender } = render( + + ); + + expect( screen.getByText( phoneData.display ) ).toBeInTheDocument(); + + // Set to an unverified and invalid phone number + mockPhoneData( '+121' ); + mockVerified( false ); + + rerender( + + ); + + expect( screen.getByText( phoneData.display ) ).toBeInTheDocument(); + } ); + + it( 'When the phone number is loaded but not yet verified, should directly render in editing mode', () => { + mockVerified( false ); + render( ); + + const button = screen.getByRole( 'button', { name: /Send/ } ); + + expect( button ).toBeInTheDocument(); + } ); + + it( 'When `showValidation` is true and the phone number is not yet verified, should show a validation error text', () => { + const text = 'A verified phone number is required.'; + mockVerified( false ); + + const { rerender } = render( + + ); + + expect( screen.queryByText( text ) ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( screen.getByText( text ) ).toBeInTheDocument(); + } ); + + it( 'When `onEditClick` is specified and the Edit button is clicked, should callback `onEditClick`', () => { + const onEditClick = jest.fn(); + render( + + ); + + const button = screen.getByRole( 'button', { name: 'Edit' } ); + + expect( button ).toBeInTheDocument(); + expect( onEditClick ).toHaveBeenCalledTimes( 0 ); + + userEvent.click( button ); + + expect( onEditClick ).toHaveBeenCalledTimes( 1 ); + } ); + + describe( 'Should callback `onPhoneNumberVerified`', () => { + it( 'When `initEditing` is not specified and loaded phone number has been verified', () => { + const onPhoneNumberVerified = jest.fn(); + + render( + + ); + + expect( onPhoneNumberVerified ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'When `initEditing` is false and loaded phone number has been verified', () => { + const onPhoneNumberVerified = jest.fn(); + render( + + ); + + expect( onPhoneNumberVerified ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'When an unverified phone number is getting verified', async () => { + const onPhoneNumberVerified = jest.fn(); + mockVerified( false ); + render( + + ); + + expect( onPhoneNumberVerified ).toHaveBeenCalledTimes( 0 ); + + await act( async () => { + const button = screen.getByRole( 'button', { name: /Send/ } ); + userEvent.click( button ); + } ); + + screen.getAllByRole( 'textbox' ).forEach( ( codeInput, i ) => { + userEvent.type( codeInput, i.toString() ); + } ); + + await act( async () => { + const button = screen.getByRole( 'button', { name: /Verify/ } ); + userEvent.click( button ); + } ); + + expect( onPhoneNumberVerified ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/js/src/components/contact-information/phone-number-card/verify-phone-number-content.js b/js/src/components/contact-information/phone-number-card/verify-phone-number-content.js index 39b8e22bd2..4e089ed911 100644 --- a/js/src/components/contact-information/phone-number-card/verify-phone-number-content.js +++ b/js/src/components/contact-information/phone-number-card/verify-phone-number-content.js @@ -226,6 +226,10 @@ export default function VerifyPhoneNumberContent( { disabled={ verifying } text={ textSwitch } onClick={ switchMethod } + aria-label={ __( + 'Switch verification method', + 'google-listings-and-ads' + ) } /> diff --git a/js/src/components/contact-information/phone-number-card/verify-phone-number-content.test.js b/js/src/components/contact-information/phone-number-card/verify-phone-number-content.test.js new file mode 100644 index 0000000000..db4481c3a8 --- /dev/null +++ b/js/src/components/contact-information/phone-number-card/verify-phone-number-content.test.js @@ -0,0 +1,310 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import VerifyPhoneNumberContent from './verify-phone-number-content'; +import { useAppDispatch } from '.~/data'; + +jest.mock( '.~/data', () => ( { + ...jest.requireActual( '.~/data' ), + useAppDispatch: jest.fn(), +} ) ); + +describe( 'VerifyPhoneNumberContent', () => { + let requestPhoneVerificationCode; + let verifyPhoneNumber; + + function getSwitchButton() { + return screen.getByRole( 'button', { + name: 'Switch verification method', + } ); + } + + function getVerifyButton() { + return screen.getByRole( 'button', { name: /Verify/ } ); + } + + beforeEach( () => { + requestPhoneVerificationCode = jest + .fn( () => Promise.resolve( { verificationId: '987654321' } ) ) + .mockName( 'requestPhoneVerificationCode' ); + + verifyPhoneNumber = jest + .fn( () => { + return new Promise( ( resolve, reject ) => { + setTimeout( + () => reject( { display: 'error reason: JS test' } ), + 1000 + ); + } ); + } ) + .mockName( 'verifyPhoneNumber' ); + + useAppDispatch.mockReturnValue( { + requestPhoneVerificationCode, + verifyPhoneNumber, + } ); + } ); + + it( 'Should render the given props with corresponding verification method', () => { + render( + + ); + + const switchButton = getSwitchButton(); + + expect( switchButton ).toBeInTheDocument(); + expect( switchButton.textContent ).toBe( + 'Or, receive a verification code through a phone call' + ); + expect( screen.getByText( '+1 213 373 4253' ) ).toBeInTheDocument(); + expect( + screen.getByText( + /A text message with the 6-digit verification code has been sent/ + ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Resend code/ } ) + ).toBeInTheDocument(); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 1 ); + + // Switch to the PHONE_CALL method + userEvent.click( switchButton ); + + expect( switchButton.textContent ).toBe( + 'Or, receive a verification code through text message' + ); + expect( screen.getByText( '+1 213 373 4253' ) ).toBeInTheDocument(); + expect( + screen.getByText( /You will receive a phone call/ ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Call again/ } ) + ).toBeInTheDocument(); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'When not yet entered 6-digit verification code, should disable the "Verify phone number" button', async () => { + await act( async () => { + render( + + ); + } ); + + const button = getVerifyButton(); + + expect( button ).toBeInTheDocument(); + expect( button ).toBeDisabled(); + + screen.getAllByRole( 'textbox' ).forEach( ( codeInput, i ) => { + userEvent.type( codeInput, i.toString() ); + } ); + + expect( button ).toBeEnabled(); + } ); + + it( 'When waiting for the countdown of each verification method, should disable the "Resend code/Call again" button', () => { + render( + + ); + + const button = screen.getByRole( 'button', { name: /Resend code/ } ); + + expect( button ).toBeDisabled(); + + // Switch to the PHONE_CALL method + userEvent.click( getSwitchButton() ); + + expect( button ).toBeDisabled(); + } ); + + it( 'When not waiting for the countdown of each verification method, should call `requestPhoneVerificationCode` with the country code, phone number and verification method', () => { + render( + + ); + + const switchButton = getSwitchButton(); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 1 ); + expect( requestPhoneVerificationCode ).toHaveBeenCalledWith( + 'US', + '+12133734253', + 'SMS' + ); + + // Switch to the PHONE_CALL method + userEvent.click( switchButton ); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 2 ); + expect( requestPhoneVerificationCode ).toHaveBeenLastCalledWith( + 'US', + '+12133734253', + 'PHONE_CALL' + ); + + // Switch back to the SMS method but it's still waiting for countdown + userEvent.click( switchButton ); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 2 ); + + // Switch back to the PHONE_CALL method but it's still waiting for countdown + userEvent.click( switchButton ); + + expect( requestPhoneVerificationCode ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'When clicking the "Verify phone number" button, should call `verifyPhoneNumber` with the verification id, code and method', async () => { + await act( async () => { + render( + {} } + /> + ); + } ); + + const button = getVerifyButton(); + + screen.getAllByRole( 'textbox' ).forEach( ( codeInput, i ) => { + userEvent.type( codeInput, i.toString() ); + } ); + + expect( verifyPhoneNumber ).toHaveBeenCalledTimes( 0 ); + + userEvent.click( button ); + + expect( verifyPhoneNumber ).toHaveBeenCalledTimes( 1 ); + expect( verifyPhoneNumber ).toHaveBeenLastCalledWith( + '987654321', + '012345', + 'SMS' + ); + } ); + + it( 'Should call back to `onVerificationStateChange` according to the state and result of `verifyPhoneNumber`', async () => { + const onVerificationStateChange = jest + .fn() + .mockName( 'onVerificationStateChange' ); + + await act( async () => { + render( + + ); + } ); + + const button = getVerifyButton(); + + screen.getAllByRole( 'textbox' ).forEach( ( codeInput, i ) => { + userEvent.type( codeInput, i.toString() ); + } ); + + // ----------------------------------------- + // Failed `verifyPhoneNumber` calling result + // ----------------------------------------- + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 0 ); + + await userEvent.click( button ); + + // First callback for loading state: + // 1. isVerifying: true + // 2. isVerified: false + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 1 ); + expect( onVerificationStateChange ).toHaveBeenCalledWith( true, false ); + + await act( async () => { + jest.runOnlyPendingTimers(); + } ); + + // Second callback for failed result: + // 1. isVerifying: false + // 2. isVerified: false + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 2 ); + expect( onVerificationStateChange ).toHaveBeenLastCalledWith( + false, + false + ); + + // `Notice` component will insert another invisible text element under with the same string. + // So here filter out it. + expect( + screen.getByText( ( content, element ) => { + return ( + element.parentElement.tagName !== 'BODY' && + content === 'error reason: JS test' + ); + } ) + ).toBeInTheDocument(); + + // --------------------------------------------- + // Successful `verifyPhoneNumber` calling result + // --------------------------------------------- + onVerificationStateChange.mockReset(); + + verifyPhoneNumber.mockImplementation( + () => new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ) + ); + + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 0 ); + + await userEvent.click( button ); + + // First callback for loading state: + // 1. isVerifying: true + // 2. isVerified: false + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 1 ); + expect( onVerificationStateChange ).toHaveBeenCalledWith( true, false ); + + await act( async () => { + jest.runOnlyPendingTimers(); + } ); + + // Second callback for successful result: + // 1. isVerifying: false + // 2. isVerified: true + expect( onVerificationStateChange ).toHaveBeenCalledTimes( 2 ); + expect( onVerificationStateChange ).toHaveBeenLastCalledWith( + false, + true + ); + + // The verify button should keep disabled after successfully verified + expect( button ).toBeDisabled(); + } ); +} ); diff --git a/js/src/components/contact-information/store-address-card.js b/js/src/components/contact-information/store-address-card.js index 3110373806..609d060ab0 100644 --- a/js/src/components/contact-information/store-address-card.js +++ b/js/src/components/contact-information/store-address-card.js @@ -2,11 +2,12 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { createInterpolateElement } from '@wordpress/element'; +import { useRef, createInterpolateElement } from '@wordpress/element'; import { CardDivider } from '@wordpress/components'; import { Spinner } from '@woocommerce/components'; import { update as updateIcon } from '@wordpress/icons'; import { getPath, getQuery } from '@woocommerce/navigation'; +import { recordEvent } from '@woocommerce/tracks'; /** * Internal dependencies @@ -16,8 +17,10 @@ import Section from '.~/wcdl/section'; import Subsection from '.~/wcdl/subsection'; import AccountCard, { APPEARANCE } from '.~/components/account-card'; import AppButton from '.~/components/app-button'; +import ValidationErrors from '.~/components/validation-errors'; import ContactInformationPreviewCard from './contact-information-preview-card'; import TrackableLink from '.~/components/trackable-link'; +import mapStoreAddressErrors from './mapStoreAddressErrors'; import './store-address-card.scss'; /** @@ -29,24 +32,62 @@ import './store-address-card.scss'; * @property {string|undefined} [subpath] The subpath used in the page, e.g. `"/edit-store-address"` or `undefined` when there is no subpath. */ +/** + * Track how many times and what fields the store address is having validation errors. + * + * @event gla_wc_store_address_validation + * @property {string} path The path used in the page from which the event tracking was sent, e.g. `"/google/setup-mc"` or `"/google/settings"`. + * @property {string|undefined} [subpath] The subpath used in the page, e.g. `"/edit-store-address"` or `undefined` when there is no subpath. + * @property {string} country_code The country code of store address, e.g. `"US"`. + * @property {string} missing_fields The string of the missing required fields of store address separated by comma, e.g. `"city,postcode"`. + */ + /** * Renders a component with a given store address. * * @fires gla_edit_wc_store_address Whenever "Edit in WooCommerce Settings" button is clicked. + * @fires gla_wc_store_address_validation Whenever the new store address data is fetched after clicking "Refresh to sync" button. + * + * @param {Object} props React props. + * @param {boolean} [props.showValidation=false] Whether to show validation error messages. * * @return {JSX.Element} Filled AccountCard component. */ -const StoreAddressCard = () => { +const StoreAddressCard = ( { showValidation = false } ) => { const { loaded, data, refetch } = useStoreAddress(); + const path = getPath(); const { subpath } = getQuery(); - const editButton = ( + + const refetchedCallbackRef = useRef( null ); + + if ( loaded && refetchedCallbackRef.current ) { + refetchedCallbackRef.current( data ); + refetchedCallbackRef.current = null; + } + + const handleRefreshClick = () => { + refetch(); + + refetchedCallbackRef.current = ( storeAddress ) => { + const eventProps = { + path, + subpath, + country_code: storeAddress.countryCode, + missing_fields: storeAddress.missingRequiredFields.join( ',' ), + }; + + recordEvent( 'gla_wc_store_address_validation', eventProps ); + }; + }; + + const refreshButton = ( ); @@ -67,7 +108,7 @@ const StoreAddressCard = () => { type="external" href="admin.php?page=wc-settings" eventName="gla_edit_wc_store_address" - eventProps={ { path: getPath(), subpath } } + eventProps={ { path, subpath } } /> ), } @@ -108,7 +149,7 @@ const StoreAddressCard = () => { alignIcon="top" alignIndicator="top" description={ description } - indicator={ editButton } + indicator={ refreshButton } > @@ -116,6 +157,11 @@ const StoreAddressCard = () => { { __( 'Store address', 'google-listings-and-ads' ) } { addressContent } + { showValidation && ( + + ) } ); diff --git a/js/src/components/free-listings/choose-audience-section/choose-audience-section.js b/js/src/components/free-listings/choose-audience-section/choose-audience-section.js index 81d52a7646..cd74fb6085 100644 --- a/js/src/components/free-listings/choose-audience-section/choose-audience-section.js +++ b/js/src/components/free-listings/choose-audience-section/choose-audience-section.js @@ -8,6 +8,7 @@ import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import AppRadioContentControl from '.~/components/app-radio-content-control'; import AppDocumentationLink from '.~/components/app-documentation-link'; import Section from '.~/wcdl/section'; @@ -23,12 +24,14 @@ import './choose-audience-section.scss'; * To be used in onboarding and further editing. * Does not provide any save strategy, this is to be bound externaly. * - * @param {Object} props React props. - * @param {Object} props.formProps Form props forwarded from `Form` component. * @fires gla_documentation_link_click with `{ context: 'setup-mc-audience', link_id: 'site-language', href: 'https://support.google.com/merchants/answer/160637' }` */ -const ChooseAudienceSection = ( { formProps } ) => { - const { values, getInputProps } = formProps; +const ChooseAudienceSection = () => { + const { + values, + getInputProps, + adapter: { renderRequestedValidation }, + } = useAdaptiveFormContext(); const { locale, language } = values; return ( @@ -106,6 +109,7 @@ const ChooseAudienceSection = ( { formProps } ) => { 'google-listings-and-ads' ) } /> + { renderRequestedValidation( 'countries' ) } { +const checkErrors = ( + values, + shippingTimes, + finalCountryCodes, + storeCountryCode +) => { const errors = {}; // Check audience. @@ -46,8 +51,8 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { ( values.shipping_country_rates.length < finalCountryCodes.length || values.shipping_country_rates.some( ( el ) => el.rate < 0 ) ) ) { - errors.shipping_rate = __( - 'Please specify shipping rates for all the countries. And the estimated shipping rate cannot be less than 0.', + errors.shipping_country_rates = __( + 'Please specify estimated shipping rates for all the countries, and the rate cannot be less than 0.', 'google-listings-and-ads' ); } @@ -73,7 +78,7 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { shippingRate.options.free_shipping_threshold === undefined ) ) { - errors.offer_free_shipping = __( + errors.free_shipping_threshold = __( 'Please enter minimum order for free shipping.', 'google-listings-and-ads' ); @@ -95,8 +100,8 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { ( shippingTimes.length < finalCountryCodes.length || shippingTimes.some( ( el ) => el.time < 0 ) ) ) { - errors.shipping_time = __( - 'Please specify shipping times for all the countries. And the estimated shipping time cannot be less than 0.', + 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' ); } @@ -105,7 +110,7 @@ const checkErrors = ( values, shippingTimes, finalCountryCodes ) => { * Check tax rate (required for U.S. only). */ if ( - finalCountryCodes.includes( 'US' ) && + ( storeCountryCode === 'US' || finalCountryCodes.includes( 'US' ) ) && ! validTaxRateSet.has( values.tax_rate ) ) { errors.tax_rate = __( 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 1f4397c4e6..0222258721 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 @@ -187,8 +187,8 @@ describe( 'checkErrors', () => { const errors = checkErrors( values, [], codes ); - expect( errors ).toHaveProperty( 'shipping_rate' ); - expect( errors.shipping_rate ).toMatchSnapshot(); + expect( errors ).toHaveProperty( 'shipping_country_rates' ); + expect( errors.shipping_country_rates ).toMatchSnapshot(); } ); it( `When all selected countries' shipping rates are set, should pass`, () => { @@ -218,8 +218,8 @@ describe( 'checkErrors', () => { const errors = checkErrors( values, [], codes ); - expect( errors ).toHaveProperty( 'shipping_rate' ); - expect( errors.shipping_rate ).toMatchSnapshot(); + expect( errors ).toHaveProperty( 'shipping_country_rates' ); + expect( errors.shipping_country_rates ).toMatchSnapshot(); } ); it( `When all shipping rates are ≥ 0, should pass`, () => { @@ -294,7 +294,8 @@ describe( 'checkErrors', () => { const errors = checkErrors( values, [], codes ); - expect( errors ).toHaveProperty( 'offer_free_shipping' ); + expect( errors ).toHaveProperty( 'free_shipping_threshold' ); + expect( errors.free_shipping_threshold ).toMatchSnapshot(); } ); } ); @@ -385,8 +386,8 @@ describe( 'checkErrors', () => { const errors = checkErrors( flatShipping, times, codes ); - expect( errors ).toHaveProperty( 'shipping_time' ); - expect( errors.shipping_time ).toMatchSnapshot(); + expect( errors ).toHaveProperty( 'shipping_country_times' ); + expect( errors.shipping_country_times ).toMatchSnapshot(); } ); it( `When all selected countries' shipping times are set, should pass`, () => { @@ -404,8 +405,8 @@ describe( 'checkErrors', () => { const errors = checkErrors( flatShipping, times, codes ); - expect( errors ).toHaveProperty( 'shipping_time' ); - expect( errors.shipping_time ).toMatchSnapshot(); + expect( errors ).toHaveProperty( 'shipping_country_times' ); + expect( errors.shipping_country_times ).toMatchSnapshot(); } ); it( `When all shipping times are ≥ 0, should pass`, () => { @@ -419,7 +420,7 @@ describe( 'checkErrors', () => { } ); } ); - describe( `For tax rate, if selected country codes include 'US'`, () => { + describe( `For tax rate, if store country code or selected country codes include 'US'`, () => { let codes; beforeEach( () => { @@ -433,6 +434,11 @@ describe( 'checkErrors', () => { expect( errors ).toHaveProperty( 'tax_rate' ); expect( errors.tax_rate ).toMatchSnapshot(); + errors = checkErrors( defaultFormValues, [], [], 'US' ); + + expect( errors ).toHaveProperty( 'tax_rate' ); + expect( errors.tax_rate ).toMatchSnapshot(); + // Invalid value errors = checkErrors( { ...defaultFormValues, tax_rate: true }, @@ -465,12 +471,20 @@ describe( 'checkErrors', () => { expect( errors ).not.toHaveProperty( 'tax_rate' ); + errors = checkErrors( destinationTaxRate, [], [], 'US' ); + + expect( errors ).not.toHaveProperty( 'tax_rate' ); + // Selected manual const manualTaxRate = { ...defaultFormValues, tax_rate: 'manual' }; errors = checkErrors( manualTaxRate, [], codes ); expect( errors ).not.toHaveProperty( 'tax_rate' ); + + errors = checkErrors( destinationTaxRate, [], [], 'US' ); + + expect( errors ).not.toHaveProperty( 'tax_rate' ); } ); } ); } ); diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time-section.js b/js/src/components/free-listings/configure-product-listings/shipping-time-section.js index 7c566d9756..d8d1d0a408 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time-section.js +++ b/js/src/components/free-listings/configure-product-listings/shipping-time-section.js @@ -14,10 +14,7 @@ import ShippingTimeSetup from './shipping-time/shipping-time-setup'; * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-read-more', href: 'https://support.google.com/merchants/answer/7050921' }` */ -const ShippingTimeSection = ( { - formProps, - countries: selectedCountryCodes, -} ) => { +const ShippingTimeSection = () => { return (
} > - - - - { __( - 'Estimated shipping times', - 'google-listings-and-ads' - ) } - - - - +
); }; diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-form.js b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-form.js index e7b643fa9c..c2da606b39 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-form.js +++ b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-form.js @@ -18,19 +18,19 @@ import getCountriesTimeArray from './getCountriesTimeArray'; * * @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.selectedCountryCodes Array of country codes of all audience countries. + * @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, - selectedCountryCodes, + audienceCountries, onChange, } ) { const actualCountryCount = shippingTimes.length; const actualCountries = new Map( shippingTimes.map( ( time ) => [ time.countryCode, time ] ) ); - const remainingCountryCodes = selectedCountryCodes.filter( + const remainingCountryCodes = audienceCountries.filter( ( el ) => ! actualCountries.has( el ) ); const remainingCount = remainingCountryCodes.length; @@ -41,7 +41,7 @@ export default function ShippingCountriesForm( { // Prefill to-be-added time. if ( countriesTimeArray.length === 0 ) { countriesTimeArray.push( { - countries: selectedCountryCodes, + countries: audienceCountries, time: null, } ); } @@ -92,7 +92,7 @@ export default function ShippingCountriesForm( { > diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-time-input/index.scss b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-time-input/index.scss index 02c95cced4..db914baa8c 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-time-input/index.scss +++ b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/countries-time-input/index.scss @@ -1,4 +1,6 @@ .gla-countries-time-input { + max-width: $gla-width-medium; + .label { display: flex; justify-content: space-between; diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.js b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.js index aa2eaad19f..c816435a09 100644 --- a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.js +++ b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.js @@ -1,38 +1,45 @@ /** - * Internal dependencies + * External dependencies */ -import AppSpinner from '.~/components/app-spinner'; -import VerticalGapLayout from '.~/components/vertical-gap-layout'; -import ShippingCountriesForm from './countries-form'; -import './index.scss'; +import { __ } from '@wordpress/i18n'; /** - * @typedef { import(".~/data/actions").CountryCode } CountryCode + * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; +import Section from '.~/wcdl/section'; +import AppSpinner from '.~/components/app-spinner'; +import ShippingCountriesForm from './countries-form'; /** * Form control to edit shipping rate settings. - * - * @param {Object} props React props. - * @param {Object} props.formProps Form props forwarded from `Form` component. - * @param {Array} props.selectedCountryCodes Array of country codes of all audience countries. */ -const ShippingTimeSetup = ( { formProps, selectedCountryCodes } ) => { - const { getInputProps } = formProps; +const ShippingTimeSetup = () => { + const { + getInputProps, + adapter: { audienceCountries, renderRequestedValidation }, + } = useAdaptiveFormContext(); - if ( ! selectedCountryCodes ) { + if ( ! audienceCountries ) { return ; } return ( -
- + + + + { __( + 'Estimated shipping times', + 'google-listings-and-ads' + ) } + - -
+ { renderRequestedValidation( 'shipping_country_times' ) } + + ); }; diff --git a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.scss b/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.scss deleted file mode 100644 index 1c7518dd10..0000000000 --- a/js/src/components/free-listings/configure-product-listings/shipping-time/shipping-time-setup/index.scss +++ /dev/null @@ -1,15 +0,0 @@ -.gla-shipping-time-setup { - margin-top: calc(var(--main-gap) / 2); - - .countries-time { - margin-bottom: calc(var(--main-gap) / 2); - - .countries-time-input-form { - max-width: $gla-width-medium; - } - } - - .add-time-button { - align-self: flex-start; - } -} diff --git a/js/src/components/free-listings/configure-product-listings/tax-rate.js b/js/src/components/free-listings/configure-product-listings/tax-rate.js index 0f9db2e4b0..ea20bed059 100644 --- a/js/src/components/free-listings/configure-product-listings/tax-rate.js +++ b/js/src/components/free-listings/configure-product-listings/tax-rate.js @@ -7,6 +7,7 @@ import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import Section from '.~/wcdl/section'; import RadioHelperText from '.~/wcdl/radio-helper-text'; import AppRadioContentControl from '.~/components/app-radio-content-control'; @@ -18,10 +19,11 @@ import VerticalGapLayout from '.~/components/vertical-gap-layout'; * @fires gla_documentation_link_click with `{ context: 'setup-mc-tax-rate', link_id: 'tax-rate-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` */ -const TaxRate = ( props ) => { +const TaxRate = () => { const { - formProps: { getInputProps }, - } = props; + getInputProps, + adapter: { renderRequestedValidation }, + } = useAdaptiveFormContext(); return (
{ + { renderRequestedValidation( 'tax_rate' ) }
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 ae101df6ef..d6337000f3 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 @@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import StepContent from '.~/components/stepper/step-content'; import StepContentFooter from '.~/components/stepper/step-content-footer'; import TaxRate from '.~/components/free-listings/configure-product-listings/tax-rate'; @@ -16,53 +17,45 @@ import ShippingTimeSection from '.~/components/free-listings/configure-product-l import AppButton from '.~/components/app-button'; import ConditionalSection from '.~/components/conditional-section'; -/** - * @typedef {import('.~/data/actions').CountryCode} CountryCode - */ - /** * Form to configure free listigns. * * @param {Object} props React props. - * @param {Array} props.countries List of available countries to be forwarded to ShippingRateSection and ShippingTimeSection. - * @param {Object} props.formProps Form props forwarded from `Form` component, containing free listings settings. - * @param {boolean} [props.saving=false] Is the form currently beign saved? * @param {string} [props.submitLabel="Complete setup"] Submit button label. */ const FormContent = ( { - countries, - formProps, - saving = false, submitLabel = __( 'Complete setup', 'google-listings-and-ads' ), } ) => { - const { values, isValidForm, handleSubmit } = formProps; - const shouldDisplayTaxRate = useDisplayTaxRate( countries ); + const { + values, + isValidForm, + handleSubmit, + adapter, + } = useAdaptiveFormContext(); + const shouldDisplayTaxRate = useDisplayTaxRate( adapter.audienceCountries ); const shouldDisplayShippingTime = values.shipping_time === 'flat'; - const isCompleteSetupDisabled = - shouldDisplayTaxRate === null || ! isValidForm; + + const handleSubmitClick = ( event ) => { + if ( shouldDisplayTaxRate !== null && isValidForm ) { + return handleSubmit( event ); + } + + adapter.showValidation(); + }; return ( - - - { shouldDisplayShippingTime && ( - - ) } + + + { shouldDisplayShippingTime && } - + { submitLabel } 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 ae3e41d5b2..bd7ab678b1 100644 --- a/js/src/components/free-listings/setup-free-listings/index.js +++ b/js/src/components/free-listings/setup-free-listings/index.js @@ -1,15 +1,17 @@ /** * External dependencies */ -import { useState, useRef } from '@wordpress/element'; +import { useRef } from '@wordpress/element'; import { pick, noop } from 'lodash'; /** * Internal dependencies */ +import useStoreCountry from '.~/hooks/useStoreCountry'; import AppSpinner from '.~/components/app-spinner'; import Hero from '.~/components/free-listings/configure-product-listings/hero'; import AdaptiveForm from '.~/components/adaptive-form'; +import ValidationErrors from '.~/components/validation-errors'; import checkErrors from '.~/components/free-listings/configure-product-listings/checkErrors'; import getOfferFreeShippingInitialValue from '.~/utils/getOfferFreeShippingInitialValue'; import isNonFreeShippingRate from '.~/utils/isNonFreeShippingRate'; @@ -91,7 +93,7 @@ const SetupFreeListings = ( { headerTitle, } ) => { const formRef = useRef(); - const [ saving, setSaving ] = useState( false ); + const { code: storeCountryCode } = useStoreCountry(); if ( ! ( targetAudience && settings && shippingRates && shippingTimes ) ) { return ; @@ -101,13 +103,12 @@ const SetupFreeListings = ( { const countries = resolveFinalCountries( values ); const { shipping_country_times: shippingTimesData } = values; - return checkErrors( values, shippingTimesData, countries ); - }; - - const handleSubmit = async () => { - setSaving( true ); - await onContinue(); - setSaving( false ); + return checkErrors( + values, + shippingTimesData, + countries, + storeCountryCode + ); }; const handleChange = ( change, values ) => { @@ -182,6 +183,22 @@ const SetupFreeListings = ( { } }; + const extendAdapter = ( formContext ) => { + return { + audienceCountries: resolveFinalCountries( formContext.values ), + renderRequestedValidation( key ) { + if ( formContext.adapter.requestedShowValidation ) { + return ( + + ); + } + return null; + }, + }; + }; + return (
@@ -210,22 +227,12 @@ const SetupFreeListings = ( { shipping_country_rates: shippingRates, shipping_country_times: shippingTimes, } } + extendAdapter={ extendAdapter } onChange={ handleChange } validate={ handleValidate } - onSubmit={ handleSubmit } + onSubmit={ onContinue } > - { ( formProps ) => { - const countries = resolveFinalCountries( formProps.values ); - - return ( - - ); - } } +
); diff --git a/js/src/components/paid-ads/asset-group/asset-group-card.js b/js/src/components/paid-ads/asset-group/asset-group-card.js index a4723d9d02..486e5667c0 100644 --- a/js/src/components/paid-ads/asset-group/asset-group-card.js +++ b/js/src/components/paid-ads/asset-group/asset-group-card.js @@ -10,6 +10,7 @@ import { SelectControl } from '@wordpress/components'; */ import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import AppInputControl from '.~/components/app-input-control'; +import ValidationErrors from '.~/components/validation-errors'; import ImagesSelector from './images-selector'; import TextsEditor from './texts-editor'; import AssetField from './asset-field'; @@ -79,13 +80,7 @@ export default function AssetGroupCard() { return null; } - return ( -
    - { [ assetGroupErrors[ key ] ].flat().map( ( message ) => ( -
  • { message }
  • - ) ) } -
- ); + return ; } function refFirstErrorField( ref ) { diff --git a/js/src/components/paid-ads/asset-group/asset-group-card.scss b/js/src/components/paid-ads/asset-group/asset-group-card.scss index 9a7e41c9b7..bd1bd6eb46 100644 --- a/js/src/components/paid-ads/asset-group/asset-group-card.scss +++ b/js/src/components/paid-ads/asset-group/asset-group-card.scss @@ -3,27 +3,8 @@ margin-left: $grid-unit-05; } - &__error-list { - width: 100%; - margin: 0; - line-height: $gla-line-height-smaller; - font-size: $gla-font-smaller; - color: $alert-red; - - &:not(:last-child) { - margin-bottom: $grid-unit-20; - } - - > li { - list-style: disc inside; - margin: 0; - padding-left: 0.5em; - - &:only-child { - list-style: none; - padding-left: 0; - } - } + .gla-validation-errors { + margin-top: 0; } } diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js index 9cf587e2e4..b1578c2023 100644 --- a/js/src/components/paid-ads/campaign-assets-form.js +++ b/js/src/components/paid-ads/campaign-assets-form.js @@ -76,7 +76,6 @@ export default function CampaignAssetsForm( { }, [ assetEntityGroup ] ); const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup ); - const [ validationRequestCount, setValidationRequestCount ] = useState( 0 ); const [ hasImportedAssets, setHasImportedAssets ] = useState( false ); const extendAdapter = ( formContext ) => { @@ -89,7 +88,6 @@ export default function CampaignAssetsForm( { // provide different special business logic. isEmptyAssetEntityGroup: ! finalUrl, baseAssetGroup, - validationRequestCount, assetGroupErrors, /* In order to show a Tip in the UI when assets are imported we created the hasImportedAssets @@ -111,10 +109,7 @@ export default function CampaignAssetsForm( { setHasImportedAssets( hasNonEmptyAssets ); setBaseAssetGroup( nextAssetGroup ); - setValidationRequestCount( 0 ); - }, - showValidation() { - setValidationRequestCount( validationRequestCount + 1 ); + formContext.adapter.hideValidation(); }, }; }; diff --git a/js/src/components/paid-ads/campaign-assets-form.test.js b/js/src/components/paid-ads/campaign-assets-form.test.js new file mode 100644 index 0000000000..19339b09b7 --- /dev/null +++ b/js/src/components/paid-ads/campaign-assets-form.test.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import CampaignAssetsForm from './campaign-assets-form'; + +const alwaysValid = () => ( {} ); + +describe( 'CampaignAssetsForm', () => { + it( 'Should extend adapter to meet the required states or functions of assets form', () => { + const children = jest.fn(); + + render( + + { children } + + ); + + const formContextSchema = expect.objectContaining( { + adapter: expect.objectContaining( { + assetGroupErrors: expect.any( Object ), + baseAssetGroup: expect.any( Object ), + hasImportedAssets: false, + isEmptyAssetEntityGroup: true, + isValidAssetGroup: expect.any( Boolean ), + resetAssetGroup: expect.any( Function ), + showValidation: expect.any( Function ), + validationRequestCount: 0, + } ), + } ); + + expect( children ).toHaveBeenLastCalledWith( formContextSchema ); + } ); + + it( 'Should be able to accumulate and reset the validation request count', async () => { + const inspect = jest.fn(); + + render( + + { ( { adapter } ) => { + inspect( adapter.validationRequestCount ); + + return ( + <> + + + + + ); + } } + + ); + + const requestButton = screen.getByRole( 'button', { name: 'request' } ); + const resetButton = screen.getByRole( 'button', { name: 'reset' } ); + + expect( inspect ).toHaveBeenLastCalledWith( 0 ); + + await userEvent.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( 1 ); + + await userEvent.click( requestButton ); + + expect( inspect ).toHaveBeenLastCalledWith( 2 ); + + await userEvent.click( resetButton ); + + expect( inspect ).toHaveBeenLastCalledWith( 0 ); + } ); +} ); diff --git a/js/src/components/pre-launch-check-item/index.js b/js/src/components/pre-launch-check-item/index.js index 15b5b94841..e4fde4de0d 100644 --- a/js/src/components/pre-launch-check-item/index.js +++ b/js/src/components/pre-launch-check-item/index.js @@ -10,6 +10,7 @@ import { useRef } from '@wordpress/element'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import AppButton from '.~/components/app-button'; import './index.scss'; @@ -21,13 +22,12 @@ const getPanelToggleHandler = ( id ) => ( isOpened ) => { }; export default function PreLaunchCheckItem( { - formProps, fieldName, firstPersonTitle, secondPersonTitle, children, } ) { - const { getInputProps, setValue, values } = formProps; + const { getInputProps, setValue, values } = useAdaptiveFormContext(); const checked = values[ fieldName ]; const initialCheckedRef = useRef( checked ); 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 657f44cb09..59730eadb8 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 @@ -29,11 +29,13 @@ import getHandlers from './getHandlers'; * @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 {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. */ export default function EstimatedShippingRatesCard( { audienceCountries, value, + helper, onChange, } ) { const { code: currencyCode } = useStoreCurrency(); @@ -141,6 +143,7 @@ export default function EstimatedShippingRatesCard( { { renderGroups() } + { helper } ); 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 bc5bcfe5bc..f469fa04b9 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 @@ -2,31 +2,41 @@ * Internal dependencies */ import isNonFreeShippingRate from '.~/utils/isNonFreeShippingRate'; +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import EstimatedShippingRatesCard from './estimated-shipping-rates-card'; import OfferFreeShippingCard from './offer-free-shipping-card'; import MinimumOrderCard from './minimum-order-card'; -const FlatShippingRatesInputCards = ( props ) => { - const { audienceCountries, formProps } = props; - const { getInputProps, values } = formProps; +const FlatShippingRatesInputCards = () => { + const { getInputProps, values, adapter } = useAdaptiveFormContext(); const displayFreeShippingCards = values.shipping_country_rates.some( isNonFreeShippingRate ); + function getCardProps( key, validationKey = key ) { + return { + ...getInputProps( key ), + helper: adapter.renderRequestedValidation( validationKey ), + }; + } + return ( <> { displayFreeShippingCards && ( <> { values.offer_free_shipping && ( ) } diff --git a/js/src/components/shipping-rate-section/minimum-order-card/minimum-order-card.js b/js/src/components/shipping-rate-section/minimum-order-card/minimum-order-card.js index 06ba0a97a6..466ed46d6d 100644 --- a/js/src/components/shipping-rate-section/minimum-order-card/minimum-order-card.js +++ b/js/src/components/shipping-rate-section/minimum-order-card/minimum-order-card.js @@ -5,8 +5,7 @@ import { __ } from '@wordpress/i18n'; import GridiconPlusSmall from 'gridicons/dist/plus-small'; /** - * @typedef { import(".~/data/actions").CountryCode } CountryCode - * @typedef { import("./typedefs").MinimumOrderGroup } MinimumOrderGroup + * @typedef { import(".~/data/actions").ShippingRate } ShippingRate */ /** @@ -23,9 +22,15 @@ import groupShippingRatesByCurrencyFreeShippingThreshold from './groupShippingRa import { calculateValueFromGroupChange } from './calculateValueFromGroupChange'; import './minimum-order-card.scss'; -const MinimumOrderCard = ( props ) => { - const { value = [], onChange } = props; - +/** + * Renders a Card UI to provide the free shipping threshold for individual 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 {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. + */ +const MinimumOrderCard = ( { value = [], helper, onChange } ) => { const renderGroups = () => { const nonZeroShippingRates = value.filter( isNonFreeShippingRate ); const groups = groupShippingRatesByCurrencyFreeShippingThreshold( @@ -133,6 +138,7 @@ const MinimumOrderCard = ( props ) => { { renderGroups() } + { helper } ); diff --git a/js/src/components/shipping-rate-section/offer-free-shipping-card.js b/js/src/components/shipping-rate-section/offer-free-shipping-card.js index 25b13e0bda..cfd1f8b53c 100644 --- a/js/src/components/shipping-rate-section/offer-free-shipping-card.js +++ b/js/src/components/shipping-rate-section/offer-free-shipping-card.js @@ -10,9 +10,16 @@ import Section from '.~/wcdl/section'; import AppRadioContentControl from '.~/components/app-radio-content-control'; import VerticalGapLayout from '.~/components/vertical-gap-layout'; -const OfferFreeShippingCard = ( props ) => { - const { value, onChange } = props; - +/** + * Renders a Card UI with options to choose whether offer free shipping + * for orders over a certain price. + * + * @param {Object} props React props. + * @param {boolean} props.value The value of whether offer free shipping. + * @param {JSX.Element} [props.helper] Helper content to be rendered at the bottom of the card body. + * @param {(nextValue: boolean) => void} props.onChange Callback called with the next value of the selected option. + */ +const OfferFreeShippingCard = ( { value, helper, onChange } ) => { const handleChange = ( newValue ) => { onChange( newValue === 'yes' ); }; @@ -40,6 +47,7 @@ const OfferFreeShippingCard = ( props ) => { onChange={ handleChange } /> + { helper } ); 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 7412879fb0..3919aad820 100644 --- a/js/src/components/shipping-rate-section/shipping-rate-section.js +++ b/js/src/components/shipping-rate-section/shipping-rate-section.js @@ -7,6 +7,7 @@ import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import Section from '.~/wcdl/section'; import AppRadioContentControl from '.~/components/app-radio-content-control'; import RadioHelperText from '.~/wcdl/radio-helper-text'; @@ -19,8 +20,8 @@ import FlatShippingRatesInputCards from './flat-shipping-rates-input-cards'; * @fires gla_documentation_link_click with `{ context: 'setup-mc-shipping', link_id: 'shipping-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` */ -const ShippingRateSection = ( { formProps, audienceCountries } ) => { - const { getInputProps, values } = formProps; +const ShippingRateSection = () => { + const { getInputProps, values } = useAdaptiveFormContext(); const inputProps = getInputProps( 'shipping_rate' ); return ( @@ -111,10 +112,7 @@ const ShippingRateSection = ( { formProps, audienceCountries } ) => { { values.shipping_rate === 'flat' && ( - + ) } diff --git a/js/src/components/validation-errors/index.js b/js/src/components/validation-errors/index.js new file mode 100644 index 0000000000..e471d521a3 --- /dev/null +++ b/js/src/components/validation-errors/index.js @@ -0,0 +1 @@ +export { default } from './validation-errors'; diff --git a/js/src/components/validation-errors/validation-errors.js b/js/src/components/validation-errors/validation-errors.js new file mode 100644 index 0000000000..7ff55f2e3a --- /dev/null +++ b/js/src/components/validation-errors/validation-errors.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import './validation-errors.scss'; + +/** + * Renders form validation error messages. + * + * @param {Object} props React props + * @param {string|string[]} [props.messages] Validation error message(s). + */ +export default function ValidationErrors( { messages } ) { + let messagesList = messages; + + if ( ! messages?.length ) { + return null; + } + + if ( ! Array.isArray( messages ) ) { + messagesList = [ messages ]; + } + + return ( +
    + { messagesList.map( ( message ) => ( +
  • { message }
  • + ) ) } +
+ ); +} diff --git a/js/src/components/validation-errors/validation-errors.scss b/js/src/components/validation-errors/validation-errors.scss new file mode 100644 index 0000000000..ee53622ba8 --- /dev/null +++ b/js/src/components/validation-errors/validation-errors.scss @@ -0,0 +1,26 @@ +.gla-validation-errors { + width: 100%; + margin: 0; + line-height: $gla-line-height-smaller; + font-size: $gla-font-smaller; + color: $alert-red; + + &:not(:first-child) { + margin-top: $grid-unit-20; + } + + &:not(:last-child) { + margin-bottom: $grid-unit-20; + } + + > li { + list-style: disc inside; + margin: 0; + padding-left: 0.5em; + + &:only-child { + list-style: none; + padding-left: 0; + } + } +} diff --git a/js/src/components/validation-errors/validation-errors.test.js b/js/src/components/validation-errors/validation-errors.test.js new file mode 100644 index 0000000000..15fa95c898 --- /dev/null +++ b/js/src/components/validation-errors/validation-errors.test.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import { screen, render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import ValidationErrors from './validation-errors'; + +describe( 'ValidationErrors', () => { + it.each( [ undefined, null, '', [] ] )( + 'Should be empty DOM element when `messages` is `%s`', + ( messages ) => { + const { container } = render( + + ); + expect( container ).toBeEmptyDOMElement(); + } + ); + + it( 'Should support passing a string to render a message', () => { + render( ); + + const list = screen.getByRole( 'list' ); + const item = screen.getByRole( 'listitem' ); + + expect( list ).toBeInTheDocument(); + expect( item ).toBeInTheDocument(); + expect( item ).toHaveTextContent( 'amount is required' ); + } ); + + it( 'Should support passing an array to render a message', () => { + render( ); + + const list = screen.getByRole( 'list' ); + const item = screen.getByRole( 'listitem' ); + + expect( list ).toBeInTheDocument(); + expect( item ).toBeInTheDocument(); + expect( item ).toHaveTextContent( 'amount is required' ); + } ); + + it( 'Should support passing an array to render multiple messages', () => { + const messages = [ + 'amount should be greater than 0', + 'amount should be an integer', + ]; + + render( ); + + const list = screen.getByRole( 'list' ); + const items = screen.getAllByRole( 'listitem' ); + + expect( list ).toBeInTheDocument(); + expect( items.length ).toBe( messages.length ); + + messages.forEach( ( message, i ) => { + expect( items[ i ] ).toBeInTheDocument(); + expect( items[ i ] ).toHaveTextContent( message ); + } ); + } ); +} ); diff --git a/js/src/gtag-events/index.js b/js/src/gtag-events/index.js index e40c025e81..c18cf74e39 100644 --- a/js/src/gtag-events/index.js +++ b/js/src/gtag-events/index.js @@ -75,6 +75,23 @@ document.defaultView.addEventListener( 'DOMContentLoaded', function () { button.addEventListener( 'click', addToCartClick ); } ); + /** + * Fix for Products (Beta) block + * + * Products (Beta) block doesn't trigger addAction events. Also it's not being queried by the previous query selector + * because we added :not( .wc-block-components-product-button__button ) to prevent tracking duplicates with + * other blocks that yes trigger addAction events. + * + * So the fix is to query again specifically the add to cart button in Products (Beta) block + */ + document + .querySelectorAll( + '[data-block-name="woocommerce/product-button"] > .add_to_cart_button:not( .product_type_variable ):not( .product_type_grouped )' + ) + .forEach( ( button ) => { + button.addEventListener( 'click', addToCartClick ); + } ); + document .querySelectorAll( '.single_add_to_cart_button' ) .forEach( ( button ) => { diff --git a/js/src/hooks/types.js b/js/src/hooks/types.js index edd24b4071..44a39e4f42 100644 --- a/js/src/hooks/types.js +++ b/js/src/hooks/types.js @@ -8,5 +8,20 @@ * @property {string} alt Alternate text. */ +/** + * @typedef {Object} StoreAddress + * @property {string} address Store address line 1. + * @property {string} address2 Address line 2. + * @property {string} city Store city. + * @property {string} state Store country state if available. + * @property {string} country Store country. + * @property {string} postcode Store postcode. + * @property {boolean|null} isAddressFilled Whether the minimum address data is filled in. + * `null` if data have not loaded yet. + * @property {boolean|null} isMCAddressDifferent Whether the address data from WC store and GMC are the same. + * `null` if data have not loaded yet. + * @property {string[]} missingRequiredFields The missing required fields of the store address. + */ + // This export is required for JSDoc in other files to import the type definitions from this file. export default {}; diff --git a/js/src/hooks/useStoreAddress.js b/js/src/hooks/useStoreAddress.js index ed9a5cdf67..8a332770ba 100644 --- a/js/src/hooks/useStoreAddress.js +++ b/js/src/hooks/useStoreAddress.js @@ -4,6 +4,10 @@ import useAppSelectDispatch from './useAppSelectDispatch'; import useCountryKeyNameMap from './useCountryKeyNameMap'; +/** + * @typedef {import('.~/hooks/types.js').StoreAddress} StoreAddress + */ + const emptyData = { address: '', address2: '', @@ -13,27 +17,16 @@ const emptyData = { postcode: '', isMCAddressDifferent: null, isAddressFilled: null, + missingRequiredFields: [], }; -/** - * @typedef {Object} StoreAddress - * @property {string} address Store address line 1. - * @property {string} address2 Address line 2. - * @property {string} city Store city. - * @property {string} state Store country state if available. - * @property {string} country Store country. - * @property {string} postcode Store postcode. - * @property {boolean|null} isAddressFilled Whether the minimum address data is filled in. - * `null` if data have not loaded yet. - * @property {boolean|null} isMCAddressDifferent Whether the address data from WC store and GMC are the same. - * `null` if data have not loaded yet. - */ /** * @typedef {Object} StoreAddressResult * @property {Function} refetch Dispatch a refetch action to reload store address. * @property {boolean} loaded Whether the data have been loaded. * @property {StoreAddress} data Store address data. */ + /** * Get store address data and refectch function. * @@ -54,7 +47,11 @@ export default function useStoreAddress( source = 'wc' ) { let data = emptyData; if ( loaded && contact ) { - const { is_mc_address_different: isMCAddressDifferent } = contact; + const { + is_mc_address_different: isMCAddressDifferent, + wc_address_errors: missingRequiredFields, + } = contact; + const storeAddress = source === 'wc' ? contact.wc_address : contact.mc_address; @@ -65,10 +62,12 @@ export default function useStoreAddress( source = 'wc' ) { const postcode = storeAddress?.postal_code || ''; const [ address, address2 = '' ] = streetAddress.split( '\n' ); - const country = countryNameDict[ storeAddress?.country ]; - const isAddressFilled = ! contact.wc_address_errors.length; + const country = countryNameDict[ storeAddress?.country ] || ''; + const countryCode = storeAddress?.country || ''; + const isAddressFilled = ! missingRequiredFields.length; data = { + countryCode, address, address2, city, @@ -77,6 +76,7 @@ export default function useStoreAddress( source = 'wc' ) { postcode, isAddressFilled, isMCAddressDifferent, + missingRequiredFields, }; } diff --git a/js/src/settings/edit-store-address.js b/js/src/settings/edit-store-address.js index 7c4553007c..d34068b474 100644 --- a/js/src/settings/edit-store-address.js +++ b/js/src/settings/edit-store-address.js @@ -90,7 +90,9 @@ const EditStoreAddress = () => {
} > - +
diff --git a/js/src/setup-mc/setup-stepper/store-requirements/index.js b/js/src/setup-mc/setup-stepper/store-requirements/index.js index f452c48d09..0c5261fff0 100644 --- a/js/src/setup-mc/setup-stepper/store-requirements/index.js +++ b/js/src/setup-mc/setup-stepper/store-requirements/index.js @@ -3,7 +3,6 @@ */ import { __ } from '@wordpress/i18n'; import { useState, useEffect } from '@wordpress/element'; -import { Form } from '@woocommerce/components'; import { isEqual } from 'lodash'; /** @@ -16,6 +15,8 @@ import useDispatchCoreNotices from '.~/hooks/useDispatchCoreNotices'; import StepContent from '.~/components/stepper/step-content'; import StepContentHeader from '.~/components/stepper/step-content-header'; import StepContentFooter from '.~/components/stepper/step-content-footer'; +import AdaptiveForm from '.~/components/adaptive-form'; +import ValidationErrors from '.~/components/validation-errors'; import ContactInformation from '.~/components/contact-information'; import AppButton from '.~/components/app-button'; import AppSpinner from '.~/components/app-spinner'; @@ -43,7 +44,6 @@ export default function StoreRequirements( { onContinue } ) { const [ isPhoneNumberReady, setPhoneNumberReady ] = useState( false ); const [ settingsSaved, setSettingsSaved ] = useState( true ); const [ preprocessed, setPreprocessed ] = useState( false ); - const [ completing, setCompleting ] = useState( false ); const handleChangeCallback = async ( _, values ) => { try { @@ -66,13 +66,9 @@ export default function StoreRequirements( { onContinue } ) { const handleSubmitCallback = async () => { try { - setCompleting( true ); - await updateGoogleMCContactInformation(); onContinue(); } catch ( error ) { - setCompleting( false ); - createNotice( 'error', __( @@ -128,6 +124,21 @@ export default function StoreRequirements( { onContinue } ) { return ; } + const extendAdapter = ( formContext ) => { + return { + renderRequestedValidation( key ) { + if ( formContext.adapter.requestedShowValidation ) { + return ( + + ); + } + return null; + }, + }; + }; + return ( -
- { ( formProps ) => { - const { handleSubmit, isValidForm } = formProps; + { ( formContext ) => { + const { handleSubmit, isValidForm, adapter } = formContext; + + const handleSubmitClick = ( event ) => { + const isReadyToComplete = + isValidForm && + isPhoneNumberReady && + address.isAddressFilled; + + if ( isReadyToComplete ) { + return handleSubmit( event ); + } - const isReadyToComplete = - isValidForm && - isPhoneNumberReady && - address.isAddressFilled && - settingsSaved; + adapter.showValidation(); + }; return ( <> @@ -168,13 +187,13 @@ export default function StoreRequirements( { onContinue } ) { setPhoneNumberReady( true ) } /> - + { __( 'Continue', @@ -185,7 +204,7 @@ export default function StoreRequirements( { onContinue } ) { ); } } - +
); } diff --git a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.js b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.js index 1d2b7bb124..67fd4f6a3f 100644 --- a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.js +++ b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.js @@ -6,40 +6,17 @@ import { __ } from '@wordpress/i18n'; export default function checkErrors( values ) { const errors = {}; - /** - * Pre-launch checklist. - */ - if ( values.website_live !== true ) { - errors.website_live = __( - 'Please check the requirement.', - 'google-listings-and-ads' - ); - } - - if ( values.checkout_process_secure !== true ) { - errors.checkout_process_secure = __( - 'Please check the requirement.', - 'google-listings-and-ads' - ); - } - - if ( values.payment_methods_visible !== true ) { - errors.payment_methods_visible = __( - 'Please check the requirement.', - 'google-listings-and-ads' - ); - } - - if ( values.refund_tos_visible !== true ) { - errors.refund_tos_visible = __( - 'Please check the requirement.', - 'google-listings-and-ads' - ); - } + const preLaunchFields = [ + 'website_live', + 'checkout_process_secure', + 'payment_methods_visible', + 'refund_tos_visible', + 'contact_info_visible', + ]; - if ( values.contact_info_visible !== true ) { - errors.contact_info_visible = __( - 'Please check the requirement.', + if ( preLaunchFields.some( ( field ) => values[ field ] !== true ) ) { + errors.preLaunchChecklist = __( + 'Please check all requirements.', 'google-listings-and-ads' ); } diff --git a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.test.js b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.test.js index 81997ef6c4..b01b8c0213 100644 --- a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.test.js +++ b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/checkErrors.test.js @@ -8,58 +8,38 @@ import checkErrors from './checkErrors'; * set properties respectively with an error message to indicate it. */ describe( 'checkErrors', () => { - describe( 'Should check the presence of required properties in the given object. Returned object should have error messages for respective properties.', () => { - for ( const property of [ - 'website_live', - 'checkout_process_secure', - 'payment_methods_visible', - 'refund_tos_visible', - 'contact_info_visible', - ] ) { - it( `${ property } === true`, () => { - expect( checkErrors( {} ) ).toHaveProperty( - property, - 'Please check the requirement.' - ); + const preLaunchFields = [ + 'website_live', + 'checkout_process_secure', + 'payment_methods_visible', + 'refund_tos_visible', + 'contact_info_visible', + ]; - expect( checkErrors( { [ property ]: 'foo' } ) ).toHaveProperty( - property, - 'Please check the requirement.' - ); + let values; - expect( - checkErrors( { [ property ]: true } ) - ).not.toHaveProperty( property ); - } ); - } - it( 'When there are many missing/invalid properties, should report them all.', () => { - const values = { - website_live: false, - payment_methods_visible: 'true', - refund_tos_visible: [], - contact_info_visible: {}, - }; - - const errorMessage = 'Please check the requirement.'; - expect( checkErrors( values ) ).toEqual( { - website_live: errorMessage, - checkout_process_secure: errorMessage, - payment_methods_visible: errorMessage, - refund_tos_visible: errorMessage, - contact_info_visible: errorMessage, - } ); + beforeEach( () => { + values = {}; + preLaunchFields.forEach( ( field ) => { + values[ field ] = true; } ); + } ); + describe( 'Should check the presence of required properties in the given object. Returned object should have error messages for respective properties.', () => { it( 'When all properties are valid, should return an empty object.', () => { - const values = { - website_live: true, - checkout_process_secure: true, - payment_methods_visible: true, - refund_tos_visible: true, - contact_info_visible: true, - }; - expect( checkErrors( values ) ).toEqual( {} ); } ); + + it.each( preLaunchFields )( + 'When %s !== true, should have the error message for `preLaunchChecklist` property', + ( field ) => { + values[ field ] = false; + + expect( checkErrors( values ) ).toHaveProperty( + 'preLaunchChecklist', + 'Please check all requirements.' + ); + } + ); } ); } ); diff --git a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/index.js b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/index.js index 765d990591..0ff1dbd746 100644 --- a/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/index.js +++ b/js/src/setup-mc/setup-stepper/store-requirements/pre-launch-checklist/index.js @@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ +import { useAdaptiveFormContext } from '.~/components/adaptive-form'; import AppDocumentationLink from '.~/components/app-documentation-link'; import PreLaunchCheckItem from '.~/components/pre-launch-check-item'; import Section from '.~/wcdl/section'; @@ -14,8 +15,10 @@ import VerticalGapLayout from '.~/components/vertical-gap-layout'; /* * @fires gla_documentation_link_click with `{ context: 'setup-mc-checklist', link_id: 'checklist-requirements', href: 'https://support.google.com/merchants/answer/6363310' }` */ -const PreLaunchChecklist = ( props ) => { - const { formProps } = props; +const PreLaunchChecklist = () => { + const { + adapter: { renderRequestedValidation }, + } = useAdaptiveFormContext(); return (
@@ -51,7 +54,6 @@ const PreLaunchChecklist = ( props ) => { { { { { { ) } + { renderRequestedValidation( 'preLaunchChecklist' ) }
diff --git a/package-lock.json b/package-lock.json index cf63cd5888..ae7811bc1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "google-listings-and-ads", - "version": "2.5.0", + "version": "2.5.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4328,6 +4328,21 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "requires": { + "debug": "^4.1.1" + } + }, + "@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true + }, "@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -4665,6 +4680,12 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "dev": true }, + "@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true + }, "@sinonjs/commons": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.5.tgz", @@ -10297,6 +10318,15 @@ "@svgr/plugin-svgo": "^6.5.1" } }, + "@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "requires": { + "defer-to-connect": "^2.0.0" + } + }, "@tannin/compile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.1.0.tgz", @@ -10572,6 +10602,18 @@ "@types/node": "*" } }, + "@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "@types/cheerio": { "version": "0.22.31", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.31.tgz", @@ -10688,6 +10730,12 @@ "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", "dev": true }, + "@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==", + "dev": true + }, "@types/http-proxy": { "version": "1.17.9", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", @@ -10759,6 +10807,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/linkify-it": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", @@ -10933,6 +10990,15 @@ "@types/react": "^17" } }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -12651,6 +12717,164 @@ "react-dom": "^17.0.2" } }, + "@wordpress/env": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-8.4.0.tgz", + "integrity": "sha512-3DnTW/WanvQpWqagCIMQYtSBYj9wXLQQqGgmKl8gVJ4MfxV3K5A9zE25Rv6iogdr7ydLcPSbmNJx6mYgQRaSog==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "copy-dir": "^1.3.0", + "docker-compose": "^0.22.2", + "extract-zip": "^1.6.7", + "got": "^11.8.5", + "inquirer": "^7.1.0", + "js-yaml": "^3.13.1", + "ora": "^4.0.2", + "rimraf": "^3.0.2", + "simple-git": "^3.5.0", + "terminal-link": "^2.0.0", + "yargs": "^17.3.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, "@wordpress/escape-html": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-2.22.0.tgz", @@ -17442,6 +17666,38 @@ "unset-value": "^1.0.0" } }, + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true + }, + "cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -17884,6 +18140,12 @@ "restore-cursor": "^3.1.0" } }, + "cli-spinners": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", + "integrity": "sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==", + "dev": true + }, "cli-table3": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", @@ -17998,6 +18260,15 @@ } } }, + "clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, "clsx": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.0.tgz", @@ -18326,6 +18597,12 @@ "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", "dev": true }, + "copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true + }, "copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -19115,6 +19392,23 @@ "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "dev": true }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + }, + "dependencies": { + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + } + } + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -19473,6 +19767,12 @@ "clone": "^1.0.2" } }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -19816,6 +20116,12 @@ "@leichtgewicht/ip-codec": "^2.0.1" } }, + "docker-compose": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.22.2.tgz", + "integrity": "sha512-iXWb5+LiYmylIMFXvGTYsjI1F+Xyx78Jm/uj1dxwwZLbWkUdH6yOXY5Nr3RjbYX15EgbGJCq78d29CmWQQQMPg==", + "dev": true + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -22925,6 +23231,25 @@ "get-intrinsic": "^1.1.3" } }, + "got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + } + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -23439,6 +23764,12 @@ "entities": "^4.3.0" } }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, "http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -23547,6 +23878,24 @@ "sshpk": "^1.7.0" } }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "dependencies": { + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + } + } + }, "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -24103,6 +24452,12 @@ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", "dev": true }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true + }, "is-map": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", @@ -27889,6 +28244,12 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -27994,6 +28355,15 @@ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", "dev": true }, + "keyv": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -28426,6 +28796,12 @@ "tslib": "^2.0.3" } }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true + }, "lru": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lru/-/lru-3.1.0.tgz", @@ -29095,6 +29471,12 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true + }, "min-document": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", @@ -30087,6 +30469,155 @@ "word-wrap": "~1.2.3" } }, + "ora": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", + "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -30114,6 +30645,12 @@ "p-map": "^2.0.0" } }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true + }, "p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", @@ -32432,6 +32969,12 @@ "supports-preserve-symlinks-flag": "^1.0.0" } }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, "resolve-bin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/resolve-bin/-/resolve-bin-0.4.3.tgz", @@ -32485,6 +33028,15 @@ "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", "dev": true }, + "responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "requires": { + "lowercase-keys": "^2.0.0" + } + }, "restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -33120,6 +33672,17 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "simple-git": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.1.tgz", + "integrity": "sha512-Ck+rcjVaE1HotraRAS8u/+xgTvToTuoMkT9/l9lvuP5jftwnYUp6DwuJzsKErHgfyRk8IB8pqGHWEbM3tLgV1w==", + "dev": true, + "requires": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + } + }, "simple-html-tokenizer": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/simple-html-tokenizer/-/simple-html-tokenizer-0.5.11.tgz", @@ -36704,9 +37267,9 @@ "dev": true }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true }, "wordwrap": { diff --git a/package.json b/package.json index e2e6e66f23..be0b563de6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "google-listings-and-ads", "title": "Google Listings and Ads", - "version": "2.5.0", + "version": "2.5.1", "description": "google-listings-and-ads", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -62,6 +62,7 @@ "@woocommerce/e2e-utils": "^0.2.0", "@woocommerce/eslint-plugin": "^1.2.0", "@wordpress/dependency-extraction-webpack-plugin": "^4.4.0", + "@wordpress/env": "^8.4.0", "@wordpress/prettier-config": "^1.1.2", "@wordpress/scripts": "^24.6.0", "bundlewatch": "^0.3.3", @@ -113,7 +114,8 @@ "test:e2e-dev": "npx wc-e2e test:e2e-dev", "test:js": "wp-scripts test-unit-js --coverage", "test:js:watch": "npm run test:js -- --watch", - "test-proxy": "node ./tests/proxy" + "test-proxy": "node ./tests/proxy", + "wp-env": "wp-env" }, "config": { "wp_org_slug": "google-listings-and-ads", diff --git a/readme.txt b/readme.txt index 6ce8a408dc..04eb8090a3 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Requires at least: 5.9 Tested up to: 6.2 Requires PHP: 7.4 Requires PHP Architecture: 64 Bits -Stable tag: 2.5.0 +Stable tag: 2.5.1 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -111,6 +111,14 @@ Yes, you can run both at the same time, and we recommend it! In the US, advertis == Changelog == += 2.5.1 - 2023-08-01 = +* Dev - Setup wp-env for E2E tests. +* Dev - automate merging trunk to develop after a release. +* Fix - Fix support for "add_to_cart" event in Products (Beta) block. +* Fix - Prevent PHP 8.2 deprecation messages. +* Tweak - Ability to filter products for syncing via `gla_filter_product_query_args` apply_filters hook. +* Update - Show validation errors on steps 2 and 3 of the onboarding flow when unable to continue. + = 2.5.0 - 2023-07-18 = * Tweak - Add Tip with information with Campaign assets are imported. * Tweak - Provide more detailed error reasons when unable to complete site verification for the Google Merchant Center account being connected in the onboarding flow. @@ -130,7 +138,4 @@ Yes, you can run both at the same time, and we recommend it! In the US, advertis * Tweak - Make the error message clearer for errors that occur in getting or updating a Google Merchant Center account. * Tweak - WC 7.9 compatibility. -= 2.4.10 - 2023-06-13 = -* Tweak - WC 7.8 compatibility. - [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/google-listings-and-ads/trunk/changelog.txt). diff --git a/src/Admin/MetaBox/ChannelVisibilityMetaBox.php b/src/Admin/MetaBox/ChannelVisibilityMetaBox.php index 70239c20b4..8cdf974fca 100644 --- a/src/Admin/MetaBox/ChannelVisibilityMetaBox.php +++ b/src/Admin/MetaBox/ChannelVisibilityMetaBox.php @@ -105,14 +105,14 @@ public function get_context(): string { public function get_classes(): array { $shown_types = array_map( function ( string $product_type ) { - return "show_if_${product_type}"; + return "show_if_{$product_type}"; }, ProductSyncer::get_supported_product_types() ); $hidden_types = array_map( function ( string $product_type ) { - return "hide_if_${product_type}"; + return "hide_if_{$product_type}"; }, ProductSyncer::get_hidden_product_types() ); diff --git a/src/Admin/MetaBox/CouponChannelVisibilityMetaBox.php b/src/Admin/MetaBox/CouponChannelVisibilityMetaBox.php index 9e29fa8c73..917b4aa609 100644 --- a/src/Admin/MetaBox/CouponChannelVisibilityMetaBox.php +++ b/src/Admin/MetaBox/CouponChannelVisibilityMetaBox.php @@ -110,14 +110,14 @@ public function get_context(): string { public function get_classes(): array { $shown_types = array_map( function ( string $coupon_type ) { - return "show_if_${coupon_type}"; + return "show_if_{$coupon_type}"; }, CouponSyncer::get_supported_coupon_types() ); $hidden_types = array_map( function ( string $coupon_type ) { - return "hide_if_${coupon_type}"; + return "hide_if_{$coupon_type}"; }, CouponSyncer::get_hidden_coupon_types() ); diff --git a/src/Admin/Product/Attributes/AttributesTab.php b/src/Admin/Product/Attributes/AttributesTab.php index 70b5b6f59b..60db105323 100644 --- a/src/Admin/Product/Attributes/AttributesTab.php +++ b/src/Admin/Product/Attributes/AttributesTab.php @@ -102,14 +102,14 @@ function () { private function add_tab( array $tabs ): array { $shown_types = array_map( function ( string $product_type ) { - return "show_if_${product_type}"; + return "show_if_{$product_type}"; }, $this->get_applicable_product_types() ); $hidden_types = array_map( function ( string $product_type ) { - return "hide_if_${product_type}"; + return "hide_if_{$product_type}"; }, ProductSyncer::get_hidden_product_types() ); diff --git a/src/Product/ProductRepository.php b/src/Product/ProductRepository.php index 631f63104b..2add3bb281 100644 --- a/src/Product/ProductRepository.php +++ b/src/Product/ProductRepository.php @@ -357,7 +357,8 @@ protected function prepare_query_args( array $args = [] ): array { $args['orderby'] = 'none'; } + $args = apply_filters( 'woocommerce_gla_product_query_args', $args ); + return $args; } - } diff --git a/src/Proxies/WC.php b/src/Proxies/WC.php index 80fe87578b..1406942364 100644 --- a/src/Proxies/WC.php +++ b/src/Proxies/WC.php @@ -34,6 +34,13 @@ class WC { */ protected $countries; + /** + * List of countries the WC store sells to. + * + * @var array + */ + protected $allowed_countries; + /** @var WC_Countries */ protected $wc_countries; diff --git a/src/Tracking/README.md b/src/Tracking/README.md index 472a2ccafa..ef3a0d39fa 100644 --- a/src/Tracking/README.md +++ b/src/Tracking/README.md @@ -243,7 +243,7 @@ When a documentation link is clicked. `href` | `string` | link's URL #### Emitters - [`AppDocumentationLink`](../../js/src/components/app-documentation-link/index.js#L29) -- [`ContactInformation`](../../js/src/components/contact-information/index.js#L90) +- [`ContactInformation`](../../js/src/components/contact-information/index.js#L91) - with `{ context: 'setup-mc-contact-information', link_id: 'contact-information-read-more', href: 'https://docs.woocommerce.com/document/google-listings-and-ads/#contact-information' }` - with `{ context: 'settings-no-phone-number-notice', link_id: 'contact-information-read-more', href: 'https://docs.woocommerce.com/document/google-listings-and-ads/#contact-information' }` - with `{ context: 'settings-no-store-address-notice', link_id: 'contact-information-read-more', href: 'https://docs.woocommerce.com/document/google-listings-and-ads/#contact-information' }` @@ -251,9 +251,9 @@ When a documentation link is clicked. - with `{ context: "dashboard", link_id: "setting-up-currency", href: "https://support.google.com/google-ads/answer/9841530" }` - with `{ context: "reports-products", link_id: "setting-up-currency", href: "https://support.google.com/google-ads/answer/9841530" }` - with `{ context: "reports-programs", link_id: "setting-up-currency", href: "https://support.google.com/google-ads/answer/9841530" }` -- [`ChooseAudienceSection`](../../js/src/components/free-listings/choose-audience-section/choose-audience-section.js#L30) with `{ context: 'setup-mc-audience', link_id: 'site-language', href: 'https://support.google.com/merchants/answer/160637' }` +- [`ChooseAudienceSection`](../../js/src/components/free-listings/choose-audience-section/choose-audience-section.js#L29) with `{ context: 'setup-mc-audience', link_id: 'site-language', href: 'https://support.google.com/merchants/answer/160637' }` - [`ShippingTimeSection`](../../js/src/components/free-listings/configure-product-listings/shipping-time-section.js#L17) with `{ context: 'setup-mc-shipping', link_id: 'shipping-read-more', href: 'https://support.google.com/merchants/answer/7050921' }` -- [`TaxRate`](../../js/src/components/free-listings/configure-product-listings/tax-rate.js#L21) +- [`TaxRate`](../../js/src/components/free-listings/configure-product-listings/tax-rate.js#L22) - with `{ context: 'setup-mc-tax-rate', link_id: 'tax-rate-read-more', href: 'https://support.google.com/merchants/answer/160162' }` - with `{ context: 'setup-mc-tax-rate', link_id: 'tax-rate-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` - [`ConnectGoogleAccountCard`](../../js/src/components/google-account-card/connect-google-account-card.js#L23) with `{ context: 'setup-mc-accounts', link_id: 'required-google-permissions', href: 'https://docs.woocommerce.com/document/google-listings-and-ads/#required-google-permissions' }` @@ -266,7 +266,7 @@ When a documentation link is clicked. - [`TermsModal`](../../js/src/components/google-mc-account-card/terms-modal/index.js#L29) with `{ context: 'setup-mc', link_id: 'google-mc-terms-of-service', href: 'https://support.google.com/merchants/answer/160173' }` - [`exports`](../../js/src/components/paid-ads/ads-campaign.js#L38) with `{ context: 'create-ads' | 'edit-ads' | 'setup-ads', link_id: 'see-what-ads-look-like', href: 'https://support.google.com/google-ads/answer/6275294' }` - [`FaqsSection`](../../js/src/components/paid-ads/asset-group/faqs-section.js#L73) with `{ context: 'assets-faq', linkId: 'assets-faq-about-ad-formats-available-in-different-campaign-types', href: 'https://support.google.com/google-ads/answer/1722124' }`. -- [`ShippingRateSection`](../../js/src/components/shipping-rate-section/shipping-rate-section.js#L22) +- [`ShippingRateSection`](../../js/src/components/shipping-rate-section/shipping-rate-section.js#L23) - with `{ context: 'setup-mc-shipping', link_id: 'shipping-read-more', href: 'https://support.google.com/merchants/answer/7050921' }` - with `{ context: 'setup-mc-shipping', link_id: 'shipping-manual', href: 'https://www.google.com/retail/solutions/merchant-center/' }` - [`Faqs`](../../js/src/get-started-page/faqs/index.js#L276) @@ -301,7 +301,7 @@ Triggered when phone number edit button is clicked. #### Emitters - [`PhoneNumberCardPreview`](../../js/src/components/contact-information/phone-number-card/phone-number-card-preview.js#L33) Whenever "Edit" is clicked. -### [`gla_edit_mc_store_address`](../../js/src/components/contact-information/store-address-card.js#L126) +### [`gla_edit_mc_store_address`](../../js/src/components/contact-information/store-address-card.js#L172) Trigger when store address edit button is clicked. Before `1.5.0` this name was used for tracking clicking "Edit in settings" to edit the WC address. As of `>1.5.0`, that event is now tracked as `edit_wc_store_address`. #### Properties @@ -310,7 +310,7 @@ Trigger when store address edit button is clicked. `path` | `string` | The path used in the page from which the link was clicked, e.g. `"/google/settings"`. `subpath` | `string\|undefined` | The subpath used in the page, e.g. `"/edit-store-address"` or `undefined` when there is no subpath. #### Emitters -- [`StoreAddressCardPreview`](../../js/src/components/contact-information/store-address-card.js#L146) Whenever "Edit" is clicked. +- [`StoreAddressCardPreview`](../../js/src/components/contact-information/store-address-card.js#L192) Whenever "Edit" is clicked. ### [`gla_edit_product_click`](../../js/src/product-feed/product-feed-table-card/index.js#L50) Triggered when edit links are clicked from product feed table. @@ -330,7 +330,7 @@ Triggered when edit links are clicked from Issues to resolve table. `code` | `string` | Issue code returned from Google `issue` | `string` | Issue description returned from Google -### [`gla_edit_wc_store_address`](../../js/src/components/contact-information/store-address-card.js#L23) +### [`gla_edit_wc_store_address`](../../js/src/components/contact-information/store-address-card.js#L26) Triggered when store address "Edit in WooCommerce Settings" button is clicked. Before `1.5.0` it was called `edit_mc_store_address`. #### Properties @@ -339,7 +339,7 @@ Triggered when store address "Edit in WooCommerce Settings" button is clicked. `path` | `string` | The path used in the page from which the link was clicked, e.g. `"/google/settings"`. `subpath` | `string\|undefined` | The subpath used in the page, e.g. `"/edit-store-address"` or `undefined` when there is no subpath. #### Emitters -- [`StoreAddressCard`](../../js/src/components/contact-information/store-address-card.js#L39) Whenever "Edit in WooCommerce Settings" button is clicked. +- [`StoreAddressCard`](../../js/src/components/contact-information/store-address-card.js#L56) Whenever "Edit in WooCommerce Settings" button is clicked. ### [`gla_faq`](../../js/src/components/faqs-panel/index.js#L22) Clicking on faq item to collapse or expand it. @@ -528,14 +528,14 @@ Check for whether the phone number for Merchant Center exists or not. #### Emitters - [`usePhoneNumberCheckTrackEventEffect`](../../js/src/components/contact-information/usePhoneNumberCheckTrackEventEffect.js#L21) -### [`gla_mc_phone_number_edit_button_click`](../../js/src/components/contact-information/phone-number-card/phone-number-card.js#L88) +### [`gla_mc_phone_number_edit_button_click`](../../js/src/components/contact-information/phone-number-card/phone-number-card.js#L104) Clicking on the Merchant Center phone number edit button. #### Properties | name | type | description | | ---- | ---- | ----------- | `view` | `string` | which view the edit button is in. Possible values: `setup-mc`, `settings`. #### Emitters -- [`PhoneNumberCard`](../../js/src/components/contact-information/phone-number-card/phone-number-card.js#L111) +- [`PhoneNumberCard`](../../js/src/components/contact-information/phone-number-card/phone-number-card.js#L128) ### [`gla_modal_closed`](../../js/src/utils/recordEvent.js#L110) A modal is closed. @@ -740,6 +740,18 @@ Viewing tooltip #### Emitters - [`HelpPopover`](../../js/src/components/help-popover/index.js#L32) with the given `id`. +### [`gla_wc_store_address_validation`](../../js/src/components/contact-information/store-address-card.js#L35) +Track how many times and what fields the store address is having validation errors. +#### Properties +| name | type | description | +| ---- | ---- | ----------- | +`path` | `string` | The path used in the page from which the event tracking was sent, e.g. `"/google/setup-mc"` or `"/google/settings"`. +`subpath` | `string\|undefined` | The subpath used in the page, e.g. `"/edit-store-address"` or `undefined` when there is no subpath. +`country_code` | `string` | The country code of store address, e.g. `"US"`. +`missing_fields` | `string` | The string of the missing required fields of store address separated by comma, e.g. `"city,postcode"`. +#### Emitters +- [`StoreAddressCard`](../../js/src/components/contact-information/store-address-card.js#L56) Whenever the new store address data is fetched after clicking "Refresh to sync" button. + ### [`gla_wordpress_account_connect_button_click`](../../js/src/components/wpcom-account-card/connect-wpcom-account-card.js#L17) Clicking on the button to connect WordPress.com account. #### Properties diff --git a/tests/Unit/API/Site/Controllers/CountryCodeTraitTest.php b/tests/Unit/API/Site/Controllers/CountryCodeTraitTest.php index 6372bd679f..adc7705aac 100644 --- a/tests/Unit/API/Site/Controllers/CountryCodeTraitTest.php +++ b/tests/Unit/API/Site/Controllers/CountryCodeTraitTest.php @@ -25,6 +25,9 @@ class CountryCodeTraitTest extends TestCase { /** @var MockObject|GoogleHelper $google_helper */ protected $google_helper; + /** @var CountryCodeTrait $trait */ + protected $trait; + /** @var bool $country_supported */ protected $country_supported; diff --git a/tests/e2e/bin/test-env-setup.sh b/tests/e2e/bin/test-env-setup.sh new file mode 100755 index 0000000000..ed48bb99d8 --- /dev/null +++ b/tests/e2e/bin/test-env-setup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +echo -e 'Activate twentytwentytwo theme \n' +wp-env run tests-cli wp theme activate twentytwentytwo + +echo -e 'Update URL structure \n' +wp-env run tests-cli wp rewrite structure '/%postname%/' --hard + +echo -e 'Add Customer user \n' +wp-env run tests-cli wp user create customer customer@woocommercee2etestsuite.com \ + --user_pass=password \ + --role=subscriber \ + --first_name='Jane' \ + --last_name='Smith' \ + --user_registered='2022-01-01 12:23:45' + +echo -e 'Update Blog Name \n' +wp-env run tests-cli wp option update blogname 'WooCommerce E2E Test Suite' + +echo -e 'Create Ready Post \n' +wp-env run tests-cli -- wp post create --post_type=page --post_status=publish --post_title='Ready' diff --git a/tests/e2e/config/default.json b/tests/e2e/config/default.json index e1faddafc4..ccfb98f0ae 100644 --- a/tests/e2e/config/default.json +++ b/tests/e2e/config/default.json @@ -1,5 +1,5 @@ { - "url": "http://localhost:8084/", + "url": "http://localhost:8889/", "users": { "admin": { "username": "admin", diff --git a/tests/e2e/config/jest-puppeteer.config.js b/tests/e2e/config/jest-puppeteer.config.js index d22b29ba63..acccac77eb 100644 --- a/tests/e2e/config/jest-puppeteer.config.js +++ b/tests/e2e/config/jest-puppeteer.config.js @@ -4,7 +4,7 @@ const { useE2EJestPuppeteerConfig } = require( '@woocommerce/e2e-environment' ); const puppeteerConfig = useE2EJestPuppeteerConfig( { launch: { browserContext: 'incognito', - args: [ '--incognito', '--window-size=1920,1080' ], + args: [ '--no-sandbox', '--incognito', '--window-size=1920,1080' ], defaultViewport: { width: 1280, height: 800,