diff --git a/packages/js/components/changelog/add-29_product_link_slug b/packages/js/components/changelog/add-29_product_link_slug new file mode 100644 index 00000000000..a0e4f41a088 --- /dev/null +++ b/packages/js/components/changelog/add-29_product_link_slug @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Update resetForm arguments, adding changed fields, touched fields and errors. diff --git a/packages/js/components/src/form/form-context.ts b/packages/js/components/src/form/form-context.ts index e1d281a358f..ef00f36586b 100644 --- a/packages/js/components/src/form/form-context.ts +++ b/packages/js/components/src/form/form-context.ts @@ -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 diff --git a/packages/js/components/src/form/form.tsx b/packages/js/components/src/form/form.tsx index f22e6ad60bb..f76132841e9 100644 --- a/packages/js/components/src/form/form.tsx +++ b/packages/js/components/src/form/form.tsx @@ -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, () => ( { diff --git a/packages/js/data/changelog/add-29_product_link_slug b/packages/js/data/changelog/add-29_product_link_slug new file mode 100644 index 00000000000..260a9ca1f67 --- /dev/null +++ b/packages/js/data/changelog/add-29_product_link_slug @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Update types for update and create product. diff --git a/packages/js/data/src/products/actions.ts b/packages/js/data/src/products/actions.ts index 399e6da44a9..d340bbcae64 100644 --- a/packages/js/data/src/products/actions.ts +++ b/packages/js/data/src/products/actions.ts @@ -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 { diff --git a/packages/js/data/src/products/constants.ts b/packages/js/data/src/products/constants.ts index a6cccbcaf06..251ff06f535 100644 --- a/packages/js/data/src/products/constants.ts +++ b/packages/js/data/src/products/constants.ts @@ -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)%/; diff --git a/packages/js/data/src/products/index.ts b/packages/js/data/src/products/index.ts index 0d1cd5a38ec..0d3087d3ff5 100644 --- a/packages/js/data/src/products/index.ts +++ b/packages/js/data/src/products/index.ts @@ -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, } ); diff --git a/packages/js/data/src/products/selectors.ts b/packages/js/data/src/products/selectors.ts index 251c69181db..ef1d3cc1bd5 100644 --- a/packages/js/data/src/products/selectors.ts +++ b/packages/js/data/src/products/selectors.ts @@ -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; diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx index 6fdcfe3e908..2ac487d0548 100644 --- a/plugins/woocommerce-admin/client/products/add-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx @@ -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 = () => { diff --git a/plugins/woocommerce-admin/client/products/edit-product-page.tsx b/plugins/woocommerce-admin/client/products/edit-product-page.tsx index 2046cdf4dc5..a1dfab56a17 100644 --- a/plugins/woocommerce-admin/client/products/edit-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/edit-product-page.tsx @@ -28,14 +28,32 @@ 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', [ - parseInt( productId, 10 ), - ] ), + product: + permalinkParts && retrievedProduct + ? retrievedProduct + : undefined, + isLoading: + ! hasFinishedResolution( 'getProduct', [ + parseInt( productId, 10 ), + ] ) || + ! hasFinishedResolution( 'getPermalinkParts', [ + parseInt( productId, 10 ), + ] ), isPendingAction: isPending( 'createProduct' ) || isPending( diff --git a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss index b7ace9dd586..9ff2510049c 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss +++ b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss @@ -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; + } } diff --git a/plugins/woocommerce-admin/client/products/layout/product-section-layout.tsx b/plugins/woocommerce-admin/client/products/layout/product-section-layout.tsx index 05feace6bcf..26fef357c8f 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-section-layout.tsx +++ b/plugins/woocommerce-admin/client/products/layout/product-section-layout.tsx @@ -29,7 +29,7 @@ export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
{ Children.map( children, ( child ) => { - if ( isValidElement( child ) && child.props.name ) { + if ( isValidElement( child ) && child.props.onChange ) { return ( { }; } ); -const SampleInputField: React.FC< { name: string } > = ( { name } ) => { +const SampleInputField: React.FC< { name: string; onChange: () => void } > = ( { + name, +} ) => { return
smaple-input-field-{ name }
; }; @@ -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( - - + {} } /> + {} } /> ); @@ -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( { 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 ); }; diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.scss b/plugins/woocommerce-admin/client/products/sections/product-details-section.scss new file mode 100644 index 00000000000..2eda224f181 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/sections/product-details-section.scss @@ -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; + } + } +} diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx b/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx index be5ac39e09b..b549105a8d9 100644 --- a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx +++ b/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx @@ -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 && ( +
+ { __( 'Product link', 'woocommerce' ) }:  + + { permalinkPrefix } + { values.slug || cleanForSlug( values.name ) } + { permalinkSuffix } + + +
+ ) } { } { ...getCheckboxProps( 'featured' ) } /> + { showProductLinkEditModal && ( + setShowProductLinkEditModal( false ) } + onSaved={ () => setShowProductLinkEditModal( false ) } + /> + ) }
); }; diff --git a/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.scss b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.scss new file mode 100644 index 00000000000..38ed78ef5dc --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.tsx b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.tsx new file mode 100644 index 00000000000..4574ba5ab43 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/edit-product-link-modal.tsx @@ -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 ( + onCancel() } + className="woocommerce-product-link-edit-modal" + > +
+ + + { __( + "Use simple, descriptive words and numbers. We'll replace spaces with hyphens (-).", + 'woocommerce' + ) } + +
+ + +
+
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/index.ts b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/index.ts new file mode 100644 index 00000000000..fdc1873765c --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/index.ts @@ -0,0 +1 @@ +export * from './edit-product-link-modal'; diff --git a/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/test/edit-product-link-modal.test.tsx b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/test/edit-product-link-modal.test.tsx new file mode 100644 index 00000000000..ac78d8da369 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/edit-product-link-modal/test/edit-product-link-modal.test.tsx @@ -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( + {} } + onSaved={ () => {} } + /> + ); + expect( + queryByText( 'wootesting.com/product/test' ) + ).toBeInTheDocument(); + } ); + + it( 'should update the permalink label as the slug is being updated', () => { + const { queryByText, getByLabelText } = render( + {} } + 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( + {} } + onSaved={ () => {} } + /> + ); + userEvent.type( + getByLabelText( 'wootesting.com/product/product' ), + '{esc}{space}update', + {} + ); + expect( + queryByText( 'wootesting.com/product/product-update' ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/test/product-form-actions.spec.tsx b/plugins/woocommerce-admin/client/products/test/product-form-actions.spec.tsx index 159b205b21c..e6f335770c8 100644 --- a/plugins/woocommerce-admin/client/products/test/product-form-actions.spec.tsx +++ b/plugins/woocommerce-admin/client/products/test/product-form-actions.spec.tsx @@ -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' ); diff --git a/plugins/woocommerce-admin/client/products/use-product-helper.ts b/plugins/woocommerce-admin/client/products/use-product-helper.ts index a98aec38224..bd39b2e1a7f 100644 --- a/plugins/woocommerce-admin/client/products/use-product-helper.ts +++ b/plugins/woocommerce-admin/client/products/use-product-helper.ts @@ -107,12 +107,20 @@ export function useProductHelper() { } }, () => { - createNotice( - 'error', - status === 'publish' - ? __( 'Failed to publish product.', 'woocommerce' ) - : __( 'Failed to create product.', 'woocommerce' ) - ); + if ( ! skipNotice ) { + createNotice( + 'error', + status === 'publish' + ? __( + '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} 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 ) => { - createNotice( - 'error', - __( 'Failed to update product.', 'woocommerce' ) - ); + if ( ! skipNotice ) { + createNotice( + 'error', + __( 'Failed to update product.', 'woocommerce' ) + ); + } setUpdating( { ...updating, [ status ]: false, diff --git a/plugins/woocommerce/changelog/add-29_product_link_slug b/plugins/woocommerce/changelog/add-29_product_link_slug new file mode 100644 index 00000000000..a3fd87da242 --- /dev/null +++ b/plugins/woocommerce/changelog/add-29_product_link_slug @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product link field to the new edit product form.