Merge branch 'trunk' into feature/34906-marketing-channels-card

This commit is contained in:
Gan Eng Chin 2023-01-29 01:29:31 +08:00
commit 449da4e91c
No known key found for this signature in database
GPG Key ID: 94D5D972860ADB01
400 changed files with 7288 additions and 5454 deletions

View File

@ -27,26 +27,26 @@ runs:
echo "BUILD_FILTERS=$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.build-filters }}')" >> $GITHUB_OUTPUT
- name: Setup PNPM
uses: pnpm/action-setup@10693b3829bf86eb2572aef5f3571dcf5ca9287d
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
with:
version: '^7.13.3'
version: '^7.22.0'
- name: Setup Node
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Setup PHP
uses: shivammathur/setup-php@e04e1d97f0c0481c6e1ba40f8a538454fe5d7709
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: ${{ inputs.php-version }}
coverage: none
tools: phpcs, sirbrillig/phpcs-changed
- name: Cache Composer Dependencies
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
uses: actions/cache@58c146cc91c5b9e778e71775dfe9bf1442ad9a12
with:
path: ~/.cache/composer/files
key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }}
@ -59,7 +59,7 @@ runs:
pnpm install ${{ steps.parse-input.outputs.INSTALL_FILTERS }}
- name: Cache Build Output
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
uses: actions/cache@58c146cc91c5b9e778e71775dfe9bf1442ad9a12
with:
path: node_modules/.cache/turbo
key: ${{ runner.os }}-build-output-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action

View File

@ -1,4 +1,4 @@
name: "Pull request post-merge processing"
name: 'Pull request post-merge processing'
on:
pull_request_target:
types: [closed]
@ -13,7 +13,7 @@ jobs:
permissions:
pull-requests: write
steps:
- name: "Get the action scripts"
- name: 'Get the action scripts'
run: |
scripts="assign-milestone-to-merged-pr.php add-post-merge-comment.php post-request-shared.php"
for script in $scripts
@ -29,11 +29,11 @@ jobs:
done
env:
GITHUB_API_URL: ${{ env.GITHUB_API_URL }}
- name: "Install PHP"
uses: shivammathur/setup-php@v2
- name: 'Install PHP'
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: '7.4'
- name: "Run the script to assign a milestone"
- name: 'Run the script to assign a milestone'
if: |
!github.event.pull_request.milestone &&
github.event.pull_request.base.ref == 'trunk'

View File

@ -30,7 +30,7 @@ jobs:
freeze: ${{ steps.check-freeze.outputs.freeze }}
steps:
- name: 'Install PHP'
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: '7.4'

View File

@ -20,7 +20,7 @@ jobs:
uses: actions/checkout@v3
- name: 'Setup node'
uses: actions/setup-node@v3
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version: 16

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Altering styles to correctly target fields within slot fills on product editor.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Include CSS for experimental tree control so it renders properly in Storybook.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Move registerFill call to inside an useEffect since it was updating a component while rendering another component

View File

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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Updating the product editor fill components to support multiple targets.

View File

@ -44,6 +44,7 @@
"@woocommerce/navigation": "workspace:*",
"@wordpress/a11y": "3.5.0",
"@wordpress/api-fetch": "^6.0.1",
"@wordpress/base-styles": "^4.3.0",
"@wordpress/block-editor": "^9.8.0",
"@wordpress/block-library": "^7.16.0",
"@wordpress/blocks": "^11.18.0",
@ -164,5 +165,11 @@
"pnpm lint:fix",
"pnpm test-staged"
]
},
"pnpm": {
"overrides": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
}

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import React from 'react';
/**
* Internal dependencies
@ -9,7 +10,6 @@ import { TreeItemProps } from '../types';
export function useTreeItem( { item, level, ...props }: TreeItemProps ) {
const nextLevel = level + 1;
const nextHeadingPaddingLeft = ( level - 1 ) * 28 + 12;
return {
item,
@ -19,8 +19,8 @@ export function useTreeItem( { item, level, ...props }: TreeItemProps ) {
},
headingProps: {
style: {
paddingLeft: nextHeadingPaddingLeft,
},
'--level': level,
} as React.CSSProperties,
},
treeProps: {
items: item.children,

View File

@ -6,7 +6,7 @@
flex-grow: 1;
gap: $gap-smaller;
min-height: $gap-largest;
padding: 0 $gap-small;
padding: 0 $gap-small 0 calc( ( var( --level ) - 1 ) * ( $gap + $gap-small ) + $gap-small );
border-radius: 2px;
&:hover,

View File

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

View File

@ -11,9 +11,9 @@
&__body {
padding: $gap-large;
> .components-base-control,
> .components-dropdown,
> .woocommerce-rich-text-editor {
.components-base-control,
.components-dropdown,
.woocommerce-rich-text-editor {
&:not(:first-child):not(.components-radio-control) {
margin-top: $gap-large - $gap-smaller;
margin-bottom: 0;

View File

@ -1,6 +1,7 @@
/**
* External Dependencies
*/
@import 'node_modules/@wordpress/base-styles/colors.native';
@import '@automattic/tour-kit/dist/esm/styles.scss';
/**
@ -56,3 +57,4 @@
@import 'collapsible-content/style.scss';
@import 'form/style.scss';
@import 'product-section-layout/style.scss';
@import 'experimental-tree-control/tree.scss';

View File

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

View File

@ -1,21 +1,21 @@
/**
* External dependencies
*/
import React from 'react';
import React, { useEffect } from 'react';
import { Slot, Fill } from '@wordpress/components';
import { createElement, Children } from '@wordpress/element';
import { createElement, Children, Fragment } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createOrderedChildren, sortFillsByOrder } from '../utils';
import { useSlotContext, SlotContextHelpersType } from '../slot-context';
import { ProductFillLocationType } from '../woo-product-tab-item';
type WooProductFieldItemProps = {
id: string;
section: string;
sections: ProductFillLocationType[];
pluginId: string;
order?: number;
};
type WooProductFieldSlotProps = {
@ -24,21 +24,32 @@ type WooProductFieldSlotProps = {
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
} = ( { children, order = 20, section, id } ) => {
} = ( { children, sections, id } ) => {
const { registerFill, getFillHelpers } = useSlotContext();
useEffect( () => {
registerFill( id );
}, [] );
return (
<Fill name={ `woocommerce_product_field_${ section }` }>
<>
{ sections.map( ( { name: sectionName, order: sectionOrder } ) => (
<Fill
name={ `woocommerce_product_field_${ sectionName }` }
key={ sectionName }
>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren<
Fill.Props & SlotContextHelpersType,
Fill.Props &
SlotContextHelpersType & {
sectionName: string;
},
{ _id: string }
>(
children,
order,
sectionOrder || 20,
{
sectionName,
...fillProps,
...getFillHelpers(),
},
@ -46,6 +57,8 @@ export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
);
} }
</Fill>
) ) }
</>
);
};

View File

@ -3,41 +3,51 @@
*/
import React from 'react';
import { Slot, Fill } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { createElement, Fragment } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createOrderedChildren, sortFillsByOrder } from '../utils';
import { ProductFillLocationType } from '../woo-product-tab-item';
type WooProductSectionItemProps = {
id: string;
location: string;
tabs: ProductFillLocationType[];
pluginId: string;
order?: number;
};
type WooProductFieldSlotProps = {
location: string;
type WooProductSectionSlotProps = {
tab: string;
};
export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & {
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
} = ( { children, order = 20, location } ) => (
<Fill name={ `woocommerce_product_section_${ location }` }>
Slot: React.FC< Slot.Props & WooProductSectionSlotProps >;
} = ( { children, tabs } ) => {
return (
<>
{ tabs.map( ( { name: tabName, order: tabOrder } ) => (
<Fill
name={ `woocommerce_product_section_${ tabName }` }
key={ tabName }
>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren< Fill.Props >(
children,
order,
fillProps
);
return createOrderedChildren<
Fill.Props & { tabName: string }
>( children, tabOrder || 20, {
tabName,
...fillProps,
} );
} }
</Fill>
);
) ) }
</>
);
};
WooProductSectionItem.Slot = ( { fillProps, location } ) => (
WooProductSectionItem.Slot = ( { fillProps, tab } ) => (
<Slot
name={ `woocommerce_product_section_${ location }` }
name={ `woocommerce_product_section_${ tab }` }
fillProps={ fillProps }
>
{ ( fills ) => {

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
"phpVersion": null,
"core": null,
"plugins": [
"https://downloads.wordpress.org/plugin/woocommerce.7.1.0.zip",
"https://downloads.wordpress.org/plugin/woocommerce.7.3.0.zip",
"."
],
"config": {

View File

@ -1,4 +1,4 @@
Significance: patch
Type: update
Disable TikTok in the OBW
bump WooCommerce version

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add FeedbackModal and ProductMVPFeedbackModal components

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add a function to help decide if comments section should be shown

View File

@ -29,6 +29,10 @@ type CustomerEffortScoreProps = {
onModalShownCallback?: () => void;
onModalDismissedCallback?: () => void;
icon?: React.ReactElement | null;
shouldShowComments?: (
firstQuestionScore: number,
secondQuestionScore: number
) => boolean;
};
/**
@ -48,6 +52,7 @@ type CustomerEffortScoreProps = {
* @param {Function} props.onNoticeDismissedCallback Function to call when the notice is dismissed.
* @param {Function} props.onModalShownCallback Function to call when the modal is shown.
* @param {Function} props.onModalDismissedCallback Function to call when modal is dismissed.
* @param {Function} props.shouldShowComments Callback to determine if comments section should be shown.
* @param {Object} props.icon Icon (React component) to be shown on the notice.
*/
const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
@ -62,6 +67,10 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
onModalShownCallback = noop,
onModalDismissedCallback = noop,
icon,
shouldShowComments = ( firstQuestionScore, secondQuestionScore ) =>
[ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
),
} ) => {
const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true );
const [ visible, setVisible ] = useState( false );
@ -108,6 +117,7 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
secondQuestion={ secondQuestion }
recordScoreCallback={ recordScoreCallback }
onCloseModal={ onModalDismissedCallback }
shouldShowComments={ shouldShowComments }
/>
);
};
@ -145,6 +155,10 @@ CustomerEffortScore.propTypes = {
* The second survey question.
*/
secondQuestion: PropTypes.string,
/**
* A function to determine whether or not the comments field shown be shown.
*/
shouldShowComments: PropTypes.func,
};
export { CustomerEffortScore };

View File

@ -32,6 +32,7 @@ import { __ } from '@wordpress/i18n';
* @param {string} props.defaultScore Default score.
* @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel.
* @param {Function} props.customOptions List of custom score options, contains label and value.
* @param {Function} props.shouldShowComments A function to determine whether or not the comments field shown be shown.
*/
function CustomerFeedbackModal( {
recordScoreCallback,
@ -42,6 +43,10 @@ function CustomerFeedbackModal( {
defaultScore = NaN,
onCloseModal,
customOptions,
shouldShowComments = ( firstQuestionScore, secondQuestionScore ) =>
[ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
),
}: {
recordScoreCallback: (
score: number,
@ -55,6 +60,10 @@ function CustomerFeedbackModal( {
defaultScore?: number;
onCloseModal?: () => void;
customOptions?: { label: string; value: string }[];
shouldShowComments?: (
firstQuestionScore: number,
secondQuestionScore: number
) => boolean;
} ): JSX.Element | null {
const options =
customOptions && customOptions.length > 0
@ -200,8 +209,10 @@ function CustomerFeedbackModal( {
</div>
) }
{ [ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
{ typeof shouldShowComments === 'function' &&
shouldShowComments(
firstQuestionScore,
secondQuestionScore
) && (
<div className="woocommerce-customer-effort-score__comments">
<TextareaControl
@ -218,7 +229,9 @@ function CustomerFeedbackModal( {
'Optional, but much apprecated. We love reading your feedback!',
'woocommerce'
) }
onChange={ ( value: string ) => setComments( value ) }
onChange={ ( value: string ) =>
setComments( value )
}
rows={ 5 }
/>
</div>

View File

@ -0,0 +1,12 @@
.woocommerce-feedback-modal__buttons {
text-align: right;
.components-button {
margin-left: 1em;
}
}
.woocommerce-feedback-modal .woocommerce-feedback-modal__description {
max-width: 550px;
margin: 0 0 1.5em 0;
}

View File

@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { createElement, useState } from '@wordpress/element';
import PropTypes from 'prop-types';
import { Button, Modal } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
import { __ } from '@wordpress/i18n';
/**
* Provides a modal requesting customer feedback.
*
* Answers and comments are sent to a callback function.
*
* @param {Object} props Component props.
* @param {Function} props.onSubmit Function to call when the results are sent.
* @param {string} props.title Title displayed in the modal.
* @param {string} props.description Description displayed in the modal.
* @param {string} props.isSubmitButtonDisabled Boolean to enable/disable the send button.
* @param {string} props.submitButtonLabel Label for the send button.
* @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.
*/
function FeedbackModal( {
onSubmit,
title,
description,
onModalClose,
children,
isSubmitButtonDisabled,
submitButtonLabel,
cancelButtonLabel,
}: {
onSubmit: () => void;
title: string;
description?: string;
onModalClose?: () => void;
children?: JSX.Element;
isSubmitButtonDisabled?: boolean;
submitButtonLabel?: string;
cancelButtonLabel?: string;
} ): JSX.Element | null {
const [ isOpen, setOpen ] = useState( true );
const closeModal = () => {
setOpen( false );
if ( onModalClose ) {
onModalClose();
}
};
if ( ! isOpen ) {
return null;
}
return (
<Modal
className="woocommerce-feedback-modal"
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>
{ children }
<div className="woocommerce-feedback-modal__buttons">
<Button isTertiary onClick={ closeModal } name="cancel">
{ cancelButtonLabel }
</Button>
<Button
isPrimary={ ! isSubmitButtonDisabled }
isSecondary={ isSubmitButtonDisabled }
onClick={ () => {
onSubmit();
setOpen( false );
} }
name="send"
disabled={ isSubmitButtonDisabled }
>
{ submitButtonLabel }
</Button>
</div>
</Modal>
);
}
FeedbackModal.propTypes = {
onSubmit: PropTypes.func.isRequired,
title: PropTypes.string,
description: PropTypes.string,
onModalClose: PropTypes.func,
isSubmitButtonDisabled: PropTypes.bool,
submitButtonLabel: PropTypes.string,
cancelButtonLabel: PropTypes.string,
};
export { FeedbackModal };

View File

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

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { FeedbackModal } from '../index';
const mockRecordScoreCallback = jest.fn();
describe( 'FeedbackModal', () => {
it( 'should render a modal', async () => {
render(
<FeedbackModal
onSubmit={ mockRecordScoreCallback }
title="Testing"
submitButtonLabel="Send"
cancelButtonLabel="Cancel"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
expect(
screen.getByRole( 'button', { name: /Send/i } )
).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: /Cancel/i } )
).toBeInTheDocument();
} );
it( 'should close modal when cancel button pressed', async () => {
render(
<FeedbackModal
onSubmit={ mockRecordScoreCallback }
title="Testing"
submitButtonLabel="Send"
cancelButtonLabel="Cancel"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Press cancel button.
fireEvent.click( screen.getByRole( 'button', { name: /Cancel/i } ) );
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
} );
} );

View File

@ -1,3 +1,5 @@
export * from './customer-effort-score';
export * from './customer-feedback-simple';
export * from './customer-feedback-modal';
export * from './product-mvp-feedback-modal';
export * from './feedback-modal';

View File

@ -0,0 +1 @@
export * from './product-mvp-feedback-modal';

View File

@ -0,0 +1,23 @@
.woocommerce-product-mvp-feedback-modal {
&__subtitle {
margin-top: $gap-smaller !important;
}
&__checkboxes {
margin: $gap-small 0;
}
&__comments {
margin-top: 2em;
margin-bottom: 1.5em;
label {
display: block;
font-weight: bold;
text-transform: none;
font-size: 14px;
}
textarea {
width: 100%;
}
}
}

View File

@ -0,0 +1,156 @@
/**
* External dependencies
*/
import { createElement, Fragment, useState } from '@wordpress/element';
import PropTypes from 'prop-types';
import { CheckboxControl, TextareaControl } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { FeedbackModal } from '../feedback-modal';
/**
* Provides a modal requesting customer feedback.
*
*
* @param {Object} props Component props.
* @param {Function} props.recordScoreCallback Function to call when the results are sent.
* @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel.
*/
function ProductMVPFeedbackModal( {
recordScoreCallback,
onCloseModal,
}: {
recordScoreCallback: ( checked: string[], comments: string ) => void;
onCloseModal?: () => void;
} ): JSX.Element | null {
const [ missingFeatures, setMissingFeatures ] = useState( false );
const [ missingPlugins, setMissingPlugins ] = useState( false );
const [ difficultToUse, setDifficultToUse ] = useState( false );
const [ slowBuggyOrBroken, setSlowBuggyOrBroken ] = useState( false );
const [ other, setOther ] = useState( false );
const checkboxes = [
{
key: 'missing-features',
label: __( 'Missing features', 'woocommerce' ),
checked: missingFeatures,
onChange: setMissingFeatures,
},
{
key: 'missing-plugins',
label: __( 'Missing plugins', 'woocommerce' ),
checked: missingPlugins,
onChange: setMissingPlugins,
},
{
key: 'difficult-to-use',
label: __( 'It is difficult to use', 'woocommerce' ),
checked: difficultToUse,
onChange: setDifficultToUse,
},
{
key: 'slow-buggy-or-broken',
label: __( 'It is slow, buggy, or broken', 'woocommerce' ),
checked: slowBuggyOrBroken,
onChange: setSlowBuggyOrBroken,
},
{
key: 'other',
label: __( 'Other (describe below)', 'woocommerce' ),
checked: other,
onChange: setOther,
},
];
const [ comments, setComments ] = useState( '' );
const onSendFeedback = () => {
const checked = checkboxes
.filter( ( checkbox ) => checkbox.checked )
.map( ( checkbox ) => checkbox.key );
recordScoreCallback( checked, comments );
};
const isSendButtonDisabled =
! comments &&
! missingFeatures &&
! missingPlugins &&
! difficultToUse &&
! slowBuggyOrBroken &&
! other;
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.',
'woocommerce'
) }
onSubmit={ onSendFeedback }
onModalClose={ onCloseModal }
isSubmitButtonDisabled={ isSendButtonDisabled }
submitButtonLabel={ __( 'Send feedback', 'woocommerce' ) }
cancelButtonLabel={ __( 'Skip', 'woocommerce' ) }
>
<>
<Text
variant="subtitle.small"
as="p"
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!',
'woocommerce'
) }
onChange={ ( value: string ) => setComments( value ) }
rows={ 5 }
/>
</div>
</>
</FeedbackModal>
);
}
ProductMVPFeedbackModal.propTypes = {
recordScoreCallback: PropTypes.func.isRequired,
onCloseModal: PropTypes.func,
};
export { ProductMVPFeedbackModal };

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ProductMVPFeedbackModal } from '../index';
const mockRecordScoreCallback = jest.fn();
describe( 'ProductMVPFeedbackModal', () => {
it( 'should close the ProductMVPFeedback modal when skip button pressed', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Press cancel button.
fireEvent.click( screen.getByRole( 'button', { name: /Skip/i } ) );
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
} );
it( 'should enable Send button when an option is checked', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
fireEvent.click( screen.getByRole( 'checkbox', { name: /other/i } ) );
fireEvent.click(
screen.getByRole( 'button', { name: /Send feedback/i } )
);
} );
it( 'should call the function sent as recordScoreCallback with the checked options', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
fireEvent.click( screen.getByRole( 'checkbox', { name: /other/i } ) );
expect( mockRecordScoreCallback ).toHaveBeenCalledWith(
[ 'other' ],
''
);
} );
} );

View File

@ -1,4 +1,6 @@
@import 'customer-feedback-simple/customer-feedback-simple.scss';
@import 'product-mvp-feedback-modal/product-mvp-feedback-modal.scss';
@import 'feedback-modal/feedback-modal.scss';
.woocommerce-customer-effort-score__selection {
margin: 1em 0 1.5em 0;

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Update type definition for ProductForm

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Removing unused code from ProductForm data store.

View File

@ -24,6 +24,7 @@ export function getProductFormSuccess( productForm: ProductForm ) {
fields: productForm.fields,
sections: productForm.sections,
subsections: productForm.subsections,
tabs: productForm.tabs,
};
}

View File

@ -17,6 +17,7 @@ const reducer: Reducer< ProductFormState, Action > = (
fields: [],
sections: [],
subsections: [],
tabs: [],
},
action
) => {
@ -42,6 +43,7 @@ const reducer: Reducer< ProductFormState, Action > = (
fields: action.fields,
sections: action.sections,
subsections: action.subsections,
tabs: action.tabs,
};
break;
case TYPES.GET_PRODUCT_FORM_ERROR:

View File

@ -1,8 +1,7 @@
/**
* External dependencies
*/
import { apiFetch, select } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
import { apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
@ -15,10 +14,6 @@ import {
} from './actions';
import { WC_ADMIN_NAMESPACE } from '../constants';
import { ProductFormField, ProductForm } from './types';
import { STORE_NAME } from './constants';
const resolveSelect =
controls && controls.resolveSelect ? controls.resolveSelect : select;
export function* getFields() {
try {
@ -34,10 +29,6 @@ export function* getFields() {
}
}
export function* getCountry() {
yield resolveSelect( STORE_NAME, 'getProductForm' );
}
export function* getProductForm() {
try {
const url = WC_ADMIN_NAMESPACE + '/product-form';

View File

@ -23,10 +23,16 @@ export type ProductFormSection = BaseComponent & {
export type Subsection = BaseComponent;
export type Tabs = BaseComponent & {
name: string;
title: string;
};
export type ProductForm = {
fields: ProductFormField[];
sections: ProductFormSection[];
subsections: Subsection[];
tabs: Tabs[];
};
export type ProductFormState = ProductForm & {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add WooOnboardingTaskListHeader component

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Slot, Fill } from '@wordpress/components';
type WooOnboardingTaskListHeaderProps = {
id: string;
};
/**
* A Fill for adding Onboarding Task List headers.
*
* @slotFill WooOnboardingTaskListHeader
* @scope woocommerce-tasks
* @param {Object} props React props.
* @param {string} props.id Task id.
*/
export const WooOnboardingTaskListHeader = ( {
id,
...props
}: WooOnboardingTaskListHeaderProps & Slot.Props ) => (
<Fill
name={ 'woocommerce_onboarding_task_list_header_' + id }
{ ...props }
/>
);
WooOnboardingTaskListHeader.Slot = ( {
id,
fillProps,
}: WooOnboardingTaskListHeaderProps & Slot.Props ) => (
<Slot
name={ 'woocommerce_onboarding_task_list_header_' + id }
fillProps={ fillProps }
/>
);

View File

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

View File

@ -13,4 +13,5 @@ export { default as WCPayLogo } from './images/wcpay-logo';
export { WooPaymentGatewaySetup } from './components/WooPaymentGatewaySetup';
export { WooPaymentGatewayConfigure } from './components/WooPaymentGatewayConfigure';
export { WooOnboardingTaskListItem } from './components/WooOnboardingTaskListItem';
export { WooOnboardingTaskListHeader } from './components/WooOnboardingTaskListHeader';
export { WooOnboardingTask } from './components/WooOnboardingTask';

View File

@ -14,12 +14,11 @@ import { ALLOW_TRACKING_OPTION_NAME } from './constants';
const CUSTOMER_EFFORT_SCORE_EXIT_PAGE_KEY = 'customer-effort-score-exit-page';
let allowTracking = false;
resolveSelect( OPTIONS_STORE_NAME )
const trackingPromise = resolveSelect( OPTIONS_STORE_NAME )
.getOption( ALLOW_TRACKING_OPTION_NAME )
.then( ( trackingOption ) => {
allowTracking = trackingOption === 'yes';
} );
/**
* Gets the list of exited pages from Localstorage.
*/
@ -42,7 +41,8 @@ export const getExitPageData = () => {
*
* @param {string} pageId of page exited early.
*/
export const addExitPage = ( pageId: string ) => {
export const addExitPage = async ( pageId: string ) => {
await trackingPromise;
if ( ! ( window.localStorage && allowTracking ) ) {
return;
}

View File

@ -86,6 +86,7 @@ export const CustomerEffortScoreModalContainer: React.FC = () => {
visibleCESModalData.props?.onCloseModal?.();
hideCesModal();
} }
shouldShowComments={ visibleCESModalData.props?.shouldShowComments }
/>
);
};

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { useEffect } from 'react';
import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
@ -29,19 +30,20 @@ function CustomerEffortScoreTracksContainer( {
resolving,
clearQueue,
} ) {
if ( resolving ) {
return null;
}
const queueForPage = queue.filter(
( item ) =>
item.pagenow === window.pagenow &&
item.adminpage === window.adminpage
);
useEffect( () => {
if ( queueForPage.length ) {
clearQueue();
}
}, [ queueForPage ] );
if ( resolving ) {
return null;
}
return (
<>

View File

@ -3,6 +3,8 @@ const TYPES = {
ADD_CES_SURVEY: 'ADD_CES_SURVEY',
SHOW_CES_MODAL: 'SHOW_CES_MODAL',
HIDE_CES_MODAL: 'HIDE_CES_MODAL',
SHOW_PRODUCT_MVP_FEEDBACK_MODAL: 'SHOW_PRODUCT_MVP_FEEDBACK_MODAL',
HIDE_PRODUCT_MVP_FEEDBACK_MODAL: 'HIDE_PRODUCT_MVP_FEEDBACK_MODAL',
};
export default TYPES;

View File

@ -141,3 +141,21 @@ export function addCesSurveyForCustomerSearch() {
},
} );
}
/**
* Add show product MVP Feedback modal.
*/
export function showProductMVPFeedbackModal() {
return {
type: TYPES.SHOW_PRODUCT_MVP_FEEDBACK_MODAL,
};
}
/**
* Hide product MVP Feedback modal.
*/
export function hideProductMVPFeedbackModal() {
return {
type: TYPES.HIDE_PRODUCT_MVP_FEEDBACK_MODAL,
};
}

View File

@ -7,6 +7,7 @@ const DEFAULT_STATE = {
queue: [],
cesModalData: undefined,
showCESModal: false,
showProductMVPFeedbackModal: false,
};
const reducer = ( state = DEFAULT_STATE, action ) => {
@ -62,6 +63,16 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
...state,
queue: [ ...state.queue, newTrack ],
};
case TYPES.SHOW_PRODUCT_MVP_FEEDBACK_MODAL:
return {
...state,
showProductMVPFeedbackModal: true,
};
case TYPES.HIDE_PRODUCT_MVP_FEEDBACK_MODAL:
return {
...state,
showProductMVPFeedbackModal: false,
};
default:
return state;

View File

@ -5,3 +5,7 @@ export function getCesSurveyQueue( state ) {
export function getVisibleCESModalData( state ) {
return state.showCESModal ? state.cesModalData : undefined;
}
export function isProductMVPFeedbackModalVisible( state ) {
return state.showProductMVPFeedbackModal;
}

View File

@ -21,9 +21,12 @@ import { STORE_KEY } from './data/constants';
export const PRODUCT_MVP_CES_ACTION_OPTION_NAME =
'woocommerce_ces_product_mvp_ces_action';
export const NEW_PRODUCT_MANAGEMENT =
'woocommerce_new_product_management_enabled';
export const ProductMVPCESFooter: React.FC = () => {
const { showCesModal } = useDispatch( STORE_KEY );
const { showCesModal, showProductMVPFeedbackModal } =
useDispatch( STORE_KEY );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const {
cesAction,
@ -83,6 +86,7 @@ export const ProductMVPCESFooter: React.FC = () => {
"Thanks for the feedback. We'll put it to good use!",
'woocommerce'
),
shouldShowComments: () => true,
},
{},
{
@ -98,6 +102,16 @@ export const ProductMVPCESFooter: React.FC = () => {
} );
};
const onDisablingNewProductExperience = () => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'hide',
} );
updateOptions( {
[ NEW_PRODUCT_MANAGEMENT ]: 'no',
} );
showProductMVPFeedbackModal();
};
const onDisablingCES = () => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'hide',
@ -125,7 +139,7 @@ export const ProductMVPCESFooter: React.FC = () => {
{ __( 'Share feedback', 'woocommerce' ) }
</Button>
<Button
onClick={ onDisablingCES }
onClick={ onDisablingNewProductExperience }
variant="tertiary"
>
{ __( 'Turn it off', 'woocommerce' ) }

View File

@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { ProductMVPFeedbackModal } from '@woocommerce/customer-effort-score';
import { recordEvent } from '@woocommerce/tracks';
import { getAdminLink } from '@woocommerce/settings';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './data/constants';
export const ProductMVPFeedbackModalContainer: React.FC = () => {
const { values } = useFormContext< Product >();
const { hideProductMVPFeedbackModal } = useDispatch( STORE_KEY );
const { isProductMVPModalVisible } = useSelect( ( select ) => {
const { isProductMVPFeedbackModalVisible } = select( STORE_KEY );
return {
isProductMVPModalVisible: isProductMVPFeedbackModalVisible(),
};
} );
const classicEditorUrl = values.id
? getAdminLink( `post.php?post=${ values.id }&action=edit` )
: getAdminLink( 'post-new.php?post_type=product' );
const recordScore = ( checked: string[], comments: string ) => {
recordEvent( 'product_mvp_feedback', {
action: 'disable',
checked,
comments: comments || '',
} );
hideProductMVPFeedbackModal();
window.location.href = `${ classicEditorUrl }&new-product-experience-disabled=true`;
};
const onCloseModal = () => {
recordEvent( 'product_mvp_feedback', {
action: 'disable',
checked: '',
comments: '',
} );
hideProductMVPFeedbackModal();
window.location.href = classicEditorUrl;
};
if ( ! isProductMVPModalVisible ) {
return null;
}
return (
<ProductMVPFeedbackModal
recordScoreCallback={ recordScore }
onCloseModal={ onCloseModal }
/>
);
};

View File

@ -19,21 +19,23 @@ async function isProductMVPCESHidden(): Promise< boolean > {
export const useProductMVPCESFooter = () => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const showCesFooter = ( actionName = 'show' ) => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: actionName,
} );
};
const onSaveDraft = async () => {
if ( ( await isProductMVPCESHidden() ) === false ) {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product',
} );
showCesFooter( 'new_product' );
}
};
const onPublish = async () => {
if ( ( await isProductMVPCESHidden() ) === false ) {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product',
} );
showCesFooter( 'new_product' );
}
};
return { onSaveDraft, onPublish };
return { onSaveDraft, onPublish, showCesFooter };
};

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
export const useHeaderHeight = () => {
const [ headerHeight, setHeaderHeight ] = useState( 60 );
const [ adminBarHeight, setAdminBarHeight ] = useState( 32 );
useEffect( () => {
const wpbody = document.querySelector( '#wpbody' ) as Node;
const observer = new MutationObserver( () => {
setHeaderHeight(
parseInt( ( wpbody as HTMLElement ).style.marginTop, 10 )
);
} );
observer.observe( wpbody, {
attributes: true,
} );
return () => {
observer.disconnect();
};
}, [] );
useLayoutEffect( () => {
const handleResize = () => {
const adminBar = document.querySelector(
'#wpadminbar'
) as HTMLElement;
setAdminBarHeight( adminBar.clientHeight );
};
window.addEventListener( 'resize', handleResize );
return () => {
window.removeEventListener( 'resize', handleResize );
};
}, [] );
return {
adminBarHeight,
headerHeight,
};
};

View File

@ -1,6 +1,4 @@
.woocommerce-marketing-card-header-title {
font-size: 20px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0;
@include font-size( 20 );
line-height: 1.4;
}

View File

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

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { Attributes } from '../../fields/attributes';
export const AttributesField = () => {
const {
getInputProps,
values: { id: productId },
} = useFormContext< Product >();
return (
<Attributes
{ ...getInputProps( 'attributes', {
productId,
} ) }
/>
);
};

View File

@ -0,0 +1,76 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalProductSectionLayout as ProductSectionLayout,
Link,
} from '@woocommerce/components';
import { registerPlugin } from '@wordpress/plugins';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { AttributesField } from './index';
import { ATTRIBUTES_SECTION_ID, TAB_GENERAL_ID, PLUGIN_ID } from '../constants';
import './attributes-section.scss';
const AttributesSection = () => (
<>
<WooProductSectionItem
id={ ATTRIBUTES_SECTION_ID }
tabs={ [ { name: TAB_GENERAL_ID, order: 5 } ] }
pluginId={ PLUGIN_ID }
>
<ProductSectionLayout
title={ __( 'Attributes', 'woocommerce' ) }
className="woocommerce-product-attributes-section"
description={
<>
<span>
{ __(
'Add descriptive pieces of information that customers can use to filter and search for this product.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/document/managing-product-taxonomies/#product-attributes"
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'learn_more_about_attributes_help'
);
} }
>
{ __(
'Learn more about attributes',
'woocommerce'
) }
</Link>
</>
}
>
<WooProductFieldItem.Slot section={ ATTRIBUTES_SECTION_ID } />
</ProductSectionLayout>
</WooProductSectionItem>
<WooProductFieldItem
id="attributes/add"
sections={ [ { name: ATTRIBUTES_SECTION_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<AttributesField />
</WooProductFieldItem>
</>
);
registerPlugin( 'wc-admin-product-editor-attributes-section', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => <AttributesSection />,
} );

View File

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

View File

@ -1,7 +1,17 @@
export const PRODUCT_DETAILS_SLUG = 'product-details';
export const PRICING_SECTION_BASIC_ID = 'pricing/basic';
export const PRICING_SECTION_TAXES_ID = 'pricing/taxes';
export const DETAILS_SECTION_ID = 'general/details';
export const INVENTORY_SECTION_ID = 'inventory/inventory';
export const INVENTORY_SECTION_ADVANCED_ID = 'inventory/advanced';
export const IMAGES_SECTION_ID = 'general/images';
export const ATTRIBUTES_SECTION_ID = 'general/attributes';
export const SHIPPING_SECTION_BASIC_ID = 'shipping/shipping';
export const SHIPPING_SECTION_DIMENSIONS_ID = 'shipping/dimensions';
export const TAB_INVENTORY_ID = 'tab/inventory';
export const TAB_GENERAL_ID = 'tab/general';
export const TAB_SHIPPING_ID = 'tab/shipping';
export const TAB_PRICING_ID = 'tab/pricing';
export const PLUGIN_ID = 'woocommerce';
export const PRODUCT_DETAILS_SLUG = 'product-details';

View File

@ -28,9 +28,8 @@ const DetailsSection = () => (
<>
<WooProductSectionItem
id={ DETAILS_SECTION_ID }
location={ TAB_GENERAL_ID }
tabs={ [ { name: TAB_GENERAL_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<ProductFieldSection
id={ DETAILS_SECTION_ID }
@ -43,41 +42,36 @@ const DetailsSection = () => (
</WooProductSectionItem>
<WooProductFieldItem
id="details/name"
section={ DETAILS_SECTION_ID }
sections={ [ { name: DETAILS_SECTION_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<DetailsNameField />
</WooProductFieldItem>
<WooProductFieldItem
id="details/categories"
section={ DETAILS_SECTION_ID }
sections={ [ { name: DETAILS_SECTION_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
order={ 3 }
>
<DetailsCategoriesField />
</WooProductFieldItem>
<WooProductFieldItem
id="details/feature"
section={ DETAILS_SECTION_ID }
sections={ [ { name: DETAILS_SECTION_ID, order: 5 } ] }
pluginId={ PLUGIN_ID }
order={ 5 }
>
<DetailsFeatureField />
</WooProductFieldItem>
<WooProductFieldItem
id="details/summary"
section={ DETAILS_SECTION_ID }
sections={ [ { name: DETAILS_SECTION_ID, order: 7 } ] }
pluginId={ PLUGIN_ID }
order={ 7 }
>
<DetailsSummaryField />
</WooProductFieldItem>
<WooProductFieldItem
id="details/description"
section={ DETAILS_SECTION_ID }
sections={ [ { name: DETAILS_SECTION_ID, order: 9 } ] }
pluginId={ PLUGIN_ID }
order={ 9 }
>
<DetailsDescriptionField />
</WooProductFieldItem>

View File

@ -23,9 +23,8 @@ const ImagesSection = () => (
<>
<WooProductSectionItem
id={ IMAGES_SECTION_ID }
location={ TAB_GENERAL_ID }
tabs={ [ { name: TAB_GENERAL_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
order={ 3 }
>
<ProductFieldSection
id={ IMAGES_SECTION_ID }
@ -58,9 +57,8 @@ const ImagesSection = () => (
</WooProductSectionItem>
<WooProductFieldItem
id="images/gallery"
section={ IMAGES_SECTION_ID }
sections={ [ { name: IMAGES_SECTION_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
order={ 1 }
>
<ImagesGalleryField />
</WooProductFieldItem>

View File

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

View File

@ -0,0 +1,6 @@
export * from './inventory-field-sku';
export * from './inventory-field-track-quantity';
export * from './inventory-field-stock-manual';
export * from './inventory-field-stock-manage';
export * from './inventory-field-stock-out';
export * from './inventory-field-stock-limit';

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { TextControl } from '@wordpress/components';
import { Product } from '@woocommerce/data';
export const InventorySkuField = () => {
const { getInputProps } = useFormContext< Product >();
return (
<TextControl
label={ __( 'SKU (Stock Keeping Unit)', 'woocommerce' ) }
{ ...getInputProps( 'sku', {
className: 'half-width-field',
} ) }
/>
);
};

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { CheckboxControl } from '@wordpress/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { getCheckboxTracks } from '../../sections/utils';
export const InventoryStockLimitField = () => {
const { getCheckboxControlProps } = useFormContext< Product >();
return (
<>
<h4>{ __( 'Restrictions', 'woocommerce' ) }</h4>
<CheckboxControl
label={ __(
'Limit purchases to 1 item per order',
'woocommerce'
) }
{ ...getCheckboxControlProps(
'sold_individually',
getCheckboxTracks( 'sold_individually' )
) }
/>
</>
);
};

View File

@ -2,11 +2,11 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useFormContext, Link } from '@woocommerce/components';
import { TextControl } from '@wordpress/components';
import { getAdminLink } from '@woocommerce/settings';
import interpolateComponents from '@automattic/interpolate-components';
import { Link, useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
import { recordEvent } from '@woocommerce/tracks';
/**
@ -14,7 +14,7 @@ import { recordEvent } from '@woocommerce/tracks';
*/
import { getAdminSetting } from '~/utils/admin-settings';
export const ManageStockSection: React.FC = () => {
export const InventoryStockManageField = () => {
const { getInputProps } = useFormContext< Product >();
const notifyLowStockAmount = getAdminSetting( 'notifyLowStockAmount', 2 );

View File

@ -2,11 +2,11 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { RadioControl } from '@wordpress/components';
import { useFormContext } from '@woocommerce/components';
import { RadioControl } from '@wordpress/components';
import { Product } from '@woocommerce/data';
export const ManualStockSection: React.FC = () => {
export const InventoryStockManualField = () => {
const { getInputProps } = useFormContext< Product >();
const inputProps = getInputProps( 'stock_status' );
// These properties cause issues with the RadioControl component.

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { RadioControl } from '@wordpress/components';
import { Product } from '@woocommerce/data';
export const InventoryStockOutField = () => {
const { getInputProps } = useFormContext< Product >();
const backordersProp = getInputProps( 'backorders' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete backordersProp.checked;
delete backordersProp.value;
return (
<RadioControl
label={ __( 'When out of stock', 'woocommerce' ) }
options={ [
{
label: __( 'Allow purchases', 'woocommerce' ),
value: 'yes',
},
{
label: __(
'Allow purchases, but notify customers',
'woocommerce'
),
value: 'notify',
},
{
label: __( "Don't allow purchases", 'woocommerce' ),
value: 'no',
},
] }
{ ...backordersProp }
/>
);
};

View File

@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useFormContext,
__experimentalConditionalWrapper as ConditionalWrapper,
} from '@woocommerce/components';
import { Tooltip, ToggleControl } from '@wordpress/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { getAdminSetting } from '~/utils/admin-settings';
import { getCheckboxTracks } from '../../sections/utils';
export const InventoryTrackQuantityField = () => {
const { getCheckboxControlProps } = useFormContext< Product >();
const canManageStock = getAdminSetting( 'manageStock', 'yes' ) === 'yes';
return (
<ConditionalWrapper
condition={ ! canManageStock }
wrapper={ ( children: JSX.Element ) => (
<Tooltip
text={ __(
'Quantity tracking is disabled for all products. Go to global store settings to change it.',
'woocommerce'
) }
position="top center"
>
<div className="woocommerce-product-form__tooltip-disabled-overlay">
{ children }
</div>
</Tooltip>
) }
>
<ToggleControl
label={ __( 'Track quantity for this product', 'woocommerce' ) }
{ ...getCheckboxControlProps(
'manage_stock',
getCheckboxTracks( 'manage_stock' )
) }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This prop does exist, but is not typed in @wordpress/components.
disabled={ ! canManageStock }
/>
</ConditionalWrapper>
);
};

View File

@ -0,0 +1,154 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalProductSectionLayout as ProductSectionLayout,
Link,
useFormContext,
CollapsibleContent,
} from '@woocommerce/components';
import { Card, CardBody } from '@wordpress/components';
import { registerPlugin } from '@wordpress/plugins';
import { recordEvent } from '@woocommerce/tracks';
import { getAdminLink } from '@woocommerce/settings';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import {
InventorySkuField,
InventoryTrackQuantityField,
InventoryStockManualField,
InventoryStockManageField,
InventoryStockLimitField,
InventoryStockOutField,
} from './index';
import {
INVENTORY_SECTION_ID,
INVENTORY_SECTION_ADVANCED_ID,
TAB_INVENTORY_ID,
PLUGIN_ID,
} from '../constants';
const InventorySection = () => {
const { values } = useFormContext< Product >();
return (
<>
<WooProductSectionItem
id={ INVENTORY_SECTION_ID }
tabs={ [ { name: TAB_INVENTORY_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<ProductSectionLayout
title={ __( 'Inventory', 'woocommerce' ) }
description={
<>
<span>
{ __(
'Set up and manage inventory for this product, including status and available quantity.',
'woocommerce'
) }
</span>
<Link
href={ getAdminLink(
'admin.php?page=wc-settings&tab=products&section=inventory'
) }
target="_blank"
type="wp-admin"
onClick={ () => {
recordEvent( 'add_product_inventory_help' );
} }
className="woocommerce-form-section__header-link"
>
{ __(
'Manage global inventory settings',
'woocommerce'
) }
</Link>
</>
}
>
<Card>
<CardBody>
<WooProductFieldItem.Slot
section={ INVENTORY_SECTION_ID }
/>
<CollapsibleContent
toggleText={ __( 'Advanced', 'woocommerce' ) }
>
<WooProductFieldItem.Slot
section={ INVENTORY_SECTION_ADVANCED_ID }
/>
</CollapsibleContent>
</CardBody>
</Card>
</ProductSectionLayout>
</WooProductSectionItem>
<WooProductFieldItem
id="inventory/sku"
sections={ [ { name: INVENTORY_SECTION_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<InventorySkuField />
</WooProductFieldItem>
<WooProductFieldItem
id="inventory/track-quantity"
sections={ [ { name: INVENTORY_SECTION_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
>
<InventoryTrackQuantityField />
</WooProductFieldItem>
{ values.manage_stock ? (
<WooProductFieldItem
id="inventory/stock-manage"
sections={ [ { name: INVENTORY_SECTION_ID, order: 5 } ] }
pluginId={ PLUGIN_ID }
>
<InventoryStockManageField />
</WooProductFieldItem>
) : (
<WooProductFieldItem
id="inventory/stock-manual"
sections={ [ { name: INVENTORY_SECTION_ID, order: 5 } ] }
pluginId={ PLUGIN_ID }
>
<InventoryStockManualField />
</WooProductFieldItem>
) }
{ values.manage_stock && (
<WooProductFieldItem
id="inventory/advanced/stock-out"
sections={ [
{ name: INVENTORY_SECTION_ADVANCED_ID, order: 1 },
] }
pluginId={ PLUGIN_ID }
>
<InventoryStockOutField />
</WooProductFieldItem>
) }
<WooProductFieldItem
id="inventory/advanced/stock-limit"
sections={ [
{ name: INVENTORY_SECTION_ADVANCED_ID, order: 3 },
] }
pluginId={ PLUGIN_ID }
>
<InventoryStockLimitField />
</WooProductFieldItem>
</>
);
};
registerPlugin( 'wc-admin-product-editor-inventory-section', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => <InventorySection />,
} );

View File

@ -0,0 +1,4 @@
export * from './pricing-field-list';
export * from './pricing-field-sale';
export * from './pricing-field-taxes-charge';
export * from './pricing-field-taxes-class';

View File

@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext, Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { useContext } from '@wordpress/element';
import { Product, SETTINGS_STORE_NAME } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import interpolateComponents from '@automattic/interpolate-components';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { CurrencyInputProps } from './pricing-section-fills';
import { formatCurrencyDisplayValue } from '../../sections/utils';
import { CurrencyContext } from '../../../lib/currency-context';
import { ADMIN_URL } from '~/utils/admin-settings';
type PricingListFieldProps = {
currencyInputProps: CurrencyInputProps;
};
export const PricingListField: React.FC< PricingListFieldProps > = ( {
currencyInputProps,
} ) => {
const { getInputProps } = useFormContext< Product >();
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const { isResolving: isTaxSettingsResolving, taxSettings } = useSelect(
( select ) => {
const { getSettings, hasFinishedResolution } =
select( SETTINGS_STORE_NAME );
return {
isResolving: ! hasFinishedResolution( 'getSettings', [
'tax',
] ),
taxSettings: getSettings( 'tax' ).tax || {},
taxesEnabled:
getSettings( 'general' )?.general
?.woocommerce_calc_taxes === 'yes',
};
}
);
const regularPriceProps = getInputProps(
'regular_price',
currencyInputProps
);
const taxIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}included{{/strong}} in the price.',
'woocommerce'
);
const taxNotIncludedInPriceText = __(
'Per your {{link}}store settings{{/link}}, tax is {{strong}}not included{{/strong}} in the price.',
'woocommerce'
);
const pricesIncludeTax =
taxSettings.woocommerce_prices_include_tax === 'yes';
const taxSettingsElement = interpolateComponents( {
mixedString: pricesIncludeTax
? taxIncludedInPriceText
: taxNotIncludedInPriceText,
components: {
link: (
<Link
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=tax` }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_pricing_list_price_help_tax_settings_click'
);
} }
>
<></>
</Link>
),
strong: <strong />,
},
} );
return (
<>
<BaseControl
id="product_pricing_regular_price"
help={ regularPriceProps?.help ?? '' }
>
<InputControl
{ ...regularPriceProps }
name="regular_price"
label={ __( 'List price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( regularPriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
{ ! isTaxSettingsResolving && (
<span className="woocommerce-product-form__secondary-text">
{ taxSettingsElement }
</span>
) }
</>
);
};

View File

@ -0,0 +1,213 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useFormContext,
Link,
__experimentalTooltip as Tooltip,
DateTimePickerControl,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { useContext, useState, useEffect } from '@wordpress/element';
import { Product, OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import interpolateComponents from '@automattic/interpolate-components';
import { format as formatDate } from '@wordpress/date';
import moment from 'moment';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
ToggleControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { CurrencyInputProps } from './pricing-section-fills';
import { formatCurrencyDisplayValue } from '../../sections/utils';
import { CurrencyContext } from '../../../lib/currency-context';
type PricingListFieldProps = {
currencyInputProps: CurrencyInputProps;
};
const PRODUCT_SCHEDULED_SALE_SLUG = 'product-scheduled-sale';
export const PricingSaleField: React.FC< PricingListFieldProps > = ( {
currencyInputProps,
} ) => {
const { getInputProps, values, setValues } = useFormContext< Product >();
const { dateFormat, timeFormat } = useSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
return {
dateFormat: ( getOption( 'date_format' ) as string ) || 'F j, Y',
timeFormat: ( getOption( 'time_format' ) as string ) || 'H:i',
};
} );
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const [ showSaleSchedule, setShowSaleSchedule ] = useState( false );
const [ userToggledSaleSchedule, setUserToggledSaleSchedule ] =
useState( false );
const [ autoToggledSaleSchedule, setAutoToggledSaleSchedule ] =
useState( false );
useEffect( () => {
if ( userToggledSaleSchedule || autoToggledSaleSchedule ) {
return;
}
const hasDateOnSaleFrom =
typeof values.date_on_sale_from_gmt === 'string' &&
values.date_on_sale_from_gmt.length > 0;
const hasDateOnSaleTo =
typeof values.date_on_sale_to_gmt === 'string' &&
values.date_on_sale_to_gmt.length > 0;
const hasSaleSchedule = hasDateOnSaleFrom || hasDateOnSaleTo;
if ( hasSaleSchedule ) {
setAutoToggledSaleSchedule( true );
setShowSaleSchedule( true );
}
}, [ userToggledSaleSchedule, autoToggledSaleSchedule, values ] );
const salePriceProps = getInputProps( 'sale_price', currencyInputProps );
const dateTimePickerProps = {
className: 'woocommerce-product__date-time-picker',
isDateOnlyPicker: true,
dateTimeFormat: dateFormat,
};
const onSaleScheduleToggleChange = ( value: boolean ) => {
recordEvent( 'product_pricing_schedule_sale_toggle_click', {
enabled: value,
} );
setUserToggledSaleSchedule( true );
setShowSaleSchedule( value );
if ( value ) {
setValues( {
date_on_sale_from_gmt: moment().startOf( 'day' ).toISOString(),
date_on_sale_to_gmt: null,
} as Product );
} else {
setValues( {
date_on_sale_from_gmt: null,
date_on_sale_to_gmt: null,
} as Product );
}
};
return (
<>
<BaseControl
id="product_pricing_sale_price"
help={ salePriceProps?.help ?? '' }
>
<InputControl
{ ...salePriceProps }
name="sale_price"
label={ __( 'Sale price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( salePriceProps?.value ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
<ToggleControl
label={
<>
{ __( 'Schedule sale', 'woocommerce' ) }
<Tooltip
text={ interpolateComponents( {
mixedString: __(
'The sale will start at the beginning of the "From" date ({{startTime/}}) and expire at the end of the "To" date ({{endTime/}}). {{moreLink/}}',
'woocommerce'
),
components: {
startTime: (
<span>
{ formatDate(
timeFormat,
moment().startOf( 'day' )
) }
</span>
),
endTime: (
<span>
{ formatDate(
timeFormat,
moment().endOf( 'day' )
) }
</span>
),
moreLink: (
<Link
href="https://woocommerce.com/document/managing-products/#product-data"
target="_blank"
type="external"
onClick={ () =>
recordEvent(
'add_product_learn_more',
{
category:
PRODUCT_SCHEDULED_SALE_SLUG,
}
)
}
>
{ __(
'Learn more',
'woocommerce'
) }
</Link>
),
},
} ) }
/>
</>
}
checked={ showSaleSchedule }
onChange={ onSaleScheduleToggleChange }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore disabled prop exists
disabled={ ! ( values.sale_price?.length > 0 ) }
/>
{ showSaleSchedule && (
<>
<DateTimePickerControl
label={ __( 'From', 'woocommerce' ) }
placeholder={ __( 'Now', 'woocommerce' ) }
timeForDateOnly={ 'start-of-day' }
currentDate={ values.date_on_sale_from_gmt }
{ ...getInputProps( 'date_on_sale_from_gmt', {
...dateTimePickerProps,
} ) }
/>
<DateTimePickerControl
label={ __( 'To', 'woocommerce' ) }
placeholder={ __( 'No end date', 'woocommerce' ) }
timeForDateOnly={ 'end-of-day' }
currentDate={ values.date_on_sale_to_gmt }
{ ...getInputProps( 'date_on_sale_to_gmt', {
...dateTimePickerProps,
} ) }
/>
</>
) }
</>
);
};

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { RadioControl } from '@wordpress/components';
export const PricingTaxesChargeField = () => {
const { getInputProps } = useFormContext< Product >();
const taxStatusProps = getInputProps( 'tax_status' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxStatusProps.checked;
delete taxStatusProps.value;
return (
<RadioControl
{ ...taxStatusProps }
label={ __( 'Charge sales tax on', 'woocommerce' ) }
options={ [
{
label: __( 'Product and shipping', 'woocommerce' ),
value: 'taxable',
},
{
label: __( 'Only shipping', 'woocommerce' ),
value: 'shipping',
},
{
label: __( `Don't charge tax`, 'woocommerce' ),
value: 'none',
},
] }
/>
);
};

View File

@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
useFormContext,
CollapsibleContent,
Link,
} from '@woocommerce/components';
import {
Product,
EXPERIMENTAL_TAX_CLASSES_STORE_NAME,
TaxClass,
} from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import { RadioControl } from '@wordpress/components';
import interpolateComponents from '@automattic/interpolate-components';
/**
* Internal dependencies
*/
import { STANDARD_RATE_TAX_CLASS_SLUG } from '../../constants';
export const PricingTaxesClassField = () => {
const { getInputProps } = useFormContext< Product >();
const { isResolving: isTaxClassesResolving, taxClasses } = useSelect(
( select ) => {
const { hasFinishedResolution, getTaxClasses } = select(
EXPERIMENTAL_TAX_CLASSES_STORE_NAME
);
return {
isResolving: ! hasFinishedResolution( 'getTaxClasses' ),
taxClasses: getTaxClasses< TaxClass[] >(),
};
}
);
const taxClassProps = getInputProps( 'tax_class' );
// These properties cause issues with the RadioControl component.
// A fix to form upstream would help if we can identify what type of input is used.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete taxClassProps.checked;
delete taxClassProps.value;
return (
<CollapsibleContent toggleText={ __( 'Advanced', 'woocommerce' ) }>
{ ! isTaxClassesResolving && taxClasses.length > 0 && (
<RadioControl
{ ...taxClassProps }
label={
<>
<span>{ __( 'Tax class', 'woocommerce' ) }</span>
<span className="woocommerce-product-form__secondary-text">
{ interpolateComponents( {
mixedString: __(
'Apply a tax rate if this product qualifies for tax reduction or exemption. {{link}}Learn more{{/link}}',
'woocommerce'
),
components: {
link: (
<Link
href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class"
target="_blank"
type="external"
>
<></>
</Link>
),
},
} ) }
</span>
</>
}
options={ taxClasses.map( ( taxClass ) => ( {
label: taxClass.name,
value:
taxClass.slug === STANDARD_RATE_TAX_CLASS_SLUG
? ''
: taxClass.slug,
} ) ) }
/>
) }
</CollapsibleContent>
);
};

View File

@ -0,0 +1,182 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalProductSectionLayout as ProductSectionLayout,
Link,
useFormContext,
} from '@woocommerce/components';
import { registerPlugin } from '@wordpress/plugins';
import { recordEvent } from '@woocommerce/tracks';
import { Product } from '@woocommerce/data';
import { useContext } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
PricingListField,
PricingSaleField,
PricingTaxesClassField,
PricingTaxesChargeField,
} from './index';
import { useProductHelper } from '../../use-product-helper';
import {
PRICING_SECTION_BASIC_ID,
PRICING_SECTION_TAXES_ID,
TAB_PRICING_ID,
PLUGIN_ID,
} from '../constants';
import { CurrencyContext } from '../../../lib/currency-context';
import './pricing-section.scss';
export type CurrencyInputProps = {
prefix: string;
className: string;
sanitize: ( value: Product[ keyof Product ] ) => string;
onFocus: ( event: React.FocusEvent< HTMLInputElement > ) => void;
onKeyUp: ( event: React.KeyboardEvent< HTMLInputElement > ) => void;
};
const PricingSection = () => {
const { setValues, values } = useFormContext< Product >();
const { sanitizePrice } = useProductHelper();
const context = useContext( CurrencyContext );
const { getCurrencyConfig } = context;
const currencyConfig = getCurrencyConfig();
const currencyInputProps: CurrencyInputProps = {
prefix: currencyConfig.symbol,
className: 'half-width-field components-currency-control',
sanitize: ( value: Product[ keyof Product ] ) => {
return sanitizePrice( String( value ) );
},
onFocus( event: React.FocusEvent< HTMLInputElement > ) {
// In some browsers like safari .select() function inside
// the onFocus event doesn't work as expected because it
// conflicts with onClick the first time user click the
// input. Using setTimeout defers the text selection and
// avoid the unexpected behaviour.
setTimeout(
function deferSelection( element: HTMLInputElement ) {
element.select();
},
0,
event.currentTarget
);
},
onKeyUp( event: React.KeyboardEvent< HTMLInputElement > ) {
const name = event.currentTarget.name as keyof Pick<
Product,
'regular_price' | 'sale_price'
>;
const amount = Number.parseFloat(
sanitizePrice( values[ name ] || '0' )
);
const step = Number( event.currentTarget.step || '1' );
if ( event.code === 'ArrowUp' ) {
setValues( {
[ name ]: String( amount + step ),
} as unknown as Product );
}
if ( event.code === 'ArrowDown' ) {
setValues( {
[ name ]: String( amount - step ),
} as unknown as Product );
}
},
};
return (
<>
<WooProductSectionItem
id={ PRICING_SECTION_BASIC_ID }
tabs={ [ { name: TAB_PRICING_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<ProductSectionLayout
title={ __( 'Pricing', 'woocommerce' ) }
description={
<>
<span>
{ __(
'Set a competitive price, put the product on sale, and manage tax calculations.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/posts/how-to-price-products-strategies-expert-tips/"
target="_blank"
type="external"
onClick={ () => {
recordEvent( 'add_product_pricing_help' );
} }
>
{ __(
'How to price your product: expert tips',
'woocommerce'
) }
</Link>
</>
}
>
<Card>
<CardBody>
<WooProductFieldItem.Slot
section={ PRICING_SECTION_BASIC_ID }
/>
</CardBody>
</Card>
<Card>
<CardBody>
<WooProductFieldItem.Slot
section={ PRICING_SECTION_TAXES_ID }
/>
</CardBody>
</Card>
</ProductSectionLayout>
</WooProductSectionItem>
<WooProductFieldItem
id="pricing/list"
sections={ [ { name: PRICING_SECTION_BASIC_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<PricingListField currencyInputProps={ currencyInputProps } />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/sale"
sections={ [ { name: PRICING_SECTION_BASIC_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
>
<PricingSaleField currencyInputProps={ currencyInputProps } />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/taxes/charge"
sections={ [ { name: PRICING_SECTION_TAXES_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<PricingTaxesChargeField />
</WooProductFieldItem>
<WooProductFieldItem
id="pricing/taxes/class"
sections={ [ { name: PRICING_SECTION_TAXES_ID, order: 3 } ] }
pluginId={ PLUGIN_ID }
>
<PricingTaxesClassField />
</WooProductFieldItem>
</>
);
};
registerPlugin( 'wc-admin-product-editor-pricing-section', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => <PricingSection />,
} );

View File

@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductFieldItem as WooProductFieldItem,
renderField,
@ -20,9 +19,8 @@ export const Fields: React.FC< { fields: ProductFormField[] } > = ( {
<WooProductFieldItem
key={ field.properties.name }
id={ field.id }
section={ field.section }
sections={ [ { name: field.section, order: field.order } ] }
pluginId={ field.plugin_id }
order={ field.order }
>
<>
{ renderField( field.type, {

View File

@ -1,8 +1,6 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Card, CardBody } from '@wordpress/components';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalProductFieldSection as ProductFieldSection,
@ -18,9 +16,10 @@ export const Sections: React.FC< { sections: ProductFormSection[] } > = ( {
<WooProductSectionItem
key={ section.id }
id={ section.id }
location={ section.location }
tabs={ [
{ name: section.location, order: section.order },
] }
pluginId={ section.plugin_id }
order={ section.order }
>
<ProductFieldSection
id={ section.id }

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export * from './shipping-field-class';
export * from './shipping-field-dimensions-width';
export * from './shipping-field-dimensions-length';
export * from './shipping-field-dimensions-height';
export * from './shipping-field-dimensions-weight';
export * from './types';

View File

@ -0,0 +1,213 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { Link, useFormContext, Spinner } from '@woocommerce/components';
import {
EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME,
PartialProduct,
ProductShippingClass,
} from '@woocommerce/data';
import interpolateComponents from '@automattic/interpolate-components';
import { recordEvent } from '@woocommerce/tracks';
import { SelectControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
ADD_NEW_SHIPPING_CLASS_OPTION_VALUE,
UNCATEGORIZED_CATEGORY_SLUG,
} from '../../constants';
import { ADMIN_URL } from '~/utils/admin-settings';
import { AddNewShippingClassModal } from '../../shared/add-new-shipping-class-modal';
import { ProductShippingSectionPropsType } from './index';
export const DEFAULT_SHIPPING_CLASS_OPTIONS: SelectControl.Option[] = [
{ value: '', label: __( 'No shipping class', 'woocommerce' ) },
{
value: ADD_NEW_SHIPPING_CLASS_OPTION_VALUE,
label: __( 'Add new shipping class', 'woocommerce' ),
},
];
function mapShippingClassToSelectOption(
shippingClasses: ProductShippingClass[]
): SelectControl.Option[] {
return shippingClasses.map( ( { slug, name } ) => ( {
value: slug,
label: name,
} ) );
}
type ServerErrorResponse = {
code: string;
};
// eslint-disable-next-line jsdoc/check-line-alignment
/**
* This extracts a shipping class from the product categories. Using
* the first category different to `Uncategorized` and check if the
* category was not added to the shipping class list
*
* @see https://github.com/woocommerce/woocommerce/issues/34657
* @see https://github.com/woocommerce/woocommerce/issues/35037
* @param product The product
* @param shippingClasses The shipping classes
* @return The default shipping class
*/
function extractDefaultShippingClassFromProduct(
product?: PartialProduct,
shippingClasses?: ProductShippingClass[]
): Partial< ProductShippingClass > | undefined {
const category = product?.categories?.find(
( { slug } ) => slug !== UNCATEGORIZED_CATEGORY_SLUG
);
if (
category &&
! shippingClasses?.some( ( { slug } ) => slug === category.slug )
) {
return {
name: category.name,
slug: category.slug,
};
}
}
export const ShippingClassField: React.FC<
ProductShippingSectionPropsType
> = ( { product } ) => {
const { getInputProps, getSelectControlProps, setValue } =
useFormContext< PartialProduct >();
const [ showShippingClassModal, setShowShippingClassModal ] =
useState( false );
const shippingClassProps = getInputProps( 'shipping_class' );
const { shippingClasses, hasResolvedShippingClasses } = useSelect(
( select ) => {
const { getProductShippingClasses, hasFinishedResolution } = select(
EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME
);
return {
hasResolvedShippingClasses: hasFinishedResolution(
'getProductShippingClasses'
),
shippingClasses:
getProductShippingClasses< ProductShippingClass[] >(),
};
},
[]
);
const { createProductShippingClass, invalidateResolution } = useDispatch(
EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME
);
const { createErrorNotice } = useDispatch( 'core/notices' );
function handleShippingClassServerError(
error: ServerErrorResponse
): Promise< ProductShippingClass > {
let message = __(
'We couldnt add this shipping class. Try again in a few seconds.',
'woocommerce'
);
if ( error.code === 'term_exists' ) {
message = __(
'A shipping class with that slug already exists.',
'woocommerce'
);
}
createErrorNotice( message, {
explicitDismiss: true,
} );
throw error;
}
return (
<>
{ hasResolvedShippingClasses ? (
<>
<SelectControl
label={ __( 'Shipping class', 'woocommerce' ) }
{ ...getSelectControlProps( 'shipping_class', {
className: 'half-width-field',
} ) }
onChange={ ( value: string ) => {
if (
value === ADD_NEW_SHIPPING_CLASS_OPTION_VALUE
) {
setShowShippingClassModal( true );
return;
}
shippingClassProps.onChange( value );
} }
options={ [
...DEFAULT_SHIPPING_CLASS_OPTIONS,
...mapShippingClassToSelectOption(
shippingClasses ?? []
),
] }
/>
<span className="woocommerce-product-form__secondary-text">
{ interpolateComponents( {
mixedString: __(
'Manage shipping classes and rates in {{link}}global settings{{/link}}.',
'woocommerce'
),
components: {
link: (
<Link
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=shipping&section=classes` }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_shipping_global_settings_link_click'
);
} }
>
<></>
</Link>
),
},
} ) }
</span>
</>
) : (
<div className="product-shipping-section__spinner-wrapper">
<Spinner />
</div>
) }
{ showShippingClassModal && (
<AddNewShippingClassModal
shippingClass={ extractDefaultShippingClassFromProduct(
product,
shippingClasses
) }
onAdd={ ( shippingClassValues ) =>
createProductShippingClass<
Promise< ProductShippingClass >
>( shippingClassValues )
.then( ( value ) => {
recordEvent(
'product_new_shipping_class_modal_add_button_click'
);
invalidateResolution(
'getProductShippingClasses'
);
setValue( 'shipping_class', value.slug );
return value;
} )
.catch( handleShippingClassServerError )
}
onCancel={ () => setShowShippingClassModal( false ) }
/>
) }
</>
);
};

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { PartialProduct } from '@woocommerce/data';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useProductHelper } from '../../use-product-helper';
import { getInterpolatedSizeLabel } from './utils';
import { ShippingDimensionsPropsType } from './index';
export const ShippingDimensionsHeightField = ( {
dimensionProps,
setHighlightSide,
}: ShippingDimensionsPropsType ) => {
const { getInputProps } = useFormContext< PartialProduct >();
const { formatNumber } = useProductHelper();
const inputHeightProps = getInputProps(
'dimensions.height',
dimensionProps
);
return (
<BaseControl
id="product_shipping_dimensions_height"
className={ inputHeightProps.className }
help={ inputHeightProps.help }
>
<InputControl
{ ...inputHeightProps }
value={ formatNumber( String( inputHeightProps.value ) ) }
label={ getInterpolatedSizeLabel(
__( 'Height {{span}}C{{/span}}', 'woocommerce' )
) }
onFocus={ () => {
setHighlightSide( 'C' );
} }
/>
</BaseControl>
);
};

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { PartialProduct } from '@woocommerce/data';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useProductHelper } from '../../use-product-helper';
import { getInterpolatedSizeLabel } from './utils';
import { ShippingDimensionsPropsType } from './index';
export const ShippingDimensionsLengthField = ( {
dimensionProps,
setHighlightSide,
}: ShippingDimensionsPropsType ) => {
const { getInputProps } = useFormContext< PartialProduct >();
const { formatNumber } = useProductHelper();
const inputLengthProps = getInputProps(
'dimensions.length',
dimensionProps
);
return (
<BaseControl
id="product_shipping_dimensions_length"
className={ inputLengthProps.className }
help={ inputLengthProps.help }
>
<InputControl
{ ...inputLengthProps }
value={ formatNumber( String( inputLengthProps.value ) ) }
label={ getInterpolatedSizeLabel(
__( 'Length {{span}}B{{/span}}', 'woocommerce' )
) }
onFocus={ () => {
setHighlightSide( 'B' );
} }
/>
</BaseControl>
);
};

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { OPTIONS_STORE_NAME, PartialProduct } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useProductHelper } from '../../use-product-helper';
export const ShippingDimensionsWeightField = () => {
const { getInputProps } = useFormContext< PartialProduct >();
const { formatNumber, parseNumber } = useProductHelper();
const { weightUnit, hasResolvedUnits } = useSelect( ( select ) => {
const { getOption, hasFinishedResolution } =
select( OPTIONS_STORE_NAME );
return {
weightUnit: getOption( 'woocommerce_weight_unit' ),
hasResolvedUnits: hasFinishedResolution( 'getOption', [
'woocommerce_weight_unit',
] ),
};
}, [] );
if ( ! hasResolvedUnits ) {
return null;
}
const inputWeightProps = getInputProps( 'weight', {
sanitize: ( value: PartialProduct[ keyof PartialProduct ] ) =>
parseNumber( String( value ) ),
} );
return (
<BaseControl
id="product_shipping_weight"
className={ inputWeightProps.className }
help={ inputWeightProps.help }
>
<InputControl
{ ...inputWeightProps }
value={ formatNumber( String( inputWeightProps.value ) ) }
label={ __( 'Weight', 'woocommerce' ) }
suffix={ weightUnit }
/>
</BaseControl>
);
};

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { PartialProduct } from '@woocommerce/data';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useProductHelper } from '../../use-product-helper';
import { getInterpolatedSizeLabel } from './utils';
import { ShippingDimensionsPropsType } from './index';
export const ShippingDimensionsWidthField = ( {
dimensionProps,
setHighlightSide,
}: ShippingDimensionsPropsType ) => {
const { getInputProps } = useFormContext< PartialProduct >();
const { formatNumber } = useProductHelper();
const inputWidthProps = getInputProps( 'dimensions.width', dimensionProps );
return (
<BaseControl
id="product_shipping_dimensions_width"
className={ inputWidthProps.className }
help={ inputWidthProps.help }
>
<InputControl
{ ...inputWidthProps }
value={ formatNumber( String( inputWidthProps.value ) ) }
label={ getInterpolatedSizeLabel(
__( 'Width {{span}}A{{/span}}', 'woocommerce' )
) }
onFocus={ () => {
setHighlightSide( 'A' );
} }
/>
</BaseControl>
);
};

View File

@ -0,0 +1,189 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
__experimentalWooProductSectionItem as WooProductSectionItem,
__experimentalWooProductFieldItem as WooProductFieldItem,
__experimentalProductSectionLayout as ProductSectionLayout,
} from '@woocommerce/components';
import { registerPlugin } from '@wordpress/plugins';
import { PartialProduct, OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
ShippingClassField,
ShippingDimensionsWidthField,
ShippingDimensionsLengthField,
ShippingDimensionsHeightField,
ShippingDimensionsWeightField,
ProductShippingSectionPropsType,
DimensionPropsType,
ShippingDimensionsPropsType,
} from './index';
import {
PLUGIN_ID,
SHIPPING_SECTION_BASIC_ID,
SHIPPING_SECTION_DIMENSIONS_ID,
TAB_SHIPPING_ID,
} from '../constants';
import {
ShippingDimensionsImage,
ShippingDimensionsImageProps,
} from '../../fields/shipping-dimensions-image';
import { useProductHelper } from '../../use-product-helper';
import './shipping-section.scss';
const ShippingSection = () => {
const [ highlightSide, setHighlightSide ] =
useState< ShippingDimensionsImageProps[ 'highlight' ] >();
const { parseNumber } = useProductHelper();
const { dimensionUnit, hasResolvedUnits } = useSelect( ( select ) => {
const { getOption, hasFinishedResolution } =
select( OPTIONS_STORE_NAME );
return {
dimensionUnit: getOption( 'woocommerce_dimension_unit' ),
weightUnit: getOption( 'woocommerce_weight_unit' ),
hasResolvedUnits:
hasFinishedResolution( 'getOption', [
'woocommerce_dimension_unit',
] ) &&
hasFinishedResolution( 'getOption', [
'woocommerce_weight_unit',
] ),
};
}, [] );
const dimensionProps: DimensionPropsType = {
onBlur: () => {
setHighlightSide( undefined );
},
sanitize: ( value: PartialProduct[ keyof PartialProduct ] ) =>
parseNumber( String( value ) ),
suffix: dimensionUnit,
};
return (
<>
<WooProductSectionItem
id={ SHIPPING_SECTION_BASIC_ID }
tabs={ [ { name: TAB_SHIPPING_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
<ProductSectionLayout
title={ __( 'Shipping', 'woocommerce' ) }
description={ __(
'Set up shipping costs and enter dimensions used for accurate rate calculations.',
'woocommerce'
) }
>
<Card>
<CardBody className="product-shipping-section__classes">
<WooProductFieldItem.Slot
section={ SHIPPING_SECTION_BASIC_ID }
/>
</CardBody>
</Card>
<Card>
<CardBody className="product-shipping-section__dimensions">
<h4>{ __( 'Dimensions', 'woocommerce' ) }</h4>
<p className="woocommerce-product-form__secondary-text">
{ __(
`Enter the size of the product as you'd put it in a shipping box, including packaging like bubble wrap.`,
'woocommerce'
) }
</p>
<div className="product-shipping-section__dimensions-body">
<div className="product-shipping-section__dimensions-body-col">
{ hasResolvedUnits && (
<WooProductFieldItem.Slot
section={
SHIPPING_SECTION_DIMENSIONS_ID
}
fillProps={
{
setHighlightSide,
dimensionProps,
} as ShippingDimensionsPropsType
}
/>
) }
</div>
<div className="product-shipping-section__dimensions-body-col">
<ShippingDimensionsImage
highlight={ highlightSide }
className="product-shipping-section__dimensions-image"
/>
</div>
</div>
</CardBody>
</Card>
</ProductSectionLayout>
</WooProductSectionItem>
<WooProductFieldItem
id="shipping/class"
sections={ [ { name: SHIPPING_SECTION_BASIC_ID, order: 1 } ] }
pluginId={ PLUGIN_ID }
>
{ ( { product }: ProductShippingSectionPropsType ) => (
<ShippingClassField product={ product } />
) }
</WooProductFieldItem>
<WooProductFieldItem
id="shipping/dimensions/width"
sections={ [
{ name: SHIPPING_SECTION_DIMENSIONS_ID, order: 1 },
] }
pluginId={ PLUGIN_ID }
>
{ ( { ...props }: ShippingDimensionsPropsType ) => (
<ShippingDimensionsWidthField { ...props } />
) }
</WooProductFieldItem>
<WooProductFieldItem
id="shipping/dimensions/length"
sections={ [
{ name: SHIPPING_SECTION_DIMENSIONS_ID, order: 3 },
] }
pluginId={ PLUGIN_ID }
>
{ ( { ...props }: ShippingDimensionsPropsType ) => (
<ShippingDimensionsLengthField { ...props } />
) }
</WooProductFieldItem>
<WooProductFieldItem
id="shipping/dimensions/height"
sections={ [
{ name: SHIPPING_SECTION_DIMENSIONS_ID, order: 5 },
] }
pluginId={ PLUGIN_ID }
>
{ ( { ...props }: ShippingDimensionsPropsType ) => (
<ShippingDimensionsHeightField { ...props } />
) }
</WooProductFieldItem>
<WooProductFieldItem
id="shipping/dimensions/weight"
sections={ [
{ name: SHIPPING_SECTION_DIMENSIONS_ID, order: 7 },
] }
pluginId={ PLUGIN_ID }
>
<ShippingDimensionsWeightField />
</WooProductFieldItem>
</>
);
};
registerPlugin( 'wc-admin-product-editor-shipping-section', {
// @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated.
scope: 'woocommerce-product-editor',
render: () => <ShippingSection />,
} );

View File

@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { PartialProduct } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { ShippingDimensionsImageProps } from '../../fields/shipping-dimensions-image';
export type ProductShippingSectionPropsType = {
product?: PartialProduct;
};
export type DimensionPropsType = {
onBlur: () => void;
sanitize: ( value: PartialProduct[ keyof PartialProduct ] ) => string;
suffix: unknown;
};
export type ShippingDimensionsPropsType = {
dimensionProps: DimensionPropsType;
setHighlightSide: (
side: ShippingDimensionsImageProps[ 'highlight' ]
) => void;
};

View File

@ -0,0 +1,13 @@
/**
* External dependencies
*/
import interpolateComponents from '@automattic/interpolate-components';
export const getInterpolatedSizeLabel = ( mixedString: string ) => {
return interpolateComponents( {
mixedString,
components: {
span: <span className="woocommerce-product-form__secondary-text" />,
},
} );
};

View File

@ -2,11 +2,13 @@
* Internal dependencies
*/
import { ProductMVPCESFooter } from '~/customer-effort-score-tracks/product-mvp-ces-footer';
import { ProductMVPFeedbackModalContainer } from '~/customer-effort-score-tracks/product-mvp-feedback-modal-container';
export const ProductFormFooter: React.FC = () => {
return (
<>
<ProductMVPCESFooter />
<ProductMVPFeedbackModalContainer />
</>
);
};

View File

@ -30,7 +30,8 @@ $product-form-tabs-height: 56px;
left: $admin-menu-width;
width: calc(100% - $admin-menu-width);
background: $white;
z-index: 1001;
// This z-index is directly below the header of 1001.
z-index: 1000;
border-bottom: 1px solid $gray-400;
border-top: 1px solid $gray-400;
padding: 0 var(--large-gap) 0 var(--large-gap);
@ -81,6 +82,16 @@ $product-form-tabs-height: 56px;
}
}
.woocommerce-admin-product-layout .woocommerce-layout__header {
min-height: $header-height + $product-form-tabs-height;
.woocommerce-admin-product-layout {
.woocommerce-layout__primary {
padding-top: $product-form-tabs-height;
}
.woocommerce-layout__header.is-scrolled {
box-shadow: none;
~.woocommerce-layout__primary .product-form-layout .components-tab-panel__tabs {
box-shadow: $header-scroll-shadow;
}
}
}

Some files were not shown because too many files have changed in this diff Show More