Add product link field (#34313)
* Add initial product field and modal for editing product link * Create a product link modal for editing product link * Only show permalink when product is published * Add changelogs * Add changelog for data package * Change save button to primary * Fix merge conflcts * Add getPermalinkParts selector to products store * Made use of getPermalinkParts to support draft products
This commit is contained in:
parent
c27565f413
commit
78c28ae9f3
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: minor
|
||||||
|
Type: add
|
||||||
|
|
||||||
|
Update resetForm arguments, adding changed fields, touched fields and errors.
|
|
@ -31,7 +31,12 @@ export type FormContext< Values extends Record< string, any > > = {
|
||||||
help: string | null | undefined;
|
help: string | null | undefined;
|
||||||
};
|
};
|
||||||
isValidForm: boolean;
|
isValidForm: boolean;
|
||||||
resetForm: ( initialValues: Values ) => void;
|
resetForm: (
|
||||||
|
initialValues: Values,
|
||||||
|
changedFields?: { [ P in keyof Values ]?: boolean | undefined },
|
||||||
|
touchedFields?: { [ P in keyof Values ]?: boolean | undefined },
|
||||||
|
errors?: { [ P in keyof Values ]?: string }
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
|
@ -110,11 +110,16 @@ function FormComponent< Values extends Record< string, any > >(
|
||||||
validate( values );
|
validate( values );
|
||||||
}, [] );
|
}, [] );
|
||||||
|
|
||||||
const resetForm = ( newInitialValues: Values ) => {
|
const resetForm = (
|
||||||
|
newInitialValues: Values,
|
||||||
|
newChangedFields = {},
|
||||||
|
newTouchedFields = {},
|
||||||
|
newErrors = {}
|
||||||
|
) => {
|
||||||
setValues( newInitialValues || {} );
|
setValues( newInitialValues || {} );
|
||||||
setChangedFields( {} );
|
setChangedFields( newChangedFields );
|
||||||
setTouched( {} );
|
setTouched( newTouchedFields );
|
||||||
setErrors( {} );
|
setErrors( newErrors );
|
||||||
};
|
};
|
||||||
|
|
||||||
useImperativeHandle( ref, () => ( {
|
useImperativeHandle( ref, () => ( {
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: patch
|
||||||
|
Type: tweak
|
||||||
|
|
||||||
|
Update types for update and create product.
|
|
@ -127,7 +127,7 @@ export function getProductsTotalCountError(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function* createProduct(
|
export function* createProduct(
|
||||||
data: Omit< Product, ReadOnlyProperties >
|
data: Partial< Omit< Product, ReadOnlyProperties > >
|
||||||
): Generator< unknown, Product, Product > {
|
): Generator< unknown, Product, Product > {
|
||||||
yield createProductStart();
|
yield createProductStart();
|
||||||
try {
|
try {
|
||||||
|
@ -147,7 +147,7 @@ export function* createProduct(
|
||||||
|
|
||||||
export function* updateProduct(
|
export function* updateProduct(
|
||||||
id: number,
|
id: number,
|
||||||
data: Omit< Product, ReadOnlyProperties >
|
data: Partial< Omit< Product, ReadOnlyProperties > >
|
||||||
): Generator< unknown, Product, Product > {
|
): Generator< unknown, Product, Product > {
|
||||||
yield updateProductStart( id );
|
yield updateProductStart( id );
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export const STORE_NAME = 'wc/admin/products';
|
export const STORE_NAME = 'wc/admin/products';
|
||||||
|
|
||||||
export const WC_PRODUCT_NAMESPACE = '/wc/v3/products';
|
export const WC_PRODUCT_NAMESPACE = '/wc/v3/products';
|
||||||
|
export const PERMALINK_PRODUCT_REGEX = /%(?:postname|pagename)%/;
|
||||||
|
|
|
@ -19,6 +19,7 @@ registerStore< State >( STORE_NAME, {
|
||||||
reducer: reducer as Reducer< ProductState >,
|
reducer: reducer as Reducer< ProductState >,
|
||||||
actions,
|
actions,
|
||||||
controls,
|
controls,
|
||||||
|
// @ts-expect-error as the registerStore type is not allowing the createRegistrySelector selector.
|
||||||
selectors,
|
selectors,
|
||||||
resolvers,
|
resolvers,
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import createSelector from 'rememo';
|
import createSelector from 'rememo';
|
||||||
|
import { createRegistrySelector } from '@wordpress/data';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
|
@ -14,6 +15,7 @@ import { WPDataSelector, WPDataSelectors } from '../types';
|
||||||
import { ProductState } from './reducer';
|
import { ProductState } from './reducer';
|
||||||
import { PartialProduct, ProductQuery } from './types';
|
import { PartialProduct, ProductQuery } from './types';
|
||||||
import { ActionDispatchers } from './actions';
|
import { ActionDispatchers } from './actions';
|
||||||
|
import { PERMALINK_PRODUCT_REGEX } from './constants';
|
||||||
|
|
||||||
export const getProduct = (
|
export const getProduct = (
|
||||||
state: ProductState,
|
state: ProductState,
|
||||||
|
@ -120,6 +122,39 @@ export const isPending = (
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPermalinkParts = createRegistrySelector(
|
||||||
|
( select ) => ( state: ProductState, productId: number ) => {
|
||||||
|
const product = select( 'core' ).getEntityRecord(
|
||||||
|
'postType',
|
||||||
|
'product',
|
||||||
|
productId,
|
||||||
|
// @ts-expect-error query object is not part of the @wordpress/core-data types yet.
|
||||||
|
{
|
||||||
|
_fields: [
|
||||||
|
'id',
|
||||||
|
'permalink_template',
|
||||||
|
'slug',
|
||||||
|
'generated_slug',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if ( product && product.permalink_template ) {
|
||||||
|
const postName = product.slug || product.generated_slug;
|
||||||
|
|
||||||
|
const [ prefix, suffix ] = product.permalink_template.split(
|
||||||
|
PERMALINK_PRODUCT_REGEX
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
postName,
|
||||||
|
suffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type ProductsSelectors = {
|
export type ProductsSelectors = {
|
||||||
getCreateProductError: WPDataSelector< typeof getCreateProductError >;
|
getCreateProductError: WPDataSelector< typeof getCreateProductError >;
|
||||||
getProduct: WPDataSelector< typeof getProduct >;
|
getProduct: WPDataSelector< typeof getProduct >;
|
||||||
|
@ -127,4 +162,7 @@ export type ProductsSelectors = {
|
||||||
getProductsTotalCount: WPDataSelector< typeof getProductsTotalCount >;
|
getProductsTotalCount: WPDataSelector< typeof getProductsTotalCount >;
|
||||||
getProductsError: WPDataSelector< typeof getProductsError >;
|
getProductsError: WPDataSelector< typeof getProductsError >;
|
||||||
isPending: WPDataSelector< typeof isPending >;
|
isPending: WPDataSelector< typeof isPending >;
|
||||||
|
getPermalinkParts: (
|
||||||
|
productId: number
|
||||||
|
) => { prefix: string; postName: string; suffix: string } | null;
|
||||||
} & WPDataSelectors;
|
} & WPDataSelectors;
|
||||||
|
|
|
@ -12,7 +12,6 @@ import { Product } from '@woocommerce/data';
|
||||||
import { ProductFormLayout } from './layout/product-form-layout';
|
import { ProductFormLayout } from './layout/product-form-layout';
|
||||||
import { ProductFormActions } from './product-form-actions';
|
import { ProductFormActions } from './product-form-actions';
|
||||||
import { ProductDetailsSection } from './sections/product-details-section';
|
import { ProductDetailsSection } from './sections/product-details-section';
|
||||||
import { ProductImagesSection } from './sections/product-images-section';
|
|
||||||
import './product-page.scss';
|
import './product-page.scss';
|
||||||
|
|
||||||
const AddProductPage: React.FC = () => {
|
const AddProductPage: React.FC = () => {
|
||||||
|
|
|
@ -28,14 +28,32 @@ const EditProductPage: React.FC = () => {
|
||||||
const formRef = useRef< FormRef< Partial< Product > > >( null );
|
const formRef = useRef< FormRef< Partial< Product > > >( null );
|
||||||
const { product, isLoading, isPendingAction } = useSelect(
|
const { product, isLoading, isPendingAction } = useSelect(
|
||||||
( select: WCDataSelector ) => {
|
( select: WCDataSelector ) => {
|
||||||
const { getProduct, hasFinishedResolution, isPending } =
|
const {
|
||||||
select( PRODUCTS_STORE_NAME );
|
getProduct,
|
||||||
|
hasFinishedResolution,
|
||||||
|
isPending,
|
||||||
|
getPermalinkParts,
|
||||||
|
} = select( PRODUCTS_STORE_NAME );
|
||||||
if ( productId ) {
|
if ( productId ) {
|
||||||
|
const retrievedProduct = getProduct(
|
||||||
|
parseInt( productId, 10 ),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const permalinkParts = getPermalinkParts(
|
||||||
|
parseInt( productId, 10 )
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
product: getProduct( parseInt( productId, 10 ), undefined ),
|
product:
|
||||||
isLoading: ! hasFinishedResolution( 'getProduct', [
|
permalinkParts && retrievedProduct
|
||||||
parseInt( productId, 10 ),
|
? retrievedProduct
|
||||||
] ),
|
: undefined,
|
||||||
|
isLoading:
|
||||||
|
! hasFinishedResolution( 'getProduct', [
|
||||||
|
parseInt( productId, 10 ),
|
||||||
|
] ) ||
|
||||||
|
! hasFinishedResolution( 'getPermalinkParts', [
|
||||||
|
parseInt( productId, 10 ),
|
||||||
|
] ),
|
||||||
isPendingAction:
|
isPendingAction:
|
||||||
isPending( 'createProduct' ) ||
|
isPending( 'createProduct' ) ||
|
||||||
isPending(
|
isPending(
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: $gap-large;
|
padding: $gap-large;
|
||||||
gap: $gap-smaller;
|
|
||||||
|
|
||||||
background: $white;
|
background: $white;
|
||||||
border: 1px solid $gray-400;
|
border: 1px solid $gray-400;
|
||||||
|
@ -18,4 +17,8 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-field-layout:not(:first-child) {
|
||||||
|
margin-top: $gap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
|
||||||
</div>
|
</div>
|
||||||
<div className="product-category-layout__fields">
|
<div className="product-category-layout__fields">
|
||||||
{ Children.map( children, ( child ) => {
|
{ Children.map( children, ( child ) => {
|
||||||
if ( isValidElement( child ) && child.props.name ) {
|
if ( isValidElement( child ) && child.props.onChange ) {
|
||||||
return (
|
return (
|
||||||
<ProductFieldLayout
|
<ProductFieldLayout
|
||||||
fieldName={ child.props.name }
|
fieldName={ child.props.name }
|
||||||
|
|
|
@ -26,7 +26,9 @@ jest.mock( '../product-field-layout', () => {
|
||||||
};
|
};
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const SampleInputField: React.FC< { name: string } > = ( { name } ) => {
|
const SampleInputField: React.FC< { name: string; onChange: () => void } > = ( {
|
||||||
|
name,
|
||||||
|
} ) => {
|
||||||
return <div>smaple-input-field-{ name }</div>;
|
return <div>smaple-input-field-{ name }</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -46,14 +48,14 @@ describe( 'ProductSectionLayout', () => {
|
||||||
expect( queryByText( 'This is a description' ) ).toBeInTheDocument();
|
expect( queryByText( 'This is a description' ) ).toBeInTheDocument();
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should wrap children in ProductFieldLayout if prop contains name', () => {
|
it( 'should wrap children in ProductFieldLayout if prop contains onChange', () => {
|
||||||
const { queryByText, queryAllByText } = render(
|
const { queryByText, queryAllByText } = render(
|
||||||
<ProductSectionLayout
|
<ProductSectionLayout
|
||||||
title="Title"
|
title="Title"
|
||||||
description="This is a description"
|
description="This is a description"
|
||||||
>
|
>
|
||||||
<SampleInputField name="name" />
|
<SampleInputField name="name" onChange={ () => {} } />
|
||||||
<SampleInputField name="description" />
|
<SampleInputField name="description" onChange={ () => {} } />
|
||||||
</ProductSectionLayout>
|
</ProductSectionLayout>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -66,7 +68,7 @@ describe( 'ProductSectionLayout', () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should not wrap children in ProductFieldLayout if prop does not contain name', () => {
|
it( 'should not wrap children in ProductFieldLayout if prop does not contain onChange', () => {
|
||||||
const { queryByText, queryAllByText } = render(
|
const { queryByText, queryAllByText } = render(
|
||||||
<ProductSectionLayout
|
<ProductSectionLayout
|
||||||
title="Title"
|
title="Title"
|
||||||
|
|
|
@ -50,7 +50,11 @@ export const ProductFormActions: React.FC = () => {
|
||||||
if ( ! values.id ) {
|
if ( ! values.id ) {
|
||||||
createProductWithStatus( values, 'draft' );
|
createProductWithStatus( values, 'draft' );
|
||||||
} else {
|
} else {
|
||||||
const product = await updateProductWithStatus( values, 'draft' );
|
const product = await updateProductWithStatus(
|
||||||
|
values.id,
|
||||||
|
values,
|
||||||
|
'draft'
|
||||||
|
);
|
||||||
if ( product && product.id ) {
|
if ( product && product.id ) {
|
||||||
resetForm( product );
|
resetForm( product );
|
||||||
}
|
}
|
||||||
|
@ -65,7 +69,11 @@ export const ProductFormActions: React.FC = () => {
|
||||||
if ( ! values.id ) {
|
if ( ! values.id ) {
|
||||||
createProductWithStatus( values, 'publish' );
|
createProductWithStatus( values, 'publish' );
|
||||||
} else {
|
} else {
|
||||||
const product = await updateProductWithStatus( values, 'publish' );
|
const product = await updateProductWithStatus(
|
||||||
|
values.id,
|
||||||
|
values,
|
||||||
|
'publish'
|
||||||
|
);
|
||||||
if ( product && product.id ) {
|
if ( product && product.id ) {
|
||||||
resetForm( product );
|
resetForm( product );
|
||||||
}
|
}
|
||||||
|
@ -78,7 +86,7 @@ export const ProductFormActions: React.FC = () => {
|
||||||
...getProductDataForTracks(),
|
...getProductDataForTracks(),
|
||||||
} );
|
} );
|
||||||
if ( values.id ) {
|
if ( values.id ) {
|
||||||
await updateProductWithStatus( values, 'publish' );
|
await updateProductWithStatus( values.id, values, 'publish' );
|
||||||
} else {
|
} else {
|
||||||
await createProductWithStatus( values, 'publish', false, true );
|
await createProductWithStatus( values, 'publish', false, true );
|
||||||
}
|
}
|
||||||
|
@ -91,7 +99,11 @@ export const ProductFormActions: React.FC = () => {
|
||||||
...getProductDataForTracks(),
|
...getProductDataForTracks(),
|
||||||
} );
|
} );
|
||||||
if ( values.id ) {
|
if ( values.id ) {
|
||||||
await updateProductWithStatus( values, values.status || 'draft' );
|
await updateProductWithStatus(
|
||||||
|
values.id,
|
||||||
|
values,
|
||||||
|
values.status || 'draft'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await copyProductWithStatus( values );
|
await copyProductWithStatus( values );
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
.product-details-section {
|
||||||
|
&__product-link {
|
||||||
|
color: $gray-700;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
> a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-button.is-link {
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: $gap-smaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,47 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { CheckboxControl, TextControl } from '@wordpress/components';
|
import { CheckboxControl, Button, TextControl } from '@wordpress/components';
|
||||||
|
import { useSelect } from '@wordpress/data';
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { useState } from '@wordpress/element';
|
||||||
|
import { cleanForSlug } from '@wordpress/url';
|
||||||
import { EnrichedLabel, useFormContext } from '@woocommerce/components';
|
import { EnrichedLabel, useFormContext } from '@woocommerce/components';
|
||||||
import { Product } from '@woocommerce/data';
|
import {
|
||||||
|
Product,
|
||||||
|
PRODUCTS_STORE_NAME,
|
||||||
|
WCDataSelector,
|
||||||
|
} from '@woocommerce/data';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { recordEvent } from '@woocommerce/tracks';
|
import { recordEvent } from '@woocommerce/tracks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
|
import './product-details-section.scss';
|
||||||
import { ProductSectionLayout } from '../layout/product-section-layout';
|
import { ProductSectionLayout } from '../layout/product-section-layout';
|
||||||
|
import { EditProductLinkModal } from '../shared/edit-product-link-modal';
|
||||||
|
|
||||||
const PRODUCT_DETAILS_SLUG = 'product-details';
|
const PRODUCT_DETAILS_SLUG = 'product-details';
|
||||||
|
|
||||||
export const ProductDetailsSection: React.FC = () => {
|
export const ProductDetailsSection: React.FC = () => {
|
||||||
const { getInputProps } = useFormContext< Product >();
|
const { getInputProps, values } = useFormContext< Product >();
|
||||||
|
const [ showProductLinkEditModal, setShowProductLinkEditModal ] =
|
||||||
|
useState( false );
|
||||||
|
const { permalinkPrefix, permalinkSuffix } = useSelect(
|
||||||
|
( select: WCDataSelector ) => {
|
||||||
|
const { getPermalinkParts } = select( PRODUCTS_STORE_NAME );
|
||||||
|
if ( values.id ) {
|
||||||
|
const parts = getPermalinkParts( values.id );
|
||||||
|
return {
|
||||||
|
permalinkPrefix: parts?.prefix,
|
||||||
|
permalinkSuffix: parts?.suffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const getCheckboxProps = ( item: string ) => {
|
const getCheckboxProps = ( item: string ) => {
|
||||||
const { checked, className, onChange, onBlur } =
|
const { checked, className, onChange, onBlur } =
|
||||||
getInputProps< boolean >( item );
|
getInputProps< boolean >( item );
|
||||||
|
@ -67,6 +92,26 @@ export const ProductDetailsSection: React.FC = () => {
|
||||||
placeholder={ __( 'e.g. 12 oz Coffee Mug', 'woocommerce' ) }
|
placeholder={ __( 'e.g. 12 oz Coffee Mug', 'woocommerce' ) }
|
||||||
{ ...getTextControlProps( 'name' ) }
|
{ ...getTextControlProps( 'name' ) }
|
||||||
/>
|
/>
|
||||||
|
{ values.id && permalinkPrefix && (
|
||||||
|
<div className="product-details-section__product-link">
|
||||||
|
{ __( 'Product link', 'woocommerce' ) }:
|
||||||
|
<a
|
||||||
|
href={ values.permalink }
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{ permalinkPrefix }
|
||||||
|
{ values.slug || cleanForSlug( values.name ) }
|
||||||
|
{ permalinkSuffix }
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={ () => setShowProductLinkEditModal( true ) }
|
||||||
|
>
|
||||||
|
{ __( 'Edit', 'woocommerce' ) }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
<CheckboxControl
|
<CheckboxControl
|
||||||
label={
|
label={
|
||||||
<EnrichedLabel
|
<EnrichedLabel
|
||||||
|
@ -85,6 +130,15 @@ export const ProductDetailsSection: React.FC = () => {
|
||||||
}
|
}
|
||||||
{ ...getCheckboxProps( 'featured' ) }
|
{ ...getCheckboxProps( 'featured' ) }
|
||||||
/>
|
/>
|
||||||
|
{ showProductLinkEditModal && (
|
||||||
|
<EditProductLinkModal
|
||||||
|
permalinkPrefix={ permalinkPrefix || '' }
|
||||||
|
permalinkSuffix={ permalinkSuffix || '' }
|
||||||
|
product={ values }
|
||||||
|
onCancel={ () => setShowProductLinkEditModal( false ) }
|
||||||
|
onSaved={ () => setShowProductLinkEditModal( false ) }
|
||||||
|
/>
|
||||||
|
) }
|
||||||
</ProductSectionLayout>
|
</ProductSectionLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
.woocommerce-product-link-edit-modal {
|
||||||
|
min-width: 650px;
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
margin-top: $gap-larger;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* External dependencies
|
||||||
|
*/
|
||||||
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { Button, Modal, TextControl } from '@wordpress/components';
|
||||||
|
import { useState } from '@wordpress/element';
|
||||||
|
import { useDispatch } from '@wordpress/data';
|
||||||
|
import { cleanForSlug } from '@wordpress/url';
|
||||||
|
import { Product } from '@woocommerce/data';
|
||||||
|
import { Text } from '@woocommerce/experimental';
|
||||||
|
import { useFormContext } from '@woocommerce/components';
|
||||||
|
import { recordEvent } from '@woocommerce/tracks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dependencies
|
||||||
|
*/
|
||||||
|
import './edit-product-link-modal.scss';
|
||||||
|
import { useProductHelper } from '../../use-product-helper';
|
||||||
|
|
||||||
|
type EditProductLinkModalProps = {
|
||||||
|
product: Product;
|
||||||
|
permalinkPrefix: string;
|
||||||
|
permalinkSuffix: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditProductLinkModal: React.FC< EditProductLinkModalProps > = ( {
|
||||||
|
product,
|
||||||
|
permalinkPrefix,
|
||||||
|
permalinkSuffix,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
} ) => {
|
||||||
|
const { createNotice } = useDispatch( 'core/notices' );
|
||||||
|
const { updateProductWithStatus, isUpdatingDraft, isUpdatingPublished } =
|
||||||
|
useProductHelper();
|
||||||
|
const [ slug, setSlug ] = useState(
|
||||||
|
product.slug || cleanForSlug( product.name )
|
||||||
|
);
|
||||||
|
const { resetForm, changedFields, touched, errors } =
|
||||||
|
useFormContext< Product >();
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
recordEvent( 'product_update_slug', {
|
||||||
|
new_product_page: true,
|
||||||
|
product_id: product.id,
|
||||||
|
product_type: product.type,
|
||||||
|
} );
|
||||||
|
const updatedProduct = await updateProductWithStatus(
|
||||||
|
product.id,
|
||||||
|
{
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
product.status,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if ( updatedProduct && updatedProduct.id ) {
|
||||||
|
// only reset the updated slug and permalink fields.
|
||||||
|
resetForm(
|
||||||
|
{
|
||||||
|
...product,
|
||||||
|
slug: updatedProduct.slug,
|
||||||
|
permalink: updatedProduct.permalink,
|
||||||
|
},
|
||||||
|
changedFields,
|
||||||
|
touched,
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
createNotice(
|
||||||
|
updatedProduct.slug === cleanForSlug( slug )
|
||||||
|
? 'success'
|
||||||
|
: 'info',
|
||||||
|
updatedProduct.slug === cleanForSlug( slug )
|
||||||
|
? __( 'Product link successfully updated.', 'woocommerce' )
|
||||||
|
: __(
|
||||||
|
'Product link already existed, updated to ',
|
||||||
|
'woocommerce'
|
||||||
|
) + updatedProduct.permalink
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createNotice(
|
||||||
|
'error',
|
||||||
|
__( 'Failed to update product link.', 'woocommerce' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
};
|
||||||
|
|
||||||
|
const newProductLinkLabel =
|
||||||
|
permalinkPrefix + cleanForSlug( slug ) + permalinkSuffix;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={ __( 'Edit product link', 'woocommerce' ) }
|
||||||
|
onRequestClose={ () => onCancel() }
|
||||||
|
className="woocommerce-product-link-edit-modal"
|
||||||
|
>
|
||||||
|
<div className="woocommerce-product-link-edit-modal__wrapper">
|
||||||
|
<TextControl
|
||||||
|
label={ newProductLinkLabel }
|
||||||
|
name="slug"
|
||||||
|
value={ slug }
|
||||||
|
onChange={ setSlug }
|
||||||
|
/>
|
||||||
|
<Text size={ 12 }>
|
||||||
|
{ __(
|
||||||
|
"Use simple, descriptive words and numbers. We'll replace spaces with hyphens (-).",
|
||||||
|
'woocommerce'
|
||||||
|
) }
|
||||||
|
</Text>
|
||||||
|
<div className="woocommerce-product-link-edit-modal__buttons">
|
||||||
|
<Button isSecondary onClick={ () => onCancel() }>
|
||||||
|
{ __( 'Cancel', 'woocommerce' ) }
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isPrimary
|
||||||
|
isBusy={ isUpdatingDraft || isUpdatingPublished }
|
||||||
|
disabled={
|
||||||
|
isUpdatingDraft ||
|
||||||
|
isUpdatingPublished ||
|
||||||
|
slug === product.slug
|
||||||
|
}
|
||||||
|
onClick={ () => {
|
||||||
|
onSave();
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ __( 'Save', 'woocommerce' ) }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './edit-product-link-modal';
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* External dependencies
|
||||||
|
*/
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { Product } from '@woocommerce/data';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dependencies
|
||||||
|
*/
|
||||||
|
import { EditProductLinkModal } from '../';
|
||||||
|
|
||||||
|
describe( 'EditProductLinkModal', () => {
|
||||||
|
beforeEach( () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should show a field with the permalink as label', () => {
|
||||||
|
const { queryByText } = render(
|
||||||
|
<EditProductLinkModal
|
||||||
|
permalinkPrefix={ 'wootesting.com/product/' }
|
||||||
|
permalinkSuffix={ '' }
|
||||||
|
product={
|
||||||
|
{
|
||||||
|
slug: 'test',
|
||||||
|
permalink: 'wootesting.com/product/test',
|
||||||
|
} as Product
|
||||||
|
}
|
||||||
|
onCancel={ () => {} }
|
||||||
|
onSaved={ () => {} }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
queryByText( 'wootesting.com/product/test' )
|
||||||
|
).toBeInTheDocument();
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should update the permalink label as the slug is being updated', () => {
|
||||||
|
const { queryByText, getByLabelText } = render(
|
||||||
|
<EditProductLinkModal
|
||||||
|
permalinkPrefix={ 'wootesting.com/product/' }
|
||||||
|
permalinkSuffix={ '' }
|
||||||
|
product={
|
||||||
|
{
|
||||||
|
slug: 'test',
|
||||||
|
permalink: 'wootesting.com/product/test',
|
||||||
|
} as Product
|
||||||
|
}
|
||||||
|
onCancel={ () => {} }
|
||||||
|
onSaved={ () => {} }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
userEvent.type(
|
||||||
|
getByLabelText( 'wootesting.com/product/test' ),
|
||||||
|
'{esc}{space}update',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
queryByText( 'wootesting.com/product/test-update' )
|
||||||
|
).toBeInTheDocument();
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should only update the end of the permalink incase the slug matches other parts of the url', () => {
|
||||||
|
const { queryByText, getByLabelText } = render(
|
||||||
|
<EditProductLinkModal
|
||||||
|
permalinkPrefix={ 'wootesting.com/product/' }
|
||||||
|
permalinkSuffix={ '' }
|
||||||
|
product={
|
||||||
|
{
|
||||||
|
slug: 'product',
|
||||||
|
permalink: 'wootesting.com/product/product',
|
||||||
|
} as Product
|
||||||
|
}
|
||||||
|
onCancel={ () => {} }
|
||||||
|
onSaved={ () => {} }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
userEvent.type(
|
||||||
|
getByLabelText( 'wootesting.com/product/product' ),
|
||||||
|
'{esc}{space}update',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
queryByText( 'wootesting.com/product/product-update' )
|
||||||
|
).toBeInTheDocument();
|
||||||
|
} );
|
||||||
|
} );
|
|
@ -177,6 +177,7 @@ describe( 'ProductFormActions', () => {
|
||||||
);
|
);
|
||||||
queryByText( 'Save draft' )?.click();
|
queryByText( 'Save draft' )?.click();
|
||||||
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
||||||
|
product.id,
|
||||||
{ ...product, name: 'Name Update' },
|
{ ...product, name: 'Name Update' },
|
||||||
'draft'
|
'draft'
|
||||||
);
|
);
|
||||||
|
@ -219,6 +220,7 @@ describe( 'ProductFormActions', () => {
|
||||||
manage_stock: true,
|
manage_stock: true,
|
||||||
} );
|
} );
|
||||||
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
||||||
|
product.id,
|
||||||
product,
|
product,
|
||||||
'publish'
|
'publish'
|
||||||
);
|
);
|
||||||
|
@ -367,6 +369,7 @@ describe( 'ProductFormActions', () => {
|
||||||
);
|
);
|
||||||
updateProductWithStatus.mockReturnValue( Promise.resolve() );
|
updateProductWithStatus.mockReturnValue( Promise.resolve() );
|
||||||
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
||||||
|
product.id,
|
||||||
product,
|
product,
|
||||||
'publish'
|
'publish'
|
||||||
);
|
);
|
||||||
|
@ -404,6 +407,7 @@ describe( 'ProductFormActions', () => {
|
||||||
} );
|
} );
|
||||||
updateProductWithStatus.mockReturnValue( Promise.resolve() );
|
updateProductWithStatus.mockReturnValue( Promise.resolve() );
|
||||||
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
expect( updateProductWithStatus ).toHaveBeenCalledWith(
|
||||||
|
product.id,
|
||||||
product,
|
product,
|
||||||
'publish'
|
'publish'
|
||||||
);
|
);
|
||||||
|
|
|
@ -107,12 +107,20 @@ export function useProductHelper() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
createNotice(
|
if ( ! skipNotice ) {
|
||||||
'error',
|
createNotice(
|
||||||
status === 'publish'
|
'error',
|
||||||
? __( 'Failed to publish product.', 'woocommerce' )
|
status === 'publish'
|
||||||
: __( 'Failed to create product.', 'woocommerce' )
|
? __(
|
||||||
);
|
'Failed to publish product.',
|
||||||
|
'woocommerce'
|
||||||
|
)
|
||||||
|
: __(
|
||||||
|
'Failed to create product.',
|
||||||
|
'woocommerce'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
setUpdating( {
|
setUpdating( {
|
||||||
...updating,
|
...updating,
|
||||||
[ status ]: false,
|
[ status ]: false,
|
||||||
|
@ -126,14 +134,16 @@ export function useProductHelper() {
|
||||||
/**
|
/**
|
||||||
* Update product with status.
|
* Update product with status.
|
||||||
*
|
*
|
||||||
* @param {Product} product the product to be updated (should contain product id).
|
* @param {number} productId the product id to be updated.
|
||||||
|
* @param {Product} product the product to be updated.
|
||||||
* @param {string} status the product status.
|
* @param {string} status the product status.
|
||||||
* @param {boolean} skipNotice if the notice should be skipped (default: false).
|
* @param {boolean} skipNotice if the notice should be skipped (default: false).
|
||||||
* @return {Promise<Product>} Returns a promise with the updated product.
|
* @return {Promise<Product>} Returns a promise with the updated product.
|
||||||
*/
|
*/
|
||||||
const updateProductWithStatus = useCallback(
|
const updateProductWithStatus = useCallback(
|
||||||
async (
|
async (
|
||||||
product: Product,
|
productId: number,
|
||||||
|
product: Partial< Product >,
|
||||||
status: ProductStatus,
|
status: ProductStatus,
|
||||||
skipNotice = false
|
skipNotice = false
|
||||||
): Promise< Product > => {
|
): Promise< Product > => {
|
||||||
|
@ -141,7 +151,7 @@ export function useProductHelper() {
|
||||||
...updating,
|
...updating,
|
||||||
[ status ]: true,
|
[ status ]: true,
|
||||||
} );
|
} );
|
||||||
return updateProduct( product.id, {
|
return updateProduct( productId, {
|
||||||
...product,
|
...product,
|
||||||
status,
|
status,
|
||||||
} ).then(
|
} ).then(
|
||||||
|
@ -174,10 +184,12 @@ export function useProductHelper() {
|
||||||
return updatedProduct;
|
return updatedProduct;
|
||||||
},
|
},
|
||||||
( error ) => {
|
( error ) => {
|
||||||
createNotice(
|
if ( ! skipNotice ) {
|
||||||
'error',
|
createNotice(
|
||||||
__( 'Failed to update product.', 'woocommerce' )
|
'error',
|
||||||
);
|
__( 'Failed to update product.', 'woocommerce' )
|
||||||
|
);
|
||||||
|
}
|
||||||
setUpdating( {
|
setUpdating( {
|
||||||
...updating,
|
...updating,
|
||||||
[ status ]: false,
|
[ status ]: false,
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: minor
|
||||||
|
Type: add
|
||||||
|
|
||||||
|
Add product link field to the new edit product form.
|
Loading…
Reference in New Issue