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:
louwie17 2022-08-04 10:15:30 -03:00 committed by GitHub
parent 5f0efcf02f
commit 470fc899e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 525 additions and 37 deletions

View File

@ -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.

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
.product-form-layout {
max-width: 1032px;
margin: 0 auto;
&__category {
&:not(:first-child) {
margin-top: $gap-largest + $gap-smaller;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
export * from './plugins';
export * from './slot-fill-ordering';
/**
* Get the URL params.

View File

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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product page layout components for new product edit page.