Merge branch 'trunk' into fix/49635-48841-refunded-taxes

This commit is contained in:
Tomek Wytrębowicz 2024-09-12 00:24:08 +02:00 committed by GitHub
commit 728a57e71e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1795 additions and 1601 deletions

View File

@ -1,3 +1,7 @@
body.wc-modal--open {
overflow: hidden;
}
body.wc-block-product-gallery-modal-open {
overflow: hidden;
}

View File

@ -15,7 +15,6 @@ import {
hasInnerBlocks,
} from '@woocommerce/blocks-checkout';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import type { ReactRootWithContainer } from '@woocommerce/base-utils';
/**
* This file contains logic used on the frontend to convert DOM elements (saved by the block editor) to React
@ -295,7 +294,7 @@ export const renderParentBlock = ( {
selector: string;
// Function to generate the props object for the block.
getProps: ( el: Element, i: number ) => Record< string, unknown >;
} ): ReactRootWithContainer[] => {
} ): void => {
/**
* In addition to getProps, we need to render and return the children. This adds children to props.
*/
@ -311,7 +310,7 @@ export const renderParentBlock = ( {
/**
* The only difference between using renderParentBlock and renderFrontend is that here we provide children.
*/
return renderFrontend( {
renderFrontend( {
Block,
selector,
getProps: getPropsWithChildren,

View File

@ -21,11 +21,16 @@ jest.mock( '@wordpress/element', () => {
};
} );
const renderInCheckoutProvider = ( ui, options = {} ) => {
const renderInCheckoutProvider = ( ui, options = { legacyRoot: true } ) => {
const Wrapper = ( { children } ) => {
return <CheckoutProvider>{ children }</CheckoutProvider>;
};
const result = render( ui, { wrapper: Wrapper, ...options } );
// We need to switch to React 17 rendering to allow these tests to keep passing, but as a result the React
// rendering error will be shown.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot`
);
return result;
};
@ -124,7 +129,7 @@ describe( 'Form Component', () => {
);
};
test( 'updates context value when interacting with form elements', async () => {
it( 'updates context value when interacting with form elements', async () => {
renderInCheckoutProvider(
<>
<WrappedAddressForm type="shipping" />
@ -150,7 +155,7 @@ describe( 'Form Component', () => {
);
} );
test( 'input fields update when changing the country', async () => {
it( 'input fields update when changing the country', async () => {
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
await act( async () => {
@ -177,7 +182,7 @@ describe( 'Form Component', () => {
expect( screen.getByLabelText( /Postal code/ ) ).toBeInTheDocument();
} );
test( 'input values are reset after changing the country', async () => {
it( 'input values are reset after changing the country', async () => {
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
// First enter an address with no state, but fill the city.

View File

@ -1,9 +1,8 @@
/**
* External dependencies
*/
import { createRoot, useEffect, Suspense } from '@wordpress/element';
import { render, Suspense } from '@wordpress/element';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import type { Root } from 'react-dom/client';
// Some blocks take care of rendering their inner blocks automatically. For
// example, the empty cart. In those cases, we don't want to trigger the render
@ -28,11 +27,6 @@ export type GetPropsFn<
TAttributes extends Record< string, unknown >
> = ( el: HTMLElement, i: number ) => BlockProps< TProps, TAttributes >;
export type ReactRootWithContainer = {
container: HTMLElement;
root: Root;
};
interface RenderBlockParams<
TProps extends Record< string, unknown >,
TAttributes extends Record< string, unknown >
@ -61,32 +55,20 @@ export const renderBlock = <
attributes = {} as TAttributes,
props = {} as BlockProps< TProps, TAttributes >,
errorBoundaryProps = {},
}: RenderBlockParams< TProps, TAttributes > ): Root => {
const BlockWrapper = () => {
useEffect( () => {
}: RenderBlockParams< TProps, TAttributes > ): void => {
render(
<BlockErrorBoundary { ...errorBoundaryProps }>
<Suspense fallback={ <div className="wc-block-placeholder" /> }>
{ Block && <Block { ...props } attributes={ attributes } /> }
</Suspense>
</BlockErrorBoundary>,
container,
() => {
if ( container.classList ) {
container.classList.remove( 'is-loading' );
}
}, [] );
return (
<BlockErrorBoundary { ...errorBoundaryProps }>
<Suspense
fallback={
<div className="wc-block-placeholder">Loading...</div>
}
>
{ Block && (
<Block { ...props } attributes={ attributes } />
) }
</Suspense>
</BlockErrorBoundary>
);
};
const root = createRoot( container );
root.render( <BlockWrapper /> );
return root;
}
);
};
interface RenderBlockInContainersParams<
@ -117,14 +99,10 @@ const renderBlockInContainers = <
containers,
getProps = () => ( {} as BlockProps< TProps, TAttributes > ),
getErrorBoundaryProps = () => ( {} ),
}: RenderBlockInContainersParams<
TProps,
TAttributes
> ): ReactRootWithContainer[] => {
}: RenderBlockInContainersParams< TProps, TAttributes > ): void => {
if ( containers.length === 0 ) {
return [];
return;
}
const roots: ReactRootWithContainer[] = [];
// Use Array.forEach for IE11 compatibility.
Array.prototype.forEach.call( containers, ( el, i ) => {
@ -136,19 +114,14 @@ const renderBlockInContainers = <
...( props.attributes || {} ),
};
roots.push( {
renderBlock( {
Block,
container: el,
root: renderBlock( {
Block,
container: el,
props,
attributes,
errorBoundaryProps,
} ),
props,
attributes,
errorBoundaryProps,
} );
} );
return roots;
};
// Given an element and a list of wrappers, check if the element is inside at
@ -184,10 +157,7 @@ const renderBlockOutsideWrappers = <
getErrorBoundaryProps,
selector,
wrappers,
}: RenderBlockOutsideWrappersParams<
TProps,
TAttributes
> ): ReactRootWithContainer[] => {
}: RenderBlockOutsideWrappersParams< TProps, TAttributes > ): void => {
const containers = document.body.querySelectorAll( selector );
// Filter out blocks inside the wrappers.
if ( wrappers && wrappers.length > 0 ) {
@ -195,8 +165,7 @@ const renderBlockOutsideWrappers = <
return ! isElementInsideWrappers( el, wrappers );
} );
}
return renderBlockInContainers( {
renderBlockInContainers( {
Block,
containers,
getProps,
@ -265,21 +234,20 @@ export const renderFrontend = <
props:
| RenderBlockOutsideWrappersParams< TProps, TAttributes >
| RenderBlockInsideWrapperParams< TProps, TAttributes >
): ReactRootWithContainer[] => {
): void => {
const wrappersToSkipOnLoad = document.body.querySelectorAll(
selectorsToSkipOnLoad.join( ',' )
);
const { Block, getProps, getErrorBoundaryProps, selector } = props;
const roots = renderBlockOutsideWrappers( {
renderBlockOutsideWrappers( {
Block,
getProps,
getErrorBoundaryProps,
selector,
wrappers: wrappersToSkipOnLoad,
} );
// For each wrapper, add an event listener to render the inner blocks when
// `wc-blocks_render_blocks_frontend` event is triggered.
Array.prototype.forEach.call( wrappersToSkipOnLoad, ( wrapper ) => {
@ -287,8 +255,6 @@ export const renderFrontend = <
renderBlockInsideWrapper( { ...props, wrapper } );
} );
} );
return roots;
};
export default renderFrontend;

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { act, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import * as hooks from '@woocommerce/base-context/hooks';
import userEvent from '@testing-library/user-event';
@ -106,8 +106,14 @@ const setup = ( params: SetupParams ) => {
results: stubCollectionData(),
isLoading: false,
} );
const utils = render( <AttributeFilterBlock attributes={ attributes } /> );
const utils = render( <AttributeFilterBlock attributes={ attributes } />, {
legacyRoot: true,
} );
// We need to switch to React 17 rendering to allow these tests to keep passing, but as a result the React
// rendering error will be shown.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot`
);
const applyButton = screen.getByRole( 'button', { name: /apply/i } );
const smallAttributeCheckbox = screen.getByRole( 'checkbox', {
name: /small/i,
@ -158,10 +164,8 @@ describe( 'Filter by Attribute block', () => {
test( 'should enable Apply button when filter attributes are changed', async () => {
const { applyButton, smallAttributeCheckbox } =
setupWithoutSelectedFilterAttributes();
await userEvent.click( smallAttributeCheckbox );
await act( async () => {
await userEvent.click( smallAttributeCheckbox );
} );
expect( applyButton ).not.toBeDisabled();
} );
} );
@ -176,25 +180,18 @@ describe( 'Filter by Attribute block', () => {
test( 'should enable Apply button when filter attributes are changed', async () => {
const { applyButton, smallAttributeCheckbox } =
setupWithSelectedFilterAttributes();
await userEvent.click( smallAttributeCheckbox );
await act( async () => {
await userEvent.click( smallAttributeCheckbox );
} );
expect( applyButton ).not.toBeDisabled();
} );
test( 'should disable Apply button when deselecting the same previously selected attribute', async () => {
const { applyButton, smallAttributeCheckbox } =
setupWithSelectedFilterAttributes( { filterSize: 'small' } );
await act( async () => {
await userEvent.click( smallAttributeCheckbox );
} );
await userEvent.click( smallAttributeCheckbox );
expect( applyButton ).not.toBeDisabled();
await act( async () => {
await userEvent.click( smallAttributeCheckbox );
} );
await userEvent.click( smallAttributeCheckbox );
expect( applyButton ).toBeDisabled();
} );
} );

View File

@ -6,7 +6,6 @@ import {
useState,
useEffect,
useCallback,
useMemo,
createInterpolateElement,
} from '@wordpress/element';
import { useShippingData, useStoreCart } from '@woocommerce/base-context/hooks';
@ -139,12 +138,10 @@ const renderPickupLocation = (
const Block = (): JSX.Element | null => {
const { shippingRates, selectShippingRate } = useShippingData();
// Memoize pickup locations to prevent re-rendering when the shipping rates change.
const pickupLocations = useMemo( () => {
return ( shippingRates[ 0 ]?.shipping_rates || [] ).filter(
isPackageRateCollectable
);
}, [ shippingRates ] );
// Get pickup locations from the first shipping package.
const pickupLocations = ( shippingRates[ 0 ]?.shipping_rates || [] ).filter(
isPackageRateCollectable
);
const [ selectedOption, setSelectedOption ] = useState< string >(
() => pickupLocations.find( ( rate ) => rate.selected )?.rate_id || ''
@ -171,19 +168,13 @@ const Block = (): JSX.Element | null => {
renderPickupLocation,
};
// Update the selected option if there is no rate selected on mount.
useEffect( () => {
if (
! selectedOption &&
pickupLocations[ 0 ] &&
selectedOption !== pickupLocations[ 0 ].rate_id
) {
if ( ! selectedOption && pickupLocations[ 0 ] ) {
setSelectedOption( pickupLocations[ 0 ].rate_id );
onSelectRate( pickupLocations[ 0 ].rate_id );
}
// Removing onSelectRate as it lead to an infinite loop when only one pickup location is available.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ pickupLocations, selectedOption ] );
}, [ onSelectRate, pickupLocations, selectedOption ] );
const packageCount = getShippingRatesPackageCount( shippingRates );
return (
<>

View File

@ -26,7 +26,6 @@ import type {
} from '@woocommerce/types';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
import type { ReactElement } from 'react';
import { useMemo } from '@wordpress/element';
/**
* Renders a shipping rate control option.
@ -74,22 +73,19 @@ const Block = ( { noShippingPlaceholder = null } ): ReactElement | null => {
const { shippingAddress } = useCustomerData();
const filteredShippingRates = useMemo( () => {
return isCollectable
? shippingRates.map( ( shippingRatesPackage ) => {
return {
...shippingRatesPackage,
shipping_rates:
shippingRatesPackage.shipping_rates.filter(
( shippingRatesPackageRate ) =>
! hasCollectableRate(
shippingRatesPackageRate.method_id
)
),
};
} )
: shippingRates;
}, [ shippingRates, isCollectable ] );
const filteredShippingRates = isCollectable
? shippingRates.map( ( shippingRatesPackage ) => {
return {
...shippingRatesPackage,
shipping_rates: shippingRatesPackage.shipping_rates.filter(
( shippingRatesPackageRate ) =>
! hasCollectableRate(
shippingRatesPackageRate.method_id
)
),
};
} )
: shippingRates;
if ( ! needsShipping ) {
return null;

View File

@ -73,22 +73,20 @@ body:has(.woocommerce-coming-soon-banner) {
}
.wp-block-loginout {
background-color: #000;
border-radius: 6px;
display: flex;
height: 40px;
width: 74px;
justify-content: center;
align-items: center;
gap: 10px;
box-sizing: border-box;
a {
box-sizing: border-box;
background-color: #000;
border-radius: 6px;
color: #fff;
text-decoration: none;
line-height: 17px;
gap: 10px;
font-size: 14px;
font-weight: 500;
font-style: normal;
line-height: normal;
text-align: center;
text-decoration: none;
padding: 17px 16px;
}
}

View File

@ -20,10 +20,15 @@ import {
isCartResponseTotals,
isNumber,
} from '@woocommerce/types';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import {
unmountComponentAtNode,
useCallback,
useEffect,
useRef,
useState,
} from '@wordpress/element';
import { sprintf, _n } from '@wordpress/i18n';
import clsx from 'clsx';
import type { ReactRootWithContainer } from '@woocommerce/base-utils';
/**
* Internal dependencies
@ -105,8 +110,6 @@ const MiniCartBlock = ( attributes: Props ): JSX.Element => {
setContentsNode( node );
}, [] );
const rootRef = useRef< ReactRootWithContainer[] | null >( null );
useEffect( () => {
const body = document.querySelector( 'body' );
if ( body ) {
@ -131,7 +134,7 @@ const MiniCartBlock = ( attributes: Props ): JSX.Element => {
return;
}
if ( isOpen ) {
const renderedBlock = renderParentBlock( {
renderParentBlock( {
Block: MiniCartContentsBlock,
blockName,
getProps: ( el: Element ) => {
@ -148,25 +151,16 @@ const MiniCartBlock = ( attributes: Props ): JSX.Element => {
selector: '.wp-block-woocommerce-mini-cart-contents',
blockMap: getRegisteredBlockComponents( blockName ),
} );
rootRef.current = renderedBlock;
}
}
return () => {
if ( contentsNode instanceof Element && isOpen ) {
const unmountingContainer = contentsNode.querySelector(
const container = contentsNode.querySelector(
'.wp-block-woocommerce-mini-cart-contents'
);
if ( unmountingContainer ) {
const foundRoot = rootRef?.current?.find(
( { container } ) => unmountingContainer === container
);
if ( typeof foundRoot?.root?.unmount === 'function' ) {
setTimeout( () => {
foundRoot.root.unmount();
} );
}
if ( container ) {
unmountComponentAtNode( container );
}
}
};

View File

@ -111,6 +111,13 @@ describe( 'Testing Mini-Cart', () => {
await waitFor( () =>
expect( screen.getByText( /your cart/i ) ).toBeInTheDocument()
);
// The opening of the drawer uses deprecated ReactDOM.render.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot%s`,
// The stack trace
expect.any( String )
);
} );
it( 'closes the drawer when clicking on the close button', async () => {
@ -125,11 +132,9 @@ describe( 'Testing Mini-Cart', () => {
// Close drawer.
let closeButton = null;
await waitFor( () => {
closeButton = screen.getByLabelText( /close/i );
} );
if ( closeButton ) {
await act( async () => {
await user.click( closeButton );
@ -141,6 +146,13 @@ describe( 'Testing Mini-Cart', () => {
screen.queryByText( /your cart/i )
).not.toBeInTheDocument();
} );
// The opening of the drawer uses deprecated ReactDOM.render.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot%s`,
// The stack trace
expect.any( String )
);
} );
it( 'renders empty cart if there are no items in the cart', async () => {
@ -155,6 +167,13 @@ describe( 'Testing Mini-Cart', () => {
} );
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
// The opening of the drawer uses deprecated ReactDOM.render.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot%s`,
// The stack trace
expect.any( String )
);
} );
it( 'updates contents when removed from cart event is triggered', async () => {

View File

@ -53,18 +53,6 @@
"overlay": {
"type": "string",
"default": "never"
},
"overlayIcon": {
"type": "string",
"default": "filter-icon-1"
},
"overlayButtonStyle": {
"type": "string",
"default": "label-icon"
},
"overlayIconSize": {
"type": "number",
"default": "12"
}
},
"viewScript": "wc-product-filters-frontend",

View File

@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { filter, filterThreeLines } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
import {
@ -10,14 +9,19 @@ import {
useBlockProps,
useInnerBlocksProps,
} from '@wordpress/block-editor';
import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import {
BlockEditProps,
BlockInstance,
InnerBlockTemplate,
createBlock,
} from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { Icon, menu, settings } from '@wordpress/icons';
import { useEffect } from '@wordpress/element';
import { select, dispatch } from '@wordpress/data';
import { useLocalStorageState } from '@woocommerce/base-hooks';
import {
ExternalLink,
PanelBody,
RadioControl,
RangeControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
@ -111,6 +115,7 @@ const TEMPLATE: InnerBlockTemplate[] = [
export const Edit = ( {
setAttributes,
attributes,
clientId,
}: BlockEditProps< BlockAttributes > ) => {
const blockProps = useBlockProps();
@ -119,6 +124,84 @@ export const Edit = ( {
''
);
const [
productFiltersOverlayNavigationAttributes,
setProductFiltersOverlayNavigationAttributes,
] = useLocalStorageState< Record< string, unknown > >(
'product-filters-overlay-navigation-attributes',
{}
);
useEffect( () => {
const filtersClientIds = select( 'core/block-editor' ).getBlocksByName(
'woocommerce/product-filters'
);
let overlayBlock:
| BlockInstance< { [ k: string ]: unknown } >
| undefined;
for ( const filterClientId of filtersClientIds ) {
const filterBlock =
select( 'core/block-editor' ).getBlock( filterClientId );
if ( filterBlock ) {
for ( const innerBlock of filterBlock.innerBlocks ) {
if (
innerBlock.name ===
'woocommerce/product-filters-overlay-navigation' &&
innerBlock.attributes.triggerType === 'open-overlay'
) {
overlayBlock = innerBlock;
}
}
}
}
if ( attributes.overlay === 'never' && overlayBlock ) {
setProductFiltersOverlayNavigationAttributes(
overlayBlock.attributes
);
dispatch( 'core/block-editor' ).updateBlockAttributes(
overlayBlock.clientId,
{
lock: {},
}
);
dispatch( 'core/block-editor' ).removeBlock(
overlayBlock.clientId
);
} else if ( attributes.overlay !== 'never' && ! overlayBlock ) {
if ( productFiltersOverlayNavigationAttributes ) {
productFiltersOverlayNavigationAttributes.triggerType =
'open-overlay';
}
dispatch( 'core/block-editor' ).insertBlock(
createBlock(
'woocommerce/product-filters-overlay-navigation',
productFiltersOverlayNavigationAttributes
? productFiltersOverlayNavigationAttributes
: {
align: 'left',
triggerType: 'open-overlay',
lock: { move: true, remove: true },
}
),
0,
clientId,
false
);
}
}, [
attributes.overlay,
clientId,
productFiltersOverlayNavigationAttributes,
setProductFiltersOverlayNavigationAttributes,
] );
return (
<div { ...blockProps }>
<InspectorControls>
@ -144,126 +227,6 @@ export const Edit = ( {
label={ __( 'Always', 'woocommerce' ) }
/>
</ToggleGroupControl>
{ attributes.overlay === 'mobile' && (
<>
<RadioControl
className="wc-block-editor-product-filters__overlay-button-style-toggle"
label={ __( 'Button', 'woocommerce' ) }
selected={ attributes.overlayButtonStyle }
onChange={ (
value: BlockAttributes[ 'overlayButtonStyle' ]
) => {
setAttributes( {
overlayButtonStyle: value,
} );
} }
options={ [
{
value: 'label-icon',
label: __(
'Label and icon',
'woocommerce'
),
},
{
value: 'label',
label: __(
'Label only',
'woocommerce'
),
},
{
value: 'icon',
label: __( 'Icon only', 'woocommerce' ),
},
] }
/>
{ attributes.overlayButtonStyle !== 'label' && (
<>
<ToggleGroupControl
className="wc-block-editor-product-filters__overlay-button-toggle"
isBlock={ true }
value={ attributes.overlayIcon }
onChange={ (
value: BlockAttributes[ 'overlayIcon' ]
) => {
setAttributes( {
overlayIcon: value,
} );
} }
>
<ToggleGroupControlOption
value={ 'filter-icon-1' }
aria-label={ __(
'Filter icon 1',
'woocommerce'
) }
label={
<Icon
size={ 32 }
icon={ filter }
/>
}
/>
<ToggleGroupControlOption
value={ 'filter-icon-2' }
aria-label={ __(
'Filter icon 2',
'woocommerce'
) }
label={
<Icon
size={ 32 }
icon={ filterThreeLines }
/>
}
/>
<ToggleGroupControlOption
value={ 'filter-icon-3' }
aria-label={ __(
'Filter icon 3',
'woocommerce'
) }
label={
<Icon
size={ 32 }
icon={ menu }
/>
}
/>
<ToggleGroupControlOption
value={ 'filter-icon-4' }
aria-label={ __(
'Filter icon 4',
'woocommerce'
) }
label={
<Icon
size={ 32 }
icon={ settings }
/>
}
/>
</ToggleGroupControl>
<RangeControl
label={ __(
'Icon size',
'woocommerce'
) }
className="wc-block-editor-product-filters__overlay-button-size"
value={ attributes.overlayIconSize }
onChange={ ( value: number ) =>
setAttributes( {
overlayIconSize: value,
} )
}
min={ 20 }
max={ 80 }
/>
</>
) }
</>
) }
{ attributes.overlay !== 'never' && (
<ExternalLink
href={ templatePartEditUri }

View File

@ -1,15 +1,40 @@
/**
* External dependencies
*/
import { store } from '@woocommerce/interactivity';
import { getContext as getContextFn, store } from '@woocommerce/interactivity';
export interface ProductFiltersContext {
productId: string;
isDialogOpen: boolean;
hasPageWithWordPressAdminBar: boolean;
}
const getContext = ( ns?: string ) =>
getContextFn< ProductFiltersContext >( ns );
const productFilters = {
state: {},
actions: {},
state: {
isDialogOpen: () => {
const context = getContext();
return context.isDialogOpen;
},
},
actions: {
openDialog: () => {
const context = getContext();
document.body.classList.add( 'wc-modal--open' );
context.hasPageWithWordPressAdminBar = Boolean(
document.getElementById( 'wpadminbar' )
);
context.isDialogOpen = true;
},
closeDialog: () => {
const context = getContext();
document.body.classList.remove( 'wc-modal--open' );
context.isDialogOpen = false;
},
},
callbacks: {},
};

View File

@ -9,6 +9,7 @@ import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
*/
import metadata from './block.json';
import { ProductFiltersBlockSettings } from './settings';
import './style.scss';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, ProductFiltersBlockSettings );

View File

@ -3,6 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import { BlockVariation } from '@wordpress/blocks';
import { Icon, button } from '@wordpress/icons';
const variations: BlockVariation[] = [
{
@ -12,6 +13,8 @@ const variations: BlockVariation[] = [
triggerType: 'open-overlay',
},
isDefault: false,
icon: <Icon icon={ button } />,
isActive: [ 'triggerType' ],
},
];

View File

@ -20,12 +20,16 @@
"default": "link"
},
"iconSize": {
"type": "string"
"type": "number"
},
"overlayMode": {
"type": "string",
"default": "never"
},
"overlayIcon": {
"type": "string",
"default": "filter-icon-1"
},
"style": {
"type": "object",
"default": {
@ -40,6 +44,7 @@
}
},
"supports": {
"interactivity": true,
"align": [ "left", "right", "center"],
"inserter": false,
"color": {

View File

@ -6,7 +6,8 @@ import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
import { BlockEditProps, store as blocksStore } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import { Icon, close } from '@wordpress/icons';
import { Icon, close, menu, settings } from '@wordpress/icons';
import { filter, filterThreeLines } from '@woocommerce/icons';
/**
* Internal dependencies
@ -16,7 +17,6 @@ import type {
BlockContext,
BlockVariationTriggerType,
} from './types';
import { default as productFiltersIcon } from '../../icon';
import { BlockOverlayAttribute as ProductFiltersBlockOverlayAttribute } from '../../constants';
import './editor.scss';
import { Inspector } from './inspector-controls';
@ -37,16 +37,33 @@ const OverlayNavigationLabel = ( {
const OverlayNavigationIcon = ( {
variation,
iconSize,
overlayIcon,
style,
}: {
variation: BlockVariationTriggerType;
iconSize: number | undefined;
overlayIcon: string;
style: BlockAttributes[ 'style' ];
} ) => {
let icon = close;
if ( variation === 'open-overlay' ) {
icon = productFiltersIcon();
switch ( overlayIcon ) {
case 'filter-icon-4':
icon = settings;
break;
case 'filter-icon-3':
icon = menu;
break;
case 'filter-icon-2':
icon = filterThreeLines;
break;
case 'filter-icon-1':
icon = filter;
break;
default:
icon = filter;
}
}
return (
@ -65,11 +82,13 @@ const OverlayNavigationContent = ( {
variation,
iconSize,
style,
overlayIcon,
navigationStyle,
}: {
variation: BlockVariationTriggerType;
iconSize: BlockAttributes[ 'iconSize' ];
style: BlockAttributes[ 'style' ];
overlayIcon: BlockAttributes[ 'overlayIcon' ];
navigationStyle: BlockAttributes[ 'navigationStyle' ];
} ) => {
const overlayNavigationLabel = (
@ -79,6 +98,7 @@ const OverlayNavigationContent = ( {
<OverlayNavigationIcon
variation={ variation }
iconSize={ iconSize }
overlayIcon={ overlayIcon }
style={ style }
/>
);
@ -111,8 +131,14 @@ const OverlayNavigationContent = ( {
type BlockProps = BlockEditProps< BlockAttributes > & { context: BlockContext };
export const Edit = ( { attributes, setAttributes, context }: BlockProps ) => {
const { navigationStyle, buttonStyle, iconSize, style, triggerType } =
attributes;
const {
navigationStyle,
buttonStyle,
iconSize,
overlayIcon,
style,
triggerType,
} = attributes;
const { 'woocommerce/product-filters/overlay': productFiltersOverlayMode } =
context;
const blockProps = useBlockProps( {
@ -214,6 +240,7 @@ export const Edit = ( { attributes, setAttributes, context }: BlockProps ) => {
variation={ triggerType }
iconSize={ iconSize }
navigationStyle={ navigationStyle }
overlayIcon={ overlayIcon }
style={ style }
/>
</div>

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { store } from '@woocommerce/interactivity';
export interface ProductFiltersContext {
isDialogOpen: boolean;
}
const productFiltersOverlayNavigation = {
state: {},
actions: {},
callbacks: {},
};
store( 'woocommerce/product-filters', productFiltersOverlayNavigation );
export type ProductFiltersOverlayNavigation =
typeof productFiltersOverlayNavigation;

View File

@ -2,8 +2,8 @@
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon } from '@wordpress/icons';
import { closeSquareShadow } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
/**

View File

@ -1,10 +1,11 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { InspectorControls } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { filter, filterThreeLines } from '@woocommerce/icons';
import { Icon, menu, settings } from '@wordpress/icons';
import {
PanelBody,
RadioControl,
@ -35,7 +36,8 @@ export const Inspector = ( {
setAttributes,
buttonStyles,
}: InspectorProps ) => {
const { navigationStyle, buttonStyle, iconSize } = attributes;
const { navigationStyle, buttonStyle, iconSize, overlayIcon, triggerType } =
attributes;
return (
<InspectorControls group="styles">
<PanelBody title={ __( 'Style', 'woocommerce' ) }>
@ -101,6 +103,61 @@ export const Inspector = ( {
/>
) }
{ triggerType === 'open-overlay' &&
navigationStyle !== 'label-only' && (
<ToggleGroupControl
label={ __( 'Icon', 'woocommerce' ) }
className="wc-block-editor-product-filters__overlay-button-toggle"
isBlock={ true }
value={ overlayIcon }
onChange={ (
value: BlockAttributes[ 'overlayIcon' ]
) => {
setAttributes( {
overlayIcon: value,
} );
} }
>
<ToggleGroupControlOption
value={ 'filter-icon-1' }
aria-label={ __(
'Filter icon 1',
'woocommerce'
) }
label={ <Icon size={ 32 } icon={ filter } /> }
/>
<ToggleGroupControlOption
value={ 'filter-icon-2' }
aria-label={ __(
'Filter icon 2',
'woocommerce'
) }
label={
<Icon
size={ 32 }
icon={ filterThreeLines }
/>
}
/>
<ToggleGroupControlOption
value={ 'filter-icon-3' }
aria-label={ __(
'Filter icon 3',
'woocommerce'
) }
label={ <Icon size={ 32 } icon={ menu } /> }
/>
<ToggleGroupControlOption
value={ 'filter-icon-4' }
aria-label={ __(
'Filter icon 4',
'woocommerce'
) }
label={ <Icon size={ 32 } icon={ settings } /> }
/>
</ToggleGroupControl>
) }
{ navigationStyle !== 'label-only' && (
<RangeControl
className="wc-block-product-filters-overlay-navigation__icon-size-control"

View File

@ -8,15 +8,18 @@
cursor: pointer;
&.alignright {
margin-left: auto;
justify-content: right;
}
&.alignleft {
margin-left: unset;
justify-content: unset;
}
&.aligncenter {
margin-left: auto;
margin-right: auto;
justify-content: center;
}
&.hidden {
display: none;
}
}

View File

@ -27,6 +27,7 @@ export type BlockAttributes = {
iconSize?: number;
overlayMode: ProductFiltersBlockOverlayAttributeOptions;
triggerType: BlockVariationTriggerType;
overlayIcon: string;
style: {
border?: {
radius?: string | BorderRadius;

View File

@ -0,0 +1,22 @@
.wc-block-product-filters {
dialog {
flex-direction: column;
position: fixed;
border: none;
top: 0;
z-index: 9999;
height: 100vh;
width: 100vw;
&.wc-block-product-filters--dialog-open {
display: flex;
padding-left: 0;
overflow-y: auto;
}
&.wc-block-product-filters--with-admin-bar {
margin-top: $gap;
height: calc(100vh - 2 * $gap);
}
}
}

View File

@ -2,14 +2,7 @@
* External dependencies
*/
import React from '@wordpress/element';
import {
act,
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import * as hooks from '@woocommerce/base-context/hooks';
import userEvent from '@testing-library/user-event';
@ -66,7 +59,6 @@ const selectors = {
};
const setup = ( params: SetupParams ) => {
cleanup();
const url = `http://woo.local/${
params.filterRating ? '?rating_filter=' + params.filterRating : ''
}`;
@ -86,7 +78,14 @@ const setup = ( params: SetupParams ) => {
} );
const { container, ...utils } = render(
<RatingFilterBlock attributes={ attributes } />
<RatingFilterBlock attributes={ attributes } />,
{ legacyRoot: true }
);
// We need to switch to React 17 rendering to allow these tests to keep passing, but as a result the React
// rendering error will be shown.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot`
);
const getList = () => container.querySelector( selectors.list );
@ -204,81 +203,74 @@ describe( 'Filter by Rating block', () => {
describe( 'Single choice Dropdown', () => {
test( 'renders dropdown', () => {
const { getDropdown, getList } = setupSingleChoiceDropdown();
expect( getDropdown() ).toBeInTheDocument();
expect( getList() ).toBeNull();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = '2';
const { getRating2Chips, getRating4Chips, getRating5Chips } =
setupSingleChoiceDropdown( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = '2';
const { getRating2Chips, getRating4Chips, getRating5Chips } =
setupSingleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
} );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
} );
test( 'replaces chosen option when another one is clicked', async () => {
await waitFor( async () => {
const ratingParam = '2';
const {
getDropdown,
getRating2Chips,
getRating4Chips,
getRating4Suggestion,
} = setupSingleChoiceDropdown( ratingParam );
const ratingParam = '2';
const {
getDropdown,
getRating2Chips,
getRating4Chips,
getRating4Suggestion,
} = setupSingleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
const dropdown = getDropdown();
const dropdown = getDropdown();
if ( dropdown ) {
await userEvent.click( dropdown );
acceptErrorWithDuplicatedKeys();
}
if ( dropdown ) {
await userEvent.click( dropdown );
acceptErrorWithDuplicatedKeys();
}
const rating4Suggestion = getRating4Suggestion();
const rating4Suggestion = getRating4Suggestion();
if ( rating4Suggestion ) {
await userEvent.click( rating4Suggestion );
}
if ( rating4Suggestion ) {
await userEvent.click( rating4Suggestion );
}
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeInTheDocument();
} );
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeInTheDocument();
} );
test( 'removes the option when the X button is clicked', async () => {
await waitFor( async () => {
const ratingParam = '4';
const {
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
const ratingParam = '4';
const {
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
const removeRating4Button = getRemoveButtonFromChips(
getRating4Chips()
);
const removeRating4Button = getRemoveButtonFromChips(
getRating4Chips()
);
if ( removeRating4Button ) {
await userEvent.click( removeRating4Button );
acceptErrorWithDuplicatedKeys();
}
if ( removeRating4Button ) {
await userEvent.click( removeRating4Button );
acceptErrorWithDuplicatedKeys();
}
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
} );
expect( getRating2Chips() ).toBeNull();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
} );
} );
@ -289,89 +281,83 @@ describe( 'Filter by Rating block', () => {
expect( getList() ).toBeNull();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = '2,4';
const { getRating2Chips, getRating4Chips, getRating5Chips } =
setupMultipleChoiceDropdown( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = '2,4';
const { getRating2Chips, getRating4Chips, getRating5Chips } =
setupMultipleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
} );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
} );
test( 'adds chosen option to another one that is clicked', async () => {
await waitFor( async () => {
const ratingParam = '2';
const {
getDropdown,
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRating4Suggestion,
getRating5Suggestion,
} = setupMultipleChoiceDropdown( ratingParam );
const ratingParam = '2';
const {
getDropdown,
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRating4Suggestion,
getRating5Suggestion,
} = setupMultipleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeNull();
const dropdown = getDropdown();
const dropdown = getDropdown();
if ( dropdown ) {
await userEvent.click( dropdown );
acceptErrorWithDuplicatedKeys();
}
if ( dropdown ) {
await userEvent.click( dropdown );
acceptErrorWithDuplicatedKeys();
}
const rating4Suggestion = getRating4Suggestion();
const rating4Suggestion = getRating4Suggestion();
if ( rating4Suggestion ) {
await userEvent.click( rating4Suggestion );
}
if ( rating4Suggestion ) {
await userEvent.click( rating4Suggestion );
}
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeNull();
const rating5Suggestion = getRating5Suggestion();
const rating5Suggestion = getRating5Suggestion();
if ( rating5Suggestion ) {
await userEvent.click( rating5Suggestion );
}
if ( rating5Suggestion ) {
await userEvent.click( rating5Suggestion );
}
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeInTheDocument();
} );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeInTheDocument();
} );
test( 'removes the option when the X button is clicked', async () => {
await waitFor( async () => {
const ratingParam = '2,4,5';
const {
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
const ratingParam = '2,4,5';
const {
getRating2Chips,
getRating4Chips,
getRating5Chips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeInTheDocument();
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeInTheDocument();
expect( getRating5Chips() ).toBeInTheDocument();
const removeRating4Button = getRemoveButtonFromChips(
getRating4Chips()
);
const removeRating4Button = getRemoveButtonFromChips(
getRating4Chips()
);
if ( removeRating4Button ) {
await userEvent.click( removeRating4Button );
}
if ( removeRating4Button ) {
await userEvent.click( removeRating4Button );
}
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeInTheDocument();
} );
expect( getRating2Chips() ).toBeInTheDocument();
expect( getRating4Chips() ).toBeNull();
expect( getRating5Chips() ).toBeInTheDocument();
} );
} );
@ -382,67 +368,61 @@ describe( 'Filter by Rating block', () => {
expect( getList() ).toBeInTheDocument();
} );
test( 'renders checked options based on URL params', async () => {
await waitFor( async () => {
const ratingParam = '4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupSingleChoiceList( ratingParam );
test( 'renders checked options based on URL params', () => {
const ratingParam = '4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupSingleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
} );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
} );
test( 'replaces chosen option when another one is clicked', async () => {
await waitFor( async () => {
const ratingParam = '2';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupSingleChoiceList( ratingParam );
const ratingParam = '2';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupSingleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeFalsy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeFalsy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
const rating4checkbox = getRating4Checkbox();
const rating4checkbox = getRating4Checkbox();
if ( rating4checkbox ) {
await act( async () => {
await userEvent.click( rating4checkbox );
} );
}
if ( rating4checkbox ) {
await userEvent.click( rating4checkbox );
}
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
} );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
} );
test( 'removes the option when it is clicked again', async () => {
await waitFor( async () => {
const ratingParam = '4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = '4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
const rating4checkbox = getRating4Checkbox();
const rating4checkbox = getRating4Checkbox();
if ( rating4checkbox ) {
await userEvent.click( rating4checkbox );
}
if ( rating4checkbox ) {
await userEvent.click( rating4checkbox );
}
await waitFor( () => {
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeFalsy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
@ -457,40 +437,38 @@ describe( 'Filter by Rating block', () => {
expect( getList() ).toBeInTheDocument();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = '4,5';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = '4,5';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeTruthy();
} );
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeTruthy();
} );
test( 'adds chosen option to another one that is clicked', async () => {
await waitFor( async () => {
const ratingParam = '2,4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = '2,4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
const rating5checkbox = getRating5Checkbox();
const rating5checkbox = getRating5Checkbox();
if ( rating5checkbox ) {
await userEvent.click( rating5checkbox );
}
if ( rating5checkbox ) {
await userEvent.click( rating5checkbox );
}
await waitFor( () => {
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeTruthy();
@ -498,24 +476,24 @@ describe( 'Filter by Rating block', () => {
} );
test( 'removes the option when it is clicked again', async () => {
await waitFor( async () => {
const ratingParam = '2,4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = '2,4';
const {
getRating2Checkbox,
getRating4Checkbox,
getRating5Checkbox,
} = setupMultipleChoiceList( ratingParam );
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
expect( getRating2Checkbox()?.checked ).toBeTruthy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();
const rating2checkbox = getRating2Checkbox();
const rating2checkbox = getRating2Checkbox();
if ( rating2checkbox ) {
await userEvent.click( rating2checkbox );
}
if ( rating2checkbox ) {
await userEvent.click( rating2checkbox );
}
await waitFor( () => {
expect( getRating2Checkbox()?.checked ).toBeFalsy();
expect( getRating4Checkbox()?.checked ).toBeTruthy();
expect( getRating5Checkbox()?.checked ).toBeFalsy();

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import metadata from './block.json';
const v1 = {
attributes: metadata.attributes,
supports: metadata.supports,
save: () => {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
{ /* @ts-expect-error: `InnerBlocks.Content` is a component that is typed in WordPress core*/ }
<InnerBlocks.Content />
</div>
);
},
};
const deprecated = [ v1 ];
export default deprecated;

View File

@ -10,10 +10,12 @@ import { BLOCK_ICON } from './constants';
import metadata from './block.json';
import edit from './edit';
import save from './save';
import deprecated from './deprecated';
// @ts-expect-error: `registerBlockType` is a function that is typed in WordPress core.
registerBlockType( metadata, {
icon: BLOCK_ICON,
edit,
save,
deprecated,
} );

View File

@ -4,6 +4,7 @@
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
const Save = () => {
// We add the `woocommerce` class to the wrapper to apply WooCommerce styles to the block.
const blockProps = useBlockProps.save( {
className: 'woocommerce',
} );

View File

@ -2,14 +2,7 @@
* External dependencies
*/
import React from '@wordpress/element';
import {
act,
cleanup,
render,
screen,
within,
waitFor,
} from '@testing-library/react';
import { act, render, screen, within, waitFor } from '@testing-library/react';
import { default as fetchMock } from 'jest-fetch-mock';
import userEvent from '@testing-library/user-event';
@ -75,7 +68,6 @@ const selectors = {
};
const setup = ( params: SetupParams = {} ) => {
cleanup();
const url = `http://woo.local/${
params.filterStock ? '?filter_stock_status=' + params.filterStock : ''
}`;
@ -95,7 +87,14 @@ const setup = ( params: SetupParams = {} ) => {
};
const { container, ...utils } = render(
<Block attributes={ attributes } />
<Block attributes={ attributes } />,
{ legacyRoot: true }
);
// We need to switch to React 17 rendering to allow these tests to keep passing, but as a result the React
// rendering error will be shown.
expect( console ).toHaveErroredWith(
`Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot`
);
const getList = () => container.querySelector( selectors.list );
@ -228,7 +227,7 @@ describe( 'Filter by Stock block', () => {
fetchMock.resetMocks();
} );
test( 'renders the stock filter block', async () => {
it( 'renders the stock filter block', async () => {
const { container } = setup( {
showFilterButton: false,
showCounts: false,
@ -236,7 +235,7 @@ describe( 'Filter by Stock block', () => {
expect( container ).toMatchSnapshot();
} );
test( 'renders the stock filter block with the filter button', async () => {
it( 'renders the stock filter block with the filter button', async () => {
const { container } = setup( {
showFilterButton: true,
showCounts: false,
@ -244,7 +243,7 @@ describe( 'Filter by Stock block', () => {
expect( container ).toMatchSnapshot();
} );
test( 'renders the stock filter block with the product counts', async () => {
it( 'renders the stock filter block with the product counts', async () => {
const { container } = setup( {
showFilterButton: false,
showCounts: true,
@ -255,86 +254,80 @@ describe( 'Filter by Stock block', () => {
describe( 'Single choice Dropdown', () => {
test( 'renders dropdown', () => {
const { getDropdown, getList } = setupSingleChoiceDropdown();
expect( getDropdown() ).toBeInTheDocument();
expect( getList() ).toBeNull();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = 'instock';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
} = setupSingleChoiceDropdown( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = 'instock';
const { getInStockChips, getOutOfStockChips, getOnBackorderChips } =
setupSingleChoiceDropdown( ratingParam );
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeNull();
} );
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeNull();
} );
test( 'replaces chosen option when another one is clicked', async () => {
await waitFor( async () => {
const user = userEvent.setup();
const ratingParam = 'instock';
const {
getDropdown,
getInStockChips,
getOutOfStockChips,
getOutOfStockSuggestion,
} = setupSingleChoiceDropdown( ratingParam );
const user = userEvent.setup();
const ratingParam = 'instock';
const {
getDropdown,
getInStockChips,
getOutOfStockChips,
getOutOfStockSuggestion,
} = setupSingleChoiceDropdown( ratingParam );
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
const dropdown = getDropdown();
const dropdown = getDropdown();
if ( dropdown ) {
await act( async () => {
await user.click( dropdown );
} );
}
if ( dropdown ) {
await act( async () => {
await user.click( dropdown );
} );
}
const outOfStockSuggestion = getOutOfStockSuggestion();
const outOfStockSuggestion = getOutOfStockSuggestion();
if ( outOfStockSuggestion ) {
await act( async () => {
await user.click( outOfStockSuggestion );
} );
}
if ( outOfStockSuggestion ) {
await act( async () => {
await user.click( outOfStockSuggestion );
} );
}
expect( getInStockChips() ).toBeNull();
expect( getOutOfStockChips() ).toBeInTheDocument();
} );
expect( getInStockChips() ).toBeNull();
expect( getOutOfStockChips() ).toBeInTheDocument();
} );
test( 'removes the option when the X button is clicked', async () => {
await waitFor( async () => {
const user = userEvent.setup();
const ratingParam = 'outofstock';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
const user = userEvent.setup();
const ratingParam = 'outofstock';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
await waitFor( () => {
expect( getInStockChips() ).toBeNull();
expect( getOutOfStockChips() ).toBeInTheDocument();
expect( getOnBackorderChips() ).toBeNull();
} );
const removeOutOfStockButton = getRemoveButtonFromChips(
getOutOfStockChips()
);
const removeOutOfStockButton = getRemoveButtonFromChips(
getOutOfStockChips()
);
if ( removeOutOfStockButton ) {
await act( async () => {
await user.click( removeOutOfStockButton );
} );
}
if ( removeOutOfStockButton ) {
act( async () => {
await user.click( removeOutOfStockButton );
} );
}
await waitFor( () => {
expect( getInStockChips() ).toBeNull();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeNull();
@ -349,65 +342,67 @@ describe( 'Filter by Stock block', () => {
expect( getList() ).toBeNull();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = 'instock,onbackorder';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
} = setupMultipleChoiceDropdown( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = 'instock,onbackorder';
const { getInStockChips, getOutOfStockChips, getOnBackorderChips } =
setupMultipleChoiceDropdown( ratingParam );
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
} );
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
} );
test( 'adds chosen option to another one that is clicked', async () => {
await waitFor( async () => {
const user = userEvent.setup();
const ratingParam = 'onbackorder';
const {
getDropdown,
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getInStockSuggestion,
getOutOfStockSuggestion,
} = setupMultipleChoiceDropdown( ratingParam );
const user = userEvent.setup();
const ratingParam = 'onbackorder';
const {
getDropdown,
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getInStockSuggestion,
getOutOfStockSuggestion,
} = setupMultipleChoiceDropdown( ratingParam );
await waitFor( () => {
expect( getInStockChips() ).toBeNull();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
} );
const dropdown = getDropdown();
const dropdown = getDropdown();
if ( dropdown ) {
if ( dropdown ) {
await act( async () => {
await user.click( dropdown );
}
} );
}
const inStockSuggestion = getInStockSuggestion();
const inStockSuggestion = getInStockSuggestion();
if ( inStockSuggestion ) {
if ( inStockSuggestion ) {
await act( async () => {
await user.click( inStockSuggestion );
}
} );
}
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
const freshDropdown = getDropdown();
if ( freshDropdown ) {
const freshDropdown = getDropdown();
if ( freshDropdown ) {
await act( async () => {
await user.click( freshDropdown );
}
} );
}
const outOfStockSuggestion = getOutOfStockSuggestion();
const outOfStockSuggestion = getOutOfStockSuggestion();
if ( outOfStockSuggestion ) {
await userEvent.click( outOfStockSuggestion );
}
if ( outOfStockSuggestion ) {
userEvent.click( outOfStockSuggestion );
}
await waitFor( () => {
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeInTheDocument();
expect( getOnBackorderChips() ).toBeInTheDocument();
@ -415,30 +410,32 @@ describe( 'Filter by Stock block', () => {
} );
test( 'removes the option when the X button is clicked', async () => {
await waitFor( async () => {
const user = userEvent.setup();
const ratingParam = 'instock,outofstock,onbackorder';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
const user = userEvent.setup();
const ratingParam = 'instock,outofstock,onbackorder';
const {
getInStockChips,
getOutOfStockChips,
getOnBackorderChips,
getRemoveButtonFromChips,
} = setupMultipleChoiceDropdown( ratingParam );
await waitFor( () => {
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeInTheDocument();
expect( getOnBackorderChips() ).toBeInTheDocument();
} );
const removeOutOfStockButton = getRemoveButtonFromChips(
getOutOfStockChips()
);
const removeOutOfStockButton = getRemoveButtonFromChips(
getOutOfStockChips()
);
if ( removeOutOfStockButton ) {
await act( async () => {
await user.click( removeOutOfStockButton );
} );
}
if ( removeOutOfStockButton ) {
act( async () => {
await user.click( removeOutOfStockButton );
} );
}
await waitFor( () => {
expect( getInStockChips() ).toBeInTheDocument();
expect( getOutOfStockChips() ).toBeNull();
expect( getOnBackorderChips() ).toBeInTheDocument();
@ -453,73 +450,67 @@ describe( 'Filter by Stock block', () => {
expect( getList() ).toBeInTheDocument();
} );
test( 'renders checked options based on URL params', async () => {
await waitFor( async () => {
const ratingParam = 'instock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupSingleChoiceList( ratingParam );
test( 'renders checked options based on URL params', () => {
const ratingParam = 'instock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupSingleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
} );
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
} );
test( 'replaces chosen option when another one is clicked', async () => {
await waitFor( async () => {
const user = userEvent.setup();
const ratingParam = 'outofstock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupSingleChoiceList( ratingParam );
const user = userEvent.setup();
const ratingParam = 'outofstock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupSingleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
const onBackorderCheckbox = getOnBackorderCheckbox();
const onBackorderCheckbox = getOnBackorderCheckbox();
if ( onBackorderCheckbox ) {
await act( async () => {
await user.click( onBackorderCheckbox );
} );
}
if ( onBackorderCheckbox ) {
await act( async () => {
await user.click( onBackorderCheckbox );
} );
}
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
} );
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
} );
test( 'removes the option when it is clicked again', async () => {
await waitFor( async () => {
const ratingParam = 'onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = 'onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
const onBackorderCheckbox = getOnBackorderCheckbox();
if ( onBackorderCheckbox ) {
userEvent.click( onBackorderCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
const onBackorderCheckbox = getOnBackorderCheckbox();
if ( onBackorderCheckbox ) {
userEvent.click( onBackorderCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
} );
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
} );
} );
} );
@ -531,72 +522,66 @@ describe( 'Filter by Stock block', () => {
expect( getList() ).toBeInTheDocument();
} );
test( 'renders chips based on URL params', async () => {
await waitFor( async () => {
const ratingParam = 'instock,onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
test( 'renders chips based on URL params', () => {
const ratingParam = 'instock,onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
} );
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeFalsy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
} );
test( 'adds chosen option to another one that is clicked', async () => {
await waitFor( async () => {
const ratingParam = 'outofstock,onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = 'outofstock,onbackorder';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
const inStockCheckbox = getInStockCheckbox();
if ( inStockCheckbox ) {
userEvent.click( inStockCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
const inStockCheckbox = getInStockCheckbox();
if ( inStockCheckbox ) {
userEvent.click( inStockCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeTruthy();
} );
} );
} );
test( 'removes the option when it is clicked again', async () => {
await waitFor( async () => {
const ratingParam = 'instock,outofstock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
const ratingParam = 'instock,outofstock';
const {
getInStockCheckbox,
getOutOfStockCheckbox,
getOnBackorderCheckbox,
} = setupMultipleChoiceList( ratingParam );
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getInStockCheckbox()?.checked ).toBeTruthy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
const inStockCheckbox = getInStockCheckbox();
if ( inStockCheckbox ) {
userEvent.click( inStockCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
const inStockCheckbox = getInStockCheckbox();
if ( inStockCheckbox ) {
userEvent.click( inStockCheckbox );
}
await waitFor( () => {
expect( getInStockCheckbox()?.checked ).toBeFalsy();
expect( getOutOfStockCheckbox()?.checked ).toBeTruthy();
expect( getOnBackorderCheckbox()?.checked ).toBeFalsy();
} );
} );
} );
} );

View File

@ -28,14 +28,20 @@ test.describe( `Filters Overlay Navigation`, () => {
} );
} );
test( 'should be included in the Filters Overlay template part', async ( {
// Since we need to overhaul the overlay area, we can skip this test for now.
// eslint-disable-next-line playwright/no-skipped-test
test.skip( 'should be included in the Filters Overlay template part', async ( {
editor,
} ) => {
const block = editor.canvas.getByLabel( `Block: ${ blockData.title }` );
await expect( block ).toBeVisible();
} );
test( 'should have settings and styles controls', async ( { editor } ) => {
// Since we need to overhaul the overlay area, we can skip this test for now.
// eslint-disable-next-line playwright/no-skipped-test
test.skip( 'should have settings and styles controls', async ( {
editor,
} ) => {
const block = editor.canvas.getByLabel( `Block: ${ blockData.title }` );
await block.click();

View File

@ -3,6 +3,35 @@
*/
import { test, expect } from '@woocommerce/e2e-utils';
const templatePartData = {
selectors: {
frontend: {},
editor: {
blocks: {
activeFilters: {
title: 'Active (Experimental)',
blockLabel: 'Block: Active (Experimental)',
},
productFilters: {
title: 'Product Filters (Experimental)',
blockLabel: 'Block: Product Filters (Experimental)',
},
filterOptions: {
title: 'Filter Options',
blockLabel: 'Block: Filter Options',
},
productFiltersOverlayNavigation: {
title: 'Overlay Navigation (Experimental)',
name: 'woocommerce/product-filters-overlay-navigation',
blockLabel: 'Block: Overlay Navigation (Experimental)',
},
},
},
},
slug: 'product-filters',
productPage: '/product/hoodie/',
};
test.describe( 'Filters Overlay Template Part', () => {
test.beforeEach( async ( { admin, requestUtils } ) => {
await requestUtils.activatePlugin(
@ -34,10 +63,255 @@ test.describe( 'Filters Overlay Template Part', () => {
.locator( '[data-type="core/template-part"]' )
.filter( {
has: editor.canvas.getByLabel(
'Block: Product Filters (Experimental)'
templatePartData.selectors.editor.blocks.productFilters
.blockLabel
),
} );
await expect( productFiltersTemplatePart ).toBeVisible();
} );
test.describe( 'frontend', () => {
test.beforeEach( async ( { admin } ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//archive-product`,
postType: 'wp_template',
canvas: 'edit',
} );
} );
test( 'should open and close the dialog when clicking on the Product Filters Overlay Navigation block', async ( {
editor,
page,
frontendUtils,
} ) => {
await editor.setContent( '' );
await editor.openGlobalBlockInserter();
await page
.getByText(
templatePartData.selectors.editor.blocks.productFilters
.title
)
.click();
const block = editor.canvas.getByLabel(
templatePartData.selectors.editor.blocks.productFilters
.blockLabel
);
await expect( block ).toBeVisible();
// This forces the list view to show the inner blocks of the Product Filters template part.
await editor.canvas
.getByLabel(
templatePartData.selectors.editor.blocks.activeFilters
.blockLabel
)
.getByLabel(
templatePartData.selectors.editor.blocks.filterOptions
.blockLabel
)
.click();
await editor.openDocumentSettingsSidebar();
await page.getByLabel( 'Document Overview' ).click();
await page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFilters.title,
} )
.nth( 1 )
.click();
const layoutSettings = editor.page.getByText(
'OverlayNeverMobileAlways'
);
await layoutSettings.getByLabel( 'Always' ).click();
await editor.page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.title,
} )
.click();
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: false,
} );
await page.goto( '/shop/' );
const productFiltersOverlayNavigation = (
await frontendUtils.getBlockByName(
templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.name
)
).filter( {
has: page.locator( ':visible' ),
} );
await expect( productFiltersOverlayNavigation ).toBeVisible();
await page
.locator( '.wc-block-product-filters-overlay-navigation' )
.first()
.click();
const productFiltersDialog = page.locator(
'.wc-block-product-filters--dialog-open'
);
await expect( productFiltersDialog ).toBeVisible();
const productFiltersDialogCloseButton = (
await frontendUtils.getBlockByName(
templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.name
)
).filter( { hasText: 'Close' } );
await expect( productFiltersDialogCloseButton ).toBeVisible();
await productFiltersDialogCloseButton.click();
await expect( productFiltersDialog ).toBeHidden();
} );
test( 'should hide Product Filters Overlay Navigation block when the Overlay mode is set to `Never`', async ( {
editor,
page,
frontendUtils,
} ) => {
await editor.setContent( '' );
await editor.openGlobalBlockInserter();
await page
.getByText(
templatePartData.selectors.editor.blocks.productFilters
.title
)
.click();
const block = editor.canvas.getByLabel(
templatePartData.selectors.editor.blocks.productFilters
.blockLabel
);
await expect( block ).toBeVisible();
// This forces the list view to show the inner blocks of the Product Filters template part.
await editor.canvas
.getByLabel(
templatePartData.selectors.editor.blocks.activeFilters
.blockLabel
)
.getByLabel(
templatePartData.selectors.editor.blocks.filterOptions
.blockLabel
)
.click();
await editor.openDocumentSettingsSidebar();
await page.getByLabel( 'Document Overview' ).click();
await page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFilters.title,
} )
.nth( 1 )
.click();
const layoutSettings = editor.page.getByText(
'OverlayNeverMobileAlways'
);
await layoutSettings.getByLabel( 'Never' ).click();
await editor.page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.title,
} )
.click();
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
await page.goto( '/shop/' );
const productFiltersOverlayNavigation = (
await frontendUtils.getBlockByName(
templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.name
)
).filter( {
has: page.locator( ':visible' ),
} );
await expect( productFiltersOverlayNavigation ).toBeHidden();
} );
test( 'should hide Product Filters Overlay Navigation block when the Overlay mode is set to `Mobile` and user is on desktop', async ( {
editor,
page,
frontendUtils,
} ) => {
await editor.setContent( '' );
await editor.openGlobalBlockInserter();
await page
.getByText(
templatePartData.selectors.editor.blocks.productFilters
.title
)
.click();
const block = editor.canvas.getByLabel(
templatePartData.selectors.editor.blocks.productFilters
.blockLabel
);
await expect( block ).toBeVisible();
// This forces the list view to show the inner blocks of the Product Filters template part.
await editor.canvas
.getByLabel(
templatePartData.selectors.editor.blocks.activeFilters
.blockLabel
)
.getByLabel(
templatePartData.selectors.editor.blocks.filterOptions
.blockLabel
)
.click();
await editor.openDocumentSettingsSidebar();
await page.getByLabel( 'Document Overview' ).click();
await page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFilters.title,
} )
.nth( 1 )
.click();
const layoutSettings = editor.page.getByText(
'OverlayNeverMobileAlways'
);
await layoutSettings.getByLabel( 'Mobile' ).click();
await editor.page
.getByRole( 'link', {
name: templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.title,
} )
.click();
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: false,
} );
await page.goto( '/shop/' );
const productFiltersOverlayNavigation = (
await frontendUtils.getBlockByName(
templatePartData.selectors.editor.blocks
.productFiltersOverlayNavigation.name
)
).filter( {
has: page.locator( ':visible' ),
} );
await expect( productFiltersOverlayNavigation ).toBeHidden();
} );
} );
} );

View File

@ -17,10 +17,21 @@ const blockData = {
settings: {},
layoutWrapper:
'.wp-block-woocommerce-product-filters-is-layout-flex',
blocks: {
filters: {
title: 'Product Filters (Experimental)',
label: 'Block: Product Filters (Experimental)',
},
overlay: {
title: 'Overlay Navigation (Experimental)',
label: 'Block: Overlay Navigation (Experimental)',
},
},
},
},
slug: 'archive-product',
productPage: '/product/hoodie/',
shopPage: '/shop/',
};
const test = base.extend< { pageObject: ProductFiltersPage } >( {
@ -53,7 +64,7 @@ test.describe( `${ blockData.name }`, () => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas.getByLabel(
'Block: Product Filters (Experimental)'
blockData.selectors.editor.blocks.filters.label
);
await expect( block ).toBeVisible();
@ -141,7 +152,7 @@ test.describe( `${ blockData.name }`, () => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas.getByLabel(
'Block: Product Filters (Experimental)'
blockData.selectors.editor.blocks.filters.label
);
await expect( block ).toBeVisible();
@ -151,7 +162,7 @@ test.describe( `${ blockData.name }`, () => {
await expect( listView ).toBeVisible();
const productFiltersBlockListItem = listView.getByRole( 'link', {
name: 'Product Filters (Experimental)',
name: blockData.selectors.editor.blocks.filters.title,
} );
await expect( productFiltersBlockListItem ).toBeVisible();
const listViewExpander =
@ -198,7 +209,7 @@ test.describe( `${ blockData.name }`, () => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas.getByLabel(
'Block: Product Filters (Experimental)'
blockData.selectors.editor.blocks.filters.label
);
await expect( block ).toBeVisible();
@ -245,10 +256,17 @@ test.describe( `${ blockData.name }`, () => {
} ) => {
await pageObject.addProductFiltersBlock( { cleanContent: true } );
const block = editor.canvas.getByLabel(
'Block: Product Filters (Experimental)'
const filtersBlock = editor.canvas.getByLabel(
blockData.selectors.editor.blocks.filters.label
);
await expect( block ).toBeVisible();
await expect( filtersBlock ).toBeVisible();
const overlayBlock = editor.canvas.getByLabel(
blockData.selectors.editor.blocks.overlay.label
);
// Overlay mode is set to 'Never' by default so the block should be hidden
await expect( overlayBlock ).toBeHidden();
await editor.openDocumentSettingsSidebar();
@ -259,17 +277,6 @@ test.describe( `${ blockData.name }`, () => {
// Overlay settings
const overlayModeSettings = [ 'Never', 'Mobile', 'Always' ];
const overlayButtonSettings = [
'Label and icon',
'Label only',
'Icon only',
];
const overlayIconsSettings = [
'Filter icon 1',
'Filter icon 2',
'Filter icon 3',
'Filter icon 4',
];
await expect( editor.page.getByText( 'Overlay' ) ).toBeVisible();
@ -277,43 +284,27 @@ test.describe( `${ blockData.name }`, () => {
await expect( editor.page.getByText( mode ) ).toBeVisible();
}
await editor.page.getByLabel( 'Never' ).click();
await expect( editor.page.getByText( 'Edit overlay' ) ).toBeHidden();
await expect( overlayBlock ).toBeHidden();
await editor.page.getByLabel( 'Mobile' ).click();
await expect( editor.page.getByText( 'BUTTON' ) ).toBeVisible();
for ( const mode of overlayButtonSettings ) {
await expect( editor.page.getByText( mode ) ).toBeVisible();
}
for ( const mode of overlayIconsSettings ) {
await expect( editor.page.getByLabel( mode ) ).toBeVisible();
}
await expect( editor.page.getByText( 'ICON SIZE' ) ).toBeVisible();
await expect( editor.page.getByText( 'Edit overlay' ) ).toBeVisible();
await expect( overlayBlock ).toBeVisible();
await editor.page.getByLabel( 'Always' ).click();
await expect( editor.page.getByText( 'BUTTON' ) ).toBeHidden();
for ( const mode of overlayButtonSettings ) {
await expect( editor.page.getByText( mode ) ).toBeHidden();
}
for ( const mode of overlayIconsSettings ) {
await expect( editor.page.getByLabel( mode ) ).toBeHidden();
}
await expect( editor.page.getByText( 'Edit overlay' ) ).toBeVisible();
await editor.page.getByLabel( 'Mobile' ).click();
await expect( overlayBlock ).toBeVisible();
await editor.page.locator( 'input[value="label"]' ).click();
await editor.page.getByLabel( 'Never' ).click();
for ( const mode of overlayIconsSettings ) {
await expect( editor.page.getByLabel( mode ) ).toBeHidden();
}
await expect( editor.page.getByText( 'Edit overlay' ) ).toBeVisible();
await expect( overlayBlock ).toBeHidden();
} );
test( 'Layout > default to vertical stretch', async ( {

View File

@ -43,4 +43,40 @@ export class ProductFiltersPage {
}
return this.editor.getBlockByName( blockName );
}
async getProductFiltersOverlayNavigationBlock( {
page,
}: {
page: 'frontend' | 'editor';
} ) {
const blockName = 'woocommerce/product-filters-overlay-navigation';
if ( page === 'frontend' ) {
return (
await this.frontendUtils.getBlockByName( blockName )
).filter( {
has: this.page.locator( ':visible' ),
} );
}
return this.editor.canvas.getByLabel(
'Block: Overlay Navigation (Experimental)'
);
}
async selectOverlayMode( {
mode,
}: {
mode: 'mobile' | 'always' | 'never';
} ) {
switch ( mode ) {
case 'always':
await this.page.getByLabel( 'Always' ).click();
break;
case 'mobile':
await this.page.getByLabel( 'Mobile' ).click();
break;
case 'never':
await this.page.getByLabel( 'Never' ).click();
break;
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Comment: Add the Fullscreen view to the Product Filters

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Comment: Product Filters: update overlay navigation UX

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Fix size for coming soon banner button

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Deprecate single product block save #51153

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix duplicate spec evaluation in evaluate_specs()

View File

@ -0,0 +1,4 @@
Significance: minor
Type: performance
Only load local pickup methods on cart/checkout pages

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Clean up Purchase task

View File

@ -1699,7 +1699,10 @@ p.demo_store,
* Buttons
*/
.woocommerce:where(body:not(.woocommerce-block-theme-has-button-styles)),
:where(body:not(.woocommerce-block-theme-has-button-styles)) .woocommerce {
:where(body:not(.woocommerce-block-theme-has-button-styles)):where(
:not(.edit-post-visual-editor *)
)
.woocommerce {
a.button,
button.button,
input.button,

View File

@ -1,203 +0,0 @@
<?php
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Purchase Task
*/
class Purchase extends Task {
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'update_option_woocommerce_onboarding_profile', array( $this, 'clear_dismissal' ), 10, 2 );
}
/**
* Clear dismissal on onboarding product type changes.
*
* @param array $old_value Old value.
* @param array $new_value New value.
*/
public function clear_dismissal( $old_value, $new_value ) {
$product_types = isset( $new_value['product_types'] ) ? (array) $new_value['product_types'] : array();
$previous_product_types = isset( $old_value['product_types'] ) ? (array) $old_value['product_types'] : array();
if ( empty( array_diff( $product_types, $previous_product_types ) ) ) {
return;
}
$this->undo_dismiss();
}
/**
* Get the task arguments.
* ID.
*
* @return string
*/
public function get_id() {
return 'purchase';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
$products = $this->get_paid_products_and_themes();
$first_product = count( $products['purchaseable'] ) >= 1 ? $products['purchaseable'][0] : false;
if ( ! $first_product ) {
return null;
}
$product_label = isset( $first_product['label'] ) ? $first_product['label'] : $first_product['title'];
$additional_count = count( $products['purchaseable'] ) - 1;
if ( $this->get_parent_option( 'use_completed_title' ) && $this->is_complete() ) {
return count( $products['purchaseable'] ) === 1
? sprintf(
/* translators: %1$s: a purchased product name */
__(
'You added %1$s',
'woocommerce'
),
$product_label
)
: sprintf(
/* translators: %1$s: a purchased product name, %2$d the number of other products purchased */
_n(
'You added %1$s and %2$d other product',
'You added %1$s and %2$d other products',
$additional_count,
'woocommerce'
),
$product_label,
$additional_count
);
}
return count( $products['purchaseable'] ) === 1
? sprintf(
/* translators: %1$s: a purchaseable product name */
__(
'Add %s to my store',
'woocommerce'
),
$product_label
)
: sprintf(
/* translators: %1$s: a purchaseable product name, %2$d the number of other products to purchase */
_n(
'Add %1$s and %2$d more product to my store',
'Add %1$s and %2$d more products to my store',
$additional_count,
'woocommerce'
),
$product_label,
$additional_count
);
}
/**
* Content.
*
* @return string
*/
public function get_content() {
$products = $this->get_paid_products_and_themes();
if ( count( $products['remaining'] ) === 1 ) {
return isset( $products['purchaseable'][0]['description'] ) ? $products['purchaseable'][0]['description'] : $products['purchaseable'][0]['excerpt'];
}
return sprintf(
/* translators: %1$s: list of product names comma separated, %2%s the last product name */
__(
'Good choice! You chose to add %1$s and %2$s to your store.',
'woocommerce'
),
implode( ', ', array_slice( $products['remaining'], 0, -1 ) ) . ( count( $products['remaining'] ) > 2 ? ',' : '' ),
end( $products['remaining'] )
);
}
/**
* Action label.
*
* @return string
*/
public function get_action_label() {
return __( 'Purchase & install now', 'woocommerce' );
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
/**
* Task completion.
*
* @return bool
*/
public function is_complete() {
$products = $this->get_paid_products_and_themes();
return count( $products['remaining'] ) === 0;
}
/**
* Dismissable.
*
* @return bool
*/
public function is_dismissable() {
return true;
}
/**
* Task visibility.
*
* @return bool
*/
public function can_view() {
$products = $this->get_paid_products_and_themes();
return count( $products['purchaseable'] ) > 0;
}
/**
* Get purchaseable and remaining products.
*
* @return array purchaseable and remaining products and themes.
*/
public static function get_paid_products_and_themes() {
$relevant_products = OnboardingProducts::get_relevant_products();
$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
$theme = isset( $profiler_data['theme'] ) ? $profiler_data['theme'] : null;
$paid_theme = $theme ? OnboardingThemes::get_paid_theme_by_slug( $theme ) : null;
if ( $paid_theme ) {
$relevant_products['purchaseable'][] = $paid_theme;
if ( isset( $paid_theme['is_installed'] ) && false === $paid_theme['is_installed'] ) {
$relevant_products['remaining'][] = $paid_theme['title'];
}
}
return $relevant_products;
}
}

View File

@ -13,6 +13,13 @@ use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleEvaluator;
* Evaluates the spec and returns the evaluated suggestion.
*/
class EvaluateSuggestion {
/**
* Stores memoized results of evaluate_specs.
*
* @var array
*/
protected static $memo = array();
/**
* Evaluates the spec and returns the suggestion.
*
@ -58,6 +65,12 @@ class EvaluateSuggestion {
* @return array The visible suggestions and errors.
*/
public static function evaluate_specs( $specs, $logger_args = array() ) {
$specs_key = self::get_memo_key( $specs );
if ( isset( self::$memo[ $specs_key ] ) ) {
return self::$memo[ $specs_key ];
}
$suggestions = array();
$errors = array();
@ -72,9 +85,43 @@ class EvaluateSuggestion {
}
}
return array(
$result = array(
'suggestions' => $suggestions,
'errors' => $errors,
);
// Memoize results, with a fail safe to prevent unbounded memory growth.
// This limit is unlikely to be reached under normal circumstances.
if ( count( self::$memo ) > 50 ) {
self::reset_memo();
}
self::$memo[ $specs_key ] = $result;
return $result;
}
/**
* Resets the memoized results. Useful for testing.
*/
public static function reset_memo() {
self::$memo = array();
}
/**
* Returns a memoization key for the given specs.
*
* @param array $specs The specs to generate a key for.
*
* @return string The memoization key.
*/
private static function get_memo_key( $specs ) {
$data = wp_json_encode( $specs );
if ( function_exists( 'hash' ) && in_array( 'xxh3', hash_algos(), true ) ) {
// Use xxHash (xxh3) if available.
return hash( 'xxh3', $data );
}
// Fall back to CRC32.
return (string) crc32( $data );
}
}

View File

@ -245,8 +245,12 @@ class Cart extends AbstractBlock {
$this->asset_data_registry->add( 'hasDarkEditorStyleSupport', current_theme_supports( 'dark-editor-style' ) );
$this->asset_data_registry->register_page_id( isset( $attributes['checkoutPageId'] ) ? $attributes['checkoutPageId'] : 0 );
$this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme() );
$pickup_location_settings = LocalPickupUtils::get_local_pickup_settings();
$local_pickup_method_ids = LocalPickupUtils::get_local_pickup_method_ids();
$this->asset_data_registry->add( 'localPickupEnabled', $pickup_location_settings['enabled'] );
$this->asset_data_registry->add( 'collectableMethodIds', $local_pickup_method_ids );
// Hydrate the following data depending on admin or frontend context.
if ( ! is_admin() && ! WC()->is_rest_api_request() ) {

View File

@ -370,8 +370,11 @@ class Checkout extends AbstractBlock {
$this->asset_data_registry->add( 'isBlockTheme', wc_current_theme_is_fse_theme() );
$pickup_location_settings = LocalPickupUtils::get_local_pickup_settings();
$local_pickup_method_ids = LocalPickupUtils::get_local_pickup_method_ids();
$this->asset_data_registry->add( 'localPickupEnabled', $pickup_location_settings['enabled'] );
$this->asset_data_registry->add( 'localPickupText', $pickup_location_settings['title'] );
$this->asset_data_registry->add( 'collectableMethodIds', $local_pickup_method_ids );
$is_block_editor = $this->is_block_editor();
@ -385,8 +388,8 @@ class Checkout extends AbstractBlock {
$shipping_methods = WC()->shipping()->get_shipping_methods();
$formatted_shipping_methods = array_reduce(
$shipping_methods,
function ( $acc, $method ) {
if ( in_array( $method->id, LocalPickupUtils::get_local_pickup_method_ids(), true ) ) {
function ( $acc, $method ) use ( $local_pickup_method_ids ) {
if ( in_array( $method->id, $local_pickup_method_ids, true ) ) {
return $acc;
}
if ( $method->supports( 'settings' ) ) {

View File

@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
* ProductFilters class.
*/
@ -18,16 +20,91 @@ class ProductFilters extends AbstractBlock {
* @return string[]
*/
protected function get_block_type_uses_context() {
return [ 'postId' ];
return array( 'postId' );
}
/**
* Get the frontend style handle for this block type.
* Return the dialog content.
*
* @return null
* @return string
*/
protected function get_block_type_style() {
return null;
protected function render_dialog() {
$template_part = BlockTemplateUtils::get_template_part( 'product-filters-overlay' );
$html = $this->render_template_part( $template_part );
$html = strtr(
'<dialog hidden role="dialog" aria-modal="true">
{{html}}
</dialog>',
array(
'{{html}}' => $html,
)
);
$p = new \WP_HTML_Tag_Processor( $html );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-filters' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
$p->set_attribute( 'data-wc-bind--hidden', '!state.isDialogOpen' );
$p->set_attribute( 'data-wc-class--wc-block-product-filters--dialog-open', 'state.isDialogOpen' );
$p->set_attribute( 'data-wc-class--wc-block-product-filters--with-admin-bar', 'context.hasPageWithWordPressAdminBar' );
$html = $p->get_updated_html();
}
return $html;
}
/**
* This method is used to render the template part. For each template part, we parse the blocks and render them.
*
* @param string $template_part The template part to render.
* @return string The rendered template part.
*/
protected function render_template_part( $template_part ) {
$parsed_blocks = parse_blocks( $template_part );
$wrapper_template_part_block = $parsed_blocks[0];
$html = $wrapper_template_part_block['innerHTML'];
$target_div = '</div>';
$template_part_content_html = array_reduce(
$wrapper_template_part_block['innerBlocks'],
function ( $carry, $item ) {
if ( 'core/template-part' === $item['blockName'] ) {
$inner_template_part = BlockTemplateUtils::get_template_part( $item['attrs']['slug'] );
$inner_template_part_content_html = $this->render_template_part( $inner_template_part );
return $carry . $inner_template_part_content_html;
}
return $carry . render_block( $item );
},
''
);
$html = str_replace( $target_div, $template_part_content_html . $target_div, $html );
return $html;
}
/**
* Inject dialog into the product filters HTML.
*
* @param string $product_filters_html The Product Filters HTML.
* @param string $dialog_html The dialog HTML.
*
* @return string
*/
protected function inject_dialog( $product_filters_html, $dialog_html ) {
// Find the position of the last </div>.
$pos = strrpos( $product_filters_html, '</div>' );
if ( $pos ) {
// Inject the dialog_html at the correct position.
$html = substr_replace( $product_filters_html, $dialog_html, $pos, 0 );
return $html;
}
return $product_filters_html;
}
/**
@ -39,6 +116,28 @@ class ProductFilters extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
return $content;
$html = $content;
$p = new \WP_HTML_Tag_Processor( $html );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-filters' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
$p->set_attribute(
'data-wc-context',
wp_json_encode(
array(
'isDialogOpen' => false,
'hasPageWithWordPressAdminBar' => false,
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
)
);
$html = $p->get_updated_html();
}
$dialog_html = $this->render_dialog();
$html = $this->inject_dialog( $html, $dialog_html );
return $html;
}
}

View File

@ -21,17 +21,6 @@ class ProductFiltersOverlayNavigation extends AbstractBlock {
return [ 'woocommerce/product-filters/overlay' ];
}
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string|null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Include and render the block.
*
@ -46,13 +35,13 @@ class ProductFiltersOverlayNavigation extends AbstractBlock {
'class' => 'wc-block-product-filters-overlay-navigation',
)
);
$overlay_mode = $block->context['woocommerce/product-filters/overlay'];
$overlay_mode = isset( $block->context['woocommerce/product-filters/overlay'] ) ? $block->context['woocommerce/product-filters/overlay'] : 'never';
if ( 'never' === $overlay_mode || ( ! wp_is_mobile() && 'mobile' === $overlay_mode ) ) {
if ( 'open-overlay' === $attributes['triggerType'] && ( 'never' === $overlay_mode || ( ! wp_is_mobile() && 'mobile' === $overlay_mode ) ) ) {
return null;
}
$html_content = strtr(
$html = strtr(
'<div {{wrapper_attributes}}>
{{primary_content}}
{{secondary_content}}
@ -63,7 +52,20 @@ class ProductFiltersOverlayNavigation extends AbstractBlock {
'{{secondary_content}}' => 'open-overlay' === $attributes['triggerType'] ? $this->render_label( $attributes ) : $this->render_icon( $attributes ),
)
);
return $html_content;
$p = new \WP_HTML_Tag_Processor( $html );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-filters' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) );
$p->set_attribute(
'data-wc-on--click',
'open-overlay' === $attributes['triggerType'] ? 'actions.openDialog' : 'actions.closeDialog'
);
$p->set_attribute( 'data-wc-class--hidden', 'open-overlay' === $attributes['triggerType'] ? 'state.isDialogOpen' : '!state.isDialogOpen' );
$html = $p->get_updated_html();
}
return $html;
}
/**

View File

@ -60,8 +60,6 @@ class ShippingController {
}
);
}
$this->asset_data_registry->add( 'collectableMethodIds', array( 'Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils', 'get_local_pickup_method_ids' ) );
$this->asset_data_registry->add( 'shippingCostRequiresAddress', get_option( 'woocommerce_shipping_cost_requires_address', false ) === 'yes' );
add_action( 'rest_api_init', array( $this, 'register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'admin_scripts' ) );

View File

@ -1,6 +1,6 @@
<!-- wp:woocommerce/product-filters-overlay {"lock":{"move":true,"remove":true}} -->
<div class="wp-block-woocommerce-product-filters-overlay wc-block-product-filters-overlay" style="padding-top:1rem;padding-right:1rem;padding-bottom:1rem;padding-left:1rem">
<!-- wp:woocommerce/product-filters-overlay-navigation {"lock":{"move":true,"remove":true}} -->
<!-- wp:woocommerce/product-filters-overlay-navigation {"triggerType":"close-overlay","lock":{"move":true,"remove":true}} -->
<div class="wp-block-woocommerce-product-filters-overlay-navigation alignright wc-block-product-filters-overlay-navigation"></div>
<!-- /wp:woocommerce/product-filters-overlay-navigation -->

View File

@ -1,45 +1,12 @@
const { test, expect } = require( '@playwright/test' );
const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default;
const { test: baseTest, expect } = require( '../../fixtures/fixtures' );
test.describe( 'Payment setup task', () => {
test.use( { storageState: process.env.ADMINSTATE } );
test.beforeEach( async ( { baseURL } ) => {
await new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc-admin',
} ).post( 'onboarding/profile', {
const test = baseTest.extend( {
storageState: process.env.ADMINSTATE,
page: async ( { api, page, wpApi, wcAdminApi }, use ) => {
await wcAdminApi.post( 'onboarding/profile', {
skipped: true,
} );
} );
test.afterAll( async ( { baseURL } ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
await api.put( 'payment_gateways/bacs', {
enabled: false,
} );
await api.put( 'payment_gateways/cod', {
enabled: false,
} );
} );
test( 'Saving valid bank account transfer details enables the payment method', async ( {
baseURL,
page,
} ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
// Ensure store's base country location is a WooPayments non-supported country (AF).
// Otherwise, the WooPayments task page logic or WooPayments redirects will kick in.
await api.post( 'settings/general/batch', {
@ -50,14 +17,44 @@ test.describe( 'Payment setup task', () => {
},
],
} );
const bacsInitialState = await api.get( 'payment_gateways/bacs' );
const codInitialState = await api.get( 'payment_gateways/cod' );
// Disable the help popover.
await wpApi.post( '/wp-json/wp/v2/users/1?_locale=user', {
data: {
woocommerce_meta: {
help_panel_highlight_shown: '"yes"',
},
},
} );
await use( page );
// Reset the payment gateways to their initial state.
await api.put( 'payment_gateways/bacs', {
enabled: bacsInitialState.data.enabled,
} );
await api.put( 'payment_gateways/cod', {
enabled: codInitialState.data.enabled,
} );
},
} );
test.describe( 'Payment setup task', () => {
test( 'Saving valid bank account transfer details enables the payment method', async ( {
page,
api,
} ) => {
await api.put( 'payment_gateways/bacs', {
enabled: false,
} );
// Load the bank transfer page.
await page.goto(
'wp-admin/admin.php?page=wc-admin&task=payments&id=bacs'
);
// purposely no await -- close the help dialog if/when it appears
page.locator( '.components-button.is-small.has-icon' )
.click()
.catch( () => {} );
// Fill in bank transfer form.
await page
@ -93,25 +90,8 @@ test.describe( 'Payment setup task', () => {
} );
test( 'Can visit the payment setup task from the homescreen if the setup wizard has been skipped', async ( {
baseURL,
page,
} ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
// Ensure store's base country location is a WooPayments non-supported country (AF).
// Otherwise, the WooPayments task page logic or WooPayments redirects will kick in.
await api.post( 'settings/general/batch', {
update: [
{
id: 'woocommerce_default_country',
value: 'AF',
},
],
} );
await page.goto( 'wp-admin/admin.php?page=wc-admin' );
await page.locator( 'text=Get paid' ).click();
await expect(
@ -121,43 +101,31 @@ test.describe( 'Payment setup task', () => {
test( 'Enabling cash on delivery enables the payment method', async ( {
page,
baseURL,
api,
} ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
await api.put( 'payment_gateways/cod', {
enabled: false,
} );
// Ensure store's base country location is a WooPayments non-supported country (AF).
// Otherwise, the WooPayments task page logic or WooPayments redirects will kick in.
await api.post( 'settings/general/batch', {
update: [
{
id: 'woocommerce_default_country',
value: 'AF',
},
],
} );
await page.goto( 'wp-admin/admin.php?page=wc-admin&task=payments' );
// purposely no await -- close the help dialog if/when it appears
page.locator( '.components-button.is-small.has-icon' )
.click()
.catch( () => {} );
const paymentGatewaysResponse = page.waitForResponse(
( response ) =>
response.url().includes( 'wp-json/wc/v3/payment_gateways' ) &&
response.ok()
);
await page.goto( 'wp-admin/admin.php?page=wc-admin&task=payments' );
await paymentGatewaysResponse;
// Enable COD payment option.
await page
.locator(
'div.woocommerce-task-payment-cod > div.woocommerce-task-payment__footer > .woocommerce-task-payment__action'
)
.locator( 'div.woocommerce-task-payment-cod' )
.getByRole( 'button', { name: 'Enable' } )
.click();
// Check that COD was set up.
await expect(
page.locator(
'div.woocommerce-task-payment-cod > div.woocommerce-task-payment__footer > .woocommerce-task-payment__action'
)
).toContainText( 'Manage' );
page
.locator( 'div.woocommerce-task-payment-cod' )
.getByRole( 'button', { name: 'Manage' } )
).toBeVisible();
await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=checkout' );

View File

@ -1,10 +1,6 @@
const { test, expect } = require( '@playwright/test' );
const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default;
const { test: baseTest, expect } = require( '../../fixtures/fixtures' );
const { random } = require( '../../utils/helpers' );
const simpleProductName = 'Add new order simple product';
const variableProductName = 'Add new order variable product';
const externalProductName = 'Add new order external product';
const groupedProductName = 'Add new order grouped product';
const taxClasses = [
{
name: 'Tax Class Simple',
@ -37,28 +33,230 @@ const taxRates = [
},
];
const taxTotals = [ '10.00', '20.00', '240.00' ];
let simpleProductId,
variableProductId,
externalProductId,
subProductAId,
subProductBId,
groupedProductId,
customerId,
orderId;
async function getOrderIdFromPage( page ) {
// get order ID from the page
const orderText = await page
.locator( 'h2.woocommerce-order-data__heading' )
.textContent();
const parts = orderText.match( /([0-9])\w+/ );
return parts[ 0 ];
}
async function addProductToOrder( page, product, quantity ) {
await page.getByRole( 'button', { name: 'Add item(s)' } ).click();
await page.getByRole( 'button', { name: 'Add product(s)' } ).click();
await page.getByText( 'Search for a product…' ).click();
await page.locator( 'span > .select2-search__field' ).fill( product.name );
await page.getByRole( 'option', { name: product.name } ).first().click();
await page
.locator( 'tr' )
.filter( { hasText: product.name } )
.getByPlaceholder( '1' )
.fill( quantity.toString() );
await page.locator( '#btn-ok' ).click();
}
const test = baseTest.extend( {
storageState: process.env.ADMINSTATE,
order: async ( { api }, use ) => {
const order = {};
await use( order );
if ( order.id ) {
await api.delete( `orders/${ order.id }`, { force: true } );
}
},
customer: async ( { api }, use ) => {
let customer = {};
const username = `sideshowbob_${ random() }`;
await api
.post( 'customers', {
email: `${ username }@example.com`,
first_name: 'Sideshow',
last_name: 'Bob',
username,
billing: {
first_name: 'Sideshow',
last_name: 'Bob',
company: 'Die Bart Die',
address_1: '123 Fake St',
address_2: '',
city: 'Springfield',
state: 'FL',
postcode: '12345',
country: 'US',
email: `${ username }@example.com`,
phone: '555-555-5556',
},
shipping: {
first_name: 'Sideshow',
last_name: 'Bob',
company: 'Die Bart Die',
address_1: '321 Fake St',
address_2: '',
city: 'Springfield',
state: 'FL',
postcode: '12345',
country: 'US',
},
} )
.then( ( response ) => {
customer = response.data;
} );
await use( customer );
// Cleanup
await api.delete( `customers/${ customer.id }`, { force: true } );
},
simpleProduct: async ( { api }, use ) => {
let product = {};
await api
.post( 'products', {
name: `Product simple ${ random() }`,
type: 'simple',
regular_price: '100',
tax_class: 'Tax Class Simple',
} )
.then( ( response ) => {
product = response.data;
} );
await use( product );
// Cleanup
await api.delete( `products/${ product.id }`, { force: true } );
},
variableProduct: async ( { api }, use ) => {
let product = {};
const variations = [
{
regular_price: '100',
attributes: [
{
name: 'Size',
option: 'Small',
},
{
name: 'Colour',
option: 'Yellow',
},
],
tax_class: 'Tax Class Variable',
},
{
regular_price: '100',
attributes: [
{
name: 'Size',
option: 'Medium',
},
{
name: 'Colour',
option: 'Magenta',
},
],
tax_class: 'Tax Class Variable',
},
];
await api
.post( 'products', {
name: `Product variable ${ random() }`,
type: 'variable',
tax_class: 'Tax Class Variable',
} )
.then( ( response ) => {
product = response.data;
} );
for ( const key in variations ) {
api.post(
`products/${ product.id }/variations`,
variations[ key ]
);
}
await use( product );
// Cleanup
await api.delete( `products/${ product.id }`, { force: true } );
},
externalProduct: async ( { api }, use ) => {
let product = {};
await api
.post( 'products', {
name: `Product external ${ random() }`,
regular_price: '800',
tax_class: 'Tax Class External',
external_url: 'https://wordpress.org/plugins/woocommerce',
type: 'external',
button_text: 'Buy now',
} )
.then( ( response ) => {
product = response.data;
} );
await use( product );
// Cleanup
await api.delete( `products/${ product.id }`, { force: true } );
},
groupedProduct: async ( { api }, use ) => {
let product = {};
let subProductAId;
let subProductBId;
await api
.post( 'products', {
name: 'Add-on A',
regular_price: '11.95',
} )
.then( ( response ) => {
subProductAId = response.data.id;
} );
await api
.post( 'products', {
name: 'Add-on B',
regular_price: '18.97',
} )
.then( ( response ) => {
subProductBId = response.data.id;
} );
await api
.post( 'products', {
name: `Product grouped ${ random() }`,
regular_price: '29.99',
grouped_products: [ subProductAId, subProductBId ],
type: 'grouped',
} )
.then( ( response ) => {
product = response.data;
} );
await use( product );
// Cleanup
await api.delete( `products/${ product.id }`, { force: true } );
},
} );
test.describe(
'WooCommerce Orders > Add new order',
{ tag: [ '@services', '@hpos' ] },
() => {
test.use( { storageState: process.env.ADMINSTATE } );
test.beforeAll( async ( { baseURL } ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
test.beforeAll( async ( { api } ) => {
// enable taxes on the account
await api.put( 'settings/general/woocommerce_calc_taxes', {
value: 'yes',
@ -71,171 +269,9 @@ test.describe(
for ( let i = 0; i < taxRates.length; i++ ) {
await api.post( 'taxes', taxRates[ i ] );
}
// create simple product
await api
.post( 'products', {
name: simpleProductName,
type: 'simple',
regular_price: '100',
tax_class: 'Tax Class Simple',
} )
.then( ( resp ) => {
simpleProductId = resp.data.id;
} );
// create variable product
const variations = [
{
regular_price: '100',
attributes: [
{
name: 'Size',
option: 'Small',
},
{
name: 'Colour',
option: 'Yellow',
},
],
tax_class: 'Tax Class Variable',
},
{
regular_price: '100',
attributes: [
{
name: 'Size',
option: 'Medium',
},
{
name: 'Colour',
option: 'Magenta',
},
],
tax_class: 'Tax Class Variable',
},
];
await api
.post( 'products', {
name: variableProductName,
type: 'variable',
tax_class: 'Tax Class Variable',
} )
.then( ( response ) => {
variableProductId = response.data.id;
for ( const key in variations ) {
api.post(
`products/${ variableProductId }/variations`,
variations[ key ]
);
}
} );
// create external product
await api
.post( 'products', {
name: externalProductName,
regular_price: '800',
tax_class: 'Tax Class External',
external_url: 'https://wordpress.org/plugins/woocommerce',
type: 'external',
button_text: 'Buy now',
} )
.then( ( response ) => {
externalProductId = response.data.id;
} );
// create grouped product
await api
.post( 'products', {
name: 'Add-on A',
regular_price: '11.95',
} )
.then( ( response ) => {
subProductAId = response.data.id;
} );
await api
.post( 'products', {
name: 'Add-on B',
regular_price: '18.97',
} )
.then( ( response ) => {
subProductBId = response.data.id;
} );
await api
.post( 'products', {
name: groupedProductName,
regular_price: '29.99',
grouped_products: [ subProductAId, subProductBId ],
type: 'grouped',
} )
.then( ( response ) => {
groupedProductId = response.data.id;
} );
// create a customer
await api
.post( 'customers', {
email: 'sideshowbob@example.com',
first_name: 'Sideshow',
last_name: 'Bob',
username: 'sideshowbob',
billing: {
first_name: 'Sideshow',
last_name: 'Bob',
company: 'Die Bart Die',
address_1: '123 Fake St',
address_2: '',
city: 'Springfield',
state: 'FL',
postcode: '12345',
country: 'US',
email: 'sideshowbob@example.com',
phone: '555-555-5556',
},
shipping: {
first_name: 'Sideshow',
last_name: 'Bob',
company: 'Die Bart Die',
address_1: '321 Fake St',
address_2: '',
city: 'Springfield',
state: 'FL',
postcode: '12345',
country: 'US',
},
} )
.then( ( response ) => {
customerId = response.data.id;
} );
} );
test.afterEach( async ( { baseURL } ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
// clean up order after each test
if ( orderId && orderId !== '' ) {
await api.delete( `orders/${ orderId }`, { force: true } );
}
} );
test.afterAll( async ( { baseURL } ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
// cleans up all products after run
await api.post( 'products/batch', {
delete: [
simpleProductId,
variableProductId,
externalProductId,
subProductAId,
subProductBId,
groupedProductId,
],
} );
test.afterAll( async ( { api } ) => {
// clean up tax classes and rates
for ( const { slug } of taxClasses ) {
await api
@ -258,19 +294,15 @@ test.describe(
await api.put( 'settings/general/woocommerce_calc_taxes', {
value: 'no',
} );
// clean up customer
await api.delete( `customers/${ customerId }`, { force: true } );
} );
test( 'can create a simple guest order', async ( { page } ) => {
test( 'can create a simple guest order', async ( {
page,
simpleProduct,
order,
} ) => {
await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
// get order ID from the page
const orderText = await page
.locator( 'h2.woocommerce-order-data__heading' )
.textContent();
orderId = orderText.match( /([0-9])\w+/ );
orderId = orderId[ 0 ].toString();
order.id = await getOrderIdFromPage( page );
await page
.locator( '#order_status' )
@ -334,22 +366,7 @@ test.describe(
.fill( 'Only asked for a slushie' );
// Add a product
await page.getByRole( 'button', { name: 'Add item(s)' } ).click();
await page
.getByRole( 'button', { name: 'Add product(s)' } )
.click();
await page.getByText( 'Search for a product…' ).click();
await page
.locator( 'span > .select2-search__field' )
.fill( 'Simple' );
await page
.getByRole( 'option', { name: simpleProductName } )
.click();
await page
.getByRole( 'row', { name: '×Add new order simple product' } )
.getByPlaceholder( '1' )
.fill( '2' );
await page.locator( '#btn-ok' ).click();
await addProductToOrder( page, simpleProduct, 2 );
// Create the order
await page.getByRole( 'button', { name: 'Create' } ).click();
@ -375,40 +392,26 @@ test.describe(
test( 'can create an order for an existing customer', async ( {
page,
simpleProduct,
customer,
order,
} ) => {
await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
// get order ID from the page
const orderText = await page
.locator( 'h2.woocommerce-order-data__heading' )
.textContent();
orderId = orderText.match( /([0-9])\w+/ );
orderId = orderId[ 0 ].toString();
order.id = await getOrderIdFromPage( page );
// Select customer
await page.getByText( 'Guest' ).click();
await page
.locator( 'input[aria-owns="select2-customer_user-results"]' )
.fill( 'sideshowbob@' );
await page.getByRole( 'option', { name: 'Sideshow Bob' } ).click();
.fill( customer.username );
await page
.getByRole( 'option', {
name: `${ customer.first_name } ${ customer.last_name }`,
} )
.click();
// Add a product
await page.getByRole( 'button', { name: 'Add item(s)' } ).click();
await page
.getByRole( 'button', { name: 'Add product(s)' } )
.click();
await page.getByText( 'Search for a product…' ).click();
await page
.locator( 'span > .select2-search__field' )
.fill( 'Simple' );
await page
.getByRole( 'option', { name: simpleProductName } )
.click();
await page
.getByRole( 'row', { name: '×Add new order simple product' } )
.getByPlaceholder( '1' )
.fill( '2' );
await page.locator( '#btn-ok' ).click();
await addProductToOrder( page, simpleProduct, 2 );
// Create the order
await page.getByRole( 'button', { name: 'Create' } ).click();
@ -431,12 +434,14 @@ test.describe(
// View customer profile
await page.getByRole( 'link', { name: 'Profile →' } ).click();
await expect(
page.getByRole( 'heading', { name: 'Edit User sideshowbob' } )
page.getByRole( 'heading', {
name: `Edit User ${ customer.username }`,
} )
).toBeVisible();
// Go back to the order
await page.goto(
`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
`wp-admin/admin.php?page=wc-orders&action=edit&id=${ order.id }`
);
await page
.getByRole( 'link', {
@ -449,17 +454,12 @@ test.describe(
await expect( page.getByRole( 'row' ) ).toHaveCount( 3 ); // 1 order and header and footer rows
} );
test( 'can create new order', async ( { page } ) => {
test( 'can create new order', async ( { page, order } ) => {
await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
await expect(
page.locator( 'h1.wp-heading-inline' )
).toContainText( 'Add new order' );
// get order ID from the page
const orderText = await page
.locator( 'h2.woocommerce-order-data__heading' )
.textContent();
orderId = orderText.match( /([0-9])\w+/ );
orderId = orderId[ 0 ].toString();
order.id = await getOrderIdFromPage( page );
await page
.locator( '#order_status' )
@ -488,74 +488,51 @@ test.describe(
test( 'can create new complex order with multiple product types & tax classes', async ( {
page,
simpleProduct,
variableProduct,
externalProduct,
groupedProduct,
order,
} ) => {
orderId = '';
await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
order.id = await getOrderIdFromPage( page );
// open modal for adding line items
await page.locator( 'button.add-line-item' ).click();
await page.locator( 'button.add-order-item' ).click();
// search for each product to add
await page.locator( 'text=Search for a product…' ).click();
await page
.locator( '.select2-search--dropdown' )
.getByRole( 'combobox' )
.pressSequentially( simpleProductName );
await page
.locator(
'li.select2-results__option.select2-results__option--highlighted'
)
.click();
await page.locator( 'text=Search for a product…' ).click();
await page
.locator( '.select2-search--dropdown' )
.getByRole( 'combobox' )
.pressSequentially( variableProductName );
await page
.locator(
'li.select2-results__option.select2-results__option--highlighted'
)
.click();
await page.locator( 'text=Search for a product…' ).click();
await page
.locator( '.select2-search--dropdown' )
.getByRole( 'combobox' )
.type( groupedProductName );
await page
.locator(
'li.select2-results__option.select2-results__option--highlighted'
)
.click();
await page.locator( 'text=Search for a product…' ).click();
await page
.locator( '.select2-search--dropdown' )
.getByRole( 'combobox' )
.type( externalProductName );
await page
.locator(
'li.select2-results__option.select2-results__option--highlighted'
)
.click();
for ( const product of [
simpleProduct,
variableProduct,
groupedProduct,
externalProduct,
] ) {
await page.getByText( 'Search for a product…' ).click();
await page
.locator( 'span > .select2-search__field' )
.fill( product.name );
await page
.getByRole( 'option', { name: product.name } )
.first()
.click();
}
await page.locator( 'button#btn-ok' ).click();
// assert that products added
await expect(
page.locator( 'td.name > a >> nth=0' )
).toContainText( simpleProductName );
).toContainText( simpleProduct.name );
await expect(
page.locator( 'td.name > a >> nth=1' )
).toContainText( variableProductName );
).toContainText( variableProduct.name );
await expect(
page.locator( 'td.name > a >> nth=2' )
).toContainText( groupedProductName );
).toContainText( groupedProduct.name );
await expect(
page.locator( 'td.name > a >> nth=3' )
).toContainText( externalProductName );
).toContainText( externalProduct.name );
// Recalculate taxes
page.on( 'dialog', ( dialog ) => dialog.accept() );

View File

@ -138,7 +138,9 @@ test.describe(
await page
.getByRole( 'button', { name: 'Add a coupon' } )
.click();
await page.getByLabel( 'Enter code' ).fill( coupons[ i ].code );
await page
.locator( '#wc-block-components-totals-coupon__input-0' )
.fill( coupons[ i ].code );
await page.getByText( 'Apply', { exact: true } ).click();
await expect(
page
@ -181,7 +183,9 @@ test.describe(
await page
.getByRole( 'button', { name: 'Add a coupon' } )
.click();
await page.getByLabel( 'Enter code' ).fill( coupons[ i ].code );
await page
.locator( '#wc-block-components-totals-coupon__input-0' )
.fill( coupons[ i ].code );
await page.getByText( 'Apply', { exact: true } ).click();
await expect(
page
@ -221,7 +225,9 @@ test.describe(
} ) => {
// try to add two same coupons and verify the error message
await page.getByRole( 'button', { name: 'Add a coupon' } ).click();
await page.getByLabel( 'Enter code' ).fill( coupons[ 0 ].code );
await page
.locator( '#wc-block-components-totals-coupon__input-0' )
.fill( coupons[ 0 ].code );
await page.getByText( 'Apply', { exact: true } ).click();
await expect(
page
@ -231,7 +237,9 @@ test.describe(
)
).toBeVisible();
await page.getByRole( 'button', { name: 'Add a coupon' } ).click();
await page.getByLabel( 'Enter code' ).fill( coupons[ 0 ].code );
await page
.locator( '#wc-block-components-totals-coupon__input-0' )
.fill( coupons[ 0 ].code );
await page.getByText( 'Apply', { exact: true } ).click();
await expect(
page
@ -247,7 +255,9 @@ test.describe(
} ) => {
// add coupon with usage limit
await page.getByRole( 'button', { name: 'Add a coupon' } ).click();
await page.getByLabel( 'Enter code' ).fill( couponLimitedCode );
await page
.locator( '#wc-block-components-totals-coupon__input-0' )
.fill( couponLimitedCode );
await page.getByText( 'Apply', { exact: true } ).click();
await expect(
page

View File

@ -1,186 +0,0 @@
<?php
/**
* Test the TaskList class.
*
* @package WooCommerce\Admin\Tests\OnboardingTasks
*/
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingThemes;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskList;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Purchase;
/**
* class WC_Admin_Tests_OnboardingTasks_TaskList
*/
class WC_Admin_Tests_OnboardingTasks_Task_Purchase extends WC_Unit_Test_Case {
/**
* Task list.
*
* @var Task|null
*/
protected $task = null;
/**
* Setup test data. Called before every test.
*/
public function setUp(): void {
parent::setUp();
$this->task = new Purchase( new TaskList() );
set_transient(
OnboardingThemes::THEMES_TRANSIENT,
array(
'free' => array(
'slug' => 'free',
'is_installed' => false,
),
'paid' => array(
'slug' => 'paid',
'id' => 12312,
'price' => '&#36;79.00',
'title' => 'theme title',
'is_installed' => false,
),
'paid_installed' => array(
'slug' => 'paid_installed',
'id' => 12312,
'price' => '&#36;79.00',
'title' => 'theme title',
'is_installed' => true,
),
'free_with_price' => array(
'slug' => 'free_with_price',
'id' => 12312,
'price' => '&#36;0.00',
'title' => 'theme title',
'is_installed' => false,
),
)
);
}
/**
* Tear down.
*/
public function tearDown(): void {
parent::tearDown();
delete_transient( OnboardingThemes::THEMES_TRANSIENT );
delete_option( OnboardingProfile::DATA_OPTION );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_complete_if_no_remaining_products() {
update_option( OnboardingProfile::DATA_OPTION, array( 'product_types' => array( 'physical' ) ) );
$this->assertEquals( true, $this->task->is_complete() );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_not_complete_if_remaining_paid_products() {
update_option( OnboardingProfile::DATA_OPTION, array( 'product_types' => array( 'memberships' ) ) );
$this->assertEquals( false, $this->task->is_complete() );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_complete_if_no_paid_themes() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array(),
'theme' => 'free',
)
);
$this->assertEquals( true, $this->task->is_complete() );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_not_complete_if_paid_theme_that_is_not_installed() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array(),
'theme' => 'paid',
)
);
$this->assertEquals( false, $this->task->is_complete() );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_complete_if_paid_theme_that_is_installed() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array(),
'theme' => 'paid_installed',
)
);
$this->assertEquals( true, $this->task->is_complete() );
}
/**
* Test is_complete function of Purchase task.
*/
public function test_is_complete_if_free_theme_with_set_price() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array(),
'theme' => 'free_with_price',
)
);
$this->assertEquals( true, $this->task->is_complete() );
}
/**
* Test the task title for a single paid item.
*/
public function test_get_title_if_single_paid_item() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array(),
'theme' => 'paid',
)
);
$this->assertEquals( 'Add theme title to my store', $this->task->get_title() );
}
/**
* Test the task title if 2 paid items exist.
*/
public function test_get_title_if_multiple_paid_themes() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array( 'memberships' ),
'theme' => 'paid',
)
);
$this->assertEquals( 'Add Memberships and 1 more product to my store', $this->task->get_title() );
}
/**
* Test the task title if multiple additional paid items exist.
*/
public function test_get_title_if_multiple_paid_products() {
update_option(
OnboardingProfile::DATA_OPTION,
array(
'product_types' => array( 'memberships', 'bookings' ),
'theme' => 'paid',
)
);
$this->assertEquals( 'Add Memberships and 2 more products to my store', $this->task->get_title() );
}
}

View File

@ -323,6 +323,31 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_EvaluateSuggestion extends WC_Uni
remove_filter( 'woocommerce_admin_remote_specs_evaluator_should_log', '__return_true' );
}
/**
* Test that the memo is set correctly.
*/
public function test_memo_set_correctly() {
$specs = array(
array(
'id' => 'test-gateway-1',
'is_visible' => true,
),
array(
'id' => 'test-gateway-2',
'is_visible' => false,
),
);
$result = TestableEvaluateSuggestion::evaluate_specs( $specs );
$memo = TestableEvaluateSuggestion::get_memo_for_tests();
$this->assertCount( 1, $memo );
$memo_key = array_keys( $memo )[0];
$this->assertEquals( $result, $memo[ $memo_key ] );
$this->assertCount( 1, $result['suggestions'] );
$this->assertEquals( 'test-gateway-1', $result['suggestions'][0]->id );
}
/**
* Overrides the WC logger.
*
@ -359,3 +384,19 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_EvaluateSuggestion extends WC_Uni
);
}
}
//phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound, Squiz.Classes.ClassFileName.NoMatch, Suin.Classes.PSR4.IncorrectClassName
/**
* TestableEvaluateSuggestion class.
*/
class TestableEvaluateSuggestion extends EvaluateSuggestion {
/**
* Get the memo for testing.
*
* @return array
*/
public static function get_memo_for_tests() {
return self::$memo;
}
}
//phpcs:enable Generic.Files.OneObjectStructurePerFile.MultipleFound, Squiz.Classes.ClassFileName.NoMatch, Suin.Classes.PSR4.IncorrectClassName

View File

@ -25,7 +25,7 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_Init extends WC_Unit_Test_Case {
delete_option( 'woocommerce_show_marketplace_suggestions' );
add_filter(
'transient_woocommerce_admin_' . PaymentGatewaySuggestionsDataSourcePoller::ID . '_specs',
function( $value ) {
function ( $value ) {
if ( $value ) {
return $value;
}
@ -37,6 +37,8 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_Init extends WC_Unit_Test_Case {
);
}
);
EvaluateSuggestion::reset_memo();
}
/**
@ -57,7 +59,7 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_Init extends WC_Unit_Test_Case {
remove_all_filters( 'transient_woocommerce_admin_' . PaymentGatewaySuggestionsDataSourcePoller::ID . '_specs' );
add_filter(
DataSourcePoller::FILTER_NAME,
function() {
function () {
return array();
}
);
@ -242,7 +244,7 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_Init extends WC_Unit_Test_Case {
add_filter(
'locale',
function( $_locale ) {
function () {
return 'zh_TW';
}
);
@ -364,5 +366,4 @@ class WC_Admin_Tests_PaymentGatewaySuggestions_Init extends WC_Unit_Test_Case {
// Clean up.
delete_option( PaymentGatewaySuggestions::RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION );
}
}

View File

@ -1,7 +1,7 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Admin\ShippingPartnerSuggestions;
namespace Automattic\WooCommerce\Tests\Admin\Features\ShippingPartnerSuggestions;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\DefaultShippingPartners;
@ -31,6 +31,8 @@ class DefaultShippingPartnersTest extends WC_Unit_Test_Case {
update_option( 'woocommerce_store_address', 'foo' );
update_option( 'active_plugins', array( 'foo/foo.php' ) );
EvaluateSuggestion::reset_memo();
}
/**

View File

@ -7,6 +7,7 @@ use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Shipping;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\DefaultShippingPartners;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\ShippingPartnerSuggestions;
use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\ShippingPartnerSuggestionsDataSourcePoller;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion;
use WC_Unit_Test_Case;
/**
@ -37,7 +38,7 @@ class ShippingPartnerSuggestionsTest extends WC_Unit_Test_Case {
delete_option( 'woocommerce_show_marketplace_suggestions' );
add_filter(
'transient_woocommerce_admin_' . ShippingPartnerSuggestionsDataSourcePoller::ID . '_specs',
function( $value ) {
function ( $value ) {
if ( $value ) {
return $value;
}
@ -70,6 +71,8 @@ class ShippingPartnerSuggestionsTest extends WC_Unit_Test_Case {
// Have a mock logger used by the suggestions rule evaluator.
$this->mock_logger = $this->getMockBuilder( 'WC_Logger_Interface' )->getMock();
add_filter( 'woocommerce_logging_class', array( $this, 'override_wc_logger' ) );
EvaluateSuggestion::reset_memo();
}
/**
@ -91,7 +94,7 @@ class ShippingPartnerSuggestionsTest extends WC_Unit_Test_Case {
remove_all_filters( 'transient_woocommerce_admin_' . ShippingPartnerSuggestionsDataSourcePoller::ID . '_specs' );
add_filter(
DataSourcePoller::FILTER_NAME,
function() {
function () {
return array();
}
);

View File

@ -29,6 +29,8 @@ class DefaultPromotionsTest extends WC_Unit_Test_Case {
update_option( 'woocommerce_store_address', 'foo' );
update_option( 'active_plugins', array( 'foo/foo.php' ) );
EvaluateSuggestion::reset_memo();
}
/**

View File

@ -26,7 +26,7 @@ class InitTest extends WC_Unit_Test_Case {
delete_option( 'woocommerce_show_marketplace_suggestions' );
add_filter(
'transient_woocommerce_admin_' . WCPayPromotionDataSourcePoller::ID . '_specs',
function( $value ) {
function ( $value ) {
if ( $value ) {
return $value;
}
@ -38,6 +38,8 @@ class InitTest extends WC_Unit_Test_Case {
);
}
);
EvaluateSuggestion::reset_memo();
}
/**
@ -59,7 +61,7 @@ class InitTest extends WC_Unit_Test_Case {
remove_all_filters( 'transient_woocommerce_admin_' . WCPayPromotionDataSourcePoller::ID . '_specs' );
add_filter(
DataSourcePoller::FILTER_NAME,
function() {
function () {
return array();
}
);