Add variation inventory quick actions (#39935)

* Add new inventory submenu to variations actions

* Move pricing submenu up to its own function

* Add tests

* Move inventory markup to seperate component

* Remove menu group label

* Add changelog

* Move pricing menu to own component

* Fix lint errors

* Update use of handlePrompt to make return more flexible
This commit is contained in:
louwie17 2023-08-29 11:46:48 -03:00 committed by GitHub
parent 1356f76f7b
commit db9cb4db4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 961 additions and 436 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new action menu item to variations list for managing inventory.

View File

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

View File

@ -0,0 +1,213 @@
/**
* 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';
import { chevronRight } from '@wordpress/icons';
/**
* Internal dependencies
*/
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;
};
export function InventoryMenuItem( {
variation,
handlePrompt,
onChange,
onClose,
}: InventoryMenuItemProps ) {
return (
<Dropdown
position="middle right"
renderToggle={ ( { isOpen, onToggle } ) => (
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_click',
{
source: TRACKS_SOURCE,
variation_id: variation.id,
}
);
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Inventory', 'woocommerce' ) }
</MenuItem>
) }
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>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'manage_stock_toggle',
variation_id: variation.id,
}
);
onChange( {
manage_stock: ! variation.manage_stock,
} );
onClose();
} }
>
{ __( 'Toggle "track quantity"', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_in_stock',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
onClose();
} }
>
{ __( 'Set status to In stock', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_out_of_stock',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
onClose();
} }
>
{ __(
'Set status to Out of stock',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_on_back_order',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
onClose();
} }
>
{ __(
'Set status to On back order',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
}
);
const lowStockAmount = Number( value );
if ( Number.isNaN( lowStockAmount ) ) {
return null;
}
return {
low_stock_amount: lowStockAmount,
manage_stock: true,
};
} );
onClose();
} }
>
{ __( 'Edit low stock threshold', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</div>
) }
/>
);
}

View File

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

View File

@ -0,0 +1,357 @@
/**
* 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';
import { chevronRight } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
function isPercentage( value: string ) {
return value.endsWith( '%' );
}
function parsePercentage( value: string ) {
const stringNumber = value.substring( 0, value.length - 1 );
if ( Number.isNaN( Number( stringNumber ) ) ) {
return undefined;
}
return Number( stringNumber );
}
function addFixedOrPercentage(
value: string,
fixedOrPercentage: string,
increaseOrDecrease: 1 | -1 = 1
) {
if ( isPercentage( fixedOrPercentage ) ) {
if ( Number.isNaN( Number( value ) ) ) {
return 0;
}
const percentage = parsePercentage( fixedOrPercentage );
if ( percentage === undefined ) {
return Number( value );
}
return (
Number( value ) +
Number( value ) * ( percentage / 100 ) * increaseOrDecrease
);
}
if ( Number.isNaN( Number( value ) ) ) {
if ( Number.isNaN( Number( fixedOrPercentage ) ) ) {
return undefined;
}
return Number( fixedOrPercentage );
}
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,
onClose,
}: PricingMenuItemProps ) {
return (
<Dropdown
position="middle right"
renderToggle={ ( { isOpen, onToggle } ) => (
<MenuItem
onClick={ () => {
recordEvent( 'product_variations_menu_pricing_click', {
source: TRACKS_SOURCE,
variation_id: variation.id,
} );
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Pricing', 'woocommerce' ) }
</MenuItem>
) }
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>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Increase list price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_decrease',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Decrease list price', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<MenuGroup label={ __( 'Sale price', 'woocommerce' ) }>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
}
);
return {
sale_price: value,
};
} );
onClose();
} }
>
{ __( 'Set sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Increase sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Decrease sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Sale start date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
return {
date_on_sale_from_gmt: value,
};
}
);
handlePrompt(
__(
'Sale end date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
return {
date_on_sale_to_gmt: value,
};
}
);
onClose();
} }
>
{ __( 'Schedule sale', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</div>
) }
/>
);
}

View File

@ -49,24 +49,22 @@ export function ShippingMenuItem( {
variation_id: variation.id, variation_id: variation.id,
} }
); );
handlePrompt( handlePrompt( undefined, ( value ) => {
'dimensions', recordEvent(
undefined, 'product_variations_menu_shipping_update',
( value ) => { {
recordEvent( source: TRACKS_SOURCE,
'product_variations_menu_shipping_update', action: 'dimensions_length_set',
{ variation_id: variation.id,
source: TRACKS_SOURCE, }
action: 'dimensions_length_set', );
variation_id: variation.id, return {
} dimensions: {
);
return {
...variation.dimensions, ...variation.dimensions,
length: value, length: value,
}; },
} };
); } );
onClose(); onClose();
} } } }
> >
@ -82,24 +80,22 @@ export function ShippingMenuItem( {
variation_id: variation.id, variation_id: variation.id,
} }
); );
handlePrompt( handlePrompt( undefined, ( value ) => {
'dimensions', recordEvent(
undefined, 'product_variations_menu_shipping_update',
( value ) => { {
recordEvent( source: TRACKS_SOURCE,
'product_variations_menu_shipping_update', action: 'dimensions_width_set',
{ variation_id: variation.id,
source: TRACKS_SOURCE, }
action: 'dimensions_width_set', );
variation_id: variation.id, return {
} dimensions: {
);
return {
...variation.dimensions, ...variation.dimensions,
width: value, width: value,
}; },
} };
); } );
onClose(); onClose();
} } } }
> >
@ -115,24 +111,22 @@ export function ShippingMenuItem( {
variation_id: variation.id, variation_id: variation.id,
} }
); );
handlePrompt( handlePrompt( undefined, ( value ) => {
'dimensions', recordEvent(
undefined, 'product_variations_menu_shipping_update',
( value ) => { {
recordEvent( source: TRACKS_SOURCE,
'product_variations_menu_shipping_update', action: 'dimensions_height_set',
{ variation_id: variation.id,
source: TRACKS_SOURCE, }
action: 'dimensions_height_set', );
variation_id: variation.id, return {
} dimensions: {
);
return {
...variation.dimensions, ...variation.dimensions,
height: value, height: value,
}; },
} };
); } );
onClose(); onClose();
} } } }
> >
@ -148,7 +142,7 @@ export function ShippingMenuItem( {
variation_id: variation.id, variation_id: variation.id,
} }
); );
handlePrompt( 'weight', undefined, ( value ) => { handlePrompt( undefined, ( value ) => {
recordEvent( recordEvent(
'product_variations_menu_shipping_update', 'product_variations_menu_shipping_update',
{ {
@ -157,7 +151,7 @@ export function ShippingMenuItem( {
variation_id: variation.id, variation_id: variation.id,
} }
); );
return value; return { weight: value };
} ); } );
onClose(); onClose();
} } } }

View File

@ -6,9 +6,8 @@ import { ProductVariation } from '@woocommerce/data';
export type ShippingMenuItemProps = { export type ShippingMenuItemProps = {
variation: ProductVariation; variation: ProductVariation;
handlePrompt( handlePrompt(
propertyName: keyof ProductVariation,
label?: string, label?: string,
parser?: ( value: string ) => unknown parser?: ( value: string ) => Partial< ProductVariation > | null
): void; ): void;
onClose(): void; onClose(): void;
}; };

View File

@ -0,0 +1,319 @@
/**
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import React, { createElement } from 'react';
/**
* Internal dependencies
*/
import { VariationActionsMenu } from '../';
import { TRACKS_SOURCE } from '../../../../constants';
import { PRODUCT_STOCK_STATUS_KEYS } from '../../../../utils/get-product-stock-status';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
const mockVariation = {
id: 10,
manage_stock: false,
attributes: [],
} as ProductVariation;
describe( 'VariationActionsMenu', () => {
let onChangeMock: jest.Mock, onDeleteMock: jest.Mock;
beforeEach( () => {
onChangeMock = jest.fn();
onDeleteMock = jest.fn();
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should trigger product_variations_menu_view track when dropdown toggled', () => {
const { getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_view',
{
source: TRACKS_SOURCE,
variation_id: 10,
}
);
} );
it( 'should render dropdown with pricing, inventory, and delete options when opened', () => {
const { queryByText, getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
expect( queryByText( 'Pricing' ) ).toBeInTheDocument();
expect( queryByText( 'Inventory' ) ).toBeInTheDocument();
expect( queryByText( 'Delete' ) ).toBeInTheDocument();
} );
it( 'should call onDelete when Delete menuItem is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Delete' ) );
expect( onDeleteMock ).toHaveBeenCalled();
} );
describe( 'Inventory sub-menu', () => {
it( 'should open Inventory sub-menu if Inventory is clicked with click track', async () => {
const { queryByText, getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_click',
{
source: TRACKS_SOURCE,
variation_id: 10,
}
);
expect( queryByText( 'Update stock' ) ).toBeInTheDocument();
expect(
queryByText( 'Toggle "track quantity"' )
).toBeInTheDocument();
expect(
queryByText( 'Set status to In stock' )
).toBeInTheDocument();
} );
it( 'should onChange with stock_quantity when Update stock is clicked', async () => {
window.prompt = jest.fn().mockReturnValue( '10' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Update stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_quantity: 10,
manage_stock: true,
} );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
} );
it( 'should not call onChange when prompt is cancelled', async () => {
window.prompt = jest.fn().mockReturnValue( null );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Update stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
expect( onChangeMock ).not.toHaveBeenCalledWith( {
stock_quantity: 10,
manage_stock: true,
} );
expect( recordEvent ).not.toHaveBeenCalledWith(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
} );
it( 'should call onChange with toggled manage_stock when toggle "track quantity" is clicked', async () => {
const { getByRole, getByText, rerender } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Toggle "track quantity"' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'manage_stock_toggle',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
manage_stock: true,
} );
onChangeMock.mockClear();
rerender(
<VariationActionsMenu
variation={ { ...mockVariation, manage_stock: true } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Toggle "track quantity"' ) );
expect( onChangeMock ).toHaveBeenCalledWith( {
manage_stock: false,
} );
} );
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 } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to In stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_in_stock',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
} );
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 } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to Out of stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_out_of_stock',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
} );
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 } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to On back order' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_on_back_order',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
} );
it( 'should call onChange with low_stock_amount when Edit low stock threshold is clicked', async () => {
window.prompt = jest.fn().mockReturnValue( '7' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Edit low stock threshold' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
low_stock_amount: 7,
manage_stock: true,
} );
} );
} );
} );

View File

@ -1,15 +1,10 @@
/** /**
* External dependencies * External dependencies
*/ */
import { import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
Dropdown,
DropdownMenu,
MenuGroup,
MenuItem,
} from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element'; import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { chevronRight, moreVertical } from '@wordpress/icons'; import { moreVertical } from '@wordpress/icons';
import { ProductVariation } from '@woocommerce/data'; import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
@ -19,45 +14,8 @@ import { recordEvent } from '@woocommerce/tracks';
import { VariationActionsMenuProps } from './types'; import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants'; import { TRACKS_SOURCE } from '../../../constants';
import { ShippingMenuItem } from '../shipping-menu-item'; import { ShippingMenuItem } from '../shipping-menu-item';
import { InventoryMenuItem } from '../inventory-menu-item';
function isPercentage( value: string ) { import { PricingMenuItem } from '../pricing-menu-item';
return value.endsWith( '%' );
}
function parsePercentage( value: string ) {
const stringNumber = value.substring( 0, value.length - 1 );
if ( Number.isNaN( Number( stringNumber ) ) ) {
return undefined;
}
return Number( stringNumber );
}
function addFixedOrPercentage(
value: string,
fixedOrPercentage: string,
increaseOrDecrease: 1 | -1 = 1
) {
if ( isPercentage( fixedOrPercentage ) ) {
if ( Number.isNaN( Number( value ) ) ) {
return 0;
}
const percentage = parsePercentage( fixedOrPercentage );
if ( percentage === undefined ) {
return Number( value );
}
return (
Number( value ) +
Number( value ) * ( percentage / 100 ) * increaseOrDecrease
);
}
if ( Number.isNaN( Number( value ) ) ) {
if ( Number.isNaN( Number( fixedOrPercentage ) ) ) {
return undefined;
}
return Number( fixedOrPercentage );
}
return Number( value ) + Number( fixedOrPercentage ) * increaseOrDecrease;
}
export function VariationActionsMenu( { export function VariationActionsMenu( {
variation, variation,
@ -65,17 +23,18 @@ export function VariationActionsMenu( {
onDelete, onDelete,
}: VariationActionsMenuProps ) { }: VariationActionsMenuProps ) {
function handlePrompt( function handlePrompt(
propertyName: keyof ProductVariation,
label: string = __( 'Enter a value', 'woocommerce' ), label: string = __( 'Enter a value', 'woocommerce' ),
parser: ( value: string ) => unknown = ( value ) => value parser: ( value: string ) => Partial< ProductVariation > | null = () =>
null
) { ) {
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
const value = window.prompt( label ); const value = window.prompt( label );
if ( value === null ) return; if ( value === null ) return;
onChange( { const updates = parser( value.trim() );
[ propertyName ]: parser( value.trim() ), if ( updates ) {
} ); onChange( updates );
}
} }
return ( return (
@ -113,340 +72,17 @@ export function VariationActionsMenu( {
</MenuItem> </MenuItem>
</MenuGroup> </MenuGroup>
<MenuGroup> <MenuGroup>
<Dropdown <PricingMenuItem
position="middle right" variation={ variation }
renderToggle={ ( { isOpen, onToggle } ) => ( handlePrompt={ handlePrompt }
<MenuItem onClose={ onClose }
onClick={ () => { />
recordEvent( <InventoryMenuItem
'product_variations_menu_pricing_click', variation={ variation }
{ handlePrompt={ handlePrompt }
source: TRACKS_SOURCE, onChange={ onChange }
variation_id: variation.id, onClose={ onClose }
}
);
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Pricing', 'woocommerce' ) }
</MenuItem>
) }
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(
'regular_price',
undefined,
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id:
variation.id,
}
);
return value;
}
);
onClose();
} }
>
{ __(
'Set list price',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id:
variation.id,
}
);
handlePrompt(
'regular_price',
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id:
variation.id,
}
);
return addFixedOrPercentage(
variation.regular_price,
value
)?.toFixed( 2 );
}
);
onClose();
} }
>
{ __(
'Increase list price',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_decrease',
variation_id:
variation.id,
}
);
handlePrompt(
'regular_price',
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id:
variation.id,
}
);
return addFixedOrPercentage(
variation.regular_price,
value,
-1
)?.toFixed( 2 );
}
);
onClose();
} }
>
{ __(
'Decrease list price',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
<MenuGroup
label={ __(
'Sale price',
'woocommerce'
) }
>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id:
variation.id,
}
);
handlePrompt(
'sale_price',
undefined,
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id:
variation.id,
}
);
return value;
}
);
onClose();
} }
>
{ __(
'Set sale price',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id:
variation.id,
}
);
handlePrompt(
'sale_price',
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id:
variation.id,
}
);
return addFixedOrPercentage(
variation.sale_price,
value
)?.toFixed( 2 );
}
);
onClose();
} }
>
{ __(
'Increase sale price',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id:
variation.id,
}
);
handlePrompt(
'sale_price',
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id:
variation.id,
}
);
return addFixedOrPercentage(
variation.sale_price,
value,
-1
)?.toFixed( 2 );
}
);
onClose();
} }
>
{ __(
'Decrease sale price',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id:
variation.id,
}
);
handlePrompt(
'date_on_sale_from_gmt',
__(
'Sale start date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id:
variation.id,
}
);
return value;
}
);
handlePrompt(
'date_on_sale_to_gmt',
__(
'Sale end date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id:
variation.id,
}
);
return value;
}
);
onClose();
} }
>
{ __(
'Schedule sale',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
</div>
) }
/> />
<ShippingMenuItem <ShippingMenuItem
variation={ variation } variation={ variation }
handlePrompt={ handlePrompt } handlePrompt={ handlePrompt }
@ -456,6 +92,7 @@ export function VariationActionsMenu( {
<MenuGroup> <MenuGroup>
<MenuItem <MenuItem
isDestructive isDestructive
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link" variant="link"
onClick={ () => { onClick={ () => {
onDelete( variation.id ); onDelete( variation.id );