Enhancement editor loading speed (#47425)

* Lazy load the PluginArea and the ModalEditor

* Remove repeated product request when editing a specific product

* Fix linter errors

* Add changelog files

* Fix linter errors

* Refactor the block editor to remove some extra rerenders

* Defer the publish button processing

* Defer the tab content render 500ms to reduce the total blocking time

* Fix linter errors

* Fix unit test and tabs unexpected rerender

* Fix linter errors

* Reduce the defered time to 300ms

* Fix get product url when the product has been duplicated since the new copy does not have permalink

* Fix the invalid unregistration of wc-admin-more-menu in the product page

* Fix compilation errors
This commit is contained in:
Maikel Perez 2024-05-24 08:39:53 -04:00 committed by GitHub
parent 756bb8ccfa
commit 4ddfd43864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 339 additions and 184 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Enhancement editor loading speed

View File

@ -3,7 +3,7 @@
*/ */
import { InnerBlocks } from '@wordpress/block-editor'; import { InnerBlocks } from '@wordpress/block-editor';
import classnames from 'classnames'; import classnames from 'classnames';
import { createElement } from '@wordpress/element'; import { createElement, useEffect, useState } from '@wordpress/element';
import type { BlockAttributes } from '@wordpress/blocks'; import type { BlockAttributes } from '@wordpress/blocks';
import { useWooBlockProps } from '@woocommerce/block-templates'; import { useWooBlockProps } from '@woocommerce/block-templates';
@ -25,21 +25,30 @@ export function TabBlockEdit( {
context, context,
}: ProductEditorBlockEditProps< TabBlockAttributes > ) { }: ProductEditorBlockEditProps< TabBlockAttributes > ) {
const blockProps = useWooBlockProps( attributes ); const blockProps = useWooBlockProps( attributes );
const { const { id, title, _templateBlockOrder: order, isSelected } = attributes;
id,
title,
_templateBlockOrder: order,
isSelected: contextIsSelected,
} = attributes;
const isSelected = context.selectedTab === id;
if ( isSelected !== contextIsSelected ) {
setAttributes( { isSelected } );
}
const classes = classnames( 'wp-block-woocommerce-product-tab__content', { const classes = classnames( 'wp-block-woocommerce-product-tab__content', {
'is-selected': isSelected, 'is-selected': isSelected,
} ); } );
const [ canRenderChildren, setCanRenderChildren ] = useState( false );
useEffect( () => {
if ( ! context.selectedTab ) return;
const isSelectedInContext = context.selectedTab === id;
setAttributes( { isSelected: isSelectedInContext } );
if ( isSelectedInContext ) {
setCanRenderChildren( true );
return;
}
const timeoutId = setTimeout( setCanRenderChildren, 300, true );
return () => clearTimeout( timeoutId );
}, [ context.selectedTab, id ] );
return ( return (
<div { ...blockProps }> <div { ...blockProps }>
<TabButton id={ id } selected={ isSelected } order={ order }> <TabButton id={ id } selected={ isSelected } order={ order }>
@ -51,9 +60,11 @@ export function TabBlockEdit( {
role="tabpanel" role="tabpanel"
className={ classes } className={ classes }
> >
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ } { canRenderChildren && (
{ /* @ts-ignore Content only template locking does exist for this property. */ } /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
/* @ts-ignore Content only template locking does exist for this property. */
<InnerBlocks templateLock="contentOnly" /> <InnerBlocks templateLock="contentOnly" />
) }
</div> </div>
</div> </div>
); );

View File

@ -8,10 +8,11 @@ import {
useLayoutEffect, useLayoutEffect,
useEffect, useEffect,
useState, useState,
lazy,
Suspense,
} from '@wordpress/element'; } from '@wordpress/element';
import { useDispatch, useSelect, select as WPSelect } from '@wordpress/data'; import { dispatch, select, useSelect } from '@wordpress/data';
import { uploadMedia } from '@wordpress/media-utils'; import { uploadMedia } from '@wordpress/media-utils';
import { PluginArea } from '@wordpress/plugins';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useLayoutTemplate } from '@woocommerce/block-templates'; import { useLayoutTemplate } from '@woocommerce/block-templates';
import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
@ -46,12 +47,23 @@ import { useConfirmUnsavedProductChanges } from '../../hooks/use-confirm-unsaved
import { useProductTemplate } from '../../hooks/use-product-template'; import { useProductTemplate } from '../../hooks/use-product-template';
import { PostTypeContext } from '../../contexts/post-type-context'; import { PostTypeContext } from '../../contexts/post-type-context';
import { store as productEditorUiStore } from '../../store/product-editor-ui'; import { store as productEditorUiStore } from '../../store/product-editor-ui';
import { ModalEditor } from '../modal-editor';
import { ProductEditorSettings } from '../editor'; import { ProductEditorSettings } from '../editor';
import { BlockEditorProps } from './types'; import { BlockEditorProps } from './types';
import { ProductTemplate } from '../../types'; import { ProductTemplate } from '../../types';
import { LoadingState } from './loading-state'; import { LoadingState } from './loading-state';
const PluginArea = lazy( () =>
import( '@wordpress/plugins' ).then( ( module ) => ( {
default: module.PluginArea,
} ) )
);
const ModalEditor = lazy( () =>
import( '../modal-editor' ).then( ( module ) => ( {
default: module.ModalEditor,
} ) )
);
function getLayoutTemplateId( function getLayoutTemplateId(
productTemplate: ProductTemplate | undefined, productTemplate: ProductTemplate | undefined,
postType: string postType: string
@ -76,11 +88,6 @@ export function BlockEditor( {
}: BlockEditorProps ) { }: BlockEditorProps ) {
useConfirmUnsavedProductChanges( postType ); useConfirmUnsavedProductChanges( postType );
const canUserCreateMedia = useSelect( ( select: typeof WPSelect ) => {
const { canUser } = select( 'core' );
return canUser( 'create', 'media', '' ) !== false;
}, [] );
/** /**
* Fire wp-pin-menu event once to trigger the pinning of the menu. * Fire wp-pin-menu event once to trigger the pinning of the menu.
* That can be necessary since wpwrap's height wasn't being recalculated after the skeleton * That can be necessary since wpwrap's height wasn't being recalculated after the skeleton
@ -94,10 +101,9 @@ export function BlockEditor( {
return () => window.removeEventListener( 'scroll', wpPinMenuEvent ); return () => window.removeEventListener( 'scroll', wpPinMenuEvent );
}, [] ); }, [] );
// @ts-expect-error Type definitions are missing
const { registerShortcut } = useDispatch( keyboardShortcutsStore );
useEffect( () => { useEffect( () => {
// @ts-expect-error Type definitions are missing
const { registerShortcut } = dispatch( keyboardShortcutsStore );
if ( registerShortcut ) { if ( registerShortcut ) {
registerShortcut( { registerShortcut( {
name: 'core/editor/save', name: 'core/editor/save',
@ -109,7 +115,7 @@ export function BlockEditor( {
}, },
} ); } );
} }
}, [ registerShortcut ] ); }, [] );
const [ settingsGlobal, setSettingsGlobal ] = useState< const [ settingsGlobal, setSettingsGlobal ] = useState<
Partial< ProductEditorSettings > | undefined Partial< ProductEditorSettings > | undefined
@ -140,6 +146,9 @@ export function BlockEditor( {
return undefined; return undefined;
} }
const canUserCreateMedia =
select( 'core' ).canUser( 'create', 'media', '' ) !== false;
const mediaSettings = canUserCreateMedia const mediaSettings = canUserCreateMedia
? { ? {
mediaUpload( { mediaUpload( {
@ -165,7 +174,7 @@ export function BlockEditor( {
...mediaSettings, ...mediaSettings,
templateLock: 'all', templateLock: 'all',
}; };
}, [ settingsGlobal, canUserCreateMedia ] ); }, [ settingsGlobal ] );
const { editedRecord: product } = useEntityRecord< Product >( const { editedRecord: product } = useEntityRecord< Product >(
'postType', 'postType',
@ -175,10 +184,14 @@ export function BlockEditor( {
{ enabled: productId !== -1 } { enabled: productId !== -1 }
); );
const productTemplateId = product?.meta_data?.find( const productTemplateId = useMemo(
() =>
product?.meta_data?.find(
( metaEntry: { key: string } ) => ( metaEntry: { key: string } ) =>
metaEntry.key === '_product_template_id' metaEntry.key === '_product_template_id'
)?.value; )?.value,
[ product?.meta_data ]
);
const { productTemplate } = useProductTemplate( const { productTemplate } = useProductTemplate(
productTemplateId, productTemplateId,
@ -196,8 +209,6 @@ export function BlockEditor( {
{ id: productId !== -1 ? productId : 0 } { id: productId !== -1 ? productId : 0 }
); );
const { updateEditorSettings } = useDispatch( 'core/editor' );
const isEditorLoading = const isEditorLoading =
! settings || ! settings ||
! layoutTemplate || ! layoutTemplate ||
@ -217,7 +228,7 @@ export function BlockEditor( {
onChange( blockInstances, {} ); onChange( blockInstances, {} );
updateEditorSettings( { dispatch( 'core/editor' ).updateEditorSettings( {
...settings, ...settings,
productTemplate, productTemplate,
} as Partial< ProductEditorSettings > ); } as Partial< ProductEditorSettings > );
@ -232,21 +243,31 @@ export function BlockEditor( {
// the blocks by calling onChange. // the blocks by calling onChange.
// //
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ layoutTemplate, settings, productTemplate, productId ] ); }, [ isEditorLoading, productId ] );
// Check if the Modal editor is open from the store. // Check if the Modal editor is open from the store.
const isModalEditorOpen = useSelect( ( select ) => { const isModalEditorOpen = useSelect( ( selectCore ) => {
return select( productEditorUiStore ).isModalEditorOpen(); return selectCore( productEditorUiStore ).isModalEditorOpen();
}, [] ); }, [] );
const { closeModalEditor } = useDispatch( productEditorUiStore ); if ( isEditorLoading ) {
return (
<div className="woocommerce-product-block-editor">
<LoadingState />
</div>
);
}
if ( isModalEditorOpen ) { if ( isModalEditorOpen ) {
return ( return (
<Suspense fallback={ null }>
<ModalEditor <ModalEditor
onClose={ closeModalEditor } onClose={
dispatch( productEditorUiStore ).closeModalEditor
}
title={ __( 'Edit description', 'woocommerce' ) } title={ __( 'Edit description', 'woocommerce' ) }
/> />
</Suspense>
); );
} }
@ -265,17 +286,15 @@ export function BlockEditor( {
<BlockEditorKeyboardShortcuts.Register /> <BlockEditorKeyboardShortcuts.Register />
<BlockTools> <BlockTools>
<ObserveTyping> <ObserveTyping>
{ isEditorLoading ? (
<LoadingState />
) : (
<BlockList className="woocommerce-product-block-editor__block-list" /> <BlockList className="woocommerce-product-block-editor__block-list" />
) }
</ObserveTyping> </ObserveTyping>
</BlockTools> </BlockTools>
{ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ } { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ }
<PostTypeContext.Provider value={ context.postType! }> <PostTypeContext.Provider value={ context.postType! }>
<Suspense fallback={ null }>
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
<PluginArea scope="woocommerce-product-block-editor" /> <PluginArea scope="woocommerce-product-block-editor" />
</Suspense>
</PostTypeContext.Provider> </PostTypeContext.Provider>
</BlockEditorProvider> </BlockEditorProvider>
</BlockContextProvider> </BlockContextProvider>

View File

@ -37,14 +37,12 @@ import { EditorProps } from './types';
import { store as productEditorUiStore } from '../../store/product-editor-ui'; import { store as productEditorUiStore } from '../../store/product-editor-ui';
import { PrepublishPanel } from '../prepublish-panel/prepublish-panel'; import { PrepublishPanel } from '../prepublish-panel/prepublish-panel';
export function Editor( { product, productType = 'product' }: EditorProps ) { export function Editor( { productId, postType = 'product' }: EditorProps ) {
const [ isEditorLoading, setIsEditorLoading ] = useState( true ); const [ isEditorLoading, setIsEditorLoading ] = useState( true );
const [ selectedTab, setSelectedTab ] = useState< string | null >( null ); const [ selectedTab, setSelectedTab ] = useState< string | null >( null );
const updatedLayoutContext = useExtendLayout( 'product-block-editor' ); const updatedLayoutContext = useExtendLayout( 'product-block-editor' );
const productId = product?.id || -1;
// Check if the prepublish sidebar is open from the store. // Check if the prepublish sidebar is open from the store.
const isPrepublishPanelOpen = useSelect( ( select ) => { const isPrepublishPanelOpen = useSelect( ( select ) => {
return select( productEditorUiStore ).isPrepublishPanelOpen(); return select( productEditorUiStore ).isPrepublishPanelOpen();
@ -55,11 +53,14 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
<StrictMode> <StrictMode>
<EntityProvider <EntityProvider
kind="postType" kind="postType"
type={ productType } type={ postType }
id={ productId } id={ productId }
> >
<ShortcutProvider> <ShortcutProvider>
<ValidationProvider initialValue={ product }> <ValidationProvider
postType={ postType }
productId={ productId }
>
<EditorLoadingContext.Provider <EditorLoadingContext.Provider
value={ isEditorLoading } value={ isEditorLoading }
> >
@ -67,17 +68,17 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
header={ header={
<Header <Header
onTabSelect={ setSelectedTab } onTabSelect={ setSelectedTab }
productType={ productType } productType={ postType }
/> />
} }
content={ content={
<> <>
<BlockEditor <BlockEditor
postType={ productType } postType={ postType }
productId={ productId } productId={ productId }
context={ { context={ {
selectedTab, selectedTab,
postType: productType, postType,
postId: productId, postId: productId,
} } } }
setIsEditorLoading={ setIsEditorLoading={
@ -89,7 +90,7 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
actions={ actions={
isPrepublishPanelOpen && ( isPrepublishPanelOpen && (
<PrepublishPanel <PrepublishPanel
productType={ productType } productType={ postType }
/> />
) )
} }

View File

@ -1,7 +1,6 @@
/** /**
* External dependencies * External dependencies
*/ */
import { Product } from '@woocommerce/data';
import { import {
EditorSettings, EditorSettings,
EditorBlockListSettings, EditorBlockListSettings,
@ -29,6 +28,6 @@ export type ProductEditorSettings = Partial<
}; };
export type EditorProps = { export type EditorProps = {
product?: Pick< Product, 'id' | 'type' > | null; productId: number;
productType?: string; postType?: string;
}; };

View File

@ -9,6 +9,8 @@ import {
useContext, useContext,
useEffect, useEffect,
Fragment, Fragment,
lazy,
Suspense,
} from '@wordpress/element'; } from '@wordpress/element';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Button, Tooltip } from '@wordpress/components'; import { Button, Tooltip } from '@wordpress/components';
@ -31,13 +33,18 @@ import { getHeaderTitle } from '../../utils';
import { MoreMenu } from './more-menu'; import { MoreMenu } from './more-menu';
import { PreviewButton } from './preview-button'; import { PreviewButton } from './preview-button';
import { SaveDraftButton } from './save-draft-button'; import { SaveDraftButton } from './save-draft-button';
import { PublishButton } from './publish-button';
import { LoadingState } from './loading-state'; import { LoadingState } from './loading-state';
import { Tabs } from '../tabs'; import { Tabs } from '../tabs';
import { HEADER_PINNED_ITEMS_SCOPE, TRACKS_SOURCE } from '../../constants'; import { HEADER_PINNED_ITEMS_SCOPE, TRACKS_SOURCE } from '../../constants';
import { useShowPrepublishChecks } from '../../hooks/use-show-prepublish-checks'; import { useShowPrepublishChecks } from '../../hooks/use-show-prepublish-checks';
import { HeaderProps, Image } from './types'; import { HeaderProps, Image } from './types';
const PublishButton = lazy( () =>
import( './publish-button' ).then( ( module ) => ( {
default: module.PublishButton,
} ) )
);
const RETURN_TO_MAIN_PRODUCT = __( const RETURN_TO_MAIN_PRODUCT = __(
'Return to the main product', 'Return to the main product',
'woocommerce' 'woocommerce'
@ -254,11 +261,13 @@ export function Header( {
productStatus={ lastPersistedProduct?.status } productStatus={ lastPersistedProduct?.status }
/> />
<Suspense fallback={ null }>
<PublishButton <PublishButton
productType={ productType } productType={ productType }
isPrePublishPanelVisible={ showPrepublishChecks } isPrePublishPanelVisible={ showPrepublishChecks }
isMenuButton isMenuButton
/> />
</Suspense>
<WooHeaderItem.Slot name="product" /> <WooHeaderItem.Slot name="product" />
<PinnedItems.Slot scope={ HEADER_PINNED_ITEMS_SCOPE } /> <PinnedItems.Slot scope={ HEADER_PINNED_ITEMS_SCOPE } />

View File

@ -26,6 +26,8 @@ export function PostPublishSection( { postType }: PostPublishSectionProps ) {
const productURL = getProductURL( isScheduled ); const productURL = getProductURL( isScheduled );
if ( ! productURL ) return null;
const CopyButton = ( { text, onCopy, children }: CopyButtonProps ) => { const CopyButton = ( { text, onCopy, children }: CopyButtonProps ) => {
const ref = useCopyToClipboard( const ref = useCopyToClipboard(
text, text,

View File

@ -1,12 +1,17 @@
/** /**
* External dependencies * External dependencies
*/ */
import { createElement, useEffect, useState } from '@wordpress/element'; import {
import { ReactElement } from 'react'; createElement,
useEffect,
useState,
Fragment,
} from '@wordpress/element';
import { ReactElement, useMemo } from 'react';
import { NavigableMenu, Slot } from '@wordpress/components'; import { NavigableMenu, Slot } from '@wordpress/components';
import { Product } from '@woocommerce/data'; import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
import { useSelect } from '@wordpress/data'; import { select } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data'; import { useEntityProp } from '@wordpress/core-data';
// 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.
@ -17,7 +22,6 @@ import { navigateTo, getNewPath, getQuery } from '@woocommerce/navigation';
* Internal dependencies * Internal dependencies
*/ */
import { getTabTracksData } from './utils/get-tab-tracks-data'; import { getTabTracksData } from './utils/get-tab-tracks-data';
import { sortFillsByOrder } from '../../utils';
import { TABS_SLOT_NAME } from './constants'; import { TABS_SLOT_NAME } from './constants';
type TabsProps = { type TabsProps = {
@ -28,6 +32,38 @@ export type TabsFillProps = {
onClick: ( tabId: string ) => void; onClick: ( tabId: string ) => void;
}; };
function TabFills( {
fills,
onDefaultSelection,
}: {
fills: readonly ( readonly ReactElement[] )[];
onDefaultSelection( tabId: string ): void;
} ) {
const sortedFills = useMemo(
function sortFillsByOrder() {
return [ ...fills ].sort(
( [ { props: a } ], [ { props: b } ] ) => a.order - b.order
);
},
[ fills ]
);
useEffect( () => {
for ( let i = 0; i < sortedFills.length; i++ ) {
const [ { props } ] = fills[ i ];
if ( ! props.disabled ) {
const tabId = props.children?.key;
if ( tabId ) {
onDefaultSelection( tabId );
}
return;
}
}
}, [ sortedFills ] );
return <>{ sortedFills }</>;
}
export function Tabs( { onChange = () => {} }: TabsProps ) { export function Tabs( { onChange = () => {} }: TabsProps ) {
const [ selected, setSelected ] = useState< string | null >( null ); const [ selected, setSelected ] = useState< string | null >( null );
const query = getQuery() as Record< string, string >; const query = getQuery() as Record< string, string >;
@ -36,41 +72,14 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
'product', 'product',
'id' 'id'
); );
const product: Product = useSelect( ( select ) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
select( 'core' ).getEditedEntityRecord(
'postType',
'product',
productId
)
);
useEffect( () => {
onChange( selected );
}, [ selected ] );
useEffect( () => { useEffect( () => {
if ( query.tab ) { if ( query.tab ) {
setSelected( query.tab ); setSelected( query.tab );
onChange( query.tab );
} }
}, [ 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;
}
const tabId = fills[ i ][ 0 ].props?.children?.key || null;
setSelected( tabId );
return;
}
}
function selectTabOnNavigate( function selectTabOnNavigate(
_childIndex: number, _childIndex: number,
child: HTMLButtonElement child: HTMLButtonElement
@ -78,6 +87,21 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
child.click(); child.click();
} }
function renderFills( fills: readonly ( readonly ReactElement[] )[] ) {
return (
<TabFills
fills={ fills }
onDefaultSelection={ ( tabId ) => {
if ( selected ) {
return;
}
setSelected( tabId );
onChange( tabId );
} }
/>
);
}
return ( return (
<NavigableMenu <NavigableMenu
role="tablist" role="tablist"
@ -92,6 +116,17 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
navigateTo( { navigateTo( {
url: getNewPath( { tab: tabId } ), url: getNewPath( { tab: tabId } ),
} ); } );
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { getEditedEntityRecord } = select( 'core' );
const product: Product = getEditedEntityRecord(
'postType',
'product',
productId
);
recordEvent( recordEvent(
'product_tab_click', 'product_tab_click',
getTabTracksData( tabId, product ) getTabTracksData( tabId, product )
@ -101,14 +136,7 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
} }
name={ TABS_SLOT_NAME } name={ TABS_SLOT_NAME }
> >
{ ( fills ) => { { renderFills }
if ( ! sortFillsByOrder ) {
return null;
}
maybeSetSelected( fills );
return sortFillsByOrder( fills );
} }
</Slot> </Slot>
</NavigableMenu> </NavigableMenu>
); );

View File

@ -1,17 +1,20 @@
/** /**
* External dependencies * External dependencies
*/ */
import { render, fireEvent } from '@testing-library/react'; import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { getQuery, navigateTo } from '@woocommerce/navigation'; import { getQuery, navigateTo } from '@woocommerce/navigation';
import React, { createElement } from 'react';
import { SlotFillProvider } from '@wordpress/components'; import { SlotFillProvider } from '@wordpress/components';
import { useState } from '@wordpress/element'; import { useState, createElement } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { Tabs } from '../'; import { Tabs } from '../';
import { TabBlockEdit as Tab } from '../../../blocks/generic/tab/edit'; import {
TabBlockEdit as Tab,
TabBlockAttributes,
} from '../../../blocks/generic/tab/edit';
jest.mock( '@woocommerce/block-templates', () => ( { jest.mock( '@woocommerce/block-templates', () => ( {
...jest.requireActual( '@woocommerce/block-templates' ), ...jest.requireActual( '@woocommerce/block-templates' ),
@ -40,6 +43,16 @@ function MockTabs( { onChange = jest.fn() } ) {
selectedTab: selected, selectedTab: selected,
}; };
function getAttributes( id: string ) {
return function setAttributes( {
isSelected,
}: Partial< TabBlockAttributes > ) {
if ( isSelected ) {
setSelected( id );
}
};
}
return ( return (
<SlotFillProvider> <SlotFillProvider>
<Tabs <Tabs
@ -50,27 +63,45 @@ function MockTabs( { onChange = jest.fn() } ) {
/> />
<Tab <Tab
{ ...blockProps } { ...blockProps }
attributes={ { id: 'test1', title: 'Test button 1', order: 1 } } attributes={ {
id: 'test1',
title: 'Test button 1',
order: 1,
isSelected: selected === 'test1',
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it // @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
context={ mockContext } context={ mockContext }
name="test1" name="test1"
setAttributes={ getAttributes( 'test1' ) }
/> />
<Tab <Tab
{ ...blockProps } { ...blockProps }
attributes={ { id: 'test2', title: 'Test button 2', order: 2 } } attributes={ {
id: 'test2',
title: 'Test button 2',
order: 2,
isSelected: selected === 'test2',
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it // @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
context={ mockContext } context={ mockContext }
name="test2" name="test2"
setAttributes={ getAttributes( 'test2' ) }
/> />
<Tab <Tab
{ ...blockProps } { ...blockProps }
attributes={ { id: 'test3', title: 'Test button 3', order: 3 } } attributes={ {
id: 'test3',
title: 'Test button 3',
order: 3,
isSelected: selected === 'test3',
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it // @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
context={ mockContext } context={ mockContext }
name="test3" name="test3"
setAttributes={ getAttributes( 'test3' ) }
/> />
</SlotFillProvider> </SlotFillProvider>
); );
@ -84,26 +115,30 @@ describe( 'Tabs', () => {
} ); } );
it( 'should render tab buttons added to the slot', () => { it( 'should render tab buttons added to the slot', () => {
const { queryByText } = render( <MockTabs /> ); render( <MockTabs /> );
expect( queryByText( 'Test button 1' ) ).toBeInTheDocument();
expect( queryByText( 'Test button 2' ) ).toBeInTheDocument(); expect( screen.queryByText( 'Test button 1' ) ).toBeInTheDocument();
expect( screen.queryByText( 'Test button 2' ) ).toBeInTheDocument();
} ); } );
it( 'should set the first tab as active initially', () => { it( 'should set the first tab as active initially', async () => {
const { queryByText } = render( <MockTabs /> ); render( <MockTabs /> );
expect( queryByText( 'Test button 1' ) ).toHaveAttribute(
expect( screen.queryByText( 'Test button 1' ) ).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
); );
expect( queryByText( 'Test button 2' ) ).toHaveAttribute(
expect( screen.queryByText( 'Test button 2' ) ).toHaveAttribute(
'aria-selected', 'aria-selected',
'false' 'false'
); );
} ); } );
it( 'should navigate to a new URL when a tab is clicked', () => { it( 'should navigate to a new URL when a tab is clicked', () => {
const { getByText } = render( <MockTabs /> ); render( <MockTabs /> );
const button = getByText( 'Test button 2' );
const button = screen.getByText( 'Test button 2' );
fireEvent.click( button ); fireEvent.click( button );
expect( navigateTo ).toHaveBeenLastCalledWith( { expect( navigateTo ).toHaveBeenLastCalledWith( {
@ -116,16 +151,16 @@ describe( 'Tabs', () => {
tab: 'test2', tab: 'test2',
} ); } );
const { getByText } = render( <MockTabs /> ); render( <MockTabs /> );
expect( getByText( 'Test button 2' ) ).toHaveAttribute( expect( screen.getByText( 'Test button 2' ) ).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
); );
} ); } );
it( 'should select the tab provided on URL change', () => { it( 'should select the tab provided on URL change', () => {
const { getByText, rerender } = render( <MockTabs /> ); const { rerender } = render( <MockTabs /> );
( getQuery as jest.Mock ).mockReturnValue( { ( getQuery as jest.Mock ).mockReturnValue( {
tab: 'test3', tab: 'test3',
@ -133,7 +168,7 @@ describe( 'Tabs', () => {
rerender( <MockTabs /> ); rerender( <MockTabs /> );
expect( getByText( 'Test button 3' ) ).toHaveAttribute( expect( screen.getByText( 'Test button 3' ) ).toHaveAttribute(
'aria-selected', 'aria-selected',
'true' 'true'
); );
@ -155,20 +190,30 @@ describe( 'Tabs', () => {
} ); } );
it( 'should add a class to the initially selected tab panel', async () => { it( 'should add a class to the initially selected tab panel', async () => {
const { getByRole } = render( <MockTabs /> ); render( <MockTabs /> );
const panel1 = getByRole( 'tabpanel', { name: 'Test button 1' } );
const panel2 = getByRole( 'tabpanel', { name: 'Test button 2' } ); const panel1 = screen.getByRole( 'tabpanel', {
name: 'Test button 1',
} );
const panel2 = screen.getByRole( 'tabpanel', {
name: 'Test button 2',
} );
expect( panel1.classList ).toContain( 'is-selected' ); expect( panel1.classList ).toContain( 'is-selected' );
expect( panel2.classList ).not.toContain( 'is-selected' ); expect( panel2.classList ).not.toContain( 'is-selected' );
} ); } );
it( 'should add a class to the newly selected tab panel', async () => { it( 'should add a class to the newly selected tab panel', async () => {
const { getByText, getByRole, rerender } = render( <MockTabs /> ); const { rerender } = render( <MockTabs /> );
const button = getByText( 'Test button 2' );
const button = screen.getByText( 'Test button 2' );
fireEvent.click( button ); fireEvent.click( button );
const panel1 = getByRole( 'tabpanel', { name: 'Test button 1' } ); const panel1 = screen.getByRole( 'tabpanel', {
const panel2 = getByRole( 'tabpanel', { name: 'Test button 2' } ); name: 'Test button 1',
} );
const panel2 = screen.getByRole( 'tabpanel', {
name: 'Test button 2',
} );
( getQuery as jest.Mock ).mockReturnValue( { ( getQuery as jest.Mock ).mockReturnValue( {
tab: 'test2', tab: 'test2',

View File

@ -19,8 +19,9 @@ export type ValidationContextProps< T > = {
validateAll( newData?: Partial< T > ): Promise< ValidationErrors >; validateAll( newData?: Partial< T > ): Promise< ValidationErrors >;
}; };
export type ValidationProviderProps< T > = { export type ValidationProviderProps = {
initialValue?: T; postType: string;
productId: number;
}; };
export type ValidationError = string | undefined; export type ValidationError = string | undefined;

View File

@ -1,8 +1,9 @@
/** /**
* External dependencies * External dependencies
*/ */
import type { PropsWithChildren } from 'react';
import { useEntityRecord } from '@wordpress/core-data';
import { createElement, useRef, useState } from '@wordpress/element'; import { createElement, useRef, useState } from '@wordpress/element';
import { PropsWithChildren } from 'react';
/** /**
* Internal dependencies * Internal dependencies
@ -17,12 +18,18 @@ import { ValidationContext } from './validation-context';
import { findFirstInvalidElement } from './helpers'; import { findFirstInvalidElement } from './helpers';
export function ValidationProvider< T >( { export function ValidationProvider< T >( {
initialValue, postType,
productId,
children, children,
}: PropsWithChildren< ValidationProviderProps< T > > ) { }: PropsWithChildren< ValidationProviderProps > ) {
const validatorsRef = useRef< Record< string, Validator< T > > >( {} ); const validatorsRef = useRef< Record< string, Validator< T > > >( {} );
const fieldRefs = useRef< Record< string, HTMLElement > >( {} ); const fieldRefs = useRef< Record< string, HTMLElement > >( {} );
const [ errors, setErrors ] = useState< ValidationErrors >( {} ); const [ errors, setErrors ] = useState< ValidationErrors >( {} );
const { record: initialValue } = useEntityRecord< T >(
'postType',
postType,
productId
);
function registerValidator( function registerValidator(
validatorId: string, validatorId: string,

View File

@ -2,7 +2,7 @@
* External dependencies * External dependencies
*/ */
import { useEntityProp } from '@wordpress/core-data'; import { useEntityProp } from '@wordpress/core-data';
import { useCallback } from 'react'; import { useCallback } from '@wordpress/element';
export function useProductURL( productType: string ) { export function useProductURL( productType: string ) {
const [ permalink ] = useEntityProp< string >( const [ permalink ] = useEntityProp< string >(
@ -10,15 +10,19 @@ export function useProductURL( productType: string ) {
productType, productType,
'permalink' 'permalink'
); );
const getProductURL = useCallback( const getProductURL = useCallback(
( isPreview: boolean ) => { ( isPreview: boolean ) => {
const productURL = new URL( permalink ) as URL | undefined; if ( ! permalink ) return undefined;
const productURL = new URL( permalink );
if ( isPreview ) { if ( isPreview ) {
productURL?.searchParams.append( 'preview', 'true' ); productURL.searchParams.append( 'preview', 'true' );
} }
return productURL?.toString() || ''; return productURL.toString();
}, },
[ permalink ] [ permalink ]
); );
return { getProductURL }; return { getProductURL };
} }

View File

@ -2,40 +2,39 @@
* External dependencies * External dependencies
*/ */
import { AUTO_DRAFT_NAME } from '@woocommerce/product-editor'; import { AUTO_DRAFT_NAME } from '@woocommerce/product-editor';
import { Product } from '@woocommerce/data'; import { type Product } from '@woocommerce/data';
import { useDispatch, resolveSelect } from '@wordpress/data'; import { dispatch } from '@wordpress/data';
import { useEffect, useState } from '@wordpress/element'; import { useEffect, useState } from '@wordpress/element';
export function useProductEntityRecord( export function useProductEntityRecord(
productId: string | undefined productId: string | undefined
): Product | undefined { ): number | undefined {
const { saveEntityRecord } = useDispatch( 'core' ); const [ id, setId ] = useState< number | undefined >( undefined );
const [ product, setProduct ] = useState< Product | undefined >(
undefined
);
useEffect( () => { useEffect( () => {
const getRecordPromise: Promise< Product > = productId if ( productId ) {
? resolveSelect( 'core' ).getEntityRecord< Product >( setId( Number.parseInt( productId, 10 ) );
return;
}
const createProductPromise = dispatch( 'core' ).saveEntityRecord(
'postType', 'postType',
'product', 'product',
Number.parseInt( productId, 10 ) {
)
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Incorrect types.
( saveEntityRecord( 'postType', 'product', {
title: AUTO_DRAFT_NAME, title: AUTO_DRAFT_NAME,
status: 'auto-draft', status: 'auto-draft',
} ) as Promise< Product > ); }
getRecordPromise ) as never as Promise< Product >;
.then( ( autoDraftProduct: Product ) => {
setProduct( autoDraftProduct ); createProductPromise
} ) .then( ( autoDraftProduct: Product ) =>
setId( autoDraftProduct.id )
)
.catch( ( e ) => { .catch( ( e ) => {
setProduct( undefined ); setId( undefined );
throw e; throw e;
} ); } );
}, [ productId ] ); }, [ productId ] );
return product; return id;
} }

View File

@ -17,6 +17,7 @@ import React, { lazy, Suspense, useContext, useEffect } from 'react';
import { registerPlugin, unregisterPlugin } from '@wordpress/plugins'; import { registerPlugin, unregisterPlugin } from '@wordpress/plugins';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { WooFooterItem } from '@woocommerce/admin-layout'; import { WooFooterItem } from '@woocommerce/admin-layout';
import { __ } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
@ -39,9 +40,7 @@ const ProductMVPFeedbackModalContainer = lazy( () =>
); );
export default function ProductPage() { export default function ProductPage() {
const { productId } = useParams(); const { productId: productIdSearchParam } = useParams();
const product = useProductEntityRecord( productId );
useEffect( () => { useEffect( () => {
document.body.classList.add( 'is-product-editor' ); document.body.classList.add( 'is-product-editor' );
@ -70,8 +69,11 @@ export default function ProductPage() {
<Suspense fallback={ <Spinner /> }> <Suspense fallback={ <Spinner /> }>
<ProductMVPFeedbackModalContainer <ProductMVPFeedbackModalContainer
productId={ productId={
productId productIdSearchParam
? parseInt( productId, 10 ) ? Number.parseInt(
productIdSearchParam,
10
)
: undefined : undefined
} }
/> />
@ -91,17 +93,17 @@ export default function ProductPage() {
return () => { return () => {
document.body.classList.remove( 'is-product-editor' ); document.body.classList.remove( 'is-product-editor' );
unregisterPlugin( 'wc-admin-more-menu' ); unregisterPlugin( 'wc-admin-product-editor' );
unregisterBlocks(); unregisterBlocks();
}; };
}, [ productId ] ); }, [ productIdSearchParam ] );
useEffect( useEffect(
function trackViewEvents() { function trackViewEvents() {
if ( productId ) { if ( productIdSearchParam ) {
recordEvent( 'product_edit_view', { recordEvent( 'product_edit_view', {
source: TRACKS_SOURCE, source: TRACKS_SOURCE,
product_id: productId, product_id: productIdSearchParam,
} ); } );
} else { } else {
recordEvent( 'product_add_view', { recordEvent( 'product_add_view', {
@ -109,12 +111,20 @@ export default function ProductPage() {
} ); } );
} }
}, },
[ productId ] [ productIdSearchParam ]
); );
const productId = useProductEntityRecord( productIdSearchParam );
if ( ! productId ) {
return ( return (
<> <div className="woocommerce-layout__loading">
<Editor product={ product } /> <Spinner
</> aria-label={ __( 'Creating the product', 'woocommerce' ) }
/>
</div>
); );
} }
return <Editor productId={ productId } />;
}

View File

@ -16,6 +16,8 @@ import { useEffect } from '@wordpress/element';
import { WooFooterItem } from '@woocommerce/admin-layout'; import { WooFooterItem } from '@woocommerce/admin-layout';
import { registerPlugin, unregisterPlugin } from '@wordpress/plugins'; import { registerPlugin, unregisterPlugin } from '@wordpress/plugins';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
@ -78,9 +80,19 @@ export default function ProductPage() {
[ productId ] [ productId ]
); );
if ( ! variation ) {
return (
<div className="woocommerce-layout__loading">
<Spinner
aria-label={ __( 'Creating the product', 'woocommerce' ) }
/>
</div>
);
}
return ( return (
<> <>
<Editor product={ variation } productType="product_variation" /> <Editor productId={ variation.id } postType="product_variation" />
<WooFooterItem order={ 0 }> <WooFooterItem order={ 0 }>
<> <>
<VariationSwitcherFooter <VariationSwitcherFooter

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Enhancement editor loading speed