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:
louwie17 2022-08-19 12:05:13 -03:00 committed by GitHub
parent c27565f413
commit 78c28ae9f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 458 additions and 41 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Update resetForm arguments, adding changed fields, touched fields and errors.

View File

@ -31,7 +31,12 @@ export type FormContext< Values extends Record< string, any > > = {
help: string | null | undefined;
};
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

View File

@ -110,11 +110,16 @@ function FormComponent< Values extends Record< string, any > >(
validate( values );
}, [] );
const resetForm = ( newInitialValues: Values ) => {
const resetForm = (
newInitialValues: Values,
newChangedFields = {},
newTouchedFields = {},
newErrors = {}
) => {
setValues( newInitialValues || {} );
setChangedFields( {} );
setTouched( {} );
setErrors( {} );
setChangedFields( newChangedFields );
setTouched( newTouchedFields );
setErrors( newErrors );
};
useImperativeHandle( ref, () => ( {

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Update types for update and create product.

View File

@ -127,7 +127,7 @@ export function getProductsTotalCountError(
}
export function* createProduct(
data: Omit< Product, ReadOnlyProperties >
data: Partial< Omit< Product, ReadOnlyProperties > >
): Generator< unknown, Product, Product > {
yield createProductStart();
try {
@ -147,7 +147,7 @@ export function* createProduct(
export function* updateProduct(
id: number,
data: Omit< Product, ReadOnlyProperties >
data: Partial< Omit< Product, ReadOnlyProperties > >
): Generator< unknown, Product, Product > {
yield updateProductStart( id );
try {

View File

@ -1,3 +1,4 @@
export const STORE_NAME = 'wc/admin/products';
export const WC_PRODUCT_NAMESPACE = '/wc/v3/products';
export const PERMALINK_PRODUCT_REGEX = /%(?:postname|pagename)%/;

View File

@ -19,6 +19,7 @@ registerStore< State >( STORE_NAME, {
reducer: reducer as Reducer< ProductState >,
actions,
controls,
// @ts-expect-error as the registerStore type is not allowing the createRegistrySelector selector.
selectors,
resolvers,
} );

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import createSelector from 'rememo';
import { createRegistrySelector } from '@wordpress/data';
/**
* Internal dependencies
@ -14,6 +15,7 @@ import { WPDataSelector, WPDataSelectors } from '../types';
import { ProductState } from './reducer';
import { PartialProduct, ProductQuery } from './types';
import { ActionDispatchers } from './actions';
import { PERMALINK_PRODUCT_REGEX } from './constants';
export const getProduct = (
state: ProductState,
@ -120,6 +122,39 @@ export const isPending = (
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 = {
getCreateProductError: WPDataSelector< typeof getCreateProductError >;
getProduct: WPDataSelector< typeof getProduct >;
@ -127,4 +162,7 @@ export type ProductsSelectors = {
getProductsTotalCount: WPDataSelector< typeof getProductsTotalCount >;
getProductsError: WPDataSelector< typeof getProductsError >;
isPending: WPDataSelector< typeof isPending >;
getPermalinkParts: (
productId: number
) => { prefix: string; postName: string; suffix: string } | null;
} & WPDataSelectors;

View File

@ -12,7 +12,6 @@ import { Product } from '@woocommerce/data';
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 = () => {

View File

@ -28,12 +28,30 @@ const EditProductPage: React.FC = () => {
const formRef = useRef< FormRef< Partial< Product > > >( null );
const { product, isLoading, isPendingAction } = useSelect(
( select: WCDataSelector ) => {
const { getProduct, hasFinishedResolution, isPending } =
select( PRODUCTS_STORE_NAME );
const {
getProduct,
hasFinishedResolution,
isPending,
getPermalinkParts,
} = select( PRODUCTS_STORE_NAME );
if ( productId ) {
const retrievedProduct = getProduct(
parseInt( productId, 10 ),
undefined
);
const permalinkParts = getPermalinkParts(
parseInt( productId, 10 )
);
return {
product: getProduct( parseInt( productId, 10 ), undefined ),
isLoading: ! hasFinishedResolution( 'getProduct', [
product:
permalinkParts && retrievedProduct
? retrievedProduct
: undefined,
isLoading:
! hasFinishedResolution( 'getProduct', [
parseInt( productId, 10 ),
] ) ||
! hasFinishedResolution( 'getPermalinkParts', [
parseInt( productId, 10 ),
] ),
isPendingAction:

View File

@ -8,7 +8,6 @@
flex-direction: column;
align-items: flex-start;
padding: $gap-large;
gap: $gap-smaller;
background: $white;
border: 1px solid $gray-400;
@ -18,4 +17,8 @@
width: 100%;
}
}
.product-field-layout:not(:first-child) {
margin-top: $gap;
}
}

View File

@ -29,7 +29,7 @@ export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
</div>
<div className="product-category-layout__fields">
{ Children.map( children, ( child ) => {
if ( isValidElement( child ) && child.props.name ) {
if ( isValidElement( child ) && child.props.onChange ) {
return (
<ProductFieldLayout
fieldName={ child.props.name }

View File

@ -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>;
};
@ -46,14 +48,14 @@ describe( 'ProductSectionLayout', () => {
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(
<ProductSectionLayout
title="Title"
description="This is a description"
>
<SampleInputField name="name" />
<SampleInputField name="description" />
<SampleInputField name="name" onChange={ () => {} } />
<SampleInputField name="description" onChange={ () => {} } />
</ProductSectionLayout>
);
@ -66,7 +68,7 @@ describe( 'ProductSectionLayout', () => {
).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(
<ProductSectionLayout
title="Title"

View File

@ -50,7 +50,11 @@ export const ProductFormActions: React.FC = () => {
if ( ! values.id ) {
createProductWithStatus( values, 'draft' );
} else {
const product = await updateProductWithStatus( values, 'draft' );
const product = await updateProductWithStatus(
values.id,
values,
'draft'
);
if ( product && product.id ) {
resetForm( product );
}
@ -65,7 +69,11 @@ export const ProductFormActions: React.FC = () => {
if ( ! values.id ) {
createProductWithStatus( values, 'publish' );
} else {
const product = await updateProductWithStatus( values, 'publish' );
const product = await updateProductWithStatus(
values.id,
values,
'publish'
);
if ( product && product.id ) {
resetForm( product );
}
@ -78,7 +86,7 @@ export const ProductFormActions: React.FC = () => {
...getProductDataForTracks(),
} );
if ( values.id ) {
await updateProductWithStatus( values, 'publish' );
await updateProductWithStatus( values.id, values, 'publish' );
} else {
await createProductWithStatus( values, 'publish', false, true );
}
@ -91,7 +99,11 @@ export const ProductFormActions: React.FC = () => {
...getProductDataForTracks(),
} );
if ( values.id ) {
await updateProductWithStatus( values, values.status || 'draft' );
await updateProductWithStatus(
values.id,
values,
values.status || 'draft'
);
}
await copyProductWithStatus( values );
};

View File

@ -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;
}
}
}

View File

@ -1,22 +1,47 @@
/**
* 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 { useState } from '@wordpress/element';
import { cleanForSlug } from '@wordpress/url';
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 { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './product-details-section.scss';
import { ProductSectionLayout } from '../layout/product-section-layout';
import { EditProductLinkModal } from '../shared/edit-product-link-modal';
const PRODUCT_DETAILS_SLUG = 'product-details';
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 { checked, className, onChange, onBlur } =
getInputProps< boolean >( item );
@ -67,6 +92,26 @@ export const ProductDetailsSection: React.FC = () => {
placeholder={ __( 'e.g. 12 oz Coffee Mug', 'woocommerce' ) }
{ ...getTextControlProps( 'name' ) }
/>
{ values.id && permalinkPrefix && (
<div className="product-details-section__product-link">
{ __( 'Product link', 'woocommerce' ) }:&nbsp;
<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
label={
<EnrichedLabel
@ -85,6 +130,15 @@ export const ProductDetailsSection: React.FC = () => {
}
{ ...getCheckboxProps( 'featured' ) }
/>
{ showProductLinkEditModal && (
<EditProductLinkModal
permalinkPrefix={ permalinkPrefix || '' }
permalinkSuffix={ permalinkSuffix || '' }
product={ values }
onCancel={ () => setShowProductLinkEditModal( false ) }
onSaved={ () => setShowProductLinkEditModal( false ) }
/>
) }
</ProductSectionLayout>
);
};

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './edit-product-link-modal';

View File

@ -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();
} );
} );

View File

@ -177,6 +177,7 @@ describe( 'ProductFormActions', () => {
);
queryByText( 'Save draft' )?.click();
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product.id,
{ ...product, name: 'Name Update' },
'draft'
);
@ -219,6 +220,7 @@ describe( 'ProductFormActions', () => {
manage_stock: true,
} );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product.id,
product,
'publish'
);
@ -367,6 +369,7 @@ describe( 'ProductFormActions', () => {
);
updateProductWithStatus.mockReturnValue( Promise.resolve() );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product.id,
product,
'publish'
);
@ -404,6 +407,7 @@ describe( 'ProductFormActions', () => {
} );
updateProductWithStatus.mockReturnValue( Promise.resolve() );
expect( updateProductWithStatus ).toHaveBeenCalledWith(
product.id,
product,
'publish'
);

View File

@ -107,12 +107,20 @@ export function useProductHelper() {
}
},
() => {
if ( ! skipNotice ) {
createNotice(
'error',
status === 'publish'
? __( 'Failed to publish product.', 'woocommerce' )
: __( 'Failed to create product.', 'woocommerce' )
? __(
'Failed to publish product.',
'woocommerce'
)
: __(
'Failed to create product.',
'woocommerce'
)
);
}
setUpdating( {
...updating,
[ status ]: false,
@ -126,14 +134,16 @@ export function useProductHelper() {
/**
* 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 {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,
productId: number,
product: Partial< Product >,
status: ProductStatus,
skipNotice = false
): Promise< Product > => {
@ -141,7 +151,7 @@ export function useProductHelper() {
...updating,
[ status ]: true,
} );
return updateProduct( product.id, {
return updateProduct( productId, {
...product,
status,
} ).then(
@ -174,10 +184,12 @@ export function useProductHelper() {
return updatedProduct;
},
( error ) => {
if ( ! skipNotice ) {
createNotice(
'error',
__( 'Failed to update product.', 'woocommerce' )
);
}
setUpdating( {
...updating,
[ status ]: false,

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product link field to the new edit product form.