Add option to create product by template (https://github.com/woocommerce/woocommerce-admin/pull/5892)
* Initial product template modal * Add custom product template for the new-post route * Add test, and code for making use of the insert_post action * Added ProductTemplates Datastore to create template with REST api * Add back variation method, deleted by accident * Move product from template endpoint to OnboardingTasks class * Fix lint errors * Added tracks and template hooks * Rename product create permission check function, for less confusion * Remove duplicate recommended option * Fix stylelint errors * PHP tests for the product_from_template endpoint * Fix onboarding php unit tests * Write tests for client product template options * Refactored list component, to use it as a radio control list * Fix lint errors * REmove assertion as it fails on the ci * Add changelog * Updating tests to make sure all product attribute taxonomies are removed * Add more specific assertions for importing sample products test * Update the sample products test * Deconstruct item object, from PR suggestion * Fix PHP errors, by updating the createProductFromTemplate call
This commit is contained in:
parent
3a792f66d9
commit
74b3eccbc9
|
@ -17,7 +17,7 @@ import { recordEvent } from '@woocommerce/tracks';
|
|||
*/
|
||||
import Appearance from './tasks/appearance';
|
||||
import { getCategorizedOnboardingProducts } from '../dashboard/utils';
|
||||
import Products from './tasks/products';
|
||||
import { Products } from './tasks/products';
|
||||
import Shipping from './tasks/shipping';
|
||||
import Tax from './tasks/tax';
|
||||
import Payments from './tasks/payments';
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Products from './products';
|
||||
import ProductTemplateModal from './product-template-modal';
|
||||
|
||||
export { Products, ProductTemplateModal };
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Button, Modal } from '@wordpress/components';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { applyFilters } from '@wordpress/hooks';
|
||||
import { ITEMS_STORE_NAME } from '@woocommerce/data';
|
||||
import { getAdminLink } from '@woocommerce/wc-admin-settings';
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { List } from '@woocommerce/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './product-template-modal.scss';
|
||||
import { createNoticesFromResponse } from '../../../lib/notices';
|
||||
|
||||
export const ONBOARDING_PRODUCT_TEMPLATES_FILTER =
|
||||
'woocommerce_admin_onboarding_product_templates';
|
||||
|
||||
const PRODUCT_TEMPLATES = [
|
||||
{
|
||||
key: 'physical',
|
||||
title: __( 'Physical product', 'woocommerce-admin' ),
|
||||
subtitle: __(
|
||||
'Tangible items that get delivered to customers',
|
||||
'woocommerce-admin'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'digital',
|
||||
title: __( 'Digital product', 'woocommerce-admin' ),
|
||||
subtitle: __(
|
||||
'Items that customers download or access through your website',
|
||||
'woocommerce-admin'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'variable',
|
||||
title: __( 'Variable product', 'woocommerce-admin' ),
|
||||
subtitle: __(
|
||||
'Products with several versions that customers can choose from',
|
||||
'woocommerce-admin'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductTemplateModal( { onClose } ) {
|
||||
const [ selectedTemplate, setSelectedTemplate ] = useState();
|
||||
const [ isRedirecting, setIsRedirecting ] = useState( false );
|
||||
const { createProductFromTemplate } = useDispatch( ITEMS_STORE_NAME );
|
||||
|
||||
const createTemplate = () => {
|
||||
setIsRedirecting( true );
|
||||
recordEvent( 'tasklist_product_template_selection', {
|
||||
product_type: selectedTemplate,
|
||||
} );
|
||||
if ( selectedTemplate ) {
|
||||
createProductFromTemplate(
|
||||
{
|
||||
template_name: selectedTemplate,
|
||||
status: 'draft',
|
||||
},
|
||||
{ _fields: [ 'id' ] }
|
||||
).then(
|
||||
( data ) => {
|
||||
if ( data && data.id ) {
|
||||
const link = getAdminLink(
|
||||
`post.php?post=${ data.id }&action=edit&wc_onboarding_active_task=products&tutorial=true`
|
||||
);
|
||||
window.location = link;
|
||||
}
|
||||
},
|
||||
( error ) => {
|
||||
// failed creating product with template
|
||||
createNoticesFromResponse( error );
|
||||
setIsRedirecting( false );
|
||||
}
|
||||
);
|
||||
} else if ( onClose ) {
|
||||
recordEvent( 'tasklist_product_template_dismiss' );
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTemplateClick = ( event ) => {
|
||||
const val = event.target && event.target.value;
|
||||
setSelectedTemplate( val );
|
||||
};
|
||||
|
||||
const templates = applyFilters(
|
||||
ONBOARDING_PRODUCT_TEMPLATES_FILTER,
|
||||
PRODUCT_TEMPLATES
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={ __( 'Start with a template' ) }
|
||||
isDismissible={ true }
|
||||
onRequestClose={ () => onClose() }
|
||||
className="woocommerce-product-template-modal"
|
||||
>
|
||||
<div className="woocommerce-product-template-modal__wrapper">
|
||||
<div className="woocommerce-product-template-modal__list">
|
||||
<List items={ templates }>
|
||||
{ ( item, index ) => (
|
||||
<div className="woocommerce-list__item-inner">
|
||||
<input
|
||||
id={ `product-templates-${
|
||||
item.key || index
|
||||
}` }
|
||||
className="components-radio-control__input"
|
||||
type="radio"
|
||||
name="product-template-options"
|
||||
value={ item.key }
|
||||
onChange={ onSelectTemplateClick }
|
||||
checked={ item.key === selectedTemplate }
|
||||
/>
|
||||
<label
|
||||
className="woocommerce-list__item-text"
|
||||
htmlFor={ `product-templates-${
|
||||
item.key || index
|
||||
}` }
|
||||
>
|
||||
<div className="woocommerce-list__item-label">
|
||||
{ item.title }
|
||||
</div>
|
||||
<div className="woocommerce-list__item-subtitle">
|
||||
{ item.subtitle }
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
) }
|
||||
</List>
|
||||
</div>
|
||||
<div className="woocommerce-product-template-modal__actions">
|
||||
<Button
|
||||
isPrimary
|
||||
isBusy={ isRedirecting }
|
||||
disabled={ ! selectedTemplate || isRedirecting }
|
||||
onClick={ createTemplate }
|
||||
>
|
||||
{ __( 'Go' ) }
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
$border-color: $gray-100;
|
||||
|
||||
.woocommerce-product-template-modal {
|
||||
min-width: 565px;
|
||||
|
||||
.woocommerce-product-template-modal__actions {
|
||||
padding-top: $gap-large;
|
||||
}
|
||||
}
|
||||
.woocommerce-product-template-modal__list {
|
||||
.woocommerce-list {
|
||||
margin: 0 #{-1 * $gap-large};
|
||||
border-bottom: 1px solid $border-color;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
.woocommerce-list__item-inner {
|
||||
.components-radio-control__input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: $gap;
|
||||
&:checked::before {
|
||||
margin: 1px 0 0 1px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--wp-admin-theme-color);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.woocommerce-list__item-label {
|
||||
color: $gray-900;
|
||||
}
|
||||
.woocommerce-list__item-subtitle {
|
||||
color: $gray-700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.woocommerce-list__item:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
.woocommerce-product-template-modal__actions {
|
||||
text-align: right;
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Component, Fragment } from '@wordpress/element';
|
||||
import { Fragment, useState } from '@wordpress/element';
|
||||
import { Card, CardBody } from '@wordpress/components';
|
||||
import { List } from '@woocommerce/components';
|
||||
import { getAdminLink } from '@woocommerce/wc-admin-settings';
|
||||
|
@ -11,10 +11,26 @@ import { recordEvent } from '@woocommerce/tracks';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductTemplateModal from './product-template-modal';
|
||||
|
||||
const subTasks = [
|
||||
{
|
||||
title: __( 'Add manually (recommended)', 'woocommerce-admin' ),
|
||||
key: 'addProductTemplate',
|
||||
title: __( 'Start with a template (recommended)', 'woocommerce-admin' ),
|
||||
content: __(
|
||||
'For small stores we recommend adding products manually',
|
||||
'woocommerce-admin'
|
||||
),
|
||||
before: <i className="material-icons-outlined">add_box</i>,
|
||||
after: <i className="material-icons-outlined">chevron_right</i>,
|
||||
onClick: () =>
|
||||
recordEvent( 'tasklist_add_product', {
|
||||
method: 'product_template',
|
||||
} ),
|
||||
},
|
||||
{
|
||||
key: 'addProductManually',
|
||||
title: __( 'Add manually', 'woocommerce-admin' ),
|
||||
content: __(
|
||||
'For small stores we recommend adding products manually',
|
||||
'woocommerce-admin'
|
||||
|
@ -28,6 +44,7 @@ const subTasks = [
|
|||
),
|
||||
},
|
||||
{
|
||||
key: 'importProducts',
|
||||
title: __( 'Import', 'woocommerce-admin' ),
|
||||
content: __(
|
||||
'For larger stores we recommend importing all products at once via CSV file',
|
||||
|
@ -42,6 +59,7 @@ const subTasks = [
|
|||
),
|
||||
},
|
||||
{
|
||||
key: 'migrateProducts',
|
||||
title: __( 'Migrate', 'woocommerce-admin' ),
|
||||
content: __(
|
||||
'For stores currently selling elsewhere we suggest using a product migration service',
|
||||
|
@ -57,16 +75,33 @@ const subTasks = [
|
|||
},
|
||||
];
|
||||
|
||||
export default class Products extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Card className="woocommerce-task-card">
|
||||
<CardBody size={ null }>
|
||||
<List items={ subTasks } />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
export default function Products() {
|
||||
const [ selectTemplate, setSelectTemplate ] = useState( null );
|
||||
|
||||
const onTaskClick = ( task ) => {
|
||||
task.onClick();
|
||||
if ( task.key === 'addProductTemplate' ) {
|
||||
setSelectTemplate( true );
|
||||
}
|
||||
};
|
||||
|
||||
const listItems = subTasks.map( ( task ) => ( {
|
||||
...task,
|
||||
onClick: () => onTaskClick( task ),
|
||||
} ) );
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Card className="woocommerce-task-card">
|
||||
<CardBody size={ null }>
|
||||
<List items={ listItems } />
|
||||
</CardBody>
|
||||
</Card>
|
||||
{ selectTemplate ? (
|
||||
<ProductTemplateModal
|
||||
onClose={ () => setSelectTemplate( null ) }
|
||||
/>
|
||||
) : null }
|
||||
</Fragment>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Products, ProductTemplateModal } from '../tasks/products';
|
||||
|
||||
describe( 'products', () => {
|
||||
describe( 'Products', () => {
|
||||
afterEach( () => jest.clearAllMocks() );
|
||||
|
||||
it( 'should render 4 different options to add products', () => {
|
||||
render( <Products /> );
|
||||
|
||||
expect(
|
||||
screen.queryByText( 'Start with a template (recommended)' )
|
||||
).toBeInTheDocument();
|
||||
expect( screen.queryByText( 'Add manually' ) ).toBeInTheDocument();
|
||||
expect( screen.queryByText( 'Import' ) ).toBeInTheDocument();
|
||||
expect( screen.queryByText( 'Migrate' ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should not render the product template modal right away', () => {
|
||||
render( <Products /> );
|
||||
|
||||
expect( screen.queryByText( '[ProductTemplateModal]' ) ).toBeNull();
|
||||
} );
|
||||
|
||||
it( 'should render product template modal when start with template task is selected', () => {
|
||||
render( <Products /> );
|
||||
|
||||
fireEvent(
|
||||
screen.queryByText( 'Start with a template (recommended)' ),
|
||||
// eslint-disable-next-line no-undef
|
||||
new MouseEvent( 'click', { bubbles: true } )
|
||||
);
|
||||
expect(
|
||||
screen.queryByText( 'Physical product' )
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Digital product' )
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Variable product' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'should allow the user to close the template modal', () => {
|
||||
render( <Products /> );
|
||||
|
||||
fireEvent(
|
||||
screen.queryByText( 'Start with a template (recommended)' ),
|
||||
// eslint-disable-next-line no-undef
|
||||
new MouseEvent( 'click', { bubbles: true } )
|
||||
);
|
||||
expect(
|
||||
screen.queryByText( 'Physical product' )
|
||||
).toBeInTheDocument();
|
||||
const closeButton = screen.getByRole( 'button', {
|
||||
name: 'Close dialog',
|
||||
} );
|
||||
fireEvent.click( closeButton );
|
||||
expect(
|
||||
screen.queryByText( 'Physical product' )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'ProductWithTemplate', () => {
|
||||
afterEach( () => jest.clearAllMocks() );
|
||||
|
||||
it( 'should render 3 different product types', () => {
|
||||
render( <ProductTemplateModal /> );
|
||||
|
||||
expect(
|
||||
screen.queryByText( 'Physical product' )
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Digital product' )
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText( 'Variable product' )
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -1,5 +1,4 @@
|
|||
List
|
||||
===
|
||||
# List
|
||||
|
||||
List component to display a list of items.
|
||||
|
||||
|
@ -29,23 +28,33 @@ const listItems = [
|
|||
},
|
||||
];
|
||||
|
||||
<List items={ listItems } />
|
||||
<List items={ listItems } />;
|
||||
```
|
||||
|
||||
If you wanted a different format for the individual list item you can pass in a functional child:
|
||||
|
||||
```
|
||||
<List items={ listItems } >
|
||||
{
|
||||
(item, index) => <div className="woocommerce-list__item-inner">{item.title}</div>
|
||||
}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Name | Type | Default | Description
|
||||
--- | --- | --- | ---
|
||||
`className` | String | `null` | Additional class name to style the component
|
||||
`items` | Array | `null` | (required) An array of list items
|
||||
| Name | Type | Default | Description |
|
||||
| ----------- | ------ | ------- | -------------------------------------------- |
|
||||
| `className` | String | `null` | Additional class name to style the component |
|
||||
| `items` | Array | `null` | (required) An array of list items |
|
||||
|
||||
`items` structure:
|
||||
|
||||
* `after`: ReactNode - Content displayed after the list item text.
|
||||
* `before`: ReactNode - Content displayed before the list item text.
|
||||
* `className`: String - Additional class name to style the list item.
|
||||
* `description`: String - Description displayed beneath the list item title.
|
||||
* `href`: String - Href attribute used in a Link wrapped around the item.
|
||||
* `onClick`: Function - Called when the list item is clicked.
|
||||
* `target`: String - Target attribute used for Link wrapper.
|
||||
* `title`: String - Title displayed for the list item.
|
||||
- `after`: ReactNode - Content displayed after the list item text.
|
||||
- `before`: ReactNode - Content displayed before the list item text.
|
||||
- `className`: String - Additional class name to style the list item.
|
||||
- `description`: String - Description displayed beneath the list item title.
|
||||
- `href`: String - Href attribute used in a Link wrapped around the item.
|
||||
- `onClick`: Function - Called when the list item is clicked.
|
||||
- `target`: String - Target attribute used for Link wrapper.
|
||||
- `title`: String - Title displayed for the list item.
|
||||
|
|
|
@ -2,119 +2,54 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { ENTER } from '@wordpress/keycodes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Link from '../link';
|
||||
import ListItem from './list-item';
|
||||
|
||||
/**
|
||||
* List component to display a list of items.
|
||||
*
|
||||
* @param {Object} props props for list
|
||||
*/
|
||||
class List extends Component {
|
||||
handleKeyDown( event, onClick ) {
|
||||
if ( typeof onClick === 'function' && event.keyCode === ENTER ) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
function List( props ) {
|
||||
const { className, items, children } = props;
|
||||
const listClassName = classnames( 'woocommerce-list', className );
|
||||
|
||||
getItemLinkType( item ) {
|
||||
const { href, linkType } = item;
|
||||
return (
|
||||
<TransitionGroup component="ul" className={ listClassName } role="menu">
|
||||
{ items.map( ( item, index ) => {
|
||||
const { className: itemClasses, href, key, onClick } = item;
|
||||
const hasAction = typeof onClick === 'function' || href;
|
||||
const itemClassName = classnames(
|
||||
'woocommerce-list__item',
|
||||
itemClasses,
|
||||
{
|
||||
'has-action': hasAction,
|
||||
}
|
||||
);
|
||||
|
||||
if ( linkType ) {
|
||||
return linkType;
|
||||
}
|
||||
|
||||
return href ? 'external' : null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, items } = this.props;
|
||||
const listClassName = classnames( 'woocommerce-list', className );
|
||||
|
||||
return (
|
||||
<TransitionGroup
|
||||
component="ul"
|
||||
className={ listClassName }
|
||||
role="menu"
|
||||
>
|
||||
{ items.map( ( item, index ) => {
|
||||
const {
|
||||
after,
|
||||
before,
|
||||
className: itemClasses,
|
||||
content,
|
||||
href,
|
||||
key,
|
||||
listItemTag,
|
||||
onClick,
|
||||
target,
|
||||
title,
|
||||
} = item;
|
||||
const hasAction = typeof onClick === 'function' || href;
|
||||
const itemClassName = classnames(
|
||||
'woocommerce-list__item',
|
||||
itemClasses,
|
||||
{
|
||||
'has-action': hasAction,
|
||||
}
|
||||
);
|
||||
const InnerTag = href ? Link : 'div';
|
||||
|
||||
const innerTagProps = {
|
||||
className: 'woocommerce-list__item-inner',
|
||||
onClick: typeof onClick === 'function' ? onClick : null,
|
||||
'aria-disabled': hasAction ? 'false' : null,
|
||||
tabIndex: hasAction ? '0' : null,
|
||||
role: hasAction ? 'menuitem' : null,
|
||||
onKeyDown: ( e ) =>
|
||||
hasAction ? this.handleKeyDown( e, onClick ) : null,
|
||||
target: href ? target : null,
|
||||
type: this.getItemLinkType( item ),
|
||||
href,
|
||||
'data-list-item-tag': listItemTag,
|
||||
};
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={ key || index }
|
||||
timeout={ 500 }
|
||||
classNames="woocommerce-list__item"
|
||||
>
|
||||
<li className={ itemClassName }>
|
||||
<InnerTag { ...innerTagProps }>
|
||||
{ before && (
|
||||
<div className="woocommerce-list__item-before">
|
||||
{ before }
|
||||
</div>
|
||||
) }
|
||||
<div className="woocommerce-list__item-text">
|
||||
<span className="woocommerce-list__item-title">
|
||||
{ title }
|
||||
</span>
|
||||
{ content && (
|
||||
<span className="woocommerce-list__item-content">
|
||||
{ content }
|
||||
</span>
|
||||
) }
|
||||
</div>
|
||||
{ after && (
|
||||
<div className="woocommerce-list__item-after">
|
||||
{ after }
|
||||
</div>
|
||||
) }
|
||||
</InnerTag>
|
||||
</li>
|
||||
</CSSTransition>
|
||||
);
|
||||
} ) }
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CSSTransition
|
||||
key={ key || index }
|
||||
timeout={ 500 }
|
||||
classNames="woocommerce-list__item"
|
||||
>
|
||||
<li className={ itemClassName }>
|
||||
{ children ? (
|
||||
children( item, index )
|
||||
) : (
|
||||
<ListItem item={ item } />
|
||||
) }
|
||||
</li>
|
||||
</CSSTransition>
|
||||
);
|
||||
} ) }
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
List.propTypes = {
|
||||
|
@ -162,6 +97,10 @@ List.propTypes = {
|
|||
* Title displayed for the list item.
|
||||
*/
|
||||
title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
|
||||
/**
|
||||
* Unique key for list item.
|
||||
*/
|
||||
key: PropTypes.string,
|
||||
} )
|
||||
).isRequired,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ENTER } from '@wordpress/keycodes';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Link from '../link';
|
||||
|
||||
function handleKeyDown( event, onClick ) {
|
||||
if ( typeof onClick === 'function' && event.keyCode === ENTER ) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
|
||||
function getItemLinkType( item ) {
|
||||
const { href, linkType } = item;
|
||||
|
||||
if ( linkType ) {
|
||||
return linkType;
|
||||
}
|
||||
|
||||
return href ? 'external' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List component to display a list of items.
|
||||
*
|
||||
* @param {Object} props props for list item
|
||||
*/
|
||||
function ListItem( props ) {
|
||||
const { item } = props;
|
||||
const {
|
||||
before,
|
||||
title,
|
||||
after,
|
||||
content,
|
||||
onClick,
|
||||
href,
|
||||
target,
|
||||
listItemTag,
|
||||
} = item;
|
||||
const hasAction = typeof onClick === 'function' || href;
|
||||
const InnerTag = href ? Link : 'div';
|
||||
|
||||
const innerTagProps = {
|
||||
className: 'woocommerce-list__item-inner',
|
||||
onClick: typeof onClick === 'function' ? onClick : null,
|
||||
'aria-disabled': hasAction ? 'false' : null,
|
||||
tabIndex: hasAction ? '0' : null,
|
||||
role: hasAction ? 'menuitem' : null,
|
||||
onKeyDown: ( e ) => ( hasAction ? handleKeyDown( e, onClick ) : null ),
|
||||
target: href ? target : null,
|
||||
type: getItemLinkType( item ),
|
||||
href,
|
||||
'data-list-item-tag': listItemTag,
|
||||
};
|
||||
|
||||
return (
|
||||
<InnerTag { ...innerTagProps }>
|
||||
{ before && (
|
||||
<div className="woocommerce-list__item-before">{ before }</div>
|
||||
) }
|
||||
<div className="woocommerce-list__item-text">
|
||||
<span className="woocommerce-list__item-title">{ title }</span>
|
||||
{ content && (
|
||||
<span className="woocommerce-list__item-content">
|
||||
{ content }
|
||||
</span>
|
||||
) }
|
||||
</div>
|
||||
{ after && (
|
||||
<div className="woocommerce-list__item-after">{ after }</div>
|
||||
) }
|
||||
</InnerTag>
|
||||
);
|
||||
}
|
||||
|
||||
ListItem.propTypes = {
|
||||
/**
|
||||
* An array of list items.
|
||||
*/
|
||||
item: PropTypes.shape( {
|
||||
/**
|
||||
* Content displayed after the list item text.
|
||||
*/
|
||||
after: PropTypes.node,
|
||||
/**
|
||||
* Content displayed before the list item text.
|
||||
*/
|
||||
before: PropTypes.node,
|
||||
/**
|
||||
* Additional class name to style the list item.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* Content displayed beneath the list item title.
|
||||
*/
|
||||
content: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
|
||||
/**
|
||||
* Href attribute used in a Link wrapped around the item.
|
||||
*/
|
||||
href: PropTypes.string,
|
||||
/**
|
||||
* Called when the list item is clicked.
|
||||
*/
|
||||
onClick: PropTypes.func,
|
||||
/**
|
||||
* Target attribute used for Link wrapper.
|
||||
*/
|
||||
target: PropTypes.string,
|
||||
/**
|
||||
* Title displayed for the list item.
|
||||
*/
|
||||
title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
|
||||
} ).isRequired,
|
||||
};
|
||||
|
||||
export default ListItem;
|
|
@ -2,12 +2,13 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TYPES from './action-types';
|
||||
import { NAMESPACE } from '../constants';
|
||||
import { NAMESPACE, WC_ADMIN_NAMESPACE } from '../constants';
|
||||
|
||||
export function setItem( itemType, id, item ) {
|
||||
return {
|
||||
|
@ -64,7 +65,6 @@ export function* updateProductStock( product, quantity ) {
|
|||
default:
|
||||
url += `/products/${ id }`;
|
||||
}
|
||||
|
||||
try {
|
||||
yield apiFetch( {
|
||||
path: url,
|
||||
|
@ -79,3 +79,22 @@ export function* updateProductStock( product, quantity ) {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function* createProductFromTemplate( itemFields, query ) {
|
||||
try {
|
||||
const url = addQueryArgs(
|
||||
`${ WC_ADMIN_NAMESPACE }/onboarding/tasks/create_product_from_template`,
|
||||
query || {}
|
||||
);
|
||||
const newItem = yield apiFetch( {
|
||||
path: url,
|
||||
method: 'POST',
|
||||
data: itemFields,
|
||||
} );
|
||||
yield setItem( 'products', newItem.id, newItem );
|
||||
return newItem;
|
||||
} catch ( error ) {
|
||||
yield setError( 'createProductFromTemplate', query, error );
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,14 +15,15 @@ const reducer = (
|
|||
) => {
|
||||
switch ( type ) {
|
||||
case TYPES.SET_ITEM:
|
||||
const itemData = state.data[ itemType ] || {};
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
[ itemType ]: {
|
||||
...state.data[ itemType ],
|
||||
...itemData,
|
||||
[ id ]: {
|
||||
...state.data[ itemType ][ id ],
|
||||
...( itemData[ id ] || {} ),
|
||||
...item,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -80,6 +80,7 @@ Release and roadmap notes are available on the [WooCommerce Developers Blog](htt
|
|||
- Add: new inbox message - Getting started in Ecommerce - watch this webinar. #6086
|
||||
- Add: Remote inbox notifications contains comparison and fix product rule. #6073
|
||||
- Update: store deprecation welcome modal support doc link #6094
|
||||
- Enhancement: Allowing users to create products by selecting a template. #5892
|
||||
|
||||
== Changelog ==
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
|
|||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'import_sample_products' ),
|
||||
'permission_callback' => array( $this, 'import_products_permission_check' ),
|
||||
'permission_callback' => array( $this, 'create_products_permission_check' ),
|
||||
),
|
||||
'schema' => array( $this, 'get_public_item_schema' ),
|
||||
)
|
||||
|
@ -74,6 +74,29 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
|
|||
'schema' => array( $this, 'get_status_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/create_product_from_template',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'create_product_from_template' ),
|
||||
'permission_callback' => array( $this, 'create_products_permission_check' ),
|
||||
'args' => array_merge(
|
||||
$this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
|
||||
array(
|
||||
'template_name' => array(
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'description' => __( 'Product template name.', 'woocommerce-admin' ),
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_public_item_schema' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -82,7 +105,7 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
|
|||
* @param WP_REST_Request $request Full details about the request.
|
||||
* @return WP_Error|boolean
|
||||
*/
|
||||
public function import_products_permission_check( $request ) {
|
||||
public function create_products_permission_check( $request ) {
|
||||
if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
|
||||
return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce-admin' ), array( 'status' => rest_authorization_required_code() ) );
|
||||
}
|
||||
|
@ -119,32 +142,78 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
|
|||
}
|
||||
|
||||
/**
|
||||
* Import sample products from WooCommerce sample CSV.
|
||||
* Import sample products from given CSV path.
|
||||
*
|
||||
* @param string $csv_file CSV file path.
|
||||
* @return WP_Error|WP_REST_Response
|
||||
*/
|
||||
public static function import_sample_products() {
|
||||
public static function import_sample_products_from_csv( $csv_file ) {
|
||||
include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
|
||||
$file = WC_ABSPATH . 'sample-data/sample_products.csv';
|
||||
|
||||
if ( file_exists( $file ) && class_exists( 'WC_Product_CSV_Importer' ) ) {
|
||||
if ( file_exists( $csv_file ) && class_exists( 'WC_Product_CSV_Importer' ) ) {
|
||||
// Override locale so we can return mappings from WooCommerce in English language stores.
|
||||
add_filter( 'locale', '__return_false', 9999 );
|
||||
$importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
|
||||
$args = array(
|
||||
'parse' => true,
|
||||
'mapping' => self::get_header_mappings( $file ),
|
||||
'mapping' => self::get_header_mappings( $csv_file ),
|
||||
);
|
||||
$args = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class );
|
||||
|
||||
$importer = new $importer_class( $file, $args );
|
||||
$importer = new $importer_class( $csv_file, $args );
|
||||
$import = $importer->import();
|
||||
return rest_ensure_response( $import );
|
||||
return $import;
|
||||
} else {
|
||||
return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce-admin' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import sample products from WooCommerce sample CSV.
|
||||
*
|
||||
* @return WP_Error|WP_REST_Response
|
||||
*/
|
||||
public static function import_sample_products() {
|
||||
$sample_csv_file = WC_ABSPATH . 'sample-data/sample_products.csv';
|
||||
|
||||
$import = self::import_sample_products_from_csv( $sample_csv_file );
|
||||
return rest_ensure_response( $import );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a product from a template name passed in through the template_name param.
|
||||
*
|
||||
* @param WP_REST_Request $request Request data.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public static function create_product_from_template( $request ) {
|
||||
$template_name = $request->get_param( 'template_name' );
|
||||
$template_path = __DIR__ . '/Templates/' . $template_name . '_product.csv';
|
||||
$template_path = apply_filters( 'woocommerce_product_template_csv_file_path', $template_path, $template_name );
|
||||
|
||||
$import = self::import_sample_products_from_csv( $template_path );
|
||||
|
||||
if ( is_wp_error( $import ) || 0 === count( $import['imported'] ) ) {
|
||||
return new \WP_Error(
|
||||
'woocommerce_rest_product_creation_error',
|
||||
/* translators: %s is template name */
|
||||
__( 'Sorry, creating the product with template failed.', 'woocommerce-admin' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
$product = wc_get_product( $import['imported'][0] );
|
||||
$product->set_status( 'auto-draft' );
|
||||
$product->save();
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'id' => $product->get_id(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get header mappings from CSV columns.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
|
||||
"simple, downloadable, virtual",Album,0,0,visible,"This is a simple, virtual product.","Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.",,,taxable,,1,,0,0,,,,,1,,,15,Music,,,https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/album-1.jpg,1,1,,,,,,,0,,,,,,,,,1,"Single 1",https://demo.woothemes.com/woocommerce/wp-content/uploads/sites/56/2017/08/single.jpg,"Single 2",https://demo.woothemes.com/woocommerce/wp-content/uploads/sites/56/2017/08/album.jpg
|
|
|
@ -0,0 +1,2 @@
|
|||
Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
|
||||
simple,"Hoodie with Logo",0,0,visible,"This is a simple product.","Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",,,taxable,,1,,0,0,2,10,6,3,1,,,45,"Clothing > Hoodies",,,https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/hoodie-with-logo-2.jpg,,,,,,,,,0,Color,Blue,1,1,,,,,1,,,,
|
|
|
@ -0,0 +1,2 @@
|
|||
Type,Name,Published,"Is featured?","Visibility in catalog","Short description",Description,"Date sale price starts","Date sale price ends","Tax status","Tax class","In stock?",Stock,"Backorders allowed?","Sold individually?","Weight (lbs)","Length (in)","Width (in)","Height (in)","Allow customer reviews?","Purchase note","Sale price","Regular price",Categories,Tags,"Shipping class",Images,"Download limit","Download expiry days",Parent,"Grouped products",Upsells,Cross-sells,"External URL","Button text",Position,"Attribute 1 name","Attribute 1 value(s)","Attribute 1 visible","Attribute 1 global","Attribute 2 name","Attribute 2 value(s)","Attribute 2 visible","Attribute 2 global","Meta: _wpcom_is_markdown","Download 1 name","Download 1 URL","Download 2 name","Download 2 URL"
|
||||
variable,"V-Neck T-Shirt",0,1,visible,"This is a variable product.","Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",,,taxable,,1,,0,0,.5,24,1,2,1,,,,"Clothing > Tshirts",,,"https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vneck-tee-2.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vnech-tee-green-1.jpg, https://woocommercecore.mystagingwebsite.com/wp-content/uploads/2017/12/vnech-tee-blue-1.jpg",,,,,,,,,0,Color,"Blue, Green, Red",1,1,Size,"Large, Medium, Small",1,1,1,,,,
|
|
|
@ -32,12 +32,27 @@ class WC_Tests_API_Onboarding_Tasks extends WC_REST_Unit_Test_Case {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove product attributes that where created in previous tests.
|
||||
*/
|
||||
public function clear_product_attribute_taxonomies() {
|
||||
$taxonomies = get_taxonomies();
|
||||
foreach ( (array) $taxonomies as $taxonomy ) {
|
||||
// pa - product attribute.
|
||||
if ( 'pa_' === substr( $taxonomy, 0, 3 ) ) {
|
||||
unregister_taxonomy( $taxonomy );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that sample product data is imported.
|
||||
*/
|
||||
public function test_import_sample_products() {
|
||||
wp_set_current_user( $this->user );
|
||||
|
||||
$this->clear_product_attribute_taxonomies();
|
||||
|
||||
$request = new WP_REST_Request( 'POST', $this->endpoint . '/import_sample_products' );
|
||||
$response = $this->server->dispatch( $request );
|
||||
$data = $response->get_data();
|
||||
|
@ -45,9 +60,60 @@ class WC_Tests_API_Onboarding_Tasks extends WC_REST_Unit_Test_Case {
|
|||
$this->assertEquals( 200, $response->get_status() );
|
||||
|
||||
$this->assertArrayHasKey( 'failed', $data );
|
||||
$this->assertEquals( 0, count( $data['failed'] ) );
|
||||
$this->assertArrayHasKey( 'imported', $data );
|
||||
$this->assertArrayHasKey( 'skipped', $data );
|
||||
// There might be previous products present.
|
||||
if ( 0 === count( $data['skipped'] ) ) {
|
||||
$this->assertGreaterThan( 10, count( $data['imported'] ) );
|
||||
}
|
||||
$this->assertArrayHasKey( 'updated', $data );
|
||||
$this->assertEquals( 0, count( $data['updated'] ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test creating a product from a template name.
|
||||
*/
|
||||
public function test_create_product_from_template() {
|
||||
wp_set_current_user( $this->user );
|
||||
$this->clear_product_attribute_taxonomies();
|
||||
|
||||
$request = new WP_REST_Request( 'POST', $this->endpoint . '/create_product_from_template' );
|
||||
$request->set_param( 'template_name', 'physical' );
|
||||
$response = $this->server->dispatch( $request );
|
||||
$data = $response->get_data();
|
||||
|
||||
$this->assertEquals( 200, $response->get_status() );
|
||||
|
||||
$this->assertArrayHasKey( 'id', $data );
|
||||
$product = wc_get_product( $data['id'] );
|
||||
$this->assertEquals( 'auto-draft', $product->get_status() );
|
||||
$this->assertEquals( 'simple', $product->get_type() );
|
||||
|
||||
$request = new WP_REST_Request( 'POST', $this->endpoint . '/create_product_from_template' );
|
||||
$request->set_param( 'template_name', 'digital' );
|
||||
$response = $this->server->dispatch( $request );
|
||||
$data = $response->get_data();
|
||||
|
||||
$this->assertEquals( 200, $response->get_status() );
|
||||
|
||||
$this->assertArrayHasKey( 'id', $data );
|
||||
$product = wc_get_product( $data['id'] );
|
||||
$this->assertEquals( 'auto-draft', $product->get_status() );
|
||||
$this->assertEquals( 'simple', $product->get_type() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that we get an error when template_name does not exist.
|
||||
*/
|
||||
public function test_create_product_from_wrong_template_name() {
|
||||
wp_set_current_user( $this->user );
|
||||
|
||||
$request = new WP_REST_Request( 'POST', $this->endpoint . '/create_product_from_template' );
|
||||
$request->set_param( 'template_name', 'random' );
|
||||
$response = $this->server->dispatch( $request );
|
||||
|
||||
$this->assertEquals( 500, $response->get_status() );
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -85,4 +151,6 @@ class WC_Tests_API_Onboarding_Tasks extends WC_REST_Unit_Test_Case {
|
|||
|
||||
$this->assertSame( 'Custom post content', get_the_content( null, null, $data['post_id'] ) );
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue