Enable image inline actions in the variations table (#48083)

* Create UploadFilesMenuItem component

* Create MediaLibraryMenuItem component

* Create ImageActionsMenu component

* Integrate ImageActionsMenu with VariationsTableRow

* Add extensibility to the ImageActionsMenu component

* Add changelog file

* Fix compilation errors

* Fix linter errors

* Allow images only to be uploaded from the UploadFilesMenuItem component

* Fix image aligment in the actions menu toggle
This commit is contained in:
Maikel Perez 2024-06-11 18:42:02 -04:00 committed by GitHub
parent 31254c4d45
commit e3e303e776
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 379 additions and 48 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Enable image inline actions in the variations table

View File

@ -0,0 +1,2 @@
export * from './media-library-menu-item';
export * from './types';

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { media } from '@wordpress/icons';
import { MediaUpload } from '@wordpress/media-utils';
/**
* Internal dependencies
*/
import type { MediaLibraryMenuItemProps } from './types';
export function MediaLibraryMenuItem( {
// MenuItem.Props
icon,
iconPosition,
text,
info,
// MediaUpload.Props
...props
}: MediaLibraryMenuItemProps ) {
return (
<MediaUpload
{ ...props }
render={ ( { open } ) => (
<MenuItem
icon={ icon ?? media }
iconPosition={ iconPosition ?? 'left' }
onClick={ open }
info={
info ??
__( 'Choose from uploaded media', 'woocommerce' )
}
>
{ text ?? __( 'Media Library', 'woocommerce' ) }
</MenuItem>
) }
/>
);
}

View File

@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { MenuItem as DropdownMenuItem } from '@wordpress/components';
import { MediaUpload } from '@wordpress/media-utils';
export type MediaLibraryMenuItemProps = Omit<
MediaUpload.Props< boolean >,
'children' | 'render' | 'onChange'
> &
Pick< DropdownMenuItem.Props, 'icon' | 'iconPosition' | 'text' | 'info' >;

View File

@ -0,0 +1,2 @@
export * from './upload-files-menu-item';
export * from './types';

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import {
FormFileUpload,
MenuItem as DropdownMenuItem,
} from '@wordpress/components';
import { MediaItem, UploadMediaOptions } from '@wordpress/media-utils';
export type UploadFilesMenuItemProps = Omit<
FormFileUpload.Props,
'children' | 'render' | 'onChange'
> &
Pick< DropdownMenuItem.Props, 'icon' | 'iconPosition' | 'text' | 'info' > &
Partial<
Pick<
UploadMediaOptions,
| 'additionalData'
| 'allowedTypes'
| 'maxUploadFileSize'
| 'wpAllowedMimeTypes'
>
> & {
onUploadProgress?( files: MediaItem[] ): void;
onUploadSuccess( files: MediaItem[] ): void;
onUploadError( error: unknown ): void;
};

View File

@ -0,0 +1,78 @@
/**
* External dependencies
*/
import type { ChangeEvent } from 'react';
import { FormFileUpload, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { upload } from '@wordpress/icons';
import { uploadMedia } from '@wordpress/media-utils';
/**
* Internal dependencies
*/
import type { UploadFilesMenuItemProps } from './types';
export function UploadFilesMenuItem( {
// UploadMediaOptions
allowedTypes,
maxUploadFileSize = 10000000,
wpAllowedMimeTypes,
additionalData,
// MenuItem.Props
icon,
iconPosition,
text,
info,
// Handlers
onUploadProgress,
onUploadSuccess,
onUploadError,
// FormFileUpload.Props
...props
}: UploadFilesMenuItemProps ) {
function handleFormFileUploadChange(
event: ChangeEvent< HTMLInputElement >
) {
const filesList = event.currentTarget.files as FileList;
uploadMedia( {
allowedTypes,
filesList,
maxUploadFileSize,
additionalData,
wpAllowedMimeTypes,
onFileChange( files ) {
const isUploading = files.some( ( file ) => ! file.id );
if ( isUploading ) {
onUploadProgress?.( files );
return;
}
onUploadSuccess( files );
},
onError: onUploadError,
} );
}
return (
<FormFileUpload
{ ...props }
onChange={ handleFormFileUploadChange }
render={ ( { openFileDialog } ) => (
<MenuItem
icon={ icon ?? upload }
iconPosition={ iconPosition ?? 'left' }
onClick={ openFileDialog }
info={
info ??
__( 'Select files from your device', 'woocommerce' )
}
>
{ text ?? __( 'Upload', 'woocommerce' ) }
</MenuItem>
) }
/>
);
}

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { Dropdown, MenuGroup } from '@wordpress/components';
import { createElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { MediaItem } from '@wordpress/media-utils';
/**
* Internal dependencies
*/
import { MediaLibraryMenuItem } from '../../menu-items/media-library-menu-item';
import { UploadFilesMenuItem } from '../../menu-items/upload-files-menu-item';
import { mapUploadImageToImage } from '../../../utils/map-upload-image-to-image';
import { VariationQuickUpdateMenuItem } from '../variation-actions-menus';
import type { ImageActionsMenuProps } from './types';
export function ImageActionsMenu( {
selection,
onChange,
onDelete,
...props
}: ImageActionsMenuProps ) {
const [ isUploading, setIsUploading ] = useState( false );
function uploadSuccessHandler( onClose: () => void ) {
return function handleUploadSuccess( files: MediaItem[] ) {
const image =
( files.length && mapUploadImageToImage( files[ 0 ] ) ) ||
undefined;
const variation = {
id: selection[ 0 ].id,
image,
};
setIsUploading( false );
onChange( [ variation ], false );
onClose();
};
}
function mediaLibraryMenuItemSelectHandler( onClose: () => void ) {
return function handleMediaLibraryMenuItemSelect( media: never ) {
const variation = {
id: selection[ 0 ].id,
image: mapUploadImageToImage( media ) || undefined,
};
onChange( [ variation ], false );
onClose();
};
}
return (
<Dropdown
{ ...props }
// @ts-expect-error missing prop in types.
popoverProps={ {
placement: 'bottom-end',
} }
renderToggle={ ( toggleProps ) =>
props.renderToggle( { ...toggleProps, isBusy: isUploading } )
}
className="woocommerce-image-actions-menu"
contentClassName="woocommerce-image-actions-menu__menu-content"
renderContent={ ( { onClose } ) => (
<div className="components-dropdown-menu__menu">
<MenuGroup>
<UploadFilesMenuItem
allowedTypes={ [ 'image' ] }
accept="image/*"
multiple={ false }
info={ __(
'1000 pixels wide or larger',
'woocommerce'
) }
onUploadProgress={ () => {
setIsUploading( true );
onClose();
} }
onUploadSuccess={ uploadSuccessHandler( onClose ) }
onUploadError={ () => {
setIsUploading( false );
onClose();
} }
/>
<MediaLibraryMenuItem
allowedTypes={ [ 'image' ] }
multiple={ false }
value={ selection[ 0 ].id }
onSelect={ mediaLibraryMenuItemSelectHandler(
onClose
) }
/>
</MenuGroup>
<VariationQuickUpdateMenuItem.Slot
group={ 'image-actions-menu' }
onChange={ onChange }
onClose={ onClose }
selection={ selection }
supportsMultipleSelection={ false }
/>
</div>
) }
/>
);
}

View File

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

View File

@ -0,0 +1,12 @@
.woocommerce-image-actions-menu {
display: inline-flex;
&__menu-content {
// To be rendered below the MediaLibrary modal.
z-index: 100000;
.components-menu-item__item {
min-width: 172px;
}
}
}

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { Dropdown } from '@wordpress/components';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from '../variation-actions-menus';
export type ImageActionsMenuProps = Omit<
Dropdown.Props,
'renderToggle' | 'renderContent'
> &
VariationActionsMenuProps & {
renderToggle(
props: Dropdown.RenderProps & { isBusy?: boolean }
): JSX.Element;
};

View File

@ -5,6 +5,7 @@
@import "./variations-filter/styles.scss"; @import "./variations-filter/styles.scss";
@import "./table-row-skeleton/styles.scss"; @import "./table-row-skeleton/styles.scss";
@import "./add-image-menu-item/style.scss"; @import "./add-image-menu-item/style.scss";
@import "./image-actions-menu/style.scss";
.woocommerce-product-variations { .woocommerce-product-variations {
display: flex; display: flex;
@ -190,17 +191,28 @@
border-radius: 2px; border-radius: 2px;
border: 1px dashed $gray-400; border: 1px dashed $gray-400;
margin-right: $gap-small; margin-right: $gap-small;
width: 32px; width: $grid-unit-40;
height: $grid-unit-40;
padding: 0; padding: 0;
display: flex;
align-items: center;
justify-content: center;
.components-spinner {
margin: 0;
}
} }
&__image-button { &__image-button {
margin-right: $gap-small; margin-right: $gap-small;
width: 32px; width: $grid-unit-40;
max-height: 32px; height: $grid-unit-40;
padding: 0; padding: 0;
} }
&__image { &__image {
width: 32px; width: 100%;
max-height: 32px; height: 100%;
background-position: center center;
background-size: contain;
background-repeat: no-repeat;
} }
} }

View File

@ -269,6 +269,18 @@ export function useVariations( { productId }: UseVariationsProps ) {
}: PartialProductVariation ) { }: PartialProductVariation ) {
if ( isUpdating[ variationId ] ) return; if ( isUpdating[ variationId ] ) return;
setVariations( ( current ) =>
current.map( ( currentVariation ) => {
if ( currentVariation.id === variationId ) {
return {
...currentVariation,
...variation,
};
}
return currentVariation;
} )
);
const { updateProductVariation } = dispatch( const { updateProductVariation } = dispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
); );

View File

@ -2,6 +2,7 @@
* External dependencies * External dependencies
*/ */
import { PartialProductVariation, ProductVariation } from '@woocommerce/data'; import { PartialProductVariation, ProductVariation } from '@woocommerce/data';
import { MenuItem } from '@wordpress/components';
export type VariationActionsMenuProps = { export type VariationActionsMenuProps = {
disabled?: boolean; disabled?: boolean;
@ -18,8 +19,7 @@ export type VariationQuickUpdateSlotProps = {
onClose: () => void; onClose: () => void;
}; };
export type MenuItemProps = { export type MenuItemProps = Omit< MenuItem.Props, 'onClick' > & {
children?: React.ReactNode;
order?: number; order?: number;
group?: string; group?: string;
supportsMultipleSelection?: boolean; supportsMultipleSelection?: boolean;

View File

@ -41,6 +41,7 @@ export const VariationQuickUpdateMenuItem: React.FC< MenuItemProps > & {
group = TOP_LEVEL_MENU, group = TOP_LEVEL_MENU,
supportsMultipleSelection, supportsMultipleSelection,
onClick = () => {}, onClick = () => {},
...props
} ) => { } ) => {
const handleClick = const handleClick =
( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) => () => { ( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) => () => {
@ -61,7 +62,7 @@ export const VariationQuickUpdateMenuItem: React.FC< MenuItemProps > & {
> >
{ ( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) => { ( fillProps: Fill.Props & VariationQuickUpdateSlotProps ) =>
createOrderedChildren( createOrderedChildren(
<MenuItem onClick={ handleClick( fillProps ) }> <MenuItem { ...props } onClick={ handleClick( fillProps ) }>
{ children } { children }
</MenuItem>, </MenuItem>,
order, order,

View File

@ -16,7 +16,6 @@ import {
import { plus, info, Icon } from '@wordpress/icons'; import { plus, info, Icon } from '@wordpress/icons';
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import classNames from 'classnames'; import classNames from 'classnames';
import { MediaUpload } from '@wordpress/media-utils';
/** /**
* Internal dependencies * Internal dependencies
@ -30,7 +29,7 @@ import {
} from '../../../utils'; } from '../../../utils';
import { SingleUpdateMenu } from '../variation-actions-menus'; import { SingleUpdateMenu } from '../variation-actions-menus';
import { VariationsTableRowProps } from './types'; import { VariationsTableRowProps } from './types';
import { mapUploadImageToImage } from '../../../utils/map-upload-image-to-image'; import { ImageActionsMenu } from '../image-actions-menu';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' ); const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
@ -142,24 +141,21 @@ export function VariationsTableRow( {
className="woocommerce-product-variations__attributes-cell" className="woocommerce-product-variations__attributes-cell"
role="cell" role="cell"
> >
<MediaUpload <ImageActionsMenu
value={ variation.id } selection={ [ variation ] }
onSelect={ ( image ) => onChange={ handleChange }
handleChange( onDelete={ handleDelete }
[ renderToggle={ ( { onToggle, isBusy } ) =>
{ isBusy ? (
id: variation.id, <div className="woocommerce-product-variations__add-image-button">
image: <Spinner
mapUploadImageToImage( image ) || aria-label={ __(
undefined, 'Loading image',
}, 'woocommerce'
], ) }
false />
) </div>
} ) : (
allowedTypes={ [ 'image' ] }
multiple={ false }
render={ ( { open } ) => (
<Button <Button
className={ classNames( className={ classNames(
variation.image variation.image
@ -171,17 +167,19 @@ export function VariationsTableRow( {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists in the props but is not typed // @ts-ignore this exists in the props but is not typed
size="compact" size="compact"
onClick={ open } onClick={ onToggle }
> >
{ variation.image && ( { variation.image && (
<img <div
className="woocommerce-product-variations__image" className="woocommerce-product-variations__image"
src={ variation.image.src } style={ {
alt={ variation.image.alt } backgroundImage: `url('${ variation.image.src }')`,
} }
/> />
) } ) }
</Button> </Button>
) } )
}
/> />
<div className="woocommerce-product-variations__attributes"> <div className="woocommerce-product-variations__attributes">