Update lockfile

This commit is contained in:
Sam Seay 2024-09-02 11:17:30 +08:00
commit e01c3749d9
No known key found for this signature in database
GPG Key ID: 2223711A9151668A
49 changed files with 2244 additions and 400 deletions

View File

@ -201,7 +201,9 @@
"@wordpress/interface",
"@wordpress/router",
"@wordpress/edit-site",
"@wordpress/private-apis"
"@wordpress/private-apis",
"@wordpress/dataviews",
"@wordpress/icons"
],
"packages": [
"@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/core-data": "wp-6.0",
"@wordpress/data": "wp-6.0",
"@wordpress/dataviews": "^4.2.0",
"@wordpress/date": "wp-6.0",
"@wordpress/deprecated": "wp-6.0",
"@wordpress/edit-post": "wp-6.0",
@ -64,7 +65,7 @@
"@wordpress/hooks": "wp-6.0",
"@wordpress/html-entities": "wp-6.0",
"@wordpress/i18n": "wp-6.0",
"@wordpress/icons": "wp-6.0",
"@wordpress/icons": "10.6.0",
"@wordpress/interface": "wp-6.0",
"@wordpress/keyboard-shortcuts": "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
*/
import { unlock } from '../lock-unlock';
import useLayoutAreas from './router';
import { Layout } from './layout';
const { RouterProvider } = unlock( routerPrivateApis );
const { GlobalStylesProvider } = unlock( editorPrivateApis );
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() {

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 {
display: none;
}
.woocommerce_page_woocommerce-products-dashboard #wpcontent {
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 reset;
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;
hasResolved: boolean;
};
const store: string;
}
declare module '@wordpress/keyboard-shortcuts' {
function useShortcut(
@ -43,3 +44,24 @@ declare module '@wordpress/keyboard-shortcuts' {
declare module '@wordpress/router' {
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' ],
alias: {
'~': path.resolve( __dirname + '/client' ),
'react/jsx-dev-runtime': require.resolve( 'react/jsx-dev-runtime' ),
'react/jsx-runtime': require.resolve( 'react/jsx-runtime' ),
},
},
plugins: [
@ -240,6 +242,10 @@ const webpackConfig = {
return null;
}
if ( request.startsWith( '@wordpress/dataviews' ) ) {
return null;
}
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.
// 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_allow_tracking', 'yes' );
update_option( 'woocommerce_remote_variant_assignment', 1 );
set_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT, WC()->version );
} else {
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

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

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: patch
Type: tweak
Comment: This reverts an existing PR.

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: minor
Type: fix
Fix variation selector display issues on the front end #51023

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: patch
Type: enhancement
Enhance WooCommerce version checking for remote logging reliability

View File

@ -445,6 +445,18 @@ p.demo_store,
min-width: 75%;
display: inline-block;
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 {

View File

@ -896,7 +896,8 @@ class WC_Admin_Post_Types {
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;
$change_price = absint( $request_data[ "change_{$price_type}_price" ] );

View File

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

View File

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

View File

@ -10,6 +10,8 @@ use WpOrg\Requests\Requests;
/**
* Class Connection
*
* @internal
*/
class Connection {
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.
*
* Process images for content
*
* @internal
*/
class ContentProcessor {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,11 +20,10 @@ use WC_Log_Levels;
* @package WooCommerce\Classes
*/
class RemoteLogger extends \WC_Log_Handler {
const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash';
const RATE_LIMIT_ID = 'woocommerce_remote_logging';
const RATE_LIMIT_DELAY = 60; // 1 minute.
const WC_LATEST_VERSION_TRANSIENT = 'latest_woocommerce_version';
const FETCH_LATEST_VERSION_RETRY = 'fetch_latest_woocommerce_version_retry';
const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash';
const RATE_LIMIT_ID = 'woocommerce_remote_logging';
const RATE_LIMIT_DELAY = 60; // 1 minute.
const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';
/**
* Handle a log entry.
@ -150,7 +149,7 @@ class RemoteLogger extends \WC_Log_Handler {
return false;
}
if ( ! $this->is_latest_woocommerce_version() ) {
if ( ! $this->should_current_version_be_logged() ) {
return false;
}
@ -221,7 +220,7 @@ class RemoteLogger extends \WC_Log_Handler {
self::LOG_ENDPOINT,
array(
'body' => wp_json_encode( $body ),
'timeout' => 2,
'timeout' => 3,
'headers' => array(
'Content-Type' => 'application/json',
),
@ -256,14 +255,22 @@ class RemoteLogger extends \WC_Log_Handler {
*
* @return bool
*/
private function is_latest_woocommerce_version() {
$latest_wc_version = $this->fetch_latest_woocommerce_version();
private function should_current_version_be_logged() {
$new_version = get_site_transient( self::WC_NEW_VERSION_TRANSIENT );
if ( is_null( $latest_wc_version ) ) {
return false;
if ( false === $new_version ) {
$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() {
$cached_version = get_transient( self::WC_LATEST_VERSION_TRANSIENT );
if ( $cached_version ) {
return $cached_version;
private function fetch_new_woocommerce_version() {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
if ( ! function_exists( 'get_plugin_updates' ) ) {
require_once ABSPATH . 'wp-admin/includes/update.php';
}
$retry_count = get_transient( self::FETCH_LATEST_VERSION_RETRY );
if ( false === $retry_count || ! is_numeric( $retry_count ) ) {
$retry_count = 0;
}
$plugin_updates = get_plugin_updates();
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;
}
if ( ! function_exists( 'plugins_api' ) ) {
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' ) );
$wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ];
if ( is_wp_error( $plugin_info ) ) {
++$retry_count;
set_transient( self::FETCH_LATEST_VERSION_RETRY, $retry_count, HOUR_IN_SECONDS );
// Ensure the update object exists and has the required information.
if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) {
return null;
}
if ( ! empty( $plugin_info->version ) ) {
$latest_version = $plugin_info->version;
set_transient( self::WC_LATEST_VERSION_TRANSIENT, $latest_version, WEEK_IN_SECONDS );
delete_transient( self::FETCH_LATEST_VERSION_RETRY );
return $latest_version;
}
return null;
$new_version = $wc_plugin_update->update->new_version;
return is_string( $new_version ) ? $new_version : null;
}
/**

View File

@ -60,9 +60,8 @@ trait DraftOrderTrait {
return true;
}
// Failed orders and those needing payment 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_status( 'pending' ) && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
// Pending and failed orders can be retried if the cart hasn't changed.
if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
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

@ -35,8 +35,7 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
public function tearDown(): void {
$this->cleanup_filters();
delete_option( 'woocommerce_feature_remote_logging_enabled' );
delete_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT );
delete_transient( RemoteLogger::FETCH_LATEST_VERSION_RETRY );
delete_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT );
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_rate_limits" );
WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP );
@ -56,6 +55,7 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
'plugins_api',
'pre_http_request',
'woocommerce_remote_logger_formatted_log_data',
'pre_site_transient_update_plugins',
);
foreach ( $filters as $filter ) {
remove_all_filters( $filter );
@ -90,18 +90,23 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
*/
public function remote_logging_disallowed_provider() {
return array(
'feature flag disabled' => array(
'feature flag disabled' => array(
'condition' => 'feature flag disabled',
'setup' => fn() => update_option( 'woocommerce_feature_remote_logging_enabled', 'no' ),
),
'tracking opted out' => array(
'tracking opted out' => array(
'condition' => 'tracking opted out',
'setup' => fn() => add_filter( 'option_woocommerce_allow_tracking', fn() => 'no' ),
),
'outdated version' => array(
'condition' => 'outdated version',
'setup' => function () {
'high variant assignment' => array(
'condition' => 'high variant assignment',
'setup' => fn() => add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 15 ),
),
'outdated version' => array(
'condition' => 'outdated version',
'setup' => function () {
$version = WC()->version;
// Next major version. (e.g. 9.0.1 -> 10.0.0).
$next_version = implode(
'.',
array_map(
@ -112,28 +117,79 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
array_keys( explode( '.', $version ) )
)
);
set_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT, $next_version );
set_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT, $next_version, WEEK_IN_SECONDS );
},
'high variant assignment' => array(
'condition' => 'high variant assignment',
'setup' => fn() => add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 15 ),
),
),
);
}
/**
* @testdox Fetch latest WooCommerce version retries on API failure
*/
public function test_fetch_latest_woocommerce_version_retry() {
$this->setup_remote_logging_conditions( true );
add_filter( 'plugins_api', fn() => new \WP_Error(), 10, 3 );
for ( $i = 1; $i <= 4; $i++ ) {
$this->sut->is_remote_logging_allowed();
$retry_count = get_transient( RemoteLogger::FETCH_LATEST_VERSION_RETRY );
$this->assertEquals( min( $i, 3 ), $retry_count );
/**
* @testdox should_current_version_be_logged method behaves correctly
* @dataProvider should_current_version_be_logged_provider
*
* @param string $current_version The current WooCommerce version.
* @param string $new_version The new WooCommerce version.
* @param string $transient_value The value of the transient.
* @param bool $expected The expected result.
*/
public function test_should_current_version_be_logged( $current_version, $new_version, $transient_value, $expected ) {
$wc_version = WC()->version;
WC()->version = $current_version;
// Set up the transient.
if ( null !== $transient_value ) {
set_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT, $transient_value, WEEK_IN_SECONDS );
} else {
delete_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT );
$this->setup_mock_plugin_updates( $new_version );
}
$result = $this->invoke_private_method( $this->sut, 'should_current_version_be_logged', array() );
$this->assertEquals( $expected, $result );
// Clean up.
delete_site_transient( RemoteLogger::WC_NEW_VERSION_TRANSIENT );
WC()->version = $wc_version;
}
/**
* Data provider for test_should_current_version_be_logged.
*/
public function should_current_version_be_logged_provider() {
return array(
'current version is latest (transient set)' => array( '9.2.0', '9.2.0', '9.2.0', true ),
'current version is newer (transient set)' => array( '9.3.0', '9.2.0', '9.2.0', true ),
'current version is older (transient set)' => array( '9.1.0', '9.2.0', '9.2.0', false ),
'new version is null (transient set)' => array( '9.2.0', null, null, true ),
'transient not set, current version is latest' => array( '9.2.0', '9.2.0', null, true ),
'transient not set, current version is newer' => array( '9.3.0', '9.2.0', null, true ),
'transient not set, current version is older' => array( '9.1.0', '9.2.0', null, false ),
'transient not set, new version is null' => array( '9.2.0', null, null, true ),
);
}
/**
* @testdox fetch_new_woocommerce_version method returns correct version
*/
public function test_fetch_new_woocommerce_version() {
$this->setup_mock_plugin_updates( '9.3.0' );
$result = $this->invoke_private_method( $this->sut, 'fetch_new_woocommerce_version', array() );
$this->assertEquals( '9.3.0', $result, 'The result should be the latest version when an update is available.' );
}
/**
* @testdox fetch_new_woocommerce_version method returns null when no update is available
*/
public function test_fetch_new_woocommerce_version_no_update() {
add_filter( 'pre_site_transient_update_plugins', fn() => array() );
$result = $this->invoke_private_method( $this->sut, 'fetch_new_woocommerce_version', array() );
$this->assertNull( $result, 'The result should be null when no update is available.' );
}
/**
@ -421,17 +477,26 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
update_option( 'woocommerce_feature_remote_logging_enabled', $enabled ? 'yes' : 'no' );
add_filter( 'option_woocommerce_allow_tracking', fn() => 'yes' );
add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 5 );
add_filter(
'plugins_api',
function ( $result, $action, $args ) use ( $enabled ) {
if ( 'plugin_information' === $action && 'woocommerce' === $args->slug ) {
return (object) array( 'version' => $enabled ? WC()->version : '9.0.0' );
}
return $result;
},
10,
3
$this->setup_mock_plugin_updates( $enabled ? WC()->version : '9.0.0' );
}
/**
* Set up mock plugin updates.
*
* @param string $new_version The new version of WooCommerce to simulate.
*/
private function setup_mock_plugin_updates( $new_version ) {
$update_plugins = (object) array(
'response' => array(
WC_PLUGIN_BASENAME => (object) array(
'new_version' => $new_version,
'package' => 'https://downloads.wordpress.org/plugin/woocommerce.zip',
'slug' => 'woocommerce',
),
),
);
add_filter( 'pre_site_transient_update_plugins', fn() => $update_plugins );
}
/**

File diff suppressed because it is too large Load Diff