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:
parent
756bb8ccfa
commit
4ddfd43864
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: enhancement
|
||||
|
||||
Enhancement editor loading speed
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import { InnerBlocks } from '@wordpress/block-editor';
|
||||
import classnames from 'classnames';
|
||||
import { createElement } from '@wordpress/element';
|
||||
import { createElement, useEffect, useState } from '@wordpress/element';
|
||||
import type { BlockAttributes } from '@wordpress/blocks';
|
||||
import { useWooBlockProps } from '@woocommerce/block-templates';
|
||||
|
||||
|
@ -25,21 +25,30 @@ export function TabBlockEdit( {
|
|||
context,
|
||||
}: ProductEditorBlockEditProps< TabBlockAttributes > ) {
|
||||
const blockProps = useWooBlockProps( attributes );
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
_templateBlockOrder: order,
|
||||
isSelected: contextIsSelected,
|
||||
} = attributes;
|
||||
const isSelected = context.selectedTab === id;
|
||||
if ( isSelected !== contextIsSelected ) {
|
||||
setAttributes( { isSelected } );
|
||||
}
|
||||
const { id, title, _templateBlockOrder: order, isSelected } = attributes;
|
||||
|
||||
const classes = classnames( 'wp-block-woocommerce-product-tab__content', {
|
||||
'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 (
|
||||
<div { ...blockProps }>
|
||||
<TabButton id={ id } selected={ isSelected } order={ order }>
|
||||
|
@ -51,9 +60,11 @@ export function TabBlockEdit( {
|
|||
role="tabpanel"
|
||||
className={ classes }
|
||||
>
|
||||
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
|
||||
{ /* @ts-ignore Content only template locking does exist for this property. */ }
|
||||
<InnerBlocks templateLock="contentOnly" />
|
||||
{ canRenderChildren && (
|
||||
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
|
||||
/* @ts-ignore Content only template locking does exist for this property. */
|
||||
<InnerBlocks templateLock="contentOnly" />
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -8,10 +8,11 @@ import {
|
|||
useLayoutEffect,
|
||||
useEffect,
|
||||
useState,
|
||||
lazy,
|
||||
Suspense,
|
||||
} 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 { PluginArea } from '@wordpress/plugins';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useLayoutTemplate } from '@woocommerce/block-templates';
|
||||
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 { PostTypeContext } from '../../contexts/post-type-context';
|
||||
import { store as productEditorUiStore } from '../../store/product-editor-ui';
|
||||
import { ModalEditor } from '../modal-editor';
|
||||
import { ProductEditorSettings } from '../editor';
|
||||
import { BlockEditorProps } from './types';
|
||||
import { ProductTemplate } from '../../types';
|
||||
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(
|
||||
productTemplate: ProductTemplate | undefined,
|
||||
postType: string
|
||||
|
@ -76,11 +88,6 @@ export function BlockEditor( {
|
|||
}: BlockEditorProps ) {
|
||||
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.
|
||||
* 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 );
|
||||
}, [] );
|
||||
|
||||
// @ts-expect-error Type definitions are missing
|
||||
const { registerShortcut } = useDispatch( keyboardShortcutsStore );
|
||||
|
||||
useEffect( () => {
|
||||
// @ts-expect-error Type definitions are missing
|
||||
const { registerShortcut } = dispatch( keyboardShortcutsStore );
|
||||
if ( registerShortcut ) {
|
||||
registerShortcut( {
|
||||
name: 'core/editor/save',
|
||||
|
@ -109,7 +115,7 @@ export function BlockEditor( {
|
|||
},
|
||||
} );
|
||||
}
|
||||
}, [ registerShortcut ] );
|
||||
}, [] );
|
||||
|
||||
const [ settingsGlobal, setSettingsGlobal ] = useState<
|
||||
Partial< ProductEditorSettings > | undefined
|
||||
|
@ -140,6 +146,9 @@ export function BlockEditor( {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const canUserCreateMedia =
|
||||
select( 'core' ).canUser( 'create', 'media', '' ) !== false;
|
||||
|
||||
const mediaSettings = canUserCreateMedia
|
||||
? {
|
||||
mediaUpload( {
|
||||
|
@ -165,7 +174,7 @@ export function BlockEditor( {
|
|||
...mediaSettings,
|
||||
templateLock: 'all',
|
||||
};
|
||||
}, [ settingsGlobal, canUserCreateMedia ] );
|
||||
}, [ settingsGlobal ] );
|
||||
|
||||
const { editedRecord: product } = useEntityRecord< Product >(
|
||||
'postType',
|
||||
|
@ -175,10 +184,14 @@ export function BlockEditor( {
|
|||
{ enabled: productId !== -1 }
|
||||
);
|
||||
|
||||
const productTemplateId = product?.meta_data?.find(
|
||||
( metaEntry: { key: string } ) =>
|
||||
metaEntry.key === '_product_template_id'
|
||||
)?.value;
|
||||
const productTemplateId = useMemo(
|
||||
() =>
|
||||
product?.meta_data?.find(
|
||||
( metaEntry: { key: string } ) =>
|
||||
metaEntry.key === '_product_template_id'
|
||||
)?.value,
|
||||
[ product?.meta_data ]
|
||||
);
|
||||
|
||||
const { productTemplate } = useProductTemplate(
|
||||
productTemplateId,
|
||||
|
@ -196,8 +209,6 @@ export function BlockEditor( {
|
|||
{ id: productId !== -1 ? productId : 0 }
|
||||
);
|
||||
|
||||
const { updateEditorSettings } = useDispatch( 'core/editor' );
|
||||
|
||||
const isEditorLoading =
|
||||
! settings ||
|
||||
! layoutTemplate ||
|
||||
|
@ -217,7 +228,7 @@ export function BlockEditor( {
|
|||
|
||||
onChange( blockInstances, {} );
|
||||
|
||||
updateEditorSettings( {
|
||||
dispatch( 'core/editor' ).updateEditorSettings( {
|
||||
...settings,
|
||||
productTemplate,
|
||||
} as Partial< ProductEditorSettings > );
|
||||
|
@ -232,21 +243,31 @@ export function BlockEditor( {
|
|||
// the blocks by calling onChange.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ layoutTemplate, settings, productTemplate, productId ] );
|
||||
}, [ isEditorLoading, productId ] );
|
||||
|
||||
// Check if the Modal editor is open from the store.
|
||||
const isModalEditorOpen = useSelect( ( select ) => {
|
||||
return select( productEditorUiStore ).isModalEditorOpen();
|
||||
const isModalEditorOpen = useSelect( ( selectCore ) => {
|
||||
return selectCore( productEditorUiStore ).isModalEditorOpen();
|
||||
}, [] );
|
||||
|
||||
const { closeModalEditor } = useDispatch( productEditorUiStore );
|
||||
if ( isEditorLoading ) {
|
||||
return (
|
||||
<div className="woocommerce-product-block-editor">
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ( isModalEditorOpen ) {
|
||||
return (
|
||||
<ModalEditor
|
||||
onClose={ closeModalEditor }
|
||||
title={ __( 'Edit description', 'woocommerce' ) }
|
||||
/>
|
||||
<Suspense fallback={ null }>
|
||||
<ModalEditor
|
||||
onClose={
|
||||
dispatch( productEditorUiStore ).closeModalEditor
|
||||
}
|
||||
title={ __( 'Edit description', 'woocommerce' ) }
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -265,17 +286,15 @@ export function BlockEditor( {
|
|||
<BlockEditorKeyboardShortcuts.Register />
|
||||
<BlockTools>
|
||||
<ObserveTyping>
|
||||
{ isEditorLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<BlockList className="woocommerce-product-block-editor__block-list" />
|
||||
) }
|
||||
<BlockList className="woocommerce-product-block-editor__block-list" />
|
||||
</ObserveTyping>
|
||||
</BlockTools>
|
||||
{ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ }
|
||||
<PostTypeContext.Provider value={ context.postType! }>
|
||||
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
|
||||
<PluginArea scope="woocommerce-product-block-editor" />
|
||||
<Suspense fallback={ null }>
|
||||
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
|
||||
<PluginArea scope="woocommerce-product-block-editor" />
|
||||
</Suspense>
|
||||
</PostTypeContext.Provider>
|
||||
</BlockEditorProvider>
|
||||
</BlockContextProvider>
|
||||
|
|
|
@ -37,14 +37,12 @@ import { EditorProps } from './types';
|
|||
import { store as productEditorUiStore } from '../../store/product-editor-ui';
|
||||
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 [ selectedTab, setSelectedTab ] = useState< string | null >( null );
|
||||
|
||||
const updatedLayoutContext = useExtendLayout( 'product-block-editor' );
|
||||
|
||||
const productId = product?.id || -1;
|
||||
|
||||
// Check if the prepublish sidebar is open from the store.
|
||||
const isPrepublishPanelOpen = useSelect( ( select ) => {
|
||||
return select( productEditorUiStore ).isPrepublishPanelOpen();
|
||||
|
@ -55,11 +53,14 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
|
|||
<StrictMode>
|
||||
<EntityProvider
|
||||
kind="postType"
|
||||
type={ productType }
|
||||
type={ postType }
|
||||
id={ productId }
|
||||
>
|
||||
<ShortcutProvider>
|
||||
<ValidationProvider initialValue={ product }>
|
||||
<ValidationProvider
|
||||
postType={ postType }
|
||||
productId={ productId }
|
||||
>
|
||||
<EditorLoadingContext.Provider
|
||||
value={ isEditorLoading }
|
||||
>
|
||||
|
@ -67,17 +68,17 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
|
|||
header={
|
||||
<Header
|
||||
onTabSelect={ setSelectedTab }
|
||||
productType={ productType }
|
||||
productType={ postType }
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<>
|
||||
<BlockEditor
|
||||
postType={ productType }
|
||||
postType={ postType }
|
||||
productId={ productId }
|
||||
context={ {
|
||||
selectedTab,
|
||||
postType: productType,
|
||||
postType,
|
||||
postId: productId,
|
||||
} }
|
||||
setIsEditorLoading={
|
||||
|
@ -89,7 +90,7 @@ export function Editor( { product, productType = 'product' }: EditorProps ) {
|
|||
actions={
|
||||
isPrepublishPanelOpen && (
|
||||
<PrepublishPanel
|
||||
productType={ productType }
|
||||
productType={ postType }
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Product } from '@woocommerce/data';
|
||||
import {
|
||||
EditorSettings,
|
||||
EditorBlockListSettings,
|
||||
|
@ -29,6 +28,6 @@ export type ProductEditorSettings = Partial<
|
|||
};
|
||||
|
||||
export type EditorProps = {
|
||||
product?: Pick< Product, 'id' | 'type' > | null;
|
||||
productType?: string;
|
||||
productId: number;
|
||||
postType?: string;
|
||||
};
|
||||
|
|
|
@ -9,6 +9,8 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
Fragment,
|
||||
lazy,
|
||||
Suspense,
|
||||
} from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Button, Tooltip } from '@wordpress/components';
|
||||
|
@ -31,13 +33,18 @@ import { getHeaderTitle } from '../../utils';
|
|||
import { MoreMenu } from './more-menu';
|
||||
import { PreviewButton } from './preview-button';
|
||||
import { SaveDraftButton } from './save-draft-button';
|
||||
import { PublishButton } from './publish-button';
|
||||
import { LoadingState } from './loading-state';
|
||||
import { Tabs } from '../tabs';
|
||||
import { HEADER_PINNED_ITEMS_SCOPE, TRACKS_SOURCE } from '../../constants';
|
||||
import { useShowPrepublishChecks } from '../../hooks/use-show-prepublish-checks';
|
||||
import { HeaderProps, Image } from './types';
|
||||
|
||||
const PublishButton = lazy( () =>
|
||||
import( './publish-button' ).then( ( module ) => ( {
|
||||
default: module.PublishButton,
|
||||
} ) )
|
||||
);
|
||||
|
||||
const RETURN_TO_MAIN_PRODUCT = __(
|
||||
'Return to the main product',
|
||||
'woocommerce'
|
||||
|
@ -254,11 +261,13 @@ export function Header( {
|
|||
productStatus={ lastPersistedProduct?.status }
|
||||
/>
|
||||
|
||||
<PublishButton
|
||||
productType={ productType }
|
||||
isPrePublishPanelVisible={ showPrepublishChecks }
|
||||
isMenuButton
|
||||
/>
|
||||
<Suspense fallback={ null }>
|
||||
<PublishButton
|
||||
productType={ productType }
|
||||
isPrePublishPanelVisible={ showPrepublishChecks }
|
||||
isMenuButton
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<WooHeaderItem.Slot name="product" />
|
||||
<PinnedItems.Slot scope={ HEADER_PINNED_ITEMS_SCOPE } />
|
||||
|
|
|
@ -26,6 +26,8 @@ export function PostPublishSection( { postType }: PostPublishSectionProps ) {
|
|||
|
||||
const productURL = getProductURL( isScheduled );
|
||||
|
||||
if ( ! productURL ) return null;
|
||||
|
||||
const CopyButton = ( { text, onCopy, children }: CopyButtonProps ) => {
|
||||
const ref = useCopyToClipboard(
|
||||
text,
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createElement, useEffect, useState } from '@wordpress/element';
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
createElement,
|
||||
useEffect,
|
||||
useState,
|
||||
Fragment,
|
||||
} from '@wordpress/element';
|
||||
import { ReactElement, useMemo } from 'react';
|
||||
import { NavigableMenu, Slot } from '@wordpress/components';
|
||||
import { Product } from '@woocommerce/data';
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { select } from '@wordpress/data';
|
||||
import { useEntityProp } from '@wordpress/core-data';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore No types for this exist yet.
|
||||
|
@ -17,7 +22,6 @@ import { navigateTo, getNewPath, getQuery } from '@woocommerce/navigation';
|
|||
* Internal dependencies
|
||||
*/
|
||||
import { getTabTracksData } from './utils/get-tab-tracks-data';
|
||||
import { sortFillsByOrder } from '../../utils';
|
||||
import { TABS_SLOT_NAME } from './constants';
|
||||
|
||||
type TabsProps = {
|
||||
|
@ -28,6 +32,38 @@ export type TabsFillProps = {
|
|||
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 ) {
|
||||
const [ selected, setSelected ] = useState< string | null >( null );
|
||||
const query = getQuery() as Record< string, string >;
|
||||
|
@ -36,41 +72,14 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
|
|||
'product',
|
||||
'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( () => {
|
||||
if ( query.tab ) {
|
||||
setSelected( query.tab );
|
||||
onChange( 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(
|
||||
_childIndex: number,
|
||||
child: HTMLButtonElement
|
||||
|
@ -78,6 +87,21 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
|
|||
child.click();
|
||||
}
|
||||
|
||||
function renderFills( fills: readonly ( readonly ReactElement[] )[] ) {
|
||||
return (
|
||||
<TabFills
|
||||
fills={ fills }
|
||||
onDefaultSelection={ ( tabId ) => {
|
||||
if ( selected ) {
|
||||
return;
|
||||
}
|
||||
setSelected( tabId );
|
||||
onChange( tabId );
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigableMenu
|
||||
role="tablist"
|
||||
|
@ -92,6 +116,17 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
|
|||
navigateTo( {
|
||||
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(
|
||||
'product_tab_click',
|
||||
getTabTracksData( tabId, product )
|
||||
|
@ -101,14 +136,7 @@ export function Tabs( { onChange = () => {} }: TabsProps ) {
|
|||
}
|
||||
name={ TABS_SLOT_NAME }
|
||||
>
|
||||
{ ( fills ) => {
|
||||
if ( ! sortFillsByOrder ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
maybeSetSelected( fills );
|
||||
return sortFillsByOrder( fills );
|
||||
} }
|
||||
{ renderFills }
|
||||
</Slot>
|
||||
</NavigableMenu>
|
||||
);
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
/**
|
||||
* 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 React, { createElement } from 'react';
|
||||
import { SlotFillProvider } from '@wordpress/components';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { useState, createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
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.requireActual( '@woocommerce/block-templates' ),
|
||||
|
@ -40,6 +43,16 @@ function MockTabs( { onChange = jest.fn() } ) {
|
|||
selectedTab: selected,
|
||||
};
|
||||
|
||||
function getAttributes( id: string ) {
|
||||
return function setAttributes( {
|
||||
isSelected,
|
||||
}: Partial< TabBlockAttributes > ) {
|
||||
if ( isSelected ) {
|
||||
setSelected( id );
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<SlotFillProvider>
|
||||
<Tabs
|
||||
|
@ -50,27 +63,45 @@ function MockTabs( { onChange = jest.fn() } ) {
|
|||
/>
|
||||
<Tab
|
||||
{ ...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
|
||||
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
|
||||
context={ mockContext }
|
||||
name="test1"
|
||||
setAttributes={ getAttributes( 'test1' ) }
|
||||
/>
|
||||
<Tab
|
||||
{ ...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
|
||||
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
|
||||
context={ mockContext }
|
||||
name="test2"
|
||||
setAttributes={ getAttributes( 'test2' ) }
|
||||
/>
|
||||
<Tab
|
||||
{ ...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
|
||||
// @ts-ignore editedProduct is not used, so we can just ignore the fact that our context doesn't have it
|
||||
context={ mockContext }
|
||||
name="test3"
|
||||
setAttributes={ getAttributes( 'test3' ) }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
);
|
||||
|
@ -84,26 +115,30 @@ describe( 'Tabs', () => {
|
|||
} );
|
||||
|
||||
it( 'should render tab buttons added to the slot', () => {
|
||||
const { queryByText } = render( <MockTabs /> );
|
||||
expect( queryByText( 'Test button 1' ) ).toBeInTheDocument();
|
||||
expect( queryByText( 'Test button 2' ) ).toBeInTheDocument();
|
||||
render( <MockTabs /> );
|
||||
|
||||
expect( screen.queryByText( 'Test button 1' ) ).toBeInTheDocument();
|
||||
expect( screen.queryByText( 'Test button 2' ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should set the first tab as active initially', () => {
|
||||
const { queryByText } = render( <MockTabs /> );
|
||||
expect( queryByText( 'Test button 1' ) ).toHaveAttribute(
|
||||
it( 'should set the first tab as active initially', async () => {
|
||||
render( <MockTabs /> );
|
||||
|
||||
expect( screen.queryByText( 'Test button 1' ) ).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
expect( queryByText( 'Test button 2' ) ).toHaveAttribute(
|
||||
|
||||
expect( screen.queryByText( 'Test button 2' ) ).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should navigate to a new URL when a tab is clicked', () => {
|
||||
const { getByText } = render( <MockTabs /> );
|
||||
const button = getByText( 'Test button 2' );
|
||||
render( <MockTabs /> );
|
||||
|
||||
const button = screen.getByText( 'Test button 2' );
|
||||
fireEvent.click( button );
|
||||
|
||||
expect( navigateTo ).toHaveBeenLastCalledWith( {
|
||||
|
@ -116,16 +151,16 @@ describe( 'Tabs', () => {
|
|||
tab: 'test2',
|
||||
} );
|
||||
|
||||
const { getByText } = render( <MockTabs /> );
|
||||
render( <MockTabs /> );
|
||||
|
||||
expect( getByText( 'Test button 2' ) ).toHaveAttribute(
|
||||
expect( screen.getByText( 'Test button 2' ) ).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should select the tab provided on URL change', () => {
|
||||
const { getByText, rerender } = render( <MockTabs /> );
|
||||
const { rerender } = render( <MockTabs /> );
|
||||
|
||||
( getQuery as jest.Mock ).mockReturnValue( {
|
||||
tab: 'test3',
|
||||
|
@ -133,7 +168,7 @@ describe( 'Tabs', () => {
|
|||
|
||||
rerender( <MockTabs /> );
|
||||
|
||||
expect( getByText( 'Test button 3' ) ).toHaveAttribute(
|
||||
expect( screen.getByText( 'Test button 3' ) ).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
|
@ -155,20 +190,30 @@ describe( 'Tabs', () => {
|
|||
} );
|
||||
|
||||
it( 'should add a class to the initially selected tab panel', async () => {
|
||||
const { getByRole } = render( <MockTabs /> );
|
||||
const panel1 = getByRole( 'tabpanel', { name: 'Test button 1' } );
|
||||
const panel2 = getByRole( 'tabpanel', { name: 'Test button 2' } );
|
||||
render( <MockTabs /> );
|
||||
|
||||
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( panel2.classList ).not.toContain( 'is-selected' );
|
||||
} );
|
||||
|
||||
it( 'should add a class to the newly selected tab panel', async () => {
|
||||
const { getByText, getByRole, rerender } = render( <MockTabs /> );
|
||||
const button = getByText( 'Test button 2' );
|
||||
const { rerender } = render( <MockTabs /> );
|
||||
|
||||
const button = screen.getByText( 'Test button 2' );
|
||||
fireEvent.click( button );
|
||||
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',
|
||||
} );
|
||||
|
||||
( getQuery as jest.Mock ).mockReturnValue( {
|
||||
tab: 'test2',
|
||||
|
|
|
@ -19,8 +19,9 @@ export type ValidationContextProps< T > = {
|
|||
validateAll( newData?: Partial< T > ): Promise< ValidationErrors >;
|
||||
};
|
||||
|
||||
export type ValidationProviderProps< T > = {
|
||||
initialValue?: T;
|
||||
export type ValidationProviderProps = {
|
||||
postType: string;
|
||||
productId: number;
|
||||
};
|
||||
|
||||
export type ValidationError = string | undefined;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useEntityRecord } from '@wordpress/core-data';
|
||||
import { createElement, useRef, useState } from '@wordpress/element';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -17,12 +18,18 @@ import { ValidationContext } from './validation-context';
|
|||
import { findFirstInvalidElement } from './helpers';
|
||||
|
||||
export function ValidationProvider< T >( {
|
||||
initialValue,
|
||||
postType,
|
||||
productId,
|
||||
children,
|
||||
}: PropsWithChildren< ValidationProviderProps< T > > ) {
|
||||
}: PropsWithChildren< ValidationProviderProps > ) {
|
||||
const validatorsRef = useRef< Record< string, Validator< T > > >( {} );
|
||||
const fieldRefs = useRef< Record< string, HTMLElement > >( {} );
|
||||
const [ errors, setErrors ] = useState< ValidationErrors >( {} );
|
||||
const { record: initialValue } = useEntityRecord< T >(
|
||||
'postType',
|
||||
postType,
|
||||
productId
|
||||
);
|
||||
|
||||
function registerValidator(
|
||||
validatorId: string,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { useEntityProp } from '@wordpress/core-data';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
|
||||
export function useProductURL( productType: string ) {
|
||||
const [ permalink ] = useEntityProp< string >(
|
||||
|
@ -10,15 +10,19 @@ export function useProductURL( productType: string ) {
|
|||
productType,
|
||||
'permalink'
|
||||
);
|
||||
|
||||
const getProductURL = useCallback(
|
||||
( isPreview: boolean ) => {
|
||||
const productURL = new URL( permalink ) as URL | undefined;
|
||||
if ( ! permalink ) return undefined;
|
||||
|
||||
const productURL = new URL( permalink );
|
||||
if ( isPreview ) {
|
||||
productURL?.searchParams.append( 'preview', 'true' );
|
||||
productURL.searchParams.append( 'preview', 'true' );
|
||||
}
|
||||
return productURL?.toString() || '';
|
||||
return productURL.toString();
|
||||
},
|
||||
[ permalink ]
|
||||
);
|
||||
|
||||
return { getProductURL };
|
||||
}
|
||||
|
|
|
@ -2,40 +2,39 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { AUTO_DRAFT_NAME } from '@woocommerce/product-editor';
|
||||
import { Product } from '@woocommerce/data';
|
||||
import { useDispatch, resolveSelect } from '@wordpress/data';
|
||||
import { type Product } from '@woocommerce/data';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
|
||||
export function useProductEntityRecord(
|
||||
productId: string | undefined
|
||||
): Product | undefined {
|
||||
const { saveEntityRecord } = useDispatch( 'core' );
|
||||
const [ product, setProduct ] = useState< Product | undefined >(
|
||||
undefined
|
||||
);
|
||||
): number | undefined {
|
||||
const [ id, setId ] = useState< number | undefined >( undefined );
|
||||
|
||||
useEffect( () => {
|
||||
const getRecordPromise: Promise< Product > = productId
|
||||
? resolveSelect( 'core' ).getEntityRecord< Product >(
|
||||
'postType',
|
||||
'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,
|
||||
status: 'auto-draft',
|
||||
} ) as Promise< Product > );
|
||||
getRecordPromise
|
||||
.then( ( autoDraftProduct: Product ) => {
|
||||
setProduct( autoDraftProduct );
|
||||
} )
|
||||
if ( productId ) {
|
||||
setId( Number.parseInt( productId, 10 ) );
|
||||
return;
|
||||
}
|
||||
|
||||
const createProductPromise = dispatch( 'core' ).saveEntityRecord(
|
||||
'postType',
|
||||
'product',
|
||||
{
|
||||
title: AUTO_DRAFT_NAME,
|
||||
status: 'auto-draft',
|
||||
}
|
||||
) as never as Promise< Product >;
|
||||
|
||||
createProductPromise
|
||||
.then( ( autoDraftProduct: Product ) =>
|
||||
setId( autoDraftProduct.id )
|
||||
)
|
||||
.catch( ( e ) => {
|
||||
setProduct( undefined );
|
||||
setId( undefined );
|
||||
throw e;
|
||||
} );
|
||||
}, [ productId ] );
|
||||
|
||||
return product;
|
||||
return id;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import React, { lazy, Suspense, useContext, useEffect } from 'react';
|
|||
import { registerPlugin, unregisterPlugin } from '@wordpress/plugins';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { WooFooterItem } from '@woocommerce/admin-layout';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -39,9 +40,7 @@ const ProductMVPFeedbackModalContainer = lazy( () =>
|
|||
);
|
||||
|
||||
export default function ProductPage() {
|
||||
const { productId } = useParams();
|
||||
|
||||
const product = useProductEntityRecord( productId );
|
||||
const { productId: productIdSearchParam } = useParams();
|
||||
|
||||
useEffect( () => {
|
||||
document.body.classList.add( 'is-product-editor' );
|
||||
|
@ -70,8 +69,11 @@ export default function ProductPage() {
|
|||
<Suspense fallback={ <Spinner /> }>
|
||||
<ProductMVPFeedbackModalContainer
|
||||
productId={
|
||||
productId
|
||||
? parseInt( productId, 10 )
|
||||
productIdSearchParam
|
||||
? Number.parseInt(
|
||||
productIdSearchParam,
|
||||
10
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
@ -91,17 +93,17 @@ export default function ProductPage() {
|
|||
|
||||
return () => {
|
||||
document.body.classList.remove( 'is-product-editor' );
|
||||
unregisterPlugin( 'wc-admin-more-menu' );
|
||||
unregisterPlugin( 'wc-admin-product-editor' );
|
||||
unregisterBlocks();
|
||||
};
|
||||
}, [ productId ] );
|
||||
}, [ productIdSearchParam ] );
|
||||
|
||||
useEffect(
|
||||
function trackViewEvents() {
|
||||
if ( productId ) {
|
||||
if ( productIdSearchParam ) {
|
||||
recordEvent( 'product_edit_view', {
|
||||
source: TRACKS_SOURCE,
|
||||
product_id: productId,
|
||||
product_id: productIdSearchParam,
|
||||
} );
|
||||
} else {
|
||||
recordEvent( 'product_add_view', {
|
||||
|
@ -109,12 +111,20 @@ export default function ProductPage() {
|
|||
} );
|
||||
}
|
||||
},
|
||||
[ productId ]
|
||||
[ productIdSearchParam ]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor product={ product } />
|
||||
</>
|
||||
);
|
||||
const productId = useProductEntityRecord( productIdSearchParam );
|
||||
|
||||
if ( ! productId ) {
|
||||
return (
|
||||
<div className="woocommerce-layout__loading">
|
||||
<Spinner
|
||||
aria-label={ __( 'Creating the product', 'woocommerce' ) }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Editor productId={ productId } />;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ import { useEffect } from '@wordpress/element';
|
|||
import { WooFooterItem } from '@woocommerce/admin-layout';
|
||||
import { registerPlugin, unregisterPlugin } from '@wordpress/plugins';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Spinner } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -78,9 +80,19 @@ export default function ProductPage() {
|
|||
[ productId ]
|
||||
);
|
||||
|
||||
if ( ! variation ) {
|
||||
return (
|
||||
<div className="woocommerce-layout__loading">
|
||||
<Spinner
|
||||
aria-label={ __( 'Creating the product', 'woocommerce' ) }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor product={ variation } productType="product_variation" />
|
||||
<Editor productId={ variation.id } postType="product_variation" />
|
||||
<WooFooterItem order={ 0 }>
|
||||
<>
|
||||
<VariationSwitcherFooter
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: enhancement
|
||||
|
||||
Enhancement editor loading speed
|
Loading…
Reference in New Issue