Add slotFill for variation menus (#43441)

* Add slotFills to main variation actions

* Add slotFills to groups

* Create variation-actions component

* Add references

* Remove file and change reference

* Add Dropdown

* Add styles

* Add changelog

* split variation-actions-menu

* Add tests

* Remove variation-actions-menu and variations-actions-menu

* Rename variation-actions and add tests

* Rename VariationSingleUpdateMenuItem

* Rename single-update

* Add supportsMultipleSelection and slotFill rename

* Add component export

* Rename groups

* Show multipleSelection items in both components

* Take fills as MenuItems

* Remove __experimental from slotFill

* Fix lint

* Add onClick to slotFill

* Rename constant QUICK_UPDATE, add new constant

* Add tests for getGroupName and getMenuItem

* Rename variation-actions tests

* fix test description

* Add onChange, onClose and selection to slots

* Refactor slotFill

* Refactor slotFill file

* Rename QuickUpdateMenu to MultipleUpdateMenu

* Modify tests

* Fix test name

* Improve import

* Always return array of selected items

* Create MenuGroup for every extension menu items

* Group fills
This commit is contained in:
Fernando Marichal 2024-01-23 13:27:03 -03:00 committed by GitHub
parent 1a4ebebf6a
commit 2acbb3d3fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 991 additions and 459 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Add slotFill for variation menus #43441

View File

@ -32,6 +32,7 @@ export { AttributeControl as __experimentalAttributeControl } from './attribute-
export { Attributes as __experimentalAttributes } from './attributes';
export * from './add-new-shipping-class-modal';
export { VariationSwitcherFooter as __experimentalVariationSwitcherFooter } from './variation-switcher-footer';
export { VariationQuickUpdateMenuItem } from './variations-table/variation-actions-menus';
export * from './remove-confirmation-modal';

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { Dropdown, MenuItem } from '@wordpress/components';
import { Dropdown, MenuItem, MenuGroup } from '@wordpress/components';
import { createElement, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronRight } from '@wordpress/icons';
@ -15,6 +15,7 @@ import { recordEvent } from '@woocommerce/tracks';
import { TRACKS_SOURCE } from '../../../constants';
import { VariationActionsMenuItemProps } from '../types';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationQuickUpdateMenuItem } from '../variation-actions-menus';
const MODAL_CLASS_NAME = 'downloads_menu_item__upload_files_modal';
const MODAL_WRAPPER_CLASS_NAME =
@ -28,6 +29,7 @@ export function DownloadsMenuItem( {
selection,
onChange,
onClose,
supportsMultipleSelection = false,
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
@ -161,44 +163,53 @@ export function DownloadsMenuItem( {
) }
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MediaUpload
modalClass={ MODAL_CLASS_NAME }
// @ts-expect-error multiple also accepts string.
multiple={ 'add' }
value={ downloadsIds }
onSelect={ handleMediaUploadSelect }
render={ ( { open } ) => (
<MenuItem
onClick={ uploadFilesClickHandler( open ) }
>
{ __( 'Upload files', 'woocommerce' ) }
</MenuItem>
) }
<MenuGroup>
<MediaUpload
modalClass={ MODAL_CLASS_NAME }
// @ts-expect-error multiple also accepts string.
multiple={ 'add' }
value={ downloadsIds }
onSelect={ handleMediaUploadSelect }
render={ ( { open } ) => (
<MenuItem
onClick={ uploadFilesClickHandler( open ) }
>
{ __( 'Upload files', 'woocommerce' ) }
</MenuItem>
) }
/>
<MenuItem
onClick={ menuItemClickHandler(
'download_limit',
__(
'Leave blank for unlimited re-downloads',
'woocommerce'
)
) }
>
{ __( 'Set download limit', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ menuItemClickHandler(
'download_expiry',
__(
'Enter the number of days before a download link expires, or leave blank',
'woocommerce'
)
) }
>
{ __( 'Set download expiry', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'downloads' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
<MenuItem
onClick={ menuItemClickHandler(
'download_limit',
__(
'Leave blank for unlimited re-downloads',
'woocommerce'
)
) }
>
{ __( 'Set download limit', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ menuItemClickHandler(
'download_expiry',
__(
'Enter the number of days before a download link expires, or leave blank',
'woocommerce'
)
) }
>
{ __( 'Set download expiry', 'woocommerce' ) }
</MenuItem>
</div>
) }
/>

View File

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

View File

@ -15,11 +15,13 @@ import { PRODUCT_STOCK_STATUS_KEYS } from '../../../utils/get-product-stock-stat
import { UpdateStockMenuItem } from '../update-stock-menu-item';
import { VariationActionsMenuItemProps } from '../types';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationQuickUpdateMenuItem } from '../variation-actions-menus';
export function InventoryMenuItem( {
selection,
onChange,
onClose,
supportsMultipleSelection = false,
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
@ -234,6 +236,13 @@ export function InventoryMenuItem( {
{ __( 'Edit low stock threshold', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'inventory' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
</div>
) }
/>

View File

@ -14,6 +14,7 @@ import { TRACKS_SOURCE } from '../../../constants';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationActionsMenuItemProps } from '../types';
import { SetListPriceMenuItem } from '../set-list-price-menu-item';
import { VariationQuickUpdateMenuItem } from '../variation-actions-menus';
function isPercentage( value: string ) {
return value.endsWith( '%' );
@ -58,6 +59,7 @@ export function PricingMenuItem( {
selection,
onChange,
onClose,
supportsMultipleSelection = false,
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
@ -435,6 +437,13 @@ export function PricingMenuItem( {
{ __( 'Schedule sale', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'pricing' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
</div>
) }
/>

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { Dropdown, MenuItem } from '@wordpress/components';
import { Dropdown, MenuItem, MenuGroup } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronRight } from '@wordpress/icons';
@ -14,11 +14,13 @@ import { recordEvent } from '@woocommerce/tracks';
import { TRACKS_SOURCE } from '../../../constants';
import { VariationActionsMenuItemProps } from '../types';
import { handlePrompt } from '../../../utils/handle-prompt';
import { VariationQuickUpdateMenuItem } from '../variation-actions-menus/variation-quick-update-menu-item';
export function ShippingMenuItem( {
selection,
onChange,
onClose,
supportsMultipleSelection = false,
}: VariationActionsMenuItemProps ) {
const ids = Array.isArray( selection )
? selection.map( ( { id } ) => id )
@ -71,174 +73,183 @@ export function ShippingMenuItem( {
) }
renderContent={ () => (
<div className="components-dropdown-menu__menu">
{ window.wcAdminFeatures[
'product-virtual-downloadable'
] && (
<MenuGroup>
{ window.wcAdminFeatures[
'product-virtual-downloadable'
] && (
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'toggle_shipping',
variation_id: ids,
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( { id, virtual } ) => ( {
id,
virtual: ! virtual,
} )
)
);
} else {
onChange( {
virtual: ! selection.virtual,
} );
}
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'toggle_shipping',
variation_id: ids,
}
);
onClose();
} }
>
{ __( 'Toggle shipping', 'woocommerce' ) }
</MenuItem>
) }
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'toggle_shipping',
variation_id: ids,
}
);
if ( Array.isArray( selection ) ) {
onChange(
selection.map(
( { id, virtual } ) => ( {
id,
virtual: ! virtual,
} )
)
);
} else {
onChange( {
virtual: ! selection.virtual,
} );
}
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'toggle_shipping',
action: 'dimensions_length_set',
variation_id: ids,
}
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: ids,
}
);
handleDimensionsChange( {
length: value,
} );
},
} );
onClose();
} }
>
{ __( 'Toggle shipping', 'woocommerce' ) }
{ __( 'Set length', 'woocommerce' ) }
</MenuItem>
) }
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: ids,
}
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: ids,
}
);
handleDimensionsChange( {
length: value,
} );
},
} );
onClose();
} }
>
{ __( 'Set length', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: ids,
}
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: ids,
}
);
handleDimensionsChange( {
width: value,
} );
},
} );
onClose();
} }
>
{ __( 'Set width', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: ids,
}
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: ids,
}
);
handleDimensionsChange( {
height: value,
} );
},
} );
onClose();
} }
>
{ __( 'Set height', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: ids,
}
);
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 } );
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: ids,
}
},
} );
onClose();
} }
>
{ __( 'Set weight', 'woocommerce' ) }
</MenuItem>
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: ids,
}
);
handleDimensionsChange( {
width: value,
} );
},
} );
onClose();
} }
>
{ __( 'Set width', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: ids,
}
);
handlePrompt( {
onOk( value ) {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: ids,
}
);
handleDimensionsChange( {
height: value,
} );
},
} );
onClose();
} }
>
{ __( 'Set height', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: ids,
}
);
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 } );
}
},
} );
onClose();
} }
>
{ __( 'Set weight', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'shipping' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
</div>
) }
/>

View File

@ -1,4 +1,4 @@
@import "./variations-actions-menu/styles.scss";
@import "./variation-actions-menus/styles.scss";
@import "./downloads-menu-item/styles.scss";
@import "./pagination/styles.scss";
@import "./table-empty-or-error-state/styles.scss";

View File

@ -9,4 +9,5 @@ export type VariationActionsMenuItemProps = {
variation: Partial< ProductVariation > | Partial< ProductVariation >[]
): void;
onClose(): void;
supportsMultipleSelection?: boolean;
};

View File

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

View File

@ -1,10 +0,0 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type VariationActionsMenuProps = {
selection: ProductVariation;
onChange( variation: Partial< ProductVariation > ): void;
onDelete( variation: ProductVariation ): void;
};

View File

@ -1,115 +0,0 @@
/**
* External dependencies
*/
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { ShippingMenuItem } from '../shipping-menu-item';
import { InventoryMenuItem } from '../inventory-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
import { ToggleVisibilityMenuItem } from '../toggle-visibility-menu-item';
import { DownloadsMenuItem } from '../downloads-menu-item';
export function VariationActionsMenu( {
selection,
onChange,
onDelete,
}: VariationActionsMenuProps ) {
return (
<DropdownMenu
popoverProps={ {
// @ts-expect-error missing TS.
placement: 'left-start',
} }
icon={ moreVertical }
label={ __( 'Actions', 'woocommerce' ) }
toggleProps={ {
onClick() {
recordEvent( 'product_variations_menu_view', {
source: TRACKS_SOURCE,
variation_id: selection.id,
} );
},
} }
>
{ ( { onClose } ) => (
<>
<MenuGroup
label={ sprintf(
/** Translators: Variation ID */
__( 'Variation Id: %s', 'woocommerce' ),
selection.id
) }
>
<MenuItem
href={ selection.permalink }
target="_blank"
rel="noreferrer"
onClick={ () => {
recordEvent( 'product_variations_preview', {
source: TRACKS_SOURCE,
variation_id: selection.id,
} );
} }
>
{ __( 'Preview', 'woocommerce' ) }
</MenuItem>
<ToggleVisibilityMenuItem
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 }
/>
{ window.wcAdminFeatures[
'product-virtual-downloadable'
] && (
<DownloadsMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
) }
</MenuGroup>
<MenuGroup>
<MenuItem
isDestructive
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link"
onClick={ () => {
onDelete( selection );
onClose();
} }
className="woocommerce-product-variations__actions--delete"
>
{ __( 'Delete', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</>
) }
</DropdownMenu>
);
}

View File

@ -0,0 +1,3 @@
export const MULTIPLE_UPDATE = 'multiple-update';
export const SINGLE_UPDATE = 'single-update';
export const VARIATION_ACTIONS_SLOT_NAME = 'woocommerce-actions-menu-slot';

View File

@ -0,0 +1,4 @@
export * from './multiple-update-menu';
export * from './single-update-menu';
export * from './variation-quick-update-menu-item';
export * from './types';

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { Button, Dropdown } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronDown, chevronUp } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from './types';
import { VariationActions } from './variation-actions';
export function MultipleUpdateMenu( {
selection,
disabled,
onChange,
onDelete,
}: VariationActionsMenuProps ) {
return (
<Dropdown
// @ts-expect-error missing prop in types.
popoverProps={ {
placement: 'bottom-end',
} }
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 }: { onClose: () => void } ) => (
<VariationActions
selection={ selection }
onClose={ onClose }
onChange={ onChange }
onDelete={ onDelete }
supportsMultipleSelection={ true }
/>
) }
/>
);
}

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { DropdownMenu } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
import { recordEvent } from '@woocommerce/tracks';
import { ProductVariation } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { VariationActions } from './variation-actions';
export function SingleUpdateMenu( {
selection,
onChange,
onDelete,
}: VariationActionsMenuProps ) {
return (
<DropdownMenu
popoverProps={ {
// @ts-expect-error missing TS.
placement: 'left-start',
} }
icon={ moreVertical }
label={ __( 'Actions', 'woocommerce' ) }
toggleProps={ {
onClick() {
recordEvent( 'product_variations_menu_view', {
source: TRACKS_SOURCE,
variation_id: ( selection as ProductVariation ).id,
} );
},
} }
>
{ ( { onClose }: { onClose: () => void } ) => (
<VariationActions
selection={ selection }
onClose={ onClose }
onChange={ onChange }
onDelete={ onDelete }
supportsMultipleSelection={ false }
/>
) }
</DropdownMenu>
);
}

View File

@ -9,7 +9,7 @@ import React, { createElement } from 'react';
/**
* Internal dependencies
*/
import { VariationActionsMenu } from '../';
import { SingleUpdateMenu, MultipleUpdateMenu } from '..';
import { TRACKS_SOURCE } from '../../../../constants';
import { PRODUCT_STOCK_STATUS_KEYS } from '../../../../utils/get-product-stock-status';
@ -25,7 +25,54 @@ const mockVariation = {
parent_id: 1,
} as ProductVariation;
describe( 'VariationActionsMenu', () => {
const anotherMockVariation = {
id: 11,
manage_stock: false,
attributes: [],
downloads: [],
name: '',
parent_id: 1,
} as ProductVariation;
describe( 'MultipleUpdateMenu', () => {
let onChangeMock: jest.Mock, onDeleteMock: jest.Mock;
beforeEach( () => {
onChangeMock = jest.fn();
onDeleteMock = jest.fn();
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render dropdown with pricing, inventory, and delete options when opened', () => {
const { queryByText, getByRole } = render(
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
fireEvent.click( getByRole( 'button', { name: 'Quick update' } ) );
expect( queryByText( 'Update stock' ) ).toBeInTheDocument();
expect( queryByText( 'Set list price' ) ).toBeInTheDocument();
expect( queryByText( 'Toggle visibility' ) ).toBeInTheDocument();
} );
it( 'should call onDelete when Delete menuItem is clicked', async () => {
const { getByRole, getByText } = render(
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click(
getByRole( 'button', { name: 'Quick update' } )
);
await fireEvent.click( getByText( 'Delete' ) );
expect( onDeleteMock ).toHaveBeenCalled();
} );
} );
describe( 'SingleUpdateMenu', () => {
let onChangeMock: jest.Mock, onDeleteMock: jest.Mock;
beforeEach( () => {
onChangeMock = jest.fn();
@ -35,7 +82,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should trigger product_variations_menu_view track when dropdown toggled', () => {
const { getByRole } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -53,7 +100,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should render dropdown with pricing, inventory, and delete options when opened', () => {
const { queryByText, getByRole } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -67,7 +114,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onDelete when Delete menuItem is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -81,7 +128,7 @@ describe( 'VariationActionsMenu', () => {
describe( 'Inventory sub-menu', () => {
it( 'should open Inventory sub-menu if Inventory is clicked with click track', async () => {
const { queryByText, getByRole, getByText } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -108,7 +155,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should onChange with stock_quantity when Update stock is clicked', async () => {
window.prompt = jest.fn().mockReturnValue( '10' );
const { getByRole, getByText } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -143,7 +190,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should not call onChange when prompt is cancelled', async () => {
window.prompt = jest.fn().mockReturnValue( null );
const { getByRole, getByText } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -177,7 +224,7 @@ describe( 'VariationActionsMenu', () => {
it( 'should call onChange with toggled manage_stock when toggle "track quantity" is clicked', async () => {
const { getByRole, getByText, rerender } = render(
<VariationActionsMenu
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -200,7 +247,7 @@ describe( 'VariationActionsMenu', () => {
} );
onChangeMock.mockClear();
rerender(
<VariationActionsMenu
<SingleUpdateMenu
selection={ { ...mockVariation, manage_stock: true } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -216,7 +263,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
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -242,7 +289,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
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -268,7 +315,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
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
@ -295,7 +342,7 @@ describe( 'VariationActionsMenu', () => {
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
<SingleUpdateMenu
selection={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }

View File

@ -0,0 +1,274 @@
/**
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import React, { createElement } from 'react';
import { SlotFillProvider } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
SingleUpdateMenu,
MultipleUpdateMenu,
VariationQuickUpdateMenuItem,
} from '..';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
const mockVariation = {
id: 10,
manage_stock: false,
attributes: [],
downloads: [],
name: '',
parent_id: 1,
} as ProductVariation;
const anotherMockVariation = {
id: 11,
manage_stock: false,
attributes: [],
downloads: [],
name: '',
parent_id: 1,
} as ProductVariation;
describe( 'SingleUpdateMenu', () => {
let onClickMock: jest.Mock,
onChangeMock: jest.Mock,
onDeleteMock: jest.Mock;
beforeEach( () => {
onClickMock = jest.fn();
onChangeMock = jest.fn();
onDeleteMock = jest.fn();
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render a top level fill in the single variation actions', () => {
const { getByRole, getByText } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'top-level' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My top level item
</VariationQuickUpdateMenuItem>
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
fireEvent.click( getByText( 'My top level item' ) );
expect( onClickMock ).toHaveBeenCalled();
expect( onClickMock ).toHaveBeenCalledWith(
expect.objectContaining( {
onChange: onChangeMock,
onClose: expect.any( Function ),
selection: [ mockVariation ],
} )
);
} );
it( 'should render a fill in the secondary area in the single variation actions', () => {
const { getByRole, getByText } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'secondary' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My secondary item
</VariationQuickUpdateMenuItem>
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
fireEvent.click( getByText( 'My secondary item' ) );
expect( onClickMock ).toHaveBeenCalled();
expect( onClickMock ).toHaveBeenCalledWith(
expect.objectContaining( {
onChange: onChangeMock,
onClose: expect.any( Function ),
selection: [ mockVariation ],
} )
);
} );
it( 'should render a fill in the tertiary area in the single variation actions', () => {
const { getByRole, getByText } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'tertiary' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My tertiary item
</VariationQuickUpdateMenuItem>
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
fireEvent.click( getByText( 'My tertiary item' ) );
expect( onClickMock ).toHaveBeenCalled();
expect( onClickMock ).toHaveBeenCalledWith(
expect.objectContaining( {
onChange: onChangeMock,
onClose: expect.any( Function ),
selection: [ mockVariation ],
} )
);
} );
it( 'should render a fill in the pricing group in the single variation actions', () => {
const { getByRole, getByText } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'shipping' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My shipping item
</VariationQuickUpdateMenuItem>
<SingleUpdateMenu
selection={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
fireEvent.click( getByText( 'Shipping' ) );
fireEvent.click( getByText( 'My shipping item' ) );
expect( onClickMock ).toHaveBeenCalled();
expect( onClickMock ).toHaveBeenCalledWith(
expect.objectContaining( {
onChange: onChangeMock,
onClose: expect.any( Function ),
selection: [ mockVariation ],
} )
);
} );
} );
describe( 'MultipleUpdateMenu', () => {
let onClickMock: jest.Mock,
onChangeMock: jest.Mock,
onDeleteMock: jest.Mock;
beforeEach( () => {
onClickMock = jest.fn();
onChangeMock = jest.fn();
onDeleteMock = jest.fn();
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render a top level fill in the multiple variation actions', () => {
const { queryByText, getByRole } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'top-level' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My top level item
</VariationQuickUpdateMenuItem>
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Quick update' } ) );
expect( queryByText( 'My top level item' ) ).toBeInTheDocument();
} );
it( 'should render a fill in the secondary area in the multiple variation actions', () => {
const { queryByText, getByRole } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'secondary' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My secondary item
</VariationQuickUpdateMenuItem>
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Quick update' } ) );
expect( queryByText( 'My secondary item' ) ).toBeInTheDocument();
} );
it( 'should render a fill in the tertiary area in the multiple variation actions', () => {
const { queryByText, getByRole } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'tertiary' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My tertiary item
</VariationQuickUpdateMenuItem>
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
fireEvent.click( getByRole( 'button', { name: 'Quick update' } ) );
expect( queryByText( 'My tertiary item' ) ).toBeInTheDocument();
} );
it( 'should render a fill in the pricing group in the multiple variation actions', async () => {
const { queryByText, getByRole, getByText } = render(
<SlotFillProvider>
<VariationQuickUpdateMenuItem
group={ 'pricing' }
order={ 20 }
supportsMultipleSelection={ true }
onClick={ onClickMock }
>
My pricing item
</VariationQuickUpdateMenuItem>
<MultipleUpdateMenu
selection={ [ mockVariation, anotherMockVariation ] }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
</SlotFillProvider>
);
await fireEvent.click(
getByRole( 'button', { name: 'Quick update' } )
);
await fireEvent.click( getByText( 'Pricing' ) );
expect( queryByText( 'My pricing item' ) ).toBeInTheDocument();
} );
} );

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type VariationActionsMenuProps = {
disabled?: boolean;
selection: ProductVariation | ProductVariation[];
onChange( variation: Partial< ProductVariation > ): void;
onDelete(
variation: ProductVariation | Partial< ProductVariation >[]
): void;
};
export type VariationQuickUpdateSlotProps = {
group: string;
supportsMultipleSelection: boolean;
selection: ProductVariation | ProductVariation[];
onChange: (
variation: Partial< ProductVariation > | Partial< ProductVariation >[]
) => void;
onClose: () => void;
};
export type MenuItemProps = {
children?: React.ReactNode;
order?: number;
group?: string;
supportsMultipleSelection?: boolean;
onClick?: ( {
onChange,
onClose,
selection,
}: {
[ K in
| 'onChange'
| 'onClose'
| 'selection' ]: VariationQuickUpdateSlotProps[ K ];
} ) => void;
onChange?: (
variation: Partial< ProductVariation > | Partial< ProductVariation >[]
) => void;
onClose?: () => void;
};

View File

@ -0,0 +1,157 @@
/**
* External dependencies
*/
import { MenuGroup, MenuItem } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import { ProductVariation } from '@woocommerce/data';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { ShippingMenuItem } from '../shipping-menu-item';
import { InventoryMenuItem } from '../inventory-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
import { ToggleVisibilityMenuItem } from '../toggle-visibility-menu-item';
import { DownloadsMenuItem } from '../downloads-menu-item';
import { VariationQuickUpdateMenuItem } from './variation-quick-update-menu-item';
import { UpdateStockMenuItem } from '../update-stock-menu-item';
import { SetListPriceMenuItem } from '../set-list-price-menu-item';
export function VariationActions( {
selection,
onChange,
onDelete,
onClose,
supportsMultipleSelection = false,
}: VariationActionsMenuProps & {
onClose: () => void;
supportsMultipleSelection: boolean;
} ) {
return (
<div
className={ classNames( {
'components-dropdown-menu__menu': supportsMultipleSelection,
} ) }
>
<MenuGroup
label={
supportsMultipleSelection
? undefined
: sprintf(
/** Translators: Variation ID */
__( 'Variation Id: %s', 'woocommerce' ),
( selection as ProductVariation ).id
)
}
>
{ supportsMultipleSelection ? (
<>
<UpdateStockMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
<SetListPriceMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
</>
) : (
<MenuItem
href={ ( selection as ProductVariation ).permalink }
target="_blank"
rel="noreferrer"
onClick={ () => {
recordEvent( 'product_variations_preview', {
source: TRACKS_SOURCE,
variation_id: ( selection as ProductVariation )
.id,
} );
} }
>
{ __( 'Preview', 'woocommerce' ) }
</MenuItem>
) }
<ToggleVisibilityMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
/>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'top-level' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
<MenuGroup>
<PricingMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
supportsMultipleSelection={ supportsMultipleSelection }
/>
<InventoryMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
supportsMultipleSelection={ supportsMultipleSelection }
/>
<ShippingMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
supportsMultipleSelection={ supportsMultipleSelection }
/>
{ window.wcAdminFeatures[ 'product-virtual-downloadable' ] && (
<DownloadsMenuItem
selection={ selection }
onChange={ onChange }
onClose={ onClose }
supportsMultipleSelection={ supportsMultipleSelection }
/>
) }
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'secondary' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
<MenuGroup>
<MenuItem
isDestructive
label={
! supportsMultipleSelection
? __( 'Delete variation', 'woocommerce' )
: undefined
}
variant="link"
onClick={ () => {
onDelete( selection );
onClose();
} }
className="woocommerce-product-variations__actions--delete"
>
{ __( 'Delete', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'tertiary' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ supportsMultipleSelection }
/>
</div>
);
}

View File

@ -0,0 +1,102 @@
/**
* External dependencies
*/
import { Slot, Fill, MenuItem, MenuGroup } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import {
createOrderedChildren,
sortFillsByOrder,
} from '@woocommerce/components';
/**
* Internal dependencies
*/
import { MenuItemProps, VariationQuickUpdateSlotProps } from './types';
import {
MULTIPLE_UPDATE,
SINGLE_UPDATE,
VARIATION_ACTIONS_SLOT_NAME,
} from './constants';
const DEFAULT_ORDER = 20;
const TOP_LEVEL_MENU = 'top-level';
export const getGroupName = (
group?: string,
isMultipleSelection?: boolean
) => {
const nameSuffix = isMultipleSelection
? `_${ MULTIPLE_UPDATE }`
: `_${ SINGLE_UPDATE }`;
return group
? `${ VARIATION_ACTIONS_SLOT_NAME }_${ group }${ nameSuffix }`
: VARIATION_ACTIONS_SLOT_NAME;
};
export const VariationQuickUpdateMenuItem: React.FC< MenuItemProps > & {
Slot: React.FC< Slot.Props & VariationQuickUpdateSlotProps >;
} = ( {
children,
order = DEFAULT_ORDER,
group = TOP_LEVEL_MENU,
supportsMultipleSelection,
onClick = () => {},
} ) => {
const handleClick =
( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) => () => {
const { selection, onChange, onClose } = fillProps;
onClick( {
selection: Array.isArray( selection )
? selection
: [ selection ],
onChange,
onClose,
} );
};
const createFill = ( updateType: string ) => (
<Fill
key={ updateType }
name={ getGroupName( group, updateType === MULTIPLE_UPDATE ) }
>
{ ( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) =>
createOrderedChildren(
<MenuItem onClick={ handleClick( fillProps ) }>
{ children }
</MenuItem>,
order,
fillProps
)
}
</Fill>
);
const fills = supportsMultipleSelection
? [ MULTIPLE_UPDATE, SINGLE_UPDATE ].map( createFill )
: createFill( SINGLE_UPDATE );
return <>{ fills }</>;
};
VariationQuickUpdateMenuItem.Slot = ( {
fillProps,
group = TOP_LEVEL_MENU,
onChange,
onClose,
selection,
supportsMultipleSelection,
} ) => {
return (
<Slot
name={ getGroupName( group, supportsMultipleSelection ) }
fillProps={ { ...fillProps, onChange, onClose, selection } }
>
{ ( fills ) => {
if ( ! sortFillsByOrder || ! fills?.length ) {
return null;
}
return <MenuGroup>{ sortFillsByOrder( fills ) }</MenuGroup>;
} }
</Slot>
);
};

View File

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

View File

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

View File

@ -1,107 +0,0 @@
/**
* 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';
import { ToggleVisibilityMenuItem } from '../toggle-visibility-menu-item';
import { DownloadsMenuItem } from '../downloads-menu-item';
export function VariationsActionsMenu( {
selection,
disabled,
onChange,
onDelete,
}: VariationsActionsMenuProps ) {
return (
<Dropdown
// @ts-expect-error missing prop in types.
popoverProps={ {
placement: 'bottom-end',
} }
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 }
/>
<ToggleVisibilityMenuItem
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 }
/>
{ window.wcAdminFeatures[
'product-virtual-downloadable'
] && (
<DownloadsMenuItem
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

@ -27,7 +27,7 @@ import {
getProductStockStatusClass,
truncate,
} from '../../../utils';
import { VariationActionsMenu } from '../variation-actions-menu';
import { SingleUpdateMenu } from '../variation-actions-menus';
import { VariationsTableRowProps } from './types';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
@ -240,7 +240,7 @@ export function VariationsTableRow( {
{ __( 'Edit', 'woocommerce' ) }
</Button>
<VariationActionsMenu
<SingleUpdateMenu
selection={ variation }
onChange={ handleChange }
onDelete={ onDelete }

View File

@ -22,13 +22,13 @@ import { useEntityId, useEntityProp } from '@wordpress/core-data';
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../constants';
import { VariationsActionsMenu } from './variations-actions-menu';
import { Pagination } from './pagination';
import { EmptyOrErrorTableState } from './table-empty-or-error-state';
import { VariationsFilter } from './variations-filter';
import { useVariations } from './use-variations';
import { TableRowSkeleton } from './table-row-skeleton';
import { VariationsTableRow } from './variations-table-row';
import { MultipleUpdateMenu } from './variation-actions-menus';
type VariationsTableProps = {
noticeText?: string;
@ -375,7 +375,7 @@ export const VariationsTable = forwardRef<
) }
</div>
<div className="woocommerce-product-variations__actions">
<VariationsActionsMenu
<MultipleUpdateMenu
selection={ selected }
disabled={
! areSomeSelected && ! isSelectingAll