Refactor product tabs and add product tab slot fills (#36551)

This commit is contained in:
louwie17 2023-01-24 19:37:21 -04:00 committed by GitHub
parent b78318525b
commit bcdf2518e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 455 additions and 143 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new WooProductTabItem component for slot filling tab items.

View File

@ -87,6 +87,7 @@ export { CollapsibleContent } from './collapsible-content';
export { createOrderedChildren, sortFillsByOrder } from './utils';
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';
export {
ProductSectionLayout as __experimentalProductSectionLayout,
ProductFieldSection as __experimentalProductFieldSection,

View File

@ -20,7 +20,10 @@ function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >(
injectProps?: S
) {
if ( typeof children === 'function' ) {
return cloneElement( children( props ), { order, ...injectProps } );
return cloneElement( children( { ...props, order, ...injectProps } ), {
order,
...injectProps,
} );
} else if ( isValidElement( children ) ) {
return cloneElement( children, { ...props, order, ...injectProps } );
}

View File

@ -0,0 +1,35 @@
# WooProductTabItem Slot & Fill
A Slotfill component that will allow you to add a new tab to the product editor.
## Usage
```jsx
<WooProductTabItem id={ key } location="tab/general" order={ 2 } pluginId="test-plugin" tabProps={ { title: 'New tab', name: 'new-tab' } } >
<Card>
<CardBody>{ /* Tab content */ }</CardBody>
</Card>
</WooProductTabItem>
<WooProductTabItem.Slot location="tab/general" />
```
### WooProductTabItem (fill)
This is the fill component. You must provide the `id` prop to identify your section 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. |
| `location` | String | The string used to identify the particular location that you want to render your section. |
| `pluginId` | String | A unique plugin ID to identify the plugin/extension that this fill is associated with. |
| `tabProps` | Object | An object containing tab props: name, title, className, disabled (see TabPanel.Tab from @wordpress/components) |
| `order` | Number | (optional) This number will dictate the order that the sections rendered by a Slot will be appear. |
### WooProductTabItem.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 |
| ---------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `location` | String | Unique to the location that the Slot appears, and must be the same as the one provided to any fills. |

View File

@ -0,0 +1 @@
export * from './woo-product-tab-item';

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import React, { ReactElement, ReactNode } from 'react';
import { Slot, Fill, TabPanel } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createOrderedChildren } from '../utils';
type WooProductTabItemProps = {
id: string;
pluginId: string;
template?: string;
order?: number;
tabProps:
| TabPanel.Tab
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| ( ( fillProps: Record< string, any > | undefined ) => TabPanel.Tab );
templates?: Array< { name: string; order?: number } >;
};
type WooProductFieldSlotProps = {
template: string;
children: (
tabs: TabPanel.Tab[],
tabChildren: Record< string, ReactNode >
) => ReactElement | null;
};
export const WooProductTabItem: React.FC< WooProductTabItemProps > & {
Slot: React.VFC<
Omit< Slot.Props, 'children' > & WooProductFieldSlotProps
>;
} = ( { children, order, template, tabProps, templates } ) => {
if ( ! template && ! templates ) {
// eslint-disable-next-line no-console
console.warn(
'WooProductTabItem fill is missing template or templates property.'
);
return null;
}
templates = templates || [ { name: template as string, order } ];
return (
<>
{ templates.map( ( templateData ) => (
<Fill
name={ `woocommerce_product_tab_${ templateData.name }` }
key={ templateData.name }
>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren< Fill.Props >(
children,
templateData.order || 20,
{},
{
tabProps,
templateName: templateData.name,
order: templateData.order || 20,
...fillProps,
}
);
} }
</Fill>
) ) }
</>
);
};
WooProductTabItem.Slot = ( { fillProps, template, children } ) => (
<Slot
name={ `woocommerce_product_tab_${ template }` }
fillProps={ fillProps }
>
{ ( fills ) => {
const tabData = fills.reduce(
( { childrenMap, tabs }, fill ) => {
const props: WooProductTabItemProps = fill[ 0 ].props;
if ( props && props.tabProps ) {
childrenMap[ props.tabProps.name ] = fill[ 0 ];
const tabProps =
typeof props.tabProps === 'function'
? props.tabProps( fillProps )
: props.tabProps;
tabs.push( {
...tabProps,
order: props.order ?? 20,
} );
}
return {
childrenMap,
tabs,
};
},
{ childrenMap: {}, tabs: [] } as {
childrenMap: Record< string, ReactElement >;
tabs: Array< TabPanel.Tab & { order: number } >;
}
);
const orderedTabs = tabData.tabs.sort( ( a, b ) => {
return a.order - b.order;
} );
return children( orderedTabs, tabData.childrenMap );
} }
</Slot>
);

View File

@ -126,7 +126,7 @@ const EditProductPage: React.FC = () => {
product.status === 'trash' &&
! isPendingAction &&
! wasDeletedUsingAction && (
<ProductFormLayout>
<ProductFormLayout id="error">
<div className="woocommerce-edit-product__error">
{ __(
'You cannot edit this item because it is in the Trash. Please restore it and try again.',

View File

@ -2,6 +2,8 @@
* Internal dependencies
*/
import './product-form-fills';
import './product-form-tab-fills';
import './product-form-variation-tab-fills';
export * from './shipping-section/shipping-section-fills';
export * from './details-section/details-section-fills';

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';
import {
__experimentalWooProductTabItem as WooProductTabItem,
__experimentalWooProductSectionItem as WooProductSectionItem,
useFormContext,
} from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import { OptionsSection } from '../sections/options-section';
import { ProductVariationsSection } from '../sections/product-variations-section';
import {
TAB_GENERAL_ID,
TAB_SHIPPING_ID,
TAB_INVENTORY_ID,
TAB_PRICING_ID,
} from './constants';
const Tabs = () => {
const { values: product } = useFormContext< Product >();
const tabPropData = useMemo(
() => ( {
general: {
name: 'general',
title: __( 'General', 'woocommerce' ),
},
pricing: {
name: 'pricing',
title: __( 'Pricing', 'woocommerce' ),
disabled: !! product?.variations?.length,
},
inventory: {
name: 'inventory',
title: __( 'Inventory', 'woocommerce' ),
disabled: !! product?.variations?.length,
},
shipping: {
name: 'shipping',
title: __( 'Shipping', 'woocommerce' ),
disabled: !! product?.variations?.length,
},
options: {
name: 'options',
title: __( 'Options', 'woocommerce' ),
},
} ),
[ product.variations ]
);
return (
<>
<WooProductTabItem
id="tab/general"
template="tab/general"
pluginId="core"
order={ 1 }
tabProps={ tabPropData.general }
>
<WooProductSectionItem.Slot location={ TAB_GENERAL_ID } />
</WooProductTabItem>
<WooProductTabItem
id="tab/pricing"
template="tab/general"
pluginId="core"
order={ 3 }
tabProps={ tabPropData.pricing }
>
<WooProductSectionItem.Slot location={ TAB_PRICING_ID } />
</WooProductTabItem>
<WooProductTabItem
id="tab/inventory"
template="tab/general"
pluginId="core"
order={ 5 }
tabProps={ tabPropData.inventory }
>
<WooProductSectionItem.Slot location={ TAB_INVENTORY_ID } />
</WooProductTabItem>
<WooProductTabItem
id="tab/shipping"
template="tab/general"
pluginId="core"
order={ 7 }
tabProps={ tabPropData.shipping }
>
<WooProductSectionItem.Slot
location={ TAB_SHIPPING_ID }
fillProps={ { product } }
/>
</WooProductTabItem>
{ window.wcAdminFeatures[ 'product-variation-management' ] ? (
<WooProductTabItem
id="tab/options"
template="tab/general"
pluginId="core"
order={ 9 }
tabProps={ tabPropData.options }
>
<>
<OptionsSection />
<ProductVariationsSection />
</>
</WooProductTabItem>
) : null }
</>
);
};
registerPlugin( 'wc-admin-product-editor-form-tab-fills', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => {
return <Tabs />;
},
} );

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';
import {
__experimentalWooProductTabItem as WooProductTabItem,
__experimentalWooProductSectionItem as WooProductSectionItem,
} from '@woocommerce/components';
import { PartialProduct } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { ProductVariationDetailsSection } from '../sections/product-variation-details-section';
import { TAB_INVENTORY_ID, TAB_SHIPPING_ID, TAB_PRICING_ID } from './constants';
const tabPropData = {
general: {
name: 'general',
title: __( 'General', 'woocommerce' ),
},
pricing: {
name: 'pricing',
title: __( 'Pricing', 'woocommerce' ),
},
inventory: {
name: 'inventory',
title: __( 'Inventory', 'woocommerce' ),
},
shipping: {
name: 'shipping',
title: __( 'Shipping', 'woocommerce' ),
},
options: {
name: 'options',
title: __( 'Options', 'woocommerce' ),
},
};
const Tabs = () => {
return (
<>
<WooProductTabItem
id="tab/general/variation"
template="tab/variation"
pluginId="core"
order={ 1 }
tabProps={ tabPropData.general }
>
<ProductVariationDetailsSection />
</WooProductTabItem>
<WooProductTabItem
id="tab/pricing"
template="tab/variation"
pluginId="core"
order={ 3 }
tabProps={ tabPropData.pricing }
>
<WooProductSectionItem.Slot location={ TAB_PRICING_ID } />
</WooProductTabItem>
<WooProductTabItem
id="tab/inventory"
template="tab/variation"
pluginId="core"
order={ 5 }
tabProps={ tabPropData.inventory }
>
<WooProductSectionItem.Slot location={ TAB_INVENTORY_ID } />
</WooProductTabItem>
<WooProductTabItem
id="tab/shipping"
template="tab/variation"
pluginId="core"
order={ 7 }
tabProps={ tabPropData.shipping }
>
{ ( { product }: { product: PartialProduct } ) => (
<WooProductSectionItem.Slot
location={ TAB_SHIPPING_ID }
fillProps={ { product } }
/>
) }
</WooProductTabItem>
</>
);
};
/**
* Preloading product form data, as product pages are waiting on this to be resolved.
* The above Form component won't get rendered until the getProductForm is resolved.
*/
registerPlugin( 'wc-admin-product-editor-form-variation-tab-fills', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => {
return <Tabs />;
},
} );

View File

@ -2,20 +2,28 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Children, useEffect } from '@wordpress/element';
import { useEffect } from '@wordpress/element';
import { TabPanel, Tooltip } from '@wordpress/components';
import { navigateTo, getNewPath, getQuery } from '@woocommerce/navigation';
import { __experimentalWooProductTabItem as WooProductTabItem } from '@woocommerce/components';
import { PartialProduct } from '@woocommerce/data';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import './product-form-layout.scss';
import { ProductFormTab } from '../product-form-tab';
import { useHeaderHeight } from '~/header/use-header-height';
export const ProductFormLayout: React.FC< {
children: JSX.Element | JSX.Element[];
} > = ( { children } ) => {
type ProductFormLayoutProps = {
id: string;
product?: PartialProduct;
};
export const ProductFormLayout: React.FC< ProductFormLayoutProps > = ( {
id,
product,
} ) => {
const query = getQuery() as Record< string, string >;
useEffect( () => {
@ -36,16 +44,15 @@ export const ProductFormLayout: React.FC< {
const tabPanelTabs = document.querySelector(
'.product-form-layout .components-tab-panel__tabs'
) as HTMLElement;
tabPanelTabs.style.top = adminBarHeight + headerHeight + 'px';
if ( tabPanelTabs ) {
tabPanelTabs.style.top = adminBarHeight + headerHeight + 'px';
}
}, [ adminBarHeight, headerHeight ] );
const tabs = Children.map( children, ( child: JSX.Element ) => {
if ( child.type !== ProductFormTab ) {
return null;
}
return {
name: child.props.name,
title: child.props.disabled ? (
const getTooltipTabs = ( tabs: TabPanel.Tab[] ) => {
return tabs.map( ( tab ) => ( {
name: tab.name,
title: tab.disabled ? (
<Tooltip
text={ __(
'Manage individual variation details in the Options tab.',
@ -54,49 +61,61 @@ export const ProductFormLayout: React.FC< {
>
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
{ tab.title }
</span>
</span>
</Tooltip>
) : (
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
{ tab.title }
</span>
</span>
),
disabled: child.props.disabled,
};
} );
disabled: tab.disabled,
} ) );
};
return (
<TabPanel
className="product-form-layout"
activeClass="is-active"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Disabled properties will be included in newer versions of Gutenberg.
tabs={ tabs }
initialTabName={ query.tab ?? tabs[ 0 ].name }
onSelect={ ( tabName: string ) => {
window.document.documentElement.scrollTop = 0;
navigateTo( {
url: getNewPath( { tab: tabName } ),
} );
} }
>
{ ( tab ) => (
<>
{ Children.map( children, ( child: JSX.Element ) => {
if (
child.type !== ProductFormTab ||
child.props.name !== tab.name
) {
return null;
}
return child;
} ) }
</>
) }
</TabPanel>
<>
<WooProductTabItem.Slot
template={ 'tab/' + id }
fillProps={ { product } }
>
{ ( tabs, childrenMap ) =>
tabs.length > 0 ? (
<TabPanel
className="product-form-layout"
activeClass="is-active"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Disabled properties will be included in newer versions of Gutenberg.
tabs={ getTooltipTabs( tabs ) }
initialTabName={ query.tab ?? tabs[ 0 ].name }
onSelect={ ( tabName: string ) => {
window.document.documentElement.scrollTop = 0;
navigateTo( {
url: getNewPath( { tab: tabName } ),
} );
} }
>
{ ( tab ) => {
const classes = classnames(
'woocommerce-product-form-tab',
'woocommerce-product-form-tab__' + tab.name
);
const child = childrenMap[ tab.name ];
return (
<div className={ classes } key={ tab.name }>
{ typeof child === 'function'
? child( product )
: child }
</div>
);
} }
</TabPanel>
) : null
}
</WooProductTabItem.Slot>
</>
);
};

View File

@ -1,12 +1,7 @@
/**
* External dependencies
*/
import {
Form,
FormRef,
__experimentalWooProductSectionItem as WooProductSectionItem,
SlotContextProvider,
} from '@woocommerce/components';
import { Form, FormRef, SlotContextProvider } from '@woocommerce/components';
import { PartialProduct, Product } from '@woocommerce/data';
import { PluginArea } from '@wordpress/plugins';
import { Ref } from 'react';
@ -16,17 +11,8 @@ import { Ref } from 'react';
*/
import { ProductFormHeader } from './layout/product-form-header';
import { ProductFormLayout } from './layout/product-form-layout';
import { ProductVariationsSection } from './sections/product-variations-section';
import { validate } from './product-validation';
import { OptionsSection } from './sections/options-section';
import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab';
import {
TAB_GENERAL_ID,
TAB_SHIPPING_ID,
TAB_INVENTORY_ID,
TAB_PRICING_ID,
} from './fills/constants';
export const ProductForm: React.FC< {
product?: PartialProduct;
@ -49,51 +35,7 @@ export const ProductForm: React.FC< {
validate={ validate }
>
<ProductFormHeader />
<ProductFormLayout>
<ProductFormTab name="general" title="General">
<WooProductSectionItem.Slot
location={ TAB_GENERAL_ID }
/>
</ProductFormTab>
<ProductFormTab
name="pricing"
title="Pricing"
disabled={ !! product?.variations?.length }
>
<WooProductSectionItem.Slot
location={ TAB_PRICING_ID }
/>
</ProductFormTab>
<ProductFormTab
name="inventory"
title="Inventory"
disabled={ !! product?.variations?.length }
>
<WooProductSectionItem.Slot
location={ TAB_INVENTORY_ID }
/>
</ProductFormTab>
<ProductFormTab
name="shipping"
title="Shipping"
disabled={ !! product?.variations?.length }
>
<WooProductSectionItem.Slot
location={ TAB_SHIPPING_ID }
fillProps={ { product } }
/>
</ProductFormTab>
{ window.wcAdminFeatures[
'product-variation-management'
] ? (
<ProductFormTab name="options" title="Options">
<OptionsSection />
<ProductVariationsSection />
</ProductFormTab>
) : (
<></>
) }
</ProductFormLayout>
<ProductFormLayout id="general" product={ product } />
<ProductFormFooter />
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
<PluginArea scope="woocommerce-product-editor" />

View File

@ -3,14 +3,9 @@
*/
import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';
import {
Form,
FormRef,
__experimentalWooProductSectionItem as WooProductSectionItem,
SlotContextProvider,
} from '@woocommerce/components';
import { PartialProduct, ProductVariation } from '@woocommerce/data';
import { Form, FormRef, SlotContextProvider } from '@woocommerce/components';
import { PluginArea } from '@wordpress/plugins';
import { PartialProduct, ProductVariation } from '@woocommerce/data';
/**
* Internal dependencies
@ -18,15 +13,8 @@ import { PluginArea } from '@wordpress/plugins';
import PostsNavigation from './shared/posts-navigation';
import { ProductFormLayout } from './layout/product-form-layout';
import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab';
import { ProductVariationDetailsSection } from './sections/product-variation-details-section';
import { ProductVariationFormHeader } from './layout/product-variation-form-header';
import useProductVariationNavigation from './hooks/use-product-variation-navigation';
import {
TAB_INVENTORY_ID,
TAB_SHIPPING_ID,
TAB_PRICING_ID,
} from './fills/constants';
import './product-variation-form.scss';
@ -60,27 +48,11 @@ export const ProductVariationForm: React.FC< {
ref={ formRef }
>
<ProductVariationFormHeader />
<ProductFormLayout key={ productVariation.id }>
<ProductFormTab name="general" title="General">
<ProductVariationDetailsSection />
</ProductFormTab>
<ProductFormTab name="pricing" title="Pricing">
<WooProductSectionItem.Slot
location={ TAB_PRICING_ID }
/>
</ProductFormTab>
<ProductFormTab name="inventory" title="Inventory">
<WooProductSectionItem.Slot
location={ TAB_INVENTORY_ID }
/>
</ProductFormTab>
<ProductFormTab name="shipping" title="Shipping">
<WooProductSectionItem.Slot
location={ TAB_SHIPPING_ID }
fillProps={ { product } }
/>
</ProductFormTab>
</ProductFormLayout>
<ProductFormLayout
key={ productVariation.id }
id="variation"
product={ productVariation as PartialProduct }
/>
<ProductFormFooter />
<div className="product-variation-form__navigation">

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add slot fill support for tabs for the new product management MVP.