diff --git a/assets/js/base/components/button/index.tsx b/assets/js/base/components/button/index.tsx index 4caf1498e58..2283c1c6cbd 100644 --- a/assets/js/base/components/button/index.tsx +++ b/assets/js/base/components/button/index.tsx @@ -12,7 +12,7 @@ import Spinner from '@woocommerce/base-components/spinner'; import './style.scss'; export interface ButtonProps - extends Omit< WPButtonType.ButtonProps, 'variant' > { + extends Omit< WPButtonType.ButtonProps, 'variant' | 'href' > { /** * Show spinner * @@ -23,6 +23,10 @@ export interface ButtonProps * Button variant */ variant?: 'text' | 'contained' | 'outlined'; + /** + * The URL the button should link to. + */ + href?: string | undefined; } export interface AnchorProps extends Omit< ButtonProps, 'href' > { diff --git a/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx b/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx new file mode 100644 index 00000000000..3a4d42ba6cc --- /dev/null +++ b/assets/js/base/components/cart-checkout/local-pickup-select/index.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { RadioControlOption } from '@woocommerce/base-components/radio-control/types'; +import { CartShippingPackageShippingRate } from '@woocommerce/types'; +/** + * Internal dependencies + */ +import RadioControl from '../../radio-control'; + +interface LocalPickupSelectProps { + title?: string | undefined; + setSelectedOption: ( value: string ) => void; + selectedOption: string; + pickupLocations: CartShippingPackageShippingRate[]; + onSelectRate: ( value: string ) => void; + renderPickupLocation: ( + location: CartShippingPackageShippingRate, + pickupLocationsCount: number + ) => RadioControlOption; + packageCount: number; +} +/** + * Local pickup select component, used to render a package title and local pickup options. + */ +export const LocalPickupSelect = ( { + title, + setSelectedOption, + selectedOption, + pickupLocations, + onSelectRate, + renderPickupLocation, + packageCount, +}: LocalPickupSelectProps ) => { + // Hacky way to check if there are multiple packages, this way is borrowed from `assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx` + // We have no built-in way of checking if other extensions have added packages. + const multiplePackages = + document.querySelectorAll( + '.wc-block-components-local-pickup-select .wc-block-components-radio-control' + ).length > 1; + return ( +
+ { multiplePackages && title ?
{ title }
: false } + { + setSelectedOption( value ); + onSelectRate( value ); + } } + selected={ selectedOption } + options={ pickupLocations.map( ( location ) => + renderPickupLocation( location, packageCount ) + ) } + /> +
+ ); +}; +export default LocalPickupSelect; diff --git a/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx b/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx new file mode 100644 index 00000000000..6d3bd6da04a --- /dev/null +++ b/assets/js/base/components/cart-checkout/local-pickup-select/test/index.tsx @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +/** + * Internal dependencies + */ +import LocalPickupSelect from '..'; + +describe( 'LocalPickupSelect', () => { + const TestComponent = ( { + selectedOptionOverride = null, + onSelectRateOverride = null, + }: { + selectedOptionOverride?: null | ( ( value: string ) => void ); + onSelectRateOverride?: null | ( ( value: string ) => void ); + } ) => ( + { + return { + value: `${ location.rate_id }`, + onChange: jest.fn(), + label: `${ location.name }`, + description: `${ location.description }`, + }; + } } + /> + ); + it( 'Does not render the title if only one package is present on the page', () => { + render( ); + expect( screen.queryByText( 'Package 1' ) ).not.toBeInTheDocument(); + } ); + it( 'Does render the title if more than one package is present on the page', () => { + const { rerender } = render( +
+
+
+ ); + // Render twice so our component can check the DOM correctly. + rerender( + <> +
+
+
+ + + ); + rerender( + <> +
+
+
+ + + ); + + expect( screen.getByText( 'Package 1' ) ).toBeInTheDocument(); + } ); + it( 'Calls the correct functions when changing selected option', () => { + const setSelectedOption = jest.fn(); + const onSelectRate = jest.fn(); + render( + + ); + userEvent.click( screen.getByText( 'Store 2' ) ); + expect( setSelectedOption ).toHaveBeenLastCalledWith( '2' ); + expect( onSelectRate ).toHaveBeenLastCalledWith( '2' ); + userEvent.click( screen.getByText( 'Store 1' ) ); + expect( setSelectedOption ).toHaveBeenLastCalledWith( '1' ); + expect( onSelectRate ).toHaveBeenLastCalledWith( '1' ); + } ); +} ); diff --git a/assets/js/base/components/state-input/StateInputProps.ts b/assets/js/base/components/state-input/StateInputProps.ts index 703c64ec32d..0abb21019f3 100644 --- a/assets/js/base/components/state-input/StateInputProps.ts +++ b/assets/js/base/components/state-input/StateInputProps.ts @@ -8,6 +8,7 @@ export interface StateInputProps { onChange: ( value: string ) => void; required?: boolean; errorMessage?: string | undefined; + errorId?: string; } export type StateInputWithStatesProps = StateInputProps & { diff --git a/assets/js/base/components/state-input/state-input.tsx b/assets/js/base/components/state-input/state-input.tsx index 15d8b6f61b9..7bb069d5999 100644 --- a/assets/js/base/components/state-input/state-input.tsx +++ b/assets/js/base/components/state-input/state-input.tsx @@ -36,6 +36,7 @@ const StateInput = ( { autoComplete = 'off', value = '', required = false, + errorId = '', }: StateInputWithStatesProps ): JSX.Element => { const countryStates = states[ country ]; const options = useMemo( @@ -102,6 +103,7 @@ const StateInput = ( { 'Please select a state.', 'woo-gutenberg-products-block' ) } + errorId={ errorId } required={ required } autoComplete={ autoComplete } /> diff --git a/assets/js/base/context/hooks/cart/use-store-cart.ts b/assets/js/base/context/hooks/cart/use-store-cart.ts index 1be0d7a8d45..9b8f223f988 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart.ts @@ -15,6 +15,7 @@ import { EMPTY_CART_ERRORS, EMPTY_SHIPPING_RATES, EMPTY_TAX_LINES, + EMPTY_PAYMENT_METHODS, EMPTY_PAYMENT_REQUIREMENTS, EMPTY_EXTENSIONS, } from '@woocommerce/block-data'; @@ -112,6 +113,7 @@ export const defaultCartData: StoreCart = { shippingRates: EMPTY_SHIPPING_RATES, isLoadingRates: false, cartHasCalculatedShipping: false, + paymentMethods: EMPTY_PAYMENT_METHODS, paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS, receiveCart: () => undefined, receiveCartContents: () => undefined, diff --git a/assets/js/base/context/hooks/tsconfig.json b/assets/js/base/context/hooks/tsconfig.json index 1c84e4557f6..6513700c600 100644 --- a/assets/js/base/context/hooks/tsconfig.json +++ b/assets/js/base/context/hooks/tsconfig.json @@ -7,7 +7,8 @@ "../providers/cart-checkout/checkout-events/index.tsx", "../providers/cart-checkout/payment-events/index.tsx", "../providers/cart-checkout/shipping/index.js", - "../../../editor-components/utils/*" + "../../../editor-components/utils/*", + "../../../data/index.ts" ], "exclude": [ "**/test/**" ] } diff --git a/assets/js/base/utils/shipping-rates.ts b/assets/js/base/utils/shipping-rates.ts index 13a4a4036d2..f5c61e6bd14 100644 --- a/assets/js/base/utils/shipping-rates.ts +++ b/assets/js/base/utils/shipping-rates.ts @@ -6,6 +6,7 @@ import { CartShippingRate, } from '@woocommerce/type-defs/cart'; import { getSetting } from '@woocommerce/settings'; +import { LOCAL_PICKUP_ENABLED } from '@woocommerce/block-settings'; /** * Get the number of packages in a shippingRates array. @@ -36,6 +37,9 @@ export const isPackageRateCollectable = ( export const hasCollectableRate = ( chosenRates: string[] | string ): boolean => { + if ( ! LOCAL_PICKUP_ENABLED ) { + return false; + } if ( Array.isArray( chosenRates ) ) { return !! chosenRates.find( ( rate ) => collectableMethodIds.includes( rate ) diff --git a/assets/js/base/utils/test/shipping-rates.ts b/assets/js/base/utils/test/shipping-rates.ts index c28e7babeae..6121834f6aa 100644 --- a/assets/js/base/utils/test/shipping-rates.ts +++ b/assets/js/base/utils/test/shipping-rates.ts @@ -6,20 +6,27 @@ import { isPackageRateCollectable, } from '@woocommerce/base-utils'; import { CartShippingRate } from '@woocommerce/type-defs/cart'; +import * as blockSettings from '@woocommerce/block-settings'; jest.mock( '@woocommerce/settings', () => { return { + __esModule: true, ...jest.requireActual( '@woocommerce/settings' ), - getSetting: ( setting: string ) => { + getSetting: jest.fn().mockImplementation( ( setting: string ) => { if ( setting === 'collectableMethodIds' ) { return [ 'local_pickup' ]; } return jest .requireActual( '@woocommerce/settings' ) .getSetting( setting ); - }, + } ), }; } ); +jest.mock( '@woocommerce/block-settings', () => ( { + __esModule: true, + ...jest.requireActual( '@woocommerce/block-settings' ), + LOCAL_PICKUP_ENABLED: true, +} ) ); describe( 'hasCollectableRate', () => { it( 'correctly identifies if an array contains a collectable rate', () => { const ratesToTest = [ 'flat_rate', 'local_pickup' ]; @@ -27,6 +34,12 @@ describe( 'hasCollectableRate', () => { const ratesToTest2 = [ 'flat_rate', 'free_shipping' ]; expect( hasCollectableRate( ratesToTest2 ) ).toBe( false ); } ); + it( 'returns false for all rates if local pickup is disabled', () => { + // Attempt to assign to const or readonly variable error on next line is OK because it is mocked by jest + blockSettings.LOCAL_PICKUP_ENABLED = false; + const ratesToTest = [ 'flat_rate', 'local_pickup' ]; + expect( hasCollectableRate( ratesToTest ) ).toBe( false ); + } ); } ); describe( 'isPackageRateCollectable', () => { diff --git a/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-methods.js b/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-methods.js index 2d29dbb4dd2..e426f7f20cc 100644 --- a/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-methods.js +++ b/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-methods.js @@ -17,7 +17,6 @@ import { dispatch } from '@wordpress/data'; * Internal dependencies */ import PaymentMethods from '../payment-methods'; -import { defaultCartState } from '../../../../data/cart/default-state'; jest.mock( '../saved-payment-method-options', () => ( { onChange } ) => { return ( @@ -102,9 +101,10 @@ describe( 'PaymentMethods', () => { wpDataFunctions .dispatch( CART_STORE_KEY ) .invalidateResolutionForStore(); - wpDataFunctions - .dispatch( CART_STORE_KEY ) - .receiveCart( defaultCartState.cartData ); + wpDataFunctions.dispatch( CART_STORE_KEY ).receiveCart( { + ...previewCart, + payment_methods: [ 'cod', 'credit-card' ], + } ); } ); afterEach( () => { diff --git a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx index 7b217647ebf..bae80a62df5 100644 --- a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx +++ b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx @@ -8,7 +8,8 @@ import { CHECKOUT_URL } from '@woocommerce/block-settings'; import { usePositionRelativeToViewport } from '@woocommerce/base-hooks'; import { getSetting } from '@woocommerce/settings'; import { useSelect } from '@wordpress/data'; -import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { applyCheckoutFilter } from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -28,10 +29,11 @@ const Block = ( { className: string; buttonLabel: string; } ): JSX.Element => { - const link = getSetting( 'page-' + checkoutPageId, false ); + const link = getSetting< string >( 'page-' + checkoutPageId, false ); const isCalculating = useSelect( ( select ) => select( CHECKOUT_STORE_KEY ).isCalculating() ); + const [ positionReferenceElement, positionRelativeToViewport ] = usePositionRelativeToViewport(); const [ showSpinner, setShowSpinner ] = useState( false ); @@ -57,16 +59,30 @@ const Block = ( { global.removeEventListener( 'pageshow', hideSpinner ); }; }, [] ); + const cart = useSelect( ( select ) => { + return select( CART_STORE_KEY ).getCartData(); + } ); + const label = applyCheckoutFilter< string >( { + filterName: 'proceedToCheckoutButtonLabel', + defaultValue: buttonLabel || defaultButtonLabel, + arg: { cart }, + } ); + + const filteredLink = applyCheckoutFilter< string >( { + filterName: 'proceedToCheckoutButtonLink', + defaultValue: link || CHECKOUT_URL, + arg: { cart }, + } ); const submitContainerContents = ( ); diff --git a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/test/block.tsx b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/test/block.tsx new file mode 100644 index 00000000000..8161dc51bfe --- /dev/null +++ b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/test/block.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; + +/** + * Internal dependencies + */ +import Block from '../block'; + +describe( 'Proceed to checkout block', () => { + it( 'allows the text to be filtered', () => { + registerCheckoutFilters( 'test-extension', { + proceedToCheckoutButtonLabel: () => { + return 'Proceed to step two'; + }, + } ); + render( + + ); + expect( screen.getByText( 'Proceed to step two' ) ).toBeInTheDocument(); + } ); + it( 'allows the link to be filtered', () => { + registerCheckoutFilters( 'test-extension', { + proceedToCheckoutButtonLink: () => { + return 'https://woocommerce.com'; + }, + } ); + render( + + ); + const button = screen.getByText( 'Proceed to Checkout' ); + const link = button.closest( 'a' ); + expect( link?.href ).toBe( 'https://woocommerce.com/' ); + } ); + it( 'does not allow incorrect types to be applied to either button label or button link', () => { + registerCheckoutFilters( 'test-extension', { + proceedToCheckoutButtonLabel: () => { + return 123; + }, + proceedToCheckoutButtonLink: () => { + return 123; + }, + } ); + render( + + ); + //@todo When https://github.com/WordPress/gutenberg/issues/22850 is complete use that new matcher here for more specific error message assertion. + expect( console ).toHaveErrored(); + } ); +} ); diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx index 4dea0a89bdb..342bbbec34c 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/block.tsx @@ -8,21 +8,23 @@ import { useCallback, createInterpolateElement, } from '@wordpress/element'; -import { useShippingData } from '@woocommerce/base-context/hooks'; +import { useShippingData, useStoreCart } from '@woocommerce/base-context/hooks'; import { getCurrencyFromPriceResponse } from '@woocommerce/price-format'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import { decodeEntities } from '@wordpress/html-entities'; import { getSetting } from '@woocommerce/settings'; import { Icon, mapMarker } from '@wordpress/icons'; -import RadioControl from '@woocommerce/base-components/radio-control'; import type { RadioControlOption } from '@woocommerce/base-components/radio-control/types'; import { CartShippingPackageShippingRate } from '@woocommerce/types'; import { isPackageRateCollectable } from '@woocommerce/base-utils'; +import { ExperimentalOrderLocalPickupPackages } from '@woocommerce/blocks-checkout'; +import { LocalPickupSelect } from '@woocommerce/base-components/cart-checkout/local-pickup-select'; /** * Internal dependencies */ import './style.scss'; +import ShippingRatesControlPackage from '../../../../base/components/cart-checkout/shipping-rates-control-package'; const getPickupLocation = ( option: CartShippingPackageShippingRate @@ -133,6 +135,20 @@ const Block = (): JSX.Element | null => { [ selectShippingRate ] ); + // Prepare props to pass to the ExperimentalOrderLocalPickupPackages slot fill. + // We need to pluck out receiveCart. + // eslint-disable-next-line no-unused-vars + const { extensions, receiveCart, ...cart } = useStoreCart(); + const slotFillProps = { + extensions, + cart, + components: { + ShippingRatesControlPackage, + LocalPickupSelect, + }, + renderPickupLocation, + }; + // Update the selected option if there is no rate selected on mount. useEffect( () => { if ( ! selectedOption && pickupLocations[ 0 ] ) { @@ -142,16 +158,19 @@ const Block = (): JSX.Element | null => { }, [ onSelectRate, pickupLocations, selectedOption ] ); return ( - { - setSelectedOption( value ); - onSelectRate( value ); - } } - selected={ selectedOption } - options={ pickupLocations.map( ( location ) => - renderPickupLocation( location, shippingRates.length ) - ) } - /> + <> + + + + + ); }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx index 42dfb4696fa..9c21e4de517 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/attributes.tsx @@ -44,4 +44,8 @@ export default { remove: true, }, }, + shippingCostRequiresAddress: { + type: 'boolean', + default: false, + }, }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json index b080adafec9..b3255985c2d 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.json @@ -19,6 +19,10 @@ "remove": true, "move": true } + }, + "shippingCostRequiresAddress": { + "type": "boolean", + "default": false } }, "parent": [ "woocommerce/checkout-fields-block" ], diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx index e59b5e91725..d6bbbfac0de 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx @@ -17,6 +17,7 @@ import './style.scss'; import { RatePrice, getLocalPickupPrices, getShippingPrices } from './shared'; import type { minMaxPrices } from './shared'; import { defaultLocalPickupText, defaultShippingText } from './constants'; +import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; const LocalPickupSelector = ( { checked, @@ -71,15 +72,19 @@ const ShippingSelector = ( { showPrice, showIcon, toggleText, + shippingCostRequiresAddress = false, }: { checked: string; rate: minMaxPrices; showPrice: boolean; showIcon: boolean; + shippingCostRequiresAddress: boolean; toggleText: string; } ) => { + const rateShouldBeHidden = + shippingCostRequiresAddress && shippingAddressHasValidationErrors(); const Price = - rate.min === undefined ? ( + rate.min === undefined || rateShouldBeHidden ? ( { __( 'calculated with an address', @@ -122,11 +127,13 @@ const Block = ( { showIcon, localPickupText, shippingText, + shippingCostRequiresAddress = false, }: { checked: string; onChange: ( value: string ) => void; showPrice: boolean; showIcon: boolean; + shippingCostRequiresAddress: boolean; localPickupText: string; shippingText: string; } ): JSX.Element | null => { @@ -145,6 +152,7 @@ const Block = ( { rate={ getShippingPrices( shippingRates[ 0 ]?.shipping_rates ) } showPrice={ showPrice } showIcon={ showIcon } + shippingCostRequiresAddress={ shippingCostRequiresAddress } toggleText={ shippingText || defaultShippingText } /> ) => void; } ): JSX.Element | null => { + const toggleAttribute = ( key: keyof Attributes ): void => { + const newAttributes = {} as Partial< Attributes >; + newAttributes[ key ] = ! ( attributes[ key ] as boolean ); + setAttributes( newAttributes ); + }; + const { setPrefersCollection } = useDispatch( CHECKOUT_STORE_KEY ); const { prefersCollection } = useSelect( ( select ) => { const checkoutStore = select( CHECKOUT_STORE_KEY ); @@ -210,6 +221,30 @@ export const Edit = ( { ) } > + + { + updateAttributeInSiblingBlock( + clientId, + 'shippingCostRequiresAddress', + selected, + 'woocommerce/checkout-shipping-methods-block' + ); + + toggleAttribute( 'shippingCostRequiresAddress' ); + } } + /> + diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx index 74ae2cc83f8..86f5154a87b 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx @@ -25,10 +25,12 @@ const FrontendBlock = ( { showIcon, shippingText, localPickupText, + shippingCostRequiresAddress, }: { title: string; description: string; showStepNumber: boolean; + shippingCostRequiresAddress: boolean; children: JSX.Element; className?: string; showPrice: boolean; @@ -90,6 +92,7 @@ const FrontendBlock = ( { showIcon={ showIcon } localPickupText={ localPickupText } shippingText={ shippingText } + shippingCostRequiresAddress={ shippingCostRequiresAddress } /> { children } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx index b36f320660d..17cb4f6bf2f 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/attributes.tsx @@ -24,4 +24,8 @@ export default { remove: true, }, }, + shippingCostRequiresAddress: { + type: 'boolean', + default: false, + }, }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json index df53c0117c8..b4c5dc5192f 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.json @@ -19,6 +19,10 @@ "remove": true, "move": true } + }, + "shippingCostRequiresAddress": { + "type": "boolean", + "default": false } }, "parent": [ "woocommerce/checkout-fields-block" ], diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx index fb241284d8a..89e67fb766e 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx @@ -21,11 +21,14 @@ import type { CartShippingPackageShippingRate, } from '@woocommerce/types'; import type { ReactElement } from 'react'; +import { useSelect } from '@wordpress/data'; +import { CART_STORE_KEY } from '@woocommerce/block-data'; /** * Internal dependencies */ import './style.scss'; +import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; /** * Renders a shipping rate control option. @@ -52,7 +55,10 @@ const renderShippingRatesControlOption = ( }; }; -const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { +const Block = ( { + noShippingPlaceholder = null, + shippingCostRequiresAddress = false, +} ): ReactElement | null => { const { isEditor } = useEditorContext(); const { @@ -63,6 +69,10 @@ const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { isCollectable, } = useShippingData(); + const shippingAddressPushed = useSelect( ( select ) => { + return select( CART_STORE_KEY ).getFullShippingAddressPushed(); + } ); + const filteredShippingRates = isCollectable ? shippingRates.map( ( shippingRatesPackage ) => { return { @@ -81,13 +91,15 @@ const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => { return null; } + const shippingAddressIsComplete = ! shippingAddressHasValidationErrors(); + const shippingRatesPackageCount = getShippingRatesPackageCount( shippingRates ); if ( - ! isEditor && - ! hasCalculatedShipping && - ! shippingRatesPackageCount + ( ! hasCalculatedShipping && ! shippingRatesPackageCount ) || + ( shippingCostRequiresAddress && + ( ! shippingAddressPushed || ! shippingAddressIsComplete ) ) ) { return (

diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx index ac1d3bbd9bf..0edb15d412a 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/edit.tsx @@ -4,12 +4,14 @@ import classnames from 'classnames'; import { __ } from '@wordpress/i18n'; import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; -import { PanelBody, ExternalLink } from '@wordpress/components'; +import { PanelBody, ExternalLink, ToggleControl } from '@wordpress/components'; import { ADMIN_URL, getSetting } from '@woocommerce/settings'; import ExternalLinkCard from '@woocommerce/editor-components/external-link-card'; import { innerBlockAreas } from '@woocommerce/blocks-checkout'; import { useCheckoutAddress } from '@woocommerce/base-context/hooks'; import Noninteractive from '@woocommerce/base-components/noninteractive'; +import { Attributes } from '@woocommerce/blocks/checkout/types'; +import { updateAttributeInSiblingBlock } from '@woocommerce/utils'; /** * Internal dependencies @@ -32,12 +34,15 @@ type shippingAdminLink = { export const Edit = ( { attributes, setAttributes, + clientId, }: { + clientId: string; attributes: { title: string; description: string; showStepNumber: boolean; className: string; + shippingCostRequiresAddress: boolean; }; setAttributes: ( attributes: Record< string, unknown > ) => void; } ): JSX.Element | null => { @@ -54,6 +59,12 @@ export const Edit = ( { return null; } + const toggleAttribute = ( key: keyof Attributes ): void => { + const newAttributes = {} as Partial< Attributes >; + newAttributes[ key ] = ! ( attributes[ key ] as boolean ); + setAttributes( newAttributes ); + }; + return ( + + { + updateAttributeInSiblingBlock( + clientId, + 'shippingCostRequiresAddress', + selected, + 'woocommerce/checkout-shipping-method-block' + ); + toggleAttribute( 'shippingCostRequiresAddress' ); + } } + /> + { globalShippingMethods.length > 0 && ( - } /> + } + shippingCostRequiresAddress={ + attributes.shippingCostRequiresAddress + } + /> diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx index dd67d39fba5..9fced3fa2e7 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/frontend.tsx @@ -20,6 +20,7 @@ const FrontendBlock = ( { showStepNumber, children, className, + shippingCostRequiresAddress = false, }: { title: string; description: string; @@ -31,6 +32,7 @@ const FrontendBlock = ( { showStepNumber: boolean; children: JSX.Element; className?: string; + shippingCostRequiresAddress: boolean; } ) => { const checkoutIsProcessing = useSelect( ( select ) => select( CHECKOUT_STORE_KEY ).isProcessing() @@ -53,7 +55,9 @@ const FrontendBlock = ( { description={ description } showStepNumber={ showStepNumber } > - + { children } ); diff --git a/assets/js/blocks/mini-cart/frontend.ts b/assets/js/blocks/mini-cart/frontend.ts index 90dbbfec7bc..79bc9a65ec8 100644 --- a/assets/js/blocks/mini-cart/frontend.ts +++ b/assets/js/blocks/mini-cart/frontend.ts @@ -1,7 +1,6 @@ /** * External dependencies */ -import { getSetting } from '@woocommerce/settings'; import preloadScript from '@woocommerce/base-utils/preload-script'; import lazyLoadScript from '@woocommerce/base-utils/lazy-load-script'; import getNavigationType from '@woocommerce/base-utils/get-navigation-type'; @@ -23,10 +22,10 @@ window.addEventListener( 'load', () => { return; } - const dependencies = getSetting( - 'mini_cart_block_frontend_dependencies', - {} - ) as Record< string, dependencyData >; + const dependencies = window.wcBlocksMiniCartFrontendDependencies as Record< + string, + dependencyData + >; // Preload scripts for ( const dependencyHandle in dependencies ) { diff --git a/assets/js/blocks/mini-cart/mini-cart-contents/index.tsx b/assets/js/blocks/mini-cart/mini-cart-contents/index.tsx index c8fc36958ce..c935619d676 100644 --- a/assets/js/blocks/mini-cart/mini-cart-contents/index.tsx +++ b/assets/js/blocks/mini-cart/mini-cart-contents/index.tsx @@ -6,6 +6,7 @@ import { cart, filledCart, removeCart } from '@woocommerce/icons'; import { Icon } from '@wordpress/icons'; import { registerBlockType } from '@wordpress/blocks'; import type { BlockConfiguration } from '@wordpress/blocks'; +import { isFeaturePluginBuild } from '@woocommerce/block-settings'; /** * Internal dependencies @@ -41,6 +42,12 @@ const settings: BlockConfiguration = { link: true, }, lock: false, + ...( isFeaturePluginBuild() && { + __experimentalBorder: { + color: true, + width: true, + }, + } ), }, attributes: { isPreview: { diff --git a/assets/js/blocks/mini-cart/style.scss b/assets/js/blocks/mini-cart/style.scss index 5305b2fedf4..c8e7b5d0569 100644 --- a/assets/js/blocks/mini-cart/style.scss +++ b/assets/js/blocks/mini-cart/style.scss @@ -87,9 +87,7 @@ .wp-block-woocommerce-mini-cart-contents { box-sizing: border-box; - height: 100vh; height: 100dvh; - max-height: -webkit-fill-available; padding: 0; justify-content: center; } @@ -99,9 +97,10 @@ .wp-block-woocommerce-empty-mini-cart-contents-block, .wp-block-woocommerce-filled-mini-cart-contents-block { - height: 100vh; - height: 100dvh; + height: 100%; max-height: -webkit-fill-available; + max-height: -moz-available; + max-height: fill-available; display: flex; flex-direction: column; } @@ -210,11 +209,13 @@ h2.wc-block-mini-cart__title { } .admin-bar .wp-block-woocommerce-mini-cart-contents { - margin-top: 32px; + margin-top: 46px; + height: calc(100dvh - 46px); } -.admin-bar .wp-block-woocommerce-mini-cart-contents, -.admin-bar .wp-block-woocommerce-empty-mini-cart-contents-block, -.admin-bar .wp-block-woocommerce-filled-mini-cart-contents-block { - height: calc(100vh - 32px); +@media only screen and (min-width: 783px) { + .admin-bar .wp-block-woocommerce-mini-cart-contents { + margin-top: 32px; + height: calc(100dvh - 32px); + } } diff --git a/assets/js/blocks/product-query/constants.ts b/assets/js/blocks/product-query/constants.ts index 13637e26087..dfc63f5e630 100644 --- a/assets/js/blocks/product-query/constants.ts +++ b/assets/js/blocks/product-query/constants.ts @@ -80,13 +80,33 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [ 'core/post-template', { __woocommerceNamespace: PRODUCT_TEMPLATE_ID }, [ - [ 'woocommerce/product-image' ], + [ + 'woocommerce/product-image', + { + style: { + spacing: { + margin: { + bottom: '0.75rem', + top: '0', + }, + }, + }, + }, + ], [ 'core/post-title', { textAlign: 'center', level: 3, fontSize: 'medium', + style: { + spacing: { + margin: { + bottom: '0.75rem', + top: '0', + }, + }, + }, isLink: true, __woocommerceNamespace: PRODUCT_TITLE_ID, }, @@ -99,7 +119,10 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [ fontSize: 'small', style: { spacing: { - margin: { bottom: '1rem' }, + margin: { + bottom: '0.75rem', + top: '0', + }, }, }, }, @@ -112,7 +135,10 @@ export const INNER_BLOCKS_TEMPLATE: InnerBlockTemplate[] = [ fontSize: 'small', style: { spacing: { - margin: { bottom: '1rem' }, + margin: { + bottom: '0.75rem', + top: '0', + }, }, }, }, diff --git a/assets/js/data/cart/action-types.ts b/assets/js/data/cart/action-types.ts index ce5ffa64032..6ee20f94551 100644 --- a/assets/js/data/cart/action-types.ts +++ b/assets/js/data/cart/action-types.ts @@ -1,5 +1,6 @@ export const ACTION_TYPES = { SET_CART_DATA: 'SET_CART_DATA', + SET_FULL_SHIPPING_ADDRESS_PUSHED: 'SET_FULL_SHIPPING_ADDRESS_PUSHED', SET_ERROR_DATA: 'SET_ERROR_DATA', APPLYING_COUPON: 'APPLYING_COUPON', REMOVING_COUPON: 'REMOVING_COUPON', diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index c7cc97261a1..393680c7cbf 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -417,7 +417,14 @@ export const selectShippingRate = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); + // Remove shipping and billing address from the response, so we don't overwrite what the shopper is + // entering in the form if rates suddenly appear mid-edit. + const { + shipping_address: shippingAddress, + billing_address: billingAddress, + ...rest + } = response; + dispatch.receiveCart( rest ); return response as CartResponse; } catch ( error ) { dispatch.receiveError( error ); @@ -474,6 +481,13 @@ export const updateCustomerData = } }; +export const setFullShippingAddressPushed = ( + fullShippingAddressPushed: boolean +) => ( { + type: types.SET_FULL_SHIPPING_ADDRESS_PUSHED, + fullShippingAddressPushed, +} ); + type Actions = | typeof addItemToCart | typeof applyCoupon @@ -494,6 +508,7 @@ type Actions = | typeof setShippingAddress | typeof shippingRatesBeingSelected | typeof updateCustomerData + | typeof setFullShippingAddressPushed | typeof updatingCustomerData; export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >; diff --git a/assets/js/data/cart/default-state.ts b/assets/js/data/cart/default-state.ts index f7f1d65fdd2..112f5eeb94f 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -15,6 +15,7 @@ import { EMPTY_CART_ERRORS, EMPTY_SHIPPING_RATES, EMPTY_TAX_LINES, + EMPTY_PAYMENT_METHODS, EMPTY_PAYMENT_REQUIREMENTS, EMPTY_EXTENSIONS, } from '../constants'; @@ -89,6 +90,7 @@ export const defaultCartState: CartState = { tax_lines: EMPTY_TAX_LINES, }, errors: EMPTY_CART_ITEM_ERRORS, + paymentMethods: EMPTY_PAYMENT_METHODS, paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS, extensions: EMPTY_EXTENSIONS, }, @@ -98,6 +100,7 @@ export const defaultCartState: CartState = { applyingCoupon: '', removingCoupon: '', isCartDataStale: false, + fullShippingAddressPushed: false, }, errors: EMPTY_CART_ERRORS, }; diff --git a/assets/js/data/cart/push-changes.ts b/assets/js/data/cart/push-changes.ts index 8410a3a0a70..2fd720de047 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -17,6 +17,7 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; import { STORE_KEY } from './constants'; import { VALIDATION_STORE_KEY } from '../validation'; import { processErrorResponse } from '../utils'; +import { shippingAddressHasValidationErrors } from './utils'; type CustomerData = { billingAddress: CartBillingAddress; @@ -192,6 +193,11 @@ const updateCustomerData = debounce( (): void => { ) as BaseAddressKey[] ), ]; } + } ) + .finally( () => { + if ( ! shippingAddressHasValidationErrors() ) { + dispatch( STORE_KEY ).setFullShippingAddressPushed( true ); + } } ); } }, 1000 ); diff --git a/assets/js/data/cart/reducers.ts b/assets/js/data/cart/reducers.ts index 67a9dcfe471..3ab809ec8c6 100644 --- a/assets/js/data/cart/reducers.ts +++ b/assets/js/data/cart/reducers.ts @@ -48,6 +48,15 @@ const reducer: Reducer< CartState > = ( action: Partial< CartAction > ) => { switch ( action.type ) { + case types.SET_FULL_SHIPPING_ADDRESS_PUSHED: + state = { + ...state, + metaData: { + ...state.metaData, + fullShippingAddressPushed: action.fullShippingAddressPushed, + }, + }; + break; case types.SET_ERROR_DATA: if ( action.error ) { state = { diff --git a/assets/js/data/cart/resolvers.ts b/assets/js/data/cart/resolvers.ts index 71b4c386d18..080b021d0db 100644 --- a/assets/js/data/cart/resolvers.ts +++ b/assets/js/data/cart/resolvers.ts @@ -9,6 +9,7 @@ import { CartResponse } from '@woocommerce/types'; */ import { CART_API_ERROR } from './constants'; import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index'; +import { shippingAddressHasValidationErrors } from './utils'; /** * Resolver for retrieving all cart data. @@ -27,6 +28,10 @@ export const getCartData = receiveError( CART_API_ERROR ); return; } + + if ( ! shippingAddressHasValidationErrors() ) { + dispatch.setFullShippingAddressPushed( true ); + } receiveCart( cartData ); }; diff --git a/assets/js/data/cart/selectors.ts b/assets/js/data/cart/selectors.ts index f4644a4b2d2..59041eb804e 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -222,3 +222,10 @@ export const getItemsPendingQuantityUpdate = ( state: CartState ): string[] => { export const getItemsPendingDelete = ( state: CartState ): string[] => { return state.cartItemsPendingDelete; }; + +/** + * Whether the address has changes that have not been synced with the server. + */ +export const getFullShippingAddressPushed = ( state: CartState ): boolean => { + return state.metaData.fullShippingAddressPushed; +}; diff --git a/assets/js/data/cart/test/push-changes.ts b/assets/js/data/cart/test/push-changes.ts index fd4ee9b827f..8bf726c2aa5 100644 --- a/assets/js/data/cart/test/push-changes.ts +++ b/assets/js/data/cart/test/push-changes.ts @@ -64,6 +64,7 @@ jest.mock( '../utils', () => ( { // need to update payment methods, they are not relevant to the tests in this file. jest.mock( '../update-payment-methods', () => ( { debouncedUpdatePaymentMethods: jest.fn(), + updatePaymentMethods: jest.fn(), } ) ); describe( 'pushChanges', () => { diff --git a/assets/js/data/cart/utils.ts b/assets/js/data/cart/utils.ts index 1130198a750..19afead5670 100644 --- a/assets/js/data/cart/utils.ts +++ b/assets/js/data/cart/utils.ts @@ -3,9 +3,38 @@ */ import { camelCase, mapKeys } from 'lodash'; import { Cart, CartResponse } from '@woocommerce/types'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_KEY as VALIDATION_STORE_KEY } from '../validation/constants'; export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => { return mapKeys( responseCart, ( _, key ) => camelCase( key ) ) as unknown as Cart; }; + +export const shippingAddressHasValidationErrors = () => { + const validationStore = select( VALIDATION_STORE_KEY ); + // Check if the shipping address form has validation errors - if not then we know the full required + // address has been pushed to the server. + const stateValidationErrors = + validationStore.getValidationError( 'shipping_state' ); + const address1ValidationErrors = + validationStore.getValidationError( 'shipping_address_1' ); + const countryValidationErrors = + validationStore.getValidationError( 'shipping_country' ); + const postcodeValidationErrors = + validationStore.getValidationError( 'shipping_postcode' ); + const cityValidationErrors = + validationStore.getValidationError( 'shipping_city' ); + return [ + cityValidationErrors, + stateValidationErrors, + address1ValidationErrors, + countryValidationErrors, + postcodeValidationErrors, + ].some( ( entry ) => typeof entry !== 'undefined' ); +}; diff --git a/assets/js/data/constants.ts b/assets/js/data/constants.ts index 14a16efc68b..1c4fb12688d 100644 --- a/assets/js/data/constants.ts +++ b/assets/js/data/constants.ts @@ -12,6 +12,7 @@ export const EMPTY_CART_FEES: [] = []; export const EMPTY_CART_ITEM_ERRORS: [] = []; export const EMPTY_CART_ERRORS: [] = []; export const EMPTY_SHIPPING_RATES: [] = []; +export const EMPTY_PAYMENT_METHODS: [] = []; export const EMPTY_PAYMENT_REQUIREMENTS: [] = []; export const EMPTY_EXTENSIONS: Record< string, unknown > = {}; export const EMPTY_TAX_LINES: [] = []; diff --git a/assets/js/data/payment/default-state.ts b/assets/js/data/payment/default-state.ts index 21e121cb61a..884799f2a60 100644 --- a/assets/js/data/payment/default-state.ts +++ b/assets/js/data/payment/default-state.ts @@ -18,7 +18,7 @@ export interface PaymentState { status: string; activePaymentMethod: string; activeSavedToken: string; - // Avilable payment methods are payment methods which have been validated and can make payment + // Available payment methods are payment methods which have been validated and can make payment. availablePaymentMethods: PlainPaymentMethods; availableExpressPaymentMethods: PlainExpressPaymentMethods; savedPaymentMethods: diff --git a/assets/js/data/payment/test/check-payment-methods.tsx b/assets/js/data/payment/test/check-payment-methods.tsx index 7f57dc0752d..4669d09e0a1 100644 --- a/assets/js/data/payment/test/check-payment-methods.tsx +++ b/assets/js/data/payment/test/check-payment-methods.tsx @@ -2,7 +2,8 @@ * External dependencies */ import * as wpDataFunctions from '@wordpress/data'; -import { PAYMENT_STORE_KEY } from '@woocommerce/block-data'; +import { previewCart } from '@woocommerce/resource-previews'; +import { PAYMENT_STORE_KEY, CART_STORE_KEY } from '@woocommerce/block-data'; import { registerPaymentMethod, registerExpressPaymentMethod, @@ -23,6 +24,7 @@ const requiredKeyCheck = ( args: CanMakePaymentArgument ) => { 'cart', 'cartNeedsShipping', 'cartTotals', + 'paymentMethods', 'paymentRequirements', 'selectedShippingMethods', 'shippingAddress', @@ -133,6 +135,10 @@ const registerMockPaymentMethods = ( savedCards = true ) => { wpDataFunctions .dispatch( PAYMENT_STORE_KEY ) .__internalUpdateAvailablePaymentMethods(); + wpDataFunctions.dispatch( CART_STORE_KEY ).receiveCart( { + ...previewCart, + payment_methods: [ 'cheque', 'bacs', 'credit-card' ], + } ); }; const resetMockPaymentMethods = () => { diff --git a/assets/js/data/payment/utils/check-payment-methods.ts b/assets/js/data/payment/utils/check-payment-methods.ts index af86f2a37a6..62c61881fe4 100644 --- a/assets/js/data/payment/utils/check-payment-methods.ts +++ b/assets/js/data/payment/utils/check-payment-methods.ts @@ -12,8 +12,6 @@ import { emptyHiddenAddressFields, } from '@woocommerce/base-utils'; import { __, sprintf } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; - import { getExpressPaymentMethods, getPaymentMethods, @@ -33,10 +31,36 @@ import { } from '../../../data/constants'; import { defaultCartState } from '../../../data/cart/default-state'; +const registrationErrorNotice = ( + paymentMethod: + | ExpressPaymentMethodConfigInstance + | PaymentMethodConfigInstance, + errorMessage: string, + express = false +) => { + const { createErrorNotice } = dispatch( 'core/notices' ); + const noticeContext = express + ? noticeContexts.EXPRESS_PAYMENTS + : noticeContexts.PAYMENTS; + const errorText = sprintf( + /* translators: %s the id of the payment method being registered (bank transfer, cheque...) */ + __( + `There was an error registering the payment method with id '%s': `, + 'woo-gutenberg-products-block' + ), + paymentMethod.paymentMethodId + ); + createErrorNotice( `${ errorText } ${ errorMessage }`, { + context: noticeContext, + id: `wc-${ paymentMethod.paymentMethodId }-registration-error`, + } ); +}; + export const checkPaymentMethodsCanPay = async ( express = false ) => { const isEditor = !! select( 'core/editor' ); let availablePaymentMethods = {}; + const paymentMethods = express ? getExpressPaymentMethods() : getPaymentMethods(); @@ -53,10 +77,6 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { }; }; - const noticeContext = express - ? noticeContexts.EXPRESS_PAYMENTS - : noticeContexts.PAYMENTS; - let cartForCanPayArgument: Record< string, unknown > = {}; let canPayArgument: Record< string, unknown > = {}; @@ -94,7 +114,6 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { paymentRequirements: cart.paymentRequirements, receiveCart: dispatch( CART_STORE_KEY ).receiveCart, }; - canPayArgument = { cart: cartForCanPayArgument, cartTotals: cart.totals, @@ -103,6 +122,7 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { billingAddress: cart.billingAddress, shippingAddress: cart.shippingAddress, selectedShippingMethods, + paymentMethods: cart.paymentMethods, paymentRequirements: cart.paymentRequirements, }; } else { @@ -139,68 +159,61 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { selectedShippingMethods: deriveSelectedShippingRates( cartForCanPayArgument.shippingRates ), + paymentMethods: previewCart.payment_methods, paymentRequirements: cartForCanPayArgument.paymentRequirements, }; } - // Order payment methods - let paymentMethodsOrder; - if ( express ) { - paymentMethodsOrder = Object.keys( paymentMethods ); - } else { - paymentMethodsOrder = Array.from( - new Set( [ - ...( getSetting( 'paymentGatewaySortOrder', [] ) as [] ), - ...Object.keys( paymentMethods ), - ] ) - ); - } + // Order payment methods. + const paymentMethodsOrder = express + ? Object.keys( paymentMethods ) + : Array.from( + new Set( [ + ...( getSetting( 'paymentGatewaySortOrder', [] ) as [] ), + ...Object.keys( paymentMethods ), + ] ) + ); + const cartPaymentMethods = canPayArgument.paymentMethods as string[]; for ( let i = 0; i < paymentMethodsOrder.length; i++ ) { const paymentMethodName = paymentMethodsOrder[ i ]; const paymentMethod = paymentMethods[ paymentMethodName ]; + if ( ! paymentMethod ) { continue; } // See if payment method should be available. This always evaluates to true in the editor context. try { + const validForCart = + isEditor || express + ? true + : cartPaymentMethods.includes( paymentMethodName ); const canPay = isEditor ? true - : await Promise.resolve( + : validForCart && + ( await Promise.resolve( paymentMethod.canMakePayment( canPayArgument ) - ); + ) ); if ( canPay ) { if ( typeof canPay === 'object' && canPay.error ) { throw new Error( canPay.error.message ); } - addAvailablePaymentMethod( paymentMethod ); } } catch ( e ) { if ( CURRENT_USER_IS_ADMIN || isEditor ) { - const { createErrorNotice } = dispatch( noticesStore ); - const errorText = sprintf( - /* translators: %s the id of the payment method being registered (bank transfer, cheque...) */ - __( - `There was an error registering the payment method with id '%s': `, - 'woo-gutenberg-products-block' - ), - paymentMethod.paymentMethodId - ); - createErrorNotice( `${ errorText } ${ e }`, { - context: noticeContext, - id: `wc-${ paymentMethod.paymentMethodId }-registration-error`, - } ); + registrationErrorNotice( paymentMethod, e as string, express ); } } } + + const availablePaymentMethodNames = Object.keys( availablePaymentMethods ); const currentlyAvailablePaymentMethods = express ? select( PAYMENT_STORE_KEY ).getAvailableExpressPaymentMethods() : select( PAYMENT_STORE_KEY ).getAvailablePaymentMethods(); - const availablePaymentMethodNames = Object.keys( availablePaymentMethods ); if ( Object.keys( currentlyAvailablePaymentMethods ).length === availablePaymentMethodNames.length && @@ -216,10 +229,11 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { __internalSetAvailablePaymentMethods, __internalSetAvailableExpressPaymentMethods, } = dispatch( PAYMENT_STORE_KEY ); - if ( express ) { - __internalSetAvailableExpressPaymentMethods( availablePaymentMethods ); - return true; - } - __internalSetAvailablePaymentMethods( availablePaymentMethods ); + + const setCallback = express + ? __internalSetAvailableExpressPaymentMethods + : __internalSetAvailablePaymentMethods; + + setCallback( availablePaymentMethods ); return true; }; diff --git a/assets/js/previews/cart.ts b/assets/js/previews/cart.ts index 32ed3f0f159..d5dceee95ef 100644 --- a/assets/js/previews/cart.ts +++ b/assets/js/previews/cart.ts @@ -637,6 +637,7 @@ export const previewCart: CartResponse = { ], }, errors: [], + payment_methods: [ 'cod', 'bacs', 'cheque' ], payment_requirements: [ 'products' ], extensions: {}, }; diff --git a/assets/js/types/type-defs/cart-response.ts b/assets/js/types/type-defs/cart-response.ts index 162ca604042..a2424c4a116 100644 --- a/assets/js/types/type-defs/cart-response.ts +++ b/assets/js/types/type-defs/cart-response.ts @@ -178,6 +178,7 @@ export interface CartResponse { fees: Array< CartResponseFeeItem >; totals: CartResponseTotals; errors: Array< CartResponseErrorItem >; + payment_methods: string[]; payment_requirements: Array< unknown >; extensions: ExtensionsData; } diff --git a/assets/js/types/type-defs/cart.ts b/assets/js/types/type-defs/cart.ts index 3765f2698c6..b1fd1cf7b4f 100644 --- a/assets/js/types/type-defs/cart.ts +++ b/assets/js/types/type-defs/cart.ts @@ -185,7 +185,7 @@ export interface CartErrorItem { message: string; } -export interface Cart { +export interface Cart extends Record< string, unknown > { coupons: Array< CartCouponItem >; shippingRates: Array< CartShippingRate >; shippingAddress: CartShippingAddress; @@ -200,6 +200,7 @@ export interface Cart { fees: Array< CartFeeItem >; totals: CartTotals; errors: Array< CartErrorItem >; + paymentMethods: Array< string >; paymentRequirements: Array< string >; extensions: ExtensionsData; } @@ -209,6 +210,8 @@ export interface CartMeta { isCartDataStale: boolean; applyingCoupon: string; removingCoupon: string; + /* Whether the full address has been previously pushed to the server */ + fullShippingAddressPushed: boolean; } export interface ExtensionCartUpdateArgs { data: Record< string, unknown >; diff --git a/assets/js/types/type-defs/hooks.ts b/assets/js/types/type-defs/hooks.ts index eb967a9e492..a9ee2aa58ef 100644 --- a/assets/js/types/type-defs/hooks.ts +++ b/assets/js/types/type-defs/hooks.ts @@ -56,6 +56,7 @@ export interface StoreCart { extensions: Record< string, unknown >; isLoadingRates: boolean; cartHasCalculatedShipping: boolean; + paymentMethods: string[]; paymentRequirements: string[]; receiveCart: ( cart: CartResponse ) => void; receiveCartContents: ( cart: CartResponse ) => void; diff --git a/assets/js/utils/attributes.ts b/assets/js/utils/attributes.ts index 3470c7a36b2..9a52106f26f 100644 --- a/assets/js/utils/attributes.ts +++ b/assets/js/utils/attributes.ts @@ -10,6 +10,7 @@ import { AttributeWithTerms, isAttributeTerm, } from '@woocommerce/types'; +import { dispatch, select } from '@wordpress/data'; const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] ); @@ -108,3 +109,35 @@ export const getTaxonomyFromAttributeId = ( attributeId: number ) => { const attribute = getAttributeFromID( attributeId ); return attribute ? attribute.taxonomy : null; }; + +/** + * Updates an attribute in a sibling block. Useful if two settings control the same attribute, but you don't want to + * have this attribute exist on a parent block. + */ +export const updateAttributeInSiblingBlock = ( + clientId: string, + attribute: string, + newValue: unknown, + siblingBlockName: string +) => { + const store = select( 'core/block-editor' ); + const actions = dispatch( 'core/block-editor' ); + const parentBlocks = store.getBlockParents( clientId ); + + let shippingMethodsBlockClientId = ''; + + // Loop through parent block's children until we find woocommerce/checkout-shipping-methods-block. + // Also set this attribute in the woocommerce/checkout-shipping-methods-block. + parentBlocks.forEach( ( parent ) => { + const childBlock = store + .getBlock( parent ) + .innerBlocks.find( ( child ) => child.name === siblingBlockName ); + if ( ! childBlock ) { + return; + } + shippingMethodsBlockClientId = childBlock.clientId; + } ); + actions.updateBlockAttributes( shippingMethodsBlockClientId, { + [ attribute ]: newValue, + } ); +}; diff --git a/docs/third-party-developers/extensibility/checkout-block/available-filters.md b/docs/third-party-developers/extensibility/checkout-block/available-filters.md index bc7de68870d..33fc5fc791b 100644 --- a/docs/third-party-developers/extensibility/checkout-block/available-filters.md +++ b/docs/third-party-developers/extensibility/checkout-block/available-filters.md @@ -6,8 +6,11 @@ - [Order Summary Items](#order-summary-items) - [Totals footer item (in Mini Cart, Cart and Checkout)](#totals-footer-item-in-mini-cart-cart-and-checkout) - [Coupons](#coupons) +- [Proceed to Checkout Button Label](#proceed-to-checkout-button-label) +- [Proceed to Checkout Button Link](#proceed-to-checkout-button-link) - [Place Order Button Label](#place-order-button-label) - [Examples](#examples) + - [Changing the wording and the link on the "Proceed to Checkout" button when a specific item is in the Cart](#changing-the-wording-and-the-link-on-the--proceed-to-checkout--button-when-a-specific-item-is-in-the-cart) - [Changing the wording of the Totals label in the Mini Cart, Cart and Checkout](#changing-the-wording-of-the-totals-label-in-the-mini-cart-cart-and-checkout) - [Changing the format of the item's single price](#changing-the-format-of-the-items-single-price) - [Change the name of a coupon](#change-the-name-of-a-coupon) @@ -90,9 +93,25 @@ CartCoupon { } ``` +## Proceed to Checkout Button Label + +The Cart block contains a button which is labelled 'Proceed to Checkout' by default. It can be changed using the following filter. + +| Filter name | Description | Return type | +|--------------------------------|-----------------------------------------------------| ----------- | +| `proceedToCheckoutButtonLabel` | The wanted label of the Proceed to Checkout button. | `string` | + +## Proceed to Checkout Button Link + +The Cart block contains a button which is labelled 'Proceed to Checkout' and links to the Checkout page by default, but can be changed using the following filter. This filter has the current cart passed to it in the third parameter. + +| Filter name | Description | Return type | +|-------------------------------|-------------------------------------------------------------| ----------- | +| `proceedToCheckoutButtonLink` | The URL that the Proceed to Checkout button should link to. | `string` | + ## Place Order Button Label -The Checkout block contains a button which is labelled 'Place Order' by default, but can be changed using the following filter. +The Checkout block contains a button which is labelled 'Place Order' by default, but can be changed using the following filter. This filter has the current cart passed to it in the third parameter. | Filter name | Description | Return type | | ----------------------- | ------------------------------------------- | ----------- | @@ -100,6 +119,45 @@ The Checkout block contains a button which is labelled 'Place Order' by default, ## Examples +### Changing the wording and the link on the "Proceed to Checkout" button when a specific item is in the Cart + +For this example, let's say our store has a checkout page for regular items, and one set up specifically for users purchasing sunglasses. We will use the `wc/store/cart` data store to check whether a specific item (Sunglasses) is in the cart, and if it is, we will change the URL and text on the "Proceed to Checkout" button in the Cart block. + +```ts +registerCheckoutFilters( 'sunglasses-store-extension', { + proceedToCheckoutButtonLabel: ( value, extensions, { cart } ) => { + if ( ! cart.items ) { + return value; + } + const isSunglassesInCart = cart.items.some( + ( item ) => item.name === 'Sunglasses' + ); + // Return the default value if sunglasses is not in the cart. + if ( ! isSunglassesInCart ) { + return value; + } + return 'Proceed to 😎 checkout'; + }, + proceedToCheckoutButtonLink: ( value, extensions, { cart } ) => { + if ( ! cart.items ) { + return value; + } + const isSunglassesInCart = cart.items.some( + ( item ) => item.name === 'Sunglasses' + ); + // Return the default value if sunglasses is not in the cart. + if ( ! isSunglassesInCart ) { + return value; + } + return '/sunglasses-checkout'; + }, +} ); +``` + +| Before | After | +|-------------------------------------------------------------------------------------------------------------------------------------------| ----- | +| image | image | + ### Changing the wording of the Totals label in the Mini Cart, Cart and Checkout For this example, let's suppose we are building an extension that lets customers pay a deposit, and defer the full amount until a later date. To make it easier to understand what the customer is paying and why, let's change the value of `Total` to `Deposit due today`. diff --git a/docs/third-party-developers/extensibility/checkout-block/available-slot-fills.md b/docs/third-party-developers/extensibility/checkout-block/available-slot-fills.md index 5f3500f3461..b5071eec4e3 100644 --- a/docs/third-party-developers/extensibility/checkout-block/available-slot-fills.md +++ b/docs/third-party-developers/extensibility/checkout-block/available-slot-fills.md @@ -57,6 +57,21 @@ Checkout: - `components`: an object containing components you can use to render your own shipping rates, it contains `ShippingRatesControlPackage`. - `context`, equal to the name of the Block in which the fill is rendered: `woocommerce/cart` or `woocommerce/checkout` +## ExperimentalOrderLocalPickupPackages + +This slot renders inside the Checkout Pickup Options block in the Checkout block. It does not render in the Cart block. + +Checkout: + +![Example of ExperimentalOrderLocalPickupPackages in the Checkout block](https://user-images.githubusercontent.com/5656702/222814945-a449d016-0621-4a70-b0f4-2ae1ce6487f1.png) + +### Passed parameters + +- `renderPickupLocation`: a render function that renders the address details of a local pickup option. +- `cart`: `wc/store/cart` data but in `camelCase` instead of `snake_case`. [Object breakdown.](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/c00da597efe4c16fcf5481c213d8052ec5df3766/assets/js/type-defs/cart.ts#L172-L188) +- `extensions`: external data registered by third-party developers using `ExtendSchema`, if you used `ExtendSchema` on `wc/store/cart` you would find your data under your namespace here. +- `components`: an object containing components you can use to render your own pickup rates, it contains `ShippingRatesControlPackage` and `RadioControl`. + ## ExperimentalDiscountsMeta This slot renders below the `CouponCode` input. diff --git a/package-lock.json b/package-lock.json index cb254321150..174f41db841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@storybook/addons": "6.5.9", "@storybook/client-api": "6.5.14", "@storybook/react": "6.5.15", - "@testing-library/jest-dom": "5.16.4", + "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "7.0.2", "@testing-library/user-event": "13.5.0", @@ -229,6 +229,12 @@ "tunnel": "^0.0.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true + }, "node_modules/@aivenio/tsc-output-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@aivenio/tsc-output-parser/-/tsc-output-parser-2.1.1.tgz", @@ -10752,15 +10758,16 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "5.16.4", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", "dev": true, - "license": "MIT", "dependencies": { + "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", "aria-query": "^5.0.0", "chalk": "^3.0.0", - "css": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.5.6", "lodash": "^4.17.15", @@ -22940,16 +22947,6 @@ "node": ">=4" } }, - "node_modules/css": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "source-map": "^0.6.1", - "source-map-resolve": "^0.6.0" - } - }, "node_modules/css-color-names": { "version": "0.0.4", "dev": true, @@ -23168,14 +23165,6 @@ "dev": true, "license": "MIT" }, - "node_modules/css/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cssesc": { "version": "3.0.0", "dev": true, @@ -45437,15 +45426,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-resolve": { - "version": "0.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "dev": true, @@ -50741,6 +50721,12 @@ "tunnel": "^0.0.6" } }, + "@adobe/css-tools": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true + }, "@aivenio/tsc-output-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@aivenio/tsc-output-parser/-/tsc-output-parser-2.1.1.tgz", @@ -57971,14 +57957,16 @@ } }, "@testing-library/jest-dom": { - "version": "5.16.4", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", "dev": true, "requires": { + "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", "aria-query": "^5.0.0", "chalk": "^3.0.0", - "css": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.5.6", "lodash": "^4.17.15", @@ -66663,21 +66651,6 @@ "version": "1.0.0", "optional": true }, - "css": { - "version": "3.0.0", - "dev": true, - "requires": { - "inherits": "^2.0.4", - "source-map": "^0.6.1", - "source-map-resolve": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "dev": true - } - } - }, "css-color-names": { "version": "0.0.4", "dev": true @@ -81949,14 +81922,6 @@ "version": "1.0.2", "dev": true }, - "source-map-resolve": { - "version": "0.6.0", - "dev": true, - "requires": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0" - } - }, "source-map-support": { "version": "0.5.21", "dev": true, diff --git a/package.json b/package.json index 672af0d38f8..36e37afde35 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@storybook/addons": "6.5.9", "@storybook/client-api": "6.5.14", "@storybook/react": "6.5.15", - "@testing-library/jest-dom": "5.16.4", + "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "7.0.2", "@testing-library/user-event": "13.5.0", diff --git a/packages/checkout/components/index.js b/packages/checkout/components/index.js index eb12c946a46..66f7233175b 100644 --- a/packages/checkout/components/index.js +++ b/packages/checkout/components/index.js @@ -3,6 +3,7 @@ export { default as TotalsWrapper } from './totals-wrapper'; export { default as ExperimentalOrderMeta } from './order-meta'; export { default as ExperimentalDiscountsMeta } from './discounts-meta'; export { default as ExperimentalOrderShippingPackages } from './order-shipping-packages'; +export { default as ExperimentalOrderLocalPickupPackages } from './order-local-pickup-packages'; export { default as Panel } from './panel'; export { default as Button } from './button'; export { default as Label } from './label'; diff --git a/packages/checkout/components/order-local-pickup-packages/index.tsx b/packages/checkout/components/order-local-pickup-packages/index.tsx new file mode 100644 index 00000000000..ef07b6d5fb0 --- /dev/null +++ b/packages/checkout/components/order-local-pickup-packages/index.tsx @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { + Cart, + CartShippingPackageShippingRate, +} from '@woocommerce/type-defs/cart'; +import { Component } from '@wordpress/element'; +import { RadioControlOption } from '@woocommerce/base-components/radio-control/types'; + +/** + * Internal dependencies + */ +import { createSlotFill } from '../../slot'; + +const slotName = '__experimentalOrderLocalPickupPackages'; +const { + Fill: ExperimentalOrderLocalPickupPackages, + Slot: OrderLocalPickupPackagesSlot, + // eslint-disable-next-line @typescript-eslint/naming-convention +} = createSlotFill( slotName ); + +interface ExperimentalOrderLocalPickupPackagesProps { + extensions: Record< string, unknown >; + cart: Cart; + components: Record< string, Component >; + renderPickupLocation: ( + option: CartShippingPackageShippingRate, + packageCount: number + ) => RadioControlOption; +} +const Slot = ( { + extensions, + cart, + components, + renderPickupLocation, +}: ExperimentalOrderLocalPickupPackagesProps ) => { + return ( + + ); +}; + +ExperimentalOrderLocalPickupPackages.Slot = Slot; + +export default ExperimentalOrderLocalPickupPackages; diff --git a/packages/checkout/filter-registry/index.ts b/packages/checkout/filter-registry/index.ts index a12846e803f..1f2958c2a22 100644 --- a/packages/checkout/filter-registry/index.ts +++ b/packages/checkout/filter-registry/index.ts @@ -15,11 +15,11 @@ import { isNull, isObject, objectHasProp } from '@woocommerce/types'; */ const returnTrue = (): true => true; -type CheckoutFilterFunction = < T >( - value: T, +type CheckoutFilterFunction< U = unknown > = < T >( + value: T | U, extensions: Record< string, unknown >, args?: CheckoutFilterArguments -) => T; +) => T | U; type CheckoutFilterArguments = | ( Record< string, unknown > & { @@ -32,7 +32,7 @@ let checkoutFilters: Record< Record< string, CheckoutFilterFunction > > = {}; -const cachedValues: Record< string, T > = {}; +let cachedValues: Record< string, unknown > = {}; /** * Register filters for a specific extension. @@ -53,7 +53,8 @@ export const registerCheckoutFilters = ( link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/bb921d21f42e21f38df2b1c87b48e07aa4cb0538/docs/extensibility/available-filters.md#coupons', } ); } - + // Clear cached values when registering new filters because otherwise we get outdated results when applying them. + cachedValues = {}; checkoutFilters = { ...checkoutFilters, [ namespace ]: filters, @@ -220,13 +221,13 @@ export const applyCheckoutFilter = < T >( { ! shouldReRunFilters( filterName, arg, extensions, defaultValue ) && cachedValues[ filterName ] !== undefined ) { - return cachedValues[ filterName ]; + return cachedValues[ filterName ] as T; } const filters = getCheckoutFilters( filterName ); let value = defaultValue; filters.forEach( ( filter ) => { try { - const newValue = filter( value, extensions || {}, arg ); + const newValue = filter( value, extensions || {}, arg ) as T; if ( typeof newValue !== typeof value ) { throw new Error( sprintf( diff --git a/packages/checkout/filter-registry/test/index.js b/packages/checkout/filter-registry/test/index.js index b9a1d1057fb..fe30f97b57a 100644 --- a/packages/checkout/filter-registry/test/index.js +++ b/packages/checkout/filter-registry/test/index.js @@ -82,4 +82,38 @@ describe( 'Checkout registry', () => { expect( newValue.current ).toBe( value ); spy.console.mockRestore(); } ); + + it( 'should allow filters to be registered multiple times and return the correct value each time', () => { + const value = 'Hello World'; + registerCheckoutFilters( filterName, { + [ filterName ]: ( val, extensions, args ) => + val.toUpperCase() + args?.punctuationSign, + } ); + const { result: newValue } = renderHook( () => + applyCheckoutFilter( { + filterName, + defaultValue: value, + arg: { + punctuationSign: '!', + }, + } ) + ); + expect( newValue.current ).toBe( 'HELLO WORLD!' ); + registerCheckoutFilters( filterName, { + [ filterName ]: ( val, extensions, args ) => + args?.punctuationSign + + val.toUpperCase() + + args?.punctuationSign, + } ); + const { result: newValue2 } = renderHook( () => + applyCheckoutFilter( { + filterName, + defaultValue: value, + arg: { + punctuationSign: '!', + }, + } ) + ); + expect( newValue2.current ).toBe( '!HELLO WORLD!' ); + } ); } ); diff --git a/patterns/product-query-1-1-image-4-column-products-row.php b/patterns/product-query-1-1-image-4-column-products-row.php index 474a21539bb..e307e0a817b 100644 --- a/patterns/product-query-1-1-image-4-column-products-row.php +++ b/patterns/product-query-1-1-image-4-column-products-row.php @@ -8,13 +8,13 @@ ?>

- + - + - + - + diff --git a/patterns/product-query-3-column-product-row.php b/patterns/product-query-3-column-product-row.php index c1dc36486ee..eb04f23e4ba 100644 --- a/patterns/product-query-3-column-product-row.php +++ b/patterns/product-query-3-column-product-row.php @@ -8,13 +8,13 @@ ?>
- + - + - + - + diff --git a/patterns/product-query-4-column-product-row.php b/patterns/product-query-4-column-product-row.php index 3b4c0366fa2..45b09a0d54d 100644 --- a/patterns/product-query-4-column-product-row.php +++ b/patterns/product-query-4-column-product-row.php @@ -10,15 +10,15 @@
- + -
+
-
+
- + diff --git a/patterns/product-query-large-image-product-gallery.php b/patterns/product-query-large-image-product-gallery.php index 99abd97f09f..ea4fced2cf7 100644 --- a/patterns/product-query-large-image-product-gallery.php +++ b/patterns/product-query-large-image-product-gallery.php @@ -10,9 +10,9 @@
- + - + diff --git a/patterns/product-query-minimal-5-column-products-row.php b/patterns/product-query-minimal-5-column-products-row.php index 4d7984c6b53..769bf297daa 100644 --- a/patterns/product-query-minimal-5-column-products-row.php +++ b/patterns/product-query-minimal-5-column-products-row.php @@ -8,15 +8,15 @@ ?>
- +
-
+
-
+
diff --git a/patterns/product-query-minimal-product-list.php b/patterns/product-query-minimal-product-list.php index e2c07faa38e..5c97cb1eb26 100644 --- a/patterns/product-query-minimal-product-list.php +++ b/patterns/product-query-minimal-product-list.php @@ -14,11 +14,11 @@ -
+
- + -
+
diff --git a/patterns/product-query-product-gallery.php b/patterns/product-query-product-gallery.php index e0595b93c5b..6943e5184ad 100644 --- a/patterns/product-query-product-gallery.php +++ b/patterns/product-query-product-gallery.php @@ -8,15 +8,15 @@ ?>
- + - + - + - + - + diff --git a/patterns/product-query-product-list-with-1-1-images.php b/patterns/product-query-product-list-with-1-1-images.php index 452438b713a..24c7444b564 100644 --- a/patterns/product-query-product-list-with-1-1-images.php +++ b/patterns/product-query-product-list-with-1-1-images.php @@ -14,15 +14,15 @@ -
+
- + - + - + -
+
diff --git a/patterns/product-query-product-list-with-full-product-description.php b/patterns/product-query-product-list-with-full-product-description.php index f90864add60..ee23a6518e0 100644 --- a/patterns/product-query-product-list-with-full-product-description.php +++ b/patterns/product-query-product-list-with-full-product-description.php @@ -14,23 +14,23 @@ -
+
- + - + - +
-
+
-
+
diff --git a/src/Assets/AssetDataRegistry.php b/src/Assets/AssetDataRegistry.php index 521e7000cac..a72a81c960d 100644 --- a/src/Assets/AssetDataRegistry.php +++ b/src/Assets/AssetDataRegistry.php @@ -67,8 +67,8 @@ public function __construct( Api $asset_api ) { */ protected function init() { add_action( 'init', array( $this, 'register_data_script' ) ); - add_action( 'wp_print_footer_scripts', array( $this, 'enqueue_asset_data' ), 1 ); - add_action( 'admin_print_footer_scripts', array( $this, 'enqueue_asset_data' ), 1 ); + add_action( 'wp_print_footer_scripts', array( $this, 'enqueue_asset_data' ), 2 ); + add_action( 'admin_print_footer_scripts', array( $this, 'enqueue_asset_data' ), 2 ); } /** diff --git a/src/BlockTypes/Checkout.php b/src/BlockTypes/Checkout.php index 5e7da29c449..75b15c45abc 100644 --- a/src/BlockTypes/Checkout.php +++ b/src/BlockTypes/Checkout.php @@ -318,17 +318,17 @@ function( $acc, $zone ) { } if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalPaymentMethods' ) ) { - $payment_gateways = $this->get_enabled_payment_gateways(); + // These are used to show options in the sidebar. We want to get the full list of enabled payment methods, + // not just the ones that are available for the current cart (which may not exist yet). + $payment_methods = $this->get_enabled_payment_gateways(); $formatted_payment_methods = array_reduce( - $payment_gateways, + $payment_methods, function( $acc, $method ) { - if ( 'yes' === $method->enabled ) { - $acc[] = [ - 'id' => $method->id, - 'title' => $method->method_title, - 'description' => $method->method_description, - ]; - } + $acc[] = [ + 'id' => $method->id, + 'title' => $method->method_title, + 'description' => $method->method_description, + ]; return $acc; }, [] diff --git a/src/BlockTypes/MiniCart.php b/src/BlockTypes/MiniCart.php index 8797b027176..58d40c056e1 100644 --- a/src/BlockTypes/MiniCart.php +++ b/src/BlockTypes/MiniCart.php @@ -70,6 +70,9 @@ public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_ protected function initialize() { parent::initialize(); add_action( 'wp_loaded', array( $this, 'register_empty_cart_message_block_pattern' ) ); + add_action( 'wp_print_footer_scripts', array( $this, 'enqueue_wc_settings' ), 1 ); + // We need this action to run after the equivalent in AssetDataRegistry. + add_action( 'wp_print_footer_scripts', array( $this, 'print_lazy_load_scripts' ), 3 ); } /** @@ -149,6 +152,86 @@ protected function enqueue_data( array $attributes = [] ) { ); } + $this->asset_data_registry->add( + 'displayCartPricesIncludingTax', + $this->display_cart_prices_including_tax, + true + ); + + $template_part_edit_uri = ''; + + if ( + current_user_can( 'edit_theme_options' ) && + wc_current_theme_is_fse_theme() + ) { + $theme_slug = BlockTemplateUtils::theme_has_template_part( 'mini-cart' ) ? wp_get_theme()->get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG; + + if ( version_compare( get_bloginfo( 'version' ), '5.9', '<' ) ) { + $site_editor_uri = add_query_arg( + array( 'page' => 'gutenberg-edit-site' ), + admin_url( 'themes.php' ) + ); + } else { + $site_editor_uri = add_query_arg( + array( + 'canvas' => 'edit', + 'path' => '/template-parts/single', + ), + admin_url( 'site-editor.php' ) + ); + } + + $template_part_edit_uri = add_query_arg( + array( + 'postId' => sprintf( '%s//%s', $theme_slug, 'mini-cart' ), + 'postType' => 'wp_template_part', + ), + $site_editor_uri + ); + } + + $this->asset_data_registry->add( + 'templatePartEditUri', + $template_part_edit_uri, + '' + ); + + /** + * Fires after cart block data is registered. + * + * @since 5.8.0 + */ + do_action( 'woocommerce_blocks_cart_enqueue_data' ); + } + + /** + * Function to enqueue `wc-settings` script and dequeue it later on so when + * AssetDataRegistry runs, it appears enqueued- This allows the necessary + * data to be printed to the page. + */ + public function enqueue_wc_settings() { + // Return early if another block has already enqueued `wc-settings`. + if ( wp_script_is( 'wc-settings', 'enqueued' ) ) { + return; + } + // We are lazy-loading `wc-settings`, but we need to enqueue it here so + // AssetDataRegistry knows it's going to load. + wp_enqueue_script( 'wc-settings' ); + // After AssetDataRegistry function runs, we dequeue `wc-settings`. + add_action( 'wp_print_footer_scripts', array( $this, 'dequeue_wc_settings' ), 4 ); + } + + /** + * Function to dequeue `wc-settings` script. + */ + public function dequeue_wc_settings() { + wp_dequeue_script( 'wc-settings' ); + } + + /** + * Prints the variable containing information about the scripts to lazy load. + */ + public function print_lazy_load_scripts() { $script_data = $this->asset_api->get_script_data( 'build/mini-cart-component-frontend.js' ); $num_dependencies = count( $script_data['dependencies'] ); @@ -207,62 +290,14 @@ protected function enqueue_data( array $attributes = [] ) { ); } - $this->asset_data_registry->add( - 'mini_cart_block_frontend_dependencies', - $this->scripts_to_lazy_load, - true - ); - - $this->asset_data_registry->add( - 'displayCartPricesIncludingTax', - $this->display_cart_prices_including_tax, - true - ); - - $template_part_edit_uri = ''; - - if ( - current_user_can( 'edit_theme_options' ) && - wc_current_theme_is_fse_theme() - ) { - $theme_slug = BlockTemplateUtils::theme_has_template_part( 'mini-cart' ) ? wp_get_theme()->get_stylesheet() : BlockTemplateUtils::PLUGIN_SLUG; - - if ( version_compare( get_bloginfo( 'version' ), '5.9', '<' ) ) { - $site_editor_uri = add_query_arg( - array( 'page' => 'gutenberg-edit-site' ), - admin_url( 'themes.php' ) - ); - } else { - $site_editor_uri = add_query_arg( - array( - 'canvas' => 'edit', - 'path' => '/template-parts/single', - ), - admin_url( 'site-editor.php' ) - ); - } - - $template_part_edit_uri = add_query_arg( - array( - 'postId' => sprintf( '%s//%s', $theme_slug, 'mini-cart' ), - 'postType' => 'wp_template_part', - ), - $site_editor_uri - ); - } + $data = rawurlencode( wp_json_encode( $this->scripts_to_lazy_load ) ); + $mini_cart_dependencies_script = "var wcBlocksMiniCartFrontendDependencies = JSON.parse( decodeURIComponent( '" . esc_js( $data ) . "' ) );"; - $this->asset_data_registry->add( - 'templatePartEditUri', - $template_part_edit_uri, - '' + wp_add_inline_script( + 'wc-mini-cart-block-frontend', + $mini_cart_dependencies_script, + 'before' ); - - /** - * Fires after cart block data is registered. - * - * @since 5.8.0 - */ - do_action( 'woocommerce_blocks_cart_enqueue_data' ); } /** diff --git a/src/Payments/Api.php b/src/Payments/Api.php index 3f37184a63d..51b0a88513e 100644 --- a/src/Payments/Api.php +++ b/src/Payments/Api.php @@ -82,6 +82,8 @@ private function is_payment_gateway_enabled( $gateway ) { public function add_payment_method_script_data() { // Enqueue the order of enabled gateways as `paymentGatewaySortOrder`. if ( ! $this->asset_registry->exists( 'paymentGatewaySortOrder' ) ) { + // We use payment_gateways() here to get the sort order of all enabled gateways. Some may be + // programmatically disabled later on, but we still need to know where the enabled ones are in the list. $payment_gateways = WC()->payment_gateways->payment_gateways(); $enabled_gateways = array_filter( $payment_gateways, array( $this, 'is_payment_gateway_enabled' ) ); $this->asset_registry->add( 'paymentGatewaySortOrder', array_keys( $enabled_gateways ) ); diff --git a/src/Shipping/ShippingController.php b/src/Shipping/ShippingController.php index ce88e97fa98..d0bdae7e910 100644 --- a/src/Shipping/ShippingController.php +++ b/src/Shipping/ShippingController.php @@ -3,6 +3,8 @@ use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi; use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry; +use Automattic\WooCommerce\Blocks\Tests\BlockTypes\Cart; +use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils; use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils; use Automattic\WooCommerce\Utilities\ArrayUtil; @@ -51,6 +53,7 @@ function() { ); } $this->asset_data_registry->add( 'collectableMethodIds', array( 'Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils', 'get_local_pickup_method_ids' ), true ); + $this->asset_data_registry->add( 'shippingCostRequiresAddress', get_option( 'woocommerce_shipping_cost_requires_address', false ) === 'yes' ); add_action( 'rest_api_init', [ $this, 'register_settings' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'admin_scripts' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'hydrate_client_settings' ] ); @@ -60,6 +63,105 @@ function() { add_filter( 'woocommerce_shipping_packages', array( $this, 'filter_shipping_packages' ) ); add_filter( 'pre_update_option_woocommerce_pickup_location_settings', array( $this, 'flush_cache' ) ); add_filter( 'pre_update_option_pickup_location_pickup_locations', array( $this, 'flush_cache' ) ); + add_filter( 'woocommerce_shipping_settings', array( $this, 'remove_shipping_settings' ) ); + add_filter( 'wc_shipping_enabled', array( $this, 'force_shipping_enabled' ), 100, 1 ); + + // This is required to short circuit `show_shipping` from class-wc-cart.php - without it, that function + // returns based on the option's value in the DB and we can't override it any other way. + add_filter( 'option_woocommerce_shipping_cost_requires_address', array( $this, 'override_cost_requires_address_option' ) ); + } + + /** + * Overrides the option to force shipping calculations NOT to wait until an address is entered, but only if the + * Checkout page contains the Checkout Block. + * + * @param boolean $value Whether shipping cost calculation requires address to be entered. + * @return boolean Whether shipping cost calculation should require an address to be entered before calculating. + */ + public function override_cost_requires_address_option( $value ) { + if ( CartCheckoutUtils::is_checkout_block_default() ) { + return 'no'; + } + return $value; + } + + /** + * Force shipping to be enabled if the Checkout block is in use on the Checkout page. + * + * @param boolean $enabled Whether shipping is currently enabled. + * @return boolean Whether shipping should continue to be enabled/disabled. + */ + public function force_shipping_enabled( $enabled ) { + if ( CartCheckoutUtils::is_checkout_block_default() ) { + return true; + } + return $enabled; + } + + /** + * If the Checkout block Remove shipping settings from WC Core's admin panels that are now block settings. + * + * @param array $settings The default WC shipping settings. + * @return array|mixed The filtered settings with relevant items removed. + */ + public function remove_shipping_settings( $settings ) { + + // Do not add the "Hide shipping costs until an address is entered" setting if the Checkout block is not used on the WC checkout page. + if ( CartCheckoutUtils::is_checkout_block_default() ) { + $settings = array_filter( + $settings, + function( $setting ) { + return ! in_array( + $setting['id'], + array( + 'woocommerce_shipping_cost_requires_address', + ), + true + ); + } + ); + } + + // Do not add the shipping calculator setting if the Cart block is not used on the WC cart page. + if ( CartCheckoutUtils::is_cart_block_default() ) { + + // If the Cart is default, but not the checkout, we should ensure the 'Calculations' title is added to the + // `woocommerce_shipping_cost_requires_address` options group, since it is attached to the + // `woocommerce_enable_shipping_calc` option that we're going to remove later. + if ( ! CartCheckoutUtils::is_checkout_block_default() ) { + $calculations_title = ''; + + // Get Calculations title so we can add it to 'Hide shipping costs until an address is entered' option. + foreach ( $settings as $setting ) { + if ( 'woocommerce_enable_shipping_calc' === $setting['id'] ) { + $calculations_title = $setting['title']; + break; + } + } + + // Add Calculations title to 'Hide shipping costs until an address is entered' option. + foreach ( $settings as $index => $setting ) { + if ( 'woocommerce_shipping_cost_requires_address' === $setting['id'] ) { + $settings[ $index ]['title'] = $calculations_title; + $settings[ $index ]['checkboxgroup'] = 'start'; + break; + } + } + } + $settings = array_filter( + $settings, + function( $setting ) { + return ! in_array( + $setting['id'], + array( + 'woocommerce_enable_shipping_calc', + ), + true + ); + } + ); + } + return $settings; } /** @@ -223,8 +325,7 @@ public function admin_scripts() { * Registers the Local Pickup shipping method used by the Checkout Block. */ public function register_local_pickup() { - $checkout_page_id = wc_get_page_id( 'checkout' ); - if ( $checkout_page_id && has_block( 'woocommerce/checkout', $checkout_page_id ) ) { + if ( CartCheckoutUtils::is_checkout_block_default() ) { wc()->shipping->register_shipping_method( new PickupLocation() ); } } diff --git a/src/StoreApi/Schemas/V1/CartSchema.php b/src/StoreApi/Schemas/V1/CartSchema.php index 2ec6377e254..442bbfe694b 100644 --- a/src/StoreApi/Schemas/V1/CartSchema.php +++ b/src/StoreApi/Schemas/V1/CartSchema.php @@ -309,6 +309,12 @@ public function get_properties() { 'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ), ], ], + 'payment_methods' => [ + 'description' => __( 'List of available payment method IDs that can be used to process the order.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], 'payment_requirements' => [ 'description' => __( 'List of required payment gateway features to process the order.', 'woo-gutenberg-products-block' ), 'type' => 'array', @@ -373,6 +379,7 @@ public function get_item_response( $cart ) { ] ), 'errors' => $cart_errors, + 'payment_methods' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ), 'payment_requirements' => $this->extend->get_payment_requirements(), self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ), ]; diff --git a/src/StoreApi/Schemas/V1/CheckoutSchema.php b/src/StoreApi/Schemas/V1/CheckoutSchema.php index 764f073230e..49521e11045 100644 --- a/src/StoreApi/Schemas/V1/CheckoutSchema.php +++ b/src/StoreApi/Schemas/V1/CheckoutSchema.php @@ -112,7 +112,7 @@ public function get_properties() { 'description' => __( 'The ID of the payment method being used to process the payment.', 'woo-gutenberg-products-block' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], - 'enum' => wc()->payment_gateways->get_payment_gateway_ids(), + 'enum' => array_values( wp_list_pluck( WC()->payment_gateways->get_available_payment_gateways(), 'id' ) ), ], 'create_account' => [ 'description' => __( 'Whether to create a new user account as part of order processing.', 'woo-gutenberg-products-block' ), diff --git a/src/StoreApi/Utilities/OrderController.php b/src/StoreApi/Utilities/OrderController.php index 77de6c0dce2..73eaaae4794 100644 --- a/src/StoreApi/Utilities/OrderController.php +++ b/src/StoreApi/Utilities/OrderController.php @@ -52,7 +52,7 @@ public function update_order_from_cart( \WC_Order $order ) { * - While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set * the customer address to the pickup location address if local pickup is the chosen method. * - * Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are caculated, + * Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated, * resulting in the wrong taxes being applied with local pickup. * * The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location @@ -63,7 +63,20 @@ public function update_order_from_cart( \WC_Order $order ) { add_filter( 'woocommerce_order_get_tax_location', function( $location ) { - return wc()->customer ? wc()->customer->get_taxable_address() : $location; + + if ( ! is_null( wc()->customer ) ) { + + $taxable_address = wc()->customer->get_taxable_address(); + + $location = array( + 'country' => $taxable_address[0], + 'state' => $taxable_address[1], + 'postcode' => $taxable_address[2], + 'city' => $taxable_address[3], + ); + } + + return $location; } ); diff --git a/src/StoreApi/docs/cart.md b/src/StoreApi/docs/cart.md index c6fef709b1a..868cf20639d 100644 --- a/src/StoreApi/docs/cart.md +++ b/src/StoreApi/docs/cart.md @@ -262,6 +262,7 @@ All endpoints under `/cart` (listed in this doc) return responses in the same fo "tax_lines": [] }, "errors": [], + "payment_methods": [ "cod", "bacs", "cheque" ], "payment_requirements": [ "products" ], "extensions": {} } diff --git a/src/Templates/SingleProductTemplateCompatibility.php b/src/Templates/SingleProductTemplateCompatibility.php index ca60ecdf3a2..378228c8dc8 100644 --- a/src/Templates/SingleProductTemplateCompatibility.php +++ b/src/Templates/SingleProductTemplateCompatibility.php @@ -287,8 +287,7 @@ private static function wrap_single_product_template( $template_content ) { $parsed_blocks = parse_blocks( $template_content ); $grouped_blocks = self::group_blocks( $parsed_blocks ); - // @todo Check this list before terminating the Blockfied Single Product Template project. - $single_product_template_blocks = array( 'woocommerce/product-image-gallery', 'woocommerce/product-details', 'woocommerce/add-to-cart-form' ); + $single_product_template_blocks = array( 'woocommerce/product-image-gallery', 'woocommerce/product-details', 'woocommerce/add-to-cart-form', 'woocommerce/product-meta', 'woocommerce/product-price', 'woocommerce/breadcrumbs' ); $wrapped_blocks = array_map( function( $blocks ) use ( $single_product_template_blocks ) { diff --git a/src/Utils/CartCheckoutUtils.php b/src/Utils/CartCheckoutUtils.php new file mode 100644 index 00000000000..8252ca0fcb3 --- /dev/null +++ b/src/Utils/CartCheckoutUtils.php @@ -0,0 +1,28 @@ +\n
\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n
\n\n\n\n
\n
\n
\n
\n"} +{"title":"Checkout Block","pageContent":"\n
\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n
\n\n\n\n
\n
\n
\n
\n"} diff --git a/tests/e2e/specs/backend/checkout.test.js b/tests/e2e/specs/backend/checkout.test.js index bc905565682..cf661d8e429 100644 --- a/tests/e2e/specs/backend/checkout.test.js +++ b/tests/e2e/specs/backend/checkout.test.js @@ -22,6 +22,7 @@ import { openWidgetEditor, closeModalIfExists, } from '../../utils.js'; +import { merchant as merchantUtils } from '../../../utils/merchant'; const block = { name: 'Checkout', @@ -64,6 +65,79 @@ describe( `${ block.name } Block`, () => { await selectBlockByName( block.slug ); } ); + it( 'can toggle "hide shipping costs until an address is entered"', async () => { + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const toggleLabel = await findLabelWithText( + 'Hide shipping costs until an address is entered' + ); + await toggleLabel.click(); + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + await expect( shippingOptionsRequireAddressText ).toHaveLength( + 1 + ); + + await toggleLabel.click(); + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); + + it( 'toggles the same setting in shipping method and shipping methods blocks', async () => { + await merchantUtils.goToLocalPickupSettingsPage(); + await merchantUtils.enableLocalPickup(); + await merchantUtils.saveLocalPickupSettingsPageWithRefresh(); + + await visitBlockPage( `${ block.name } Block` ); + await expect( page ).toClick( + '.wc-block-checkout__shipping-method button', + { text: 'Shipping' } + ); + await openDocumentSettingsSidebar(); + const toggleLabel = await findLabelWithText( + 'Hide shipping costs until an address is entered' + ); + await toggleLabel.click(); + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + const shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + await expect( shippingMethodSettingIsChecked ).toBe( true ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const [ shippingMethodsLabel ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodsLabelForValue = await page.evaluate( + ( passedShippingMethodsLabel ) => + passedShippingMethodsLabel.getAttribute( 'for' ), + shippingMethodsLabel + ); + const shippingMethodLabelIsChecked = await page.evaluate( + ( passedShippingMethodsLabelForValue ) => + document.getElementById( + passedShippingMethodsLabelForValue + ).checked, + shippingMethodsLabelForValue + ); + expect( shippingMethodSettingIsChecked ).toBe( + shippingMethodLabelIsChecked + ); + } ); + it( 'can enable dark mode inputs', async () => { const toggleLabel = await findLabelWithText( 'Dark mode inputs' diff --git a/tests/e2e/specs/merchant/checkout-terms.test.js b/tests/e2e/specs/merchant/checkout-terms.test.js index c27733f85cc..70824729214 100644 --- a/tests/e2e/specs/merchant/checkout-terms.test.js +++ b/tests/e2e/specs/merchant/checkout-terms.test.js @@ -83,6 +83,11 @@ describe( 'Merchant → Checkout → Can adjust T&S and Privacy Policy options', await shopper.block.goToCheckout(); await shopper.block.fillBillingDetails( BILLING_DETAILS ); + // Wait for the "Place Order" button to avoid flakey tests. + await page.waitForSelector( + '.wc-block-components-checkout-place-order-button:not([disabled])' + ); + // Placing an order now, must lead to an error. await page.click( '.wc-block-components-checkout-place-order-button' ); diff --git a/tests/e2e/specs/merchant/local-pickup.test.ts b/tests/e2e/specs/merchant/local-pickup.test.ts index 6c34d155f08..cc845c53789 100644 --- a/tests/e2e/specs/merchant/local-pickup.test.ts +++ b/tests/e2e/specs/merchant/local-pickup.test.ts @@ -3,52 +3,17 @@ */ import { switchUserToAdmin, visitAdminPage } from '@wordpress/e2e-test-utils'; import { findLabelWithText } from '@woocommerce/blocks-test-utils'; +import WooCommerceRestApi from '@woocommerce/woocommerce-rest-api'; +import { default as axios } from 'axios'; -const goToSettingsPage = async () => { - await visitAdminPage( - 'admin.php', - 'page=wc-settings&tab=shipping§ion=pickup_location' - ); - await page.waitForSelector( - '#wc-shipping-method-pickup-location-settings-container' - ); -}; - -const saveSettingsPageWithRefresh = async () => { - await expect( page ).toClick( 'button', { - text: 'Save changes', - } ); - await expect( page ).toMatchElement( '.components-snackbar__content', { - text: 'Local Pickup settings have been saved.', - } ); - await goToSettingsPage(); -}; +/** + * Internal dependencies + */ +import { merchant } from '../../../utils'; const setDefaults = async () => { - const enabledLabel = await findLabelWithText( 'Enable local pickup' ); - const enabledChecked = await page.$eval( - '#inspector-checkbox-control-1', - ( el ) => ( el as HTMLInputElement ).checked - ); - if ( enabledChecked ) { - await enabledLabel.click(); - } - - await expect( page ).toFill( - 'input[name="local_pickup_title"]', - 'Local Pickup' - ); - - const costLabel = await findLabelWithText( - 'Add a price for customers who choose local pickup' - ); - const costChecked = await page.$eval( - '#inspector-checkbox-control-1', - ( el ) => ( el as HTMLInputElement ).checked - ); - if ( costChecked ) { - await costLabel.click(); - } + await merchant.enableLocalPickup(); + await merchant.removeCostForLocalPickup(); }; const clearLocations = async () => { @@ -66,32 +31,123 @@ const clearLocations = async () => { } }; +/** + * Sets the WC Cart and Checkout page IDs to the IDs of the pages with the given slugs. + */ +const setCartCheckoutPages = async ( { + cartSlug, + checkoutSlug, +}: { + cartSlug: string; + checkoutSlug: string; +} ) => { + const WPAPI = `${ process.env.WORDPRESS_BASE_URL }/wp-json/wp/v2/pages`; + const response = await axios.get( `${ WPAPI }?per_page=100` ); + const pages = response.data; + const cartBlock = pages.find( ( page ) => page.slug === cartSlug ); + const checkoutBlock = pages.find( ( page ) => page.slug === checkoutSlug ); + const WooCommerce = new WooCommerceRestApi( { + url: `${ process.env.WORDPRESS_BASE_URL }/`, + consumerKey: 'consumer_key', // Your consumer key + consumerSecret: 'consumer_secret', // Your consumer secret + version: 'wc/v3', + axiosConfig: { + auth: { + username: process.env.WORDPRESS_LOGIN, + password: process.env.WORDPRESS_PASSWORD, + }, + }, + } ); + const fixture = [ + { + id: 'woocommerce_cart_page_id', + value: cartBlock.id.toString() || '', + }, + { + id: 'woocommerce_checkout_page_id', + value: checkoutBlock.id.toString() || '', + }, + ]; + + await WooCommerce.post( 'settings/advanced/batch', { + update: fixture, + } ); +}; describe( `Local Pickup Settings`, () => { beforeAll( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); await setDefaults(); await clearLocations(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); } ); afterAll( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); await setDefaults(); await clearLocations(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); } ); beforeEach( async () => { await switchUserToAdmin(); - await goToSettingsPage(); + await merchant.goToLocalPickupSettingsPage(); } ); it( 'renders without crashing', async () => { await expect( page ).toMatchElement( '#local-pickup-settings' ); } ); + describe( 'Core Settings', () => { + afterAll( async () => { + await setCartCheckoutPages( { + cartSlug: 'cart-block', + checkoutSlug: 'checkout-block', + } ); + } ); + it( 'hides the correct shipping options if Checkout block is the default', async () => { + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=options' + ); + const hideShippingLabel = await findLabelWithText( + 'Hide shipping costs until an address is entered' + ); + expect( hideShippingLabel ).toBeUndefined(); + + const shippingCalculatorLabel = await findLabelWithText( + 'Enable the shipping calculator on the cart page' + ); + expect( shippingCalculatorLabel ).toBeUndefined(); + } ); + + it( 'does not hide the relevant setting if Cart or Checkout block is not the default', async () => { + await setCartCheckoutPages( { + cartSlug: 'cart', + checkoutSlug: 'checkout', + } ); + + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=advanced' + ); + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=options' + ); + const hideShippingLabel = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + await expect( hideShippingLabel ).toHaveLength( 1 ); + + const shippingCalculatorLabel = await page.$x( + '//label[contains(., "Enable the shipping calculator on the cart page")]' + ); + await expect( shippingCalculatorLabel ).toHaveLength( 1 ); + } ); + } ); + describe( 'Global Settings', () => { it( 'allows toggling of enabled on', async () => { const initialChecked = await page.$eval( @@ -102,7 +158,7 @@ describe( `Local Pickup Settings`, () => { 'Enable local pickup' ); await toggleLabel.click(); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); expect( await page.$eval( @@ -118,7 +174,7 @@ describe( `Local Pickup Settings`, () => { 'Local Pickup Test' ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); expect( await page.$eval( @@ -158,7 +214,7 @@ describe( `Local Pickup Settings`, () => { 'none' ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); const refreshChecked = await page.$eval( '#inspector-checkbox-control-1', @@ -218,7 +274,7 @@ describe( `Local Pickup Settings`, () => { text: 'Done', } ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); await expect( page ).toMatchElement( '.pickup-locations tbody tr td', @@ -247,7 +303,7 @@ describe( `Local Pickup Settings`, () => { text: 'Delete location', } ); - await saveSettingsPageWithRefresh(); + await merchant.saveLocalPickupSettingsPageWithRefresh(); await expect( page ).not.toMatchElement( '.pickup-locations tbody tr td', { diff --git a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js index 0a3eb12f26f..8fbb77c9c40 100644 --- a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js +++ b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js @@ -15,7 +15,7 @@ import { getToggleIdByLabel, switchBlockInspectorTabWhenGutenbergIsInstalled, } from '@woocommerce/blocks-test-utils'; - +import { visitAdminPage } from '@wordpress/e2e-test-utils'; /** * Internal dependencies */ @@ -27,17 +27,101 @@ import { SHIPPING_DETAILS, SIMPLE_PHYSICAL_PRODUCT_NAME, SIMPLE_VIRTUAL_PRODUCT_NAME, + BASE_URL, } from '../../../../utils'; - +import { merchant as merchantUtils } from '../../../../utils/merchant'; import { createCoupon } from '../../../utils'; let coupon; describe( 'Shopper → Checkout', () => { beforeAll( async () => { + // Check that Woo Collection is enabled. + await page.goto( + `${ BASE_URL }?check_third_party_local_pickup_method` + ); + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toMatch( 'Woo Collection' ); + await shopper.block.emptyCart(); } ); + describe( 'Local pickup', () => { + beforeAll( async () => { + // Enable local pickup. + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=pickup_location' + ); + + const localPickupCheckbox = await page.waitForXPath( + '//input[@name="local_pickup_enabled"]' + ); + const isCheckboxChecked = await page.evaluate( + ( checkbox ) => checkbox.checked, + localPickupCheckbox + ); + + if ( isCheckboxChecked === true ) { + return; + } + + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toClick( 'label', { + text: 'Enable local pickup', + } ); + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toClick( 'button', { + text: 'Save changes', + } ); + } ); + afterAll( async () => { + // Disable local pickup. + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=pickup_location' + ); + + const localPickupCheckbox = await page.waitForXPath( + '//input[@name="local_pickup_enabled"]' + ); + const isCheckboxChecked = await page.evaluate( + ( checkbox ) => checkbox.checked, + localPickupCheckbox + ); + + // Skip this if it's already unchecked. + if ( isCheckboxChecked === false ) { + return; + } + + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toClick( 'label', { + text: 'Enable local pickup', + } ); + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toClick( 'button', { + text: 'Save changes', + } ); + } ); + it( 'The shopper can choose a local pickup option', async () => { + await shopper.block.emptyCart(); + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + await expect( page ).toClick( + '.wc-block-checkout__shipping-method-option-title', + { + text: 'Local Pickup', + } + ); + expect( page ).toMatch( 'Woo Collection' ); + await shopper.block.fillBillingDetails( BILLING_DETAILS ); + await shopper.block.placeOrder(); + await shopper.block.verifyBillingDetails( BILLING_DETAILS ); + } ); + } ); + describe( 'Payment Methods', () => { it( 'User can change payment methods', async () => { await shopper.block.emptyCart(); @@ -94,8 +178,12 @@ describe( 'Shopper → Checkout', () => { await shopper.block.goToShop(); await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); await shopper.block.goToCheckout(); - await page.waitForSelector( '#checkbox-control-0' ); - await unsetCheckbox( '#checkbox-control-0' ); + await page.waitForSelector( + '.wc-block-checkout__use-address-for-billing input[type="checkbox"]' + ); + await unsetCheckbox( + '.wc-block-checkout__use-address-for-billing input[type="checkbox"]' + ); await shopper.block.fillShippingDetails( SHIPPING_DETAILS ); await shopper.block.fillBillingDetails( BILLING_DETAILS ); await shopper.block.placeOrder(); @@ -209,6 +297,44 @@ describe( 'Shopper → Checkout', () => { const NORMAL_SHIPPING_NAME = 'Normal Shipping'; const NORMAL_SHIPPING_PRICE = '$20.00'; + afterAll( async () => { + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + let shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + if ( ! shippingMethodSettingIsChecked ) { + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + } + shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + + await merchantUtils.disableLocalPickup(); + } ); + it( 'User can choose free shipping', async () => { await shopper.block.goToShop(); await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); @@ -238,11 +364,175 @@ describe( 'Shopper → Checkout', () => { await expect( page ).toMatch( 'Order received' ); await expect( page ).toMatch( NORMAL_SHIPPING_NAME ); } ); + + it( 'User sees the correct shipping options based on block settings', async () => { + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + const [ label ] = await page.$x( + '//label[contains(., "Hide shipping costs until an address is entered")]' + ); + const shippingMethodForValue = await page.evaluate( + ( passedLabel ) => passedLabel.getAttribute( 'for' ), + label + ); + let shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + if ( ! shippingMethodSettingIsChecked ) { + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + } + shippingMethodSettingIsChecked = await page.evaluate( + ( passedShippingMethodForValue ) => + document.getElementById( passedShippingMethodForValue ) + .checked, + shippingMethodForValue + ); + await expect( shippingMethodSettingIsChecked ).toBe( true ); + await saveOrPublish(); + await shopper.block.emptyCart(); + // Log out to have a fresh empty cart. + await shopper.logout(); + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + // Expect no shipping options to be shown, but with a friendly message. + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + expect( shippingOptionsRequireAddressText ).toHaveLength( 1 ); + + // Enter the address and expect shipping options to be shown. + await shopper.block.fillInCheckoutWithTestData(); + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + + // This sequence will reset the checkout form. + await shopper.login(); + await shopper.logout(); + + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await unsetCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect the shipping options to be displayed without entering an address. + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); + + it( 'User does not see shipping rates until full address is entered', async () => { + await preventCompatibilityNotice(); + await merchant.login(); + + await merchantUtils.enableLocalPickup(); + await merchantUtils.addLocalPickupLocation(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await setCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + // Log out to have a fresh empty cart. + await shopper.logout(); + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect no shipping options to be shown, but with a friendly message. + const shippingOptionsRequireAddressText = await page.$x( + '//p[contains(text(), "Shipping options will be displayed here after entering your full shipping address.")]' + ); + + expect( shippingOptionsRequireAddressText ).toHaveLength( 1 ); + + await expect( page ).toClick( + '.wc-block-checkout__shipping-method button', + { text: 'Shipping' } + ); + + // Enter the address but not city and expect shipping options not to be shown. + await shopper.block.fillInCheckoutWithTestData( { city: '' } ); + + await expect( page ).not.toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + + // This sequence will reset the checkout form. + await shopper.login(); + await shopper.logout(); + + await preventCompatibilityNotice(); + await merchant.login(); + await visitBlockPage( 'Checkout Block' ); + await openDocumentSettingsSidebar(); + await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); + await selectBlockByName( + 'woocommerce/checkout-shipping-methods-block' + ); + + await unsetCheckbox( + await getToggleIdByLabel( + 'Hide shipping costs until an address is entered' + ) + ); + await saveOrPublish(); + await shopper.block.emptyCart(); + + await shopper.block.goToShop(); + await shopper.addToCartFromShopPage( SIMPLE_PHYSICAL_PRODUCT_NAME ); + await shopper.block.goToCheckout(); + + // Expect the shipping options to be displayed without entering an address. + await expect( page ).toMatchElement( + '.wc-block-components-shipping-rates-control' + ); + } ); } ); describe( 'Coupons', () => { beforeAll( async () => { coupon = await createCoupon( { usageLimit: 1 } ); + await shopper.logout(); await shopper.login(); } ); diff --git a/tests/mocks/woo-test-helper/woo-test-helper.php b/tests/mocks/woo-test-helper/woo-test-helper.php index 491b7f4e0d4..5869cc351ed 100644 --- a/tests/mocks/woo-test-helper/woo-test-helper.php +++ b/tests/mocks/woo-test-helper/woo-test-helper.php @@ -2,7 +2,7 @@ /** * Plugin Name: Woo Test Helper * Description: A helper plugin to control settings within Woo e2e tests. - * Version: 0.0.1 + * Version: 0.0.2 * Author: Automattic * Author URI: https://automattic.com * Text Domain: woo-test-helper @@ -125,3 +125,125 @@ function setup_cross_sells() { update_post_meta( $id_product->ID, '_crosssell_ids', wp_list_pluck( $id_cross_sells, 'ID' ) ); } } + +/** + * Registers a third party local pickup method, this will have a different ID to the ones we add in the WC Settings. + */ +function register_third_party_local_pickup_method() { + /** + * This function initialises our local pickup method. + */ + function woo_collection_shipping_init() { + + /** + * Custom Local Pickup method. + */ + class Woo_Collection_Shipping_Method extends WC_Shipping_Method { + + /** + * Min amount to be valid. + * + * @var integer + */ + public $min_amount = 0; + + /** + * Requires option. + * + * @var string + */ + public $requires = ''; + + /** + * Constructor. + * + * @param int $instance_id Shipping method instance. + */ + public function __construct( $instance_id = 0 ) { + $this->id = 'woo_collection_shipping'; + $this->instance_id = absint( $instance_id ); + $this->title = 'Woo Collection'; + $this->method_title = __( 'Woo Collection', 'woo-gutenberg-products-block' ); + $this->method_description = __( 'Get your order shipped to an Woo Collection point.', 'woo-gutenberg-products-block' ); + $this->supports = array( + 'instance-settings', + 'instance-settings-modal', + 'local-pickup', + ); + + $this->init(); + } + + /** + * Initialize Woo Collection shipping. + */ + public function init() { + } + + /** + * See if Woo Collection shipping is available based on the package and cart. + * + * @param array $package Shipping package. + * @return bool + */ + public function is_available( $package ) { + return true; + } + + /** + * Called to calculate shipping rates for this method. Rates can be added using the add_rate() method. + * + * @param array $package Shipping package. + * @uses WC_Shipping_Method::add_rate() + */ + public function calculate_shipping( $package = array() ) { + $this->add_rate( + array( + 'label' => $this->title, + 'cost' => 0, + 'taxes' => false, + 'package' => $package, + ) + ); + } + } + } + + // Use this hook to initialize your new custom method. + add_action( 'woocommerce_shipping_init', 'woo_collection_shipping_init' ); + + /** + * Adds the Woo Collection shipping method to the list of available methods in WooCommerce. + * @param array $methods The current list of methods. + * @return array The modified list of methods. + */ + function add_woo_collection_shipping( $methods ) { + $methods['woo_collection_shipping'] = 'Woo_Collection_Shipping_Method'; + + return $methods; + } + add_filter( 'woocommerce_shipping_methods', 'add_woo_collection_shipping' ); +} +register_third_party_local_pickup_method(); + +/** + * Define URL endpoint for setting up third party local pickup method. + */ +function check_third_party_local_pickup_method() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['check_third_party_local_pickup_method'] ) ) { + add_action( + 'woocommerce_blocks_loaded', + function () { + $method_titles = array_map( + function ( $method ) { + return $method->title; + }, + wc()->shipping()->get_shipping_methods() + ); + exit( wp_kses( implode( ', ', $method_titles ), array() ) ); + } + ); + } +} +add_action( 'plugins_loaded', 'check_third_party_local_pickup_method' ); diff --git a/tests/utils/merchant.js b/tests/utils/merchant.js deleted file mode 100644 index 21b86cfaeda..00000000000 --- a/tests/utils/merchant.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * External dependencies - */ -import { merchant as wcMerchant } from '@woocommerce/e2e-utils'; -import { visitAdminPage } from '@wordpress/e2e-test-utils'; - -export const merchant = { - ...wcMerchant, - changeLanguage: async ( language ) => { - await visitAdminPage( 'options-general.php' ); - await page.select( 'select#WPLANG', language ); - await page.click( 'input[type="submit"]' ); - await page.waitForSelector( '#setting-error-settings_updated', { - visible: true, - } ); - }, -}; diff --git a/tests/utils/merchant.ts b/tests/utils/merchant.ts new file mode 100644 index 00000000000..344170a69a4 --- /dev/null +++ b/tests/utils/merchant.ts @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import { merchant as wcMerchant } from '@woocommerce/e2e-utils'; +import { visitAdminPage } from '@wordpress/e2e-test-utils'; +import { findLabelWithText } from '@woocommerce/blocks-test-utils'; + +export const merchant = { + ...wcMerchant, + changeLanguage: async ( language ) => { + await visitAdminPage( 'options-general.php' ); + await page.select( 'select#WPLANG', language ); + await page.click( 'input[type="submit"]' ); + await page.waitForSelector( '#setting-error-settings_updated', { + visible: true, + } ); + }, + goToLocalPickupSettingsPage: async () => { + await visitAdminPage( + 'admin.php', + 'page=wc-settings&tab=shipping§ion=pickup_location' + ); + await page.waitForSelector( + '#wc-shipping-method-pickup-location-settings-container' + ); + }, + saveLocalPickupSettingsPageWithRefresh: async () => { + await expect( page ).toClick( 'button', { + text: 'Save changes', + } ); + await expect( page ).toMatchElement( '.components-snackbar__content', { + text: 'Local Pickup settings have been saved.', + } ); + await merchant.goToLocalPickupSettingsPage(); + }, + enableLocalPickup: async () => { + await merchant.goToLocalPickupSettingsPage(); + const enabledLabel = await findLabelWithText( 'Enable local pickup' ); + const enabledChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( ! enabledChecked ) { + await enabledLabel.click(); + } + + await expect( page ).toFill( + 'input[name="local_pickup_title"]', + 'Local Pickup' + ); + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, + disableLocalPickup: async () => { + await merchant.goToLocalPickupSettingsPage(); + const enabledLabel = await findLabelWithText( 'Enable local pickup' ); + const enabledChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( enabledChecked ) { + await enabledLabel.click(); + } + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, + removeCostForLocalPickup: async () => { + const costLabel = await findLabelWithText( + 'Add a price for customers who choose local pickup' + ); + const costChecked = await page.$eval( + '#inspector-checkbox-control-1', + ( el ) => ( el as HTMLInputElement ).checked + ); + if ( costChecked ) { + await costLabel.click(); + } + }, + addLocalPickupLocation: async () => { + await merchant.goToLocalPickupSettingsPage(); + await expect( page ).toClick( 'button', { + text: 'Add pickup location', + } ); + await expect( page ).toFill( + 'input[name="location_name"]', + 'Test Location' + ); + await expect( page ).toFill( + 'input[name="location_address"]', + 'Test Address 1' + ); + await expect( page ).toFill( + 'input[name="location_city"]', + 'Test City' + ); + await expect( page ).toFill( + 'input[name="location_postcode"]', + '90210' + ); + await expect( page ).toFill( + 'input[name="pickup_details"]', + 'Collect from store' + ); + await expect( page ).toSelect( + 'select[name="location_country"]', + 'US' + ); + await expect( page ).toSelect( 'select[name="location_state"]', 'CA' ); + await expect( page ).toClick( 'button', { text: 'Done' } ); + await merchant.saveLocalPickupSettingsPageWithRefresh(); + }, +};