diff --git a/packages/js/data/changelog/dev-43062_add_menuitem_to_copy_to_draft b/packages/js/data/changelog/dev-43062_add_menuitem_to_copy_to_draft new file mode 100644 index 00000000000..79b4a835072 --- /dev/null +++ b/packages/js/data/changelog/dev-43062_add_menuitem_to_copy_to_draft @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Add duplicate product to store #46294 diff --git a/packages/js/data/src/products/action-types.ts b/packages/js/data/src/products/action-types.ts index 862975267ce..1c1c4e5f063 100644 --- a/packages/js/data/src/products/action-types.ts +++ b/packages/js/data/src/products/action-types.ts @@ -14,6 +14,9 @@ export enum TYPES { DELETE_PRODUCT_START = 'DELETE_PRODUCT_START', DELETE_PRODUCT_ERROR = 'DELETE_PRODUCT_ERROR', DELETE_PRODUCT_SUCCESS = 'DELETE_PRODUCT_SUCCESS', + DUPLICATE_PRODUCT_START = 'DUPLICATE_PRODUCT_START', + DUPLICATE_PRODUCT_ERROR = 'DUPLICATE_PRODUCT_ERROR', + DUPLICATE_PRODUCT_SUCCESS = 'DUPLICATE_PRODUCT_SUCCESS', SET_SUGGESTED_PRODUCTS = 'SET_SUGGESTED_PRODUCTS', } diff --git a/packages/js/data/src/products/actions.ts b/packages/js/data/src/products/actions.ts index dec8182268b..b3f9a4bec10 100644 --- a/packages/js/data/src/products/actions.ts +++ b/packages/js/data/src/products/actions.ts @@ -57,6 +57,29 @@ export function createProductError( }; } +function duplicateProductStart( id: number ) { + return { + type: TYPES.DUPLICATE_PRODUCT_START as const, + id, + }; +} + +function duplicateProductSuccess( id: number, product: Partial< Product > ) { + return { + type: TYPES.DUPLICATE_PRODUCT_SUCCESS as const, + id, + product, + }; +} + +export function duplicateProductError( id: number, error: unknown ) { + return { + type: TYPES.DUPLICATE_PRODUCT_ERROR as const, + id, + error, + }; +} + function updateProductStart( id: number ) { return { type: TYPES.UPDATE_PRODUCT_START as const, @@ -165,6 +188,26 @@ export function* updateProduct( } } +export function* duplicateProduct( + id: number, + data: Partial< Omit< Product, ReadOnlyProperties > > +): Generator< unknown, Product, Product > { + yield duplicateProductStart( id ); + try { + const product: Product = yield apiFetch( { + path: `${ WC_PRODUCT_NAMESPACE }/${ id }/duplicate`, + method: 'POST', + data, + } ); + + yield duplicateProductSuccess( product.id, product ); + return product; + } catch ( error ) { + yield duplicateProductError( id, error ); + throw error; + } +} + export function deleteProductStart( id: number ) { return { type: TYPES.DELETE_PRODUCT_START as const, @@ -240,6 +283,9 @@ export type Actions = ReturnType< | typeof deleteProductStart | typeof deleteProductSuccess | typeof deleteProductError + | typeof duplicateProductStart + | typeof duplicateProductError + | typeof duplicateProductSuccess | typeof setSuggestedProductAction >; @@ -247,4 +293,5 @@ export type ActionDispatchers = DispatchFromMap< { createProduct: typeof createProduct; updateProduct: typeof updateProduct; deleteProduct: typeof deleteProduct; + duplicateProduct: typeof duplicateProduct; } >; diff --git a/packages/js/data/src/products/reducer.ts b/packages/js/data/src/products/reducer.ts index 7f34de7c4ac..63584e23b08 100644 --- a/packages/js/data/src/products/reducer.ts +++ b/packages/js/data/src/products/reducer.ts @@ -31,6 +31,7 @@ export type ProductState = { pending: { createProduct?: boolean; updateProduct?: Record< number, boolean >; + duplicateProduct?: Record< number, boolean >; deleteProduct?: Record< number, boolean >; }; @@ -71,9 +72,20 @@ const reducer: Reducer< ProductState, Actions > = ( }, }, }; + case TYPES.DUPLICATE_PRODUCT_START: + return { + ...state, + pending: { + duplicateProduct: { + ...( state.pending.duplicateProduct || {} ), + [ payload.id ]: true, + }, + }, + }; case TYPES.CREATE_PRODUCT_SUCCESS: case TYPES.GET_PRODUCT_SUCCESS: case TYPES.UPDATE_PRODUCT_SUCCESS: + case TYPES.DUPLICATE_PRODUCT_SUCCESS: const productData = state.data || {}; return { ...state, @@ -86,6 +98,10 @@ const reducer: Reducer< ProductState, Actions > = ( }, pending: { createProduct: false, + duplicateProduct: { + ...( state.pending.duplicateProduct || {} ), + [ payload.id ]: false, + }, updateProduct: { ...( state.pending.updateProduct || {} ), [ payload.id ]: false, @@ -158,6 +174,14 @@ const reducer: Reducer< ProductState, Actions > = ( [ `update/${ payload.id }` ]: payload.error, }, }; + case TYPES.DUPLICATE_PRODUCT_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ `duplicate/${ payload.id }` ]: payload.error, + }, + }; case TYPES.DELETE_PRODUCT_START: return { ...state, diff --git a/packages/js/product-editor/changelog/dev-43062_add_menuitem_to_copy_to_draft b/packages/js/product-editor/changelog/dev-43062_add_menuitem_to_copy_to_draft new file mode 100644 index 00000000000..7f01e9316a8 --- /dev/null +++ b/packages/js/product-editor/changelog/dev-43062_add_menuitem_to_copy_to_draft @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Add menu item to Publish button with copy to draft #46294 diff --git a/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx index 12c5fba4bc9..40ae9273ef9 100644 --- a/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx +++ b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx @@ -7,7 +7,7 @@ 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 { getNewPath, navigateTo } from '@woocommerce/navigation'; import { getAdminLink } from '@woocommerce/settings'; /** @@ -31,7 +31,7 @@ export function PublishButtonMenu( { const [ showScheduleModal, setShowScheduleModal ] = useState< 'schedule' | 'edit' | undefined >(); - const { trash } = useProductManager( postType ); + const { copyToDraft, trash } = useProductManager( postType ); const { createErrorNotice, createSuccessNotice } = useDispatch( 'core/notices' ); const [ , , prevStatus ] = useEntityProp< ProductStatus >( @@ -108,6 +108,36 @@ export function PublishButtonMenu( { { prevStatus !== 'trash' && ( + { + copyToDraft() + .then( ( duplicatedProduct ) => { + recordProductEvent( + 'product_copied_to_draft', + duplicatedProduct + ); + createSuccessNotice( + __( + 'Product successfully duplicated', + 'woocommerce' + ) + ); + const url = getNewPath( + {}, + `/product/${ duplicatedProduct.id }` + ); + navigateTo( { url } ); + } ) + .catch( ( error ) => { + const message = + getProductErrorMessage( error ); + createErrorNotice( message ); + } ); + onClose(); + } } + > + { __( 'Copy to a new draft', 'woocommerce' ) } + { diff --git a/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts b/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts index 98f5df9c95a..0fe4d92ab9e 100644 --- a/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts +++ b/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts @@ -4,13 +4,14 @@ import { useEntityProp } from '@wordpress/core-data'; import { dispatch, useSelect, select as wpSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; -import type { Product, ProductStatus } from '@woocommerce/data'; +import { Product, ProductStatus, PRODUCTS_STORE_NAME } from '@woocommerce/data'; /** * Internal dependencies */ import { useValidations } from '../../contexts/validation-context'; import type { WPError } from '../../utils/get-product-error-message'; +import { AUTO_DRAFT_NAME } from '../../utils/constants'; function errorHandler( error: WPError, productStatus: ProductStatus ) { if ( error.code ) { @@ -45,6 +46,11 @@ function errorHandler( error: WPError, productStatus: ProductStatus ) { export function useProductManager< T = Product >( postType: string ) { const [ id ] = useEntityProp< number >( 'postType', postType, 'id' ); + const [ name, , prevName ] = useEntityProp< string >( + 'postType', + postType, + 'name' + ); const [ status ] = useEntityProp< ProductStatus >( 'postType', postType, @@ -102,6 +108,27 @@ export function useProductManager< T = Product >( postType: string ) { } } + async function copyToDraft() { + try { + // When "Copy to a new draft" is used on an unsaved product with a filled-out name, + // the name is retained in the copied product. + const data = + AUTO_DRAFT_NAME === prevName && name !== prevName + ? { name } + : {}; + setIsSaving( true ); + const duplicatedProduct = await dispatch( + PRODUCTS_STORE_NAME + ).duplicateProduct( id, data ); + + return duplicatedProduct 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'; @@ -156,5 +183,6 @@ export function useProductManager< T = Product >( postType: string ) { save, publish, trash, + copyToDraft, }; }