Add product form buttons and specific product edit page (#34211)

* Add initial product form action buttons to form

* Add new edit page and allow for updating and creating products

* Move crud functions to helper hook to keep things seperated better

* Add changelog

* ADd package changelogs

* Add isPending selector to products store

* Fix trash screen showing up when deleting product and add loading indicators

* Add extra types to Product type

* Update track names and add product data to tracks

* Update track logic when product is published

* Remove the Image section for now, to prevent confusion as it is not ready yet

* Add tests for the product form actions

* Update copy for publish & duplicate

* Remove unused code

* Set window.location correctly with href

* Reset changes in pnpm lock

* Moved pending action variables to product helper and updated buttons to reflect new suggestions

* Fix backwards compabitibility issue with Form changes

* Add switch to draft button
This commit is contained in:
louwie17 2022-08-11 11:04:14 -03:00 committed by GitHub
parent 046fb2100e
commit a498cc8280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1506 additions and 91 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update types for Form component and allow Form state to be reset.

View File

@ -7,26 +7,31 @@ import { createContext, useContext } from '@wordpress/element';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FormContext< Values extends Record< string, any > > = {
values: Values;
errors: Record< string, string >;
errors: {
[ P in keyof Values ]?: string;
};
isDirty: boolean;
touched: { [ P in keyof Values ]?: boolean | undefined };
changedFields: { [ P in keyof Values ]?: boolean | undefined };
setTouched: React.Dispatch<
React.SetStateAction< { [ P in keyof Values ]?: boolean | undefined } >
>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue: ( name: string, value: any ) => void;
handleSubmit: () => Promise< Values >;
getInputProps< Value = string >(
getInputProps< Value extends Values[ keyof Values ] >(
name: string
): {
value: Value;
checked: boolean;
selected: Value;
onChange: ( value: ChangeEvent< HTMLInputElement > ) => void;
selected?: boolean;
onChange: ( value: ChangeEvent< HTMLInputElement > | Value ) => void;
onBlur: () => void;
className: string | undefined;
help: string | null;
help: string | null | undefined;
};
isValidForm: boolean;
resetForm: ( initialValues: Values ) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -7,9 +7,11 @@ import {
createElement,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
} from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
import { ChangeEvent, PropsWithChildren } from 'react';
import { ChangeEvent, PropsWithChildren, useRef } from 'react';
/**
* Internal dependencies
@ -20,15 +22,15 @@ type FormProps< Values > = {
/**
* Object of all initial errors to store in state.
*/
errors: Record< keyof Values, string >;
errors?: { [ P in keyof Values ]?: string };
/**
* Object key:value pair list of all initial field values.
*/
initialValues: Values;
initialValues?: Values;
/**
* This prop helps determine whether or not a field has received focus
*/
touched: Record< keyof Values, boolean >;
touched?: Record< keyof Values, boolean >;
/**
* Function to call when a form is submitted with valid fields.
*
@ -61,23 +63,44 @@ type FormProps< Values > = {
validate?: ( values: Values ) => Record< string, string >;
};
function isChangeEvent< T >(
value: T | ChangeEvent< HTMLInputElement >
): value is ChangeEvent< HTMLInputElement > {
return ( value as ChangeEvent< HTMLInputElement > ).target !== undefined;
}
export type FormRef< Values > = {
resetForm: ( initialValues: Values ) => void;
};
/**
* A form component to handle form state and provide input helper props.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function Form< Values extends Record< string, any > >(
props: PropsWithChildren< FormProps< Values > >
function FormComponent< Values extends Record< string, any > >(
{
onSubmit = () => {},
onChange = () => {},
initialValues = {} as Values,
...props
}: PropsWithChildren< FormProps< Values > >,
ref: React.Ref< FormRef< Values > >
): React.ReactElement | null {
const [ values, setValues ] = useState< Values >( props.initialValues );
const [ errors, setErrors ] = useState< Record< string, string > >( {} );
const [ touched, setTouched ] = useState< {
const [ values, setValues ] = useState< Values >( initialValues );
const [ errors, setErrors ] = useState< {
[ P in keyof Values ]?: string;
} >( props.errors || {} );
const [ changedFields, setChangedFields ] = useState< {
[ P in keyof Values ]?: boolean;
} >( {} );
const [ touched, setTouched ] = useState< {
[ P in keyof Values ]?: boolean;
} >( props.touched || {} );
const validate = useCallback(
( newValues: Values, onValidate = () => {} ) => {
const newErrors = props.validate ? props.validate( newValues ) : {};
setErrors( newErrors );
setErrors( newErrors || {} );
onValidate();
},
[ props.validate ]
@ -87,6 +110,17 @@ function Form< Values extends Record< string, any > >(
validate( values );
}, [] );
const resetForm = ( newInitialValues: Values ) => {
setValues( newInitialValues || {} );
setChangedFields( {} );
setTouched( {} );
setErrors( {} );
};
useImperativeHandle( ref, () => ( {
resetForm,
} ) );
const isValidForm = async () => {
await validate( values );
return ! Object.keys( errors ).length;
@ -96,8 +130,22 @@ function Form< Values extends Record< string, any > >(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
( name: string, value: any ) => {
setValues( { ...values, [ name ]: value } );
if ( initialValues[ name ] !== value && ! changedFields[ name ] ) {
setChangedFields( {
...changedFields,
[ name ]: true,
} );
} else if (
initialValues[ name ] === value &&
changedFields[ name ]
) {
setChangedFields( {
...changedFields,
[ name ]: false,
} );
}
validate( { ...values, [ name ]: value }, () => {
const { onChange, onChangeCallback } = props;
const { onChangeCallback } = props;
// Note that onChange is a no-op by default so this will never be null
const callback = onChangeCallback || onChange;
@ -120,13 +168,16 @@ function Form< Values extends Record< string, any > >(
}
} );
},
[ values, validate, props.onChange, props.onChangeCallback ]
[ values, validate, onChange, props.onChangeCallback ]
);
const handleChange = useCallback(
( name: string, value: ChangeEvent< HTMLInputElement > ) => {
(
name: string,
value: ChangeEvent< HTMLInputElement > | Values[ keyof Values ]
) => {
// Handle native events.
if ( value.target ) {
if ( isChangeEvent( value ) && value.target ) {
if ( value.target.type === 'checkbox' ) {
setValue( name, ! values[ name ] );
} else {
@ -150,7 +201,7 @@ function Form< Values extends Record< string, any > >(
);
const handleSubmit = async () => {
const { onSubmitCallback, onSubmit } = props;
const { onSubmitCallback } = props;
const touchedFields: { [ P in keyof Values ]?: boolean } = {};
Object.keys( values ).map(
( name: keyof Values ) => ( touchedFields[ name ] = true )
@ -175,23 +226,26 @@ function Form< Values extends Record< string, any > >(
}
};
function getInputProps< Value = string >(
function getInputProps< Value = Values[ keyof Values ] >(
name: string
): {
value: Value;
checked: boolean;
selected: Value;
onChange: ( value: ChangeEvent< HTMLInputElement > ) => void;
selected?: boolean;
onChange: (
value: ChangeEvent< HTMLInputElement > | Values[ keyof Values ]
) => void;
onBlur: () => void;
className: string | undefined;
help: string | null;
help: string | null | undefined;
} {
return {
value: values[ name ],
checked: Boolean( values[ name ] ),
selected: values[ name ],
onChange: ( value: ChangeEvent< HTMLInputElement > ) =>
handleChange( name, value ),
onChange: (
value: ChangeEvent< HTMLInputElement > | Values[ keyof Values ]
) => handleChange( name, value ),
onBlur: () => handleBlur( name ),
className:
touched[ name ] && errors[ name ] ? 'has-error' : undefined,
@ -212,6 +266,8 @@ function Form< Values extends Record< string, any > >(
};
};
const isDirty = Object.values( changedFields ).some( Boolean );
if ( props.children && typeof props.children === 'function' ) {
const element = props.children( getStateAndHelpers() );
return (
@ -220,11 +276,14 @@ function Form< Values extends Record< string, any > >(
values,
errors,
touched,
isDirty,
changedFields,
setTouched,
setValue,
handleSubmit,
getInputProps,
isValidForm: ! Object.keys( errors ).length,
resetForm,
} }
>
{ cloneElement( element ) }
@ -237,11 +296,14 @@ function Form< Values extends Record< string, any > >(
values,
errors,
touched,
isDirty,
changedFields,
setTouched,
setValue,
handleSubmit,
getInputProps,
isValidForm: ! Object.keys( errors ).length,
resetForm,
} }
>
{ props.children }
@ -249,15 +311,14 @@ function Form< Values extends Record< string, any > >(
);
}
Form.defaultProps = {
errors: {},
initialValues: {},
onSubmitCallback: null,
onSubmit: () => {},
onChangeCallback: null,
onChange: () => {},
touched: {},
validate: () => {},
};
const Form = forwardRef( FormComponent ) as <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Values extends Record< string, any >
>(
props: PropsWithChildren< FormProps< Values > > & {
ref?: React.ForwardedRef< FormRef< Values > >;
},
ref: React.Ref< FormRef< Values > >
) => React.ReactElement | null;
export { Form };

View File

@ -13,7 +13,7 @@ export { default as EllipsisMenu } from './ellipsis-menu';
export { default as EmptyContent } from './empty-content';
export { default as Flag } from './flag';
export { Form, useFormContext } from './form';
export type { FormContext } from './form';
export type { FormContext, FormRef } from './form';
export { default as FilterPicker } from './filter-picker';
export { H, Section } from './section';
export { default as ImageUpload } from './image-upload';

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add getProduct selector and deleteProduct action to products data store.

View File

@ -1,4 +1,5 @@
export enum TYPES {
CREATE_PRODUCT_START = 'CREATE_PRODUCT_START',
CREATE_PRODUCT_ERROR = 'CREATE_PRODUCT_ERROR',
CREATE_PRODUCT_SUCCESS = 'CREATE_PRODUCT_SUCCESS',
GET_PRODUCT_SUCCESS = 'GET_PRODUCT_SUCCESS',
@ -7,8 +8,10 @@ export enum TYPES {
GET_PRODUCTS_ERROR = 'GET_PRODUCTS_ERROR',
GET_PRODUCTS_TOTAL_COUNT_SUCCESS = 'GET_PRODUCTS_TOTAL_COUNT_SUCCESS',
GET_PRODUCTS_TOTAL_COUNT_ERROR = 'GET_PRODUCTS_TOTAL_COUNT_ERROR',
UPDATE_PRODUCT_START = 'UPDATE_PRODUCT_START',
UPDATE_PRODUCT_ERROR = 'UPDATE_PRODUCT_ERROR',
UPDATE_PRODUCT_SUCCESS = 'UPDATE_PRODUCT_SUCCESS',
DELETE_PRODUCT_START = 'DELETE_PRODUCT_START',
DELETE_PRODUCT_ERROR = 'DELETE_PRODUCT_ERROR',
DELETE_PRODUCT_SUCCESS = 'DELETE_PRODUCT_SUCCESS',
}

View File

@ -24,17 +24,20 @@ export function getProductSuccess( id: number, product: PartialProduct ) {
};
}
export function getProductError(
query: Partial< ProductQuery >,
error: unknown
) {
export function getProductError( productId: number, error: unknown ) {
return {
type: TYPES.GET_PRODUCT_ERROR as const,
query,
productId,
error,
};
}
function createProductStart() {
return {
type: TYPES.CREATE_PRODUCT_START as const,
};
}
function createProductSuccess( id: number, product: Partial< Product > ) {
return {
type: TYPES.CREATE_PRODUCT_SUCCESS as const,
@ -54,6 +57,13 @@ export function createProductError(
};
}
function updateProductStart( id: number ) {
return {
type: TYPES.UPDATE_PRODUCT_START as const,
id,
};
}
function updateProductSuccess( id: number, product: Partial< Product > ) {
return {
type: TYPES.UPDATE_PRODUCT_SUCCESS as const,
@ -116,7 +126,10 @@ export function getProductsTotalCountError(
};
}
export function* createProduct( data: Omit< Product, ReadOnlyProperties > ) {
export function* createProduct(
data: Omit< Product, ReadOnlyProperties >
): Generator< unknown, Product, Product > {
yield createProductStart();
try {
const product: Product = yield apiFetch( {
path: WC_PRODUCT_NAMESPACE,
@ -135,7 +148,8 @@ export function* createProduct( data: Omit< Product, ReadOnlyProperties > ) {
export function* updateProduct(
id: number,
data: Omit< Product, ReadOnlyProperties >
) {
): Generator< unknown, Product, Product > {
yield updateProductStart( id );
try {
const product: Product = yield apiFetch( {
path: `${ WC_PRODUCT_NAMESPACE }/${ id }`,
@ -151,6 +165,13 @@ export function* updateProduct(
}
}
export function deleteProductStart( id: number ) {
return {
type: TYPES.DELETE_PRODUCT_START as const,
id,
};
}
export function deleteProductSuccess(
id: number,
product: PartialProduct,
@ -172,7 +193,11 @@ export function deleteProductError( id: number, error: unknown ) {
};
}
export function* removeProduct( id: number, force = false ) {
export function* deleteProduct(
id: number,
force = false
): Generator< unknown, Product, Product > {
yield deleteProductStart( id );
try {
const url = force
? `${ WC_PRODUCT_NAMESPACE }/${ id }?force=true`
@ -192,6 +217,7 @@ export function* removeProduct( id: number, force = false ) {
}
export type Actions = ReturnType<
| typeof createProductStart
| typeof createProductError
| typeof createProductSuccess
| typeof getProductSuccess
@ -200,8 +226,10 @@ export type Actions = ReturnType<
| typeof getProductsError
| typeof getProductsTotalCountSuccess
| typeof getProductsTotalCountError
| typeof updateProductStart
| typeof updateProductError
| typeof updateProductSuccess
| typeof deleteProductStart
| typeof deleteProductSuccess
| typeof deleteProductError
>;
@ -209,4 +237,5 @@ export type Actions = ReturnType<
export type ActionDispatchers = DispatchFromMap< {
createProduct: typeof createProduct;
updateProduct: typeof updateProduct;
deleteProduct: typeof deleteProduct;
} >;

View File

@ -24,6 +24,11 @@ export type ProductState = {
productsCount: Record< string, number >;
errors: Record< string, unknown >;
data: Record< number, PartialProduct >;
pending: {
createProduct?: boolean;
updateProduct?: Record< number, boolean >;
deleteProduct?: Record< number, boolean >;
};
};
const reducer: Reducer< ProductState, Actions > = (
@ -32,11 +37,29 @@ const reducer: Reducer< ProductState, Actions > = (
productsCount: {},
errors: {},
data: {},
pending: {},
},
payload
) => {
if ( payload && 'type' in payload ) {
switch ( payload.type ) {
case TYPES.CREATE_PRODUCT_START:
return {
...state,
pending: {
createProduct: true,
},
};
case TYPES.UPDATE_PRODUCT_START:
return {
...state,
pending: {
updateProduct: {
...( state.pending.updateProduct || {} ),
[ payload.id ]: true,
},
},
};
case TYPES.CREATE_PRODUCT_SUCCESS:
case TYPES.GET_PRODUCT_SUCCESS:
case TYPES.UPDATE_PRODUCT_SUCCESS:
@ -50,6 +73,13 @@ const reducer: Reducer< ProductState, Actions > = (
...payload.product,
},
},
pending: {
createProduct: false,
updateProduct: {
...( state.pending.updateProduct || {} ),
[ payload.id ]: false,
},
},
};
case TYPES.GET_PRODUCTS_SUCCESS:
const ids: number[] = [];
@ -88,6 +118,13 @@ const reducer: Reducer< ProductState, Actions > = (
},
};
case TYPES.GET_PRODUCT_ERROR:
return {
...state,
errors: {
...state.errors,
[ payload.productId ]: payload.error,
},
};
case TYPES.GET_PRODUCTS_ERROR:
case TYPES.GET_PRODUCTS_TOTAL_COUNT_ERROR:
case TYPES.CREATE_PRODUCT_ERROR:
@ -98,6 +135,9 @@ const reducer: Reducer< ProductState, Actions > = (
[ getProductResourceName( payload.query ) ]:
payload.error,
},
pending: {
createProduct: false,
},
};
case TYPES.UPDATE_PRODUCT_ERROR:
return {
@ -107,6 +147,16 @@ const reducer: Reducer< ProductState, Actions > = (
[ `update/${ payload.id }` ]: payload.error,
},
};
case TYPES.DELETE_PRODUCT_START:
return {
...state,
pending: {
deleteProduct: {
...( state.pending.deleteProduct || {} ),
[ payload.id ]: true,
},
},
};
case TYPES.DELETE_PRODUCT_ERROR:
return {
...state,
@ -114,6 +164,12 @@ const reducer: Reducer< ProductState, Actions > = (
...state.errors,
[ `delete/${ payload.id }` ]: payload.error,
},
pending: {
deleteProduct: {
...( state.pending.deleteProduct || {} ),
[ payload.id ]: false,
},
},
};
case TYPES.DELETE_PRODUCT_SUCCESS:
const prData = state.data || {};
@ -127,6 +183,12 @@ const reducer: Reducer< ProductState, Actions > = (
status: payload.force ? 'deleted' : 'trash',
},
},
pending: {
deleteProduct: {
...( state.pending.deleteProduct || {} ),
[ payload.id ]: false,
},
},
};
default:
return state;

View File

@ -1,13 +1,20 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { WC_PRODUCT_NAMESPACE } from './constants';
import { Product, ProductQuery } from './types';
import {
getProductError,
getProductsError,
getProductsSuccess,
getProductsTotalCountError,
getProductsTotalCountSuccess,
getProductSuccess,
} from './actions';
import { request } from '../utils';
@ -38,6 +45,21 @@ export function* getProducts( query: Partial< ProductQuery > ) {
}
}
export function* getProduct( productId: number ) {
try {
const product: Product = yield apiFetch( {
path: `${ WC_PRODUCT_NAMESPACE }/${ productId }`,
method: 'GET',
} );
yield getProductSuccess( productId, product );
return product;
} catch ( error ) {
yield getProductError( productId, error );
throw error;
}
}
export function* getProductsTotalCount( query: Partial< ProductQuery > ) {
try {
const totalsQuery = {

View File

@ -13,6 +13,15 @@ import {
import { WPDataSelector, WPDataSelectors } from '../types';
import { ProductState } from './reducer';
import { PartialProduct, ProductQuery } from './types';
import { ActionDispatchers } from './actions';
export const getProduct = (
state: ProductState,
productId: number,
defaultValue = undefined
) => {
return state.data[ productId ] || defaultValue;
};
export const getProducts = createSelector(
( state: ProductState, query: ProductQuery, defaultValue = undefined ) => {
@ -98,9 +107,24 @@ export const getDeleteProductError = ( state: ProductState, id: number ) => {
return state.errors[ `delete/${ id }` ];
};
export const isPending = (
state: ProductState,
action: keyof ActionDispatchers,
productId?: number
) => {
if ( productId !== undefined && action !== 'createProduct' ) {
return state.pending[ action ]?.[ productId ] || false;
} else if ( action === 'createProduct' ) {
return state.pending[ action ] || false;
}
return false;
};
export type ProductsSelectors = {
getCreateProductError: WPDataSelector< typeof getCreateProductError >;
getProduct: WPDataSelector< typeof getProduct >;
getProducts: WPDataSelector< typeof getProducts >;
getProductsTotalCount: WPDataSelector< typeof getProductsTotalCount >;
getProductsError: WPDataSelector< typeof getProductsError >;
isPending: WPDataSelector< typeof isPending >;
} & WPDataSelectors;

View File

@ -15,6 +15,7 @@ const defaultState: ProductState = {
productsCount: {},
errors: {},
data: {},
pending: {},
};
describe( 'products reducer', () => {
@ -40,6 +41,7 @@ describe( 'products reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' },
},
pending: {},
};
const update: PartialProduct = {
id: 2,
@ -188,14 +190,23 @@ describe( 'products reducer', () => {
status: 'draft',
};
const state = reducer( defaultState, {
const state = reducer(
{
...defaultState,
pending: {
createProduct: true,
},
},
{
type: TYPES.CREATE_PRODUCT_SUCCESS,
id: update.id,
product: update,
} );
}
);
expect( state.data[ 2 ].name ).toEqual( update.name );
expect( state.data[ 2 ].status ).toEqual( update.status );
expect( state.pending.createProduct ).toEqual( false );
} );
it( 'should handle CREATE_PRODUCT_ERROR', () => {
@ -211,6 +222,15 @@ describe( 'products reducer', () => {
expect( state.errors[ resourceName ] ).toBe( error );
} );
it( 'should handle CREATE_PRODUCT_START', () => {
const id = 1;
const state = reducer( defaultState, {
type: TYPES.CREATE_PRODUCT_START,
} );
expect( state.pending.createProduct ).toEqual( true );
} );
it( 'should handle UPDATE_PRODUCT_SUCCESS', () => {
const itemType = 'guyisms';
const initialState: ProductState = {
@ -227,6 +247,12 @@ describe( 'products reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' },
},
pending: {
updateProduct: {
1: false,
2: true,
},
},
};
const product: PartialProduct = {
id: 2,
@ -247,6 +273,10 @@ describe( 'products reducer', () => {
expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id );
expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title );
expect( state.data[ 2 ].name ).toEqual( product.name );
expect( ( state.pending.updateProduct || {} )[ product.id ] ).toEqual(
false
);
expect( ( state.pending.updateProduct || {} )[ 1 ] ).toEqual( false );
} );
it( 'should handle UPDATE_PRODUCT_ERROR', () => {
@ -261,6 +291,16 @@ describe( 'products reducer', () => {
expect( state.errors[ `update/${ id }` ] ).toBe( error );
} );
it( 'should handle UPDATE_PRODUCT_START', () => {
const id = 1;
const state = reducer( defaultState, {
type: TYPES.UPDATE_PRODUCT_START,
id,
} );
expect( ( state.pending.updateProduct || {} )[ id ] ).toEqual( true );
} );
it( 'should handle DELETE_PRODUCT_SUCCESS', () => {
const itemType = 'guyisms';
const initialState: ProductState = {
@ -277,6 +317,12 @@ describe( 'products reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' },
},
pending: {
deleteProduct: {
1: true,
2: true,
},
},
};
const product: PartialProduct = {
id: 1,
@ -304,6 +350,12 @@ describe( 'products reducer', () => {
expect( state.data[ 1 ].status ).toEqual( 'trash' );
expect( state.data[ 2 ].status ).toEqual( 'deleted' );
expect( ( state.pending.deleteProduct || {} )[ product.id ] ).toEqual(
false
);
expect(
( state.pending.deleteProduct || {} )[ anotherProduct.id ]
).toEqual( false );
} );
it( 'should handle DELETE_PRODUCT_ERROR', () => {
@ -317,4 +369,14 @@ describe( 'products reducer', () => {
expect( state.errors[ `delete/${ id }` ] ).toBe( error );
} );
it( 'should handle DELETE_PRODUCT_START', () => {
const id = 1;
const state = reducer( defaultState, {
type: TYPES.DELETE_PRODUCT_START,
id,
} );
expect( ( state.pending.deleteProduct || {} )[ id ] ).toEqual( true );
} );
} );

View File

@ -19,6 +19,12 @@ export type ProductStatus =
| 'trash'
| 'future';
export type ProductDownload = {
id: string;
name: string;
file: string;
};
export type Product< Status = ProductStatus, Type = ProductType > = Omit<
Schema.Post,
'status'
@ -37,32 +43,65 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit<
description: string;
short_description: string;
sku: string;
date_on_sale_from: string | null;
date_on_sale_from_gmt: string | null;
date_on_sale_to: string | null;
date_on_sale_to_gmt: string | null;
virtual: boolean;
downloadable: boolean;
downloads: ProductDownload[];
download_limit: number;
download_expiry: number;
external_url: string;
button_text: string;
tax_status: 'taxable' | 'shipping' | 'none';
tax_class: 'standard' | 'reduced-rate' | 'zero-rate' | undefined;
manage_stock: boolean;
stock_quantity: number;
stock_status: 'instock' | 'outofstock' | 'onbackorder';
backorders: 'no' | 'notify' | 'yes';
price: string;
price_html: string;
regular_price: string;
sale_price: string;
on_sale: boolean;
purchasable: boolean;
total_sales: number;
backorders_allowed: boolean;
backordered: boolean;
shipping_required: boolean;
shipping_taxable: boolean;
shipping_class_id: number;
average_rating: string;
rating_count: number;
related_ids: number[];
variations: number[];
};
export type ReadOnlyProperties =
| 'id'
| 'permalink'
| 'date_created'
| 'date_created_gmt'
| 'date_modified'
| 'date_modified_gmt'
| 'price'
| 'price_html'
| 'on_sale'
| 'purchasable'
| 'total_sales'
| 'backorders_allowed'
| 'backordered'
| 'shipping_required'
| 'shipping_taxable'
| 'shipping_class_id'
| 'average_rating'
| 'rating_count'
| 'related_ids'
| 'variations';
export const productReadOnlyProperties = [
'id',
'permalink',
'date_created',
'date_created_gmt',
'date_modified',
'date_modified_gmt',
'price',
'price_html',
'on_sale',
'purchasable',
'total_sales',
'backorders_allowed',
'backordered',
'shipping_required',
'shipping_taxable',
'shipping_class_id',
'average_rating',
'rating_count',
'related_ids',
'variations',
] as const;
export type ReadOnlyProperties = typeof productReadOnlyProperties[ number ];
export type PartialProduct = Partial< Product > & Pick< Product, 'id' >;
@ -93,5 +132,5 @@ export type ProductQuery<
on_sale: boolean;
min_price: string;
max_price: string;
stock_status: 'instock' | 'outofstock';
stock_status: 'instock' | 'outofstock' | 'onbackorder';
};

View File

@ -24,6 +24,12 @@ import getReports from '../analytics/report/get-reports';
import { getAdminSetting } from '~/utils/admin-settings';
import { NoMatch } from './NoMatch';
const EditProductPage = lazy( () =>
import(
/* webpackChunkName: "edit-product-page" */ '../products/edit-product-page'
)
);
const AddProductPage = lazy( () =>
import(
/* webpackChunkName: "add-product-page" */ '../products/add-product-page'
@ -173,7 +179,7 @@ export const getPages = () => {
path: '/add-product',
breadcrumbs: [
[ '/add-product', __( 'Product', 'woocommerce' ) ],
__( 'Add New', 'woocommerce' ),
__( 'Add New Product', 'woocommerce' ),
],
navArgs: {
id: 'woocommerce-add-product',
@ -181,6 +187,20 @@ export const getPages = () => {
wpOpenMenu: 'menu-posts-product',
capability: 'manage_woocommerce',
} );
pages.push( {
container: EditProductPage,
path: '/product/:productId',
breadcrumbs: [
[ '/edit-product', __( 'Product', 'woocommerce' ) ],
__( 'Edit Product', 'woocommerce' ),
],
navArgs: {
id: 'woocommerce-edit-product',
},
wpOpenMenu: 'menu-posts-product',
capability: 'manage_woocommerce',
} );
}
if ( window.wcAdminFeatures.onboarding ) {

View File

@ -3,24 +3,32 @@
*/
import { recordEvent } from '@woocommerce/tracks';
import { useEffect } from '@wordpress/element';
import { Form } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { ProductFormLayout } from './layout/product-form-layout';
import { ProductFormActions } from './product-form-actions';
import { ProductDetailsSection } from './sections/product-details-section';
import { ProductImagesSection } from './sections/product-images-section';
import './product-page.scss';
const AddProductPage: React.FC = () => {
useEffect( () => {
recordEvent( 'view_new_product_management_experience' );
}, [] );
return (
<div className="woocommerce-add-product">
<Form< Partial< Product > > initialValues={ {} } errors={ {} }>
<ProductFormLayout>
<ProductDetailsSection />
<ProductImagesSection />
<ProductFormActions />
</ProductFormLayout>
</Form>
</div>
);
};

View File

@ -0,0 +1,115 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect, useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { Form, Spinner, FormRef } from '@woocommerce/components';
import {
PartialProduct,
Product,
PRODUCTS_STORE_NAME,
WCDataSelector,
} from '@woocommerce/data';
import { useParams } from 'react-router-dom';
/**
* Internal dependencies
*/
import { ProductFormLayout } from './layout/product-form-layout';
import { ProductFormActions } from './product-form-actions';
import { ProductDetailsSection } from './sections/product-details-section';
import './product-page.scss';
const EditProductPage: React.FC = () => {
const { productId } = useParams();
const previousProductRef = useRef< PartialProduct >();
const formRef = useRef< FormRef< Partial< Product > > >( null );
const { product, isLoading, isPendingAction } = useSelect(
( select: WCDataSelector ) => {
const { getProduct, hasFinishedResolution, isPending } =
select( PRODUCTS_STORE_NAME );
if ( productId ) {
return {
product: getProduct( parseInt( productId, 10 ), undefined ),
isLoading: ! hasFinishedResolution( 'getProduct', [
parseInt( productId, 10 ),
] ),
isPendingAction:
isPending( 'createProduct' ) ||
isPending(
'deleteProduct',
parseInt( productId, 10 )
) ||
isPending( 'updateProduct', parseInt( productId, 10 ) ),
};
}
return {
isLoading: false,
isPendingAction: false,
};
}
);
useEffect( () => {
// used for determening the wasDeletedUsingAction condition.
if (
previousProductRef.current &&
product &&
previousProductRef.current.id !== product.id &&
formRef.current
) {
formRef.current.resetForm( product );
}
previousProductRef.current = product;
}, [ product ] );
useEffect( () => {
recordEvent( 'view_new_product_management_experience' );
}, [] );
const wasDeletedUsingAction =
previousProductRef.current?.id === product?.id &&
previousProductRef.current?.status !== 'trash' &&
product?.status === 'trash';
return (
<div className="woocommerce-edit-product">
{ isLoading && ! product ? (
<div className="woocommerce-edit-product__spinner">
<Spinner />
</div>
) : null }
{ product &&
product.status === 'trash' &&
! isPendingAction &&
! wasDeletedUsingAction && (
<ProductFormLayout>
<div className="woocommerce-edit-product__error">
{ __(
'You cannot edit this item because it is in the Trash. Please restore it and try again.',
'woocommerce'
) }
</div>
</ProductFormLayout>
) }
{ product &&
( product.status !== 'trash' || wasDeletedUsingAction ) && (
<Form< Partial< Product > >
ref={ formRef }
initialValues={ product || {} }
errors={ {} }
>
<ProductFormLayout>
<ProductDetailsSection />
<ProductFormActions />
</ProductFormLayout>
</Form>
) }
</div>
);
};
export default EditProductPage;

View File

@ -10,10 +10,6 @@ import { SlotFillProvider } from '@wordpress/components';
import { ProductFieldLayout } from '../product-field-layout';
import { WooProductFieldItem } from '../woo-product-field-item';
const SampleInputField: React.FC< { name: string } > = ( { name } ) => {
return <div>smaple-input-field-{ name }</div>;
};
describe( 'ProductFieldLayout', () => {
beforeEach( () => {
jest.clearAllMocks();

View File

@ -0,0 +1,37 @@
$gutenberg-blue: #007cba;
$gutenberg-blue-darker: #0063a1;
.woocommerce-product-form-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
> .components-button {
margin-right: $gap-smaller;
}
.components-button:not(.is-primary):not(.is-destructive) {
color: $gutenberg-blue;
&:hover {
color: $gutenberg-blue-darker;
}
}
&__publish-button-group {
display: flex;
> :not(:last-child) {
margin-right: 1px;
}
.components-button.is-primary {
box-shadow: none;
}
.components-menu-group .components-button {
box-shadow: none;
}
}
}

View File

@ -0,0 +1,219 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
Button,
ButtonGroup,
DropdownMenu,
MenuGroup,
MenuItem,
} from '@wordpress/components';
import { chevronDown, check, Icon } from '@wordpress/icons';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './product-form-actions.scss';
import { useProductHelper } from './use-product-helper';
export const ProductFormActions: React.FC = () => {
const {
createProductWithStatus,
updateProductWithStatus,
deleteProductAndRedirect,
copyProductWithStatus,
isUpdatingDraft,
isUpdatingPublished,
isDeleting,
} = useProductHelper();
const { isDirty, values, resetForm } = useFormContext< Product >();
const getProductDataForTracks = () => {
return {
product_id: values.id,
product_type: values.type,
is_downloadable: values.downloadable,
is_virtual: values.virtual,
manage_stock: values.manage_stock,
};
};
const onSaveDraft = async () => {
recordEvent( 'product_edit', {
new_product_page: true,
...getProductDataForTracks(),
} );
if ( ! values.id ) {
createProductWithStatus( values, 'draft' );
} else {
const product = await updateProductWithStatus( values, 'draft' );
if ( product && product.id ) {
resetForm( product );
}
}
};
const onPublish = async () => {
recordEvent( 'product_update', {
new_product_page: true,
...getProductDataForTracks(),
} );
if ( ! values.id ) {
createProductWithStatus( values, 'publish' );
} else {
const product = await updateProductWithStatus( values, 'publish' );
if ( product && product.id ) {
resetForm( product );
}
}
};
const onPublishAndDuplicate = async () => {
recordEvent( 'product_publish_and_copy', {
new_product_page: true,
...getProductDataForTracks(),
} );
if ( values.id ) {
await updateProductWithStatus( values, 'publish' );
} else {
await createProductWithStatus( values, 'publish', false, true );
}
await copyProductWithStatus( values );
};
const onCopyToNewDraft = async () => {
recordEvent( 'product_copy', {
new_product_page: true,
...getProductDataForTracks(),
} );
if ( values.id ) {
await updateProductWithStatus( values, values.status || 'draft' );
}
await copyProductWithStatus( values );
};
const onTrash = () => {
recordEvent( 'product_delete', {
new_product_page: true,
...getProductDataForTracks(),
} );
if ( values.id ) {
deleteProductAndRedirect( values.id );
}
};
const isPublished = values.id && values.status === 'publish';
return (
<div className="woocommerce-product-form-actions">
<Button
onClick={ onSaveDraft }
disabled={
( ! isDirty &&
!! values.id &&
values.status !== 'publish' ) ||
isUpdatingDraft ||
isUpdatingPublished ||
isDeleting
}
>
{ ! isDirty && values.id && values.status !== 'publish' && (
<Icon icon={ check } />
) }
{ isUpdatingDraft ? __( 'Saving', 'woocommerce' ) : null }
{ ( isDirty || ! values.id ) &&
! isUpdatingDraft &&
values.status !== 'publish'
? __( 'Save draft', 'woocommerce' )
: null }
{ values.status === 'publish' && ! isUpdatingDraft
? __( 'Switch to draft', 'woocommerce' )
: null }
{ ! isDirty &&
values.id &&
! isUpdatingDraft &&
values.status !== 'publish'
? __( 'Saved', 'woocommerce' )
: null }
</Button>
<Button
onClick={ () =>
recordEvent( 'product_preview_changes', {
new_product_page: true,
...getProductDataForTracks(),
} )
}
href={ values.permalink + '?preview=true' }
disabled={ ! values.permalink }
target="_blank"
>
{ __( 'Preview', 'woocommerce' ) }
</Button>
<ButtonGroup className="woocommerce-product-form-actions__publish-button-group">
<Button
onClick={ onPublish }
variant="primary"
isBusy={ isUpdatingPublished }
disabled={
( ! isDirty && !! isPublished ) ||
isUpdatingDraft ||
isUpdatingPublished ||
isDeleting
}
>
{ isUpdatingPublished
? __( 'Updating', 'woocommerce' )
: null }
{ isPublished && ! isUpdatingPublished
? __( 'Update', 'woocommerce' )
: null }
{ ! isPublished && ! isUpdatingPublished
? __( 'Publish', 'woocommerce' )
: null }
</Button>
<DropdownMenu
className="woocommerce-product-form-actions__publish-dropdown"
label={ __( 'Publish options', 'woocommerce' ) }
icon={ chevronDown }
popoverProps={ { position: 'bottom left' } }
toggleProps={ { variant: 'primary' } }
>
{ () => (
<>
<MenuGroup>
<MenuItem onClick={ onPublishAndDuplicate }>
{ isPublished
? __(
'Update & duplicate',
'woocommerce'
)
: __(
'Publish & duplicate',
'woocommerce'
) }
</MenuItem>
<MenuItem onClick={ onCopyToNewDraft }>
{ __(
'Copy to a new draft',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ onTrash }
isDestructive
disabled={ ! values.id }
>
{ __( 'Move to trash', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</>
) }
</DropdownMenu>
</ButtonGroup>
</div>
);
};

View File

@ -0,0 +1,27 @@
.woocommerce-add-product,
.woocommerce-edit-product {
.woocommerce-product-form-actions {
margin-top: $gap-largest + $gap-smaller;
}
}
.woocommerce-edit-product {
position: relative;
&__error {
background: #fff;
text-align: center;
border: 1px solid $gray-400;
border-radius: 2px;
padding: 1em 2em;
}
&__spinner {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
}
}

View File

@ -3,6 +3,8 @@
*/
import { TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
@ -10,6 +12,7 @@ import { __ } from '@wordpress/i18n';
import { ProductSectionLayout } from '../layout/product-section-layout';
export const ProductDetailsSection: React.FC = () => {
const formContext = useFormContext< Product >();
return (
<ProductSectionLayout
title={ __( 'Product details', 'woocommerce' ) }
@ -21,8 +24,7 @@ export const ProductDetailsSection: React.FC = () => {
<TextControl
label={ __( 'Name', 'woocommerce' ) }
name="name"
value={ '' }
onChange={ () => {} }
{ ...formContext.getInputProps< string >( 'name' ) }
/>
</ProductSectionLayout>
);

View File

@ -0,0 +1,415 @@
/**
* External dependencies
*/
import { render, waitFor } from '@testing-library/react';
import { Form, FormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import { ProductFormActions } from '../product-form-actions';
const createProductWithStatus = jest.fn();
const updateProductWithStatus = jest.fn();
const copyProductWithStatus = jest.fn();
const deleteProductAndRedirect = jest.fn();
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
jest.mock( '../use-product-helper', () => {
return {
useProductHelper: () => ( {
createProductWithStatus,
updateProductWithStatus,
copyProductWithStatus,
deleteProductAndRedirect,
} ),
};
} );
describe( 'ProductFormActions', () => {
beforeEach( () => {
jest.clearAllMocks();
} );
it( 'should render the form action buttons', () => {
const { queryByText } = render(
<Form initialValues={ {} }>
<ProductFormActions />
</Form>
);
expect( queryByText( 'Save draft' ) ).toBeInTheDocument();
expect( queryByText( 'Preview' ) ).toBeInTheDocument();
expect( queryByText( 'Publish' ) ).toBeInTheDocument();
} );
it( 'should have a publish dropdown button with three other actions', () => {
const { queryByText, queryByLabelText, debug } = render(
<Form initialValues={ {} }>
<ProductFormActions />
</Form>
);
queryByLabelText( 'Publish options' )?.click();
expect( queryByText( 'Publish & duplicate' ) ).toBeInTheDocument();
expect( queryByText( 'Copy to a new draft' ) ).toBeInTheDocument();
expect( queryByText( 'Move to trash' ) ).toBeInTheDocument();
} );
describe( 'with new product', () => {
it( 'should trigger createProductWithStatus and the product_edit track when Save draft is clicked', () => {
const product = { name: 'Name' };
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByText( 'Save draft' )?.click();
expect( createProductWithStatus ).toHaveBeenCalledWith(
product,
'draft'
);
expect( recordEvent ).toHaveBeenCalledWith( 'product_edit', {
new_product_page: true,
product_id: undefined,
product_type: undefined,
is_downloadable: undefined,
is_virtual: undefined,
manage_stock: undefined,
} );
} );
it( 'should trigger createProductWithStatus and the product_update track when Publish is clicked', () => {
const product = { name: 'Name' };
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByText( 'Publish' )?.click();
expect( createProductWithStatus ).toHaveBeenCalledWith(
product,
'publish'
);
expect( recordEvent ).toHaveBeenCalledWith( 'product_update', {
new_product_page: true,
product_id: undefined,
product_type: undefined,
is_downloadable: undefined,
is_virtual: undefined,
manage_stock: undefined,
} );
} );
it( 'should have the Preview button disabled', () => {
const product = { name: 'Name' };
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
const previewButton = queryByText( 'Preview' );
expect( ( previewButton as HTMLButtonElement ).disabled ).toEqual(
true
);
} );
it( 'should have the Move to trash button disabled', () => {
const product = { name: 'Name' };
const { queryByText, queryByLabelText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByLabelText( 'Publish options' )?.click();
const moveToTrashButton = queryByText( 'Move to trash' );
expect(
( moveToTrashButton?.parentElement as HTMLButtonElement )
.disabled
).toEqual( true );
} );
} );
describe( 'with existing product', () => {
it( 'The publish button should be renamed to Update when product is published', () => {
const { queryByText } = render(
<Form< Partial< Product > >
initialValues={ { id: 5, name: 'test', status: 'publish' } }
>
<ProductFormActions />
</Form>
);
expect( queryByText( 'Update' ) ).toBeInTheDocument();
} );
it( 'should trigger updateProductWithStatus and the product_edit track when Save draft is clicked', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'draft',
downloadable: false,
virtual: false,
manage_stock: true,
};
const { queryByText, getByLabelText } = render(
<Form< Partial< Product > > initialValues={ product }>
{ ( { getInputProps }: FormContext< Product > ) => {
return (
<>
<label htmlFor="product-name">Name</label>
<input
id="product-name"
name="name"
{ ...getInputProps< string >( 'name' ) }
/>
<ProductFormActions />
</>
);
} }
</Form>
);
userEvent.type(
getByLabelText( 'Name' ),
'{esc}{space}Update',
{}
);
queryByText( 'Save draft' )?.click();
expect( updateProductWithStatus ).toHaveBeenCalledWith(
{ ...product, name: 'Name Update' },
'draft'
);
expect( recordEvent ).toHaveBeenCalledWith( 'product_edit', {
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
} );
} );
it( 'should trigger updateProductWithStatus and the product_update track when Publish is clicked', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'draft',
downloadable: false,
virtual: false,
manage_stock: true,
};
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
const publishButton = queryByText( 'Publish' );
expect( ( publishButton as HTMLButtonElement ).disabled ).toEqual(
false
);
publishButton?.click();
expect( recordEvent ).toHaveBeenCalledWith( 'product_update', {
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
} );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product,
'publish'
);
} );
it( 'should disable publish/update button when product is published and not dirty', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
};
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
const publishButton = queryByText( 'Update' );
expect( ( publishButton as HTMLButtonElement ).disabled ).toEqual(
true
);
} );
it( 'should have the Preview button enabled', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
permalink: 'some_permalink',
};
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
const previewButton = queryByText( 'Preview' );
expect( ( previewButton as HTMLButtonElement ).disabled ).toEqual(
undefined
);
} );
it( 'should trigger the product_preview_changes track when Preview is clicked', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
permalink: 'some_permalink',
};
const { queryByText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
const previewButton = queryByText( 'Preview' );
previewButton?.click();
expect( recordEvent ).toHaveBeenCalledWith(
'product_preview_changes',
{
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
}
);
} );
it( 'should have the Move to trash button enabled and trigger the product_delete track and deleteProductAndRedirect function', () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
permalink: 'some_permalink',
};
const { queryByText, queryByLabelText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByLabelText( 'Publish options' )?.click();
const moveToTrashButton = queryByText( 'Move to trash' );
expect(
( moveToTrashButton?.parentElement as HTMLButtonElement )
.disabled
).toEqual( false );
moveToTrashButton?.click();
expect( recordEvent ).toHaveBeenCalledWith( 'product_delete', {
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
} );
expect( deleteProductAndRedirect ).toHaveBeenCalledWith(
product.id
);
} );
it( 'should trigger updateProductWithStatus and copyProductWithStatus when Update & duplicate is clicked', async () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
permalink: 'some_permalink',
};
const { queryByText, queryByLabelText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByLabelText( 'Publish options' )?.click();
const publishAndDuplicateButton =
queryByText( 'Update & duplicate' );
publishAndDuplicateButton?.click();
expect( recordEvent ).toHaveBeenCalledWith(
'product_publish_and_copy',
{
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
}
);
updateProductWithStatus.mockReturnValue( Promise.resolve() );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product,
'publish'
);
await waitFor( () =>
expect( copyProductWithStatus ).toHaveBeenCalledWith( product )
);
} );
it( 'should trigger updateProductWithStatus and copyProductWithStatus when Copy to a new draft is clicked', async () => {
const product: Partial< Product > = {
id: 5,
name: 'Name',
type: 'simple',
status: 'publish',
downloadable: false,
virtual: false,
manage_stock: true,
permalink: 'some_permalink',
};
const { queryByText, queryByLabelText } = render(
<Form initialValues={ product }>
<ProductFormActions />
</Form>
);
queryByLabelText( 'Publish options' )?.click();
const copyToANewDraftButton = queryByText( 'Copy to a new draft' );
copyToANewDraftButton?.click();
expect( recordEvent ).toHaveBeenCalledWith( 'product_copy', {
new_product_page: true,
product_id: 5,
product_type: 'simple',
is_downloadable: false,
is_virtual: false,
manage_stock: true,
} );
updateProductWithStatus.mockReturnValue( Promise.resolve() );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product,
'publish'
);
await waitFor( () =>
expect( copyProductWithStatus ).toHaveBeenCalledWith( product )
);
} );
} );
} );

View File

@ -0,0 +1,249 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useDispatch } from '@wordpress/data';
import { useCallback, useState } from '@wordpress/element';
import {
Product,
ProductsStoreActions,
ProductStatus,
PRODUCTS_STORE_NAME,
ReadOnlyProperties,
productReadOnlyProperties,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { navigateTo } from '@woocommerce/navigation';
function removeReadonlyProperties(
product: Product
): Omit< Product, ReadOnlyProperties > {
productReadOnlyProperties.forEach( ( key ) => delete product[ key ] );
return product;
}
function getNoticePreviewActions( status: ProductStatus, permalink: string ) {
return status === 'publish' && permalink
? [
{
label: __( 'View in store', 'woocommerce' ),
onClick: () => {
recordEvent( 'product_preview_changes', {
new_product_page: true,
} );
window.open( permalink, '_blank' );
},
},
]
: [];
}
export function useProductHelper() {
const { createProduct, updateProduct, deleteProduct } = useDispatch(
PRODUCTS_STORE_NAME
) as ProductsStoreActions;
const { createNotice } = useDispatch( 'core/notices' );
const [ isDeleting, setIsDeleting ] = useState( false );
const [ updating, setUpdating ] = useState( {
draft: false,
publish: false,
} );
/**
* Create product with status.
*
* @param {Product} product the product to be created.
* @param {string} status the product status.
* @param {boolean} skipNotice if the notice should be skipped (default: false).
* @param {boolean} skipRedirect if the user should skip the redirection to the new product page (default: false).
* @return {Promise<Product>} Returns a promise with the created product.
*/
const createProductWithStatus = useCallback(
async (
product: Omit< Product, ReadOnlyProperties >,
status: ProductStatus,
skipNotice = false,
skipRedirect = false
) => {
setUpdating( {
...updating,
[ status ]: true,
} );
createProduct( {
...product,
status,
} ).then(
( newProduct ) => {
if ( ! skipRedirect ) {
navigateTo( {
url:
'admin.php?page=wc-admin&path=/product/' +
newProduct.id,
} );
}
if ( ! skipNotice ) {
createNotice(
'success',
newProduct.status === 'publish'
? __(
'🎉 Product published. View in store',
'woocommerce'
)
: __(
'🎉 Product successfully created.',
'woocommerce'
),
{
actions: getNoticePreviewActions(
newProduct.status,
newProduct.permalink
),
}
);
}
setUpdating( {
...updating,
[ status ]: false,
} );
},
() => {
createNotice(
'error',
status === 'publish'
? __( 'Failed to publish product.', 'woocommerce' )
: __( 'Failed to create product.', 'woocommerce' )
);
setUpdating( {
...updating,
[ status ]: false,
} );
}
);
},
[ updating ]
);
/**
* Update product with status.
*
* @param {Product} product the product to be updated (should contain product id).
* @param {string} status the product status.
* @param {boolean} skipNotice if the notice should be skipped (default: false).
* @return {Promise<Product>} Returns a promise with the updated product.
*/
const updateProductWithStatus = useCallback(
async (
product: Product,
status: ProductStatus,
skipNotice = false
): Promise< Product > => {
setUpdating( {
...updating,
[ status ]: true,
} );
return updateProduct( product.id, {
...product,
status,
} ).then(
( updatedProduct ) => {
if ( ! skipNotice ) {
createNotice(
'success',
product.status === 'draft' &&
updatedProduct.status === 'publish'
? __(
'🎉 Product published. View in store.',
'woocommerce'
)
: __(
'🎉 Product successfully updated.',
'woocommerce'
),
{
actions: getNoticePreviewActions(
updatedProduct.status,
updatedProduct.permalink
),
}
);
}
setUpdating( {
...updating,
[ status ]: false,
} );
return updatedProduct;
},
( error ) => {
createNotice(
'error',
__( 'Failed to update product.', 'woocommerce' )
);
setUpdating( {
...updating,
[ status ]: false,
} );
return error;
}
);
},
[ updating ]
);
/**
* Creates a copy of the given product with the given status.
*
* @param {Product} product the product to be copied.
* @param {string} status the product status.
* @return {Promise<Product>} promise with the newly created and copied product.
*/
const copyProductWithStatus = useCallback(
async ( product: Product, status: ProductStatus = 'draft' ) => {
return createProductWithStatus(
removeReadonlyProperties( {
...product,
name: ( product.name || 'AUTO-DRAFT' ) + ' - Copy',
} ),
status
);
},
[]
);
/**
* Deletes a product by given id and redirects to the product list page.
*
* @param {number} id the product id to be deleted.
* @param {string} redirectUrl the redirection url, defaults to product list ('edit.php?post_type=product').
* @return {Promise<Product>} promise with the deleted product.
*/
const deleteProductAndRedirect = useCallback(
( id: number, redirectUrl = 'edit.php?post_type=product' ) => {
setIsDeleting( true );
return deleteProduct( id ).then( () => {
createNotice(
'success',
__(
'🎉 Successfully moved product to Trash.',
'woocommerce'
)
);
navigateTo( {
url: redirectUrl,
} );
setIsDeleting( false );
} );
},
[]
);
return {
createProductWithStatus,
updateProductWithStatus,
copyProductWithStatus,
deleteProductAndRedirect,
isUpdatingDraft: updating.draft,
isUpdatingPublished: updating.publish,
isDeleting,
};
}

View File

@ -41,7 +41,7 @@ const CardLayout: React.FC< CardProps > = ( { items } ) => {
method: 'manually',
} );
recordCompletionTime();
window.location = getAdminLink(
window.location.href = getAdminLink(
'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true'
);
return false;

View File

@ -36,7 +36,7 @@ const Footer: React.FC = () => {
method: 'import',
} );
recordCompletionTime();
window.location = getAdminLink(
window.location.href = getAdminLink(
'edit.php?post_type=product&page=product_importer&wc_onboarding_active_task=products'
);
return false;

View File

@ -48,7 +48,7 @@ const Stack: React.FC< StackProps > = ( {
method: 'manually',
} );
recordCompletionTime();
window.location = getAdminLink(
window.location.href = getAdminLink(
'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true'
);
return false;

View File

@ -18,7 +18,7 @@ export const useCreateProductByType = () => {
const createProductByType = async ( type: ProductTypeKey ) => {
if ( type === 'subscription' ) {
window.location = getAdminLink(
window.location.href = getAdminLink(
'post-new.php?post_type=product&subscription_pointers=true'
);
return;
@ -39,7 +39,7 @@ export const useCreateProductByType = () => {
const link = getAdminLink(
`post.php?post=${ data.id }&action=edit&wc_onboarding_active_task=products&tutorial=true`
);
window.location = link;
window.location.href = link;
} else {
throw new Error( 'Unexpected empty data response from server' );
}

View File

@ -88,7 +88,7 @@ export default function ProductTemplateModal( { onClose } ) {
product_type: selectedTemplate,
} );
if ( selectedTemplate === 'subscription' ) {
window.location = getAdminLink(
window.location.href = getAdminLink(
'post-new.php?post_type=product&subscription_pointers=true'
);
return;

View File

@ -1,6 +1,14 @@
declare module '@woocommerce/e2e-utils';
declare module '@woocommerce/e2e-environment';
declare module '@woocommerce/settings';
declare module '@woocommerce/settings' {
export declare function getAdminLink( path: string ): string;
export declare function getSetting< T >(
name: string,
fallback?: unknown,
filter = ( val: unknown, fb: unknown ) =>
typeof val !== 'undefined' ? val : fb
): T;
}
declare module '@wordpress/components/build/ui' {
// Typescript seems unable to resolve this correctly by default, so we need to re-export it in our type defs.
export * from '@wordpress/components/build-types/ui';

View File

@ -82,7 +82,7 @@ const onboardingHomepageNotice = () => {
'tasklist_appearance_continue_setup',
{}
);
window.location = getAdminLink(
window.location.href = getAdminLink(
'admin.php?page=wc-admin&task=appearance'
);
},

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product form buttons to new product page and also a product edit page.