Add/33 two column product page layout (#34113)
* Add product page layout components * Add a quick sample page to test the product form layout * Add changelog * Add option 2 * Refactor the product field layout structure * Update page component structure of product form * Add simple SlotFill support * Update product page chunk name * Add order to slot fill and move product field layout wrapper into product category layout * Remove unused import * Consolidate the slot fill ordering logic * Rename category component to section component to prevent confusion * Remove edit product page and use the new add product page instead * Add tests Co-authored-by: Fernando Marichal <contacto@fernandomarichal.com>
This commit is contained in:
parent
5f0efcf02f
commit
470fc899e1
|
@ -1,44 +1,12 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isValidElement } from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { cloneElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Ordered header item.
|
||||
*
|
||||
* @param {Node} children - Node children.
|
||||
* @param {number} order - Node order.
|
||||
* @param {Array} props - Fill props.
|
||||
* @return {Node} Node.
|
||||
* Internal dependencies
|
||||
*/
|
||||
const createOrderedChildren = (
|
||||
children: React.ReactNode,
|
||||
order: number,
|
||||
props: Fill.Props
|
||||
) => {
|
||||
if ( typeof children === 'function' ) {
|
||||
return cloneElement( children( props ), { order } );
|
||||
} else if ( isValidElement( children ) ) {
|
||||
return cloneElement( children, { ...props, order } );
|
||||
}
|
||||
throw Error( 'Invalid children type' );
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort fills by order for slot children.
|
||||
*
|
||||
* @param {Array} fills - slot's `Fill`s.
|
||||
* @return {Node} Node.
|
||||
*/
|
||||
const sortFillsByOrder: Slot.Props[ 'children' ] = ( fills ) => {
|
||||
// Copy fills array here because its type is readonly array that doesn't have .sort method in Typescript definition.
|
||||
const sortedFills = [ ...fills ].sort( ( a, b ) => {
|
||||
return a[ 0 ].props.order - b[ 0 ].props.order;
|
||||
} );
|
||||
return <>{ sortedFills }</>;
|
||||
};
|
||||
import { createOrderedChildren, sortFillsByOrder } from '~/utils';
|
||||
|
||||
/**
|
||||
* Create a Fill for extensions to add items to the WooCommerce Admin header.
|
||||
|
|
|
@ -23,8 +23,12 @@ import { Spinner } from '@woocommerce/components';
|
|||
import getReports from '../analytics/report/get-reports';
|
||||
import { getAdminSetting } from '~/utils/admin-settings';
|
||||
import { NoMatch } from './NoMatch';
|
||||
import { AddProductPage } from '~/products';
|
||||
|
||||
const AddProductPage = lazy( () =>
|
||||
import(
|
||||
/* webpackChunkName: "add-product-page" */ '../products/add-product-page'
|
||||
)
|
||||
);
|
||||
const AnalyticsReport = lazy( () =>
|
||||
import( /* webpackChunkName: "analytics-report" */ '../analytics/report' )
|
||||
);
|
||||
|
|
|
@ -4,13 +4,25 @@
|
|||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
|
||||
export const AddProductPage: React.FC = () => {
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductFormLayout } from './layout/product-form-layout';
|
||||
import { ProductDetailsSection } from './sections/product-details-section';
|
||||
import { ProductImagesSection } from './sections/product-images-section';
|
||||
|
||||
const AddProductPage: React.FC = () => {
|
||||
useEffect( () => {
|
||||
recordEvent( 'view_new_product_management_experience' );
|
||||
}, [] );
|
||||
return (
|
||||
<div className="woocommerce-add-product">
|
||||
<h1>Add Product</h1>
|
||||
<ProductFormLayout>
|
||||
<ProductDetailsSection />
|
||||
<ProductImagesSection />
|
||||
</ProductFormLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProductPage;
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { WooProductFieldItem } from './woo-product-field-item';
|
||||
|
||||
type ProductFieldLayoutProps = {
|
||||
fieldName: string;
|
||||
categoryName: string;
|
||||
};
|
||||
|
||||
export const ProductFieldLayout: React.FC< ProductFieldLayoutProps > = ( {
|
||||
fieldName,
|
||||
categoryName,
|
||||
children,
|
||||
} ) => {
|
||||
return (
|
||||
<div className="product-field-layout">
|
||||
<WooProductFieldItem.Slot
|
||||
fieldName={ fieldName }
|
||||
categoryName={ categoryName }
|
||||
location="before"
|
||||
/>
|
||||
{ children }
|
||||
<WooProductFieldItem.Slot
|
||||
fieldName={ fieldName }
|
||||
categoryName={ categoryName }
|
||||
location="after"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
.product-form-layout {
|
||||
max-width: 1032px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__category {
|
||||
&:not(:first-child) {
|
||||
margin-top: $gap-largest + $gap-smaller;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './product-form-layout.scss';
|
||||
|
||||
export const ProductFormLayout: React.FC = ( { children } ) => {
|
||||
return <div className="product-form-layout">{ children }</div>;
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
.product-category-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 32% auto;
|
||||
gap: $gap-large;
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: $gap-large;
|
||||
gap: $gap-smaller;
|
||||
|
||||
background: $white;
|
||||
border: 1px solid $gray-400;
|
||||
border-radius: 2px;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Children, isValidElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './product-section-layout.scss';
|
||||
import { ProductFieldLayout } from './product-field-layout';
|
||||
|
||||
type ProductSectionLayoutProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
} ) => {
|
||||
return (
|
||||
<div className="product-form-layout__category product-category-layout">
|
||||
<div className="product-category-layout__header">
|
||||
<h3 className="product-category-layout__title">{ title }</h3>
|
||||
<div>
|
||||
<p>{ description }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-category-layout__fields">
|
||||
{ Children.map( children, ( child ) => {
|
||||
if ( isValidElement( child ) && child.props.name ) {
|
||||
return (
|
||||
<ProductFieldLayout
|
||||
fieldName={ child.props.name }
|
||||
categoryName={ title }
|
||||
>
|
||||
{ child }
|
||||
</ProductFieldLayout>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
} ) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { SlotFillProvider } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
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();
|
||||
} );
|
||||
|
||||
it( 'should allow adding extra fields before the field using slot fill', () => {
|
||||
const { queryByText } = render(
|
||||
<SlotFillProvider>
|
||||
<ProductFieldLayout
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
>
|
||||
<div>Name field</div>
|
||||
</ProductFieldLayout>
|
||||
<div>
|
||||
<WooProductFieldItem
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
location="before"
|
||||
>
|
||||
<div>New field</div>
|
||||
</WooProductFieldItem>
|
||||
</div>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect( queryByText( 'New field' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'New field' )?.nextSibling?.textContent ).toEqual(
|
||||
'Name field'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should allow adding extra fields after the field using slot fill', () => {
|
||||
const { queryByText } = render(
|
||||
<SlotFillProvider>
|
||||
<ProductFieldLayout
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
>
|
||||
<div>Name field</div>
|
||||
</ProductFieldLayout>
|
||||
<div>
|
||||
<WooProductFieldItem
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
location="after"
|
||||
>
|
||||
<div>New field</div>
|
||||
</WooProductFieldItem>
|
||||
</div>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect( queryByText( 'New field' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'Name field' )?.nextSibling?.textContent ).toEqual(
|
||||
'New field'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should not render new slot fills when field name does not match', () => {
|
||||
const { queryByText } = render(
|
||||
<SlotFillProvider>
|
||||
<ProductFieldLayout
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
>
|
||||
<div>Name field</div>
|
||||
</ProductFieldLayout>
|
||||
<div>
|
||||
<WooProductFieldItem
|
||||
fieldName="Description"
|
||||
categoryName="Product Details"
|
||||
location="after"
|
||||
>
|
||||
<div>New field</div>
|
||||
</WooProductFieldItem>
|
||||
</div>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect( queryByText( 'New field' ) ).not.toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should not render new slot fills when category name does not match', () => {
|
||||
const { queryByText } = render(
|
||||
<SlotFillProvider>
|
||||
<ProductFieldLayout
|
||||
fieldName="Name"
|
||||
categoryName="Product Details"
|
||||
>
|
||||
<div>Name field</div>
|
||||
</ProductFieldLayout>
|
||||
<div>
|
||||
<WooProductFieldItem
|
||||
fieldName="Name"
|
||||
categoryName="Images"
|
||||
location="after"
|
||||
>
|
||||
<div>New field</div>
|
||||
</WooProductFieldItem>
|
||||
</div>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
expect( queryByText( 'New field' ) ).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductSectionLayout } from '../product-section-layout';
|
||||
|
||||
jest.mock( '../product-field-layout', () => {
|
||||
const productFieldLayoutMock: React.FC< {
|
||||
fieldName: string;
|
||||
categoryName: string;
|
||||
} > = ( { children, fieldName, categoryName } ) => {
|
||||
return (
|
||||
<div className="product-field-layout-mock">
|
||||
<span>fieldName: { fieldName }</span>
|
||||
<span>categoryName: { categoryName }</span>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return {
|
||||
ProductFieldLayout: productFieldLayoutMock,
|
||||
};
|
||||
} );
|
||||
|
||||
const SampleInputField: React.FC< { name: string } > = ( { name } ) => {
|
||||
return <div>smaple-input-field-{ name }</div>;
|
||||
};
|
||||
|
||||
describe( 'ProductSectionLayout', () => {
|
||||
beforeEach( () => {
|
||||
jest.clearAllMocks();
|
||||
} );
|
||||
|
||||
it( 'should render the title and description', () => {
|
||||
const { queryByText } = render(
|
||||
<ProductSectionLayout
|
||||
title="Title"
|
||||
description="This is a description"
|
||||
/>
|
||||
);
|
||||
expect( queryByText( 'Title' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'This is a description' ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should wrap children in ProductFieldLayout if prop contains name', () => {
|
||||
const { queryByText, queryAllByText } = render(
|
||||
<ProductSectionLayout
|
||||
title="Title"
|
||||
description="This is a description"
|
||||
>
|
||||
<SampleInputField name="name" />
|
||||
<SampleInputField name="description" />
|
||||
</ProductSectionLayout>
|
||||
);
|
||||
|
||||
expect( queryByText( 'fieldName: name' ) ).toBeInTheDocument();
|
||||
expect( queryAllByText( 'categoryName: Title' ).length ).toEqual( 2 );
|
||||
|
||||
expect( queryByText( 'smaple-input-field-name' ) ).toBeInTheDocument();
|
||||
expect(
|
||||
queryByText( 'smaple-input-field-description' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should not wrap children in ProductFieldLayout if prop does not contain name', () => {
|
||||
const { queryByText, queryAllByText } = render(
|
||||
<ProductSectionLayout
|
||||
title="Title"
|
||||
description="This is a description"
|
||||
>
|
||||
<div> A child</div>
|
||||
<div> Another child</div>
|
||||
</ProductSectionLayout>
|
||||
);
|
||||
|
||||
expect( queryAllByText( 'categoryName: Title' ).length ).toEqual( 0 );
|
||||
|
||||
expect( queryByText( 'A child' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'Another child' ) ).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { snakeCase } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { createOrderedChildren, sortFillsByOrder } from '~/utils';
|
||||
|
||||
// TODO: move this to a published JS package once ready.
|
||||
|
||||
/**
|
||||
* Create a Fill for extensions to add items to the Product edit page.
|
||||
*
|
||||
* @slotFill WooProductFieldItem
|
||||
* @scope woocommerce-admin
|
||||
* @example
|
||||
* const MyProductDetailsFieldItem = () => (
|
||||
* <WooProductFieldItem fieldName="name" categoryName="Product details" location="after">My header item</WooProductFieldItem>
|
||||
* );
|
||||
*
|
||||
* registerPlugin( 'my-extension', {
|
||||
* render: MyProductDetailsFieldItem,
|
||||
* scope: 'woocommerce-admin',
|
||||
* } );
|
||||
* @param {Object} param0
|
||||
* @param {Array} param0.children - Node children.
|
||||
* @param {string} param0.fieldName - Field name.
|
||||
* @param {string} param0.categoryName - Category name.
|
||||
* @param {number} param0.order - Order of Fill component.
|
||||
* @param {string} param0.location - Location before or after.
|
||||
*/
|
||||
export const WooProductFieldItem: React.FC< {
|
||||
fieldName: string;
|
||||
categoryName: string;
|
||||
order?: number;
|
||||
location: 'before' | 'after';
|
||||
} > & {
|
||||
Slot: React.FC<
|
||||
Slot.Props & {
|
||||
fieldName: string;
|
||||
categoryName: string;
|
||||
location: 'before' | 'after';
|
||||
}
|
||||
>;
|
||||
} = ( { children, fieldName, categoryName, location, order = 1 } ) => {
|
||||
const categoryKey = snakeCase( categoryName );
|
||||
const fieldKey = snakeCase( fieldName );
|
||||
return (
|
||||
<Fill
|
||||
name={ `woocommerce_product_${ categoryKey }_${ fieldKey }_${ location }` }
|
||||
>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren( children, order, fillProps );
|
||||
} }
|
||||
</Fill>
|
||||
);
|
||||
};
|
||||
|
||||
WooProductFieldItem.Slot = ( {
|
||||
fillProps,
|
||||
fieldName,
|
||||
categoryName,
|
||||
location,
|
||||
} ) => {
|
||||
const categoryKey = snakeCase( categoryName );
|
||||
const fieldKey = snakeCase( fieldName );
|
||||
return (
|
||||
<Slot
|
||||
name={ `woocommerce_product_${ categoryKey }_${ fieldKey }_${ location }` }
|
||||
fillProps={ fillProps }
|
||||
>
|
||||
{ sortFillsByOrder }
|
||||
</Slot>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { TextControl } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductSectionLayout } from '../layout/product-section-layout';
|
||||
|
||||
export const ProductDetailsSection: React.FC = () => {
|
||||
return (
|
||||
<ProductSectionLayout
|
||||
title={ __( 'Product details', 'woocommerce' ) }
|
||||
description={ __(
|
||||
'This info will be displayed on the product page, category pages, social media, and search results.',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<TextControl
|
||||
label={ __( 'Name', 'woocommerce' ) }
|
||||
name="name"
|
||||
value={ '' }
|
||||
onChange={ () => {} }
|
||||
/>
|
||||
</ProductSectionLayout>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { TextControl } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductSectionLayout } from '../layout/product-section-layout';
|
||||
|
||||
export const ProductImagesSection: React.FC = () => {
|
||||
return (
|
||||
<ProductSectionLayout
|
||||
title={ __( 'Images', 'woocommerce' ) }
|
||||
description={ __(
|
||||
'For best results, use JPEG files that are 1000 by 1000 pixels or larger..',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<TextControl
|
||||
label={ __( 'Images', 'woocommerce' ) }
|
||||
name="images"
|
||||
value={ '' }
|
||||
onChange={ () => {} }
|
||||
/>
|
||||
</ProductSectionLayout>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
export * from './plugins';
|
||||
export * from './slot-fill-ordering';
|
||||
|
||||
/**
|
||||
* Get the URL params.
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isValidElement } from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { cloneElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Ordered fill item.
|
||||
*
|
||||
* @param {Node} children - Node children.
|
||||
* @param {number} order - Node order.
|
||||
* @param {Array} props - Fill props.
|
||||
* @return {Node} Node.
|
||||
*/
|
||||
export const createOrderedChildren = (
|
||||
children: React.ReactNode,
|
||||
order: number,
|
||||
props: Fill.Props
|
||||
) => {
|
||||
if ( typeof children === 'function' ) {
|
||||
return cloneElement( children( props ), { order } );
|
||||
} else if ( isValidElement( children ) ) {
|
||||
return cloneElement( children, { ...props, order } );
|
||||
}
|
||||
throw Error( 'Invalid children type' );
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort fills by order for slot children.
|
||||
*
|
||||
* @param {Array} fills - slot's `Fill`s.
|
||||
* @return {Node} Node.
|
||||
*/
|
||||
export const sortFillsByOrder: Slot.Props[ 'children' ] = ( fills ) => {
|
||||
// Copy fills array here because its type is readonly array that doesn't have .sort method in Typescript definition.
|
||||
const sortedFills = [ ...fills ].sort( ( a, b ) => {
|
||||
return a[ 0 ].props.order - b[ 0 ].props.order;
|
||||
} );
|
||||
return <>{ sortedFills }</>;
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add product page layout components for new product edit page.
|
Loading…
Reference in New Issue