Add tab blocks to the blocks product editor (#37174)

* Add initial tab block and tabs render

* Add tabs styling

* Set initially selected tab on render

* Set initial tab based on query param

* Add template placeholder with tabs

* Add changelog entry

* Allow multiple tab and section blocks

* Add changelog entry for wc

* Fix up arrow alignment

* Fix up block configuration type

* Add missing navigation dependency to product editor
This commit is contained in:
Joshua T Flowers 2023-03-13 15:00:50 -07:00 committed by GitHub
parent b1a0d3177c
commit 31ec8d8352
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 366 additions and 32 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tabs block and tabs to product editor

View File

@ -34,6 +34,7 @@
"@woocommerce/components": "workspace:*", "@woocommerce/components": "workspace:*",
"@woocommerce/currency": "workspace:*", "@woocommerce/currency": "workspace:*",
"@woocommerce/data": "workspace:^4.1.0", "@woocommerce/data": "workspace:^4.1.0",
"@woocommerce/navigation": "workspace:^8.1.0",
"@woocommerce/number": "workspace:*", "@woocommerce/number": "workspace:*",
"@woocommerce/tracks": "workspace:^1.3.0", "@woocommerce/tracks": "workspace:^1.3.0",
"@wordpress/block-editor": "^9.8.0", "@wordpress/block-editor": "^9.8.0",

View File

@ -2,7 +2,12 @@
* External dependencies * External dependencies
*/ */
import { Template } from '@wordpress/blocks'; import { Template } from '@wordpress/blocks';
import { createElement, useMemo, useLayoutEffect } from '@wordpress/element'; import {
createElement,
useMemo,
useLayoutEffect,
useState,
} from '@wordpress/element';
import { Product } from '@woocommerce/data'; import { Product } from '@woocommerce/data';
import { useSelect, select as WPSelect, useDispatch } from '@wordpress/data'; import { useSelect, select as WPSelect, useDispatch } from '@wordpress/data';
import { uploadMedia } from '@wordpress/media-utils'; import { uploadMedia } from '@wordpress/media-utils';
@ -10,6 +15,9 @@ import {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet. // @ts-ignore No types for this exist yet.
BlockBreadcrumb, BlockBreadcrumb,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
BlockContextProvider,
BlockEditorKeyboardShortcuts, BlockEditorKeyboardShortcuts,
BlockEditorProvider, BlockEditorProvider,
BlockList, BlockList,
@ -34,6 +42,7 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import { Sidebar } from '../sidebar'; import { Sidebar } from '../sidebar';
import { Tabs } from '../tabs';
type BlockEditorProps = { type BlockEditorProps = {
product: Partial< Product >; product: Partial< Product >;
@ -48,6 +57,8 @@ export function BlockEditor( {
settings: _settings, settings: _settings,
product, product,
}: BlockEditorProps ) { }: BlockEditorProps ) {
const [ selectedTab, setSelectedTab ] = useState< string | null >( null );
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore __experimentalTearDownEditor is not yet included in types package. // @ts-ignore __experimentalTearDownEditor is not yet included in types package.
const { setupEditor, __experimentalTearDownEditor } = const { setupEditor, __experimentalTearDownEditor } =
@ -102,29 +113,32 @@ export function BlockEditor( {
return ( return (
<div className="woocommerce-product-block-editor"> <div className="woocommerce-product-block-editor">
<BlockEditorProvider <BlockContextProvider value={ { selectedTab } }>
value={ blocks } <BlockEditorProvider
onInput={ onInput } value={ blocks }
onChange={ onChange } onInput={ onInput }
settings={ settings } onChange={ onChange }
> settings={ settings }
<BlockBreadcrumb /> >
<Sidebar.InspectorFill> <Tabs onChange={ setSelectedTab } />
<BlockInspector /> <BlockBreadcrumb />
</Sidebar.InspectorFill> <Sidebar.InspectorFill>
<div className="editor-styles-wrapper"> <BlockInspector />
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ } </Sidebar.InspectorFill>
{ /* @ts-ignore No types for this exist yet. */ } <div className="editor-styles-wrapper">
<BlockEditorKeyboardShortcuts.Register /> { /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
<BlockTools> { /* @ts-ignore No types for this exist yet. */ }
<WritingFlow> <BlockEditorKeyboardShortcuts.Register />
<ObserveTyping> <BlockTools>
<BlockList className="woocommerce-product-block-editor__block-list" /> <WritingFlow>
</ObserveTyping> <ObserveTyping>
</WritingFlow> <BlockList className="woocommerce-product-block-editor__block-list" />
</BlockTools> </ObserveTyping>
</div> </WritingFlow>
</BlockEditorProvider> </BlockTools>
</div>
</BlockEditorProvider>
</BlockContextProvider>
</div> </div>
); );
} }

View File

@ -3,8 +3,10 @@
*/ */
import { init as initName } from '../details-name-block'; import { init as initName } from '../details-name-block';
import { init as initSection } from '../section'; import { init as initSection } from '../section';
import { init as initTab } from '../tab';
export const initBlocks = () => { export const initBlocks = () => {
initName(); initName();
initSection(); initSection();
initTab();
}; };

View File

@ -18,7 +18,7 @@
"supports": { "supports": {
"align": false, "align": false,
"html": false, "html": false,
"multiple": false, "multiple": true,
"reusable": false, "reusable": false,
"inserter": false, "inserter": false,
"lock": false "lock": false

View File

@ -0,0 +1,27 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-tab",
"title": "Product tab",
"category": "woocommerce",
"description": "The product tab.",
"keywords": [ "products", "tab", "group" ],
"textdomain": "default",
"attributes": {
"id": {
"type": "string"
},
"title": {
"type": "string"
}
},
"supports": {
"align": false,
"html": false,
"multiple": true,
"reusable": false,
"inserter": false,
"lock": false
},
"usesContext": [ "selectedTab" ]
}

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import type { BlockAttributes } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { TabButton } from './tab-button';
export function Edit( {
attributes,
context,
}: {
attributes: BlockAttributes;
context?: {
selectedTab?: string;
};
} ) {
const blockProps = useBlockProps();
const { id, title } = attributes;
const isSelected = context?.selectedTab === id;
const classes = classnames( 'wp-block-woocommerce-product-tab__content', {
'is-selected': isSelected,
} );
return (
<div { ...blockProps }>
<TabButton
id={ id }
className={ isSelected ? 'is-selected' : undefined }
>
{ title }
</TabButton>
<div className={ classes }>
<InnerBlocks templateLock="all" />
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { Edit } from './edit';
const { name } = metadata;
export { metadata, name };
export const settings = {
example: {},
edit: Edit,
};
export const init = () => initBlock( { name, metadata, settings } );

View File

@ -0,0 +1,5 @@
.wp-block-woocommerce-product-tab__content {
&:not(.is-selected) {
display: none;
}
}

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import { Button, Fill } from '@wordpress/components';
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { TABS_SLOT_NAME } from '../tabs/constants';
import { TabsFillProps } from '../tabs';
export function TabButton( {
children,
className,
id,
}: {
children: string | JSX.Element;
className?: string;
id: string;
} ) {
const classes = classnames(
'wp-block-woocommerce-product-tab__button',
className
);
return (
<Fill name={ TABS_SLOT_NAME }>
{ ( fillProps: TabsFillProps ) => {
const { onClick } = fillProps;
return (
<Button
key={ id }
className={ classes }
onClick={ () => onClick( id ) }
>
{ children }
</Button>
);
} }
</Fill>
);
}

View File

@ -0,0 +1 @@
export const TABS_SLOT_NAME = 'woocommerce_product_tabs';

View File

@ -0,0 +1 @@
export * from './tabs';

View File

@ -0,0 +1,17 @@
.woocommerce-product-tabs {
display: flex;
justify-content: center;
.components-button {
padding: $gap-smaller 0 20px 0;
margin-left: $gap;
margin-right: $gap;
border-bottom: 3.5px solid transparent;
border-radius: 0;
height: auto;
&.is-selected {
border-color: var(--wp-admin-theme-color);
}
}
}

View File

@ -0,0 +1,84 @@
/**
* External dependencies
*/
import {
createElement,
Fragment,
useEffect,
useState,
} from '@wordpress/element';
import { ReactElement } from 'react';
import { Slot } from '@wordpress/components';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { navigateTo, getNewPath, getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { TABS_SLOT_NAME } from './constants';
type TabsProps = {
onChange: ( tabId: string | null ) => void;
};
export type TabsFillProps = {
onClick: ( tabId: string ) => void;
};
export function Tabs( { onChange = () => {} }: TabsProps ) {
const [ selected, setSelected ] = useState< string | null >( null );
const query = getQuery() as Record< string, string >;
function onClick( tabId: string ) {
window.document.documentElement.scrollTop = 0;
navigateTo( {
url: getNewPath( { tab: tabId } ),
} );
}
useEffect( () => {
onChange( selected );
}, [ selected ] );
useEffect( () => {
if ( query.tab ) {
setSelected( query.tab );
}
}, [ query.tab ] );
function maybeSetSelected( fills: readonly ( readonly ReactElement[] )[] ) {
if ( selected ) {
return;
}
for ( let i = 0; i < fills.length; i++ ) {
if ( fills[ i ][ 0 ].props.disabled ) {
continue;
}
// Remove the `.$` prefix on keys. E.g., .$key => key
const tabId = fills[ i ][ 0 ].key?.toString().slice( 2 ) || null;
setSelected( tabId );
return;
}
}
return (
<div className="woocommerce-product-tabs">
<Slot
fillProps={
{
onClick,
} as TabsFillProps
}
name={ TABS_SLOT_NAME }
>
{ ( fills ) => {
maybeSetSelected( fills );
return <>{ fills }</>;
} }
</Slot>
</div>
);
}

View File

@ -6,3 +6,5 @@
@import 'components/header/style.scss'; @import 'components/header/style.scss';
@import 'components/block-editor/style.scss'; @import 'components/block-editor/style.scss';
@import 'components/section/style.scss'; @import 'components/section/style.scss';
@import 'components/tab/style.scss';
@import 'components/tabs/style.scss';

View File

@ -1,12 +1,25 @@
/** /**
* External dependencies * External dependencies
*/ */
import { BlockConfiguration, registerBlockType } from '@wordpress/blocks'; import {
BlockConfiguration,
BlockEditProps,
registerBlockType,
} from '@wordpress/blocks';
import { ComponentType } from 'react';
type BlockRepresentation = { type BlockRepresentation = {
name: string; name: string;
metadata: BlockConfiguration; metadata: BlockConfiguration;
settings: Partial< BlockConfiguration >; settings: Partial< Omit< BlockConfiguration, 'edit' > > & {
readonly edit?:
| ComponentType<
BlockEditProps< object > & {
context?: Record< string, unknown >;
}
>
| undefined;
};
}; };
/** /**

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add tabs and sections placeholders in product blocks template

View File

@ -368,9 +368,61 @@ class WC_Post_Types {
'rest_namespace' => 'wp/v3', 'rest_namespace' => 'wp/v3',
'template' => array( 'template' => array(
array( array(
'woocommerce/product-name', 'woocommerce/product-tab',
array( array(
'name' => 'Product name', 'id' => 'general',
'title' => __( 'General', 'woocommerce' ),
),
array(
array(
'woocommerce/product-section',
array(
'title' => __( 'Basic details', 'woocommerce' ),
'description' => __( 'This info will be displayed on the product page, category pages, social media, and search results.', 'woocommerce' ),
),
array(
array(
'woocommerce/product-name',
array(
'name' => 'Product name',
),
),
),
),
),
),
array(
'woocommerce/product-tab',
array(
'id' => 'pricing',
'title' => __( 'Pricing', 'woocommerce' ),
),
),
array(
'woocommerce/product-tab',
array(
'id' => 'inventory',
'title' => __( 'Inventory', 'woocommerce' ),
),
),
array(
'woocommerce/product-tab',
array(
'id' => 'shipping',
'title' => __( 'Shipping', 'woocommerce' ),
),
array(
array(
'woocommerce/product-section',
array(
'title' => __( 'Shipping section', 'woocommerce' ),
),
array(
array(
'core/image',
),
),
),
), ),
), ),
), ),

View File

@ -1371,6 +1371,7 @@ importers:
'@woocommerce/data': workspace:^4.1.0 '@woocommerce/data': workspace:^4.1.0
'@woocommerce/eslint-plugin': workspace:* '@woocommerce/eslint-plugin': workspace:*
'@woocommerce/internal-style-build': workspace:* '@woocommerce/internal-style-build': workspace:*
'@woocommerce/navigation': workspace:^8.1.0
'@woocommerce/number': workspace:* '@woocommerce/number': workspace:*
'@woocommerce/tracks': workspace:^1.3.0 '@woocommerce/tracks': workspace:^1.3.0
'@wordpress/block-editor': ^9.8.0 '@wordpress/block-editor': ^9.8.0
@ -1415,6 +1416,7 @@ importers:
'@woocommerce/components': link:../components '@woocommerce/components': link:../components
'@woocommerce/currency': link:../currency '@woocommerce/currency': link:../currency
'@woocommerce/data': link:../data '@woocommerce/data': link:../data
'@woocommerce/navigation': link:../navigation
'@woocommerce/number': link:../number '@woocommerce/number': link:../number
'@woocommerce/tracks': link:../tracks '@woocommerce/tracks': link:../tracks
'@wordpress/block-editor': 9.8.0_vcke6catv4iqpjdw24uwvlzyyi '@wordpress/block-editor': 9.8.0_vcke6catv4iqpjdw24uwvlzyyi
@ -15314,7 +15316,7 @@ packages:
'@wordpress/rich-text': 5.17.0_react@17.0.2 '@wordpress/rich-text': 5.17.0_react@17.0.2
'@wordpress/shortcode': 3.28.0 '@wordpress/shortcode': 3.28.0
'@wordpress/style-engine': 1.2.0 '@wordpress/style-engine': 1.2.0
'@wordpress/token-list': 2.19.0 '@wordpress/token-list': 2.28.0
'@wordpress/url': 3.29.0 '@wordpress/url': 3.29.0
'@wordpress/warning': 2.28.0 '@wordpress/warning': 2.28.0
'@wordpress/wordcount': 3.28.0 '@wordpress/wordcount': 3.28.0
@ -33587,7 +33589,7 @@ packages:
postcss: 8.4.12 postcss: 8.4.12
schema-utils: 3.1.1 schema-utils: 3.1.1
semver: 7.3.8 semver: 7.3.8
webpack: 5.70.0 webpack: 5.70.0_webpack-cli@3.3.12
/postcss-loader/6.2.1_wn4p5kzkgq2ohl66pfawxjf2x4: /postcss-loader/6.2.1_wn4p5kzkgq2ohl66pfawxjf2x4:
resolution: {integrity: sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==} resolution: {integrity: sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==}
@ -35003,7 +35005,7 @@ packages:
is-touch-device: 1.0.1 is-touch-device: 1.0.1
lodash: 4.17.21 lodash: 4.17.21
moment: 2.29.4 moment: 2.29.4
object.assign: 4.1.2 object.assign: 4.1.4
object.values: 1.1.5 object.values: 1.1.5
prop-types: 15.8.1 prop-types: 15.8.1
raf: 3.4.1 raf: 3.4.1