Merge branch 'trunk' into fix/hpos-unit-test-trait

This commit is contained in:
Ron Rennick 2023-06-14 11:01:28 -03:00
commit 0ef06972af
95 changed files with 2027 additions and 411 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Do not add script prop to generated block.json

View File

@ -40,10 +40,6 @@ module.exports = {
wpEnv: true,
editorScript: 'file:./index.js',
editorStyle: 'file:./index.css',
style: 'file:./index.css',
customBlockJSON: {
script: 'file:./index.js',
},
},
pluginTemplatesPath: join( __dirname, 'plugin-templates' ),
blockTemplatesPath: join( __dirname, 'block-templates' ),

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add props to allow passing a classname to the feedback modal

View File

@ -5,6 +5,7 @@ import { createElement, useState } from '@wordpress/element';
import PropTypes from 'prop-types';
import { Button, Modal } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
import classnames from 'classnames';
/**
* Provides a modal requesting customer feedback.
@ -20,6 +21,7 @@ import { Text } from '@woocommerce/experimental';
* @param {string} props.cancelButtonLabel Label for the cancel button.
* @param {Function} props.onModalClose Callback for when user closes modal by clicking cancel.
* @param {Function} props.children Children to be rendered.
* @param {string} props.className Class name to addd to the modal.
*/
function FeedbackModal( {
onSubmit,
@ -30,6 +32,7 @@ function FeedbackModal( {
isSubmitButtonDisabled,
submitButtonLabel,
cancelButtonLabel,
className,
}: {
onSubmit: () => void;
title: string;
@ -39,6 +42,7 @@ function FeedbackModal( {
isSubmitButtonDisabled?: boolean;
submitButtonLabel?: string;
cancelButtonLabel?: string;
className?: string;
} ): JSX.Element | null {
const [ isOpen, setOpen ] = useState( true );
@ -55,21 +59,23 @@ function FeedbackModal( {
return (
<Modal
className="woocommerce-feedback-modal"
className={ classnames( 'woocommerce-feedback-modal', className ) }
title={ title }
onRequestClose={ closeModal }
shouldCloseOnClickOutside={ false }
>
<Text
variant="body"
as="p"
className="woocommerce-feedback-modal__description"
size={ 14 }
lineHeight="20px"
marginBottom="1.5em"
>
{ description }
</Text>
{ description && (
<Text
variant="body"
as="p"
className="woocommerce-feedback-modal__description"
size={ 14 }
lineHeight="20px"
marginBottom="1.5em"
>
{ description }
</Text>
) }
{ children }
<div className="woocommerce-feedback-modal__buttons">
<Button isTertiary onClick={ closeModal } name="cancel">

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Added install-and-activate-async to Onboarding and updated related types

View File

@ -17,8 +17,9 @@ import {
TaskListType,
TaskType,
OnboardingProductTypes,
InstallAndActivatePluginsAsyncResponse,
} from './types';
import { Plugin } from '../plugins/types';
import { Plugin, PluginNames } from '../plugins/types';
export function getFreeExtensionsError( error: unknown ) {
return {
@ -465,6 +466,28 @@ export function* actionTask( id: string ) {
}
}
export function* installAndActivatePluginsAsync(
plugins: Partial< PluginNames >[]
) {
yield setIsRequesting( 'installAndActivatePluginsAsync', true );
try {
const results: InstallAndActivatePluginsAsyncResponse = yield apiFetch(
{
path: `${ WC_ADMIN_NAMESPACE }/onboarding/plugins/install-and-activate-async`,
method: 'POST',
data: { plugins },
}
);
return results;
} catch ( error ) {
throw error;
} finally {
yield setIsRequesting( 'installAndActivatePluginsAsync', false );
}
}
export type Action = ReturnType<
| typeof getFreeExtensionsError
| typeof getFreeExtensionsSuccess

View File

@ -49,12 +49,19 @@ describe( 'plugins reducer', () => {
it( 'should handle SET_PROFILE_ITEMS', () => {
const state = reducer(
{
// @ts-expect-error - we're only testing profileItems
profileItems,
freeExtensions: [],
taskLists: {},
paymentMethods: [],
productTypes: {},
emailPrefill: '',
errors: {},
requesting: {},
},
{
type: TYPES.SET_PROFILE_ITEMS,
profileItems: { is_agree_marketing: true },
replace: false,
}
);
@ -64,8 +71,14 @@ describe( 'plugins reducer', () => {
it( 'should handle SET_PROFILE_ITEMS with replace', () => {
const state = reducer(
{
// @ts-expect-error - we're only testing profileItems
profileItems,
freeExtensions: [],
taskLists: {},
paymentMethods: [],
productTypes: {},
emailPrefill: '',
errors: {},
requesting: {},
},
{
type: TYPES.SET_PROFILE_ITEMS,

View File

@ -117,7 +117,7 @@ export type RevenueTypeSlug =
| 'more-than-250000';
export type ProfileItems = {
business_extensions?: [] | null;
business_extensions?: string[] | null;
completed?: boolean | null;
industry?: Industry[] | null;
number_employees?: string | null;
@ -180,7 +180,21 @@ export type Extension = {
image_url: string;
manage_url: string;
name: string;
label?: string;
is_built_by_wc: boolean;
is_visible: boolean;
is_installed?: boolean;
is_activated?: boolean;
learn_more_link?: string;
install_priority?: number;
};
export type InstallAndActivatePluginsAsyncResponse = {
job_id: string;
status: 'pending' | 'in-progress' | 'completed' | 'failed';
plugins: Array< {
status: 'pending' | 'installing' | 'installed' | 'activated' | 'failed';
errors: string[];
install_duration?: number;
} >;
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add editor history and undo/redo toolbar buttons

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update product CES modal design and fields

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Remove editor-styles-wrapper from product editor

View File

@ -107,16 +107,14 @@ export function BlockEditor( {
onChange={ onChange }
settings={ settings }
>
<div className="editor-styles-wrapper">
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore No types for this exist yet. */ }
<BlockEditorKeyboardShortcuts.Register />
<BlockTools>
<ObserveTyping>
<BlockList className="woocommerce-product-block-editor__block-list" />
</ObserveTyping>
</BlockTools>
</div>
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore No types for this exist yet. */ }
<BlockEditorKeyboardShortcuts.Register />
<BlockTools>
<ObserveTyping>
<BlockList className="woocommerce-product-block-editor__block-list" />
</ObserveTyping>
</BlockTools>
</BlockEditorProvider>
</BlockContextProvider>
</div>

View File

@ -14,6 +14,10 @@
font-family: var(--wp--preset--font-family--system-font);
}
h4 {
font-size: 16px;
}
label {
color: $gray-900;
}
@ -22,6 +26,20 @@
text-decoration: none;
}
/*
* Override default block margins and layout applied for themes without a theme.json file.
*
* If we no longer call `is_block_editor( true )` in the future for the product editor,
* we can remove this.
*
* See: `wp_add_editor_classic_theme_styles()`
*/
:where(.wp-block) {
margin-bottom: 0;
margin-top: 0;
max-width: unset;
}
.has-error {
.components-base-control {
margin-bottom: 0;
@ -64,26 +82,6 @@
margin-top: calc(2 * $gap);
}
.editor-styles-wrapper {
h4 {
font-size: 16px;
}
.block-editor-block-list__layout.is-root-container {
padding-left: calc(2 * $gap);
padding-right: calc(2 * $gap);
padding-bottom: 128px;
@include breakpoint(">782px") {
padding-left: 0;
padding-right: 0;
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
}
}
.components-input-control {
&__input::placeholder {
color: $gray-700;
@ -126,9 +124,24 @@
font-weight: 500;
}
.block-editor-block-list__layout
.block-editor-block-list__layout {
&.is-root-container {
padding-left: calc(2 * $gap);
padding-right: calc(2 * $gap);
padding-bottom: 128px;
@include breakpoint(">782px") {
padding-left: 0;
padding-right: 0;
max-width: 650px;
margin-left: auto;
margin-right: auto;
}
}
.block-editor-block-list__block:not([contenteditable]):focus:after {
display: none; // use important or increase specificity.
display: none; // use important or increase specificity.
}
}
}

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { createContext } from '@wordpress/element';
type EditorContextType = {
hasRedo: boolean;
hasUndo: boolean;
isInserterOpened: boolean;
redo: () => void;
setIsInserterOpened: ( value: boolean ) => void;
undo: () => void;
};
export const EditorContext = createContext< EditorContextType >( {
hasRedo: false,
hasUndo: false,
isInserterOpened: false,
redo: () => {},
setIsInserterOpened: () => {},
undo: () => {},
} );

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { __, isRTL } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { createElement, forwardRef, useContext } from '@wordpress/element';
import { redo as redoIcon, undo as undoIcon } from '@wordpress/icons';
import { Ref } from 'react';
/**
* Internal dependencies
*/
import { EditorContext } from '../context';
function EditorHistoryRedo(
props: { [ key: string ]: unknown },
ref: Ref< HTMLButtonElement >
) {
const { hasRedo, redo } = useContext( EditorContext );
return (
<Button
{ ...props }
ref={ ref }
icon={ ! isRTL() ? redoIcon : undoIcon }
/* translators: button label text should, if possible, be under 16 characters. */
label={ __( 'Redo', 'woocommerce' ) }
// If there are no redo levels we don't want to actually disable this
// button, because it will remove focus for keyboard users.
// See: https://github.com/WordPress/gutenberg/issues/3486
aria-disabled={ ! hasRedo }
onClick={ hasRedo ? redo : undefined }
className="editor-history__redo"
/>
);
}
export default forwardRef( EditorHistoryRedo );

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { __, isRTL } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { createElement, forwardRef, useContext } from '@wordpress/element';
import { Ref } from 'react';
import { undo as undoIcon, redo as redoIcon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { EditorContext } from '../context';
function EditorHistoryUndo(
props: { [ key: string ]: unknown },
ref: Ref< HTMLButtonElement >
) {
const { hasUndo, undo } = useContext( EditorContext );
return (
<Button
{ ...props }
ref={ ref }
icon={ ! isRTL() ? undoIcon : redoIcon }
/* translators: button label text should, if possible, be under 16 characters. */
label={ __( 'Undo', 'woocommerce' ) }
// If there are no undo levels we don't want to actually disable this
// button, because it will remove focus for keyboard users.
// See: https://github.com/WordPress/gutenberg/issues/3486
aria-disabled={ ! hasUndo }
onClick={ hasUndo ? undo : undefined }
className="editor-history__undo"
/>
);
}
export default forwardRef( EditorHistoryUndo );

View File

@ -2,29 +2,37 @@
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { useViewportMatch } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import {
NavigableToolbar,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { plus } from '@wordpress/icons';
import { createElement, useRef, useCallback } from '@wordpress/element';
import {
createElement,
Fragment,
useRef,
useCallback,
useContext,
} from '@wordpress/element';
import { MouseEvent } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ToolbarItem exists in WordPress components.
// eslint-disable-next-line @woocommerce/dependency-group
import { Button, ToolbarItem } from '@wordpress/components';
type HeaderToolbarProps = {
isInserterOpened: boolean;
setIsInserterOpened: ( value: boolean ) => void;
};
/**
* Internal dependencies
*/
import { EditorContext } from '../context';
import EditorHistoryRedo from './editor-history-redo';
import EditorHistoryUndo from './editor-history-undo';
export function HeaderToolbar( {
isInserterOpened,
setIsInserterOpened,
}: HeaderToolbarProps ) {
// console.log( editPost );
export function HeaderToolbar() {
const { isInserterOpened, setIsInserterOpened } =
useContext( EditorContext );
const isWideViewport = useViewportMatch( 'wide' );
const inserterButton = useRef< HTMLButtonElement | null >( null );
const { isInserterEnabled } = useSelect( ( select ) => {
const {
@ -88,6 +96,12 @@ export function HeaderToolbar( {
}
showTooltip
/>
{ isWideViewport && (
<>
<ToolbarItem as={ EditorHistoryUndo } />
<ToolbarItem as={ EditorHistoryRedo } />
</>
) }
</div>
</NavigableToolbar>
);

View File

@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { BlockInstance } from '@wordpress/blocks';
import { useState } from '@wordpress/element';
type useEditorHistoryProps = {
maxHistory?: number;
setBlocks: ( blocks: BlockInstance[] ) => void;
};
const DEFAULT_MAX_HISTORY = 50;
export function useEditorHistory( {
maxHistory = DEFAULT_MAX_HISTORY,
setBlocks,
}: useEditorHistoryProps ) {
const [ edits, setEdits ] = useState< BlockInstance[][] >( [] );
const [ offsetIndex, setOffsetIndex ] = useState< number >( 0 );
function appendEdit( edit: BlockInstance[] ) {
const currentEdits = edits.slice( 0, offsetIndex + 1 );
const newEdits = [ ...currentEdits, edit ].slice( maxHistory * -1 );
setEdits( newEdits );
setOffsetIndex( newEdits.length - 1 );
}
function undo() {
const newIndex = Math.max( 0, offsetIndex - 1 );
if ( ! edits[ newIndex ] ) {
return;
}
setBlocks( edits[ newIndex ] );
setOffsetIndex( newIndex );
}
function redo() {
const newIndex = Math.min( edits.length - 1, offsetIndex + 1 );
if ( ! edits[ newIndex ] ) {
return;
}
setBlocks( edits[ newIndex ] );
setOffsetIndex( newIndex );
}
function hasUndo() {
return !! edits.length && offsetIndex > 0;
}
function hasRedo() {
return !! edits.length && offsetIndex < edits.length - 1;
}
return {
appendEdit,
hasRedo: hasRedo(),
hasUndo: hasUndo(),
redo,
undo,
};
}

View File

@ -26,9 +26,11 @@ import {
*/
import { BackButton } from './back-button';
import { EditorCanvas } from './editor-canvas';
import { HeaderToolbar } from './header-toolbar';
import { EditorContext } from './context';
import { HeaderToolbar } from './header-toolbar/header-toolbar';
import { ResizableEditor } from './resizable-editor';
import { SecondarySidebar } from './secondary-sidebar/secondary-sidebar';
import { useEditorHistory } from './hooks/use-editor-history';
type IframeEditorProps = {
initialBlocks?: BlockInstance[];
@ -47,6 +49,9 @@ export function IframeEditor( {
}: IframeEditorProps ) {
const [ resizeObserver, sizes ] = useResizeObserver();
const [ blocks, setBlocks ] = useState< BlockInstance[] >( initialBlocks );
const { appendEdit, hasRedo, hasUndo, redo, undo } = useEditorHistory( {
setBlocks,
} );
const [ isInserterOpened, setIsInserterOpened ] = useState( false );
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This action exists in the block editor store.
@ -70,74 +75,80 @@ export function IframeEditor( {
return (
<div className="woocommerce-iframe-editor">
<BlockEditorProvider
settings={ {
...settings,
hasFixedToolbar: true,
templateLock: false,
<EditorContext.Provider
value={ {
hasRedo,
hasUndo,
isInserterOpened,
redo,
setIsInserterOpened,
undo,
} }
value={ blocks }
onChange={ ( updatedBlocks: BlockInstance[] ) => {
setBlocks( updatedBlocks );
onChange( updatedBlocks );
} }
onInput={ onInput }
useSubRegistry={ true }
>
<HeaderToolbar
isInserterOpened={ isInserterOpened }
setIsInserterOpened={ setIsInserterOpened }
/>
<div className="woocommerce-iframe-editor__main">
<SecondarySidebar
isInserterOpened={ isInserterOpened }
setIsInserterOpened={ setIsInserterOpened }
/>
<BlockTools
className={ 'woocommerce-iframe-editor__content' }
onClick={ (
event: React.MouseEvent<
HTMLDivElement,
MouseEvent
>
) => {
// Clear selected block when clicking on the gray background.
if ( event.target === event.currentTarget ) {
clearSelectedBlock();
}
} }
>
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore */ }
<BlockEditorKeyboardShortcuts.Register />
{ onClose && (
<BackButton
onClick={ () => {
setTimeout( onClose, 550 );
} }
/>
) }
<ResizableEditor
enableResizing={ true }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This accepts numbers or strings.
height={ sizes.height ?? '100%' }
<BlockEditorProvider
settings={ {
...settings,
hasFixedToolbar: true,
templateLock: false,
} }
value={ blocks }
onChange={ ( updatedBlocks: BlockInstance[] ) => {
appendEdit( updatedBlocks );
setBlocks( updatedBlocks );
onChange( updatedBlocks );
} }
onInput={ onInput }
useSubRegistry={ true }
>
<HeaderToolbar />
<div className="woocommerce-iframe-editor__main">
<SecondarySidebar />
<BlockTools
className={ 'woocommerce-iframe-editor__content' }
onClick={ (
event: React.MouseEvent<
HTMLDivElement,
MouseEvent
>
) => {
// Clear selected block when clicking on the gray background.
if ( event.target === event.currentTarget ) {
clearSelectedBlock();
}
} }
>
<EditorCanvas
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore */ }
<BlockEditorKeyboardShortcuts.Register />
{ onClose && (
<BackButton
onClick={ () => {
setTimeout( onClose, 550 );
} }
/>
) }
<ResizableEditor
enableResizing={ true }
settings={ settings }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This accepts numbers or strings.
height={ sizes.height ?? '100%' }
>
{ resizeObserver }
<BlockList className="edit-site-block-editor__block-list wp-site-blocks" />
</EditorCanvas>
<Popover.Slot />
</ResizableEditor>
</BlockTools>
<div className="woocommerce-iframe-editor__sidebar">
<BlockInspector />
<EditorCanvas
enableResizing={ true }
settings={ settings }
>
{ resizeObserver }
<BlockList className="edit-site-block-editor__block-list wp-site-blocks" />
</EditorCanvas>
<Popover.Slot />
</ResizableEditor>
</BlockTools>
<div className="woocommerce-iframe-editor__sidebar">
<BlockInspector />
</div>
</div>
</div>
</BlockEditorProvider>
</BlockEditorProvider>
</EditorContext.Provider>
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
import {
createElement,
useCallback,
useContext,
useEffect,
useRef,
} from '@wordpress/element';
@ -23,13 +24,13 @@ import {
__experimentalLibrary as Library,
} from '@wordpress/block-editor';
type InserterSidebarProps = {
setIsInserterOpened: ( value: boolean ) => void;
};
/**
* Internal dependencies
*/
import { EditorContext } from '../context';
export default function InserterSidebar( {
setIsInserterOpened,
}: InserterSidebarProps ) {
export default function InserterSidebar() {
const { setIsInserterOpened } = useContext( EditorContext );
const isMobileViewport = useViewportMatch( 'medium', '<' );
const { rootClientId } = useSelect( ( select ) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -1,24 +1,19 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { createElement, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { EditorContext } from '../context';
import InserterSidebar from './inserter-sidebar';
type SecondarySidebarProps = {
isInserterOpened: boolean;
setIsInserterOpened: ( value: boolean ) => void;
};
export function SecondarySidebar() {
const { isInserterOpened } = useContext( EditorContext );
export function SecondarySidebar( {
isInserterOpened,
setIsInserterOpened,
}: SecondarySidebarProps ) {
if ( isInserterOpened ) {
return <InserterSidebar setIsInserterOpened={ setIsInserterOpened } />;
return <InserterSidebar />;
}
return null;

View File

@ -1,5 +1,5 @@
@import './iframe-editor.scss';
@import './header-toolbar.scss';
@import './header-toolbar/header-toolbar.scss';
@import './resize-handle.scss';
@import './back-button.scss';
@import './secondary-sidebar/inserter-sidebar.scss';

View File

@ -41,11 +41,16 @@ export const ProductMVPFeedbackModalContainer: React.FC< {
`post-new.php?post_type=product&product_block_editor=0&_feature_nonce=${ _feature_nonce }`
);
const recordScore = ( checked: string[], comments: string ) => {
const recordScore = (
checked: string[],
comments: string,
email: string
) => {
recordEvent( 'product_mvp_feedback', {
action: 'disable',
checked,
comments: comments || '',
email,
} );
hideProductMVPFeedbackModal();
window.location.href = `${ classicEditorUrl }&new-product-experience-disabled=true`;

View File

@ -1,9 +1,18 @@
/**
* External dependencies
*/
import { createElement, Fragment, useState } from '@wordpress/element';
import {
createElement,
createInterpolateElement,
Fragment,
useState,
} from '@wordpress/element';
import PropTypes from 'prop-types';
import { CheckboxControl, TextareaControl } from '@wordpress/components';
import {
CheckboxControl,
TextareaControl,
TextControl,
} from '@wordpress/components';
import { FeedbackModal } from '@woocommerce/customer-effort-score';
import { Text } from '@woocommerce/experimental';
import { __ } from '@wordpress/i18n';
@ -20,7 +29,11 @@ function ProductMVPFeedbackModal( {
recordScoreCallback,
onCloseModal,
}: {
recordScoreCallback: ( checked: string[], comments: string ) => void;
recordScoreCallback: (
checked: string[],
comments: string,
email: string
) => void;
onCloseModal?: () => void;
} ): JSX.Element | null {
const [ missingFeatures, setMissingFeatures ] = useState( false );
@ -61,37 +74,33 @@ function ProductMVPFeedbackModal( {
},
];
const [ comments, setComments ] = useState( '' );
const [ email, setEmail ] = useState( '' );
const checked = checkboxes
.filter( ( checkbox ) => checkbox.checked )
.map( ( checkbox ) => checkbox.key );
const onSendFeedback = () => {
const checked = checkboxes
.filter( ( checkbox ) => checkbox.checked )
.map( ( checkbox ) => checkbox.key );
recordScoreCallback( checked, comments );
recordScoreCallback( checked, comments, email );
};
const isSendButtonDisabled =
! comments &&
! missingFeatures &&
! missingPlugins &&
! difficultToUse &&
! slowBuggyOrBroken &&
! other;
const optionalElement = (
<span className="woocommerce-product-mvp-feedback-modal__optional">
{ __( '(optional)', 'woocommerce' ) }
</span>
);
return (
<FeedbackModal
title={ __(
'Thanks for trying out the new product editor!',
'woocommerce'
) }
description={ __(
'Were working on making it better, and your feedback will help improve the experience for thousands of merchants like you.',
'Thanks for trying out the new product form!',
'woocommerce'
) }
onSubmit={ onSendFeedback }
onModalClose={ onCloseModal }
isSubmitButtonDisabled={ isSendButtonDisabled }
isSubmitButtonDisabled={ ! checked.length }
submitButtonLabel={ __( 'Send feedback', 'woocommerce' ) }
cancelButtonLabel={ __( 'Skip', 'woocommerce' ) }
className="woocommerce-product-mvp-feedback-modal"
>
<>
<Text
@ -100,45 +109,63 @@ function ProductMVPFeedbackModal( {
weight="600"
size="14"
lineHeight="20px"
>
{ __(
'What made you switch back to the classic product editor?',
'woocommerce'
) }
</Text>
<Text
weight="400"
size="12"
as="p"
lineHeight="16px"
color="#757575"
className="woocommerce-product-mvp-feedback-modal__subtitle"
>
{ __( '(Check all that apply)', 'woocommerce' ) }
</Text>
<div className="woocommerce-product-mvp-feedback-modal__checkboxes">
{ checkboxes.map( ( checkbox, index ) => (
<CheckboxControl
key={ index }
label={ checkbox.label }
name={ checkbox.key }
checked={ checkbox.checked }
onChange={ checkbox.onChange }
/>
) ) }
</div>
<div className="woocommerce-product-mvp-feedback-modal__comments">
<TextareaControl
label={ __( 'Additional comments', 'woocommerce' ) }
value={ comments }
placeholder={ __(
'Optional, but much apprecated. We love reading your feedback!',
></Text>
<fieldset className="woocommerce-product-mvp-feedback-modal__reason">
<legend>
{ __(
'What made you turn off the new product form?',
'woocommerce'
) }
</legend>
<div className="woocommerce-product-mvp-feedback-modal__checkboxes">
{ checkboxes.map( ( checkbox, index ) => (
<CheckboxControl
key={ index }
label={ checkbox.label }
name={ checkbox.key }
checked={ checkbox.checked }
onChange={ checkbox.onChange }
/>
) ) }
</div>
</fieldset>
<div className="woocommerce-product-mvp-feedback-modal__comments">
<TextareaControl
label={ createInterpolateElement(
__(
'Additional thoughts <optional/>',
'woocommerce'
),
{
optional: optionalElement,
}
) }
value={ comments }
onChange={ ( value: string ) => setComments( value ) }
rows={ 5 }
/>
</div>
<div className="woocommerce-product-mvp-feedback-modal__email">
<TextControl
label={ createInterpolateElement(
__(
'Your email address <optional/>',
'woocommerce'
),
{
optional: optionalElement,
}
) }
value={ email }
onChange={ ( value: string ) => setEmail( value ) }
rows={ 5 }
help={ __(
'In case you want to participate in further discussion and future user research.',
'woocommerce'
) }
/>
</div>
</>
</FeedbackModal>
);

View File

@ -1,23 +1,62 @@
$modal-header-height: 84px;
.woocommerce-product-mvp-feedback-modal {
width: 600px;
.components-modal__header {
height: $modal-header-height;
&-heading {
font-weight: 500;
font-size: 20px;
}
}
.components-modal__content {
margin-top: $modal-header-height;
padding-bottom: 32px;
}
legend,
label {
color: $gray-900;
}
.woocommerce-product-mvp-feedback-modal__optional {
color: $gray-700;
}
legend {
font-size: 11px;
font-weight: 500;
line-height: 1.4;
text-transform: uppercase;
display: inline-block;
margin-bottom: $gap-small;
padding: 0;
}
&__subtitle {
margin-top: $gap-smaller !important;
}
&__checkboxes {
margin: $gap-small 0;
display: grid;
grid-template-columns: 1fr 1fr;
}
&__comments {
margin-top: 2em;
margin-bottom: 1.5em;
label {
display: block;
font-weight: bold;
text-transform: none;
font-size: 14px;
}
textarea {
width: 100%;
}
}
&__reason,
&__comments,
&__email {
margin-bottom: $gap-large;
}
}

View File

@ -48,6 +48,7 @@ describe( 'ProductMVPFeedbackModal', () => {
fireEvent.click( screen.getByRole( 'checkbox', { name: /other/i } ) );
expect( mockRecordScoreCallback ).toHaveBeenCalledWith(
[ 'other' ],
'',
''
);
} );

View File

@ -11,6 +11,7 @@ import {
CoreProfilerStateMachineContext,
UserProfileEvent,
BusinessInfoEvent,
PluginsLearnMoreLinkClicked,
} from '..';
import { POSSIBLY_DEFAULT_STORE_NAMES } from '../pages/BusinessInfo';
@ -89,6 +90,18 @@ const recordTracksBusinessInfoCompleted = (
} );
};
const recordTracksPluginsLearnMoreLinkClicked = (
_context: unknown,
_event: PluginsLearnMoreLinkClicked,
{ action }: { action: unknown }
) => {
const { step } = action as { step: string };
recordEvent( `storeprofiler_${ step }_learn_more_link_clicked`, {
plugin: _event.payload.plugin,
link: _event.payload.learnMoreLink,
} );
};
export default {
recordTracksStepViewed,
recordTracksStepSkipped,
@ -96,4 +109,5 @@ export default {
recordTracksUserProfileCompleted,
recordTracksSkipBusinessLocationCompleted,
recordTracksBusinessInfoCompleted,
recordTracksPluginsLearnMoreLinkClicked,
};

View File

@ -10,10 +10,10 @@
.woocommerce-profiler-heading__title {
font-style: normal;
font-weight: 500;
font-size: 32px;
font-size: 40px;
line-height: 40px;
text-align: center;
color: #000;
color: $gray-900;
margin-bottom: 12px;
padding-top: 0;
@ -29,7 +29,7 @@
font-size: 16px;
line-height: 24px;
text-align: center;
color: $gray-700;
color: $gray-800;
@include breakpoint( '<782px' ) {
color: $gray-800;
@ -50,8 +50,6 @@
margin: 52px 0 40px;
.woocommerce-profiler-heading__title {
font-size: 32px;
line-height: 40px;
text-align: left;
}
.woocommerce-profiler-heading__subtitle {

View File

@ -16,6 +16,10 @@
max-width: 100%;
}
.components-checkbox-control__input-container {
margin-top: 6px;
}
input {
margin: 3px 26px 0 0;
width: 20px;
@ -26,8 +30,7 @@
}
img {
margin-right: 12px;
width: 25px;
height: 25px;
width: 28px;
@include breakpoint( '<782px' ) {
align-self: center;
}
@ -35,6 +38,7 @@
h3 {
font-size: 14px;
line-height: 20px;
font-weight: 500;
margin: 0 0 8px 0;
padding: 0;
@include breakpoint( '<782px' ) {
@ -43,14 +47,20 @@
}
p {
display: inline;
font-size: 13px;
line-height: 16px;
color: $gray-700;
margin: 0;
padding: 0;
a {
color: inherit;
@include breakpoint( '<782px' ) {
display: none;
}
}
a {
color: $gray-700;
margin-left: 5px;
@include breakpoint( '<782px' ) {
display: none;
}
@ -63,6 +73,7 @@
height: 16px;
&:focus {
box-shadow: none;
border-color: #1e1e1e;
}
}
.components-checkbox-control__input[type='checkbox']:checked {

View File

@ -19,6 +19,7 @@ export const PluginCard = ( {
onChange,
checked = false,
description,
learnMoreLink,
}: {
// Checkbox will be hidden if true
installed?: boolean;
@ -28,6 +29,7 @@ export const PluginCard = ( {
description: string | ReactNode;
checked?: boolean;
onChange?: () => void;
learnMoreLink?: ReactNode;
} ) => {
return (
<div className="woocommerce-profiler-plugins-plugin-card">
@ -53,6 +55,7 @@ export const PluginCard = ( {
) }
</div>
<p dangerouslySetInnerHTML={ sanitizeHTML( description ) } />
{ learnMoreLink }
</div>
</div>
);

View File

@ -106,6 +106,14 @@ export type PluginsInstallationRequestedEvent = {
};
};
export type PluginsLearnMoreLinkClicked = {
type: 'PLUGINS_LEARN_MORE_LINK_CLICKED';
payload: {
plugin: string;
learnMoreLink: string;
};
};
// TODO: add types as we develop the pages
export type OnboardingProfile = {
business_choice: BusinessChoice;
@ -1107,6 +1115,14 @@ export const coreProfilerStateMachineDefinition = createMachine( {
],
target: 'pluginsSkipped',
},
PLUGINS_LEARN_MORE_LINK_CLICKED: {
actions: [
{
type: 'recordTracksPluginsLearnMoreLinkClicked',
step: 'plugins',
},
],
},
PLUGINS_INSTALLATION_REQUESTED: {
target: 'installPlugins',
actions: [ 'assignPluginsSelected' ],
@ -1266,6 +1282,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
data: ( context ) => {
return {
selectedPlugins: context.pluginsSelected,
pluginsAvailable: context.pluginsAvailable,
};
},
},

View File

@ -17,15 +17,13 @@ import { Navigation } from '../components/navigation/navigation';
export const IntroOptIn = ( {
sendEvent,
navigationProgress,
context,
}: {
sendEvent: ( event: IntroOptInEvent ) => void;
navigationProgress: number;
context: CoreProfilerStateMachineContext;
} ) => {
const [ iOptInDataSharing, setIsOptInDataSharing ] = useState< boolean >(
context.optInDataSharing
);
const [ iOptInDataSharing, setIsOptInDataSharing ] =
useState< boolean >( true );
return (
<div

View File

@ -11,7 +11,10 @@ import { useState } from 'react';
/**
* Internal dependencies
*/
import { CoreProfilerStateMachineContext } from '../index';
import {
CoreProfilerStateMachineContext,
PluginsLearnMoreLinkClicked,
} from '../index';
import { PluginsInstallationRequestedEvent, PluginsPageSkippedEvent } from '..';
import { Heading } from '../components/heading/heading';
import { Navigation } from '../components/navigation/navigation';
@ -36,13 +39,16 @@ export const Plugins = ( {
}: {
context: CoreProfilerStateMachineContext;
sendEvent: (
payload: PluginsInstallationRequestedEvent | PluginsPageSkippedEvent
payload:
| PluginsInstallationRequestedEvent
| PluginsPageSkippedEvent
| PluginsLearnMoreLinkClicked
) => void;
navigationProgress: number;
} ) => {
const [ selectedPlugins, setSelectedPlugins ] = useState<
ExtensionList[ 'plugins' ]
>( context.pluginsAvailable.filter( ( plugin ) => ! plugin.is_installed ) );
>( context.pluginsAvailable.filter( ( plugin ) => ! plugin.is_activated ) );
const setSelectedPlugin = ( plugin: Extension ) => {
setSelectedPlugins(
@ -136,10 +142,29 @@ export const Plugins = ( {
) }
<div className="woocommerce-profiler-plugins__list">
{ context.pluginsAvailable.map( ( plugin ) => {
const learnMoreLink = plugin.learn_more_link ? (
<Link
onClick={ () => {
sendEvent( {
type: 'PLUGINS_LEARN_MORE_LINK_CLICKED',
payload: {
plugin: plugin.key,
learnMoreLink:
plugin.learn_more_link ?? '',
},
} );
} }
href={ plugin.learn_more_link }
target="_blank"
type="external"
>
{ __( 'Learn More', 'woocommerce' ) }
</Link>
) : null;
return (
<PluginCard
key={ `checkbox-control-${ plugin.key }` }
installed={ plugin.is_installed }
installed={ plugin.is_activated }
onChange={ () => {
setSelectedPlugin( plugin );
} }
@ -156,8 +181,9 @@ export const Plugins = ( {
/>
) : null
}
title={ plugin.name }
title={ plugin.label }
description={ plugin.description }
learnMoreLink={ learnMoreLink }
/>
);
} ) }

View File

@ -227,7 +227,7 @@ export const UserProfile = ( {
>
<Navigation
percentage={ navigationProgress }
skipText={ __( 'Skip this setup', 'woocommerce' ) }
skipText={ __( 'Skip this step', 'woocommerce' ) }
onSkip={ () =>
sendEvent( {
type: 'USER_PROFILE_SKIPPED',

View File

@ -48,20 +48,6 @@ describe( 'IntroOptIn', () => {
expect( screen.getByRole( 'checkbox' ) ).toBeChecked();
} );
it( 'should checkbox be unchecked when optInDataSharing is false', () => {
const newProps = {
...props,
context: {
optInDataSharing: false,
},
};
render(
// @ts-ignore
<IntroOptIn { ...newProps } />
);
expect( screen.getByRole( 'checkbox' ) ).not.toBeChecked();
} );
it( 'should toggle checkbox when checkbox is clicked', () => {
render(
// @ts-ignore

View File

@ -114,7 +114,7 @@ describe( 'UserProfile', () => {
);
screen
.getByRole( 'button', {
name: /Skip this setup/i,
name: /Skip this step/i,
} )
.click();
expect( props.sendEvent ).toHaveBeenCalledWith( {

View File

@ -1,7 +1,12 @@
/**
* External dependencies
*/
import { PLUGINS_STORE_NAME, PluginNames } from '@woocommerce/data';
import {
ExtensionList,
ONBOARDING_STORE_NAME,
PLUGINS_STORE_NAME,
PluginNames,
} from '@woocommerce/data';
import { dispatch } from '@wordpress/data';
import {
assign,
@ -68,6 +73,7 @@ const createPluginInstalledAndActivatedEvent = (
export type PluginInstallerMachineContext = {
selectedPlugins: PluginNames[];
pluginsAvailable: ExtensionList[ 'plugins' ] | [];
pluginsInstallationQueue: PluginNames[];
installedPlugins: InstalledPlugin[];
startTime: number;
@ -93,6 +99,7 @@ export const pluginInstallerMachine = createMachine(
initial: 'installing',
context: {
selectedPlugins: [] as PluginNames[],
pluginsAvailable: [] as ExtensionList[ 'plugins' ] | [],
pluginsInstallationQueue: [] as PluginNames[],
installedPlugins: [] as InstalledPlugin[],
startTime: 0,
@ -160,7 +167,7 @@ export const pluginInstallerMachine = createMachine(
invoke: {
src: 'queueRemainingPluginsAsync',
onDone: {
target: 'finished',
target: 'reportSuccess',
},
},
},
@ -188,7 +195,21 @@ export const pluginInstallerMachine = createMachine(
} ),
assignPluginsInstallationQueue: assign( {
pluginsInstallationQueue: ( ctx ) => {
return ctx.selectedPlugins;
// Sort the plugins by install_priority so that the smaller plugins are installed first
// install_priority is set by plugin's size
// Lower install_prioirty means the plugin is smaller
return ctx.selectedPlugins.slice().sort( ( a, b ) => {
const aIndex = ctx.pluginsAvailable.find(
( plugin ) => plugin.key === a
);
const bIndex = ctx.pluginsAvailable.find(
( plugin ) => plugin.key === b
);
return (
( aIndex?.install_priority ?? 99 ) -
( bIndex?.install_priority ?? 99 )
);
} );
},
} ),
assignStartTime: assign( {
@ -262,9 +283,10 @@ export const pluginInstallerMachine = createMachine(
);
},
queueRemainingPluginsAsync: ( ctx ) => {
return dispatch( PLUGINS_STORE_NAME ).installPlugins(
ctx.pluginsInstallationQueue,
true
return dispatch(
ONBOARDING_STORE_NAME
).installAndActivatePluginsAsync(
ctx.pluginsInstallationQueue
);
},
},

View File

@ -48,6 +48,7 @@ describe( 'pluginInstallerMachine', () => {
.withContext( {
...defaultContext,
selectedPlugins: [ 'woocommerce-payments' ],
pluginsAvailable: [],
} );
dispatchInstallPluginMock.mockImplementationOnce( ( context ) => {
@ -88,3 +89,10 @@ describe( 'pluginInstallerMachine', () => {
} );
} );
} );
// TODO: write more tests, I ran out of time and it's friday night
// we need tests for:
// 1. when given multiple plugins it should call the installPlugin service multiple times with the right plugins
// 2. when given multiple plugins and a mocked delay using the config, we can mock the installs to take longer than the timeout and then some plugins should not finish installing, then it should add the remaining to async queue
// 3. when a plugin gives an error it should report the error to the parents. we can check this by mocking 'updateParentWithInstallationErrors'
// 4. it should update parent with the plugin installation progress, we can check this by mocking the action 'updateParentWithPluginProgress'

View File

@ -44,7 +44,7 @@
padding: 10px 16px;
height: 48px;
font-size: 14px;
font-weight: 500;
font-weight: normal;
}
.woocommerce-select-control__option {
@ -115,7 +115,7 @@
}
.woocommerce-profiler-intro-opt-in__content {
padding-top: 81px;
padding-top: 110px;
flex: 1;
@include breakpoint( '<782px' ) {
@ -127,7 +127,7 @@
}
.woocommerce-profiler-welcome-image {
margin-bottom: 48px;
margin-bottom: 64px;
width: 266px;
height: 172px;
background: url(./assets/images/welcome-desktop.svg) no-repeat center
@ -145,10 +145,12 @@
.woocommerce-profiler-setup-store__button {
padding: 10px 16px;
width: 200px;
height: 54px;
height: 48px;
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
font-weight: normal;
@include breakpoint( '<782px' ) {
width: 100%;
@ -171,6 +173,7 @@
outline: 2px solid transparent;
width: 16px;
height: 16px;
border-radius: 2px;
}
.components-checkbox-control__input-container {
@ -248,43 +251,47 @@
top: 40px !important;
}
.woocommerce-profiler-business-location {
}
.woocommerce-profiler-business-location {
display: flex;
flex-direction: column;
.woocommerce-profiler-business-location__content {
width: 100%;
display: flex;
flex-direction: column;
.woocommerce-profiler-business-location__content {
max-width: 550px;
align-self: center;
& > div {
width: 100%;
display: flex;
align-self: center;
}
& > div {
width: 100%;
}
.woocommerce-profiler-heading {
max-width: 570px;
}
.components-base-control__field {
height: 40px;
}
.components-base-control__field {
height: 40px;
}
.woocommerce-select-control__control-icon {
.woocommerce-select-control__control-icon {
display: none;
}
.woocommerce-select-control__control.is-active {
.components-base-control__label {
display: none;
}
}
.woocommerce-select-control__control.is-active {
.components-base-control__label {
display: none;
}
}
.woocommerce-select-control.is-searchable
.woocommerce-select-control__control-input {
margin: 0;
padding: 0;
}
.woocommerce-select-control.is-searchable
.woocommerce-select-control__control-input {
margin: 0;
padding: 0;
}
.woocommerce-select-control.is-searchable
.components-base-control__label {
left: 13px;
}
.woocommerce-select-control.is-searchable
.components-base-control__label {
left: 13px;
}
}
}
@ -398,8 +405,12 @@
max-width: 615px;
margin: 58px 0 0 0;
}
.woocommerce-profiler-heading__title {
color: $gray-900;
padding: 0;
}
.woocommerce-profiler-heading__subtitle {
margin: 0 0 48px 0 !important;
margin: 12px 0 48px 0 !important;
@include breakpoint( '<782px' ) {
margin-top: 12px !important;
}
@ -418,6 +429,8 @@
margin-top: 28px;
text-align: center;
display: block;
font-size: 14px;
font-weight: normal;
}
.plugin-error {
@ -506,6 +519,7 @@
border-color: #bbb;
border-radius: 2px;
border-width: 1px;
font-size: 13px;
}
.woocommerce-profiler-select-control__industry {

View File

@ -3086,7 +3086,7 @@ Object {
class="components-button woocommerce-profiler-navigation-skip-link is-link"
type="button"
>
Skip this setup
Skip this step
</button>
</div>
</div>
@ -3267,7 +3267,7 @@ Object {
class="components-button woocommerce-profiler-navigation-skip-link is-link"
type="button"
>
Skip this setup
Skip this step
</button>
</div>
</div>

View File

@ -2,23 +2,25 @@
* External dependencies
*/
import { Guide } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Guide from '../components/guide';
import './style.scss';
interface Props {
onCloseGuide: () => void;
onCloseGuide: ( currentPage: number, origin: 'close' | 'finish' ) => void;
}
const BlockEditorGuide = ( { onCloseGuide }: Props ) => {
return (
<Guide
className="woocommerce-block-editor-guide"
finishButtonText={ __( 'Close', 'woocommerce' ) }
contentLabel=""
finishButtonText={ __( 'Tell me more', 'woocommerce' ) }
finishButtonLink="https://woocommerce.com/product-form-beta"
onFinish={ onCloseGuide }
pages={ [
{

View File

@ -5,6 +5,7 @@ import { Pill, TourKit } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect, useState } from '@wordpress/element';
import { __experimentalUseFeedbackBar as useFeedbackBar } from '@woocommerce/product-editor';
/**
* Internal dependencies
@ -27,17 +28,35 @@ const BlockEditorTour = ( { shouldTourBeShown, dismissModal }: Props ) => {
const [ isGuideOpen, setIsGuideOpen ] = useState( false );
const { maybeShowFeedbackBar } = useFeedbackBar();
const openGuide = () => {
setIsGuideOpen( true );
};
const closeGuide = () => {
recordEvent( 'block_product_editor_spotlight_completed' );
setIsGuideOpen( false );
};
if ( isGuideOpen ) {
return <BlockEditorGuide onCloseGuide={ closeGuide } />;
return (
<BlockEditorGuide
onCloseGuide={ ( currentPage, source ) => {
dismissModal();
if ( source === 'finish' ) {
recordEvent(
'block_product_editor_spotlight_tell_me_more_click'
);
} else {
// adding 1 to consider the TourKit as page 0
recordEvent(
'block_product_editor_spotlight_dismissed',
{
current_page: currentPage + 1,
}
);
}
setIsGuideOpen( false );
maybeShowFeedbackBar();
} }
/>
);
} else if ( shouldTourBeShown ) {
return (
<TourKit
@ -78,16 +97,20 @@ const BlockEditorTour = ( { shouldTourBeShown, dismissModal }: Props ) => {
},
],
closeHandler: ( _steps, _currentStepIndex, source ) => {
dismissModal();
if ( source === 'done-btn' ) {
recordEvent(
'block_product_editor_spotlight_view_highlights'
);
openGuide();
} else {
dismissModal();
recordEvent(
'block_product_editor_spotlight_dismissed'
'block_product_editor_spotlight_dismissed',
{
current_page: 0,
}
);
maybeShowFeedbackBar();
}
},
options: {
@ -116,6 +139,7 @@ const BlockEditorTour = ( { shouldTourBeShown, dismissModal }: Props ) => {
},
},
],
classNames: 'woocommerce-block-editor-tourkit',
},
} }
/>

View File

@ -1,10 +1,11 @@
$background-height: 220px;
$yellow: #f5e6ab;
$light-purple: #f2edff;
.woocommerce-block-editor-guide {
&__background1 {
height: $background-height;
background-color: #f2edff;
background-color: $light-purple;
}
&__background2 {
height: $background-height;
@ -56,3 +57,12 @@ $yellow: #f5e6ab;
}
}
}
.woocommerce-block-editor-tourkit {
.components-card__header {
align-items: flex-start;
height: 200px;
background-color: $light-purple;
margin-bottom: $gap;
}
}

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { SVG, Circle } from '@wordpress/primitives';
import { createElement } from '@wordpress/element';
export const PageControlIcon = ( { isSelected }: { isSelected: boolean } ) => (
<SVG width="8" height="8" fill="none" xmlns="http://www.w3.org/2000/svg">
<Circle
cx="4"
cy="4"
r="4"
fill={ isSelected ? '#419ECD' : '#E1E3E6' }
/>
</SVG>
);

View File

@ -0,0 +1,123 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useState, useRef, createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Modal, Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import PageControl from './page-control';
import type { GuideProps } from './types';
/*
* This component was copied from @wordpress/components since we needed
* additional functionality and also found some issues.
* 1: The Close button was being focused every time the page changed.
* 2: It was not possible to know if the Guide was closed because the modal was closed or because the Finish button was clicked.
* 3: It was not possible to know which was the current page when the modal was closed.
* 4: It was not possible to provide a link to the Finish button.
*
* If/when all those are implemented at some point, we can migrate to the original component.
*/
function Guide( {
className,
contentLabel,
finishButtonText = __( 'Finish', 'woocommerce' ),
finishButtonLink,
onFinish,
pages = [],
}: GuideProps ) {
const guideContainer = useRef< HTMLDivElement >( null );
const [ currentPage, setCurrentPage ] = useState( 0 );
const canGoBack = currentPage > 0;
const canGoForward = currentPage < pages.length - 1;
const goBack = () => {
if ( canGoBack ) {
setCurrentPage( currentPage - 1 );
}
};
const goForward = () => {
if ( canGoForward ) {
setCurrentPage( currentPage + 1 );
}
};
if ( pages.length === 0 ) {
return null;
}
return (
<Modal
className={ classnames( 'components-guide', className ) }
title={ contentLabel }
onRequestClose={ () => {
onFinish( currentPage, 'close' );
} }
onKeyDown={ ( event ) => {
if ( event.code === 'ArrowLeft' ) {
goBack();
// Do not scroll the modal's contents.
event.preventDefault();
} else if ( event.code === 'ArrowRight' ) {
goForward();
// Do not scroll the modal's contents.
event.preventDefault();
}
} }
ref={ guideContainer }
>
<div className="components-guide__container">
<div className="components-guide__page">
{ pages[ currentPage ].image }
{ pages.length > 1 && (
<PageControl
currentPage={ currentPage }
numberOfPages={ pages.length }
setCurrentPage={ setCurrentPage }
/>
) }
{ pages[ currentPage ].content }
</div>
<div className="components-guide__footer">
{ canGoBack && (
<Button
className="components-guide__back-button"
onClick={ goBack }
>
{ __( 'Previous', 'woocommerce' ) }
</Button>
) }
{ canGoForward && (
<Button
className="components-guide__forward-button"
onClick={ goForward }
>
{ __( 'Next', 'woocommerce' ) }
</Button>
) }
{ ! canGoForward && (
<Button
className="components-guide__finish-button"
href={ finishButtonLink }
target={ finishButtonLink ? '_blank' : undefined }
rel={ finishButtonLink ? 'noopener' : undefined }
onClick={ () => onFinish( currentPage, 'finish' ) }
>
{ finishButtonText }
</Button>
) }
</div>
</div>
</Modal>
);
}
export default Guide;

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { PageControlIcon } from './icons';
import type { PageControlProps } from './types';
export default function PageControl( {
currentPage,
numberOfPages,
setCurrentPage,
}: PageControlProps ) {
return (
<ul
className="components-guide__page-control"
aria-label={ __( 'Guide controls', 'woocommerce' ) }
>
{ Array.from( { length: numberOfPages } ).map( ( _, page ) => (
<li
key={ page }
// Set aria-current="step" on the active page, see https://www.w3.org/TR/wai-aria-1.1/#aria-current
aria-current={ page === currentPage ? 'step' : undefined }
>
<Button
key={ page }
icon={
<PageControlIcon
isSelected={ page === currentPage }
/>
}
aria-label={ sprintf(
/* translators: 1: current page number 2: total number of pages */
__( 'Page %1$d of %2$d', 'woocommerce' ),
page + 1,
numberOfPages
) }
onClick={ () => setCurrentPage( page ) }
/>
</li>
) ) }
</ul>
);
}

View File

@ -0,0 +1,132 @@
.components-guide {
$image-height: 300px;
$image-width: 320px;
@include break-small() {
width: 600px;
}
.components-modal__content {
padding: 0;
margin-top: 0;
border-radius: $radius-block-ui;
&::before {
content: none;
}
}
.components-modal__header {
border-bottom: none;
padding: 0;
position: sticky;
height: $header-height;
.components-button {
align-self: flex-start;
margin: $grid-unit-10 $grid-unit-10 0 0;
position: static;
&:hover {
svg {
fill: #fff;
}
}
}
}
&__container {
display: flex;
flex-direction: column;
justify-content: space-between;
margin-top: -$header-height;
min-height: 100%;
}
&__page {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
@include break-small() {
min-height: $image-height;
}
}
&__footer {
align-content: center;
display: flex;
height: 30px;
justify-content: center;
margin: 0 0 $grid-unit-30 0;
padding: 0 $grid-unit-40;
position: relative;
width: 100%;
}
&__page-control {
margin: 0;
text-align: center;
li {
display: inline-block;
margin: 0;
}
.components-button {
height: 30px;
min-width: 20px;
margin: -6px 0;
}
}
}
.components-modal__frame.components-guide {
border: none;
min-width: 312px;
height: 80vh;
max-height: 575px;
@media ( max-width: $break-small ) {
margin: auto;
max-width: calc(100vw - #{$grid-unit-20} * 2);
}
}
.components-button {
&.components-guide__back-button,
&.components-guide__forward-button,
&.components-guide__finish-button {
height: 30px;
position: absolute;
}
&.components-guide__back-button,
&.components-guide__forward-button {
font-size: $default-font-size;
padding: 4px 2px;
&.has-text svg {
margin: 0;
}
&:hover {
text-decoration: underline;
}
}
&.components-guide__back-button {
left: $grid-unit-40;
}
&.components-guide__forward-button {
right: $grid-unit-40;
color: #1386bf;
font-weight: bold;
}
&.components-guide__finish-button {
right: $grid-unit-40;
}
}

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
export type Page = {
/**
* Content of the page.
*/
content: ReactNode;
/**
* Image displayed above the page content.
*/
image?: ReactNode;
};
export type GuideProps = {
/**
* Deprecated. Use `pages` prop instead.
*
* @deprecated since 5.5
*/
children?: ReactNode;
/**
* A custom class to add to the modal.
*/
className?: string;
/**
* Used as the modal's accessibility label.
*/
contentLabel: string;
/**
* Use this to customize the label of the _Finish_ button shown at the end of the guide.
*
* @default 'Finish'
*/
finishButtonText?: string;
/**
* Use this to customize href of the _Finish_ button shown at the end of the guide.
*
*/
finishButtonLink?: string;
/**
* A function which is called when the guide is closed, either through closing the dialog or clicking the Finish button.
*/
onFinish: ( currentPage: number, origin: 'close' | 'finish' ) => void;
/**
* A list of objects describing each page in the guide. Each object **must** contain a `'content'` property and may optionally contain a `'image'` property.
*
* @default []
*/
pages?: Page[];
};
export type PageControlProps = {
/**
* Current page index.
*/
currentPage: number;
/**
* Total number of pages.
*/
numberOfPages: number;
/**
* Called when user clicks on a `PageControlIcon` button.
*/
setCurrentPage: ( page: number ) => void;
};

View File

@ -541,6 +541,27 @@ const attachProductAttributesTracks = () => {
} );
};
const attachGeneralTabTracks = () => {
document
.querySelector(
'#general_product_data .woocommerce-message .variations-tab-navigation-link'
)
?.addEventListener( 'click', () => {
recordEvent( 'disabled_general_tab', {
action: 'go_to_variations',
} );
} );
document
.querySelector(
'#general_product_data .woocommerce-message .linked-products-navigation-link'
)
?.addEventListener( 'click', () => {
recordEvent( 'disabled_general_tab', {
action: 'go_to_linked_products',
} );
} );
};
/**
* Attaches product variations tracks.
*/
@ -731,6 +752,7 @@ export const initProductScreenTracks = () => {
attachProductVariationsTracks();
attachProductTabsTracks();
attachProductInventoryTabTracks();
attachGeneralTabTracks();
};
export function addExitPageListener( pageId: string ) {

View File

@ -0,0 +1,18 @@
<svg width="29" height="29" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1698_35803)">
<g clip-path="url(#clip1_1698_35803)">
<path d="M25.9029 14.3535C25.9029 13.341 25.8207 12.6022 25.6429 11.8359H14.0809V16.4059H20.8675C20.7308 17.5416 19.9919 19.252 18.3499 20.4013L18.3269 20.5543L21.9826 23.3863L22.2359 23.4115C24.5619 21.2633 25.9029 18.1026 25.9029 14.3535Z" fill="#4285F4"/>
<path d="M14.0809 26.3942C17.4058 26.3942 20.197 25.2995 22.2358 23.4113L18.3499 20.401C17.31 21.1262 15.9144 21.6325 14.0809 21.6325C10.8244 21.6325 8.06055 19.4843 7.0753 16.5151L6.93088 16.5274L3.12967 19.4692L3.07996 19.6074C5.10498 23.6302 9.26455 26.3942 14.0809 26.3942Z" fill="#34A853"/>
<path d="M7.07547 16.515C6.8155 15.7488 6.66505 14.9278 6.66505 14.0795C6.66505 13.2311 6.8155 12.4102 7.06179 11.6439L7.0549 11.4808L3.20604 8.4917L3.08012 8.5516C2.2455 10.2209 1.7666 12.0955 1.7666 14.0795C1.7666 16.0635 2.2455 17.938 3.08012 19.6073L7.07547 16.515Z" fill="#FBBC05"/>
<path d="M14.081 6.52671C16.3933 6.52671 17.9531 7.52554 18.8425 8.36025L22.318 4.9669C20.1835 2.98291 17.4058 1.76514 14.081 1.76514C9.26457 1.76514 5.10499 4.52903 3.07996 8.55173L7.06164 11.6441C8.06057 8.67492 10.8245 6.52671 14.081 6.52671Z" fill="#EB4335"/>
</g>
</g>
<defs>
<clipPath id="clip0_1698_35803">
<rect width="24.7139" height="24.7139" fill="white" transform="translate(1.76526 1.76514)"/>
</clipPath>
<clipPath id="clip1_1698_35803">
<rect width="24.1504" height="24.7139" fill="white" transform="translate(1.76526 1.76514)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1698_35800)">
<path d="M13.9767 1.75C7.23333 1.75 1.75 7.23333 1.75 13.9767C1.75 20.72 7.23333 26.2033 13.9767 26.2033C20.72 26.2033 26.2033 20.72 26.2033 13.9767C26.2033 7.23333 20.72 1.75 13.9767 1.75ZM13.3467 16.0067H7.25667L13.3467 4.15333V16.0067ZM14.5833 23.7767V11.9233H20.6733L14.5833 23.7767Z" fill="#069E08"/>
</g>
<defs>
<clipPath id="clip0_1698_35800">
<rect width="24.5" height="24.5" fill="white" transform="translate(1.75 1.75)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 594 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,15 @@
<svg width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1698_35810)">
<g clip-path="url(#clip1_1698_35810)">
<path d="M1.99438 14.0002C1.99438 19.0164 5.01076 23.3257 9.32706 25.2203C9.29261 24.3649 9.32094 23.338 9.54029 22.4073C9.77582 21.4129 11.1165 15.7322 11.1165 15.7322C11.1165 15.7322 10.7252 14.95 10.7252 13.794C10.7252 11.9787 11.7774 10.6228 13.0877 10.6228C14.202 10.6228 14.7403 11.4598 14.7403 12.462C14.7403 13.5821 14.0259 15.2575 13.6585 16.8094C13.3516 18.1088 14.31 19.1687 15.592 19.1687C17.913 19.1687 19.4762 16.1877 19.4762 12.6557C19.4762 9.9708 17.6679 7.96123 14.3788 7.96123C10.6629 7.96123 8.34792 10.7324 8.34792 13.8278C8.34792 14.8951 8.6626 15.6477 9.15547 16.2306C9.38209 16.4982 9.41358 16.6059 9.33156 16.9133C9.2728 17.1387 9.13786 17.6813 9.08197 17.8964C9.00043 18.2066 8.74902 18.3176 8.46861 18.203C6.75705 17.5043 5.95994 15.6299 5.95994 13.5229C5.95994 10.0431 8.89477 5.8704 14.7151 5.8704C19.3921 5.8704 22.4704 9.25485 22.4704 12.8878C22.4704 17.6934 19.7987 21.2835 15.8605 21.2835C14.538 21.2835 13.294 20.5686 12.8678 19.7565C12.8678 19.7565 12.1566 22.579 12.006 23.1241C11.7462 24.0685 11.2379 25.0126 10.773 25.7483C11.8748 26.0735 13.0386 26.2507 14.2449 26.2507C21.0095 26.2507 26.4945 20.766 26.4945 14.0002C26.4945 7.23465 21.0095 1.75 14.2449 1.75C7.47951 1.75 1.99438 7.23465 1.99438 14.0002Z" fill="#CB1F27"/>
</g>
</g>
<defs>
<clipPath id="clip0_1698_35810">
<rect width="24.5" height="24.5" fill="white" transform="translate(1.99438 1.75)"/>
</clipPath>
<clipPath id="clip1_1698_35810">
<rect width="24.5" height="24.5" fill="white" transform="translate(1.99438 1.75)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 278 KiB

View File

@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1698_35791)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5751 7H2.61755C1.16468 7 -0.0114466 8.18766 8.40658e-05 9.62899V18.3923C8.40658e-05 19.8452 1.17621 21.0213 2.62908 21.0213H13.5025L18.4722 23.7887L17.3422 21.0213H25.5751C27.028 21.0213 28.2041 19.8452 28.2041 18.3923V9.62899C28.2041 8.17613 27.028 7 25.5751 7ZM2.13267 9.03307C1.80982 9.05613 1.56767 9.17144 1.40624 9.39052C1.24481 9.59807 1.18716 9.86328 1.22175 10.1631C1.90206 14.4871 2.53625 17.4043 3.12431 18.9149C3.35493 19.4683 3.62013 19.7335 3.93146 19.7105C4.41575 19.6759 4.99228 19.0071 5.67259 17.7041C6.03005 16.9662 6.58352 15.8592 7.33301 14.3833C7.95567 16.5626 8.80894 18.2 9.88129 19.2954C10.1811 19.6067 10.4924 19.7451 10.7922 19.722C11.0574 19.699 11.265 19.5606 11.4033 19.3069C11.5186 19.0878 11.5648 18.8341 11.5417 18.5459C11.4725 17.4966 11.5763 16.0322 11.8646 14.1527C12.1644 12.2155 12.5334 10.8203 12.983 9.99012C13.0753 9.81716 13.1099 9.64419 13.0984 9.43664C13.0753 9.17144 12.96 8.95236 12.7409 8.7794C12.5218 8.60643 12.2797 8.52572 12.0145 8.54878C11.6801 8.57184 11.4264 8.73327 11.2534 9.05613C10.5385 10.3591 10.0312 12.4692 9.73139 15.398C9.29323 14.2911 8.92425 12.9881 8.63598 11.4545C8.50914 10.7742 8.19781 10.4513 7.69046 10.4859C7.34454 10.509 7.05628 10.7396 6.82566 11.1778L4.30044 15.9861C3.88534 14.3141 3.49329 12.2732 3.13584 9.86328C3.05513 9.26368 2.72074 8.98695 2.13267 9.03307ZM24.3521 9.86167C25.1707 10.0346 25.7819 10.4728 26.197 11.1992C26.566 11.8219 26.7504 12.5714 26.7504 13.4708C26.7504 14.6584 26.4506 15.7423 25.8511 16.734C25.1592 17.887 24.2598 18.4636 23.1413 18.4636C22.9453 18.4636 22.7378 18.4405 22.5187 18.3944C21.7 18.2214 21.0889 17.7832 20.6738 17.0568C20.3048 16.4226 20.1203 15.6616 20.1203 14.7737C20.1203 13.5861 20.4201 12.5022 21.0197 11.5221C21.7231 10.369 22.6225 9.79248 23.7294 9.79248C23.9254 9.79248 24.133 9.81554 24.3521 9.86167ZM23.8678 16.0998C24.2944 15.7193 24.5827 15.1542 24.7441 14.3932C24.7902 14.128 24.8248 13.8397 24.8248 13.54C24.8248 13.2056 24.7556 12.8481 24.6173 12.4907C24.4443 12.041 24.2137 11.7988 23.937 11.7412C23.5219 11.6604 23.1183 11.8911 22.7378 12.4561C22.4264 12.8942 22.2304 13.3555 22.1266 13.8282C22.069 14.0934 22.0459 14.3817 22.0459 14.67C22.0459 15.0044 22.1151 15.3618 22.2535 15.7192C22.4264 16.1689 22.657 16.4111 22.9338 16.4687C23.2221 16.5264 23.5334 16.3996 23.8678 16.0998ZM18.9674 11.1992C18.5523 10.4728 17.9296 10.0346 17.1225 9.86167C16.9034 9.81554 16.6959 9.79248 16.4998 9.79248C15.3929 9.79248 14.4935 10.369 13.7901 11.5221C13.1905 12.5022 12.8907 13.5861 12.8907 14.7737C12.8907 15.6616 13.0752 16.4226 13.4442 17.0568C13.8593 17.7832 14.4704 18.2214 15.2891 18.3944C15.5082 18.4405 15.7157 18.4636 15.9118 18.4636C17.0302 18.4636 17.9296 17.887 18.6215 16.734C19.2211 15.7423 19.5209 14.6584 19.5209 13.4708C19.5209 12.5714 19.3364 11.8219 18.9674 11.1992ZM17.5145 14.3932C17.3531 15.1542 17.0648 15.7193 16.6382 16.0998C16.3038 16.3996 15.9925 16.5264 15.7042 16.4687C15.4275 16.4111 15.1969 16.1689 15.0239 15.7192C14.8855 15.3618 14.8164 15.0044 14.8164 14.67C14.8164 14.3817 14.8394 14.0934 14.8971 13.8282C15.0008 13.3555 15.1969 12.8942 15.5082 12.4561C15.8887 11.8911 16.2923 11.6604 16.7074 11.7412C16.9841 11.7988 17.2147 12.041 17.3877 12.4907C17.5261 12.8481 17.5952 13.2056 17.5952 13.54C17.5952 13.8397 17.5722 14.128 17.5145 14.3932Z" fill="#7F54B3"/>
</g>
<defs>
<clipPath id="clip0_1698_35791">
<rect width="28" height="28" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Always show pricing group fields, disable if not available for a product type

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Provide a data-store agnostic way of untrashing orders.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Fix flakiness in `can set variation defaults` test.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Handle possibly empty refund value in reports (PHP 8.1+).

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Add support for taxonomy meta boxes in HPOS order edit screen.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
improve get_children transient validation

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add re-migrate support to HPOS CLI.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Ensure order ordering in order filter unit test

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Address possible PHP warning in wc-admin/options REST endpoint.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Additional changes for the core profiler plugins page

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Visual changes for the core profiler pages -- intro, guided setup, and skipped guided setup pages

View File

@ -0,0 +1,5 @@
Significance: patch
Type: enhancement
Comment: Add wcadmin_settings_change tracks event when adding/removing entries in shipping

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Show feedback bar after product block editor tour/guide

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add 'Tell me more' button to end of block editor tour for more information

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: Update styles of block editor tour

View File

@ -959,6 +959,7 @@
#variable_product_options #message,
#inventory_product_data .notice,
#general_product_data .notice,
#variable_product_options .notice {
display: flex;
margin: 10px;
@ -5363,6 +5364,7 @@ img.help_tip {
padding: 0;
margin: 0 0 0 -150px;
.req {
font-weight: 700;
font-style: normal;
@ -5379,6 +5381,14 @@ img.help_tip {
display: inline;
}
label,
input {
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
input:not([type="checkbox"]):not([type="radio"]) + .description {
display: block;
clear: both;

View File

@ -139,6 +139,7 @@ jQuery( function ( $ ) {
}
show_and_hide_panels();
disable_or_enable_fields();
change_product_type_tip( get_product_tip_content( select_val ) );
$( 'ul.wc-tabs li:visible' ).eq( 0 ).find( 'a' ).trigger( 'click' );
@ -261,6 +262,39 @@ jQuery( function ( $ ) {
} );
}
function disable_or_enable_fields() {
var product_type = $( 'select#product-type' ).val();
var hasDisabledFields = true;
$( `.enable_if_simple` ).each( function () {
$( this ).addClass( 'disabled' );
if ( $( this ).is( 'input' ) ) {
$( this ).prop( 'disabled', true );
}
} );
$( `.enable_if_external` ).each( function () {
$( this ).addClass( 'disabled' );
if ( $( this ).is( 'input' ) ) {
$( this ).prop( 'disabled', true );
}
} );
$( `.enable_if_${ product_type }` ).each( function () {
hasDisabledFields = false;
$( this ).removeClass( 'disabled' );
if ( $( this ).is( 'input' ) ) {
$( this ).prop( 'disabled', false );
}
} );
if (
hasDisabledFields &&
! $( '#general_product_data .woocommerce-message' ).is( ':visible' )
) {
$( `.pricing_disabled_fallback_message` ).show();
} else {
$( `.pricing_disabled_fallback_message` ).hide();
}
}
// Sale price schedule.
$( '.sale_price_dates_fields' ).each( function () {
var $these_sale_dates = $( this );
@ -891,7 +925,7 @@ jQuery( function ( $ ) {
} );
// Go to attributes tab when clicking on link in variations message
$( document.body ).on(
$( '#woocommerce-product-data' ).on(
'click',
'#variable_product_options .add-attributes-message a[href="#product_attributes"]',
function () {
@ -902,6 +936,34 @@ jQuery( function ( $ ) {
}
);
// Go to variations tab when clicking on link in the general tab message
$( '#woocommerce-product-data' ).on(
'click',
'#general_product_data .woocommerce-message a[href="#variable_product_options"]',
function () {
$(
'#woocommerce-product-data .variations_tab a[href="#variable_product_options"]'
).trigger( 'click' );
return false;
}
);
// Go to linked products tab when clicking on link in the general tab message
$( '#woocommerce-product-data' ).on(
'click',
'#general_product_data .woocommerce-message a[href="#linked_product_data"]',
function () {
$(
'#woocommerce-product-data .linked_product_tab a[href="#linked_product_data"]'
).trigger( 'click' );
return false;
}
);
// Uploading files.
var downloadable_file_frame;
var file_path_field;

View File

@ -89,7 +89,7 @@ class WC_Meta_Box_Product_Data {
'general' => array(
'label' => __( 'General', 'woocommerce' ),
'target' => 'general_product_data',
'class' => array( 'hide_if_grouped' ),
'class' => array(),
'priority' => 10,
),
'inventory' => array(

View File

@ -36,14 +36,61 @@ defined( 'ABSPATH' ) || exit;
?>
</div>
<div class="options_group pricing show_if_simple show_if_external hidden">
<div class="inline notice woocommerce-message show_if_variable">
<p>
<?php
/**
* Allow developers to change the general pricing message.
*
* @since 7.9.0
*/
echo esc_html( apply_filters( 'woocommerce_general_pricing_disabled_message', __( 'You can manage pricing and other details individually for each product variation.', 'woocommerce' ), 'variable' ) );
?>
<a class="variations-tab-navigation-link" href="#variable_product_options">
<?php
esc_html_e( 'Go to Variations', 'woocommerce' );
?>
</a>
</p>
</div>
<div class="inline notice woocommerce-message show_if_grouped">
<p>
<?php
/**
* Allow developers to change the general pricing message.
*
* @since 7.9.0
*/
echo esc_html( apply_filters( 'woocommerce_general_pricing_disabled_message', __( 'You can manage pricing and other details individually for each product added to this group.', 'woocommerce' ), 'grouped' ) );
?>
<a class="linked-products-navigation-link" href="#linked_product_data"><?php esc_html_e( 'Go to Linked Products', 'woocommerce' ); ?></a>
</p>
</div>
<div class="inline notice woocommerce-message pricing_disabled_fallback_message">
<p>
<?php
/**
* Allow developers to change the general pricing message.
*
* @since 7.9.0
*/
echo esc_html( apply_filters( 'woocommerce_general_pricing_disabled_message', __( 'You can manage pricing and other details in one of the other tabs.', 'woocommerce' ), 'grouped' ) );
?>
</p>
</div>
<div class="options_group pricing">
<?php
woocommerce_wp_text_input(
array(
'id' => '_regular_price',
'value' => $product_object->get_regular_price( 'edit' ),
'label' => __( 'Regular price', 'woocommerce' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'id' => '_regular_price',
'value' => $product_object->get_regular_price( 'edit' ),
'label' => __( 'Regular price', 'woocommerce' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'label_class' => 'enable_if_simple enable_if_external',
'data_type' => 'price',
'class' => 'enable_if_simple enable_if_external',
)
);
@ -53,7 +100,9 @@ defined( 'ABSPATH' ) || exit;
'value' => $product_object->get_sale_price( 'edit' ),
'data_type' => 'price',
'label' => __( 'Sale price', 'woocommerce' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'description' => '<a href="#" class="sale_schedule">' . __( 'Schedule', 'woocommerce' ) . '</a>',
'label_class' => 'enable_if_simple enable_if_external',
'description' => '<a href="#" class="sale_schedule show_if_simple show_if_external">' . __( 'Schedule', 'woocommerce' ) . '</a>',
'class' => 'enable_if_simple enable_if_external',
)
);
@ -93,7 +142,7 @@ defined( 'ABSPATH' ) || exit;
if ( $downloadable_files ) {
foreach ( $downloadable_files as $key => $file ) {
$disabled_download = isset( $file['enabled'] ) && false === $file['enabled'];
$disabled_download = isset( $file['enabled'] ) && false === $file['enabled'];
$disabled_downloads_count += (int) $disabled_download;
include __DIR__ . '/html-product-download.php';
}
@ -105,8 +154,8 @@ defined( 'ABSPATH' ) || exit;
<th colspan="2">
<a href="#" class="button insert" data-row="
<?php
$key = '';
$file = array(
$key = '';
$file = array(
'file' => '',
'name' => '',
);

View File

@ -512,7 +512,7 @@ class WC_Admin_Report {
}
if ( $data_key ) {
$prepared_data[ $time ][1] += $d->$data_key;
$prepared_data[ $time ][1] += is_numeric( $d->$data_key ) ? $d->$data_key : 0;
} else {
$prepared_data[ $time ][1] ++;
}

View File

@ -326,7 +326,7 @@ class WC_Report_Sales_By_Date extends WC_Admin_Report {
);
foreach ( $this->report_data->partial_refunds as $key => $order ) {
$this->report_data->partial_refunds[ $key ]->net_refund = $order->total_refund - ( $order->total_shipping + $order->total_tax + $order->total_shipping_tax );
$this->report_data->partial_refunds[ $key ]->net_refund = (float) $order->total_refund - ( (float) $order->total_shipping + (float) $order->total_tax + (float) $order->total_shipping_tax );
}
/**

View File

@ -31,6 +31,7 @@ function woocommerce_wp_text_input( $field, WC_Data $data = null ) {
$field['name'] = isset( $field['name'] ) ? $field['name'] : $field['id'];
$field['type'] = isset( $field['type'] ) ? $field['type'] : 'text';
$field['desc_tip'] = isset( $field['desc_tip'] ) ? $field['desc_tip'] : false;
$field['label_class'] = isset( $field['label_class'] ) ? $field['label_class'] : '';
$data_type = empty( $field['data_type'] ) ? '' : $field['data_type'];
switch ( $data_type ) {
@ -65,8 +66,10 @@ function woocommerce_wp_text_input( $field, WC_Data $data = null ) {
}
}
$label_class = ! empty( $field['label_class'] ) ? 'class="' . esc_attr( $field['label_class'] ) . '" ' : '';
echo '<p class="form-field ' . esc_attr( $field['id'] ) . '_field ' . esc_attr( $field['wrapper_class'] ) . '">
<label for="' . esc_attr( $field['id'] ) . '">' . wp_kses_post( $field['label'] ) . '</label>';
<label ' . esc_attr( $label_class ) . ' for="' . esc_attr( $field['id'] ) . '">' . wp_kses_post( $field['label'] ) . '</label>';
if ( ! empty( $field['description'] ) && false !== $field['desc_tip'] ) {
echo wc_help_tip( $field['description'] );

View File

@ -2995,6 +2995,18 @@ class WC_AJAX {
// That's fine, it's not in the database anyways. NEXT!
continue;
}
/**
* Notify that a non-option setting has been deleted.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'shipping_zone',
'action' => 'delete',
)
);
WC_Shipping_Zones::delete_zone( $zone_id );
continue;
}
@ -3024,19 +3036,18 @@ class WC_AJAX {
);
$zone->set_zone_order( $zone_data['zone_order'] );
}
global $current_tab;
$current_tab = 'shipping';
/**
* Completes the saving process for options.
*
* @since 7.8.0
*/
do_action( 'woocommerce_update_options' );
$zone->save();
}
}
global $current_tab;
$current_tab = 'shipping';
/**
* Completes the saving process for options.
*
* @since 7.8.0
*/
do_action( 'woocommerce_update_options' );
wp_send_json_success(
array(
'zones' => WC_Shipping_Zones::get_zones( 'json' ),
@ -3066,15 +3077,31 @@ class WC_AJAX {
$zone_id = wc_clean( wp_unslash( $_POST['zone_id'] ) );
$zone = new WC_Shipping_Zone( $zone_id );
// A shipping zone can be created here if the user is adding a method without first saving the shipping zone.
if ( '' === $zone_id ) {
/**
* Notified that a non-option setting has been added.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'shipping_zone',
'action' => 'add',
)
);
}
/**
* Notify that a non-option setting has been updated.
* Notify that a non-option setting has been added.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'zone_method',
'id' => 'zone_method',
'action' => 'add',
)
);
$instance_id = $zone->add_shipping_method( wc_clean( wp_unslash( $_POST['method_id'] ) ) );
@ -3178,11 +3205,26 @@ class WC_AJAX {
$zone_id = wc_clean( wp_unslash( $_POST['zone_id'] ) );
$zone = new WC_Shipping_Zone( $zone_id );
// A shipping zone can be created here if the user is adding a method without first saving the shipping zone.
if ( '' === $zone_id ) {
/**
* Notifies that a non-option setting has been added.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'shipping_zone',
'action' => 'add',
)
);
}
$changes = wp_unslash( $_POST['changes'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( isset( $changes['zone_name'] ) ) {
/**
* Completes the saving process for options.
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
@ -3192,7 +3234,7 @@ class WC_AJAX {
if ( isset( $changes['zone_locations'] ) ) {
/**
* Completes the saving process for options.
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
@ -3218,7 +3260,7 @@ class WC_AJAX {
if ( isset( $changes['zone_postcodes'] ) ) {
/**
* Completes the saving process for options.
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
@ -3231,12 +3273,6 @@ class WC_AJAX {
}
if ( isset( $changes['methods'] ) ) {
/**
* Completes the saving process for options.
*
* @since 7.8.0
*/
do_action( 'woocommerce_update_non_option_setting', array( 'id' => 'zone_methods' ) );
foreach ( $changes['methods'] as $instance_id => $data ) {
$method_id = $wpdb->get_var( $wpdb->prepare( "SELECT method_id FROM {$wpdb->prefix}woocommerce_shipping_zone_methods WHERE instance_id = %d", $instance_id ) );
@ -3245,6 +3281,18 @@ class WC_AJAX {
$option_key = $shipping_method->get_instance_option_key();
if ( $wpdb->delete( "{$wpdb->prefix}woocommerce_shipping_zone_methods", array( 'instance_id' => $instance_id ) ) ) {
delete_option( $option_key );
/**
* Notifies that a non-option setting has been deleted.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'zone_method',
'action' => 'delete',
)
);
do_action( 'woocommerce_shipping_zone_method_deleted', $instance_id, $method_id, $zone_id );
}
continue;
@ -3260,7 +3308,7 @@ class WC_AJAX {
if ( isset( $method_data['method_order'] ) ) {
/**
* Completes the saving process for options.
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
@ -3270,7 +3318,7 @@ class WC_AJAX {
if ( isset( $method_data['enabled'] ) ) {
/**
* Completes the saving process for options.
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
@ -3385,6 +3433,18 @@ class WC_AJAX {
// That's fine, it's not in the database anyways. NEXT!
continue;
}
/**
* Notifies that a non-option setting has been deleted.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'shipping_class',
'action' => 'delete',
)
);
wp_delete_term( $term_id, 'product_shipping_class' );
continue;
}
@ -3426,9 +3486,27 @@ class WC_AJAX {
if ( empty( $update_args['name'] ) ) {
continue;
}
/**
* Notifies that a non-option setting has been added.
*
* @since 7.8.0
*/
do_action(
'woocommerce_update_non_option_setting',
array(
'id' => 'shipping_class',
'action' => 'add',
)
);
$inserted_term = wp_insert_term( $update_args['name'], 'product_shipping_class', $update_args );
$term_id = is_wp_error( $inserted_term ) ? 0 : $inserted_term['term_id'];
} else {
/**
* Notifies that a non-option setting has been updated.
*
* @since 7.8.0
*/
do_action( 'woocommerce_update_non_option_setting', array( 'id' => 'shipping_class' ) );
wp_update_term( $term_id, 'product_shipping_class', $update_args );
}

View File

@ -2286,4 +2286,13 @@ class WC_Order extends WC_Abstract_Order {
public function is_created_via( $modus ) {
return apply_filters( 'woocommerce_order_is_created_via', $modus === $this->get_created_via(), $this, $modus );
}
/**
* Attempts to restore the specified order back to its original status (after having been trashed).
*
* @return bool If the operation was successful.
*/
public function untrash(): bool {
return (bool) $this->data_store->untrash_order( $this );
}
}

View File

@ -1181,4 +1181,20 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
);
WC_Order::prime_raw_meta_data_cache( $raw_meta_data_collection, 'orders' );
}
/**
* Attempts to restore the specified order back to its original status (after having been trashed).
*
* @param WC_Order $order The order to be untrashed.
*
* @return bool If the operation was successful.
*/
public function untrash_order( WC_Order $order ): bool {
if ( ! wp_untrash_post( $order->get_id() ) ) {
return false;
}
$order->set_status( get_post_field( 'post_status', $order->get_id() ) );
return (bool) $order->save();
}
}

View File

@ -119,11 +119,11 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
public function read_children( &$product, $force_read = false ) {
$children_transient_name = 'wc_product_children_' . $product->get_id();
$children = get_transient( $children_transient_name );
if ( false === $children ) {
if ( empty( $children ) || ! is_array( $children ) ) {
$children = array();
}
if ( empty( $children ) || ! is_array( $children ) || ! isset( $children['all'] ) || ! isset( $children['visible'] ) || $force_read ) {
if ( ! isset( $children['all'] ) || ! isset( $children['visible'] ) || $force_read ) {
$all_args = array(
'post_parent' => $product->get_id(),
'post_type' => 'product_variation',

View File

@ -43,6 +43,21 @@ class WC_Settings_Tracking {
*/
protected $modified_options = array();
/**
* List of options that have been deleted.
*
* @var array
*/
protected $deleted_options = array();
/**
* List of options that have been added.
*
* @var array
*/
protected $added_options = array();
/**
* Toggled options.
*
@ -74,7 +89,11 @@ class WC_Settings_Tracking {
if ( ! in_array( $option['id'], $this->allowed_options, true ) ) {
$this->allowed_options[] = $option['id'];
}
if ( ! in_array( $option['id'], $this->updated_options, true ) ) {
if ( 'add' === $option['action'] ) {
$this->added_options[] = $option['id'];
} elseif ( 'delete' === $option['action'] ) {
$this->deleted_options[] = $option['id'];
} elseif ( ! in_array( $option['id'], $this->updated_options, true ) ) {
$this->updated_options[] = $option['id'];
}
}
@ -143,13 +162,23 @@ class WC_Settings_Tracking {
public function send_settings_change_event() {
global $current_tab, $current_section;
if ( empty( $this->updated_options ) ) {
if ( empty( $this->updated_options ) && empty( $this->deleted_options ) && empty( $this->added_options ) ) {
return;
}
$properties = array(
'settings' => implode( ',', $this->updated_options ),
);
$properties = array();
if ( ! empty( $this->updated_options ) ) {
$properties['settings'] = implode( ',', $this->updated_options );
}
if ( ! empty( $this->deleted_options ) ) {
$properties['deleted'] = implode( ',', $this->deleted_options );
}
if ( ! empty( $this->added_options ) ) {
$properties['added'] = implode( ',', $this->added_options );
}
foreach ( $this->toggled_options as $state => $options ) {
if ( ! empty( $options ) ) {

View File

@ -69,9 +69,9 @@ class Options extends \WC_REST_Data_Controller {
* @return WP_Error|boolean
*/
public function get_item_permissions_check( $request ) {
$params = explode( ',', $request['options'] );
$params = ( isset( $request['options'] ) && is_string( $request['options'] ) ) ? explode( ',', $request['options'] ) : array();
if ( ! isset( $request['options'] ) || ! is_array( $params ) ) {
if ( ! $params ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'You must supply an array of options.', 'woocommerce' ), 500 );
}
@ -233,13 +233,13 @@ class Options extends \WC_REST_Data_Controller {
* @return array Options object with option values.
*/
public function get_options( $request ) {
$params = explode( ',', $request['options'] );
$options = array();
if ( ! is_array( $params ) ) {
return array();
if ( empty( $request['options'] ) || ! is_string( $request['options'] ) ) {
return $options;
}
$params = explode( ',', $request['options'] );
foreach ( $params as $option ) {
$options[ $option ] = get_option( $option );
}

View File

@ -304,6 +304,11 @@ class CLIRunner {
* ---
* default: Output of function `wc_get_order_types( 'cot-migration' )`
*
* [--re-migrate]
* : Attempt to re-migrate orders that failed verification. You should only use this option when you have never run the site with HPOS as authoritative source of order data yet, or you have manually checked the reported errors, otherwise, you risk stale data overwriting the more recent data.
* This option can only be enabled when --verbose flag is also set.
* default: false
*
* ## EXAMPLES
*
* # Verify migrated order data, 500 orders at a time.
@ -327,6 +332,7 @@ class CLIRunner {
'end-at' => - 1,
'verbose' => false,
'order-types' => '',
're-migrate' => false,
)
);
@ -340,6 +346,7 @@ class CLIRunner {
$batch_size = ( (int) $assoc_args['batch-size'] ) === 0 ? 500 : (int) $assoc_args['batch-size'];
$verbose = (bool) $assoc_args['verbose'];
$order_types = wc_get_order_types( 'cot-migration' );
$remigrate = (bool) $assoc_args['re-migrate'];
if ( ! empty( $assoc_args['order-types'] ) ) {
$passed_order_types = array_map( 'trim', explode( ',', $assoc_args['order-types'] ) );
$order_types = array_intersect( $order_types, $passed_order_types );
@ -415,6 +422,36 @@ class CLIRunner {
$errors
)
);
if ( $remigrate ) {
WP_CLI::warning(
sprintf(
__( 'Attempting to remigrate...', 'woocommerce' )
)
);
$failed_ids = array_keys( $failed_ids_in_current_batch );
$this->synchronizer->process_batch( $failed_ids );
$errors_in_remigrate_batch = $this->post_to_cot_migrator->verify_migrated_orders( $failed_ids );
$errors_in_remigrate_batch = $this->verify_meta_data( $failed_ids, $errors_in_remigrate_batch );
if ( count( $errors_in_remigrate_batch ) > 0 ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- This is a CLI command and debugging code is intended.
$formatted_errors = print_r( $errors_in_remigrate_batch, true );
WP_CLI::warning(
sprintf(
/* Translators: %1$d is number of errors and %2$s is the formatted array of order IDs. */
_n(
'%1$d error found: %2$s when re-migrating order. Please review the error above.',
'%1$d errors found: %2$s when re-migrating orders. Please review the errors above.',
count( $errors_in_remigrate_batch ),
'woocommerce'
),
count( $errors_in_remigrate_batch ),
$formatted_errors
)
);
} else {
WP_CLI::warning( 'Re-migration successful.', 'woocommerce' );
}
}
}
$progress->tick();

View File

@ -243,13 +243,7 @@ abstract class MetaToCustomTableMigrator extends TableMigrator {
$to_insert = array_diff_key( $data['data'], $existing_records );
$this->process_insert_batch( $to_insert );
$existing_records = array_filter(
$existing_records,
function( $record_data ) {
return '1' === $record_data->modified;
}
);
$to_update = array_intersect_key( $data['data'], $existing_records );
$to_update = array_intersect_key( $data['data'], $existing_records );
$this->process_update_batch( $to_update, $existing_records );
}
@ -357,38 +351,13 @@ abstract class MetaToCustomTableMigrator extends TableMigrator {
$entity_id_placeholder = implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) );
// Additional SQL to check if the row needs update according to the column mapping.
// The IFNULL and CHAR(0) "hack" is needed because NULLs can't be directly compared in SQL.
$modified_selector = array();
$core_column_mapping = array_filter(
$this->core_column_mapping,
function( $mapping ) {
return ! isset( $mapping['select_clause'] );
}
);
foreach ( $core_column_mapping as $column_name => $mapping ) {
if ( $column_name === $source_primary_key_column ) {
continue;
}
$modified_selector[] =
"IFNULL(source.$column_name,CHAR(0)) != IFNULL(destination.{$mapping['destination']},CHAR(0))"
. ( 'string' === $mapping['type'] ? ' COLLATE ' . $wpdb->collate : '' );
}
if ( empty( $modified_selector ) ) {
$modified_selector = ', 1 AS modified';
} else {
$modified_selector = trim( implode( ' OR ', $modified_selector ) );
$modified_selector = ", if( $modified_selector, 1, 0 ) AS modified";
}
$additional_where = $this->get_additional_where_clause_for_get_data_to_insert_or_update( $entity_ids );
$already_migrated_entity_ids = $this->db_get_results(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- All columns and table names are hardcoded.
"
SELECT source.`$source_primary_key_column` as source_id, destination.`$destination_primary_key_column` as destination_id $modified_selector
SELECT source.`$source_primary_key_column` as source_id, destination.`$destination_primary_key_column` as destination_id
FROM `$destination_table` destination
JOIN `$source_table` source ON source.`$source_destination_join_column` = destination.`$destination_source_join_column`
WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder ) $additional_where

View File

@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Internal\Admin\Orders;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
/**
* Class Edit.
@ -26,6 +27,13 @@ class Edit {
*/
private $custom_meta_box;
/**
* Instance of the TaxonomiesMetaBox class. Used to render meta box for taxonomies.
*
* @var TaxonomiesMetaBox
*/
private $taxonomies_meta_box;
/**
* Instance of WC_Order to be used in metaboxes.
*
@ -110,10 +118,16 @@ class Edit {
if ( ! isset( $this->custom_meta_box ) ) {
$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
}
if ( ! isset( $this->taxonomies_meta_box ) ) {
$this->taxonomies_meta_box = wc_get_container()->get( TaxonomiesMetaBox::class );
}
$this->add_save_meta_boxes();
$this->handle_order_update();
$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
$this->add_order_specific_meta_box();
$this->add_order_taxonomies_meta_box();
/**
* From wp-admin/includes/meta-boxes.php.
@ -159,6 +173,15 @@ class Edit {
);
}
/**
* Render custom meta box.
*
* @return void
*/
private function add_order_taxonomies_meta_box() {
$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
}
/**
* Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
*
@ -176,6 +199,10 @@ class Edit {
check_admin_referer( $this->get_order_edit_nonce_action() );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized later on by taxonomies_meta_box object.
$taxonomy_input = isset( $_POST['tax_input'] ) ? wp_unslash( $_POST['tax_input'] ) : null;
$this->taxonomies_meta_box->save_taxonomies( $this->order, $taxonomy_input );
/**
* Save meta for shop order.
*

View File

@ -0,0 +1,147 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
/**
* TaxonomiesMetaBox class, renders taxonomy sidebar widget on order edit screen.
*/
class TaxonomiesMetaBox {
/**
* Order Table data store class.
*
* @var OrdersTableDataStore
*/
private $orders_table_data_store;
/**
* Dependency injection init method.
*
* @param OrdersTableDataStore $orders_table_data_store Order Table data store class.
*
* @return void
*/
public function init( OrdersTableDataStore $orders_table_data_store ) {
$this->orders_table_data_store = $orders_table_data_store;
}
/**
* Registers meta boxes to be rendered in order edit screen for taxonomies.
*
* Note: This is re-implementation of part of WP core's `register_and_do_post_meta_boxes` function. Since the code block that add meta box for taxonomies is not filterable, we have to re-implement it.
*
* @param string $screen_id Screen ID.
* @param string $order_type Order type to register meta boxes for.
*
* @return void
*/
public function add_taxonomies_meta_boxes( string $screen_id, string $order_type ) {
include_once ABSPATH . 'wp-admin/includes/meta-boxes.php';
$taxonomies = get_object_taxonomies( $order_type );
// All taxonomies.
foreach ( $taxonomies as $tax_name ) {
$taxonomy = get_taxonomy( $tax_name );
if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) {
continue;
}
if ( 'post_categories_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_categories_meta_box' );
}
if ( 'post_tags_meta_box' === $taxonomy->meta_box_cb ) {
$taxonomy->meta_box_cb = array( $this, 'order_tags_meta_box' );
}
$label = $taxonomy->labels->name;
if ( ! is_taxonomy_hierarchical( $tax_name ) ) {
$tax_meta_box_id = 'tagsdiv-' . $tax_name;
} else {
$tax_meta_box_id = $tax_name . 'div';
}
add_meta_box(
$tax_meta_box_id,
$label,
$taxonomy->meta_box_cb,
$screen_id,
'side',
'core',
array(
'taxonomy' => $tax_name,
'__back_compat_meta_box' => true,
)
);
}
}
/**
* Save handler for taxonomy data.
*
* @param \WC_Abstract_Order $order Order object.
* @param array|null $taxonomy_input Taxonomy input passed from input.
*/
public function save_taxonomies( \WC_Abstract_Order $order, $taxonomy_input ) {
if ( ! isset( $taxonomy_input ) ) {
return;
}
$sanitized_tax_input = $this->sanitize_tax_input( $taxonomy_input );
$sanitized_tax_input = $this->orders_table_data_store->init_default_taxonomies( $order, $sanitized_tax_input );
$this->orders_table_data_store->set_custom_taxonomies( $order, $sanitized_tax_input );
}
/**
* Sanitize taxonomy input by calling sanitize callbacks for each registered taxonomy.
*
* @param array|null $taxonomy_data Nonce verified taxonomy input.
*
* @return array Sanitized taxonomy input.
*/
private function sanitize_tax_input( $taxonomy_data ) : array {
$sanitized_tax_input = array();
if ( ! is_array( $taxonomy_data ) ) {
return $sanitized_tax_input;
}
// Convert taxonomy input to term IDs, to avoid ambiguity.
foreach ( $taxonomy_data as $taxonomy => $terms ) {
$tax_object = get_taxonomy( $taxonomy );
if ( $tax_object && isset( $tax_object->meta_box_sanitize_cb ) ) {
$sanitized_tax_input[ $taxonomy ] = call_user_func_array( $tax_object->meta_box_sanitize_cb, array( $taxonomy, $terms ) );
}
}
return $sanitized_tax_input;
}
/**
* Add the categories meta box to the order screen. This is just a wrapper around the post_categories_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_categories_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_categories_meta_box( $post, $box );
}
/**
* Add the tags meta box to the order screen. This is just a wrapper around the post_tags_meta_box.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $box Meta box args.
*
* @return void
*/
public function order_tags_meta_box( $order, $box ) {
$post = get_post( $order->get_id() );
post_tags_meta_box( $post, $box );
}
}

View File

@ -816,6 +816,7 @@ class DefaultFreeExtensions {
*
* - Updated description for the core-profiler.
* - Adds learn_more_link and label.
* - Adds install_priority, which is used to sort the plugins. The value is determined by the plugin size. Lower = smaller.
*
* @param array $plugins Array of plugins.
*
@ -824,47 +825,72 @@ class DefaultFreeExtensions {
public static function with_core_profiler_fields( array $plugins ) {
$_plugins = array(
'woocommerce-payments' => array(
'label' => __( 'Get paid with WooCommerce Payments', 'woocommerce' ),
'description' => __( 'Accept credit cards and other popular payment methods smoothly.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/woocommerce-payments',
'label' => __( 'Get paid with WooCommerce Payments', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( "Securely accept payments and manage payment activity straight from your store's dashboard", 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/woocommerce-payments',
'install_priority' => 5,
),
'woocommerce-services:shipping' => array(
'label' => __( 'Print shipping labels with WooCommerce Shipping', 'woocommerce' ),
'description' => __( 'Print USPS and DHL labels directly from your dashboard and save on shipping.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/woocommerce-shipping',
'label' => __( 'Print shipping labels with WooCommerce Shipping', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( 'Print USPS and DHL labels directly from your dashboard and save on shipping.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/woocommerce-shipping',
'install_priority' => 3,
),
'jetpack' => array(
'label' => __( 'Enhance security with Jetpack', 'woocommerce' ),
'description' => __( 'Get auto real-time backups, malware scans, and spam protection.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/jetpack',
'label' => __( 'Enhance security with Jetpack', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-jetpack.svg', WC_PLUGIN_FILE ),
'description' => __( 'Get auto real-time backups, malware scans, and spam protection.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/jetpack',
'install_priority' => 8,
),
'pinterest-for-woocommerce' => array(
'label' => __( 'Showcase your products with Pinterest', 'woocommerce' ),
'description' => __( 'Get your products in front of a highly engaged audience.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/pinterest-for-woocommerce',
'label' => __( 'Showcase your products with Pinterest', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-pinterest.svg', WC_PLUGIN_FILE ),
'description' => __( 'Get your products in front of a highly engaged audience.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/pinterest-for-woocommerce',
'install_priority' => 2,
),
'mailpoet' => array(
'label' => __( 'Reach your customers with MailPoet', 'woocommerce' ),
'description' => __( 'Send purchase follow-up emails, newsletters, and promotional campaigns.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/mailpoet',
'label' => __( 'Reach your customers with MailPoet', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-mailpoet.svg', WC_PLUGIN_FILE ),
'description' => __( 'Send purchase follow-up emails, newsletters, and promotional campaigns.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/mailpoet',
'install_priority' => 7,
),
'tiktok-for-business' => array(
'label' => __( 'Create ad campaigns with TikTok', 'woocommerce' ),
'description' => __( 'Create advertising campaigns and reach one billion global users.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tiktok-for-woocommerce',
'label' => __( 'Create ad campaigns with TikTok', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-tiktok.svg', WC_PLUGIN_FILE ),
'description' => __( 'Create advertising campaigns and reach one billion global users.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tiktok-for-woocommerce',
'install_priority' => 1,
),
'google-listings-and-ads' => array(
'label' => __( 'Drive sales with Google Listings & Ads', 'woocommerce' ),
'description' => __( 'Reach millions of active shoppers across Google with free product listings and ads.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/google-listings-and-ads',
'label' => __( 'Drive sales with Google Listings & Ads', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-google.svg', WC_PLUGIN_FILE ),
'description' => __( 'Reach millions of active shoppers across Google with free product listings and ads.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/google-listings-and-ads',
'install_priority' => 6,
),
'woocommerce-services:tax' => array(
'label' => __( 'Get automated tax rates with WooCommerce Tax', 'woocommerce' ),
'description' => __( 'Automatically calculate how much sales tax should be collected by city, country, or state.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tax',
'label' => __( 'Get automated tax rates with WooCommerce Tax', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE ),
'description' => __( 'Automatically calculate how much sales tax should be collected by city, country, or state.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/tax',
'install_priority' => 4,
),
);
// Copy shipping for the core-profiler and remove is_visible conditions, except for the country restriction.
$_plugins['woocommerce-services:shipping']['is_visible'] = [
array(
'type' => 'base_location_country',
'value' => 'US',
'operation' => '=',
),
];
$remove_plugins_activated_rule = function( $is_visible ) {
$is_visible = array_filter(
array_map(

View File

@ -1641,6 +1641,84 @@ FROM $order_meta_table
$changes = $order->get_changes();
$this->update_address_index_meta( $order, $changes );
$default_taxonomies = $this->init_default_taxonomies( $order, array() );
$this->set_custom_taxonomies( $order, $default_taxonomies );
}
/**
* Set default taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set default taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return array Sanitized tax input with default taxonomies.
*/
public function init_default_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( 'auto-draft' === $order->get_status() ) {
return $sanitized_tax_input;
}
foreach ( get_object_taxonomies( $order->get_type(), 'object' ) as $taxonomy => $tax_object ) {
if ( empty( $tax_object->default_term ) ) {
return $sanitized_tax_input;
}
// Filter out empty terms.
if ( isset( $sanitized_tax_input[ $taxonomy ] ) && is_array( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = array_filter( $sanitized_tax_input[ $taxonomy ] );
}
// Passed custom taxonomy list overwrites the existing list if not empty.
$terms = wp_get_object_terms( $order->get_id(), $taxonomy, array( 'fields' => 'ids' ) );
if ( ! empty( $terms ) && empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$sanitized_tax_input[ $taxonomy ] = $terms;
}
if ( empty( $sanitized_tax_input[ $taxonomy ] ) ) {
$default_term_id = get_option( 'default_term_' . $taxonomy );
if ( ! empty( $default_term_id ) ) {
$sanitized_tax_input[ $taxonomy ] = array( (int) $default_term_id );
}
}
}
return $sanitized_tax_input;
}
/**
* Set custom taxonomies for the order.
*
* Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set custom taxonomies is not filterable, we have to re-implement it.
*
* @param \WC_Abstract_Order $order Order object.
* @param array $sanitized_tax_input Sanitized taxonomy input.
*
* @return void
*/
public function set_custom_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
if ( empty( $sanitized_tax_input ) ) {
return;
}
foreach ( $sanitized_tax_input as $taxonomy => $tags ) {
$taxonomy_obj = get_taxonomy( $taxonomy );
if ( ! $taxonomy_obj ) {
/* translators: %s: Taxonomy name. */
_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Invalid taxonomy: %s.', 'woocommerce' ), $taxonomy ) ), '7.9.0' );
continue;
}
// array = hierarchical, string = non-hierarchical.
if ( is_array( $tags ) ) {
$tags = array_filter( $tags );
}
if ( current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
wp_set_post_terms( $order->get_id(), $tags, $taxonomy );
}
}
}
/**

View File

@ -9,7 +9,9 @@ use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\Edit;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\Admin\Orders\ListTable;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
@ -28,6 +30,7 @@ class OrderAdminServiceProvider extends AbstractServiceProvider {
Edit::class,
ListTable::class,
EditLock::class,
TaxonomiesMetaBox::class,
);
/**
@ -41,5 +44,6 @@ class OrderAdminServiceProvider extends AbstractServiceProvider {
$this->share( Edit::class )->addArgument( PageController::class );
$this->share( ListTable::class )->addArgument( PageController::class );
$this->share( EditLock::class );
$this->share( TaxonomiesMetaBox::class )->addArgument( OrdersTableDataStore::class );
}
}

View File

@ -138,7 +138,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step( 'Expand all variations.', async () => {
@ -231,7 +231,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step( 'Expand all variations.', async () => {
@ -344,7 +344,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step(
@ -387,7 +387,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step(
@ -418,7 +418,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step( 'Expand all variations', async () => {
@ -538,7 +538,11 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step( 'Wait for block overlay to disappear.', async () => {
await expect( page.locator( '.blockOverlay' ) ).not.toBeVisible();
} );
await test.step( 'Select variation defaults', async () => {
@ -598,7 +602,7 @@ test.describe( 'Update variations', () => {
} );
await test.step( 'Click on the "Variations" tab.', async () => {
await page.locator( 'a[href="#variable_product_options"]' ).click();
await page.locator( '.variations_tab > a[href="#variable_product_options"]' ).click();
} );
await test.step( 'Click "Remove" on a variation', async () => {

View File

@ -190,7 +190,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
*/
public function test_apply_coupon_across_status() {
$coupon_code = 'coupon_test_count_across_status';
$coupon = WC_Helper_Coupon::create_coupon( $coupon_code );
$coupon = WC_Helper_Coupon::create_coupon( $coupon_code );
$this->assertEquals( 0, $coupon->get_usage_count() );
$order = WC_Helper_Order::create_order();
@ -253,8 +253,8 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
*/
public function test_apply_coupon_stores_meta_data() {
$coupon_code = 'coupon_test_meta_data';
$coupon = WC_Helper_Coupon::create_coupon( $coupon_code );
$order = WC_Helper_Order::create_order();
$coupon = WC_Helper_Coupon::create_coupon( $coupon_code );
$order = WC_Helper_Order::create_order();
$order->set_status( 'processing' );
$order->save();
$order->apply_coupon( $coupon_code );
@ -324,4 +324,29 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$order = wc_get_order( $order->get_id() );
$this->assertInstanceOf( Automattic\WooCommerce\Admin\Overrides\Order::class, $order );
}
/**
* @testDox When a taxonomy with a default term is set on the order, it's inserted when a new order is created.
*/
public function test_default_term_for_custom_taxonomy() {
$custom_taxonomy = register_taxonomy(
'custom_taxonomy',
'shop_order',
array(
'default_term' => 'new_term',
),
);
// Set user who has access to create term.
$current_user_id = get_current_user_id();
$user = new WP_User( wp_create_user( 'test', '' ) );
$user->set_role( 'administrator' );
wp_set_current_user( $user->ID );
$order = wc_create_order();
wp_set_current_user( $current_user_id );
$order_terms = wp_list_pluck( wp_get_object_terms( $order->get_id(), $custom_taxonomy->name ), 'name' );
$this->assertContains( 'new_term', $order_terms );
}
}

View File

@ -275,4 +275,22 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
}
}
/**
* Test the untrashing an order works as expected when done in an agnostic way (ie, not depending directly on
* functions such as `wp_untrash_post()`.
*
* @return void
*/
public function test_untrash(): void {
$order = WC_Helper_Order::create_order();
$order_id = $order->get_id();
$original_status = $order->get_status();
$order->delete();
$this->assertEquals( 'trash', $order->get_status(), 'The order was successfully trashed.' );
$order = wc_get_order( $order_id );
$this->assertTrue( $order->untrash(), 'The order was restored from the trash.' );
$this->assertEquals( $original_status, $order->get_status(), 'The original order status is restored following untrash.' );
}
}

View File

@ -181,6 +181,7 @@ class OrdersTableQueryTests extends WC_Unit_Test_Case {
*/
public function test_query_filters() {
$order1 = new \WC_Order();
$order1->set_date_created( time() - HOUR_IN_SECONDS );
$order1->save();
$order2 = new \WC_Order();
@ -198,10 +199,10 @@ class OrdersTableQueryTests extends WC_Unit_Test_Case {
$this->assertCount( 0, wc_get_orders( array() ) );
remove_all_filters( 'woocommerce_orders_table_query_clauses' );
// Force a query that sorts orders by id DESC (as opposed to the default date ASC) if a query arg is present.
$filter_callback = function( $clauses, $query, $query_args ) use ( $order1 ) {
// Force a query that sorts orders by id ASC (as opposed to the default date DESC) if a query arg is present.
$filter_callback = function( $clauses, $query, $query_args ) {
if ( ! empty( $query_args['my_custom_arg'] ) ) {
$clauses['orderby'] = $query->get_table_name( 'orders' ) . '.id DESC';
$clauses['orderby'] = $query->get_table_name( 'orders' ) . '.id ASC';
}
return $clauses;
@ -211,7 +212,8 @@ class OrdersTableQueryTests extends WC_Unit_Test_Case {
$this->assertEquals(
wc_get_orders(
array(
'return' => 'ids',
'return' => 'ids',
'my_custom_arg' => true,
)
),
array(
@ -222,8 +224,7 @@ class OrdersTableQueryTests extends WC_Unit_Test_Case {
$this->assertEquals(
wc_get_orders(
array(
'return' => 'ids',
'my_custom_arg' => true,
'return' => 'ids',
)
),
array(