Merge branch 'trunk' into issue-37835

This commit is contained in:
Jason Kytros 2023-09-01 12:02:59 +03:00
commit 16819d18ac
108 changed files with 2983 additions and 1004 deletions

View File

@ -1,5 +1,11 @@
== Changelog ==
= 8.0.3 2023-08-29 =
* Update - Bump WooCommerce Blocks to 10.6.6. [#39853](https://github.com/woocommerce/woocommerce/pull/39853)
* Fix - Avoid extra queries when a WooPayments incentive has been dismissed. [#39882](https://github.com/woocommerce/woocommerce/pull/39882)
= 8.0.2 2023-08-15 =
* Fix - Fix an issue which was causing some attributes to default to a minimum length of 3. [#39686](https://github.com/woocommerce/woocommerce/pull/39686)

View File

@ -1,3 +1,5 @@
# Adding actions and filters
Like many WordPress plugins, WooCommerce provides a range of actions and filters through which developers can extend and modify the platform.
Often, when writing new code or revising existing code, there is a desire to add new hooks—but this should always be done with thoughtfulness and care. This document aims to provide high-level guidance on the matter.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Refactor Pagination component and split out into multiple re-usable components. Also added a `usePagination` hook.

View File

@ -27,7 +27,7 @@ export { MediaUploader } from './media-uploader';
export { default as MenuItem } from './ellipsis-menu/menu-item';
export { default as MenuTitle } from './ellipsis-menu/menu-title';
export { default as OrderStatus } from './order-status';
export { default as Pagination } from './pagination';
export * from './pagination';
export { default as Pill } from './pill';
export { default as Plugins } from './plugins';
export { default as ProductImage } from './product-image';

View File

@ -1,9 +1,15 @@
Pagination
===
# Pagination
Use `Pagination` to allow navigation between pages that represent a collection of items.
The component allows for selecting a new page and items per page options.
You can also make use of the `usePagination` hook and the custom components:
- `PaginationPageArrowsWithPicker`
- `PaginationPageArrows`
- `PaginationPagePicker`
- `PaginationPageSizePicker`
## Usage
```jsx
@ -16,16 +22,41 @@ The component allows for selecting a new page and items per page options.
/>
```
## Custom Example
```jsx
const paginationProps = usePagination( {
totalCount: 200,
defaultPerPage: 25,
} );
return (
<div>
<div>
Viewing { paginationProps.start }-{ paginationProps.end } of 200
items
</div>
<PaginationPageArrowsWithPicker { ...paginationProps } />
<PaginationPageSizePicker
{ ...paginationProps }
total={ 200 }
perPageOptions={ [ 5, 10, 25 ] }
label=""
/>
</div>
);
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`page` | Number | `null` | (required) The current page of the collection
`onPageChange` | Function | `noop` | A function to execute when the page is changed
`perPage` | Number | `null` | (required) The amount of results that are being displayed per page
`onPerPageChange` | Function | `noop` | A function to execute when the per page option is changed
`total` | Number | `null` | (required) The total number of results
`className` | String | `null` | Additional classNames
`showPagePicker` | Boolean | `true` | Whether the page picker should be shown.
`showPerPagePicker` | Boolean | `true` | Whether the per page picker should shown.
`showPageArrowsLabel` | Boolean | `true` | Whether the page arrows label should be shown.
| Name | Type | Default | Description |
| --------------------- | -------- | ------- | ------------------------------------------------------------------ |
| `page` | Number | `null` | (required) The current page of the collection |
| `onPageChange` | Function | `noop` | A function to execute when the page is changed |
| `perPage` | Number | `null` | (required) The amount of results that are being displayed per page |
| `onPerPageChange` | Function | `noop` | A function to execute when the per page option is changed |
| `total` | Number | `null` | (required) The total number of results |
| `className` | String | `null` | Additional classNames |
| `showPagePicker` | Boolean | `true` | Whether the page picker should be shown. |
| `showPerPagePicker` | Boolean | `true` | Whether the per page picker should shown. |
| `showPageArrowsLabel` | Boolean | `true` | Whether the page arrows label should be shown. |

View File

@ -1,269 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { createElement, Component } from '@wordpress/element';
import { Button, SelectControl } from '@wordpress/components';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { noop, uniqueId } from 'lodash';
import { Icon, chevronLeft, chevronRight } from '@wordpress/icons';
const PER_PAGE_OPTIONS = [ 25, 50, 75, 100 ];
/**
* Use `Pagination` to allow navigation between pages that represent a collection of items.
* The component allows for selecting a new page and items per page options.
*/
class Pagination extends Component {
constructor( props ) {
super( props );
this.state = {
inputValue: this.props.page,
};
this.previousPage = this.previousPage.bind( this );
this.nextPage = this.nextPage.bind( this );
this.onInputChange = this.onInputChange.bind( this );
this.onInputBlur = this.onInputBlur.bind( this );
this.perPageChange = this.perPageChange.bind( this );
this.selectInputValue = this.selectInputValue.bind( this );
}
previousPage( event ) {
event.stopPropagation();
const { page, onPageChange } = this.props;
if ( page - 1 < 1 ) {
return;
}
onPageChange( page - 1, 'previous' );
}
nextPage( event ) {
event.stopPropagation();
const { page, onPageChange } = this.props;
if ( page + 1 > this.pageCount ) {
return;
}
onPageChange( page + 1, 'next' );
}
perPageChange( perPage ) {
const { onPerPageChange, onPageChange, total, page } = this.props;
onPerPageChange( parseInt( perPage, 10 ) );
const newMaxPage = Math.ceil( total / parseInt( perPage, 10 ) );
if ( page > newMaxPage ) {
onPageChange( newMaxPage );
}
}
onInputChange( event ) {
this.setState( {
inputValue: event.target.value,
} );
}
onInputBlur( event ) {
const { onPageChange, page } = this.props;
const newPage = parseInt( event.target.value, 10 );
if (
newPage !== page &&
Number.isFinite( newPage ) &&
newPage > 0 &&
this.pageCount &&
this.pageCount >= newPage
) {
onPageChange( newPage, 'goto' );
}
}
selectInputValue( event ) {
event.target.select();
}
renderPageArrows() {
const { page, showPageArrowsLabel } = this.props;
if ( this.pageCount <= 1 ) {
return null;
}
const previousLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': page > 1,
} );
const nextLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': page < this.pageCount,
} );
return (
<div className="woocommerce-pagination__page-arrows">
{ showPageArrowsLabel && (
<span
className="woocommerce-pagination__page-arrows-label"
role="status"
aria-live="polite"
>
{ sprintf(
__( 'Page %d of %d', 'woocommerce' ),
page,
this.pageCount
) }
</span>
) }
<div className="woocommerce-pagination__page-arrows-buttons">
<Button
className={ previousLinkClass }
disabled={ ! ( page > 1 ) }
onClick={ this.previousPage }
label={ __( 'Previous Page', 'woocommerce' ) }
>
<Icon icon={ chevronLeft } />
</Button>
<Button
className={ nextLinkClass }
disabled={ ! ( page < this.pageCount ) }
onClick={ this.nextPage }
label={ __( 'Next Page', 'woocommerce' ) }
>
<Icon icon={ chevronRight } />
</Button>
</div>
</div>
);
}
renderPagePicker() {
const { page } = this.props;
const { inputValue } = this.state;
const isError = page < 1 || page > this.pageCount;
const inputClass = classNames(
'woocommerce-pagination__page-picker-input',
{
'has-error': isError,
}
);
const instanceId = uniqueId( 'woocommerce-pagination-page-picker-' );
return (
<div className="woocommerce-pagination__page-picker">
<label
htmlFor={ instanceId }
className="woocommerce-pagination__page-picker-label"
>
{ __( 'Go to page', 'woocommerce' ) }
<input
id={ instanceId }
className={ inputClass }
aria-invalid={ isError }
type="number"
onClick={ this.selectInputValue }
onChange={ this.onInputChange }
onBlur={ this.onInputBlur }
value={ inputValue }
min={ 1 }
max={ this.pageCount }
/>
</label>
</div>
);
}
renderPerPagePicker() {
// @todo Replace this with a styleized Select drop-down/control?
const pickerOptions = PER_PAGE_OPTIONS.map( ( option ) => {
return { value: option, label: option };
} );
return (
<div className="woocommerce-pagination__per-page-picker">
<SelectControl
label={ __( 'Rows per page', 'woocommerce' ) }
labelPosition="side"
value={ this.props.perPage }
onChange={ this.perPageChange }
options={ pickerOptions }
/>
</div>
);
}
render() {
const { total, perPage, className, showPagePicker, showPerPagePicker } =
this.props;
this.pageCount = Math.ceil( total / perPage );
const classes = classNames( 'woocommerce-pagination', className );
if ( this.pageCount <= 1 ) {
return (
( total > PER_PAGE_OPTIONS[ 0 ] && (
<div className={ classes }>
{ this.renderPerPagePicker() }
</div>
) ) ||
null
);
}
return (
<div className={ classes }>
{ this.renderPageArrows() }
{ showPagePicker && this.renderPagePicker() }
{ showPerPagePicker && this.renderPerPagePicker() }
</div>
);
}
}
Pagination.propTypes = {
/**
* The current page of the collection.
*/
page: PropTypes.number.isRequired,
/**
* A function to execute when the page is changed.
*/
onPageChange: PropTypes.func,
/**
* The amount of results that are being displayed per page.
*/
perPage: PropTypes.number.isRequired,
/**
* A function to execute when the per page option is changed.
*/
onPerPageChange: PropTypes.func,
/**
* The total number of results.
*/
total: PropTypes.number.isRequired,
/**
* Additional classNames.
*/
className: PropTypes.string,
/**
* Whether the page picker should be rendered.
*/
showPagePicker: PropTypes.bool,
/**
* Whether the perPage picker should be rendered.
*/
showPerPagePicker: PropTypes.bool,
/**
* Whether the page arrows label should be rendered.
*/
showPageArrowsLabel: PropTypes.bool,
};
Pagination.defaultProps = {
onPageChange: noop,
onPerPageChange: noop,
showPagePicker: true,
showPerPagePicker: true,
showPageArrowsLabel: true,
};
export default Pagination;

View File

@ -0,0 +1,6 @@
export * from './pagination';
export { PageArrows as PaginationPageArrows } from './page-arrows';
export { PagePicker as PaginationPagePicker } from './page-picker';
export { PageSizePicker as PaginationPageSizePicker } from './page-size-picker';
export { PageArrowsWithPicker as PaginationPageArrowsWithPicker } from './page-arrows-with-picker';
export * from './use-pagination';

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { createElement, useEffect, useState } from '@wordpress/element';
import { chevronLeft, chevronRight } from '@wordpress/icons';
import { sprintf, __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { uniqueId } from 'lodash';
type PageArrowsWithPickerProps = {
currentPage: number;
pageCount: number;
setCurrentPage: (
page: number,
action?: 'previous' | 'next' | 'goto'
) => void;
};
export function PageArrowsWithPicker( {
pageCount,
currentPage,
setCurrentPage,
}: PageArrowsWithPickerProps ) {
const [ inputValue, setInputValue ] = useState( currentPage );
useEffect( () => {
if ( currentPage !== inputValue ) {
setInputValue( currentPage );
}
}, [ currentPage ] );
function onInputChange( event: React.FormEvent< HTMLInputElement > ) {
setInputValue( parseInt( event.currentTarget.value, 10 ) );
}
function onInputBlur( event: React.FocusEvent< HTMLInputElement > ) {
const newPage = parseInt( event.target.value, 10 );
if (
newPage !== currentPage &&
Number.isFinite( newPage ) &&
newPage > 0 &&
pageCount &&
pageCount >= newPage
) {
setCurrentPage( newPage, 'goto' );
} else {
setInputValue( currentPage );
}
}
function previousPage( event: React.MouseEvent ) {
event.stopPropagation();
if ( currentPage - 1 < 1 ) {
return;
}
setInputValue( currentPage - 1 );
setCurrentPage( currentPage - 1, 'previous' );
}
function nextPage( event: React.MouseEvent ) {
event.stopPropagation();
if ( currentPage + 1 > pageCount ) {
return;
}
setInputValue( currentPage + 1 );
setCurrentPage( currentPage + 1, 'next' );
}
if ( pageCount <= 1 ) {
return null;
}
const previousLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': currentPage > 1,
} );
const nextLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': currentPage < pageCount,
} );
const isError = currentPage < 1 || currentPage > pageCount;
const inputClass = classNames(
'woocommerce-pagination__page-arrow-picker-input',
{
'has-error': isError,
}
);
const instanceId = uniqueId( 'woocommerce-pagination-page-picker-' );
return (
<div className="woocommerce-pagination__page-arrows">
<Button
className={ previousLinkClass }
icon={ chevronLeft }
disabled={ ! ( currentPage > 1 ) }
onClick={ previousPage }
label={ __( 'Previous Page', 'woocommerce' ) }
/>
<input
id={ instanceId }
className={ inputClass }
aria-invalid={ isError }
type="number"
onChange={ onInputChange }
onBlur={ onInputBlur }
value={ inputValue }
min={ 1 }
max={ pageCount }
/>
{ sprintf( __( 'of %d', 'woocommerce' ), pageCount ) }
<Button
className={ nextLinkClass }
icon={ chevronRight }
disabled={ ! ( currentPage < pageCount ) }
onClick={ nextPage }
label={ __( 'Next Page', 'woocommerce' ) }
/>
</div>
);
}

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { Button, Icon } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { chevronLeft, chevronRight } from '@wordpress/icons';
import { sprintf, __ } from '@wordpress/i18n';
import classNames from 'classnames';
type PageArrowsProps = {
currentPage: number;
pageCount: number;
showPageArrowsLabel?: boolean;
setCurrentPage: (
page: number,
action?: 'previous' | 'next' | 'goto'
) => void;
};
export function PageArrows( {
pageCount,
currentPage,
showPageArrowsLabel = true,
setCurrentPage,
}: PageArrowsProps ) {
function previousPage( event: React.MouseEvent ) {
event.stopPropagation();
if ( currentPage - 1 < 1 ) {
return;
}
setCurrentPage( currentPage - 1, 'previous' );
}
function nextPage( event: React.MouseEvent ) {
event.stopPropagation();
if ( currentPage + 1 > pageCount ) {
return;
}
setCurrentPage( currentPage + 1, 'next' );
}
if ( pageCount <= 1 ) {
return null;
}
const previousLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': currentPage > 1,
} );
const nextLinkClass = classNames( 'woocommerce-pagination__link', {
'is-active': currentPage < pageCount,
} );
return (
<div className="woocommerce-pagination__page-arrows">
{ showPageArrowsLabel && (
<span
className="woocommerce-pagination__page-arrows-label"
role="status"
aria-live="polite"
>
{ sprintf(
__( 'Page %d of %d', 'woocommerce' ),
currentPage,
pageCount
) }
</span>
) }
<div className="woocommerce-pagination__page-arrows-buttons">
<Button
className={ previousLinkClass }
disabled={ ! ( currentPage > 1 ) }
onClick={ previousPage }
label={ __( 'Previous Page', 'woocommerce' ) }
>
<Icon icon={ chevronLeft } />
</Button>
<Button
className={ nextLinkClass }
disabled={ ! ( currentPage < pageCount ) }
onClick={ nextPage }
label={ __( 'Next Page', 'woocommerce' ) }
>
<Icon icon={ chevronRight } />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
/**
* External dependencies
*/
import { createElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { uniqueId } from 'lodash';
type PagePickerProps = {
currentPage: number;
pageCount: number;
setCurrentPage: (
page: number,
action?: 'previous' | 'next' | 'goto'
) => void;
};
export function PagePicker( {
pageCount,
currentPage,
setCurrentPage,
}: PagePickerProps ) {
const [ inputValue, setInputValue ] = useState( currentPage );
function onInputChange( event: React.FormEvent< HTMLInputElement > ) {
setInputValue( parseInt( event.currentTarget.value, 10 ) );
}
function onInputBlur( event: React.FocusEvent< HTMLInputElement > ) {
const newPage = parseInt( event.target.value, 10 );
if (
newPage !== currentPage &&
Number.isFinite( newPage ) &&
newPage > 0 &&
pageCount &&
pageCount >= newPage
) {
setCurrentPage( newPage, 'goto' );
}
}
function selectInputValue( event: React.MouseEvent< HTMLInputElement > ) {
event.currentTarget.select();
}
const isError = currentPage < 1 || currentPage > pageCount;
const inputClass = classNames(
'woocommerce-pagination__page-picker-input',
{
'has-error': isError,
}
);
const instanceId = uniqueId( 'woocommerce-pagination-page-picker-' );
return (
<div className="woocommerce-pagination__page-picker">
<label
htmlFor={ instanceId }
className="woocommerce-pagination__page-picker-label"
>
{ __( 'Go to page', 'woocommerce' ) }
<input
id={ instanceId }
className={ inputClass }
aria-invalid={ isError }
type="number"
onClick={ selectInputValue }
onChange={ onInputChange }
onBlur={ onInputBlur }
value={ inputValue }
min={ 1 }
max={ pageCount }
/>
</label>
</div>
);
}

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { SelectControl } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
export const DEFAULT_PER_PAGE_OPTIONS = [ 25, 50, 75, 100 ];
type PageSizePickerProps = {
currentPage: number;
perPage: number;
total: number;
setCurrentPage: (
page: number,
action?: 'previous' | 'next' | 'goto'
) => void;
setPerPageChange?: ( perPage: number ) => void;
perPageOptions?: number[];
label?: string;
};
export function PageSizePicker( {
perPage,
currentPage,
total,
setCurrentPage,
setPerPageChange = () => {},
perPageOptions = DEFAULT_PER_PAGE_OPTIONS,
label = __( 'Rows per page', 'woocommerce' ),
}: PageSizePickerProps ) {
function perPageChange( newPerPage: string ) {
setPerPageChange( parseInt( newPerPage, 10 ) );
const newMaxPage = Math.ceil( total / parseInt( newPerPage, 10 ) );
if ( currentPage > newMaxPage ) {
setCurrentPage( newMaxPage );
}
}
// @todo Replace this with a styleized Select drop-down/control?
const pickerOptions = perPageOptions.map( ( option ) => {
return { value: option.toString(), label: option.toString() };
} );
return (
<div className="woocommerce-pagination__per-page-picker">
<SelectControl
label={ label }
// @ts-expect-error outdated types file.
labelPosition="side"
value={ perPage.toString() }
onChange={ perPageChange }
options={ pickerOptions }
/>
</div>
);
}

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { PageArrows } from './page-arrows';
import { PagePicker } from './page-picker';
import { DEFAULT_PER_PAGE_OPTIONS, PageSizePicker } from './page-size-picker';
export type PaginationProps = {
page: number;
perPage: number;
total: number;
onPageChange?: (
page: number,
action?: 'previous' | 'next' | 'goto'
) => void;
onPerPageChange?: ( perPage: number ) => void;
className?: string;
showPagePicker?: boolean;
showPerPagePicker?: boolean;
showPageArrowsLabel?: boolean;
perPageOptions?: number[];
children?: ( props: { pageCount: number } ) => JSX.Element;
};
export function Pagination( {
page,
onPageChange = () => {},
total,
perPage,
onPerPageChange = () => {},
showPagePicker = true,
showPerPagePicker = true,
showPageArrowsLabel = true,
className,
perPageOptions = DEFAULT_PER_PAGE_OPTIONS,
children,
}: PaginationProps ): JSX.Element | null {
const pageCount = Math.ceil( total / perPage );
if ( children && typeof children === 'function' ) {
return children( {
pageCount,
} );
}
const classes = classNames( 'woocommerce-pagination', className );
if ( pageCount <= 1 ) {
return (
( total > perPageOptions[ 0 ] && (
<div className={ classes }>
<PageSizePicker
currentPage={ page }
perPage={ perPage }
setCurrentPage={ onPageChange }
total={ total }
setPerPageChange={ onPerPageChange }
perPageOptions={ perPageOptions }
/>
</div>
) ) ||
null
);
}
return (
<div className={ classes }>
<PageArrows
currentPage={ page }
pageCount={ pageCount }
showPageArrowsLabel={ showPageArrowsLabel }
setCurrentPage={ onPageChange }
/>
{ showPagePicker && (
<PagePicker
currentPage={ page }
pageCount={ pageCount }
setCurrentPage={ onPageChange }
/>
) }
{ showPerPagePicker && (
<PageSizePicker
currentPage={ page }
perPage={ perPage }
setCurrentPage={ onPageChange }
total={ total }
setPerPageChange={ onPerPageChange }
perPageOptions={ perPageOptions }
/>
) }
</div>
);
}

View File

@ -7,7 +7,12 @@ import { createElement, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import Pagination from '../';
import {
Pagination,
PaginationPageArrowsWithPicker,
usePagination,
PaginationPageSizePicker,
} from '../';
export default {
title: 'WooCommerce Admin/components/Pagination',
@ -18,6 +23,10 @@ export default {
showPerPagePicker: true,
showPageArrowsLabel: true,
},
argTypes: {
onPageChange: { action: 'onPageChange' },
onPerPageChange: { action: 'onPerPageChange' },
},
};
export const Default = ( args ) => {
@ -34,3 +43,28 @@ export const Default = ( args ) => {
/>
);
};
export const CustomWithHook = ( args ) => {
const paginationProps = usePagination( {
totalCount: args.total,
defaultPerPage: 25,
onPageChange: args.onPageChange,
onPerPageChange: args.onPerPageChange,
} );
return (
<div>
<div>
Viewing { paginationProps.start }-{ paginationProps.end } of{ ' ' }
{ args.total } items
</div>
<PaginationPageArrowsWithPicker { ...paginationProps } />
<PaginationPageSizePicker
{ ...paginationProps }
total={ args.total }
perPageOptions={ [ 5, 10, 25 ] }
label=""
/>
</div>
);
};

View File

@ -5,7 +5,7 @@
justify-content: center;
align-items: center;
@include breakpoint( '<782px' ) {
@include breakpoint("<782px") {
flex-direction: column;
}
@ -17,6 +17,8 @@
.woocommerce-pagination__page-arrows {
display: flex;
flex-direction: row;
align-items: center;
gap: $gap-smaller;
}
.woocommerce-pagination__page-arrows-buttons {
@ -33,7 +35,7 @@
justify-content: center;
}
.components-button:not(:disabled):not([aria-disabled='true']):hover {
.components-button:not(:disabled):not([aria-disabled="true"]):hover {
color: $gray-700;
}
@ -61,7 +63,7 @@
.woocommerce-pagination__page-picker {
margin-left: $spacing;
@include breakpoint( '<782px' ) {
@include breakpoint("<782px") {
margin-top: $gap;
margin-left: 0;
}
@ -76,7 +78,7 @@
.woocommerce-pagination__per-page-picker {
margin-left: $spacing;
@include breakpoint( '<782px' ) {
@include breakpoint("<782px") {
margin-top: $gap;
margin-left: 0;
}
@ -110,7 +112,23 @@
}
.woocommerce-pagination__page-picker-input.has-error,
.woocommerce-pagination__page-picker-input.has-error:focus {
.woocommerce-pagination__page-picker-input.has-error:focus,
.woocommerce-pagination__page-arrow-picker-input.has-error,
.woocommerce-pagination__page-arrow-picker-input.has-error:focus {
border-color: $error-red;
box-shadow: 0 0 2px $error-red;
}
.woocommerce-pagination__page-arrow-picker-input {
/* Chrome, Safari, Edge, Opera */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
&[type="number"] {
-moz-appearance: textfield;
}
}

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
export type usePaginationProps = {
totalCount: number;
defaultPerPage?: number;
onPageChange?: ( page: number ) => void;
onPerPageChange?: ( page: number ) => void;
};
export function usePagination( {
totalCount,
defaultPerPage = 25,
onPageChange,
onPerPageChange,
}: usePaginationProps ) {
const [ currentPage, setCurrentPage ] = useState( 1 );
const [ perPage, setPerPage ] = useState( defaultPerPage );
const pageCount = Math.ceil( totalCount / perPage );
const start = perPage * ( currentPage - 1 ) + 1;
const end = Math.min( perPage * currentPage, totalCount );
return {
start,
end,
currentPage,
perPage,
pageCount,
setCurrentPage: ( newPage: number ) => {
setCurrentPage( newPage );
if ( onPageChange ) {
onPageChange( newPage );
}
},
setPerPageChange: ( newPerPage: number ) => {
setPerPage( newPerPage );
if ( onPerPageChange ) {
onPerPageChange( newPerPage );
}
},
};
}

View File

@ -39,6 +39,7 @@ export type SortableProps = {
onDragOver?: DragEventHandler< HTMLLIElement >;
onDragStart?: DragEventHandler< HTMLDivElement >;
onOrderChange?: ( items: SortableChild[] ) => void;
className?: string;
};
const THROTTLE_TIME = 16;
@ -52,6 +53,7 @@ export const Sortable = ( {
onDragOver = () => null,
onDragStart = () => null,
onOrderChange = () => null,
className,
}: SortableProps ) => {
const ref = useRef< HTMLOListElement >( null );
const [ items, setItems ] = useState< SortableChild[] >( [] );
@ -227,7 +229,7 @@ export const Sortable = ( {
return (
<SortableContext.Provider value={ {} }>
<ol
className={ classnames( 'woocommerce-sortable', {
className={ classnames( 'woocommerce-sortable', className, {
'is-dragging': dragIndex !== null,
'is-horizontal': isHorizontal,
} ) }

View File

@ -21,7 +21,7 @@ import {
import EllipsisMenu from '../ellipsis-menu';
import MenuItem from '../ellipsis-menu/menu-item';
import MenuTitle from '../ellipsis-menu/menu-title';
import Pagination from '../pagination';
import { Pagination } from '../pagination';
import Table from './table';
import TablePlaceholder from './placeholder';
import TableSummary, { TableSummaryPlaceholder } from './summary';
@ -105,14 +105,14 @@ const TableCard: React.VFC< TableCardProps > = ( {
};
const onPageChange = (
newPage: string,
direction?: 'previous' | 'next'
newPage: number,
direction?: 'previous' | 'next' | 'goto'
) => {
if ( props.onPageChange ) {
props.onPageChange( parseInt( newPage, 10 ), direction );
props.onPageChange( newPage, direction );
}
if ( onQueryChange ) {
onQueryChange( 'paged' )( newPage, direction );
onQueryChange( 'paged' )( newPage.toString(), direction );
}
};
@ -233,7 +233,11 @@ const TableCard: React.VFC< TableCardProps > = ( {
perPage={ rowsPerPage }
total={ totalRows }
onPageChange={ onPageChange }
onPerPageChange={ onQueryChange( 'per_page' ) }
onPerPageChange={ ( perPage ) =>
onQueryChange( 'per_page' )(
perPage.toString()
)
}
/>
{ summary && <TableSummary data={ summary } /> }

View File

@ -164,7 +164,10 @@ export type TableCardProps = CommonTableProps & {
/**
* A callback function that is invoked when the current page is changed.
*/
onPageChange?: ( newPage: number, direction?: 'previous' | 'next' ) => void;
onPageChange?: (
newPage: number,
direction?: 'previous' | 'next' | 'goto'
) => void;
/**
* The total number of rows to display per page.
*/

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update generateProductVariations action to add support for default_attributes and allowing to disable the product saving.

View File

@ -17,7 +17,7 @@ import type {
GenerateRequest,
} from './types';
import CRUD_ACTIONS from './crud-actions';
import { ProductAttribute } from '../products/types';
import { ProductAttribute, ProductDefaultAttribute } from '../products/types';
export function generateProductVariationsError( key: IdType, error: unknown ) {
return {
@ -47,8 +47,10 @@ export const generateProductVariations = function* (
productData: {
type?: string;
attributes: ProductAttribute[];
default_attributes?: ProductDefaultAttribute[];
},
data: GenerateRequest
data: GenerateRequest,
saveAttributes = true
) {
const urlParameters = getUrlParameters(
WC_PRODUCT_VARIATIONS_NAMESPACE,
@ -57,20 +59,22 @@ export const generateProductVariations = function* (
const { key } = parseId( idQuery, urlParameters );
yield generateProductVariationsRequest( key );
try {
yield controls.dispatch(
'core',
'saveEntityRecord',
'postType',
'product',
{
id: urlParameters[ 0 ],
...productData,
}
);
} catch ( error ) {
yield generateProductVariationsError( key, error );
throw error;
if ( saveAttributes ) {
try {
yield controls.dispatch(
'core',
'saveEntityRecord',
'postType',
'product',
{
id: urlParameters[ 0 ],
...productData,
}
);
} catch ( error ) {
yield generateProductVariationsError( key, error );
throw error;
}
}
try {
@ -83,7 +87,6 @@ export const generateProductVariations = function* (
method: 'POST',
data,
} );
yield generateProductVariationsSuccess( key );
return result;
} catch ( error ) {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add global quick actions dropdown menu to the Variations table header

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Inventory item to the global Quick Update dropdown

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Shipping item to the global Quick Update dropdown

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tracking events to add edit and update attribute

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add woocommerce/taxonomy-field block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix bug where the form was dirty still after adding product variations for the first time.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update pagination look of the Pagination table.

View File

@ -26,3 +26,4 @@ export { init as initRequirePassword } from './password';
export { init as initVariationItems } from './variation-items';
export { init as initVariationOptions } from './variation-options';
export { init as initNotice } from './notice';
export { init as initTaxonomy } from './taxonomy';

View File

@ -18,3 +18,4 @@
@import 'password/editor.scss';
@import 'variation-items/editor.scss';
@import 'variation-options/editor.scss';
@import 'taxonomy/editor.scss';

View File

@ -0,0 +1,51 @@
# woocommerce/taxonomy-field block
This is a block that displays a taxonomy field, allowing searching, selection, and creation of new items, to be used in a product context.
Please note that to use this block you need to have the custom taxonomy registered in the backend, attached to the products post type and added to the REST API. Here's a snippet that shows how to add an already registered taxonomy to the REST API:
```php
function YOUR_PREFIX_rest_api_prepare_custom1_to_product( $response, $post ) {
$post_id = $post->get_id();
if ( empty( $response->data[ 'custom1' ] ) ) {
$terms = [];
foreach ( wp_get_post_terms( $post_id, 'custom-taxonomy' ) as $term ) {
$terms[] = [
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
];
}
$response->data[ 'custom1' ] = $terms;
}
return $response;
}
add_filter( 'woocommerce_rest_prepare_product_object', 'YOUR_PREFIX_rest_api_prepare_custom1_to_product', 10, 2 );
function YOUR_PREFIX_rest_api_add_custom1_to_product( $product, $request, $creating = true ) {
$product_id = $product->get_id();
$params = $request->get_params();
$custom1s = isset( $params['custom1'] ) ? $params['custom1'] : array();
if ( ! empty( $custom1s ) ) {
if ( $custom1s[0]['id'] ) {
$custom1s = array_map(
function ( $custom1 ) {
return absint( $custom1['id'] );
},
$custom1s
);
} else {
$custom1s = array_map( 'absint', $custom1s );
}
wp_set_object_terms( $product_id, $custom1s, 'custom-taxonomy' );
}
}
add_filter( 'woocommerce_rest_insert_product_object', 'YOUR_PREFIX_rest_api_add_custom1_to_product', 10, 3 );
```

View File

@ -0,0 +1,38 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/taxonomy-field",
"title": "Taxonomy",
"category": "widgets",
"description": "A block that displays a taxonomy field, allowing searching, selection, and creation of new items",
"keywords": [ "taxonomy"],
"textdomain": "default",
"attributes": {
"slug": {
"type": "string",
"__experimentalRole": "content"
},
"property": {
"type": "string",
"__experimentalRole": "content"
},
"label": {
"type": "string",
"__experimentalRole": "content"
},
"createTitle": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"editorStyle": "file:./editor.css"
}

View File

@ -0,0 +1,192 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { BaseControl, Button, Modal, TextControl } from '@wordpress/components';
import {
useState,
useEffect,
createElement,
createInterpolateElement,
useCallback,
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import {
__experimentalSelectTreeControl as SelectTree,
TreeItemType as Item,
} from '@woocommerce/components';
import { useDebounce, useInstanceId } from '@wordpress/compose';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { Taxonomy } from './types';
import useTaxonomySearch from './use-taxonomy-search';
type CreateTaxonomyModalProps = {
initialName?: string;
hierarchical: boolean;
slug: string;
title: string;
onCancel: () => void;
onCreate: ( taxonomy: Taxonomy ) => void;
};
export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
onCancel,
onCreate,
initialName,
slug,
hierarchical,
title,
} ) => {
const [ categoryParentTypedValue, setCategoryParentTypedValue ] =
useState( '' );
const [ allEntries, setAllEntries ] = useState< Taxonomy[] >( [] );
const { searchEntity, isResolving } = useTaxonomySearch( slug );
const searchDelayed = useDebounce(
useCallback(
( val ) => searchEntity( val || '' ).then( setAllEntries ),
[]
),
150
);
useEffect( () => {
searchDelayed( '' );
}, [] );
const { saveEntityRecord } = useDispatch( 'core' );
const [ isCreating, setIsCreating ] = useState( false );
const [ errorMessage, setErrorMessage ] = useState< string | null >( null );
const [ name, setName ] = useState( initialName || '' );
const [ parent, setParent ] = useState< Taxonomy | null >( null );
const onSave = async () => {
setErrorMessage( null );
try {
const newTaxonomy: Taxonomy = await saveEntityRecord(
'taxonomy',
slug,
{
name,
parent: parent ? parent.id : null,
},
{
throwOnError: true,
}
);
onCreate( newTaxonomy );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch ( e: any ) {
setIsCreating( false );
if ( e.message ) {
setErrorMessage( e.message );
} else {
setErrorMessage(
__( `Failed to create taxonomy`, 'woocommerce' )
);
throw e;
}
}
};
const id = useInstanceId( BaseControl, 'taxonomy_name' ) as string;
const selectId = useInstanceId(
SelectTree,
'parent-taxonomy-select'
) as string;
return (
<Modal
title={ title }
onRequestClose={ onCancel }
className="woocommerce-create-new-taxonomy-modal"
>
<div className="woocommerce-create-new-taxonomy-modal__wrapper">
<BaseControl
id={ id }
label={ __( 'Name', 'woocommerce' ) }
help={ errorMessage }
className={ classNames( {
'has-error': errorMessage,
} ) }
>
<TextControl
id={ id }
value={ name }
onChange={ setName }
/>
</BaseControl>
{ hierarchical && (
<SelectTree
isLoading={ isResolving }
label={ createInterpolateElement(
__( 'Parent <optional/>', 'woocommerce' ),
{
optional: (
<span className="woocommerce-product-form__optional-input">
{ __( '(optional)', 'woocommerce' ) }
</span>
),
}
) }
id={ selectId }
items={ allEntries.map( ( taxonomy ) => ( {
label: taxonomy.name,
value: String( taxonomy.id ),
parent:
taxonomy.parent > 0
? String( taxonomy.parent )
: undefined,
} ) ) }
shouldNotRecursivelySelect
selected={
parent
? {
value: String( parent.id ),
label: parent.name,
}
: undefined
}
onSelect={ ( item: Item ) =>
item &&
setParent( {
id: +item.value,
name: item.label,
parent: item.parent ? +item.parent : 0,
} )
}
onRemove={ () => setParent( null ) }
onInputChange={ ( value ) => {
searchDelayed( value );
setCategoryParentTypedValue( value || '' );
} }
createValue={ categoryParentTypedValue }
/>
) }
<div className="woocommerce-create-new-taxonomy-modal__buttons">
<Button
isSecondary
onClick={ onCancel }
disabled={ isCreating }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
disabled={ name.length === 0 || isCreating }
isBusy={ isCreating }
onClick={ onSave }
>
{ __( 'Save', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,184 @@
/**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import {
createElement,
useState,
Fragment,
useCallback,
useEffect,
} from '@wordpress/element';
import '@woocommerce/settings';
import { __experimentalSelectTreeControl as SelectTreeControl } from '@woocommerce/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDebounce, useInstanceId } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { CreateTaxonomyModal } from './create-taxonomy-modal';
import { Taxonomy, TaxonomyMetadata } from './types';
import useTaxonomySearch from './use-taxonomy-search';
interface TaxonomyBlockAttributes extends BlockAttributes {
label: string;
slug: string;
property: string;
createTitle: string;
}
export function Edit( {
attributes,
}: {
attributes: TaxonomyBlockAttributes;
} ) {
const blockProps = useBlockProps();
const { hierarchical }: TaxonomyMetadata = useSelect(
( select ) =>
select( 'core' ).getTaxonomy( attributes.slug ) || {
hierarchical: false,
}
);
const { label, slug, property, createTitle } = attributes;
const [ searchValue, setSearchValue ] = useState( '' );
const [ allEntries, setAllEntries ] = useState< Taxonomy[] >( [] );
const { searchEntity, isResolving } = useTaxonomySearch( slug, {
fetchParents: hierarchical,
} );
const searchDelayed = useDebounce(
useCallback(
( val ) => {
setSearchValue( val );
searchEntity( val || '' ).then( setAllEntries );
},
[ hierarchical ]
),
150
);
useEffect( () => {
searchDelayed( '' );
}, [] );
const [ selectedEntries, setSelectedEntries ] = useEntityProp< Taxonomy[] >(
'postType',
'product',
property
);
const mappedEntries = selectedEntries.map( ( b ) => ( {
value: String( b.id ),
label: b.name,
} ) );
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const mappedAllEntries = allEntries.map( ( taxonomy ) => ( {
parent:
hierarchical && taxonomy.parent && taxonomy.parent > 0
? String( taxonomy.parent )
: undefined,
label: taxonomy.name,
value: String( taxonomy.id ),
} ) );
return (
<div { ...blockProps }>
<>
<SelectTreeControl
id={
useInstanceId(
SelectTreeControl,
'woocommerce-taxonomy-select'
) as string
}
label={ label }
isLoading={ isResolving }
multiple
createValue={ searchValue }
onInputChange={ searchDelayed }
shouldNotRecursivelySelect
shouldShowCreateButton={ ( typedValue ) =>
! typedValue ||
mappedAllEntries.findIndex(
( taxonomy ) =>
taxonomy.label.toLowerCase() ===
typedValue.toLowerCase()
) === -1
}
onCreateNew={ () => setShowCreateNewModal( true ) }
items={ mappedAllEntries }
selected={ mappedEntries }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
setSelectedEntries( [
...selectedItems.map( ( i ) => ( {
id: +i.value,
name: i.label,
parent: +( i.parent || 0 ),
} ) ),
...selectedEntries,
] );
} else {
setSelectedEntries( [
{
id: +selectedItems.value,
name: selectedItems.label,
parent: +( selectedItems.parent || 0 ),
},
...selectedEntries,
] );
}
} }
onRemove={ ( removedItems ) => {
if ( Array.isArray( removedItems ) ) {
setSelectedEntries(
selectedEntries.filter(
( taxonomy ) =>
! removedItems.find(
( item ) =>
item.value ===
String( taxonomy.id )
)
)
);
} else {
setSelectedEntries(
selectedEntries.filter(
( taxonomy ) =>
String( taxonomy.id ) !==
removedItems.value
)
);
}
} }
></SelectTreeControl>
{ showCreateNewModal && (
<CreateTaxonomyModal
slug={ slug }
hierarchical={ hierarchical }
title={ createTitle }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( taxonomy ) => {
setShowCreateNewModal( false );
setSearchValue( '' );
setSelectedEntries( [
{
id: taxonomy.id,
name: taxonomy.name,
parent: taxonomy.parent,
},
...selectedEntries,
] );
} }
initialName={ searchValue }
/>
) }
</>
</div>
);
}

View File

@ -0,0 +1,24 @@
.components-modal__screen-overlay {
.woocommerce-create-new-taxonomy-modal {
min-width: 650px;
overflow: visible;
&__buttons {
margin-top: $gap-larger;
display: flex;
flex-direction: row;
gap: $gap-smaller;
justify-content: flex-end;
}
}
.has-error {
.components-text-control__input {
border-color: $studio-red-50;
}
.components-base-control__help {
color: $studio-red-50;
}
}
}

View File

@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import { initBlock } from '../../utils';
import metadata from './block.json';
import { Edit } from './edit';
const { name } = metadata;
export { metadata, name };
export const settings = {
example: {},
edit: Edit,
};
export const init = () =>
initBlock( {
name,
metadata: metadata as never,
settings,
} );

View File

@ -0,0 +1,9 @@
export interface Taxonomy {
id: number;
name: string;
parent: number;
}
export interface TaxonomyMetadata {
hierarchical: boolean;
}

View File

@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
import { resolveSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { Taxonomy } from './types';
async function getTaxonomiesMissingParents(
taxonomies: Taxonomy[],
taxonomyName: string
): Promise< Taxonomy[] > {
// Retrieve the missing parent objects incase not all of them were included.
const missingParentIds: number[] = [];
const taxonomiesLookup: Record< number, Taxonomy > = {};
taxonomies.forEach( ( taxonomy ) => {
taxonomiesLookup[ taxonomy.id ] = taxonomy;
} );
taxonomies.forEach( ( taxonomy ) => {
if ( taxonomy.parent > 0 && ! taxonomiesLookup[ taxonomy.parent ] ) {
missingParentIds.push( taxonomy.parent );
}
} );
if ( missingParentIds.length > 0 ) {
return resolveSelect( 'core' )
.getEntityRecords< Taxonomy[] >( 'taxonomy', taxonomyName, {
include: missingParentIds,
} )
.then( ( parentTaxonomies ) => {
return getTaxonomiesMissingParents(
[ ...( parentTaxonomies as Taxonomy[] ), ...taxonomies ],
taxonomyName
);
} );
}
return taxonomies;
}
const PAGINATION_SIZE = 30;
interface UseTaxonomySearchOptions {
fetchParents?: boolean;
}
const useTaxonomySearch = (
taxonomyName: string,
options: UseTaxonomySearchOptions = { fetchParents: true }
): {
searchEntity: ( search: string ) => Promise< Taxonomy[] >;
isResolving: boolean;
} => {
const [ isSearching, setIsSearching ] = useState( false );
async function searchEntity( search: string ): Promise< Taxonomy[] > {
setIsSearching( true );
let taxonomies: Taxonomy[] = [];
try {
taxonomies = await resolveSelect( 'core' ).getEntityRecords<
Taxonomy[]
>( 'taxonomy', taxonomyName, {
per_page: PAGINATION_SIZE,
search,
} );
if ( options?.fetchParents ) {
taxonomies = await getTaxonomiesMissingParents(
taxonomies,
taxonomyName
);
}
} catch ( e ) {
setIsSearching( false );
}
return taxonomies;
}
return {
searchEntity,
isResolving: isSearching,
};
};
export default useTaxonomySearch;

View File

@ -1,7 +1,13 @@
/**
* External dependencies
*/
import { createElement, useEffect, useRef, useState } from '@wordpress/element';
import {
createElement,
useEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { TourKit, TourKitTypes } from '@woocommerce/components';
import {
@ -18,25 +24,28 @@ import { useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { DEFAULT_PER_PAGE_OPTION } from '../../constants';
import { DEFAULT_VARIATION_PER_PAGE_OPTION } from '../../constants';
export const VariableProductTour: React.FC = () => {
const [ isTourOpen, setIsTourOpen ] = useState( false );
const productId = useEntityId( 'postType', 'product' );
const prevTotalCount = useRef< undefined | number >();
const requestParams = useMemo(
() => ( {
product_id: productId,
page: 1,
per_page: DEFAULT_VARIATION_PER_PAGE_OPTION,
order: 'asc',
orderby: 'menu_order',
} ),
[ productId ]
);
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const requestParams = {
product_id: productId,
page: 1,
per_page: DEFAULT_PER_PAGE_OPTION,
order: 'asc',
orderby: 'menu_order',
};
return {
totalCount:
getProductVariationsTotalCount< number >( requestParams ),

View File

@ -22,32 +22,10 @@ import { useEntityProp, useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import {
EnhancedProductAttribute,
useProductAttributes,
} from '../../hooks/use-product-attributes';
import { useProductAttributes } from '../../hooks/use-product-attributes';
import { AttributeControl } from '../../components/attribute-control';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
function manageDefaultAttributes( values: EnhancedProductAttribute[] ) {
return values.reduce< Product[ 'default_attributes' ] >(
( prevDefaultAttributes, currentAttribute ) => {
if ( currentAttribute.isDefault ) {
return [
...prevDefaultAttributes,
{
id: currentAttribute.id,
name: currentAttribute.name,
option: currentAttribute.options[ 0 ],
},
];
}
return prevDefaultAttributes;
},
[]
);
}
export function Edit() {
const blockProps = useBlockProps();
const { generateProductVariations } = useProductVariationsHelper();
@ -72,10 +50,10 @@ export function Edit() {
allAttributes: entityAttributes,
isVariationAttributes: true,
productId: useEntityId( 'postType', 'product' ),
onChange( values ) {
onChange( values, defaultAttributes ) {
setEntityAttributes( values );
setEntityDefaultAttributes( manageDefaultAttributes( values ) );
generateProductVariations( values );
setEntityDefaultAttributes( defaultAttributes );
generateProductVariations( values, defaultAttributes );
},
} );

View File

@ -6,6 +6,7 @@ import type { BlockEditProps } from '@wordpress/blocks';
import { Button } from '@wordpress/components';
import { Link } from '@woocommerce/components';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
createElement,
useState,
@ -36,16 +37,7 @@ import {
import { getAttributeId } from '../../components/attribute-control/utils';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
import { hasAttributesUsedForVariations } from '../../utils';
function getFirstOptionFromEachAttribute(
attributes: Product[ 'attributes' ]
): Product[ 'default_attributes' ] {
return attributes.map( ( attribute ) => ( {
id: attribute.id,
name: attribute.name,
option: attribute.options[ 0 ],
} ) );
}
import { TRACKS_SOURCE } from '../../constants';
export function Edit( {
attributes,
@ -66,12 +58,10 @@ export function Edit( {
allAttributes: productAttributes,
isVariationAttributes: true,
productId: useEntityId( 'postType', 'product' ),
onChange( values ) {
onChange( values, defaultAttributes ) {
setProductAttributes( values );
setDefaultProductAttributes(
getFirstOptionFromEachAttribute( values )
);
generateProductVariations( values );
setDefaultProductAttributes( defaultAttributes );
generateProductVariations( values, defaultAttributes );
},
}
);
@ -101,15 +91,22 @@ export function Edit( {
};
const handleAdd = ( newOptions: EnhancedProductAttribute[] ) => {
handleChange( [
...newOptions.filter(
( newAttr ) =>
! variationOptions.find(
( attr: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
),
] );
const addedAttributesOnly = newOptions.filter(
( newAttr ) =>
! variationOptions.some(
( attr: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
);
recordEvent( 'product_options_add', {
source: TRACKS_SOURCE,
options: addedAttributesOnly.map( ( attribute ) => ( {
attribute: attribute.name,
values: attribute.options,
} ) ),
} );
handleChange( addedAttributesOnly );
closeNewModal();
};

View File

@ -31,6 +31,7 @@ import {
import { AttributeListItem } from '../attribute-list-item';
import { NewAttributeModal } from './new-attribute-modal';
import { RemoveConfirmationModal } from './remove-confirmation-modal';
import { TRACKS_SOURCE } from '../../constants';
type AttributeControlProps = {
value: EnhancedProductAttribute[];
@ -157,6 +158,10 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
};
const openEditModal = ( attribute: ProductAttribute ) => {
recordEvent( 'product_options_edit', {
source: TRACKS_SOURCE,
attribute: attribute.name,
} );
setCurrentAttributeId( getAttributeId( attribute ) );
onEditModalOpen( attribute );
};
@ -167,21 +172,35 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
};
const handleAdd = ( newAttributes: EnhancedProductAttribute[] ) => {
handleChange( [
...value,
...newAttributes.filter(
( newAttr ) =>
! value.find(
( attr ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
),
] );
const addedAttributesOnly = newAttributes.filter(
( newAttr ) =>
! value.some(
( current: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( current )
)
);
recordEvent( 'product_options_add', {
source: TRACKS_SOURCE,
options: addedAttributesOnly.map( ( attribute ) => ( {
attribute: attribute.name,
values: attribute.options,
} ) ),
} );
handleChange( [ ...value, ...addedAttributesOnly ] );
onAdd( newAttributes );
closeNewModal();
};
const handleEdit = ( updatedAttribute: ProductAttribute ) => {
const handleEdit = ( updatedAttribute: EnhancedProductAttribute ) => {
recordEvent( 'product_options_update', {
source: TRACKS_SOURCE,
attribute: updatedAttribute.name,
values: updatedAttribute.terms?.map( ( term ) => term.name ),
default: updatedAttribute.isDefault,
visible: updatedAttribute.visible,
filter: true, // default true until attribute filter gets implemented
} );
const updatedAttributes = value.map( ( attr ) => {
if (
getAttributeId( attr ) === getAttributeId( updatedAttribute )
@ -221,7 +240,9 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
className="woocommerce-add-attribute-list-item__add-button"
onClick={ () => {
openNewModal();
recordEvent( 'product_add_attributes_click' );
recordEvent( 'product_options_add_button_click', {
source: TRACKS_SOURCE,
} );
} }
>
{ uiStrings.newAttributeListItemLabel }

View File

@ -1 +1,2 @@
export * from './variations-table';
export * from './types';

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
@ -13,23 +12,19 @@ import { chevronRight } from '@wordpress/icons';
*/
import { TRACKS_SOURCE } from '../../../constants';
import { PRODUCT_STOCK_STATUS_KEYS } from '../../../utils/get-product-stock-status';
export type InventoryMenuItemProps = {
variation: ProductVariation;
handlePrompt(
label?: string,
parser?: ( value: string ) => Partial< ProductVariation > | null
): void;
onChange( values: Partial< ProductVariation > ): void;
onClose(): void;
};
import { UpdateStockMenuItem } from '../update-stock-menu-item';
import { VariationActionsMenuItemProps } from '../types';
import { handlePrompt } from '../../../utils/handle-prompt';
export function InventoryMenuItem( {
variation,
handlePrompt,
selection,
onChange,
onClose,
}: InventoryMenuItemProps ) {
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
: selection.id;
return (
<Dropdown
position="middle right"
@ -40,7 +35,7 @@ export function InventoryMenuItem( {
'product_variations_menu_inventory_click',
{
source: TRACKS_SOURCE,
variation_id: variation.id,
variation_id: ids,
}
);
onToggle();
@ -55,39 +50,11 @@ export function InventoryMenuItem( {
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MenuGroup>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
const stockQuantity = Number( value );
if ( Number.isNaN( stockQuantity ) ) {
return {};
}
recordEvent(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: variation.id,
}
);
return {
stock_quantity: stockQuantity,
manage_stock: true,
};
} );
onClose();
} }
>
{ __( 'Update stock', 'woocommerce' ) }
</MenuItem>
<UpdateStockMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<MenuItem
onClick={ () => {
recordEvent(
@ -95,12 +62,23 @@ export function InventoryMenuItem( {
{
source: TRACKS_SOURCE,
action: 'manage_stock_toggle',
variation_id: variation.id,
variation_id: ids,
}
);
onChange( {
manage_stock: ! variation.manage_stock,
} );
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( { id, manage_stock } ) => ( {
id,
manage_stock: ! manage_stock,
} )
)
);
} else {
onChange( {
manage_stock: ! selection.manage_stock,
} );
}
onClose();
} }
>
@ -113,14 +91,25 @@ export function InventoryMenuItem( {
{
source: TRACKS_SOURCE,
action: 'set_status_in_stock',
variation_id: variation.id,
variation_id: ids,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
stock_status:
PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} ) )
);
} else {
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
}
onClose();
} }
>
@ -133,14 +122,25 @@ export function InventoryMenuItem( {
{
source: TRACKS_SOURCE,
action: 'set_status_out_of_stock',
variation_id: variation.id,
variation_id: ids,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
stock_status:
PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} ) )
);
} else {
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
}
onClose();
} }
>
@ -156,14 +156,25 @@ export function InventoryMenuItem( {
{
source: TRACKS_SOURCE,
action: 'set_status_on_back_order',
variation_id: variation.id,
variation_id: ids,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
stock_status:
PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} ) )
);
} else {
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
}
onClose();
} }
>
@ -179,26 +190,40 @@ export function InventoryMenuItem( {
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: ids,
}
);
const lowStockAmount = Number( value );
if ( Number.isNaN( lowStockAmount ) ) {
return null;
}
);
const lowStockAmount = Number( value );
if ( Number.isNaN( lowStockAmount ) ) {
return null;
}
return {
low_stock_amount: lowStockAmount,
manage_stock: true,
};
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
low_stock_amount:
lowStockAmount,
manage_stock: true,
} ) )
);
} else {
onChange( {
low_stock_amount:
lowStockAmount,
manage_stock: true,
} );
}
},
} );
onClose();
} }

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
@ -12,6 +11,9 @@ import { chevronRight } from '@wordpress/icons';
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationActionsMenuItemProps } from '../types';
import { SetListPriceMenuItem } from '../set-list-price-menu-item';
function isPercentage( value: string ) {
return value.endsWith( '%' );
@ -52,20 +54,15 @@ function addFixedOrPercentage(
return Number( value ) + Number( fixedOrPercentage ) * increaseOrDecrease;
}
export type PricingMenuItemProps = {
variation: ProductVariation;
handlePrompt(
label?: string,
parser?: ( value: string ) => Partial< ProductVariation >
): void;
onClose(): void;
};
export function PricingMenuItem( {
variation,
handlePrompt,
selection,
onChange,
onClose,
}: PricingMenuItemProps ) {
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
: selection.id;
return (
<Dropdown
position="middle right"
@ -74,7 +71,7 @@ export function PricingMenuItem( {
onClick={ () => {
recordEvent( 'product_variations_menu_pricing_click', {
source: TRACKS_SOURCE,
variation_id: variation.id,
variation_id: ids,
} );
onToggle();
} }
@ -88,34 +85,11 @@ export function PricingMenuItem( {
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MenuGroup label={ __( 'List price', 'woocommerce' ) }>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: variation.id,
}
);
return {
regular_price: value,
};
} );
onClose();
} }
>
{ __( 'Set list price', 'woocommerce' ) }
</MenuItem>
<SetListPriceMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<MenuItem
onClick={ () => {
recordEvent(
@ -123,31 +97,50 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt(
__(
handlePrompt( {
message: __(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
variation_id: ids,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value
)?.toFixed( 2 ),
};
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( {
id,
regular_price,
} ) => ( {
id,
regular_price:
addFixedOrPercentage(
regular_price,
value
)?.toFixed( 2 ),
} )
)
);
} else {
onChange( {
regular_price:
addFixedOrPercentage(
selection.regular_price,
value
)?.toFixed( 2 ),
} );
}
},
} );
onClose();
} }
>
@ -160,32 +153,52 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'list_price_decrease',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt(
__(
handlePrompt( {
message: __(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
variation_id: ids,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( {
id,
regular_price,
} ) => ( {
id,
regular_price:
addFixedOrPercentage(
regular_price,
value,
-1
)?.toFixed( 2 ),
} )
)
);
} else {
onChange( {
regular_price:
addFixedOrPercentage(
selection.regular_price,
value,
-1
)?.toFixed( 2 ),
} );
}
},
} );
onClose();
} }
>
@ -200,21 +213,32 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: ids,
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
sale_price: value,
} ) )
);
} else {
onChange( {
sale_price: value,
} );
}
);
return {
sale_price: value,
};
},
} );
onClose();
} }
@ -228,31 +252,50 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt(
__(
handlePrompt( {
message: __(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
variation_id: ids,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value
)?.toFixed( 2 ),
};
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( {
id,
sale_price,
} ) => ( {
id,
sale_price:
addFixedOrPercentage(
sale_price,
value
)?.toFixed( 2 ),
} )
)
);
} else {
onChange( {
sale_price:
addFixedOrPercentage(
selection.sale_price,
value
)?.toFixed( 2 ),
} );
}
},
} );
onClose();
} }
>
@ -265,32 +308,52 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt(
__(
handlePrompt( {
message: __(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
variation_id: ids,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( {
id,
sale_price,
} ) => ( {
id,
sale_price:
addFixedOrPercentage(
sale_price,
value,
-1
)?.toFixed( 2 ),
} )
)
);
} else {
onChange( {
sale_price:
addFixedOrPercentage(
selection.sale_price,
value,
-1
)?.toFixed( 2 ),
} );
}
},
} );
onClose();
} }
>
@ -303,47 +366,66 @@ export function PricingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt(
__(
handlePrompt( {
message: __(
'Sale start date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
variation_id: ids,
}
);
return {
date_on_sale_from_gmt: value,
};
}
);
handlePrompt(
__(
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
date_on_sale_from_gmt:
value,
} ) )
);
} else {
onChange( {
date_on_sale_from_gmt: value,
} );
}
},
} );
handlePrompt( {
message: __(
'Sale end date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
onOk( value ) {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
variation_id: ids,
}
);
return {
date_on_sale_to_gmt: value,
};
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
date_on_sale_to_gmt: value,
} ) )
);
} else {
onChange( {
date_on_sale_to_gmt: value,
} );
}
},
} );
onClose();
} }
>

View File

@ -0,0 +1 @@
export * from './set-list-price-menu-item';

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationActionsMenuItemProps } from '../types';
export function SetListPriceMenuItem( {
selection,
onChange,
onClose,
}: VariationActionsMenuItemProps ) {
return (
<MenuItem
onClick={ () => {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
: selection.id;
recordEvent( 'product_variations_menu_pricing_select', {
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: ids,
} );
handlePrompt( {
onOk( value ) {
recordEvent( 'product_variations_menu_pricing_update', {
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: ids,
} );
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
regular_price: value,
} ) )
);
} else {
onChange( {
regular_price: value,
} );
}
},
} );
onClose();
} }
>
{ __( 'Set list price', 'woocommerce' ) }
</MenuItem>
);
}

View File

@ -5,19 +5,48 @@ import { Dropdown, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronRight } from '@wordpress/icons';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { ShippingMenuItemProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { VariationActionsMenuItemProps } from '../types';
import { handlePrompt } from '../../../utils/handle-prompt';
export function ShippingMenuItem( {
variation,
handlePrompt,
selection,
onChange,
onClose,
}: ShippingMenuItemProps ) {
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
: selection.id;
function handleDimensionsChange(
value: Partial< ProductVariation[ 'dimensions' ] >
) {
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id, dimensions } ) => ( {
id,
dimensions: {
...dimensions,
...value,
},
} ) )
);
} else {
onChange( {
dimensions: {
...selection.dimensions,
...value,
},
} );
}
}
return (
<Dropdown
position="middle right"
@ -26,7 +55,7 @@ export function ShippingMenuItem( {
onClick={ () => {
recordEvent( 'product_variations_menu_shipping_click', {
source: TRACKS_SOURCE,
variation_id: variation.id,
variation_id: ids,
} );
onToggle();
} }
@ -46,24 +75,23 @@ export function ShippingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: ids,
}
);
handleDimensionsChange( {
length: value,
},
};
} );
},
} );
onClose();
} }
@ -77,24 +105,23 @@ export function ShippingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: ids,
}
);
handleDimensionsChange( {
width: value,
},
};
} );
},
} );
onClose();
} }
@ -108,24 +135,23 @@ export function ShippingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: ids,
}
);
handleDimensionsChange( {
height: value,
},
};
} );
},
} );
onClose();
} }
@ -139,19 +165,30 @@ export function ShippingMenuItem( {
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: variation.id,
variation_id: ids,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: variation.id,
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: ids,
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
weight: value,
} ) )
);
} else {
onChange( { weight: value } );
}
);
return { weight: value };
},
} );
onClose();
} }

View File

@ -1,3 +1,7 @@
@import "./variations-actions-menu/styles.scss";
$table-row-height: calc($grid-unit * 9);
.woocommerce-product-variations {
display: flex;
flex-direction: column;
@ -10,6 +14,11 @@
border-bottom: 1px solid $gray-200;
}
&__table {
height: $table-row-height * 5;
overflow: auto;
}
&__selection {
.components-checkbox-control__input[type="checkbox"] {
&:not(:checked):not(:focus) {
@ -18,6 +27,10 @@
}
}
&__filters {
flex: 1 0 auto;
}
&__loading {
display: flex;
flex-direction: column;
@ -101,7 +114,7 @@
display: grid;
grid-template-columns: 44px auto 25% 25% 88px;
padding: 0;
min-height: calc($grid-unit * 9);
min-height: $table-row-height;
border: none;
}
@ -119,6 +132,7 @@
}
&__footer {
padding: $gap;
padding: $gap 0;
justify-content: space-between;
}
}

View File

@ -0,0 +1,12 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type VariationActionsMenuItemProps = {
selection: ProductVariation | ProductVariation[];
onChange(
variation: Partial< ProductVariation > | Partial< ProductVariation >[]
): void;
onClose(): void;
};

View File

@ -0,0 +1 @@
export * from './update-stock-menu-item';

View File

@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationActionsMenuItemProps } from '../types';
export function UpdateStockMenuItem( {
selection,
onChange,
onClose,
}: VariationActionsMenuItemProps ) {
return (
<MenuItem
onClick={ () => {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
: selection.id;
recordEvent( 'product_variations_menu_inventory_select', {
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: ids,
} );
handlePrompt( {
onOk( value ) {
const stockQuantity = Number( value );
if ( Number.isNaN( stockQuantity ) ) {
return;
}
recordEvent(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: ids,
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map( ( { id } ) => ( {
id,
stock_quantity: stockQuantity,
manage_stock: true,
} ) )
);
} else {
onChange( {
stock_quantity: stockQuantity,
manage_stock: true,
} );
}
},
} );
onClose();
} }
>
{ __( 'Update stock', 'woocommerce' ) }
</MenuItem>
);
}

View File

@ -33,7 +33,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should trigger product_variations_menu_view track when dropdown toggled', () => {
const { getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -51,7 +51,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should render dropdown with pricing, inventory, and delete options when opened', () => {
const { queryByText, getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -65,7 +65,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onDelete when Delete menuItem is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ mockVariation }
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -79,7 +79,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should open Inventory sub-menu if Inventory is clicked with click track', async () => {
const { queryByText, getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -106,7 +106,7 @@ describe( 'VariationActionsMenu', () => {
window.prompt = jest.fn().mockReturnValue( '10' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -141,7 +141,7 @@ describe( 'VariationActionsMenu', () => {
window.prompt = jest.fn().mockReturnValue( null );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -175,7 +175,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onChange with toggled manage_stock when toggle "track quantity" is clicked', async () => {
const { getByRole, getByText, rerender } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -198,7 +198,7 @@ describe( 'VariationActionsMenu', () => {
onChangeMock.mockClear();
rerender(
<VariationActionsMenu
variation={ { ...mockVariation, manage_stock: true } }
selection={ { ...mockVariation, manage_stock: true } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -214,7 +214,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onChange with toggled stock_status when toggle "Set status to In stock" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -240,7 +240,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onChange with toggled stock_status when toggle "Set status to Out of stock" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -266,7 +266,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onChange with toggled stock_status when toggle "Set status to On back order" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
@ -293,7 +293,7 @@ describe( 'VariationActionsMenu', () => {
window.prompt = jest.fn().mockReturnValue( '7' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>

View File

@ -4,7 +4,7 @@
import { ProductVariation } from '@woocommerce/data';
export type VariationActionsMenuProps = {
variation: ProductVariation;
selection: ProductVariation;
onChange( variation: Partial< ProductVariation > ): void;
onDelete( variationId: number ): void;
onDelete( variation: ProductVariation ): void;
};

View File

@ -5,7 +5,6 @@ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
@ -18,25 +17,10 @@ import { InventoryMenuItem } from '../inventory-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
export function VariationActionsMenu( {
variation,
selection,
onChange,
onDelete,
}: VariationActionsMenuProps ) {
function handlePrompt(
label: string = __( 'Enter a value', 'woocommerce' ),
parser: ( value: string ) => Partial< ProductVariation > | null = () =>
null
) {
// eslint-disable-next-line no-alert
const value = window.prompt( label );
if ( value === null ) return;
const updates = parser( value.trim() );
if ( updates ) {
onChange( updates );
}
}
return (
<DropdownMenu
icon={ moreVertical }
@ -45,7 +29,7 @@ export function VariationActionsMenu( {
onClick() {
recordEvent( 'product_variations_menu_view', {
source: TRACKS_SOURCE,
variation_id: variation.id,
variation_id: selection.id,
} );
},
} }
@ -56,15 +40,15 @@ export function VariationActionsMenu( {
label={ sprintf(
/** Translators: Variation ID */
__( 'Variation Id: %s', 'woocommerce' ),
variation.id
selection.id
) }
>
<MenuItem
href={ variation.permalink }
href={ selection.permalink }
onClick={ () => {
recordEvent( 'product_variations_preview', {
source: TRACKS_SOURCE,
variation_id: variation.id,
variation_id: selection.id,
} );
} }
>
@ -73,19 +57,18 @@ export function VariationActionsMenu( {
</MenuGroup>
<MenuGroup>
<PricingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<InventoryMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<ShippingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
</MenuGroup>
@ -95,7 +78,7 @@ export function VariationActionsMenu( {
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link"
onClick={ () => {
onDelete( variation.id );
onDelete( selection.id );
onClose();
} }
className="woocommerce-product-variations__actions--delete"

View File

@ -0,0 +1,2 @@
export * from './variations-actions-menu';
export * from './types';

View File

@ -0,0 +1,9 @@
.variations-actions-menu {
&__toogle {
flex-direction: row-reverse;
> span {
margin: 0 6px;
}
}
}

View File

@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type VariationsActionsMenuProps = {
disabled?: boolean;
selection: ProductVariation[];
onChange( variations: Partial< ProductVariation >[] ): void;
onDelete( variations: ProductVariation[] ): void;
};

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { Button, Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronDown, chevronUp } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { VariationsActionsMenuProps } from './types';
import { UpdateStockMenuItem } from '../update-stock-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
import { SetListPriceMenuItem } from '../set-list-price-menu-item';
import { InventoryMenuItem } from '../inventory-menu-item';
import { ShippingMenuItem } from '../shipping-menu-item';
export function VariationsActionsMenu( {
selection,
disabled,
onChange,
onDelete,
}: VariationsActionsMenuProps ) {
return (
<Dropdown
position="bottom left"
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
disabled={ disabled }
aria-expanded={ isOpen }
icon={ isOpen ? chevronUp : chevronDown }
variant="secondary"
onClick={ onToggle }
className="variations-actions-menu__toogle"
>
<span>{ __( 'Quick update', 'woocommerce' ) }</span>
</Button>
) }
renderContent={ ( { onClose } ) => (
<div className="components-dropdown-menu__menu">
<MenuGroup>
<UpdateStockMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<SetListPriceMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
</MenuGroup>
<MenuGroup>
<PricingMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<InventoryMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<ShippingMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
</MenuGroup>
<MenuGroup>
<MenuItem
isDestructive
variant="link"
onClick={ () => {
onDelete( selection );
onClose();
} }
className="woocommerce-product-variations__actions--delete"
>
{ __( 'Delete', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</div>
) }
/>
);
}

View File

@ -13,8 +13,21 @@ import {
ProductVariation,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { ListItem, Pagination, Sortable, Tag } from '@woocommerce/components';
import { useContext, useState, createElement } from '@wordpress/element';
import {
ListItem,
Sortable,
Tag,
PaginationPageSizePicker,
PaginationPageArrowsWithPicker,
usePagination,
} from '@woocommerce/components';
import {
useContext,
useState,
createElement,
useRef,
useMemo,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import classnames from 'classnames';
import truncate from 'lodash/truncate';
@ -31,12 +44,13 @@ import HiddenIcon from './hidden-icon';
import VisibleIcon from './visible-icon';
import { getProductStockStatus, getProductStockStatusClass } from '../../utils';
import {
DEFAULT_PER_PAGE_OPTION,
DEFAULT_VARIATION_PER_PAGE_OPTION,
PRODUCT_VARIATION_TITLE_LIMIT,
TRACKS_SOURCE,
} from '../../constants';
import { VariationActionsMenu } from './variation-actions-menu';
import { useSelection } from '../../hooks/use-selection';
import { VariationsActionsMenu } from './variations-actions-menu';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
@ -44,7 +58,10 @@ const UPDATING_TEXT = __( 'Updating product variation', 'woocommerce' );
export function VariationsTable() {
const [ currentPage, setCurrentPage ] = useState( 1 );
const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION );
const lastVariations = useRef< ProductVariation[] | null >( null );
const [ perPage, setPerPage ] = useState(
DEFAULT_VARIATION_PER_PAGE_OPTION
);
const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
{}
);
@ -58,53 +75,83 @@ export function VariationsTable() {
} = useSelection();
const productId = useEntityId( 'postType', 'product' );
const requestParams = useMemo(
() => ( {
product_id: productId,
page: currentPage,
per_page: perPage,
order: 'asc',
orderby: 'menu_order',
} ),
[ productId, currentPage, perPage ]
);
const totalCountRequestParams = useMemo(
() => ( {
product_id: productId,
order: 'asc',
orderby: 'menu_order',
} ),
[ productId ]
);
const context = useContext( CurrencyContext );
const { formatAmount } = context;
const { isLoading, variations, totalCount, isGeneratingVariations } =
useSelect(
( select ) => {
const {
getProductVariations,
hasFinishedResolution,
getProductVariationsTotalCount,
isGeneratingVariations: getIsGeneratingVariations,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const requestParams = {
product_id: productId,
page: currentPage,
per_page: perPage,
order: 'asc',
orderby: 'menu_order',
};
return {
isLoading: ! hasFinishedResolution(
'getProductVariations',
[ requestParams ]
),
isGeneratingVariations: getIsGeneratingVariations( {
product_id: productId,
} ),
variations:
getProductVariations< ProductVariation[] >(
requestParams
),
totalCount:
getProductVariationsTotalCount< number >(
requestParams
),
};
},
[ currentPage, perPage, productId ]
);
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const { updateProductVariation, deleteProductVariation } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
return {
totalCount: getProductVariationsTotalCount< number >(
totalCountRequestParams
),
};
},
[ productId ]
);
const { isLoading, latestVariations, isGeneratingVariations } = useSelect(
( select ) => {
const {
getProductVariations,
hasFinishedResolution,
isGeneratingVariations: getIsGeneratingVariations,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
return {
isLoading: ! hasFinishedResolution( 'getProductVariations', [
requestParams,
] ),
isGeneratingVariations: getIsGeneratingVariations( {
product_id: requestParams.product_id,
} ),
latestVariations:
getProductVariations< ProductVariation[] >( requestParams ),
};
},
[ currentPage, perPage, productId ]
);
const paginationProps = usePagination( {
totalCount,
defaultPerPage: DEFAULT_VARIATION_PER_PAGE_OPTION,
onPageChange: setCurrentPage,
onPerPageChange: setPerPage,
} );
const {
updateProductVariation,
deleteProductVariation,
batchUpdateProductVariations,
invalidateResolution,
} = useDispatch( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const { createSuccessNotice, createErrorNotice } =
useDispatch( 'core/notices' );
if ( ! variations && isLoading ) {
if ( latestVariations && latestVariations !== lastVariations.current ) {
lastVariations.current = latestVariations;
}
if ( isLoading && lastVariations.current === null ) {
return (
<div className="woocommerce-product-variations__loading">
<Spinner />
@ -116,6 +163,8 @@ export function VariationsTable() {
</div>
);
}
// this prevents a weird jump from happening while changing pages.
const variations = latestVariations || lastVariations.current;
const variationIds = variations.map( ( { id } ) => id );
@ -174,22 +223,72 @@ export function VariationsTable() {
);
}
function handleUpdateAll( update: Partial< ProductVariation >[] ) {
batchUpdateProductVariations< { update: [] } >(
{ product_id: productId },
{ update }
)
.then( ( response ) =>
invalidateResolution( 'getProductVariations', [
requestParams,
] ).then( () => response )
)
.then( ( response ) => {
createSuccessNotice(
sprintf(
/* translators: The updated variations count */
__( '%s variation/s updated.', 'woocommerce' ),
response.update.length
)
);
} )
.catch( () => {
createErrorNotice(
__( 'Failed to update variations.', 'woocommerce' )
);
} );
}
function handleDeleteAll( values: Partial< ProductVariation >[] ) {
batchUpdateProductVariations< { delete: [] } >(
{ product_id: productId },
{
delete: values.map( ( { id } ) => id ),
}
)
.then( ( response ) =>
invalidateResolution( 'getProductVariations', [
requestParams,
] ).then( () => response )
)
.then( ( response ) => {
createSuccessNotice(
sprintf(
/* translators: The updated variations count */
__( '%s variation/s updated.', 'woocommerce' ),
response.delete.length
)
);
} )
.catch( () => {
createErrorNotice(
__( 'Failed to delete variations.', 'woocommerce' )
);
} );
}
return (
<div className="woocommerce-product-variations">
{ isLoading ||
( isGeneratingVariations && (
<div className="woocommerce-product-variations__loading">
<Spinner />
{ isGeneratingVariations && (
<span>
{ __(
'Generating variations…',
'woocommerce'
) }
</span>
) }
</div>
) ) }
{ ( isLoading || isGeneratingVariations ) && (
<div className="woocommerce-product-variations__loading">
<Spinner />
{ isGeneratingVariations && (
<span>
{ __( 'Generating variations…', 'woocommerce' ) }
</span>
) }
</div>
) }
<div className="woocommerce-product-variations__header">
<div className="woocommerce-product-variations__selection">
<CheckboxControl
@ -203,7 +302,7 @@ export function VariationsTable() {
onChange={ onSelectAll( variationIds ) }
/>
</div>
<div>
<div className="woocommerce-product-variations__filters">
<Button
variant="tertiary"
disabled={ areAllSelected( variationIds ) }
@ -219,8 +318,18 @@ export function VariationsTable() {
{ __( 'Clear selection', 'woocommerce' ) }
</Button>
</div>
<div>
<VariationsActionsMenu
selection={ variations.filter( ( variation ) =>
isSelected( variation.id )
) }
disabled={ ! hasSelection( variationIds ) }
onChange={ handleUpdateAll }
onDelete={ handleDeleteAll }
/>
</div>
</div>
<Sortable>
<Sortable className="woocommerce-product-variations__table">
{ variations.map( ( variation ) => (
<ListItem key={ `${ variation.id }` }>
<div className="woocommerce-product-variations__selection">
@ -353,26 +462,38 @@ export function VariationsTable() {
</Tooltip>
) }
<VariationActionsMenu
variation={ variation }
selection={ variation }
onChange={ ( value ) =>
handleVariationChange( variation.id, value )
}
onDelete={ handleDeleteVariationClick }
onDelete={ ( { id } ) =>
handleDeleteVariationClick( id )
}
/>
</div>
</ListItem>
) ) }
</Sortable>
<Pagination
className="woocommerce-product-variations__footer"
page={ currentPage }
perPage={ perPage }
total={ totalCount }
showPagePicker={ false }
onPageChange={ setCurrentPage }
onPerPageChange={ setPerPage }
/>
{ totalCount > 5 && (
<div className="woocommerce-product-variations__footer woocommerce-pagination">
<div>
{ sprintf(
__( 'Viewing %d-%d of %d items', 'woocommerce' ),
paginationProps.start,
paginationProps.end,
totalCount
) }
</div>
<PaginationPageArrowsWithPicker { ...paginationProps } />
<PaginationPageSizePicker
{ ...paginationProps }
total={ totalCount }
perPageOptions={ [ 5, 10, 25 ] }
label=""
/>
</div>
) }
</div>
);
}

View File

@ -62,3 +62,5 @@ export const TRACKS_SOURCE = 'product-block-editor-v1';
* @see https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/components/src/pagination/index.js#L12
*/
export const DEFAULT_PER_PAGE_OPTION = 25;
export const DEFAULT_VARIATION_PER_PAGE_OPTION = 5;

View File

@ -172,15 +172,18 @@ describe( 'useProductAttributes', () => {
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [
allAttributes[ 0 ],
allAttributes[ 1 ],
{ ...testAttributes[ 0 ] },
] );
expect( onChange ).toHaveBeenCalledWith( [
{ ...allAttributes[ 0 ], position: 0 },
{ ...allAttributes[ 1 ], position: 1 },
{ ...testAttributes[ 0 ], variation: false, position: 2 },
{ ...allAttributes[ 0 ], isDefault: false },
{ ...allAttributes[ 1 ], isDefault: false },
{ ...testAttributes[ 0 ], isDefault: false },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 0 ], position: 0 },
{ ...allAttributes[ 1 ], position: 1 },
{ ...testAttributes[ 0 ], variation: false, position: 2 },
],
[]
);
} );
it( 'should keep both variable and non variable as part of the onChange list, when isVariation is false', async () => {
@ -202,12 +205,17 @@ describe( 'useProductAttributes', () => {
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [ { ...testAttributes[ 0 ] } ] );
expect( onChange ).toHaveBeenCalledWith( [
{ ...testAttributes[ 0 ], variation: false, position: 0 },
{ ...allAttributes[ 0 ], position: 1 },
{ ...allAttributes[ 1 ], position: 2 },
result.current.handleChange( [
{ ...testAttributes[ 0 ], isDefault: false },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...testAttributes[ 0 ], variation: false, position: 0 },
{ ...allAttributes[ 0 ], position: 1 },
{ ...allAttributes[ 1 ], position: 2 },
],
[]
);
} );
it( 'should keep both variable and non variable as part of the onChange list, when isVariation is true', async () => {
@ -229,12 +237,17 @@ describe( 'useProductAttributes', () => {
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [ { ...testAttributes[ 0 ] } ] );
expect( onChange ).toHaveBeenCalledWith( [
{ ...allAttributes[ 0 ], position: 0 },
{ ...allAttributes[ 1 ], position: 1 },
{ ...testAttributes[ 0 ], variation: true, position: 2 },
result.current.handleChange( [
{ ...testAttributes[ 0 ], isDefault: false },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 0 ], position: 0 },
{ ...allAttributes[ 1 ], position: 1 },
{ ...testAttributes[ 0 ], variation: true, position: 2 },
],
[]
);
} );
it( 'should remove duplicate globals', async () => {
@ -256,11 +269,16 @@ describe( 'useProductAttributes', () => {
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [ { ...testAttributes[ 1 ] } ] );
expect( onChange ).toHaveBeenCalledWith( [
{ ...allAttributes[ 1 ], position: 0 },
{ ...allAttributes[ 0 ], position: 1, variation: true },
result.current.handleChange( [
{ ...testAttributes[ 1 ], isDefault: false },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 1 ], position: 0 },
{ ...allAttributes[ 0 ], position: 1, variation: true },
],
[]
);
} );
it( 'should remove duplicate locals by name', async () => {
@ -282,11 +300,94 @@ describe( 'useProductAttributes', () => {
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [ { ...testAttributes[ 0 ] } ] );
expect( onChange ).toHaveBeenCalledWith( [
{ ...allAttributes[ 1 ], position: 0 },
{ ...allAttributes[ 0 ], position: 1, variation: true },
result.current.handleChange( [
{ ...testAttributes[ 0 ], isDefault: false },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 1 ], position: 0 },
{ ...allAttributes[ 0 ], position: 1, variation: true },
],
[]
);
} );
it( 'should pass default attributes as second param, defaulting to true when isDefault is not defined', async () => {
const allAttributes = [
{ ...testAttributes[ 0 ] },
{ ...testAttributes[ 1 ] },
];
const onChange = jest.fn();
const { result, waitForNextUpdate } = renderHook(
useProductAttributes,
{
initialProps: {
allAttributes,
onChange,
isVariationAttributes: true,
productId: 123,
},
}
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [ { ...testAttributes[ 0 ] } ] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 1 ], position: 0 },
{ ...allAttributes[ 0 ], position: 1, variation: true },
],
[
{
id: testAttributes[ 0 ].id,
name: testAttributes[ 0 ].name,
option: testAttributes[ 0 ].options[ 0 ],
},
]
);
} );
it( 'should pass default attributes as second param, when isDefault is true', async () => {
const allAttributes = [
{ ...testAttributes[ 0 ] },
{ ...testAttributes[ 1 ] },
];
const onChange = jest.fn();
const { result, waitForNextUpdate } = renderHook(
useProductAttributes,
{
initialProps: {
allAttributes,
onChange,
isVariationAttributes: true,
productId: 123,
},
}
);
jest.runOnlyPendingTimers();
await waitForNextUpdate();
result.current.handleChange( [
{ ...testAttributes[ 0 ], isDefault: true },
{ ...testAttributes[ 1 ], isDefault: true },
] );
expect( onChange ).toHaveBeenCalledWith(
[
{ ...allAttributes[ 0 ], position: 0, variation: true },
{ ...allAttributes[ 1 ], position: 1, variation: true },
],
[
{
id: testAttributes[ 0 ].id,
name: testAttributes[ 0 ].name,
option: testAttributes[ 0 ].options[ 0 ],
},
{
id: testAttributes[ 1 ].id,
name: testAttributes[ 1 ].name,
option: testAttributes[ 1 ].options[ 0 ],
},
]
);
} );
} );

View File

@ -3,8 +3,10 @@
*/
import {
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
Product,
ProductAttribute,
ProductAttributeTerm,
ProductDefaultAttribute,
} from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
@ -23,7 +25,10 @@ export type EnhancedProductAttribute = ProductAttribute & {
type useProductAttributesProps = {
allAttributes: ProductAttribute[];
isVariationAttributes?: boolean;
onChange: ( attributes: ProductAttribute[] ) => void;
onChange: (
attributes: ProductAttribute[],
defaultAttributes: ProductDefaultAttribute[]
) => void;
productId?: number;
};
@ -36,6 +41,29 @@ const getFilteredAttributes = (
: attr.filter( ( attribute ) => ! attribute.variation );
};
function manageDefaultAttributes( values: EnhancedProductAttribute[] ) {
return values.reduce< Product[ 'default_attributes' ] >(
( prevDefaultAttributes, currentAttribute ) => {
if (
// defaults to true.
currentAttribute.isDefault === undefined ||
currentAttribute.isDefault === true
) {
return [
...prevDefaultAttributes,
{
id: currentAttribute.id,
name: currentAttribute.name,
option: currentAttribute.options[ 0 ],
},
];
}
return prevDefaultAttributes;
},
[]
);
}
export function useProductAttributes( {
allAttributes = [],
isVariationAttributes = false,
@ -91,6 +119,7 @@ export function useProductAttributes( {
};
const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
const defaultAttributes = manageDefaultAttributes( newAttributes );
let otherAttributes = isVariationAttributes
? allAttributes.filter( ( attribute ) => ! attribute.variation )
: allAttributes.filter( ( attribute ) => !! attribute.variation );
@ -126,15 +155,15 @@ export function useProductAttributes( {
);
if ( isVariationAttributes ) {
onChange( [
...otherAugmentedAttributes,
...newAugmentedAttributes,
] );
onChange(
[ ...otherAugmentedAttributes, ...newAugmentedAttributes ],
defaultAttributes
);
} else {
onChange( [
...newAugmentedAttributes,
...otherAugmentedAttributes,
] );
onChange(
[ ...newAugmentedAttributes, ...otherAugmentedAttributes ],
defaultAttributes
);
}
};

View File

@ -4,7 +4,10 @@
import { useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { useCallback, useState } from '@wordpress/element';
import { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from '@woocommerce/data';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
ProductDefaultAttribute,
} from '@woocommerce/data';
/**
* Internal dependencies
@ -25,7 +28,10 @@ export function useProductVariationsHelper() {
const [ isGenerating, setIsGenerating ] = useState( false );
const generateProductVariations = useCallback(
async ( attributes: EnhancedProductAttribute[] ) => {
async (
attributes: EnhancedProductAttribute[],
defaultAttributes?: ProductDefaultAttribute[]
) => {
setIsGenerating( true );
const hasVariableAttribute = attributes.some(
@ -42,6 +48,7 @@ export function useProductVariationsHelper() {
{
type: hasVariableAttribute ? 'variable' : 'simple',
attributes,
default_attributes: defaultAttributes,
},
{
delete: true,

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export type HandlePromptProps = {
message?: string;
defaultValue?: string;
onOk( value: string ): void;
onCancel?(): void;
};
export async function handlePrompt( {
message = __( 'Enter a value', 'woocommerce' ),
defaultValue,
onOk,
onCancel,
}: HandlePromptProps ) {
// eslint-disable-next-line no-alert
const value = window.prompt( message, defaultValue );
if ( value === null ) {
if ( onCancel ) {
onCancel();
}
return;
}
onOk( value );
}

View File

@ -5,7 +5,6 @@
* External dependencies
*/
import classnames from 'classnames';
import { useState } from '@wordpress/element';
import {
useReducedMotion,
useResizeObserver,

View File

@ -1,27 +1,26 @@
Data
====
# Data
WooCommerce Admin data stores implement the [`SqlQuery` class](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php).
### SqlQuery Class
## SqlQuery Class
The `SqlQuery` class is a SQL Query statement object. Its properties consist of
- A `context` string identifying the context of the query.
- SQL clause (`type`) string arrays used to construct the SQL statement:
- `select`
- `from`
- `right_join`
- `join`
- `left_join`
- `where`
- `where_time`
- `group_by`
- `having`
- `order_by`
- `limit`
- `select`
- `from`
- `right_join`
- `join`
- `left_join`
- `where`
- `where_time`
- `group_by`
- `having`
- `order_by`
- `limit`
### Reports Data Stores
## Reports Data Stores
The base DataStore `Automattic\WooCommerce\Admin\API\Reports\DataStore` extends the `SqlQuery` class. The implementation data store classes use the following `SqlQuery` instances:
@ -49,7 +48,7 @@ Query contexts are named as follows:
- Interval Query = Class Context + `_interval`
- Total Query = Class Context + `_total`
### Filters
## Filters
When getting the full statement the clause arrays are passed through two filters where `$context` is the query object context and `$type` is:
@ -64,13 +63,13 @@ When getting the full statement the clause arrays are passed through two filters
The filters are:
- `apply_filters( "wc_admin_clauses_{$type}", $clauses, $context );`
- `apply_filters( "wc_admin_clauses_{$type}_{$context}", $clauses );`
- `apply_filters( "woocommerce_analytics_clauses_{$type}", $clauses, $context );`
- `apply_filters( "woocommerce_analytics_clauses_{$type}_{$context}", $clauses );`
Example usage
```
add_filter( 'wc_admin_clauses_product_stats_select_total', 'my_custom_product_stats' );
```php
add_filter( 'woocommerce_analytics_clauses_product_stats_select_total', 'my_custom_product_stats' );
/**
* Add sample data to product stats totals.
*

View File

@ -0,0 +1,90 @@
# Core Profiler
The Core Profiler feature is a modernized and simplified new user setup experience for WooCommerce core. It is the first thing a new merchant will see upon installation of WooCommerce.
It requests the minimum amount of information from a merchant to get their store up and running, and suggests some optional extensions that may fulfil common needs for new stores.
There are 4 pages in the Core Profiler:
1. Introduction & Data sharing opt-in
2. User Profile - Some questions determining the user's entry point to the WooCommerce setup
3. Business Information - Store Details and Location
4. Extensions - Optional extensions that may be useful to the merchant
If the merchant chooses to install any extensions that require Jetpack, they will then be redirected to WordPress.com to login to Jetpack after the extensions page. Upon completion of that, they will be returned back to the WooCommerce Admin homescreen which contains the Task List. The Task List will provide next steps for the merchant to continue with their store setup.
## Development
The Core Profiler is gated behind the `core-profiler` feature flag, but is enabled by default.
This feature is the first feature in WooCommerce to use XState for state management, and so naming and organisational conventions will be developed as the team's experience with XState grows.
Refer to the [XState Dev Tooling](xstate.md) documentation for information on how to use the XState visualizer to debug the state machines.
The state machine for the Core Profiler is centrally located at `./client/core-profiler/index.tsx`, and is responsible for managing the state of the entire feature. It is responsible for rendering the correct page based on the current state, handling events that are triggered by the user, triggering side effects such as API calls and handling the responses. It also handles updating the browser URL state as well as responding to changes in it.
While working on this feature, bear in mind that the state machine should interact with WordPress and WooCommerce via actions and services, and the UI code should not be responsible for any API calls or interaction with WordPress Data Stores. This allows us to easily render the UI pages in isolation, for example use in Storybook. The UI pages should only send events back to the state machine in order to trigger side effects.
## Saving and retrieving data
As of writing, the following options are saved (and retrieved if the user has already completed the Core Profiler):
- `blogname`: string
This stores the name of the store, which is used in the store header and in the browser tab title, among other places.
- `woocommerce_onboarding_profile`:
```typescript
{
business_choice: "im_just_starting_my_business" | "im_already_selling" | "im_setting_up_a_store_for_a_client" | undefined
selling_online_answer: "yes_im_selling_online" | "no_im_selling_offline" | "im_selling_both_online_and_offline" | undefined
selling_platforms: ("amazon" | "adobe_commerce" | "big_cartel" | "big_commerce" | "ebay" | "ecwid" | "etsy" | "facebook_marketplace" | "google_shopping" | "pinterest" | "shopify" | "square" | "squarespace" | "wix" | "wordpress")[] | undefined
is_store_country_set: true | false
industry: "clothing_and_accessories" | "health_and_beauty" | "food_and_drink" | "home_furniture_and_garden" | "education_and_learning" | "electronics_and_computers" | "other"
}
```
This stores the merchant's onboarding profile, some of which are used for suggesting extensions and toggling other features.
- `woocommerce_default_country`: e.g 'US:CA', 'SG', 'AU:VIC'
This stores the location that the WooCommerce store believes it is in. This is used for determining extensions eligibility.
- `woocommerce_allow_tracking`: 'yes' | 'no'
This determines whether we return telemetry to Automattic.
As this information is not automatically updated, it would be best to refer directly to the data types present in the source code for the most up to date information.
### API Calls
The following WP Data API calls are used in the Core Profiler:
- `resolveSelect( ONBOARDING_STORE_NAME ).getFreeExtensions()`
This is used to retrieve the list of extensions that will be shown on the Extensions page. It makes an API call to the WooCommerce REST API, which will make a call to WooCommerce.com if permitted. Otherwise it retrieves the locally stored list of free extensions.
- `resolveSelect( COUNTRIES_STORE_NAME ).getCountries()`
This is used to retrieve the list of countries that will be shown in the Country dropdown on the Business Information page. It makes an API call to the WooCommerce REST API.
- `resolveSelect( COUNTRIES_STORE_NAME ).geolocate()`
This is used to retrieve the country that the store believes it is in. It makes an API call to the WordPress.com geolocation API, if permitted. Otherwise it will not be used.
- `resolveSelect( PLUGINS_STORE_NAME ).isJetpackConnected()`
This is used to determine whether the store is connected to Jetpack.
- `resolveSelect( ONBOARDING_STORE_NAME ).getJetpackAuthUrl()`
This is used to retrieve the URL that the browser should be redirected to in order to connect to Jetpack.
### Extensions Installation
The Core Profiler has a loading screen that is shown after the Extensions page. This loading screen is meant to hide the installation of Extensions, while also giving the user a sense of progress. At the same time, some extensions take extremely long to install, and thus we have a 30 second timeout.
The selected extensions will be put into an installation queue, and the queue will be processed sequentially while the loader is on screen.
Beyond the 30 second timeout, the remaining plugins will be installed in the background, and the user will be redirected to the WooCommerce Admin homescreen or the Jetpack connection page.

View File

@ -1,4 +1,8 @@
# WooCommerce Onboarding
# WooCommerce Onboarding (DEPRECATED)
**Refer to the new [Core Profiler](./core-profiler.md) documentation for the latest onboarding experience.**
**Some parts of this documentation are still relevant and will be gradually moved to the new Core Profiler documentation.**
The onboarding feature is a reimagined new user setup experience for WooCommerce core. It contains two sections aimed at getting merchants started with their stores. The merchant begins with the "profile wizard", which helps with initial steps like setting a store address, making extension recommendations, and connecting to Jetpack for additional features. Once the profile wizard is complete, the merchant can purchase & install optional extensions via WooCommerce.com, before continuing to the "task list". From the task list, merchants are walked through a variety of items to help them start selling, such as adding their first product and setting up payment methods.
@ -6,7 +10,7 @@ The onboarding feature is a reimagined new user setup experience for WooCommerce
If you run the development version of WooCommerce Admin from GitHub directly, no further action should be needed to enable Onboarding.
Users of the published WooCommerce Admin plugin need to either opt-in to using the new onboarding experience manually, or become part of the a/b test in core. See https://github.com/woocommerce/woocommerce/pull/24991 for more details on the testing implementation.
Users of the published WooCommerce Admin plugin need to either opt-in to using the new onboarding experience manually, or become part of the a/b test in core. See [https://github.com/woocommerce/woocommerce/pull/24991](https://github.com/woocommerce/woocommerce/pull/24991) for more details on the testing implementation.
To enable the new onboarding experience manually, log-in to `wp-admin`, and go to `WooCommerce > Settings > Help > Setup Wizard`. Click `Enable` under the `New onboarding experience` heading.
@ -63,7 +67,7 @@ To make the connection from the new onboarding experience possible, we build our
Both of these endpoints use WooCommerce Core's `WC_Helper_API` directly. The main difference with our connection (compared to the connection on the subscriptions page) is the addition of two additional query string parameters:
* `wccom-from=onboarding`, which is used to tell WooCommerce.com & WordPress.com that we are connecting from the new onboarding flow. This parameter is passed from the user's site to WooCommerce.com and finally into Calypso, so that the Calypso login and sign-up screens match the rest of the profile wizard (https://github.com/Automattic/wp-calypso/pull/35193). Without this parameter, you would end up on the existing WooCommerce OAuth screen.
* `wccom-from=onboarding`, which is used to tell WooCommerce.com & WordPress.com that we are connecting from the new onboarding flow. This parameter is passed from the user's site to WooCommerce.com and finally into Calypso, so that the Calypso login and sign-up screens match the rest of the profile wizard [https://github.com/Automattic/wp-calypso/pull/35193](https://github.com/Automattic/wp-calypso/pull/35193). Without this parameter, you would end up on the existing WooCommerce OAuth screen.
* `calypso_env` allows us to load different versions of Calypso when testing. See the Calypso section below.
To disconnect from WooCommerce.com, go to `WooCommerce > Extensions > WooCommerce.com Subscriptions > Connected to WooCommerce.com > Disconnect`.
@ -77,7 +81,7 @@ We have a special Jetpack connection flow designed specifically for WooCommerce
We use Jetpack's `build_connect_url` function directly, but add the following two query parameters:
* `calypso_env`, which allows us to load different versions of Calypso when testing. See the Calypso section below.
* `from=woocommerce-onboarding`, which is used to conditionally show the WooCommerce themed Jetpack authorization process (https://github.com/Automattic/wp-calypso/pull/34380). Without this parameter, you would end up in the normal Jetpack authorization flow.
* `from=woocommerce-onboarding`, which is used to conditionally show the WooCommerce themed Jetpack authorization process [https://github.com/Automattic/wp-calypso/pull/34380](https://github.com/Automattic/wp-calypso/pull/34380). Without this parameter, you would end up in the normal Jetpack authorization flow.
The user is prompted to install and connect to Jetpack as the first step of the profile wizard. If the user hasn't connected when they arrive at the task list, we also prompt them on certain tasks to make the setup process easier, such as the shipping and tax steps.
@ -89,7 +93,7 @@ To disconnect from Jetpack, go to `Jetpack > Dashboard > Connections > Site conn
Both the WooCommerce.com & Jetpack connection processes (outlined below) send the user to [Calypso](https://github.com/Automattic/wp-calypso), the interface that powers WordPress.com, to sign-up or login.
By default, a merchant will end up on a production version of Calypso (https://wordpress.com). If we make changes to the Calypso part of the flow and want to test them, we can do so with a `calypso_env` query parameter passed by both of our connection methods.
By default, a merchant will end up on a production version of Calypso [https://wordpress.com](https://wordpress.com). If we make changes to the Calypso part of the flow and want to test them, we can do so with a `calypso_env` query parameter passed by both of our connection methods.
To change the value of `calypso_env`, set `WOOCOMMERCE_CALYPSO_ENVIRONMENT` to one of the following values in your `wp-config.php`:

View File

@ -0,0 +1,50 @@
# XState Dev Tooling
XState is a state management framework for Javascript applications. It is based on the concept of [finite state machines](https://en.wikipedia.org/wiki/Finite-state_machine) and statecharts.
In order to help you visualize and debug the state machines within WooCommerce Core, XState provides a [visualizer](https://stately.ai/viz) that you can use in your browser.
## Enabling the visualizer
To enable this, run this command in your browser's developer console:
```js
localStorage.setItem('xstate_inspect', 'true');
```
Then, a new tab with the XState visualizer should appear for pages that have state machines.
## Using the visualizer
### Main View
The main panel in the visualizer will show you the current state of the state machine. Current states are shown with a blue border, and available transitions are solid blue bubbles. If the events are simple without payload requirements, you can click on the transition bubbles to trigger the transition. Otherwise, you can use the 'Send event' button on the bottom right of the visualizer to send events to the state machine.
### State Tab
The context is the working memory that the state machine has access to. It is a plain Javascript object that can be modified by the state machine.
Within the 'State' tab, you can see the printouts of three objects: "Value", "Context", and "State".
The 'Value' object is simply the current state that the state machine is in, and it may be an object if there are nested state machines.
The 'Context' object is the current context of the state machine. It is a plain Javascript object that can be modified by the state machine. The context is used to store information that is relevant to the state machine, but not part of the state itself. For example, the context may contain information about the user, or the current page that the user is on.
The 'State' object is the current state of the state machine. It contains information about the current state, the events that led to the current state, as well as the history object which contains the previous states.
### Events Tab
The 'Events' tab shows the events that have occurred since the state machine was initialized. You can click on the events to see the payload of the events.
Clicking on 'Show built-in events' will include built-in events in the list. These are non-user events that are triggered by the state machine itself, such as invoked promises.
These events are useful for debugging, as they can help you understand what events have occurred and what the payload of the events are, and provides a similar functionality to Redux Dev Tools.
### Actors Tab
If there is more than one state machine active (e.g, there are multiple state machines or there are child state machines), you can select which one to inspect by clicking on the 'Actors' tab on the top right of the visualizer.

View File

@ -217,6 +217,12 @@ const webpackConfig = {
return null;
}
if ( request === '@wordpress/router' ) {
// The external wp.router does not exist in WP 6.2 and below, so we need to skip requesting to external here.
// We use the router in the customize store. We can remove this once our minimum support is WP 6.3.
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

@ -268,16 +268,16 @@
},
{
"name": "symfony/console",
"version": "v5.4.26",
"version": "v5.4.28",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "b504a3d266ad2bb632f196c0936ef2af5ff6e273"
"reference": "f4f71842f24c2023b91237c72a365306f3c58827"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/b504a3d266ad2bb632f196c0936ef2af5ff6e273",
"reference": "b504a3d266ad2bb632f196c0936ef2af5ff6e273",
"url": "https://api.github.com/repos/symfony/console/zipball/f4f71842f24c2023b91237c72a365306f3c58827",
"reference": "f4f71842f24c2023b91237c72a365306f3c58827",
"shasum": ""
},
"require": {
@ -347,7 +347,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v5.4.26"
"source": "https://github.com/symfony/console/tree/v5.4.28"
},
"funding": [
{
@ -363,7 +363,7 @@
"type": "tidelift"
}
],
"time": "2023-07-19T20:11:33+00:00"
"time": "2023-08-07T06:12:30+00:00"
},
{
"name": "symfony/deprecation-contracts",
@ -497,16 +497,16 @@
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "5bbc823adecdae860bb64756d639ecfec17b050a"
"reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a",
"reference": "5bbc823adecdae860bb64756d639ecfec17b050a",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
"reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
"shasum": ""
},
"require": {
@ -521,7 +521,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -559,7 +559,7 @@
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0"
},
"funding": [
{
@ -575,20 +575,20 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-01-26T09:26:14+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "511a08c03c1960e08a883f4cffcacd219b758354"
"reference": "875e90aeea2777b6f135677f618529449334a612"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/511a08c03c1960e08a883f4cffcacd219b758354",
"reference": "511a08c03c1960e08a883f4cffcacd219b758354",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612",
"reference": "875e90aeea2777b6f135677f618529449334a612",
"shasum": ""
},
"require": {
@ -600,7 +600,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -640,7 +640,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0"
},
"funding": [
{
@ -656,20 +656,20 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-01-26T09:26:14+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6"
"reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/19bd1e4fcd5b91116f14d8533c57831ed00571b6",
"reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
"reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
"shasum": ""
},
"require": {
@ -681,7 +681,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -724,7 +724,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0"
},
"funding": [
{
@ -740,20 +740,20 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-01-26T09:26:14+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534"
"reference": "42292d99c55abe617799667f454222c54c60e229"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534",
"reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229",
"reference": "42292d99c55abe617799667f454222c54c60e229",
"shasum": ""
},
"require": {
@ -768,7 +768,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -807,7 +807,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0"
},
"funding": [
{
@ -823,20 +823,20 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-07-28T09:04:16+00:00"
},
{
"name": "symfony/polyfill-php73",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
"reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9"
"reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/9e8ecb5f92152187c4799efd3c96b78ccab18ff9",
"reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9",
"url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fe2f306d1d9d346a7fee353d0d5012e401e984b5",
"reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5",
"shasum": ""
},
"require": {
@ -845,7 +845,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -886,7 +886,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php73/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-php73/tree/v1.28.0"
},
"funding": [
{
@ -902,20 +902,20 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-01-26T09:26:14+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.27.0",
"version": "v1.28.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936"
"reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936",
"reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
"reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
"shasum": ""
},
"require": {
@ -924,7 +924,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.27-dev"
"dev-main": "1.28-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -969,7 +969,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0"
},
"funding": [
{
@ -985,7 +985,7 @@
"type": "tidelift"
}
],
"time": "2022-11-03T14:55:06+00:00"
"time": "2023-01-26T09:26:14+00:00"
},
{
"name": "symfony/service-contracts",
@ -1169,5 +1169,5 @@
"platform-overrides": {
"php": "7.3"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.1.0"
}

View File

@ -258,16 +258,16 @@
},
{
"name": "sirbrillig/phpcs-changed",
"version": "v2.11.2",
"version": "v2.11.3",
"source": {
"type": "git",
"url": "https://github.com/sirbrillig/phpcs-changed.git",
"reference": "03492be0d8ef076c6ca5189312ee39eb36767442"
"reference": "9fc94ed9adee571b7e30da4467b1d41073a9dd1c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sirbrillig/phpcs-changed/zipball/03492be0d8ef076c6ca5189312ee39eb36767442",
"reference": "03492be0d8ef076c6ca5189312ee39eb36767442",
"url": "https://api.github.com/repos/sirbrillig/phpcs-changed/zipball/9fc94ed9adee571b7e30da4467b1d41073a9dd1c",
"reference": "9fc94ed9adee571b7e30da4467b1d41073a9dd1c",
"shasum": ""
},
"require": {
@ -306,9 +306,9 @@
"description": "Run phpcs on files, but only report warnings/errors from lines which were changed.",
"support": {
"issues": "https://github.com/sirbrillig/phpcs-changed/issues",
"source": "https://github.com/sirbrillig/phpcs-changed/tree/v2.11.2"
"source": "https://github.com/sirbrillig/phpcs-changed/tree/v2.11.3"
},
"time": "2023-08-21T15:07:52+00:00"
"time": "2023-08-24T23:27:01+00:00"
},
{
"name": "squizlabs/php_codesniffer",
@ -473,5 +473,5 @@
"platform-overrides": {
"php": "7.2"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.1.0"
}

View File

@ -1711,5 +1711,5 @@
"platform-overrides": {
"php": "7.0"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.1.0"
}

View File

@ -634,5 +634,5 @@
"platform-overrides": {
"php": "7.0"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.1.0"
}

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Add Variation options section back to the product blocks template

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add woocommerce_block_template_register action.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: tweak
Add order property to every block in SimpleProductTemplate

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Added documentation for the Core Profiler

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
FIx WC Admin pages are empty for WP 6.2 and below.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fixed missed lint error in Assembler Hub

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
update the SqlQuery filter prefix in data.md

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Fix a minor code typo, no change in functionality

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Updated WC API Core tests readme.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update WooCommerce Blocks to 11.0.0

View File

@ -23,7 +23,7 @@
"maxmind-db/reader": "^1.11",
"pelago/emogrifier": "^6.0",
"woocommerce/action-scheduler": "3.6.2",
"woocommerce/woocommerce-blocks": "10.9.3"
"woocommerce/woocommerce-blocks": "11.0.0"
},
"require-dev": {
"automattic/jetpack-changelogger": "^3.3.0",

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0fbbf35adfe8924174d461ce53cfe1c6",
"content-hash": "0c27792dd3b1927fff20aa226d428068",
"packages": [
{
"name": "automattic/jetpack-a8c-mc-stats",
@ -1004,16 +1004,16 @@
},
{
"name": "woocommerce/woocommerce-blocks",
"version": "10.9.3",
"version": "11.0.0",
"source": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce-blocks.git",
"reference": "c9b6f46758f446b21cfd9f98ae1d2ca714effbc3"
"reference": "926f174399738ea25de191b1db05cf42ef4635d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/c9b6f46758f446b21cfd9f98ae1d2ca714effbc3",
"reference": "c9b6f46758f446b21cfd9f98ae1d2ca714effbc3",
"url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/926f174399738ea25de191b1db05cf42ef4635d3",
"reference": "926f174399738ea25de191b1db05cf42ef4635d3",
"shasum": ""
},
"require": {
@ -1062,9 +1062,9 @@
],
"support": {
"issues": "https://github.com/woocommerce/woocommerce-blocks/issues",
"source": "https://github.com/woocommerce/woocommerce-blocks/tree/v10.9.3"
"source": "https://github.com/woocommerce/woocommerce-blocks/tree/v11.0.0"
},
"time": "2023-08-24T20:50:04+00:00"
"time": "2023-08-30T10:20:08+00:00"
}
],
"packages-dev": [
@ -3947,5 +3947,5 @@
"platform-overrides": {
"php": "7.4"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.1.0"
}

Some files were not shown because too many files have changed in this diff Show More