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

View File

@ -6,9 +6,8 @@ import { ProductVariation } from '@woocommerce/data';
export type ShippingMenuItemProps = {
variation: ProductVariation;
handlePrompt(
propertyName: keyof ProductVariation,
label?: string,
parser?: ( value: string ) => unknown
parser?: ( value: string ) => Partial< ProductVariation > | null
): 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
*/
import {
Dropdown,
DropdownMenu,
MenuGroup,
MenuItem,
} from '@wordpress/components';
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { chevronRight, moreVertical } from '@wordpress/icons';
import { moreVertical } from '@wordpress/icons';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
@ -19,45 +14,8 @@ import { recordEvent } from '@woocommerce/tracks';
import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { ShippingMenuItem } from '../shipping-menu-item';
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;
}
import { InventoryMenuItem } from '../inventory-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
export function VariationActionsMenu( {
variation,
@ -65,17 +23,18 @@ export function VariationActionsMenu( {
onDelete,
}: VariationActionsMenuProps ) {
function handlePrompt(
propertyName: keyof ProductVariation,
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
const value = window.prompt( label );
if ( value === null ) return;
onChange( {
[ propertyName ]: parser( value.trim() ),
} );
const updates = parser( value.trim() );
if ( updates ) {
onChange( updates );
}
}
return (
@ -113,340 +72,17 @@ export function VariationActionsMenu( {
</MenuItem>
</MenuGroup>
<MenuGroup>
<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(
'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>
) }
<PricingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
onClose={ onClose }
/>
<InventoryMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
onChange={ onChange }
onClose={ onClose }
/>
<ShippingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
@ -456,6 +92,7 @@ export function VariationActionsMenu( {
<MenuGroup>
<MenuItem
isDestructive
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link"
onClick={ () => {
onDelete( variation.id );