Merge branch 'trunk' into add/add-changelog-file-tool

This commit is contained in:
Néstor Soriano 2024-09-02 09:58:39 +02:00 committed by GitHub
commit 90550c3394
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 4144 additions and 1213 deletions

View File

@ -201,7 +201,9 @@
"@wordpress/interface", "@wordpress/interface",
"@wordpress/router", "@wordpress/router",
"@wordpress/edit-site", "@wordpress/edit-site",
"@wordpress/private-apis" "@wordpress/private-apis",
"@wordpress/dataviews",
"@wordpress/icons"
], ],
"packages": [ "packages": [
"@woocommerce/block-templates", "@woocommerce/block-templates",

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add sidebar and dataviews list to the experimental dataviews products page.

View File

@ -56,6 +56,7 @@
"@wordpress/compose": "wp-6.0", "@wordpress/compose": "wp-6.0",
"@wordpress/core-data": "wp-6.0", "@wordpress/core-data": "wp-6.0",
"@wordpress/data": "wp-6.0", "@wordpress/data": "wp-6.0",
"@wordpress/dataviews": "^4.2.0",
"@wordpress/date": "wp-6.0", "@wordpress/date": "wp-6.0",
"@wordpress/deprecated": "wp-6.0", "@wordpress/deprecated": "wp-6.0",
"@wordpress/edit-post": "wp-6.0", "@wordpress/edit-post": "wp-6.0",
@ -64,7 +65,7 @@
"@wordpress/hooks": "wp-6.0", "@wordpress/hooks": "wp-6.0",
"@wordpress/html-entities": "wp-6.0", "@wordpress/html-entities": "wp-6.0",
"@wordpress/i18n": "wp-6.0", "@wordpress/i18n": "wp-6.0",
"@wordpress/icons": "wp-6.0", "@wordpress/icons": "10.6.0",
"@wordpress/interface": "wp-6.0", "@wordpress/interface": "wp-6.0",
"@wordpress/keyboard-shortcuts": "wp-6.0", "@wordpress/keyboard-shortcuts": "wp-6.0",
"@wordpress/keycodes": "wp-6.0", "@wordpress/keycodes": "wp-6.0",

View File

@ -0,0 +1,8 @@
export const LAYOUT_GRID = 'grid';
export const LAYOUT_TABLE = 'table';
export const LAYOUT_LIST = 'list';
export const OPERATOR_IS = 'is';
export const OPERATOR_IS_NOT = 'isNot';
export const OPERATOR_IS_ANY = 'isAny';
export const OPERATOR_IS_NONE = 'isNone';

View File

@ -13,12 +13,16 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import { unlock } from '../lock-unlock'; import { unlock } from '../lock-unlock';
import useLayoutAreas from './router';
import { Layout } from './layout';
const { RouterProvider } = unlock( routerPrivateApis ); const { RouterProvider } = unlock( routerPrivateApis );
const { GlobalStylesProvider } = unlock( editorPrivateApis ); const { GlobalStylesProvider } = unlock( editorPrivateApis );
function ProductsLayout() { function ProductsLayout() {
return <div>Initial Products Layout</div>; // This ensures the edited entity id and type are initialized properly.
const route = useLayoutAreas();
return <Layout route={ route } />;
} }
export function ProductsApp() { export function ProductsApp() {

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { createElement, Fragment, useRef } from '@wordpress/element';
import {
useViewportMatch,
useResizeObserver,
useReducedMotion,
} from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import {
// @ts-expect-error missing type.
EditorSnackbars,
// @ts-expect-error missing type.
privateApis as editorPrivateApis,
} from '@wordpress/editor';
// eslint-disable-next-line @woocommerce/dependency-group
import {
// @ts-expect-error missing type.
__unstableMotion as motion,
// @ts-expect-error missing type.
__unstableAnimatePresence as AnimatePresence,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import SidebarContent from './sidebar';
import SiteHub from './site-hub';
import { Route } from './router';
import { unlock } from '../lock-unlock';
const { NavigableRegion } = unlock( editorPrivateApis );
const ANIMATION_DURATION = 0.3;
type LayoutProps = {
route: Route;
};
export function Layout( { route }: LayoutProps ) {
const [ fullResizer ] = useResizeObserver();
const toggleRef = useRef< HTMLAnchorElement >( null );
const isMobileViewport = useViewportMatch( 'medium', '<' );
const disableMotion = useReducedMotion();
const { key: routeKey, areas, widths } = route;
return (
<>
{ fullResizer }
<div className="edit-site-layout">
<div className="edit-site-layout__content">
{ /*
The NavigableRegion must always be rendered and not use
`inert` otherwise `useNavigateRegions` will fail.
*/ }
{ ( ! isMobileViewport || ! areas.mobile ) && (
<NavigableRegion
ariaLabel={ __( 'Navigation', 'woocommerce' ) }
className="edit-site-layout__sidebar-region"
>
<AnimatePresence>
<motion.div
initial={ { opacity: 0 } }
animate={ { opacity: 1 } }
exit={ { opacity: 0 } }
transition={ {
type: 'tween',
duration:
// Disable transition in mobile to emulate a full page transition.
disableMotion || isMobileViewport
? 0
: ANIMATION_DURATION,
ease: 'easeOut',
} }
className="edit-site-layout__sidebar"
>
<SiteHub
ref={ toggleRef }
isTransparent={ false }
/>
<SidebarContent routeKey={ routeKey }>
{ areas.sidebar }
</SidebarContent>
</motion.div>
</AnimatePresence>
</NavigableRegion>
) }
<EditorSnackbars />
{ ! isMobileViewport && areas.content && (
<div
className="edit-site-layout__area"
style={ {
maxWidth: widths?.content,
} }
>
{ areas.content }
</div>
) }
{ ! isMobileViewport && areas.edit && (
<div
className="edit-site-layout__area"
style={ {
maxWidth: widths?.edit,
} }
>
{ areas.edit }
</div>
) }
</div>
</div>
</>
);
}

View File

@ -0,0 +1,343 @@
/**
* External dependencies
*/
import { Action, DataViews, View } from '@wordpress/dataviews';
import {
createElement,
useState,
useMemo,
useCallback,
useEffect,
} from '@wordpress/element';
import { Product, ProductQuery } from '@woocommerce/data';
import { drawerRight } from '@wordpress/icons';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import classNames from 'classnames';
import {
// @ts-expect-error missing types.
__experimentalHeading as Heading,
// @ts-expect-error missing types.
__experimentalText as Text,
// @ts-expect-error missing types.
__experimentalHStack as HStack,
// @ts-expect-error missing types.
__experimentalVStack as VStack,
FlexItem,
Button,
} from '@wordpress/components';
// @ts-expect-error missing type.
// eslint-disable-next-line @woocommerce/dependency-group
import { privateApis as editorPrivateApis } from '@wordpress/editor';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import {
useDefaultViews,
defaultLayouts,
} from '../sidebar-dataviews/default-views';
import { LAYOUT_LIST, OPERATOR_IS } from '../constants';
const { NavigableRegion } = unlock( editorPrivateApis );
const { useHistory, useLocation } = unlock( routerPrivateApis );
const STATUSES = [
{ value: 'draft', label: __( 'Draft', 'woocommerce' ) },
{ value: 'future', label: __( 'Scheduled', 'woocommerce' ) },
{ value: 'private', label: __( 'Private', 'woocommerce' ) },
{ value: 'publish', label: __( 'Published', 'woocommerce' ) },
{ value: 'trash', label: __( 'Trash', 'woocommerce' ) },
];
/**
* TODO: auto convert some of the product editor blocks ( from the blocks directory ) to this format.
* The edit function should work relatively well with the edit from the blocks, the only difference is that the blocks rely on getEntityProp to get the value
*/
const fields = [
{
id: 'name',
label: __( 'Name', 'woocommerce' ),
enableHiding: false,
type: 'text',
render: function nameRender( { item }: { item: Product } ) {
return item.name;
},
},
{
id: 'sku',
label: __( 'SKU', 'woocommerce' ),
enableHiding: false,
enableSorting: false,
render: ( { item }: { item: Product } ) => {
return item.sku;
},
},
{
id: 'date',
label: __( 'Date', 'woocommerce' ),
render: ( { item }: { item: Product } ) => {
return <time>{ item.date_created }</time>;
},
},
{
label: __( 'Status', 'woocommerce' ),
id: 'status',
getValue: ( { item }: { item: Product } ) =>
STATUSES.find( ( { value } ) => value === item.status )?.label ??
item.status,
elements: STATUSES,
filterBy: {
operators: [ OPERATOR_IS ],
},
enableSorting: false,
},
];
export type ProductListProps = {
subTitle?: string;
className?: string;
hideTitleFromUI?: boolean;
postType?: string;
};
const PAGE_SIZE = 25;
const EMPTY_ARRAY: Product[] = [];
const EMPTY_ACTIONS_ARRAY: Action< Product >[] = [];
const getDefaultView = (
defaultViews: Array< { slug: string; view: View } >,
activeView: string
) => {
return defaultViews.find( ( { slug } ) => slug === activeView )?.view;
};
/**
* This function abstracts working with default & custom views by
* providing a [ state, setState ] tuple based on the URL parameters.
*
* Consumers use the provided tuple to work with state
* and don't have to deal with the specifics of default & custom views.
*
* @param {string} postType Post type to retrieve default views for.
* @return {Array} The [ state, setState ] tuple.
*/
function useView(
postType: string
): [ View, ( view: View ) => void, ( view: View ) => void ] {
const {
params: { activeView = 'all', isCustom = 'false', layout },
} = useLocation();
const history = useHistory();
const defaultViews = useDefaultViews( { postType } );
const [ view, setView ] = useState< View >( () => {
const initialView = getDefaultView( defaultViews, activeView ) ?? {
type: layout ?? LAYOUT_LIST,
};
const type = layout ?? initialView.type;
return {
...initialView,
type,
};
} );
const setViewWithUrlUpdate = useCallback(
( newView: View ) => {
const { params } = history.getLocationWithParams();
if ( newView.type === LAYOUT_LIST && ! params?.layout ) {
// Skip updating the layout URL param if
// it is not present and the newView.type is LAYOUT_LIST.
} else if ( newView.type !== params?.layout ) {
history.push( {
...params,
layout: newView.type,
} );
}
setView( newView );
},
[ history, isCustom ]
);
// When layout URL param changes, update the view type
// without affecting any other config.
useEffect( () => {
setView( ( prevView ) => ( {
...prevView,
type: layout ?? LAYOUT_LIST,
} ) );
}, [ layout ] );
// When activeView or isCustom URL parameters change, reset the view.
useEffect( () => {
const newView = getDefaultView( defaultViews, activeView );
if ( newView ) {
const type = layout ?? newView.type;
setView( {
...newView,
type,
} );
}
}, [ activeView, isCustom, layout, defaultViews ] );
return [ view, setViewWithUrlUpdate, setViewWithUrlUpdate ];
}
function getItemId( item: Product ) {
return item.id.toString();
}
export default function ProductList( {
subTitle,
className,
hideTitleFromUI = false,
}: ProductListProps ) {
const history = useHistory();
const location = useLocation();
const {
postId,
quickEdit = false,
postType = 'product',
isCustom,
activeView = 'all',
} = location.params;
const [ selection, setSelection ] = useState( [ postId ] );
const [ view, setView ] = useView( postType );
const queryParams = useMemo( () => {
const filters: Partial< ProductQuery > = {};
view.filters?.forEach( ( filter ) => {
if ( filter.field === 'status' ) {
filters.status = Array.isArray( filter.value )
? filter.value.join( ',' )
: filter.value;
}
} );
const orderby =
view.sort?.field === 'name' ? 'title' : view.sort?.field;
return {
per_page: view.perPage,
page: view.page,
order: view.sort?.direction,
orderby,
search: view.search,
...filters,
};
}, [ location.params, view ] );
const onChangeSelection = useCallback(
( items ) => {
setSelection( items );
history.push( {
...location.params,
postId: items.join( ',' ),
} );
},
[ history, location.params, view?.type ]
);
// TODO: Use the Woo data store to get all the products, as this doesn't contain all the product data.
const { records, totalCount, isLoading } = useSelect(
( select ) => {
const { getProducts, getProductsTotalCount, isResolving } =
select( 'wc/admin/products' );
return {
records: getProducts( queryParams ) as Product[],
totalCount: getProductsTotalCount( queryParams ) as number,
isLoading: isResolving( 'getProducts', [ queryParams ] ),
};
},
[ queryParams ]
);
const paginationInfo = useMemo(
() => ( {
totalItems: totalCount,
totalPages: Math.ceil( totalCount / ( view.perPage || PAGE_SIZE ) ),
} ),
[ totalCount, view.perPage ]
);
const classes = classNames( 'edit-site-page', className );
return (
<NavigableRegion
className={ classes }
ariaLabel={ __( 'Products', 'woocommerce' ) }
>
<div className="edit-site-page-content">
{ ! hideTitleFromUI && (
<VStack
className="edit-site-page-header"
as="header"
spacing={ 0 }
>
<HStack className="edit-site-page-header__page-title">
<Heading
as="h2"
level={ 3 }
weight={ 500 }
className="edit-site-page-header__title"
truncate
>
{ __( 'Products', 'woocommerce' ) }
</Heading>
<FlexItem className="edit-site-page-header__actions">
{ /* { actions } */ }
</FlexItem>
</HStack>
{ subTitle && (
<Text
variant="muted"
as="p"
className="edit-site-page-header__sub-title"
>
{ subTitle }
</Text>
) }
</VStack>
) }
<DataViews
key={ activeView + isCustom }
paginationInfo={ paginationInfo }
// @ts-expect-error types seem rather strict for this still.
fields={ fields }
actions={ EMPTY_ACTIONS_ARRAY }
data={ records || EMPTY_ARRAY }
isLoading={ isLoading }
view={ view }
onChangeView={ setView }
onChangeSelection={ onChangeSelection }
getItemId={ getItemId }
selection={ selection }
defaultLayouts={ defaultLayouts }
header={
<Button
// @ts-expect-error outdated type.
size="compact"
isPressed={ quickEdit }
icon={ drawerRight }
label={ __(
'Toggle details panel',
'woocommerce'
) }
onClick={ () => {
history.push( {
...location.params,
quickEdit: quickEdit ? undefined : true,
} );
} }
/>
}
/>
</div>
</NavigableRegion>
);
}

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';
import ProductList from './product-list';
import DataViewsSidebarContent from './sidebar-dataviews';
import SidebarNavigationScreen from './sidebar-navigation-screen';
const { useLocation } = unlock( routerPrivateApis );
export type Route = {
key: string;
areas: {
sidebar: React.JSX.Element | React.FunctionComponent;
content?: React.JSX.Element | React.FunctionComponent;
edit?: React.JSX.Element | React.FunctionComponent;
mobile?: React.JSX.Element | React.FunctionComponent | boolean;
preview?: boolean;
};
widths?: {
content?: number;
edit?: number;
sidebar?: number;
};
};
export default function useLayoutAreas() {
const { params = {} } = useLocation();
const { postType = 'product', layout = 'table', canvas } = params;
// Products list.
if ( [ 'product' ].includes( postType ) ) {
const isListLayout = layout === 'list' || ! layout;
return {
key: 'products-list',
areas: {
sidebar: (
<SidebarNavigationScreen
title={ 'Products' }
isRoot
content={ <DataViewsSidebarContent /> }
/>
),
content: <ProductList />,
preview: false,
mobile: <ProductList postType={ postType } />,
},
widths: {
content: isListLayout ? 380 : undefined,
},
};
}
// Fallback shows the home page preview
return {
key: 'default',
areas: {
sidebar: () => null,
preview: false,
mobile: canvas === 'edit',
},
};
}

View File

@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import classNames from 'classnames';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { addQueryArgs, getQueryArgs, removeQueryArgs } from '@wordpress/url';
import { VIEW_LAYOUTS } from '@wordpress/dataviews';
// @ts-expect-error missing type.
// eslint-disable-next-line @woocommerce/dependency-group
import { __experimentalHStack as HStack } from '@wordpress/components';
/**
* Internal dependencies
*/
import SidebarNavigationItem from '../sidebar-navigation-item';
import { unlock } from '../../lock-unlock';
const { useHistory, useLocation } = unlock( routerPrivateApis );
type DataViewItemProps = {
title: string;
slug: string;
customViewId?: string;
type: string;
icon: React.JSX.Element;
isActive: boolean;
isCustom: boolean;
suffix?: string;
};
function useLink(
params: Record< string, string | undefined >,
state?: Record< string, string | undefined >,
shouldReplace = false
) {
const history = useHistory();
function onClick( event: Event ) {
event?.preventDefault();
if ( shouldReplace ) {
history.replace( params, state );
} else {
history.push( params, state );
}
}
const currentArgs = getQueryArgs( window.location.href );
const currentUrlWithoutArgs = removeQueryArgs(
window.location.href,
...Object.keys( currentArgs )
);
const newUrl = addQueryArgs( currentUrlWithoutArgs, params );
return {
href: newUrl,
onClick,
};
}
export default function DataViewItem( {
title,
slug,
customViewId,
type,
icon,
isActive,
isCustom,
suffix,
}: DataViewItemProps ) {
const {
params: { postType, page },
} = useLocation();
const iconToUse =
icon || VIEW_LAYOUTS.find( ( v ) => v.type === type )?.icon;
let activeView: undefined | string = isCustom ? customViewId : slug;
if ( activeView === 'all' ) {
activeView = undefined;
}
const linkInfo = useLink( {
page,
postType,
layout: type,
activeView,
isCustom: isCustom ? 'true' : undefined,
} );
return (
<HStack
justify="flex-start"
className={ classNames(
'edit-site-sidebar-dataviews-dataview-item',
{
'is-selected': isActive,
}
) }
>
<SidebarNavigationItem
icon={ iconToUse }
{ ...linkInfo }
aria-current={ isActive ? 'true' : undefined }
>
{ title }
</SidebarNavigationItem>
{ suffix }
</HStack>
);
}

View File

@ -0,0 +1,179 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
import {
trash,
pages,
drafts,
published,
scheduled,
notAllowed,
} from '@wordpress/icons';
import type { ColumnStyle, ViewTable } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
import {
LAYOUT_LIST,
LAYOUT_TABLE,
LAYOUT_GRID,
OPERATOR_IS,
} from '../constants';
export const defaultLayouts: Record<
string,
{
layout: {
primaryField: string;
mediaField?: string;
styles?: Record< string, ColumnStyle >;
};
}
> = {
[ LAYOUT_TABLE ]: {
layout: {
primaryField: 'name',
styles: {
name: {
maxWidth: 300,
},
},
},
},
[ LAYOUT_GRID ]: {
layout: {
mediaField: 'featured-image',
primaryField: 'name',
},
},
[ LAYOUT_LIST ]: {
layout: {
primaryField: 'name',
mediaField: 'featured-image',
},
},
};
const DEFAULT_POST_BASE: Omit< ViewTable, 'view' | 'title' | 'slug' | 'icon' > =
{
type: LAYOUT_TABLE,
search: '',
filters: [],
page: 1,
perPage: 20,
sort: {
field: 'date',
direction: 'desc',
},
fields: [ 'name', 'sku', 'status', 'date' ],
layout: defaultLayouts[ LAYOUT_LIST ].layout,
};
export function useDefaultViews( { postType }: { postType: string } ): Array< {
title: string;
slug: string;
icon: React.JSX.Element;
view: ViewTable;
} > {
const labels = useSelect(
( select ) => {
const { getPostType } = select( coreStore );
const postTypeData: { labels?: Record< string, string > } =
getPostType( postType );
return postTypeData?.labels;
},
[ postType ]
);
return useMemo( () => {
return [
{
title: labels?.all_items || __( 'All items', 'woocommerce' ),
slug: 'all',
icon: pages,
view: { ...DEFAULT_POST_BASE },
},
{
title: __( 'Published', 'woocommerce' ),
slug: 'published',
icon: published,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'publish',
},
],
},
},
{
title: __( 'Scheduled', 'woocommerce' ),
slug: 'future',
icon: scheduled,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'future',
},
],
},
},
{
title: __( 'Drafts', 'woocommerce' ),
slug: 'drafts',
icon: drafts,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'draft',
},
],
},
},
{
title: __( 'Private', 'woocommerce' ),
slug: 'private',
icon: notAllowed,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'private',
},
],
},
},
{
title: __( 'Trash', 'woocommerce' ),
slug: 'trash',
icon: trash,
view: {
...DEFAULT_POST_BASE,
type: LAYOUT_TABLE,
layout: defaultLayouts[ LAYOUT_TABLE ].layout,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'trash',
},
],
},
},
];
}, [ labels ] );
}

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { createElement, Fragment } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-expect-error missing type.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis, @woocommerce/dependency-group
import { __experimentalItemGroup as ItemGroup } from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import DataViewItem from './dataview-item';
import { useDefaultViews } from './default-views';
const { useLocation } = unlock( routerPrivateApis );
export default function DataViewsSidebarContent() {
const {
params: {
postType = 'product',
activeView = 'all',
isCustom = 'false',
},
} = useLocation();
const defaultViews = useDefaultViews( { postType } );
if ( ! postType ) {
return null;
}
const isCustomBoolean = isCustom === 'true';
return (
<>
<ItemGroup>
{ defaultViews.map( ( dataview ) => {
return (
<DataViewItem
key={ dataview.slug }
slug={ dataview.slug }
title={ dataview.title }
icon={ dataview.icon }
type={ dataview.view.type }
isActive={
! isCustomBoolean &&
dataview.slug === activeView
}
isCustom={ false }
/>
);
} ) }
</ItemGroup>
</>
);
}

View File

@ -0,0 +1,19 @@
.edit-site-sidebar-navigation-screen__title-icon {
position: sticky;
top: 0;
background: $gray-900;
padding-top: $grid-unit-60;
margin-bottom: $grid-unit-10;
padding-bottom: $grid-unit-10;
}
.edit-site-sidebar-button {
color: #e0e0e0;
flex-shrink: 0;
}
.edit-site-sidebar-navigation-screen__title {
flex-grow: 1;
overflow-wrap: break-word;
padding: 2px 0 0
}

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { isRTL } from '@wordpress/i18n';
import { chevronRightSmall, chevronLeftSmall, Icon } from '@wordpress/icons';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import classNames from 'classnames';
import { createElement } from '@wordpress/element';
import {
// @ts-expect-error missing type.
__experimentalItem as Item,
// @ts-expect-error missing type.
__experimentalHStack as HStack,
FlexBlock,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
const { useHistory } = unlock( routerPrivateApis );
type SidebarNavigationItemProps = {
className?: string;
icon?: React.JSX.Element;
suffix?: string;
withChevron?: boolean;
uid?: string;
params?: Record< string, string >;
onClick?: ( e: Event ) => void;
children: React.ReactNode;
};
export default function SidebarNavigationItem( {
className,
icon,
withChevron = false,
suffix,
uid,
params,
onClick,
children,
...props
}: SidebarNavigationItemProps ) {
const history = useHistory();
// If there is no custom click handler, create one that navigates to `params`.
function handleClick( e: Event ) {
if ( onClick ) {
onClick( e );
} else if ( params ) {
e.preventDefault();
history.push( params );
}
}
return (
<Item
className={ classNames(
'edit-site-sidebar-navigation-item',
{ 'with-suffix': ! withChevron && suffix },
className
) }
onClick={ handleClick }
id={ uid }
{ ...props }
>
<HStack justify="flex-start">
{ icon && (
<Icon
style={ { fill: 'currentcolor' } }
icon={ icon }
size={ 24 }
/>
) }
<FlexBlock>{ children }</FlexBlock>
{ withChevron && (
<Icon
icon={ isRTL() ? chevronLeftSmall : chevronRightSmall }
className="edit-site-sidebar-navigation-item__drilldown-indicator"
size={ 24 }
/>
) }
{ ! withChevron && suffix }
</HStack>
</Item>
);
}

View File

@ -0,0 +1,136 @@
/**
* External dependencies
*/
import { isRTL, __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { chevronRight, chevronLeft } from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { createElement, Fragment } from '@wordpress/element';
import {
// @ts-expect-error missing type.
__experimentalHStack as HStack,
// @ts-expect-error missing type.
__experimentalHeading as Heading,
// @ts-expect-error missing type.
__experimentalVStack as VStack,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import SidebarButton from './sidebar-button';
const { useHistory, useLocation } = unlock( routerPrivateApis );
type SidebarNavigationScreenProps = {
isRoot?: boolean;
title: string;
actions?: React.JSX.Element;
meta?: string;
content: React.JSX.Element;
footer?: string;
description?: string;
backPath?: string;
};
export default function SidebarNavigationScreen( {
isRoot,
title,
actions,
meta,
content,
footer,
description,
backPath: backPathProp,
}: SidebarNavigationScreenProps ) {
const { dashboardLink, dashboardLinkText } = useSelect( ( select ) => {
const { getSettings } = unlock( select( 'core/edit-site' ) );
return {
dashboardLink: getSettings().__experimentalDashboardLink,
dashboardLinkText: getSettings().__experimentalDashboardLinkText,
};
}, [] );
const location = useLocation();
const history = useHistory();
const backPath = backPathProp ?? location.state?.backPath;
const icon = isRTL() ? chevronRight : chevronLeft;
return (
<>
<VStack
className={ classNames(
'edit-site-sidebar-navigation-screen__main',
{
'has-footer': !! footer,
}
) }
spacing={ 0 }
justify="flex-start"
>
<HStack
spacing={ 3 }
alignment="flex-start"
className="edit-site-sidebar-navigation-screen__title-icon"
>
{ ! isRoot && (
<SidebarButton
onClick={ () => {
history.push( backPath );
} }
icon={ icon }
label={ __( 'Back', 'woocommerce' ) }
showTooltip={ false }
/>
) }
{ isRoot && (
<SidebarButton
icon={ icon }
label={
dashboardLinkText ||
__( 'Go to the Dashboard', 'woocommerce' )
}
href={ dashboardLink || 'index.php' }
/>
) }
<Heading
className="edit-site-sidebar-navigation-screen__title"
color={ '#e0e0e0' /* $gray-200 */ }
level={ 1 }
size={ 20 }
>
{ title }
</Heading>
{ actions && (
<div className="edit-site-sidebar-navigation-screen__actions">
{ actions }
</div>
) }
</HStack>
{ meta && (
<>
<div className="edit-site-sidebar-navigation-screen__meta">
{ meta }
</div>
</>
) }
<div className="edit-site-sidebar-navigation-screen__content">
{ description && (
<p className="edit-site-sidebar-navigation-screen__description">
{ description }
</p>
) }
{ content }
</div>
</VStack>
{ footer && (
<footer className="edit-site-sidebar-navigation-screen__footer">
{ footer }
</footer>
) }
</>
);
}

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Button } from '@wordpress/components';
import classNames from 'classnames';
export default function SidebarButton( props: Button.Props ) {
return (
<Button
{ ...props }
className={ classNames(
'edit-site-sidebar-button',
props.className
) }
/>
);
}

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { createElement, useRef } from '@wordpress/element';
function SidebarContentWrapper( { children }: { children: React.ReactNode } ) {
const wrapperRef = useRef< HTMLDivElement | null >( null );
const wrapperCls = 'edit-site-sidebar__screen-wrapper';
return (
<div ref={ wrapperRef } className={ wrapperCls }>
{ children }
</div>
);
}
export default function SidebarContent( {
routeKey,
children,
}: {
routeKey: string;
children: React.ReactNode;
} ) {
return (
<div className="edit-site-sidebar__content">
<SidebarContentWrapper key={ routeKey }>
{ children }
</SidebarContentWrapper>
</div>
);
}

View File

@ -0,0 +1,114 @@
/**
* External dependencies
*/
import { createElement, memo, forwardRef } from '@wordpress/element';
import classNames from 'classnames';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { filterURLForDisplay } from '@wordpress/url';
import {
Button,
// @ts-expect-error missing types.
__experimentalHStack as HStack,
VisuallyHidden,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import SiteIcon from './site-icon';
import { unlock } from '../../lock-unlock';
const SiteHub = memo(
forwardRef(
(
{ isTransparent }: { isTransparent: boolean },
ref: React.Ref< HTMLAnchorElement >
) => {
const { dashboardLink, homeUrl, siteTitle } = useSelect(
( select ) => {
const { getSettings } = unlock(
select( 'core/edit-site' )
);
const {
getSite,
getUnstableBase, // Site index.
} = select( coreStore );
const _site: undefined | { title: string; url: string } =
getSite();
const base: { home: string } | undefined =
getUnstableBase();
return {
dashboardLink:
getSettings().__experimentalDashboardLink ||
'index.php',
homeUrl: base?.home,
siteTitle:
! _site?.title && !! _site?.url
? filterURLForDisplay( _site?.url )
: _site?.title,
};
},
[]
);
return (
<div className="edit-site-site-hub">
<HStack justify="flex-start" spacing="0">
<div
className={ classNames(
'edit-site-site-hub__view-mode-toggle-container',
{
'has-transparent-background': isTransparent,
}
) }
>
<Button
ref={ ref }
href={ dashboardLink }
label={ __(
'Go to the Dashboard',
'woocommerce'
) }
className="edit-site-layout__view-mode-toggle"
style={ {
transform: 'scale(0.5)',
borderRadius: 4,
} }
>
<SiteIcon className="edit-site-layout__view-mode-toggle-icon" />
</Button>
</div>
<HStack>
<div className="edit-site-site-hub__title">
<Button
variant="link"
href={ homeUrl }
target="_blank"
>
{ siteTitle && decodeEntities( siteTitle ) }
<VisuallyHidden as="span">
{
/* translators: accessibility text */
__(
'(opens in a new tab)',
'woocommerce'
)
}
</VisuallyHidden>
</Button>
</div>
</HStack>
</HStack>
</div>
);
}
)
);
export default SiteHub;

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { Icon } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { wordpress } from '@wordpress/icons';
import { store as coreDataStore } from '@wordpress/core-data';
import classNames from 'classnames';
type SiteIconProps = {
className: string;
};
function SiteIcon( { className }: SiteIconProps ) {
const { isRequestingSite, siteIconUrl } = useSelect( ( select ) => {
const { getEntityRecord } = select( coreDataStore );
const siteData: { site_icon_url?: string } = getEntityRecord(
'root',
'__unstableBase',
undefined
);
return {
isRequestingSite: ! siteData,
siteIconUrl: siteData?.site_icon_url,
};
}, [] );
if ( isRequestingSite && ! siteIconUrl ) {
return <div className="edit-site-site-icon__image" />;
}
const icon = siteIconUrl ? (
<img
className="edit-site-site-icon__image"
alt={ __( 'Site Icon', 'woocommerce' ) }
src={ siteIconUrl }
/>
) : (
<Icon
className="edit-site-site-icon__icon"
icon={ wordpress }
size={ 48 }
/>
);
return (
<div className={ classNames( className, 'edit-site-site-icon' ) }>
{ icon }
</div>
);
}
export default SiteIcon;

View File

@ -4,11 +4,12 @@
.woocommerce_page_woocommerce-products-dashboard #adminmenumain { .woocommerce_page_woocommerce-products-dashboard #adminmenumain {
display: none; display: none;
} }
.woocommerce_page_woocommerce-products-dashboard #wpcontent { .woocommerce_page_woocommerce-products-dashboard #wpcontent {
margin-left: 0; margin-left: 0;
} }
body.woocommerce_page_woocommerce-products-dashboard
#woocommerce-products-dashboard { body.woocommerce_page_woocommerce-products-dashboard #woocommerce-products-dashboard {
@include wp-admin-reset("#woocommerce-products-dashboard"); @include wp-admin-reset("#woocommerce-products-dashboard");
@include reset; @include reset;
display: block !important; display: block !important;
@ -38,3 +39,5 @@ body.js.is-fullscreen-mode {
} }
} }
} }
@import "products-app/sidebar-dataviews/style.scss";

View File

@ -31,6 +31,7 @@ declare module '@wordpress/core-data' {
isResolving: boolean; isResolving: boolean;
hasResolved: boolean; hasResolved: boolean;
}; };
const store: string;
} }
declare module '@wordpress/keyboard-shortcuts' { declare module '@wordpress/keyboard-shortcuts' {
function useShortcut( function useShortcut(
@ -43,3 +44,24 @@ declare module '@wordpress/keyboard-shortcuts' {
declare module '@wordpress/router' { declare module '@wordpress/router' {
const privateApis; const privateApis;
} }
declare module '@wordpress/edit-site/build-module/components/sync-state-with-url/use-init-edited-entity-from-url' {
export default function useInitEditedEntityFromURL(): void;
}
declare module '@wordpress/edit-site/build-module/components/sidebar-navigation-screen' {
const SidebarNavigationScreen: React.FunctionComponent< {
title: string;
isRoot: boolean;
content: JSX.Element;
} >;
export default SidebarNavigationScreen;
}
declare module '@wordpress/edit-site/build-module/components/site-hub' {
const SiteHub: React.FunctionComponent< {
ref: React.Ref;
isTransparent: boolean;
} >;
export default SiteHub;
}

View File

@ -186,6 +186,8 @@ const webpackConfig = {
extensions: [ '.json', '.js', '.jsx', '.ts', '.tsx' ], extensions: [ '.json', '.js', '.jsx', '.ts', '.tsx' ],
alias: { alias: {
'~': path.resolve( __dirname + '/client' ), '~': path.resolve( __dirname + '/client' ),
'react/jsx-dev-runtime': require.resolve( 'react/jsx-dev-runtime' ),
'react/jsx-runtime': require.resolve( 'react/jsx-runtime' ),
}, },
}, },
plugins: [ plugins: [
@ -240,6 +242,10 @@ const webpackConfig = {
return null; return null;
} }
if ( request.startsWith( '@wordpress/dataviews' ) ) {
return null;
}
if ( request.startsWith( '@wordpress/edit-site' ) ) { if ( request.startsWith( '@wordpress/edit-site' ) ) {
// The external wp.editSite does not include edit-site components, so we need to skip requesting to external here. We can remove this once the edit-site components are exported in the external wp.editSite. // The external wp.editSite does not include edit-site components, so we need to skip requesting to external here. We can remove this once the edit-site components are exported in the external wp.editSite.
// We use the edit-site components in the customize store. // We use the edit-site components in the customize store.

View File

@ -74,6 +74,7 @@ function toggle_remote_logging( $request ) {
update_option( 'woocommerce_feature_remote_logging_enabled', 'yes' ); update_option( 'woocommerce_feature_remote_logging_enabled', 'yes' );
update_option( 'woocommerce_allow_tracking', 'yes' ); update_option( 'woocommerce_allow_tracking', 'yes' );
update_option( 'woocommerce_remote_variant_assignment', 1 ); update_option( 'woocommerce_remote_variant_assignment', 1 );
set_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT, WC()->version );
} else { } else {
update_option( 'woocommerce_feature_remote_logging_enabled', 'no' ); update_option( 'woocommerce_feature_remote_logging_enabled', 'no' );
} }

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update remote logger tool to toggle remote logging feature properly

View File

@ -24,6 +24,14 @@ export const blockAttributes: BlockAttributes = {
type: 'boolean', type: 'boolean',
default: false, default: false,
}, },
prefix: {
type: 'string',
default: 'SKU:',
},
suffix: {
type: 'string',
default: '',
},
}; };
export default blockAttributes; export default blockAttributes;

View File

@ -1,7 +1,6 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
useInnerBlockLayoutContext, useInnerBlockLayoutContext,
@ -10,6 +9,8 @@ import {
import { withProductDataContext } from '@woocommerce/shared-hocs'; import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useStyleProps } from '@woocommerce/base-hooks'; import { useStyleProps } from '@woocommerce/base-hooks';
import { RichText } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
/** /**
* Internal dependencies * Internal dependencies
@ -17,18 +18,24 @@ import { useStyleProps } from '@woocommerce/base-hooks';
import './style.scss'; import './style.scss';
import type { Attributes } from './types'; import type { Attributes } from './types';
type Props = Attributes & HTMLAttributes< HTMLDivElement >; type Props = BlockEditProps< Attributes > & HTMLAttributes< HTMLDivElement >;
const Preview = ( { const Preview = ( {
setAttributes,
parentClassName, parentClassName,
sku, sku,
className, className,
style, style,
prefix,
suffix,
}: { }: {
setAttributes: ( attributes: Record< string, unknown > ) => void;
parentClassName: string; parentClassName: string;
sku: string; sku: string;
className?: string | undefined; className?: string | undefined;
style?: React.CSSProperties | undefined; style?: React.CSSProperties | undefined;
prefix?: string;
suffix?: string;
} ) => ( } ) => (
<div <div
className={ clsx( className, { className={ clsx( className, {
@ -36,7 +43,19 @@ const Preview = ( {
} ) } } ) }
style={ style } style={ style }
> >
{ __( 'SKU:', 'woocommerce' ) } <strong>{ sku }</strong> <RichText
tagName="span"
placeholder="Prefix"
value={ prefix }
onChange={ ( value ) => setAttributes( { prefix: value } ) }
/>
<strong>{ sku }</strong>
<RichText
tagName="span"
placeholder="Suffix"
value={ suffix }
onChange={ ( value ) => setAttributes( { suffix: value } ) }
/>
</div> </div>
); );
@ -50,9 +69,12 @@ const Block = ( props: Props ): JSX.Element | null => {
if ( props.isDescendentOfSingleProductTemplate ) { if ( props.isDescendentOfSingleProductTemplate ) {
return ( return (
<Preview <Preview
setAttributes={ props.setAttributes }
parentClassName={ parentClassName } parentClassName={ parentClassName }
className={ className } className={ className }
sku={ 'Product SKU' } sku={ 'Product SKU' }
prefix={ props.prefix }
suffix={ props.suffix }
/> />
); );
} }
@ -63,9 +85,12 @@ const Block = ( props: Props ): JSX.Element | null => {
return ( return (
<Preview <Preview
setAttributes={ props.setAttributes }
className={ className } className={ className }
parentClassName={ parentClassName } parentClassName={ parentClassName }
sku={ sku } sku={ sku }
prefix={ props.prefix }
suffix={ props.suffix }
{ ...( props.isDescendantOfAllProducts && { { ...( props.isDescendantOfAllProducts && {
className: clsx( className: clsx(
className, className,

View File

@ -10,6 +10,7 @@ import { useEffect } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './editor.scss';
import Block from './block'; import Block from './block';
import type { Attributes } from './types'; import type { Attributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block'; import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
@ -68,7 +69,7 @@ const Edit = ( {
attributes.isDescendantOfAllProducts ? undefined : style attributes.isDescendantOfAllProducts ? undefined : style
} }
> >
<Block { ...blockAttrs } /> <Block { ...blockAttrs } setAttributes={ setAttributes } />
</div> </div>
</> </>
); );

View File

@ -0,0 +1,4 @@
.wc-block-components-product-sku strong {
margin-left: $gap-smallest;
margin-right: $gap-smallest;
}

View File

@ -29,6 +29,9 @@ const blockConfig: BlockConfiguration = {
'woocommerce/product-meta', 'woocommerce/product-meta',
], ],
edit, edit,
save() {
return null;
},
supports, supports,
}; };

View File

@ -1,6 +1,9 @@
.wc-block-components-product-sku { .wc-block-components-product-sku {
display: block; display: block;
text-transform: uppercase;
@include font-size(small); @include font-size(small);
overflow-wrap: break-word; overflow-wrap: break-word;
strong {
text-transform: uppercase;
}
} }

View File

@ -5,4 +5,6 @@ export interface Attributes {
isDescendentOfSingleProductBlock: boolean; isDescendentOfSingleProductBlock: boolean;
showProductSelector: boolean; showProductSelector: boolean;
isDescendantOfAllProducts: boolean; isDescendantOfAllProducts: boolean;
prefix: string;
suffix: string;
} }

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Icon, info } from '@wordpress/icons';
import ProductControl from '@woocommerce/editor-components/product-control';
import type { SelectedOption } from '@woocommerce/block-hocs';
import { createInterpolateElement } from '@wordpress/element';
import {
Placeholder,
// @ts-expect-error Using experimental features
__experimentalHStack as HStack,
// @ts-expect-error Using experimental features
__experimentalText as Text,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import type { ProductCollectionEditComponentProps } from '../types';
import { getCollectionByName } from '../collections';
const ProductPicker = ( props: ProductCollectionEditComponentProps ) => {
const blockProps = useBlockProps();
const attributes = props.attributes;
const collection = getCollectionByName( attributes.collection );
if ( ! collection ) {
return;
}
return (
<div { ...blockProps }>
<Placeholder className="wc-blocks-product-collection__editor-product-picker">
<HStack alignment="center">
<Icon
icon={ info }
className="wc-blocks-product-collection__info-icon"
/>
<Text>
{ createInterpolateElement(
sprintf(
/* translators: %s: collection title */
__(
'<strong>%s</strong> requires a product to be selected in order to display associated items.',
'woocommerce'
),
collection.title
),
{
strong: <strong />,
}
) }
</Text>
</HStack>
<ProductControl
selected={
attributes.query?.productReference as SelectedOption
}
onChange={ ( value = [] ) => {
const isValidId = ( value[ 0 ]?.id ?? null ) !== null;
if ( isValidId ) {
props.setAttributes( {
query: {
...attributes.query,
productReference: value[ 0 ].id,
},
} );
}
} }
messages={ {
search: __( 'Select a product', 'woocommerce' ),
} }
/>
</Placeholder>
</div>
);
};
export default ProductPicker;

View File

@ -168,3 +168,49 @@ $max-button-width: calc(100% / #{$max-button-columns});
color: var(--wp-components-color-accent-inverted, #fff); color: var(--wp-components-color-accent-inverted, #fff);
} }
} }
// Editor Product Picker
.wc-blocks-product-collection__editor-product-picker {
.wc-blocks-product-collection__info-icon {
fill: var(--wp--preset--color--luminous-vivid-orange, #e26f56);
}
}
// Linked Product Control
.wc-block-product-collection-linked-product-control {
width: 100%;
text-align: left;
&__button {
width: 100%;
height: 100%;
padding: 10px;
border: 1px solid $gray-300;
}
&__image-container {
flex-shrink: 0;
width: 45px;
height: 45px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__content {
text-align: left;
}
}
.wc-block-product-collection-linked-product__popover-content .components-popover__content {
width: 100%;
.woocommerce-search-list__search {
border: 0;
padding: 0;
}
}

View File

@ -4,18 +4,25 @@
import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as blockEditorStore } from '@wordpress/block-editor';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import type { ProductCollectionEditComponentProps } from '../types'; import {
ProductCollectionEditComponentProps,
ProductCollectionUIStatesInEditor,
} from '../types';
import ProductCollectionPlaceholder from './product-collection-placeholder'; import ProductCollectionPlaceholder from './product-collection-placeholder';
import ProductCollectionContent from './product-collection-content'; import ProductCollectionContent from './product-collection-content';
import CollectionSelectionModal from './collection-selection-modal'; import CollectionSelectionModal from './collection-selection-modal';
import './editor.scss'; import './editor.scss';
import { getProductCollectionUIStateInEditor } from '../utils';
import ProductPicker from './ProductPicker';
const Edit = ( props: ProductCollectionEditComponentProps ) => { const Edit = ( props: ProductCollectionEditComponentProps ) => {
const { clientId, attributes } = props; const { clientId, attributes } = props;
const location = useGetLocation( props.context, props.clientId );
const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false ); const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false );
const hasInnerBlocks = useSelect( const hasInnerBlocks = useSelect(
@ -24,9 +31,37 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
[ clientId ] [ clientId ]
); );
const Component = hasInnerBlocks const productCollectionUIStateInEditor =
? ProductCollectionContent getProductCollectionUIStateInEditor( {
: ProductCollectionPlaceholder; hasInnerBlocks,
location,
attributes: props.attributes,
usesReference: props.usesReference,
} );
/**
* Component to render based on the UI state.
*/
let Component,
isUsingReferencePreviewMode = false;
switch ( productCollectionUIStateInEditor ) {
case ProductCollectionUIStatesInEditor.COLLECTION_PICKER:
Component = ProductCollectionPlaceholder;
break;
case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER:
Component = ProductPicker;
break;
case ProductCollectionUIStatesInEditor.VALID:
Component = ProductCollectionContent;
break;
case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW:
Component = ProductCollectionContent;
isUsingReferencePreviewMode = true;
break;
default:
// By default showing collection chooser.
Component = ProductCollectionPlaceholder;
}
return ( return (
<> <>
@ -35,6 +70,9 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
openCollectionSelectionModal={ () => openCollectionSelectionModal={ () =>
setIsSelectionModalOpen( true ) setIsSelectionModalOpen( true )
} }
isUsingReferencePreviewMode={ isUsingReferencePreviewMode }
location={ location }
usesReference={ props.usesReference }
/> />
{ isSelectionModalOpen && ( { isSelectionModalOpen && (
<CollectionSelectionModal <CollectionSelectionModal

View File

@ -50,6 +50,7 @@ import LayoutOptionsControl from './layout-options-control';
import FeaturedProductsControl from './featured-products-control'; import FeaturedProductsControl from './featured-products-control';
import CreatedControl from './created-control'; import CreatedControl from './created-control';
import PriceRangeControl from './price-range-control'; import PriceRangeControl from './price-range-control';
import LinkedProductControl from './linked-product-control';
const prepareShouldShowFilter = const prepareShouldShowFilter =
( hideControls: FilterName[] ) => ( filter: FilterName ) => { ( hideControls: FilterName[] ) => ( filter: FilterName ) => {
@ -121,6 +122,13 @@ const ProductCollectionInspectorControls = (
return ( return (
<InspectorControls> <InspectorControls>
<LinkedProductControl
query={ props.attributes.query }
setAttributes={ props.setAttributes }
usesReference={ props.usesReference }
location={ props.location }
/>
<ToolsPanel <ToolsPanel
label={ __( 'Settings', 'woocommerce' ) } label={ __( 'Settings', 'woocommerce' ) }
resetAll={ () => { resetAll={ () => {

View File

@ -0,0 +1,166 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import ProductControl from '@woocommerce/editor-components/product-control';
import { SelectedOption } from '@woocommerce/block-hocs';
import { useState, useMemo } from '@wordpress/element';
import type { WooCommerceBlockLocation } from '@woocommerce/blocks/product-template/utils';
import type { ProductResponseItem } from '@woocommerce/types';
import { decodeEntities } from '@wordpress/html-entities';
import {
PanelBody,
PanelRow,
Button,
Flex,
FlexItem,
Dropdown,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalText as Text,
Spinner,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useGetProduct } from '../../utils';
import type {
ProductCollectionQuery,
ProductCollectionSetAttributes,
} from '../../types';
const ProductButton: React.FC< {
isOpen: boolean;
onToggle: () => void;
product: ProductResponseItem | null;
isLoading: boolean;
} > = ( { isOpen, onToggle, product, isLoading } ) => {
if ( isLoading && ! product ) {
return <Spinner />;
}
return (
<Button
className="wc-block-product-collection-linked-product-control__button"
onClick={ onToggle }
aria-expanded={ isOpen }
disabled={ isLoading }
>
<Flex direction="row" expanded justify="flex-start">
<FlexItem className="wc-block-product-collection-linked-product-control__image-container">
<img
src={ product?.images?.[ 0 ]?.src }
alt={ product?.name }
/>
</FlexItem>
<Flex
direction="column"
align="flex-start"
gap={ 1 }
className="wc-block-product-collection-linked-product-control__content"
>
<FlexItem>
<Text color="inherit" lineHeight={ 1 }>
{ product?.name
? decodeEntities( product.name )
: '' }
</Text>
</FlexItem>
<FlexItem>
<Text color="inherit" lineHeight={ 1 }>
{ product?.sku }
</Text>
</FlexItem>
</Flex>
</Flex>
</Button>
);
};
const LinkedProductPopoverContent: React.FC< {
query: ProductCollectionQuery;
setAttributes: ProductCollectionSetAttributes;
setIsDropdownOpen: React.Dispatch< React.SetStateAction< boolean > >;
} > = ( { query, setAttributes, setIsDropdownOpen } ) => (
<ProductControl
selected={ query?.productReference as SelectedOption }
onChange={ ( value: SelectedOption[] = [] ) => {
const productId = value[ 0 ]?.id ?? null;
if ( productId !== null ) {
setAttributes( {
query: {
...query,
productReference: productId,
},
} );
setIsDropdownOpen( false );
}
} }
messages={ {
search: __( 'Select a product', 'woocommerce' ),
} }
/>
);
const LinkedProductControl = ( {
query,
setAttributes,
location,
usesReference,
}: {
query: ProductCollectionQuery;
setAttributes: ProductCollectionSetAttributes;
location: WooCommerceBlockLocation;
usesReference: string[] | undefined;
} ) => {
const [ isDropdownOpen, setIsDropdownOpen ] = useState< boolean >( false );
const { product, isLoading } = useGetProduct( query.productReference );
const showLinkedProductControl = useMemo( () => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( query?.productReference ?? null ) !== null;
return (
isProductContextRequired &&
! isInRequiredLocation &&
isProductContextSelected
);
}, [ location.type, query?.productReference, usesReference ] );
if ( ! showLinkedProductControl ) return null;
return (
<PanelBody title={ __( 'Linked Product', 'woocommerce' ) }>
<PanelRow>
<Dropdown
className="wc-block-product-collection-linked-product-control"
contentClassName="wc-block-product-collection-linked-product__popover-content"
popoverProps={ { placement: 'left-start' } }
renderToggle={ ( { isOpen, onToggle } ) => (
<ProductButton
isOpen={ isOpen }
onToggle={ onToggle }
product={ product }
isLoading={ isLoading }
/>
) }
renderContent={ () => (
<LinkedProductPopoverContent
query={ query }
setAttributes={ setAttributes }
setIsDropdownOpen={ setIsDropdownOpen }
/>
) }
open={ isDropdownOpen }
onToggle={ () => setIsDropdownOpen( ! isDropdownOpen ) }
/>
</PanelRow>
</PanelBody>
);
};
export default LinkedProductControl;

View File

@ -10,7 +10,6 @@ import { useInstanceId } from '@wordpress/compose';
import { useEffect, useRef, useMemo } from '@wordpress/element'; import { useEffect, useRef, useMemo } from '@wordpress/element';
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
import fastDeepEqual from 'fast-deep-equal/es6'; import fastDeepEqual from 'fast-deep-equal/es6';
/** /**
@ -68,19 +67,23 @@ const useQueryId = (
const ProductCollectionContent = ( { const ProductCollectionContent = ( {
preview: { setPreviewState, initialPreviewState } = {}, preview: { setPreviewState, initialPreviewState } = {},
usesReference,
...props ...props
}: ProductCollectionEditComponentProps ) => { }: ProductCollectionEditComponentProps ) => {
const isInitialAttributesSet = useRef( false ); const isInitialAttributesSet = useRef( false );
const { clientId, attributes, setAttributes } = props; const {
const location = useGetLocation( props.context, props.clientId ); clientId,
attributes,
setAttributes,
location,
isUsingReferencePreviewMode,
} = props;
useSetPreviewState( { useSetPreviewState( {
setPreviewState, setPreviewState,
setAttributes, setAttributes,
location, location,
attributes, attributes,
usesReference, isUsingReferencePreviewMode,
} ); } );
const blockProps = useBlockProps(); const blockProps = useBlockProps();

View File

@ -9,6 +9,16 @@ import { type AttributeMetadata } from '@woocommerce/types';
*/ */
import { WooCommerceBlockLocation } from '../product-template/utils'; import { WooCommerceBlockLocation } from '../product-template/utils';
export enum ProductCollectionUIStatesInEditor {
COLLECTION_PICKER = 'collection_chooser',
PRODUCT_REFERENCE_PICKER = 'product_context_picker',
VALID_WITH_PREVIEW = 'uses_reference_preview_mode',
VALID = 'valid',
// Future states
// INVALID = 'invalid',
// DELETED_PRODUCT_REFERENCE = 'deleted_product_reference',
}
export interface ProductCollectionAttributes { export interface ProductCollectionAttributes {
query: ProductCollectionQuery; query: ProductCollectionQuery;
queryId: number; queryId: number;
@ -95,6 +105,7 @@ export interface ProductCollectionQuery {
woocommerceHandPickedProducts: string[]; woocommerceHandPickedProducts: string[];
priceRange: undefined | PriceRange; priceRange: undefined | PriceRange;
filterable: boolean; filterable: boolean;
productReference?: number;
} }
export type ProductCollectionEditComponentProps = export type ProductCollectionEditComponentProps =
@ -108,6 +119,8 @@ export type ProductCollectionEditComponentProps =
context: { context: {
templateSlug: string; templateSlug: string;
}; };
isUsingReferencePreviewMode: boolean;
location: WooCommerceBlockLocation;
}; };
export type TProductCollectionOrder = 'asc' | 'desc'; export type TProductCollectionOrder = 'asc' | 'desc';

View File

@ -6,8 +6,10 @@ import { addFilter } from '@wordpress/hooks';
import { select } from '@wordpress/data'; import { select } from '@wordpress/data';
import { isWpVersion } from '@woocommerce/settings'; import { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks'; import type { BlockEditProps, Block } from '@wordpress/blocks';
import { useLayoutEffect } from '@wordpress/element'; import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import type { ProductResponseItem } from '@woocommerce/types';
import { getProduct } from '@woocommerce/editor-components/utils';
import { import {
createBlock, createBlock,
// @ts-expect-error Type definitions for this function are missing in Guteberg // @ts-expect-error Type definitions for this function are missing in Guteberg
@ -18,13 +20,14 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import { import {
type ProductCollectionAttributes, ProductCollectionAttributes,
type TProductCollectionOrder, TProductCollectionOrder,
type TProductCollectionOrderBy, TProductCollectionOrderBy,
type ProductCollectionQuery, ProductCollectionQuery,
type ProductCollectionDisplayLayout, ProductCollectionDisplayLayout,
type PreviewState, PreviewState,
type SetPreviewState, SetPreviewState,
ProductCollectionUIStatesInEditor,
} from './types'; } from './types';
import { import {
coreQueryPaginationBlockName, coreQueryPaginationBlockName,
@ -166,41 +169,14 @@ export const addProductCollectionToQueryPaginationParentOrAncestor = () => {
}; };
/** /**
* Get the preview message for the Product Collection block based on the usesReference. * Get the message to show in the preview label when the block is in preview mode based
* There are two scenarios: * on the `usesReference` value.
* 1. When usesReference is product, the preview message will be:
* "Actual products will vary depending on the product being viewed."
* 2. For all other usesReference, the preview message will be:
* "Actual products will vary depending on the page being viewed."
*
* This message will be shown when the usesReference isn't available on the Editor side, but is available on the Frontend.
*/ */
export const getUsesReferencePreviewMessage = ( export const getUsesReferencePreviewMessage = (
location: WooCommerceBlockLocation, location: WooCommerceBlockLocation,
usesReference?: string[] isUsingReferencePreviewMode: boolean
) => { ) => {
if ( ! ( Array.isArray( usesReference ) && usesReference.length > 0 ) ) { if ( isUsingReferencePreviewMode ) {
return '';
}
if ( usesReference.includes( location.type ) ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if ( isArchiveLocationWithTermId || isProductLocationWithProductId ) {
return '';
}
if ( location.type === LocationType.Product ) { if ( location.type === LocationType.Product ) {
return __( return __(
'Actual products will vary depending on the product being viewed.', 'Actual products will vary depending on the product being viewed.',
@ -217,12 +193,77 @@ export const getUsesReferencePreviewMessage = (
return ''; return '';
}; };
export const getProductCollectionUIStateInEditor = ( {
location,
usesReference,
attributes,
hasInnerBlocks,
}: {
location: WooCommerceBlockLocation;
usesReference?: string[] | undefined;
attributes: ProductCollectionAttributes;
hasInnerBlocks: boolean;
} ): ProductCollectionUIStatesInEditor => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isCollectionSelected = !! attributes.collection;
/**
* Case 1: Product context picker
*/
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( attributes.query?.productReference ?? null ) !== null;
if (
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
! isProductContextSelected
) {
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
}
/**
* Case 2: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if (
! isArchiveLocationWithTermId &&
! isProductLocationWithProductId
) {
return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW;
}
}
/**
* Case 3: Collection chooser
*/
if ( ! hasInnerBlocks && ! isCollectionSelected ) {
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER;
}
return ProductCollectionUIStatesInEditor.VALID;
};
export const useSetPreviewState = ( { export const useSetPreviewState = ( {
setPreviewState, setPreviewState,
location, location,
attributes, attributes,
setAttributes, setAttributes,
usesReference, isUsingReferencePreviewMode,
}: { }: {
setPreviewState?: SetPreviewState | undefined; setPreviewState?: SetPreviewState | undefined;
location: WooCommerceBlockLocation; location: WooCommerceBlockLocation;
@ -231,6 +272,7 @@ export const useSetPreviewState = ( {
attributes: Partial< ProductCollectionAttributes > attributes: Partial< ProductCollectionAttributes >
) => void; ) => void;
usesReference?: string[] | undefined; usesReference?: string[] | undefined;
isUsingReferencePreviewMode: boolean;
} ) => { } ) => {
const setState = ( newPreviewState: PreviewState ) => { const setState = ( newPreviewState: PreviewState ) => {
setAttributes( { setAttributes( {
@ -240,8 +282,6 @@ export const useSetPreviewState = ( {
}, },
} ); } );
}; };
const isCollectionUsesReference =
usesReference && usesReference?.length > 0;
/** /**
* When usesReference is available on Frontend but not on Editor side, * When usesReference is available on Frontend but not on Editor side,
@ -249,10 +289,10 @@ export const useSetPreviewState = ( {
*/ */
const usesReferencePreviewMessage = getUsesReferencePreviewMessage( const usesReferencePreviewMessage = getUsesReferencePreviewMessage(
location, location,
usesReference isUsingReferencePreviewMode
); );
useLayoutEffect( () => { useLayoutEffect( () => {
if ( isCollectionUsesReference ) { if ( isUsingReferencePreviewMode ) {
setAttributes( { setAttributes( {
__privatePreviewState: { __privatePreviewState: {
isPreview: usesReferencePreviewMessage.length > 0, isPreview: usesReferencePreviewMessage.length > 0,
@ -263,12 +303,12 @@ export const useSetPreviewState = ( {
}, [ }, [
setAttributes, setAttributes,
usesReferencePreviewMessage, usesReferencePreviewMessage,
isCollectionUsesReference, isUsingReferencePreviewMode,
] ); ] );
// Running setPreviewState function provided by Collection, if it exists. // Running setPreviewState function provided by Collection, if it exists.
useLayoutEffect( () => { useLayoutEffect( () => {
if ( ! setPreviewState && ! isCollectionUsesReference ) { if ( ! setPreviewState && ! isUsingReferencePreviewMode ) {
return; return;
} }
@ -294,11 +334,14 @@ export const useSetPreviewState = ( {
* - Products by tag * - Products by tag
* - Products by attribute * - Products by attribute
*/ */
const termId =
location.type === LocationType.Archive
? location.sourceData?.termId
: null;
useLayoutEffect( () => { useLayoutEffect( () => {
if ( ! setPreviewState && ! isCollectionUsesReference ) { if ( ! setPreviewState && ! isUsingReferencePreviewMode ) {
const isGenericArchiveTemplate = const isGenericArchiveTemplate =
location.type === LocationType.Archive && location.type === LocationType.Archive && termId === null;
location.sourceData?.termId === null;
setAttributes( { setAttributes( {
__privatePreviewState: { __privatePreviewState: {
@ -315,11 +358,11 @@ export const useSetPreviewState = ( {
}, [ }, [
attributes?.query?.inherit, attributes?.query?.inherit,
usesReferencePreviewMessage, usesReferencePreviewMessage,
location.sourceData?.termId, termId,
location.type, location.type,
setAttributes, setAttributes,
setPreviewState, setPreviewState,
isCollectionUsesReference, isUsingReferencePreviewMode,
] ); ] );
}; };
@ -356,3 +399,35 @@ export const getDefaultProductCollection = () =>
}, },
createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE ) createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
); );
export const useGetProduct = ( productId: number | undefined ) => {
const [ product, setProduct ] = useState< ProductResponseItem | null >(
null
);
const [ isLoading, setIsLoading ] = useState< boolean >( false );
useEffect( () => {
const fetchProduct = async () => {
if ( productId ) {
setIsLoading( true );
try {
const fetchedProduct = ( await getProduct(
productId
) ) as ProductResponseItem;
setProduct( fetchedProduct );
} catch ( error ) {
setProduct( null );
} finally {
setIsLoading( false );
}
} else {
setProduct( null );
setIsLoading( false );
}
};
fetchProduct();
}, [ productId ] );
return { product, isLoading };
};

View File

@ -266,7 +266,7 @@ const ProductTemplateEdit = (
products: getEntityRecords( 'postType', postType, { products: getEntityRecords( 'postType', postType, {
...query, ...query,
...restQueryArgs, ...restQueryArgs,
location, productCollectionLocation: location,
productCollectionQueryContext, productCollectionQueryContext,
previewState: __privateProductCollectionPreviewState, previewState: __privateProductCollectionPreviewState,
/** /**

View File

@ -63,17 +63,65 @@ const prepareIsInGenericTemplate =
( entitySlug: string ): boolean => ( entitySlug: string ): boolean =>
templateSlug === entitySlug; templateSlug === entitySlug;
export type WooCommerceBlockLocation = ReturnType< interface WooCommerceBaseLocation {
typeof createLocationObject type: LocationType;
>; sourceData?: object | undefined;
}
const createLocationObject = ( interface ProductLocation extends WooCommerceBaseLocation {
type: LocationType, type: LocationType.Product;
sourceData: Record< string, unknown > = {} sourceData?:
) => ( { | {
type, productId: number;
sourceData, }
} ); | undefined;
}
interface ArchiveLocation extends WooCommerceBaseLocation {
type: LocationType.Archive;
sourceData?:
| {
taxonomy: string;
termId: number;
}
| undefined;
}
interface CartLocation extends WooCommerceBaseLocation {
type: LocationType.Cart;
sourceData?:
| {
productIds: number[];
}
| undefined;
}
interface OrderLocation extends WooCommerceBaseLocation {
type: LocationType.Order;
sourceData?:
| {
orderId: number;
}
| undefined;
}
interface SiteLocation extends WooCommerceBaseLocation {
type: LocationType.Site;
sourceData?: object | undefined;
}
export type WooCommerceBlockLocation =
| ProductLocation
| ArchiveLocation
| CartLocation
| OrderLocation
| SiteLocation;
const createLocationObject = ( type: LocationType, sourceData: object = {} ) =>
( {
type,
sourceData,
} as WooCommerceBlockLocation );
type ContextProperties = { type ContextProperties = {
templateSlug: string; templateSlug: string;
@ -83,7 +131,7 @@ type ContextProperties = {
export const useGetLocation = < T, >( export const useGetLocation = < T, >(
context: Context< T & ContextProperties >, context: Context< T & ContextProperties >,
clientId: string clientId: string
) => { ): WooCommerceBlockLocation => {
const templateSlug = context.templateSlug || ''; const templateSlug = context.templateSlug || '';
const postId = context.postId || null; const postId = context.postId || null;

View File

@ -4,7 +4,9 @@
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
const Save = () => { const Save = () => {
const blockProps = useBlockProps.save(); const blockProps = useBlockProps.save( {
className: 'woocommerce',
} );
return ( return (
<div { ...blockProps }> <div { ...blockProps }>

View File

@ -62,6 +62,16 @@ interface ProductControlProps {
* Whether to show variations in the list of items available. * Whether to show variations in the list of items available.
*/ */
showVariations?: boolean; showVariations?: boolean;
/**
* Different messages to display in the component.
* If any of the messages are not provided, the default message will be used.
*/
messages?: {
list?: string;
noItems?: string;
search?: string;
updated?: string;
};
} }
const messages = { const messages = {
@ -188,7 +198,7 @@ const ProductControl = (
} else if ( showVariations ) { } else if ( showVariations ) {
return renderItemWithVariations; return renderItemWithVariations;
} }
return () => null; return undefined;
}; };
if ( error ) { if ( error ) {
@ -216,7 +226,10 @@ const ProductControl = (
onChange={ onChange } onChange={ onChange }
renderItem={ getRenderItemFunc() } renderItem={ getRenderItemFunc() }
onSearch={ onSearch } onSearch={ onSearch }
messages={ messages } messages={ {
...messages,
...props.messages,
} }
isHierarchical isHierarchical
/> />
); );

View File

@ -9,6 +9,7 @@ import { test as base, expect } from '@woocommerce/e2e-utils';
*/ */
import ProductCollectionPage, { import ProductCollectionPage, {
BLOCK_LABELS, BLOCK_LABELS,
Collections,
SELECTORS, SELECTORS,
} from './product-collection.page'; } from './product-collection.page';
@ -81,6 +82,87 @@ test.describe( 'Product Collection', () => {
).toBeVisible(); ).toBeVisible();
} ); } );
test.describe( 'when no results are found', () => {
test.beforeEach( async ( { admin } ) => {
await admin.createNewPost();
} );
test( 'does not render', async ( { page, editor, pageObject } ) => {
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( 'featured' );
await pageObject.addFilter( 'Price Range' );
await pageObject.setPriceRange( {
max: '1',
} );
const featuredBlock = editor.canvas.getByLabel( 'Block: Featured' );
await expect(
featuredBlock.getByText( 'Featured products' )
).toBeVisible();
// The "No results found" info is rendered in editor for all collections.
await expect(
featuredBlock.getByText( 'No results found' )
).toBeVisible();
await pageObject.publishAndGoToFrontend();
const content = page.locator( 'main' );
await expect( content ).not.toContainText( 'Featured products' );
await expect( content ).not.toContainText( 'No results found' );
} );
// This test ensures the runtime render state is correctly reset for
// each block.
test( 'does not prevent subsequent blocks from render', async ( {
page,
pageObject,
} ) => {
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( 'featured' );
await pageObject.addFilter( 'Price Range' );
await pageObject.setPriceRange( {
max: '1',
} );
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( 'topRated' );
await pageObject.refreshLocators( 'editor' );
await expect( pageObject.products ).toHaveCount( 5 );
await pageObject.publishAndGoToFrontend();
await pageObject.refreshLocators( 'frontend' );
await expect( pageObject.products ).toHaveCount( 5 );
await expect( page.locator( 'main' ) ).not.toContainText(
'Featured products'
);
} );
test( 'renders if No Results block is present', async ( {
page,
editor,
pageObject,
} ) => {
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( 'productCatalog' );
await pageObject.addFilter( 'Price Range' );
await pageObject.setPriceRange( {
max: '1',
} );
await expect(
editor.canvas.getByText( 'No results found' )
).toBeVisible();
await pageObject.publishAndGoToFrontend();
await expect( page.getByText( 'No results found' ) ).toBeVisible();
} );
} );
test.describe( 'Renders correctly with all Product Elements', () => { test.describe( 'Renders correctly with all Product Elements', () => {
const expectedProductContent = [ const expectedProductContent = [
'Beanie', // core/post-title 'Beanie', // core/post-title
@ -321,7 +403,7 @@ test.describe( 'Product Collection', () => {
} ); } );
} ); } );
test.describe( 'Location is recognised', () => { test.describe( 'Location is recognized', () => {
const filterRequest = ( request: Request ) => { const filterRequest = ( request: Request ) => {
const url = request.url(); const url = request.url();
return ( return (
@ -337,7 +419,9 @@ test.describe( 'Product Collection', () => {
return ( return (
url.includes( 'wp/v2/product' ) && url.includes( 'wp/v2/product' ) &&
searchParams.get( 'isProductCollectionBlock' ) === 'true' && searchParams.get( 'isProductCollectionBlock' ) === 'true' &&
!! searchParams.get( `location[sourceData][productId]` ) !! searchParams.get(
`productCollectionLocation[sourceData][productId]`
)
); );
}; };
@ -349,26 +433,30 @@ test.describe( 'Product Collection', () => {
if ( locationType === 'product' ) { if ( locationType === 'product' ) {
return { return {
type: searchParams.get( 'location[type]' ), type: searchParams.get( 'productCollectionLocation[type]' ),
productId: searchParams.get( productId: searchParams.get(
`location[sourceData][productId]` `productCollectionLocation[sourceData][productId]`
), ),
}; };
} }
if ( locationType === 'archive' ) { if ( locationType === 'archive' ) {
return { return {
type: searchParams.get( 'location[type]' ), type: searchParams.get( 'productCollectionLocation[type]' ),
taxonomy: searchParams.get( taxonomy: searchParams.get(
`location[sourceData][taxonomy]` `productCollectionLocation[sourceData][taxonomy]`
),
termId: searchParams.get(
`productCollectionLocation[sourceData][termId]`
), ),
termId: searchParams.get( `location[sourceData][termId]` ),
}; };
} }
return { return {
type: searchParams.get( 'location[type]' ), type: searchParams.get( 'productCollectionLocation[type]' ),
sourceData: searchParams.get( `location[sourceData]` ), sourceData: searchParams.get(
`productCollectionLocation[sourceData]`
),
}; };
}; };
@ -401,10 +489,10 @@ test.describe( 'Product Collection', () => {
pageObject.BLOCK_NAME pageObject.BLOCK_NAME
); );
const locationReuqestPromise = const locationRequestPromise =
page.waitForRequest( filterProductRequest ); page.waitForRequest( filterProductRequest );
await pageObject.chooseCollectionInTemplate( 'featured' ); await pageObject.chooseCollectionInTemplate( 'featured' );
const locationRequest = await locationReuqestPromise; const locationRequest = await locationRequestPromise;
const { type, productId } = getLocationDetailsFromRequest( const { type, productId } = getLocationDetailsFromRequest(
locationRequest, locationRequest,
@ -880,3 +968,309 @@ test.describe( 'Product Collection', () => {
} ); } );
} ); } );
} ); } );
test.describe( 'Testing "usesReference" argument in "registerProductCollection"', () => {
const MY_REGISTERED_COLLECTIONS = {
myCustomCollectionWithProductContext: {
name: 'My Custom Collection - Product Context',
label: 'Block: My Custom Collection - Product Context',
previewLabelTemplate: [ 'woocommerce/woocommerce//single-product' ],
shouldShowProductPicker: true,
},
myCustomCollectionWithCartContext: {
name: 'My Custom Collection - Cart Context',
label: 'Block: My Custom Collection - Cart Context',
previewLabelTemplate: [ 'woocommerce/woocommerce//page-cart' ],
shouldShowProductPicker: false,
},
myCustomCollectionWithOrderContext: {
name: 'My Custom Collection - Order Context',
label: 'Block: My Custom Collection - Order Context',
previewLabelTemplate: [
'woocommerce/woocommerce//order-confirmation',
],
shouldShowProductPicker: false,
},
myCustomCollectionWithArchiveContext: {
name: 'My Custom Collection - Archive Context',
label: 'Block: My Custom Collection - Archive Context',
previewLabelTemplate: [
'woocommerce/woocommerce//taxonomy-product_cat',
],
shouldShowProductPicker: false,
},
myCustomCollectionMultipleContexts: {
name: 'My Custom Collection - Multiple Contexts',
label: 'Block: My Custom Collection - Multiple Contexts',
previewLabelTemplate: [
'woocommerce/woocommerce//single-product',
'woocommerce/woocommerce//order-confirmation',
],
shouldShowProductPicker: true,
},
};
// Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'register-product-collection-tester'
);
} );
Object.entries( MY_REGISTERED_COLLECTIONS ).forEach(
( [ key, collection ] ) => {
for ( const template of collection.previewLabelTemplate ) {
test( `Collection "${ collection.name }" should show preview label in "${ template }"`, async ( {
pageObject,
editor,
} ) => {
await pageObject.goToEditorTemplate( template );
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInTemplate(
key as Collections
);
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeVisible();
} );
}
test( `Collection "${ collection.name }" should not show preview label in a post`, async ( {
pageObject,
editor,
admin,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Check visibility of product picker
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
const expectedVisibility = collection.shouldShowProductPicker
? 'toBeVisible'
: 'toBeHidden';
await expect( editorProductPicker )[ expectedVisibility ]();
if ( collection.shouldShowProductPicker ) {
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
}
// At this point, the product picker should be hidden
await expect( editorProductPicker ).toBeHidden();
// Check visibility of preview label
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeHidden();
} );
test( `Collection "${ collection.name }" should not show preview label in Product Catalog template`, async ( {
pageObject,
editor,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
key as Collections
);
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeHidden();
} );
}
);
} );
test.describe( 'Product picker', () => {
const MY_REGISTERED_COLLECTIONS_THAT_NEEDS_PRODUCT = {
myCustomCollectionWithProductContext: {
name: 'My Custom Collection - Product Context',
label: 'Block: My Custom Collection - Product Context',
collection:
'woocommerce/product-collection/my-custom-collection-product-context',
},
myCustomCollectionMultipleContexts: {
name: 'My Custom Collection - Multiple Contexts',
label: 'Block: My Custom Collection - Multiple Contexts',
collection:
'woocommerce/product-collection/my-custom-collection-multiple-contexts',
},
};
// Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'register-product-collection-tester'
);
} );
Object.entries( MY_REGISTERED_COLLECTIONS_THAT_NEEDS_PRODUCT ).forEach(
( [ key, collection ] ) => {
test( `For collection "${ collection.name }" - manually selected product reference should be available on Frontend in a post`, async ( {
pageObject,
admin,
page,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// On Frontend, verify that product reference is a number
await pageObject.publishAndGoToFrontend();
const collectionWithProductContext = page.locator(
`[data-collection="${ collection.collection }"]`
);
const queryAttribute = JSON.parse(
( await collectionWithProductContext.getAttribute(
'data-query'
) ) || '{}'
);
expect( typeof queryAttribute?.productReference ).toBe(
'number'
);
} );
test( `For collection "${ collection.name }" - changing product using inspector control`, async ( {
pageObject,
admin,
page,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Verify that Album is selected
await expect(
admin.page.locator( SELECTORS.linkedProductControl.button )
).toContainText( 'Album' );
// Change product using inspector control to Beanie
await admin.page
.locator( SELECTORS.linkedProductControl.button )
.click();
await admin.page
.locator( SELECTORS.linkedProductControl.popoverContent )
.getByLabel( 'Beanie', { exact: true } )
.click();
await expect(
admin.page.locator( SELECTORS.linkedProductControl.button )
).toContainText( 'Beanie' );
// On Frontend, verify that product reference is a number
await pageObject.publishAndGoToFrontend();
const collectionWithProductContext = page.locator(
`[data-collection="${ collection.collection }"]`
);
const queryAttribute = JSON.parse(
( await collectionWithProductContext.getAttribute(
'data-query'
) ) || '{}'
);
expect( typeof queryAttribute?.productReference ).toBe(
'number'
);
} );
test( `For collection "${ collection.name }" - product picker shouldn't be shown in Single Product template`, async ( {
pageObject,
admin,
editor,
} ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//single-product`,
postType: 'wp_template',
canvas: 'edit',
} );
await editor.canvas.locator( 'body' ).click();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInTemplate(
key as Collections
);
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeHidden();
} );
}
);
test( 'Product picker should work as expected while changing collection using "Choose collection" button from Toolbar', async ( {
pageObject,
admin,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost(
'myCustomCollectionWithProductContext'
);
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Change collection using Toolbar
await pageObject.changeCollectionUsingToolbar(
'myCustomCollectionMultipleContexts'
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Product picker should be hidden for collections that don't need product
await pageObject.changeCollectionUsingToolbar( 'featured' );
await expect( editorProductPicker ).toBeHidden();
} );
} );

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { Locator, Page } from '@playwright/test'; import { FrameLocator, Locator, Page } from '@playwright/test';
import { Editor, Admin } from '@woocommerce/e2e-utils'; import { Editor, Admin } from '@woocommerce/e2e-utils';
import { BlockRepresentation } from '@wordpress/e2e-test-utils-playwright/build-types/editor/insert-block'; import { BlockRepresentation } from '@wordpress/e2e-test-utils-playwright/build-types/editor/insert-block';
@ -62,6 +62,12 @@ export const SELECTORS = {
previewButtonTestID: 'product-collection-preview-button', previewButtonTestID: 'product-collection-preview-button',
collectionPlaceholder: collectionPlaceholder:
'[data-type="woocommerce/product-collection"] .components-placeholder', '[data-type="woocommerce/product-collection"] .components-placeholder',
productPicker: '.wc-blocks-product-collection__editor-product-picker',
linkedProductControl: {
button: '.wc-block-product-collection-linked-product-control__button',
popoverContent:
'.wc-block-product-collection-linked-product__popover-content',
},
}; };
export type Collections = export type Collections =
@ -200,10 +206,31 @@ class ProductCollectionPage {
} }
} }
async chooseProductInEditorProductPickerIfAvailable(
pageReference: Page | FrameLocator
) {
const editorProductPicker = pageReference.locator(
SELECTORS.productPicker
);
if ( await editorProductPicker.isVisible() ) {
await editorProductPicker
.locator( 'label' )
.filter( {
hasText: 'Album',
} )
.click();
}
}
async createNewPostAndInsertBlock( collection?: Collections ) { async createNewPostAndInsertBlock( collection?: Collections ) {
await this.admin.createNewPost(); await this.admin.createNewPost();
await this.insertProductCollection(); await this.insertProductCollection();
await this.chooseCollectionInPost( collection ); await this.chooseCollectionInPost( collection );
// If product picker is available, choose a product.
await this.chooseProductInEditorProductPickerIfAvailable(
this.admin.page
);
await this.refreshLocators( 'editor' ); await this.refreshLocators( 'editor' );
await this.editor.openDocumentSettingsSidebar(); await this.editor.openDocumentSettingsSidebar();
} }
@ -345,6 +372,10 @@ class ProductCollectionPage {
await this.editor.canvas.locator( 'body' ).click(); await this.editor.canvas.locator( 'body' ).click();
await this.insertProductCollection(); await this.insertProductCollection();
await this.chooseCollectionInTemplate( collection ); await this.chooseCollectionInTemplate( collection );
// If product picker is available, choose a product.
await this.chooseProductInEditorProductPickerIfAvailable(
this.editor.canvas
);
await this.refreshLocators( 'editor' ); await this.refreshLocators( 'editor' );
} }
@ -571,6 +602,30 @@ class ProductCollectionPage {
.click(); .click();
} }
async changeCollectionUsingToolbar( collection: Collections ) {
// Click "Choose collection" button in the toolbar.
await this.admin.page
.getByRole( 'toolbar', { name: 'Block Tools' } )
.getByRole( 'button', { name: 'Choose collection' } )
.click();
// Select the collection from the modal.
const collectionChooserModal = this.admin.page.locator(
'.wc-blocks-product-collection__modal'
);
await collectionChooserModal
.getByRole( 'button', {
name: collectionToButtonNameMap[ collection ],
} )
.click();
await collectionChooserModal
.getByRole( 'button', {
name: 'Continue',
} )
.click();
}
async setDisplaySettings( { async setDisplaySettings( {
itemsPerPage, itemsPerPage,
offset, offset,

View File

@ -0,0 +1,4 @@
Significance: major
Type: add
Product Collection - Show product picker in Editor when collection requires a product but not available <details> A collection can define if it requires a product context. This can be done using `usesReference` argument i.e. ```tsx __experimentalRegisterProductCollection({ ..., usesReference: ['product'], ) ``` When product context doesn't exist in current template/page/post etc. then we show product picker in Editor. This way, merchant can manually provide a product context to the collection.

View File

@ -0,0 +1,4 @@
Significance: major
Type: add
Product Collection - Implement Inspector control to change selected product

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Product bulk edit: fix increasing & decreasing sale price when there was no previous sale.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Product SKU block: add editable prefixes and suffixes.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Comment: This reverts an existing PR.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Added doc blocks to conditionally fired hooks to better explain nuanced behavior

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update webpack config to bundle in @wordpress/dataviews package.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Trap focus inside the product gallery modal.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix variation selector display issues on the front end #51023

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix My Account block icon being too small when inserted via block hooks

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Add filter for response of `wc_rest_should_load_namespace` function to allow loading namespaces.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Mark several Customize Your Store PHP classes as internal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Product Collection: Don't render when empty unless the no results block is present.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add query params masking to remote logger

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Enhance WooCommerce version checking for remote logging reliability

View File

@ -445,6 +445,18 @@ p.demo_store,
min-width: 75%; min-width: 75%;
display: inline-block; display: inline-block;
margin-right: 1em; margin-right: 1em;
/* We hide the default chevron because it cannot be directly modified. Instead, we add a custom chevron using a background image. */
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 2em;
background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hldnJvbi1kb3duIj48cG9seWxpbmUgcG9pbnRzPSI2IDkgMTIgMTUgMTggOSI+PC9wb2x5bGluZT48L3N2Zz4=)
no-repeat;
background-size: 16px;
-webkit-background-size: 16px;
background-position: calc(100% - 12px) 50%;
-webkit-background-position: calc(100% - 12px) 50%;
} }
td.label { td.label {

View File

@ -127,6 +127,8 @@ jQuery( function( $ ) {
this.onResetSlidePosition = this.onResetSlidePosition.bind( this ); this.onResetSlidePosition = this.onResetSlidePosition.bind( this );
this.getGalleryItems = this.getGalleryItems.bind( this ); this.getGalleryItems = this.getGalleryItems.bind( this );
this.openPhotoswipe = this.openPhotoswipe.bind( this ); this.openPhotoswipe = this.openPhotoswipe.bind( this );
this.trapFocusPhotoswipe = this.trapFocusPhotoswipe.bind( this );
this.handlePswpTrapFocus = this.handlePswpTrapFocus.bind( this );
if ( this.flexslider_enabled ) { if ( this.flexslider_enabled ) {
this.initFlexslider( args.flexslider ); this.initFlexslider( args.flexslider );
@ -307,8 +309,10 @@ jQuery( function( $ ) {
e.preventDefault(); e.preventDefault();
var pswpElement = $( '.pswp' )[0], var pswpElement = $( '.pswp' )[0],
items = this.getGalleryItems(), items = this.getGalleryItems(),
eventTarget = $( e.target ), eventTarget = $( e.target ),
currentTarget = e.currentTarget,
self = this,
clicked; clicked;
if ( 0 < eventTarget.closest( '.woocommerce-product-gallery__trigger' ).length ) { if ( 0 < eventTarget.closest( '.woocommerce-product-gallery__trigger' ).length ) {
@ -326,14 +330,73 @@ jQuery( function( $ ) {
} }
captionEl.children[0].textContent = item.title; captionEl.children[0].textContent = item.title;
return true; return true;
} },
timeToIdle: 0, // Ensure the gallery controls are always visible to avoid keyboard navigation issues.
}, wc_single_product_params.photoswipe_options ); }, wc_single_product_params.photoswipe_options );
// Initializes and opens PhotoSwipe. // Initializes and opens PhotoSwipe.
var photoswipe = new PhotoSwipe( pswpElement, PhotoSwipeUI_Default, items, options ); var photoswipe = new PhotoSwipe( pswpElement, PhotoSwipeUI_Default, items, options );
photoswipe.listen( 'afterInit', function() {
self.trapFocusPhotoswipe( true );
});
photoswipe.listen( 'close', function() {
self.trapFocusPhotoswipe( false );
currentTarget.focus();
});
photoswipe.init(); photoswipe.init();
}; };
/**
* Control focus in photoswipe modal.
*
* @param {boolean} trapFocus - Whether to trap focus or not.
*/
ProductGallery.prototype.trapFocusPhotoswipe = function( trapFocus ) {
var pswp = document.querySelector( '.pswp' );
if ( ! pswp ) {
return;
}
if ( trapFocus ) {
pswp.addEventListener( 'keydown', this.handlePswpTrapFocus );
} else {
pswp.removeEventListener( 'keydown', this.handlePswpTrapFocus );
}
};
/**
* Handle keydown event in photoswipe modal.
*/
ProductGallery.prototype.handlePswpTrapFocus = function( e ) {
var allFocusablesEls = e.currentTarget.querySelectorAll( 'button:not([disabled])' );
var filteredFocusablesEls = Array.from( allFocusablesEls ).filter( function( btn ) {
return btn.style.display !== 'none' && window.getComputedStyle( btn ).display !== 'none';
} );
if ( 1 >= filteredFocusablesEls.length ) {
return;
}
var firstTabStop = filteredFocusablesEls[0];
var lastTabStop = filteredFocusablesEls[filteredFocusablesEls.length - 1];
if ( e.key === 'Tab' ) {
if ( e.shiftKey ) {
if ( document.activeElement === firstTabStop ) {
e.preventDefault();
lastTabStop.focus();
}
} else if ( document.activeElement === lastTabStop ) {
e.preventDefault();
firstTabStop.focus();
}
}
};
/** /**
* Function to call wc_product_gallery on jquery selector. * Function to call wc_product_gallery on jquery selector.
*/ */

View File

@ -896,7 +896,8 @@ class WC_Admin_Post_Types {
return false; return false;
} }
$old_price = (float) $product->{"get_{$price_type}_price"}(); $old_price = $product->{"get_{$price_type}_price"}();
$old_price = '' === $old_price ? (float) $product->get_regular_price() : (float) $old_price;
$price_changed = false; $price_changed = false;
$change_price = absint( $request_data[ "change_{$price_type}_price" ] ); $change_price = absint( $request_data[ "change_{$price_type}_price" ] );

View File

@ -641,8 +641,13 @@ class WC_Shortcode_Products {
do_action( "woocommerce_shortcode_before_{$this->type}_loop", $this->attributes ); do_action( "woocommerce_shortcode_before_{$this->type}_loop", $this->attributes );
// Fire standard shop loop hooks when paginating results so we can show result counts and so on.
if ( wc_string_to_bool( $this->attributes['paginate'] ) ) { if ( wc_string_to_bool( $this->attributes['paginate'] ) ) {
/**
* Fire the standard shop hooks when paginating so we can display result counts etc.
* If the pagination is not enabled, this hook will not be fired.
*
* @since 3.3.1
*/
do_action( 'woocommerce_before_shop_loop' ); do_action( 'woocommerce_before_shop_loop' );
} }
@ -667,8 +672,13 @@ class WC_Shortcode_Products {
$GLOBALS['post'] = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $GLOBALS['post'] = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
woocommerce_product_loop_end(); woocommerce_product_loop_end();
// Fire standard shop loop hooks when paginating results so we can show result counts and so on.
if ( wc_string_to_bool( $this->attributes['paginate'] ) ) { if ( wc_string_to_bool( $this->attributes['paginate'] ) ) {
/**
* Fire the standard shop hooks when paginating so we can display the pagination.
* If the pagination is not enabled, this hook will not be fired.
*
* @since 3.3.1
*/
do_action( 'woocommerce_after_shop_loop' ); do_action( 'woocommerce_after_shop_loop' );
} }

View File

@ -410,8 +410,6 @@ function wc_rest_should_load_namespace( string $ns, string $rest_route = '' ): b
'wc/private', 'wc/private',
); );
// We can consider allowing filtering this list in the future.
$known_namespace_request = false; $known_namespace_request = false;
foreach ( $known_namespaces as $known_namespace ) { foreach ( $known_namespaces as $known_namespace ) {
if ( str_starts_with( $rest_route, $known_namespace ) ) { if ( str_starts_with( $rest_route, $known_namespace ) ) {
@ -424,5 +422,15 @@ function wc_rest_should_load_namespace( string $ns, string $rest_route = '' ): b
return true; return true;
} }
return str_starts_with( $rest_route, $ns ); /**
* Filters whether a namespace should be loaded.
*
* @param bool $should_load True if the namespace should be loaded, false otherwise.
* @param string $ns The namespace to check.
* @param string $rest_route The REST route being checked.
* @param array $known_namespaces Known namespaces that we know are safe to not load if the request is not for them.
*
* @since 9.4
*/
return apply_filters( 'wc_rest_should_load_namespace', str_starts_with( $rest_route, $ns ), $ns, $rest_route, $known_namespaces );
} }

View File

@ -7,6 +7,8 @@ use WP_Post;
/** /**
* Customize Your Store Task * Customize Your Store Task
*
* @internal
*/ */
class CustomizeStore extends Task { class CustomizeStore extends Task {
/** /**

View File

@ -8,6 +8,8 @@ use Automattic\Jetpack\Connection\Utils;
/** /**
* Class Configuration * Class Configuration
*
* @internal
*/ */
class Configuration { class Configuration {

View File

@ -10,6 +10,8 @@ use WpOrg\Requests\Requests;
/** /**
* Class Connection * Class Connection
*
* @internal
*/ */
class Connection { class Connection {
const TEXT_COMPLETION_API_URL = 'https://public-api.wordpress.com/wpcom/v2/text-completion'; const TEXT_COMPLETION_API_URL = 'https://public-api.wordpress.com/wpcom/v2/text-completion';

View File

@ -10,6 +10,8 @@ use WP_Error;
* ContentProcessor class. * ContentProcessor class.
* *
* Process images for content * Process images for content
*
* @internal
*/ */
class ContentProcessor { class ContentProcessor {

View File

@ -5,6 +5,8 @@ namespace Automattic\WooCommerce\Blocks\AIContent;
/** /**
* Patterns Dictionary class. * Patterns Dictionary class.
*
* @internal
*/ */
class PatternsDictionary { class PatternsDictionary {
/** /**

View File

@ -6,6 +6,8 @@ use WP_Error;
/** /**
* Patterns Helper class. * Patterns Helper class.
*
* @internal
*/ */
class PatternsHelper { class PatternsHelper {
/** /**

View File

@ -7,6 +7,8 @@ use WP_Error;
/** /**
* Pattern Images class. * Pattern Images class.
*
* @internal
*/ */
class UpdatePatterns { class UpdatePatterns {

View File

@ -6,6 +6,8 @@ use Automattic\WooCommerce\Blocks\AI\Connection;
use WP_Error; use WP_Error;
/** /**
* Pattern Images class. * Pattern Images class.
*
* @internal
*/ */
class UpdateProducts { class UpdateProducts {

View File

@ -68,6 +68,7 @@ class CustomerAccount extends AbstractBlock {
public function modify_hooked_block_attributes( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ) { public function modify_hooked_block_attributes( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ) {
$parsed_hooked_block['attrs']['displayStyle'] = 'icon_only'; $parsed_hooked_block['attrs']['displayStyle'] = 'icon_only';
$parsed_hooked_block['attrs']['iconStyle'] = 'line'; $parsed_hooked_block['attrs']['iconStyle'] = 'line';
$parsed_hooked_block['attrs']['iconClass'] = 'wc-block-customer-account__account-icon';
/* /*
* The Mini Cart block (which is hooked into the header) has a margin of 0.5em on the left side. * The Mini Cart block (which is hooked into the header) has a margin of 0.5em on the left side.

View File

@ -47,6 +47,18 @@ class ProductCollection extends AbstractBlock {
protected $custom_order_opts = array( 'popularity', 'rating' ); protected $custom_order_opts = array( 'popularity', 'rating' );
/**
* The render state of the product collection block.
*
* These props are runtime-based and reinitialize for every block on a page.
*
* @var array
*/
private $render_state = array(
'has_results' => false,
'has_no_results_block' => false,
);
/** /**
* Initialize this block type. * Initialize this block type.
* *
@ -80,8 +92,30 @@ class ProductCollection extends AbstractBlock {
// Provide location context into block's context. // Provide location context into block's context.
add_filter( 'render_block_context', array( $this, 'provide_location_context_for_inner_blocks' ), 11, 1 ); add_filter( 'render_block_context', array( $this, 'provide_location_context_for_inner_blocks' ), 11, 1 );
// Disable block render if the ProductTemplate block is empty.
add_filter(
'render_block_woocommerce/product-template',
function ( $html ) {
$this->render_state['has_results'] = ! empty( $html );
return $html;
},
100,
1
);
// Enable block render if the ProductCollectionNoResults block is rendered.
add_filter(
'render_block_woocommerce/product-collection-no-results',
function ( $html ) {
$this->render_state['has_no_results_block'] = ! empty( $html );
return $html;
},
100,
1
);
// Interactivity API: Add navigation directives to the product collection block. // Interactivity API: Add navigation directives to the product collection block.
add_filter( 'render_block_woocommerce/product-collection', array( $this, 'enhance_product_collection_with_interactivity' ), 10, 2 ); add_filter( 'render_block_woocommerce/product-collection', array( $this, 'handle_rendering' ), 10, 2 );
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 ); add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 ); add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 );
@ -90,6 +124,46 @@ class ProductCollection extends AbstractBlock {
add_filter( 'render_block_data', array( $this, 'disable_enhanced_pagination' ), 10, 1 ); add_filter( 'render_block_data', array( $this, 'disable_enhanced_pagination' ), 10, 1 );
} }
/**
* Handle the rendering of the block.
*
* @param string $block_content The block content about to be rendered.
* @param array $block The block being rendered.
*
* @return string
*/
public function handle_rendering( $block_content, $block ) {
if ( $this->should_prevent_render() ) {
return ''; // Prevent rendering.
}
// Reset the render state for the next render.
$this->reset_render_state();
return $this->enhance_product_collection_with_interactivity( $block_content, $block );
}
/**
* Check if the block should be prevented from rendering.
*
* @return bool
*/
private function should_prevent_render() {
return ! $this->render_state['has_results'] && ! $this->render_state['has_no_results_block'];
}
/**
* Reset the render state.
*/
private function reset_render_state() {
$this->render_state = array(
'has_results' => false,
'has_no_results_block' => false,
);
}
/** /**
* Provides the location context to each inner block of the product collection block. * Provides the location context to each inner block of the product collection block.
* Hint: Only blocks using the 'query' context will be affected. * Hint: Only blocks using the 'query' context will be affected.

View File

@ -69,14 +69,27 @@ class ProductSKU extends AbstractBlock {
$styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); $styles_and_classes = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
$prefix = isset( $attributes['prefix'] ) ? wp_kses_post( ( $attributes['prefix'] ) ) : __( 'SKU: ', 'woocommerce' );
if ( ! empty( $prefix ) ) {
$prefix = sprintf( '<span class="prefix">%s</span>', $prefix );
}
$suffix = isset( $attributes['suffix'] ) ? wp_kses_post( ( $attributes['suffix'] ) ) : '';
if ( ! empty( $suffix ) ) {
$suffix = sprintf( '<span class="suffix">%s</span>', $suffix );
}
return sprintf( return sprintf(
'<div class="wc-block-components-product-sku wc-block-grid__product-sku wp-block-woocommerce-product-sku product_meta %1$s" style="%2$s"> '<div class="wc-block-components-product-sku wc-block-grid__product-sku wp-block-woocommerce-product-sku product_meta %1$s" style="%2$s">
SKU: %3$s
<strong class="sku">%3$s</strong> <strong class="sku">%4$s</strong>
%5$s
</div>', </div>',
esc_attr( $styles_and_classes['classes'] ), esc_attr( $styles_and_classes['classes'] ),
esc_attr( $styles_and_classes['styles'] ?? '' ), esc_attr( $styles_and_classes['styles'] ?? '' ),
$product_sku $prefix,
$product_sku,
$suffix
); );
} }
} }

View File

@ -8,6 +8,8 @@ use Automattic\WooCommerce\Blocks\Images\Pexels;
/** /**
* AIPatterns class. * AIPatterns class.
*
* @internal
*/ */
class AIPatterns { class AIPatterns {
const PATTERNS_AI_DATA_POST_TYPE = 'patterns_ai_data'; const PATTERNS_AI_DATA_POST_TYPE = 'patterns_ai_data';

View File

@ -5,6 +5,8 @@ use WP_Error;
/** /**
* PatternsToolkit class. * PatternsToolkit class.
*
* @internal
*/ */
class PTKClient { class PTKClient {
/** /**

View File

@ -7,6 +7,8 @@ use WP_Upgrader;
/** /**
* PTKPatterns class. * PTKPatterns class.
*
* @internal
*/ */
class PTKPatternsStore { class PTKPatternsStore {
const TRANSIENT_NAME = 'ptk_patterns'; const TRANSIENT_NAME = 'ptk_patterns';

View File

@ -5,6 +5,8 @@ use Automattic\WooCommerce\Admin\Features\Features;
/** /**
* PatternRegistry class. * PatternRegistry class.
*
* @internal
*/ */
class PatternRegistry { class PatternRegistry {
const SLUG_REGEX = '/^[A-z0-9\/_-]+$/'; const SLUG_REGEX = '/^[A-z0-9\/_-]+$/';

View File

@ -20,11 +20,10 @@ use WC_Log_Levels;
* @package WooCommerce\Classes * @package WooCommerce\Classes
*/ */
class RemoteLogger extends \WC_Log_Handler { class RemoteLogger extends \WC_Log_Handler {
const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash'; const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash';
const RATE_LIMIT_ID = 'woocommerce_remote_logging'; const RATE_LIMIT_ID = 'woocommerce_remote_logging';
const RATE_LIMIT_DELAY = 60; // 1 minute. const RATE_LIMIT_DELAY = 60; // 1 minute.
const WC_LATEST_VERSION_TRANSIENT = 'latest_woocommerce_version'; const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';
const FETCH_LATEST_VERSION_RETRY = 'fetch_latest_woocommerce_version_retry';
/** /**
* Handle a log entry. * Handle a log entry.
@ -71,7 +70,7 @@ class RemoteLogger extends \WC_Log_Handler {
'wc_version' => WC()->version, 'wc_version' => WC()->version,
'php_version' => phpversion(), 'php_version' => phpversion(),
'wp_version' => get_bloginfo( 'version' ), 'wp_version' => get_bloginfo( 'version' ),
'request_uri' => filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ), 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ),
), ),
); );
@ -150,7 +149,7 @@ class RemoteLogger extends \WC_Log_Handler {
return false; return false;
} }
if ( ! $this->is_latest_woocommerce_version() ) { if ( ! $this->should_current_version_be_logged() ) {
return false; return false;
} }
@ -221,7 +220,7 @@ class RemoteLogger extends \WC_Log_Handler {
self::LOG_ENDPOINT, self::LOG_ENDPOINT,
array( array(
'body' => wp_json_encode( $body ), 'body' => wp_json_encode( $body ),
'timeout' => 2, 'timeout' => 3,
'headers' => array( 'headers' => array(
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
), ),
@ -256,14 +255,22 @@ class RemoteLogger extends \WC_Log_Handler {
* *
* @return bool * @return bool
*/ */
private function is_latest_woocommerce_version() { private function should_current_version_be_logged() {
$latest_wc_version = $this->fetch_latest_woocommerce_version(); $new_version = get_site_transient( self::WC_NEW_VERSION_TRANSIENT );
if ( is_null( $latest_wc_version ) ) { if ( false === $new_version ) {
return false; $new_version = $this->fetch_new_woocommerce_version();
// Cache the new version for a week since we want to keep logging in with the same version for a while even if the new version is available.
set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS );
} }
return version_compare( WC()->version, $latest_wc_version, '>=' ); if ( ! is_string( $new_version ) || '' === $new_version ) {
// If the new version is not available, we consider the current version to be the latest.
return true;
}
// If the current version is the latest, we don't want to log errors.
return version_compare( WC()->version, $new_version, '>=' );
} }
/** /**
@ -316,45 +323,34 @@ class RemoteLogger extends \WC_Log_Handler {
} }
/** /**
* Fetch the latest WooCommerce version using the WordPress API and cache it. * Fetch the new version of WooCommerce from the WordPress API.
* *
* @return string|null * @return string|null New version if an update is available, null otherwise.
*/ */
private function fetch_latest_woocommerce_version() { private function fetch_new_woocommerce_version() {
$cached_version = get_transient( self::WC_LATEST_VERSION_TRANSIENT ); if ( ! function_exists( 'get_plugins' ) ) {
if ( $cached_version ) { require_once ABSPATH . 'wp-admin/includes/plugin.php';
return $cached_version; }
if ( ! function_exists( 'get_plugin_updates' ) ) {
require_once ABSPATH . 'wp-admin/includes/update.php';
} }
$retry_count = get_transient( self::FETCH_LATEST_VERSION_RETRY ); $plugin_updates = get_plugin_updates();
if ( false === $retry_count || ! is_numeric( $retry_count ) ) {
$retry_count = 0;
}
if ( $retry_count >= 3 ) { // Check if WooCommerce plugin update information is available.
if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) {
return null; return null;
} }
if ( ! function_exists( 'plugins_api' ) ) { $wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ];
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
}
// Fetch the latest version from the WordPress API.
$plugin_info = plugins_api( 'plugin_information', array( 'slug' => 'woocommerce' ) );
if ( is_wp_error( $plugin_info ) ) { // Ensure the update object exists and has the required information.
++$retry_count; if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) {
set_transient( self::FETCH_LATEST_VERSION_RETRY, $retry_count, HOUR_IN_SECONDS );
return null; return null;
} }
if ( ! empty( $plugin_info->version ) ) { $new_version = $wc_plugin_update->update->new_version;
$latest_version = $plugin_info->version; return is_string( $new_version ) ? $new_version : null;
set_transient( self::WC_LATEST_VERSION_TRANSIENT, $latest_version, WEEK_IN_SECONDS );
delete_transient( self::FETCH_LATEST_VERSION_RETRY );
return $latest_version;
}
return null;
} }
/** /**
@ -435,4 +431,52 @@ class RemoteLogger extends \WC_Log_Handler {
protected function is_dev_or_local_environment() { protected function is_dev_or_local_environment() {
return in_array( wp_get_environment_type(), array( 'development', 'local' ), true ); return in_array( wp_get_environment_type(), array( 'development', 'local' ), true );
} }
/**
* Sanitize the request URI to only allow certain query parameters.
*
* @param string $request_uri The request URI to sanitize.
* @return string The sanitized request URI.
*/
private function sanitize_request_uri( $request_uri ) {
$default_whitelist = array( 'path', 'page', 'step', 'task', 'tab' );
/**
* Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging.
*
* @since 9.4.0
*
* @param string $default_whitelist The default whitelist of query parameters.
*/
$whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist );
$parsed_url = wp_parse_url( $request_uri );
if ( ! isset( $parsed_url['query'] ) ) {
return $request_uri;
}
parse_str( $parsed_url['query'], $query_params );
foreach ( $query_params as $key => &$value ) {
if ( ! in_array( $key, $whitelist, true ) ) {
$value = 'xxxxxx';
}
}
$parsed_url['query'] = http_build_query( $query_params );
return $this->build_url( $parsed_url );
}
/**
* Build a URL from its parsed components.
*
* @param array $parsed_url The parsed URL components.
* @return string The built URL.
*/
private function build_url( $parsed_url ) {
$path = $parsed_url['path'] ?? '';
$query = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : '';
$fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : '';
return "$path$query$fragment";
}
} }

View File

@ -60,9 +60,8 @@ trait DraftOrderTrait {
return true; return true;
} }
// Failed orders and those needing payment can be retried if the cart hasn't changed. // Pending and failed orders can be retried if the cart hasn't changed.
// Pending orders are excluded from this check since they may be awaiting an update from the payment processor. if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
if ( $order_object->needs_payment() && ! $order_object->has_status( 'pending' ) && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
return true; return true;
} }

View File

@ -282,3 +282,119 @@ test(
} ); } );
} }
); );
test(
'can decrease the sale price if the product was not previously in sale when bulk editing products',
{ tag: [ '@gutenberg', '@services' ] },
async ( { page, products } ) => {
await page.goto( `wp-admin/edit.php?post_type=product` );
const salePriceDecrease = 10;
await test.step( 'Update products with the "Sale > Decrease existing sale price" option', async () => {
await page.goto( `wp-admin/edit.php?post_type=product` );
for ( const product of products ) {
await page.getByLabel( `Select ${ product.name }` ).click();
}
await page
.locator( '#bulk-action-selector-top' )
.selectOption( 'Edit' );
await page.locator( '#doaction' ).click();
await page
.locator( 'select[name="change_sale_price"]' )
.selectOption(
'Decrease existing sale price by (fixed amount or %):'
);
await page
.getByPlaceholder( 'Enter sale price ($)' )
.fill( `${ salePriceDecrease }%` );
await page.getByRole( 'button', { name: 'Update' } ).click();
} );
await test.step( 'Verify products have a sale price', async () => {
for ( const product of products ) {
await page.goto( `product/${ product.slug }` );
const expectedSalePrice = (
product.regular_price *
( 1 - salePriceDecrease / 100 )
).toFixed( 2 );
await expect
.soft(
await page
.locator( 'ins' )
.getByText( `$${ expectedSalePrice }` )
.count()
)
.toBeGreaterThan( 0 );
}
} );
}
);
test(
'increasing the sale price from 0 does not change the sale price when bulk editing products',
{ tag: [ '@gutenberg', '@services' ] },
async ( { page, api } ) => {
let product;
await api
.post( 'products', {
id: 0,
name: `Product _${ Date.now() }`,
type: 'simple',
regular_price: '100',
sale_price: '0',
manage_stock: true,
stock_quantity: 10,
stock_status: 'instock',
} )
.then( ( response ) => {
product = response.data;
} );
const salePriceIncrease = 10;
await test.step( 'Update products with the "Sale > Increase existing sale price" option', async () => {
await page.goto( `wp-admin/edit.php?post_type=product` );
await page.getByLabel( `Select ${ product.name }` ).click();
await page
.locator( '#bulk-action-selector-top' )
.selectOption( 'Edit' );
await page.locator( '#doaction' ).click();
await page
.locator( 'select[name="change_sale_price"]' )
.selectOption(
'Increase existing sale price by (fixed amount or %):'
);
await page
.getByPlaceholder( 'Enter sale price ($)' )
.fill( `${ salePriceIncrease }%` );
await page.getByRole( 'button', { name: 'Update' } ).click();
} );
await test.step( 'Verify products have a sale price', async () => {
await page.goto( `product/${ product.slug }` );
const expectedSalePrice = '$0.00';
await expect
.soft(
await page
.locator( 'ins' )
.getByText( expectedSalePrice )
.count()
)
.toBeGreaterThan( 0 );
} );
}
);

View File

@ -27,4 +27,22 @@ class WCRestFunctionsTest extends WC_Unit_Test_Case {
$this->assertFalse( wc_rest_should_load_namespace( 'wc-analytics', 'wc/v2' ) ); $this->assertFalse( wc_rest_should_load_namespace( 'wc-analytics', 'wc/v2' ) );
$this->assertTrue( wc_rest_should_load_namespace( 'wc/v2', 'wc/v2' ) ); $this->assertTrue( wc_rest_should_load_namespace( 'wc/v2', 'wc/v2' ) );
} }
/**
* @testDox Test wc_rest_should_load_namespace known works with preload.
*/
public function test_wc_rest_should_load_namespace_known_works_with_preload() {
$memo = rest_preload_api_request( array(), '/wc/store/v1/cart' );
$this->assertArrayHasKey( '/wc/store/v1/cart', $memo );
}
/**
* @testDox Test wc_rest_should_load_namespace filter.
*/
public function test_wc_rest_should_load_namespace_filter() {
$this->assertFalse( wc_rest_should_load_namespace( 'wc/v1', 'wc/v2' ) );
add_filter( 'wc_rest_should_load_namespace', '__return_true' );
$this->assertTrue( wc_rest_should_load_namespace( 'wc/v1', 'wc/v2' ) );
remove_filter( 'wc_rest_should_load_namespace', '__return_true' );
}
} }

File diff suppressed because it is too large Load Diff