Featured Product Block: Make it possible to feature a specific product variation (https://github.com/woocommerce/woocommerce-blocks/pull/608)

* Duplicate SearchListControl into ProductSearchListControl component

* Undo control copy; it's not needed

* Variation search + counts showing

* Dropdown styling (also fixed SVG icon markup in SCSS file)

* Style the variation count

* Handle variation display on frontend and backend

* Fixed selection callbacks hooray

* Extend v3 api to return name. Use v3 API for featured product block.

* Switch description based on type

* Fix isSelected check

* Define a11yProps

* Variations rest endpoint

* Remove isTertiary

* REST endpoints with variation handling

* Handle variation data frontend

* Handle variation data in editor

* renamed description schema

* tweak variation display

* Update assets/js/components/product-control/style.scss

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* Flip icon direction

* Use classnames

* fix isSingle warning

* standards

* Only try to load variations if product has them
This commit is contained in:
Mike Jolley 2019-07-09 10:48:31 +01:00 committed by GitHub
parent 3625225892
commit 5362b468dc
11 changed files with 444 additions and 63 deletions

View File

@ -29,7 +29,7 @@ import {
import classnames from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { debounce, isObject } from 'lodash';
import { debounce, isObject, isEmpty } from 'lodash';
import PropTypes from 'prop-types';
/**
@ -193,7 +193,7 @@ class FeaturedProduct extends Component {
className="wc-block-featured-product"
>
{ __(
'Visually highlight a product and encourage prompt action',
'Visually highlight a product or variation and encourage prompt action',
'woo-gutenberg-products-block'
) }
<div className="wc-block-handpicked-products__selection">
@ -305,11 +305,19 @@ class FeaturedProduct extends Component {
__html: product.name,
} }
/>
{ ! isEmpty( product.variation ) && (
<h3
className="wc-block-featured-product__variation"
dangerouslySetInnerHTML={ {
__html: product.variation,
} }
/>
) }
{ showDesc && (
<div
className="wc-block-featured-product__description"
dangerouslySetInnerHTML={ {
__html: product.short_description,
__html: product.description,
} }
/>
) }

View File

@ -24,7 +24,7 @@ registerBlockType( 'woocommerce/featured-product', {
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
description: __(
'Visually highlight a product and encourage prompt action.',
'Visually highlight a product or variation and encourage prompt action.',
'woo-gutenberg-products-block'
),
supports: {

View File

@ -25,6 +25,7 @@
justify-content: flex-start;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
margin-left: 0;
@ -36,6 +37,7 @@
justify-content: flex-end;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
margin-right: 0;
@ -44,6 +46,7 @@
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
color: $white;
@ -60,25 +63,34 @@
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price,
.wc-block-featured-product__link {
width: 100%;
padding: 0 48px 16px 48px;
padding: 16px 48px 0 48px;
z-index: 1;
}
.wc-block-featured-product__title {
.wc-block-featured-product__title,
.wc-block-featured-product__variation {
margin-top: 0;
border: 0;
&:before {
display: none;
}
}
.wc-block-featured-product__variation {
font-style: italic;
padding-top: 0;
}
.wc-block-featured-product__description {
p {
margin: 0;
line-height: 1.5em;
}
}

View File

@ -42,14 +42,14 @@
content: '';
height: $gap-large;
width: $gap-large;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{$core-grey-dark-300}" /></svg>');
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="{$core-grey-dark-300}" /></svg>');
background-repeat: no-repeat;
background-position: center right;
background-size: contain;
}
&.depth-0[aria-expanded="true"]::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{$core-grey-dark-300}" /></svg>');
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="{$core-grey-dark-300}" /></svg>');
}
&[disabled].depth-0::after {

View File

@ -1,54 +1,245 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { __, _n, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { debounce, find } from 'lodash';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { debounce, find, escapeRegExp, isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import { SearchListControl } from '@woocommerce/components';
import {
SearchListControl,
SearchListItem,
} from '@woocommerce/components';
import { Spinner, MenuItem } from '@wordpress/components';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { isLargeCatalog, getProducts } from '../utils';
import {
IconRadioSelected,
IconRadioUnselected,
} from '../icons';
import './style.scss';
function getHighlightedName( name, search ) {
if ( ! search ) {
return name;
}
const re = new RegExp( escapeRegExp( search ), 'ig' );
return name.replace( re, '<strong>$&</strong>' );
}
const getInteractionIcon = ( isSelected = false ) => {
return isSelected ? <IconRadioSelected /> : <IconRadioUnselected />;
};
class ProductControl extends Component {
constructor() {
super( ...arguments );
this.state = {
list: [],
products: [],
product: 0,
variationsList: {},
variationsLoading: false,
loading: true,
};
this.debouncedOnSearch = debounce( this.onSearch.bind( this ), 400 );
this.debouncedGetVariations = debounce( this.getVariations.bind( this ), 200 );
this.renderItem = this.renderItem.bind( this );
this.onProductSelect = this.onProductSelect.bind( this );
}
componentDidMount() {
const { selected } = this.props;
getProducts( { selected } )
.then( ( list ) => {
this.setState( { list, loading: false } );
.then( ( products ) => {
products = products.map( ( product ) => {
const count = product.variations ? product.variations.length : 0;
return {
...product,
parent: 0,
count: count,
};
} );
this.setState( { products, loading: false } );
} )
.catch( () => {
this.setState( { list: [], loading: false } );
this.setState( { products: [], loading: false } );
} );
}
componentDidUpdate( prevProps, prevState ) {
if ( prevState.product !== this.state.product ) {
this.debouncedGetVariations();
}
}
getVariations() {
const { product, variationsList } = this.state;
if ( ! product ) {
this.setState( {
variationsList: {},
variationsLoading: false,
} );
return;
}
const productDetails = this.state.products.find( ( findProduct ) => findProduct.id === product );
if ( ! productDetails.variations || productDetails.variations.length === 0 ) {
return;
}
if ( ! variationsList[ product ] ) {
this.setState( { variationsLoading: true } );
}
apiFetch( {
path: addQueryArgs( `/wc/blocks/products/${ product }/variations`, {
per_page: -1,
} ),
} )
.then( ( variations ) => {
variations = variations.map( ( variation ) => ( { ...variation, parent: product } ) );
this.setState( ( prevState ) => ( {
variationsList: { ...prevState.variationsList, [ product ]: variations },
variationsLoading: false,
} ) );
} )
.catch( () => {
this.setState( { termsLoading: false } );
} );
}
onSearch( search ) {
const { selected } = this.props;
getProducts( { selected, search } )
.then( ( list ) => {
this.setState( { list, loading: false } );
.then( ( products ) => {
this.setState( { products, loading: false } );
} )
.catch( () => {
this.setState( { list: [], loading: false } );
this.setState( { products: [], loading: false } );
} );
}
onProductSelect( item, isSelected ) {
return () => {
this.setState( {
product: isSelected ? 0 : item.id,
} );
};
}
renderItem( args ) {
const { item, search, depth = 0, isSelected, onSelect } = args;
const { product, variationsLoading } = this.state;
const classes = classnames(
'woocommerce-search-product__item',
'woocommerce-search-list__item',
`depth-${ depth }`,
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
'is-variable': item.count > 0,
}
);
const itemArgs = Object.assign( {}, args );
delete itemArgs.isSingle;
const a11yProps = {
role: 'menuitemradio',
};
if ( item.breadcrumbs.length ) {
a11yProps[ 'aria-label' ] = `${ item.breadcrumbs[ 0 ] }: ${ item.name }`;
}
if ( item.count ) {
a11yProps[ 'aria-expanded' ] = item.id === product;
}
// Top level items custom rendering based on SearchListItem.
if ( ! item.breadcrumbs.length ) {
return [
<MenuItem
key={ `product-${ item.id }` }
isSelected={ isSelected }
{ ...itemArgs }
{ ...a11yProps }
className={ classes }
onClick={ () => {
onSelect( item )();
this.onProductSelect( item, isSelected )();
} }
>
<span className="woocommerce-search-list__item-state">
{ getInteractionIcon( isSelected ) }
</span>
<span className="woocommerce-search-list__item-label">
<span
className="woocommerce-search-list__item-name"
dangerouslySetInnerHTML={ {
__html: getHighlightedName( item.name, search ),
} }
/>
</span>
{ item.count ? (
<span
className="woocommerce-search-list__item-variation-count"
>
{ sprintf(
_n(
'%d variation',
'%d variations',
item.count,
'woo-gutenberg-products-block'
),
item.count
) }
</span>
) : null }
</MenuItem>,
product === item.id && item.count > 0 && variationsLoading && (
<div
key="loading"
className={
'woocommerce-search-list__item woocommerce-search-product__item' +
'depth-1 is-loading is-not-active'
}
>
<Spinner />
</div>
),
];
}
if ( ! isEmpty( item.variation ) ) {
item.name = item.variation;
}
return (
<SearchListItem
className={ classes }
{ ...args }
{ ...a11yProps }
/>
);
}
render() {
const { list, loading } = this.state;
const { products, loading, product, variationsList } = this.state;
const { onChange, selected } = this.props;
const currentVariations = variationsList[ product ] || [];
const currentList = [ ...products, ...currentVariations ];
const messages = {
list: __( 'Products', 'woo-gutenberg-products-block' ),
noItems: __(
@ -64,19 +255,21 @@ class ProductControl extends Component {
'woo-gutenberg-products-block'
),
};
const selectedListItems = selected ? [ find( currentList, { id: selected } ) ] : [];
// Note: selected prop still needs to be array for SearchListControl.
return (
<Fragment>
<SearchListControl
className="woocommerce-products"
list={ list }
list={ currentList }
isLoading={ loading }
isSingle
selected={ [ find( list, { id: selected } ) ] }
selected={ selectedListItems }
onChange={ onChange }
onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
messages={ messages }
renderItem={ this.renderItem }
isHierarchical
/>
</Fragment>
);

View File

@ -0,0 +1,43 @@
.woocommerce-search-product__item {
.woocommerce-search-list__item-name {
.description {
display: block;
}
}
&.is-searching,
&.is-skip-level {
.woocommerce-search-list__item-prefix::after {
content: ":";
}
}
&.is-not-active {
@include hover-state {
background: $white;
}
}
&.is-loading {
justify-content: center;
.components-spinner {
margin-bottom: $gap-small;
}
}
&.depth-0.is-variable::after {
margin-left: $gap-smaller;
content: '';
height: $gap-large;
width: $gap-large;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="{$core-grey-dark-300}" /></svg>');
background-repeat: no-repeat;
background-position: center right;
background-size: contain;
}
&.depth-0.is-variable[aria-expanded="true"]::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="{$core-grey-dark-300}" /></svg>');
}
}

View File

@ -438,16 +438,16 @@
},
{
"name": "phpcompatibility/php-compatibility",
"version": "9.1.1",
"version": "9.2.0",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibility.git",
"reference": "2b63c5d284ab8857f7b1d5c240ddb507a6b2293c"
"reference": "3db1bf1e28123fd574a4ae2e9a84072826d51b5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/2b63c5d284ab8857f7b1d5c240ddb507a6b2293c",
"reference": "2b63c5d284ab8857f7b1d5c240ddb507a6b2293c",
"url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/3db1bf1e28123fd574a4ae2e9a84072826d51b5e",
"reference": "3db1bf1e28123fd574a4ae2e9a84072826d51b5e",
"shasum": ""
},
"require": {
@ -461,7 +461,7 @@
"phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0"
},
"suggest": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.4.3 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
"dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
@ -492,7 +492,7 @@
"phpcs",
"standards"
],
"time": "2018-12-30T23:16:27+00:00"
"time": "2019-06-27T19:58:56+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-paragonie",

View File

@ -60,9 +60,16 @@ class FeaturedProduct extends AbstractDynamicBlock {
wp_kses_post( $product->get_title() )
);
if ( $product->is_type( 'variation' ) ) {
$title .= sprintf(
'<h3 class="wc-block-featured-product__variation">%s</h3>',
wc_get_formatted_variation( $product, true, true, false )
);
}
$desc_str = sprintf(
'<div class="wc-block-featured-product__description">%s</div>',
apply_filters( 'woocommerce_short_description', $product->get_short_description() )
apply_filters( 'woocommerce_short_description', $product->get_short_description() ? $product->get_short_description() : wc_trim_string( $product->get_description(), 400 ) )
);
$price_str = sprintf(

View File

@ -44,6 +44,7 @@ class RestApi {
'product-attribute-terms' => __NAMESPACE__ . '\RestApi\Controllers\ProductAttributeTerms',
'product-categories' => __NAMESPACE__ . '\RestApi\Controllers\ProductCategories',
'products' => __NAMESPACE__ . '\RestApi\Controllers\Products',
'variations' => __NAMESPACE__ . '\RestApi\Controllers\Variations',
];
}
}

View File

@ -167,27 +167,23 @@ class Products extends WC_REST_Products_Controller {
/**
* Get product data.
*
* @param WC_Product $product Product instance.
* @param string $context Request context.
* Options: 'view' and 'edit'.
* @param \WC_Product|\WC_Product_Variation $product Product instance.
* @param string $context Request context. Options: 'view' and 'edit'.
* @return array
*/
protected function get_product_data( $product, $context = 'view' ) {
$raw_data = parent::get_product_data( $product, $context );
$data = array();
$data['id'] = $raw_data['id'];
$data['name'] = $raw_data['name'];
$data['permalink'] = $raw_data['permalink'];
$data['sku'] = $raw_data['sku'];
$data['description'] = $raw_data['description'];
$data['short_description'] = $raw_data['short_description'];
$data['price'] = $raw_data['price'];
$data['price_html'] = $raw_data['price_html'];
$data['images'] = $raw_data['images'];
$data['average_rating'] = $raw_data['average_rating'];
return $data;
return array(
'id' => $product->get_id(),
'name' => $product->get_title(),
'variation' => $product->is_type( 'variation' ) ? wc_get_formatted_variation( $product, true, true, false ) : '',
'permalink' => $product->get_permalink(),
'sku' => $product->get_sku(),
'description' => apply_filters( 'woocommerce_short_description', $product->get_short_description() ? $product->get_short_description() : wc_trim_string( $product->get_description(), 400 ) ),
'price' => $product->get_price(),
'price_html' => $product->get_price_html(),
'images' => $this->get_images( $product ),
'average_rating' => $product->get_average_rating(),
);
}
/**
@ -201,7 +197,7 @@ class Products extends WC_REST_Products_Controller {
$attachment_ids = array();
// Add featured image.
if ( has_post_thumbnail( $product->get_id() ) ) {
if ( $product->get_image_id() ) {
$attachment_ids[] = $product->get_image_id();
}
@ -287,58 +283,58 @@ class Products extends WC_REST_Products_Controller {
'title' => 'product_block_product',
'type' => 'object',
'properties' => array(
'id' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'name' => array(
'name' => array(
'description' => __( 'Product name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
'permalink' => array(
'variation' => array(
'description' => __( 'Product variation attributes, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
'permalink' => array(
'description' => __( 'Product URL.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'format' => 'uri',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'description' => array(
'description' => __( 'Product description.', 'woo-gutenberg-products-block' ),
'description' => array(
'description' => __( 'Short description or excerpt from description.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
'short_description' => array(
'description' => __( 'Product short description.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
'sku' => array(
'sku' => array(
'description' => __( 'Unique identifier.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
'price' => array(
'price' => array(
'description' => __( 'Current product price.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'price_html' => array(
'price_html' => array(
'description' => __( 'Price formatted in HTML.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'average_rating' => array(
'average_rating' => array(
'description' => __( 'Reviews average rating.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'images' => array(
'images' => array(
'description' => __( 'List of images.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => array( 'view', 'edit', 'embed' ),

View File

@ -0,0 +1,121 @@
<?php
/**
* REST API Variations controller customized for Products Block.
*
* Handles requests to the /products/product/variations endpoint. These endpoints allow read-only access to editors.
*
* @internal This API is used internally by the block post editor--it is still in flux. It should not be used outside of wc-blocks.
* @package WooCommerce/Blocks
*/
namespace Automattic\WooCommerce\Blocks\RestApi\Controllers;
defined( 'ABSPATH' ) || exit;
use \WC_REST_Product_Variations_Controller;
/**
* REST API variations controller class.
*/
class Variations extends WC_REST_Product_Variations_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/blocks';
/**
* Register the routes for variations.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'args' => array(
'product_id' => array(
'description' => __( 'Unique identifier for the variable product.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
),
),
array(
'methods' => 'GET',
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Check if a given request has access to read items.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! \current_user_can( 'edit_posts' ) ) {
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woo-gutenberg-products-block' ), array( 'status' => \rest_authorization_required_code() ) );
}
return true;
}
/**
* Prepare a single variation output for response.
*
* @param \WC_Data $object Object data.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
public function prepare_object_for_response( $object, $request ) {
$data = array(
'id' => $object->get_id(),
'name' => $object->get_title(),
'variation' => wc_get_formatted_variation( $object, true, true, false ),
);
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $object, $request ) );
return $response;
}
/**
* Get the Product's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'product_block_variation',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'name' => array(
'description' => __( 'Product name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
'variation' => array(
'description' => __( 'Product variation attributes, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}