Add menu item to publish button with 'Move to trash' (#44940)

* Move products to trash

* Create useProductManager hook

* Use useProductManager hook in the publish button

* Create PublishButtonMenu

* Hide move to trash when the product is already in trash

* Fix linter error

* Add changelog file
This commit is contained in:
Maikel Perez 2024-03-04 14:04:38 -03:00 committed by GitHub
parent 5d808ad9fc
commit d0f095df72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 452 additions and 274 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add menu item to publish button with 'Move to trash'

View File

@ -13,11 +13,9 @@ import type { ButtonWithDropdownMenuProps } from './types';
export * from './types';
export const ButtonWithDropdownMenu: React.FC<
ButtonWithDropdownMenuProps
> = ( {
export function ButtonWithDropdownMenu( {
dropdownButtonLabel = __( 'More options', 'woocommerce' ),
controls = [],
controls,
defaultOpen = false,
popoverProps: {
placement = 'bottom-end',
@ -29,8 +27,9 @@ export const ButtonWithDropdownMenu: React.FC<
offset: 0,
},
className,
renderMenu,
...props
} ) => {
}: ButtonWithDropdownMenuProps ) {
return (
<Flex
className={ `woocommerce-button-with-dropdown-menu${
@ -65,8 +64,10 @@ export const ButtonWithDropdownMenu: React.FC<
offset,
} }
defaultOpen={ defaultOpen }
/>
>
{ renderMenu }
</DropdownMenu>
</FlexItem>
</Flex>
);
};
}

View File

@ -3,6 +3,7 @@
*/
import { Button } from '@wordpress/components';
import type {
Dropdown,
// @ts-expect-error no exported member.
DropdownOption,
} from '@wordpress/components';
@ -44,4 +45,5 @@ export type ButtonWithDropdownMenuProps = Omit<
defaultOpen?: boolean;
controls?: DropdownOption[];
popoverProps?: PopoverProps;
renderMenu?( props: Dropdown.RenderProps ): React.ReactElement;
};

View File

@ -4,18 +4,17 @@
import { MouseEvent } from 'react';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import type { Product, ProductVariation } from '@woocommerce/data';
import type { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { useProductManager } from '../../../../hooks/use-product-manager';
import type { WPError } from '../../../../utils/get-product-error-message';
import type { PublishButtonProps } from '../../publish-button';
export function usePublish( {
export function usePublish< T = Product >( {
productType = 'product',
disabled,
onClick,
@ -23,20 +22,11 @@ export function usePublish( {
onPublishError,
...props
}: PublishButtonProps & {
onPublishSuccess?( product: Product ): void;
onPublishSuccess?( product: T ): void;
onPublishError?( error: WPError ): void;
} ): Button.ButtonProps & {
publish(
productOrVariation?: Partial< Product | ProductVariation >
): Promise< Product | ProductVariation | undefined >;
} {
const { isValidating, validate } = useValidations< Product >();
const [ productId ] = useEntityProp< number >(
'postType',
productType,
'id'
);
} ): Button.ButtonProps {
const { isValidating, isDirty, isPublishing, publish } =
useProductManager( productType );
const [ status, , prevStatus ] = useEntityProp< Product[ 'status' ] >(
'postType',
@ -44,97 +34,10 @@ export function usePublish( {
'status'
);
const { isSaving, isDirty } = useSelect(
( select ) => {
const {
// @ts-expect-error There are no types for this.
isSavingEntityRecord,
// @ts-expect-error There are no types for this.
hasEditsForEntityRecord,
} = select( 'core' );
return {
isSaving: isSavingEntityRecord< boolean >(
'postType',
productType,
productId
),
isDirty: hasEditsForEntityRecord(
'postType',
productType,
productId
),
};
},
[ productId ]
);
const isBusy = isSaving || isValidating;
const isBusy = isPublishing || isValidating;
const isDisabled = disabled || isBusy || ! isDirty;
// @ts-expect-error There are no types for this.
const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' );
async function publish(
productOrVariation: Partial< Product | ProductVariation > = {}
) {
const isPublished = status === 'publish' || status === 'future';
try {
// The publish button click not only change the status of the product
// but also save all the pending changes. So even if the status is
// publish it's possible to save the product too.
const data = ! isPublished
? { status: 'publish', ...productOrVariation }
: productOrVariation;
await validate( data as Partial< Product > );
await editEntityRecord( 'postType', productType, productId, data );
const publishedProduct = await saveEditedEntityRecord<
Product | ProductVariation
>( 'postType', productType, productId, {
throwOnError: true,
} );
if ( publishedProduct && onPublishSuccess ) {
onPublishSuccess( publishedProduct );
}
return publishedProduct as Product | ProductVariation;
} catch ( error ) {
if ( onPublishError ) {
let wpError = error as WPError;
if ( ! wpError.code ) {
wpError = {
code: isPublished
? 'product_publish_error'
: 'product_create_error',
} as WPError;
if ( ( error as Record< string, string > ).variations ) {
wpError.code = 'variable_product_no_variation_prices';
wpError.message = (
error as Record< string, string >
).variations;
} else {
const errorMessage = Object.values(
error as Record< string, string >
).find( ( value ) => value !== undefined ) as
| string
| undefined;
if ( errorMessage !== undefined ) {
wpError.code = 'product_form_field_error';
wpError.message = errorMessage;
}
}
}
onPublishError( wpError );
}
}
}
async function handleClick( event: MouseEvent< HTMLButtonElement > ) {
function handleClick( event: MouseEvent< HTMLButtonElement > ) {
if ( isDisabled ) {
event.preventDefault?.();
return;
@ -144,7 +47,7 @@ export function usePublish( {
onClick( event );
}
await publish();
publish().then( onPublishSuccess ).catch( onPublishError );
}
function getButtonText() {
@ -169,6 +72,5 @@ export function usePublish( {
'aria-disabled': isDisabled,
variant: 'primary',
onClick: handleClick,
publish,
};
}

View File

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

View File

@ -0,0 +1,155 @@
/**
* External dependencies
*/
import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { createElement, Fragment, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { ProductStatus } from '@woocommerce/data';
import { navigateTo } from '@woocommerce/navigation';
import { getAdminLink } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { useProductManager } from '../../../../hooks/use-product-manager';
import { useProductScheduled } from '../../../../hooks/use-product-scheduled';
import { recordProductEvent } from '../../../../utils/record-product-event';
import { getProductErrorMessage } from '../../../../utils/get-product-error-message';
import { ButtonWithDropdownMenu } from '../../../button-with-dropdown-menu';
import { SchedulePublishModal } from '../../../schedule-publish-modal';
import { showSuccessNotice } from '../utils';
import type { PublishButtonMenuProps } from './types';
export function PublishButtonMenu( {
postType,
...props
}: PublishButtonMenuProps ) {
const { isScheduled, schedule, date, formattedDate } =
useProductScheduled( postType );
const [ showScheduleModal, setShowScheduleModal ] = useState<
'schedule' | 'edit' | undefined
>();
const { trash } = useProductManager( postType );
const { createErrorNotice, createSuccessNotice } =
useDispatch( 'core/notices' );
const [ , , prevStatus ] = useEntityProp< ProductStatus >(
'postType',
postType,
'status'
);
function scheduleProduct( dateString?: string ) {
schedule( dateString )
.then( ( scheduledProduct ) => {
recordProductEvent( 'product_schedule', scheduledProduct );
showSuccessNotice( scheduledProduct );
} )
.catch( ( error ) => {
const message = getProductErrorMessage( error );
createErrorNotice( message );
} )
.finally( () => {
setShowScheduleModal( undefined );
} );
}
function renderSchedulePublishModal() {
return (
showScheduleModal && (
<SchedulePublishModal
postType={ postType }
value={ showScheduleModal === 'edit' ? date : undefined }
onCancel={ () => setShowScheduleModal( undefined ) }
onSchedule={ scheduleProduct }
/>
)
);
}
function renderMenu( { onClose }: Dropdown.RenderProps ) {
return (
<>
<MenuGroup>
{ isScheduled ? (
<>
<MenuItem
onClick={ () => {
scheduleProduct();
onClose();
} }
>
{ __( 'Publish now', 'woocommerce' ) }
</MenuItem>
<MenuItem
info={ formattedDate }
onClick={ () => {
setShowScheduleModal( 'edit' );
onClose();
} }
>
{ __( 'Edit schedule', 'woocommerce' ) }
</MenuItem>
</>
) : (
<MenuItem
onClick={ () => {
setShowScheduleModal( 'schedule' );
onClose();
} }
>
{ __( 'Schedule publish', 'woocommerce' ) }
</MenuItem>
) }
</MenuGroup>
{ prevStatus !== 'trash' && (
<MenuGroup>
<MenuItem
isDestructive
onClick={ () => {
trash()
.then( ( deletedProduct ) => {
recordProductEvent(
'product_delete',
deletedProduct
);
createSuccessNotice(
__(
'Product successfully deleted',
'woocommerce'
)
);
const productListUrl = getAdminLink(
'edit.php?post_type=product'
);
navigateTo( {
url: productListUrl,
} );
} )
.catch( ( error ) => {
const message =
getProductErrorMessage( error );
createErrorNotice( message );
} );
onClose();
} }
>
{ __( 'Move to trash', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
) }
</>
);
}
return (
<>
<ButtonWithDropdownMenu { ...props } renderMenu={ renderMenu } />
{ renderSchedulePublishModal() }
</>
);
}

View File

@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { ButtonWithDropdownMenuProps } from '../../../button-with-dropdown-menu';
export type PublishButtonMenuProps = ButtonWithDropdownMenuProps & {
postType: string;
};

View File

@ -1,12 +1,11 @@
/**
* External dependencies
*/
import { MouseEvent, useState } from 'react';
import { Button } from '@wordpress/components';
import type { MouseEvent } from 'react';
import { Button, Dropdown } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { dispatch, useDispatch } from '@wordpress/data';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch } from '@wordpress/data';
import { createElement } from '@wordpress/element';
import { type Product } from '@woocommerce/data';
import { getNewPath, navigateTo } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
@ -19,58 +18,10 @@ import { getProductErrorMessage } from '../../../utils/get-product-error-message
import { recordProductEvent } from '../../../utils/record-product-event';
import { useFeedbackBar } from '../../../hooks/use-feedback-bar';
import { TRACKS_SOURCE } from '../../../constants';
import { ButtonWithDropdownMenu } from '../../button-with-dropdown-menu';
import { usePublish } from '../hooks/use-publish';
import { PublishButtonProps } from './types';
import { useProductScheduled } from '../../../hooks/use-product-scheduled';
import { SchedulePublishModal } from '../../schedule-publish-modal';
import { formatScheduleDatetime } from '../../../utils';
function getNoticeContent( product: Product, prevStatus: Product[ 'status' ] ) {
if (
window.wcAdminFeatures[ 'product-pre-publish-modal' ] &&
product.status === 'future'
) {
return sprintf(
// translators: %s: The datetime the product is scheduled for.
__( 'Product scheduled for %s.', 'woocommerce' ),
formatScheduleDatetime( product.date_created )
);
}
if ( prevStatus === 'publish' || prevStatus === 'future' ) {
return __( 'Product updated.', 'woocommerce' );
}
return __( 'Product published.', 'woocommerce' );
}
function showSuccessNotice(
product: Product,
prevStatus: Product[ 'status' ]
) {
const { createSuccessNotice } = dispatch( 'core/notices' );
const noticeContent = getNoticeContent( product, prevStatus );
const noticeOptions = {
icon: '🎉',
actions: [
{
label: __( 'View in store', 'woocommerce' ),
// Leave the url to support a11y.
url: product.permalink,
onClick( event: MouseEvent< HTMLAnchorElement > ) {
event.preventDefault();
// Notice actions do not support target anchor prop,
// so this forces the page to be opened in a new tab.
window.open( product.permalink, '_blank' );
},
},
],
};
createSuccessNotice( noticeContent, noticeOptions );
}
import { PublishButtonMenu } from './publish-button-menu';
import { showSuccessNotice } from './utils';
import type { PublishButtonProps } from './types';
export function PublishButton( {
productType = 'product',
@ -87,7 +38,7 @@ export function PublishButton( {
'status'
);
const { publish, ...publishButtonProps } = usePublish( {
const publishButtonProps = usePublish( {
productType,
...props,
onPublishSuccess( savedProduct: Product ) {
@ -114,70 +65,16 @@ export function PublishButton( {
},
} );
const { isScheduled, schedule, date, formattedDate } =
useProductScheduled( productType );
const [ showScheduleModal, setShowScheduleModal ] = useState<
'schedule' | 'edit' | undefined
>();
if (
productType === 'product' &&
window.wcAdminFeatures[ 'product-pre-publish-modal' ] &&
prePublish
) {
function getPublishButtonControls() {
return [
isScheduled
? [
{
title: __( 'Publish now', 'woocommerce' ),
async onClick() {
await schedule( publish );
},
},
{
title: (
<div className="woocommerce-product-header__actions-edit-schedule">
<div>
{ __(
'Edit schedule',
'woocommerce'
) }
</div>
<div>{ formattedDate }</div>
</div>
),
onClick() {
setShowScheduleModal( 'edit' );
},
},
]
: [
{
title: __( 'Schedule publish', 'woocommerce' ),
onClick() {
setShowScheduleModal( 'schedule' );
},
},
],
];
}
function renderSchedulePublishModal() {
function renderPublishButtonMenu(
menuProps: Dropdown.RenderProps
): React.ReactElement {
return (
showScheduleModal && (
<SchedulePublishModal
postType={ productType }
value={
showScheduleModal === 'edit' ? date : undefined
}
onCancel={ () => setShowScheduleModal( undefined ) }
onSchedule={ async ( value ) => {
await schedule( publish, value );
setShowScheduleModal( undefined );
} }
/>
)
<PublishButtonMenu { ...menuProps } postType={ productType } />
);
}
@ -198,27 +95,23 @@ export function PublishButton( {
}
return (
<>
<ButtonWithDropdownMenu
{ ...publishButtonProps }
onClick={ handlePrePublishButtonClick }
controls={ getPublishButtonControls() }
/>
{ renderSchedulePublishModal() }
</>
<PublishButtonMenu
{ ...publishButtonProps }
postType={ productType }
controls={ undefined }
onClick={ handlePrePublishButtonClick }
renderMenu={ renderPublishButtonMenu }
/>
);
}
return (
<>
<ButtonWithDropdownMenu
{ ...publishButtonProps }
controls={ getPublishButtonControls() }
/>
{ renderSchedulePublishModal() }
</>
<PublishButtonMenu
{ ...publishButtonProps }
postType={ productType }
controls={ undefined }
renderMenu={ renderPublishButtonMenu }
/>
);
}

View File

@ -0,0 +1 @@
export * from './show-success-notice';

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { dispatch } from '@wordpress/data';
import { __, sprintf } from '@wordpress/i18n';
import type { Product, ProductStatus } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { formatScheduleDatetime } from '../../../../utils';
function getNoticeContent( product: Product, prevStatus?: ProductStatus ) {
if (
window.wcAdminFeatures[ 'product-pre-publish-modal' ] &&
product.status === 'future'
) {
return sprintf(
// translators: %s: The datetime the product is scheduled for.
__( 'Product scheduled for %s.', 'woocommerce' ),
formatScheduleDatetime( `${ product.date_created_gmt }+00:00` )
);
}
if ( prevStatus === 'publish' || prevStatus === 'future' ) {
return __( 'Product updated.', 'woocommerce' );
}
return __( 'Product published.', 'woocommerce' );
}
export function showSuccessNotice(
product: Product,
prevStatus?: ProductStatus
) {
const { createSuccessNotice } = dispatch( 'core/notices' );
const noticeContent = getNoticeContent( product, prevStatus );
const noticeOptions = {
icon: '🎉',
actions: [
{
label: __( 'View in store', 'woocommerce' ),
// Leave the url to support a11y.
url: product.permalink,
onClick( event: React.MouseEvent< HTMLAnchorElement > ) {
event.preventDefault();
// Notice actions do not support target anchor prop,
// so this forces the page to be opened in a new tab.
window.open( product.permalink, '_blank' );
},
},
],
};
createSuccessNotice( noticeContent, noticeOptions );
}

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { PanelBody } from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
@ -13,22 +12,15 @@ import {
/**
* Internal dependencies
*/
import useProductEntityProp from '../../../hooks/use-product-entity-prop';
import { useProductScheduled } from '../../../hooks/use-product-scheduled';
import { isSiteSettingsTime12HourFormatted } from '../../../utils';
import { ScheduleSectionProps } from './types';
export function ScheduleSection( { postType }: ScheduleSectionProps ) {
const [ productId ] = useProductEntityProp< number >( 'id' );
const { schedule, date, formattedDate } = useProductScheduled( postType );
// @ts-expect-error There are no types for this.
const { editEntityRecord } = useDispatch( 'core' );
const { setDate, date, formattedDate } = useProductScheduled( postType );
async function handlePublishDateTimePickerChange( value: string | null ) {
await schedule( ( product ) => {
return editEntityRecord( 'postType', postType, productId, product );
}, value ?? undefined );
await setDate( value ?? undefined );
}
return (

View File

@ -7,3 +7,4 @@ export { default as __experimentalUseProductEntityProp } from './use-product-ent
export { default as __experimentalUseProductMetadata } from './use-product-metadata';
export { useProductTemplate as __experimentalUseProductTemplate } from './use-product-template';
export { useProductScheduled as __experimentalUseProductScheduled } from './use-product-scheduled';
export { useProductManager as __experimentalUseProductManager } from './use-product-manager';

View File

@ -0,0 +1 @@
export * from './use-product-manager';

View File

@ -0,0 +1,152 @@
/**
* External dependencies
*/
import { useEntityProp } from '@wordpress/core-data';
import { dispatch, useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import type { Product, ProductStatus } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { useValidations } from '../../contexts/validation-context';
import type { WPError } from '../../utils/get-product-error-message';
function errorHandler( error: WPError, productStatus: ProductStatus ) {
if ( error.code ) {
return error;
}
if ( 'variations' in error && error.variations ) {
return {
code: 'variable_product_no_variation_prices',
message: error.variations,
};
}
const errorMessage = Object.values( error ).find(
( value ) => value !== undefined
) as string | undefined;
if ( errorMessage !== undefined ) {
return {
code: 'product_form_field_error',
message: errorMessage,
};
}
return {
code:
productStatus === 'publish' || productStatus === 'future'
? 'product_publish_error'
: 'product_create_error',
};
}
export function useProductManager< T = Product >( postType: string ) {
const [ id ] = useEntityProp< number >( 'postType', postType, 'id' );
const [ status ] = useEntityProp< ProductStatus >(
'postType',
postType,
'status'
);
const [ isSaving, setIsSaving ] = useState( false );
const [ isTrashing, setTrashing ] = useState( false );
const { isValidating, validate } = useValidations< T >();
const { isDirty } = useSelect(
( select ) => ( {
// @ts-expect-error There are no types for this.
isDirty: select( 'core' ).hasEditsForEntityRecord(
'postType',
postType,
id
),
} ),
[ postType, id ]
);
async function save( extraProps: Partial< T > = {} ) {
try {
setIsSaving( true );
await validate( extraProps );
// @ts-expect-error There are no types for this.
const { editEntityRecord, saveEditedEntityRecord } =
dispatch( 'core' );
await editEntityRecord< T >( 'postType', postType, id, extraProps );
const savedProduct = await saveEditedEntityRecord< T >(
'postType',
postType,
id,
{
throwOnError: true,
}
);
return savedProduct as T;
} catch ( error ) {
throw errorHandler( error as WPError, status );
} finally {
setIsSaving( false );
}
}
async function publish( extraProps: Partial< T > = {} ) {
const isPublished = status === 'publish' || status === 'future';
// The publish button click not only change the status of the product
// but also save all the pending changes. So even if the status is
// publish it's possible to save the product too.
const data: Partial< T > = isPublished
? extraProps
: { status: 'publish', ...extraProps };
return save( data );
}
async function trash( force = false ) {
try {
setTrashing( true );
await validate();
// @ts-expect-error There are no types for this.
const { deleteEntityRecord, saveEditedEntityRecord } =
dispatch( 'core' );
await saveEditedEntityRecord< T >( 'postType', postType, id, {
throwOnError: true,
} );
const deletedProduct = await deleteEntityRecord< T >(
'postType',
postType,
id,
{
force,
throwOnError: true,
}
);
return deletedProduct as T;
} catch ( error ) {
throw errorHandler( error as WPError, status );
} finally {
setTrashing( false );
}
}
return {
isValidating,
isDirty,
isSaving,
isPublishing: isSaving,
isTrashing,
save,
publish,
trash,
};
}

View File

@ -3,38 +3,33 @@
*/
import { useEntityProp } from '@wordpress/core-data';
import { getDate, isInTheFuture, date as parseDate } from '@wordpress/date';
import { Product, ProductStatus, ProductVariation } from '@woocommerce/data';
import type { ProductStatus } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { formatScheduleDatetime, getSiteDatetime } from '../../utils';
import { useProductManager } from '../use-product-manager';
export const TIMEZONELESS_FORMAT = 'Y-m-d\\TH:i:s';
export function useProductScheduled( postType: string ) {
const [ date ] = useEntityProp< string >(
const { isSaving, save } = useProductManager( postType );
const [ date, set ] = useEntityProp< string >(
'postType',
postType,
'date_created_gmt'
);
const [ editedStatus, , prevStatus ] = useEntityProp< ProductStatus >(
'postType',
postType,
'status'
);
const [ editedStatus, setStatus, prevStatus ] =
useEntityProp< ProductStatus >( 'postType', postType, 'status' );
const gmtDate = `${ date }+00:00`;
const siteDate = getSiteDatetime( gmtDate );
async function schedule(
publish: (
productOrVariation?: Partial< Product | ProductVariation >
) => Promise< Product | ProductVariation | undefined >,
value?: string
) {
function calcDateAndStatus( value?: string ) {
const newSiteDate = getDate( value ?? null );
const newGmtDate = parseDate( TIMEZONELESS_FORMAT, newSiteDate, 'GMT' );
@ -45,16 +40,28 @@ export function useProductScheduled( postType: string ) {
status = 'publish';
}
return publish( {
status,
date_created_gmt: newGmtDate,
} );
return { status, date_created_gmt: newGmtDate };
}
async function setDate( value?: string ) {
const result = calcDateAndStatus( value );
set( result.date_created_gmt );
setStatus( result.status );
}
async function schedule( value?: string ) {
const result = calcDateAndStatus( value );
return save( result );
}
return {
isScheduling: isSaving,
isScheduled: editedStatus === 'future' || isInTheFuture( siteDate ),
date: siteDate,
formattedDate: formatScheduleDatetime( gmtDate ),
setDate,
schedule,
};
}