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:
parent
046fb2100e
commit
a498cc8280
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Update types for Form component and allow Form state to be reset.
|
|
@ -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
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add getProduct selector and deleteProduct action to products data store.
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
} >;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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, {
|
||||
type: TYPES.CREATE_PRODUCT_SUCCESS,
|
||||
id: update.id,
|
||||
product: update,
|
||||
} );
|
||||
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 );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -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';
|
||||
};
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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">
|
||||
<ProductFormLayout>
|
||||
<ProductDetailsSection />
|
||||
<ProductImagesSection />
|
||||
</ProductFormLayout>
|
||||
<Form< Partial< Product > > initialValues={ {} } errors={ {} }>
|
||||
<ProductFormLayout>
|
||||
<ProductDetailsSection />
|
||||
|
||||
<ProductFormActions />
|
||||
</ProductFormLayout>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 )
|
||||
);
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -82,7 +82,7 @@ const onboardingHomepageNotice = () => {
|
|||
'tasklist_appearance_continue_setup',
|
||||
{}
|
||||
);
|
||||
window.location = getAdminLink(
|
||||
window.location.href = getAdminLink(
|
||||
'admin.php?page=wc-admin&task=appearance'
|
||||
);
|
||||
},
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add product form buttons to new product page and also a product edit page.
|
Loading…
Reference in New Issue