Adding WooProductFieldItem slotfill (#36315)
This commit is contained in:
parent
c6e1fb767e
commit
f429b9444c
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding WooProductFieldItem slotfill.
|
|
@ -84,3 +84,5 @@ export { DynamicForm } from './dynamic-form';
|
|||
export { default as TourKit } from './tour-kit';
|
||||
export * as TourKitTypes from './tour-kit/types';
|
||||
export { CollapsibleContent } from './collapsible-content';
|
||||
export { createOrderedChildren, sortFillsByOrder } from './utils';
|
||||
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import React, { isValidElement, Fragment } from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { cloneElement, createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Ordered fill item.
|
||||
*
|
||||
* @param {Node} children - Node children.
|
||||
* @param {number} order - Node order.
|
||||
* @param {Array} props - Fill props.
|
||||
* @return {Node} Node.
|
||||
*/
|
||||
function createOrderedChildren< T = Fill.Props >(
|
||||
children: React.ReactNode,
|
||||
order: number,
|
||||
props: T
|
||||
) {
|
||||
if ( typeof children === 'function' ) {
|
||||
return cloneElement( children( props ), { order } );
|
||||
} else if ( isValidElement( children ) ) {
|
||||
return cloneElement( children, { ...props, order } );
|
||||
}
|
||||
throw Error( 'Invalid children type' );
|
||||
}
|
||||
export { createOrderedChildren };
|
||||
|
||||
/**
|
||||
* 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 <Fragment>{ sortedFills }</Fragment>;
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
# WooProductFieldItem Slot & Fill
|
||||
|
||||
A Slotfill component that will allow you to add a new field to a specific section in the product editor.
|
||||
|
||||
## Usage
|
||||
|
||||
```jsx
|
||||
<WooProductFieldItem id={ key } section="details" order={ 2 } pluginId="test-plugin" >
|
||||
{ () => {
|
||||
return (
|
||||
<TextControl
|
||||
label="Name"
|
||||
name={ `product-mvp-name` }
|
||||
placeholder="e.g. 12 oz Coffee Mug"
|
||||
value="Test Name"
|
||||
onChange={ () => console.debug( 'Changed!' ) }
|
||||
/>
|
||||
);
|
||||
} }
|
||||
</WooProductFieldItem>
|
||||
|
||||
<WooProductFieldItem.Slot section="details" />
|
||||
```
|
||||
|
||||
### WooProductFieldItem (fill)
|
||||
|
||||
This is the fill component. You must provide the `id` prop to identify your product field fill with a unique string. This component will accept a series of props:
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------| -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `id` | String | A unique string to identify your fill. Used for configuiration management. |
|
||||
| `section ` | String | The string used to identify the particular section where you want to render your field. |
|
||||
| `pluginId` | String | A unique plugin ID to identify the plugin/extension that this fill is associated with. |
|
||||
| `order` | Number | (optional) This number will dictate the order that the fields rendered by a Slot will be appear. |
|
||||
|
||||
### WooProductFieldItem.Slot (slot)
|
||||
|
||||
This is the slot component, and will not be used as frequently. It must also receive the required `location` prop that will be identical to the fill `location`.
|
||||
|
||||
| Name | Type | Description |
|
||||
| ----------- | ------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `section` | String | Unique to the section that the Slot appears, and must be the same as the one provided to any fills. |
|
|
@ -0,0 +1 @@
|
|||
export * from './woo-product-field-item';
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { createElement, Children } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { createOrderedChildren, sortFillsByOrder } from '../utils';
|
||||
|
||||
type WooProductFieldItemProps = {
|
||||
id: string;
|
||||
section: string;
|
||||
pluginId: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
type WooProductFieldSlotProps = {
|
||||
section: string;
|
||||
};
|
||||
|
||||
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
|
||||
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
||||
} = ( { children, order = 1, section } ) => (
|
||||
<Fill name={ `woocommerce_product_field_${ section }` }>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren< Fill.Props >(
|
||||
children,
|
||||
order,
|
||||
fillProps
|
||||
);
|
||||
} }
|
||||
</Fill>
|
||||
);
|
||||
|
||||
WooProductFieldItem.Slot = ( { fillProps, section } ) => (
|
||||
<Slot
|
||||
name={ `woocommerce_product_field_${ section }` }
|
||||
fillProps={ fillProps }
|
||||
>
|
||||
{ ( fills ) => {
|
||||
if ( ! sortFillsByOrder ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Children.map(
|
||||
sortFillsByOrder( fills )?.props.children,
|
||||
( child ) => (
|
||||
<div className="woocommerce-product-form__field">
|
||||
{ child }
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} }
|
||||
</Slot>
|
||||
);
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
|
@ -22,7 +22,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.woocommerce-product-form__field {
|
||||
.woocommerce-product-form__field:not(:first-child) {
|
||||
margin-top: $gap-large;
|
||||
|
||||
> .components-base-control {
|
||||
|
|
|
@ -8,7 +8,6 @@ import { FormSection } from '@woocommerce/components';
|
|||
* Internal dependencies
|
||||
*/
|
||||
import './product-section-layout.scss';
|
||||
import { ProductFieldLayout } from './product-field-layout';
|
||||
|
||||
type ProductSectionLayoutProps = {
|
||||
title: string;
|
||||
|
@ -31,12 +30,7 @@ export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
|
|||
{ Children.map( children, ( child ) => {
|
||||
if ( isValidElement( child ) && child.props.onChange ) {
|
||||
return (
|
||||
<ProductFieldLayout
|
||||
fieldName={ child.props.name }
|
||||
categoryName={ title }
|
||||
>
|
||||
{ child }
|
||||
</ProductFieldLayout>
|
||||
<div className="product-field-layout">{ child }</div>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
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();
|
||||
} );
|
||||
} );
|
|
@ -8,28 +8,10 @@ import { render } from '@testing-library/react';
|
|||
*/
|
||||
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; onChange: () => void } > = ( {
|
||||
name,
|
||||
} ) => {
|
||||
return <div>smaple-input-field-{ name }</div>;
|
||||
return <div>sample-input-field-{ name }</div>;
|
||||
};
|
||||
|
||||
describe( 'ProductSectionLayout', () => {
|
||||
|
@ -59,12 +41,9 @@ describe( 'ProductSectionLayout', () => {
|
|||
</ProductSectionLayout>
|
||||
);
|
||||
|
||||
expect( queryByText( 'fieldName: name' ) ).toBeInTheDocument();
|
||||
expect( queryAllByText( 'categoryName: Title' ).length ).toEqual( 2 );
|
||||
|
||||
expect( queryByText( 'smaple-input-field-name' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'sample-input-field-name' ) ).toBeInTheDocument();
|
||||
expect(
|
||||
queryByText( 'smaple-input-field-description' )
|
||||
queryByText( 'sample-input-field-description' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
|
@ -17,6 +17,7 @@ import {
|
|||
useFormContext,
|
||||
__experimentalRichTextEditor as RichTextEditor,
|
||||
__experimentalTooltip as Tooltip,
|
||||
__experimentalWooProductFieldItem as WooProductFieldItem,
|
||||
} from '@woocommerce/components';
|
||||
import interpolateComponents from '@automattic/interpolate-components';
|
||||
import {
|
||||
|
@ -241,6 +242,7 @@ export const ProductDetailsSection: React.FC = () => {
|
|||
'woocommerce'
|
||||
) }
|
||||
/>
|
||||
<WooProductFieldItem.Slot section="details" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ProductSectionLayout>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Adding WooProductFieldItem slot to product details section.
|
Loading…
Reference in New Issue