diff --git a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/block.js b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/block.js index 2d0be6011d5..5d9d67dc614 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/block.js @@ -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' ) }
@@ -305,11 +305,19 @@ class FeaturedProduct extends Component { __html: product.name, } } /> + { ! isEmpty( product.variation ) && ( +

+ ) } { showDesc && (
) } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/index.js b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/index.js index 818616f6ad0..07da5413e2f 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/index.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/index.js @@ -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: { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/style.scss index 8c383554c25..8a5aee85d73 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/featured-product/style.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/featured-product/style.scss @@ -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; } } diff --git a/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss index fe2e31e4ad3..200a47374f9 100644 --- a/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss +++ b/plugins/woocommerce-blocks/assets/js/components/product-attribute-control/style.scss @@ -42,14 +42,14 @@ content: ''; height: $gap-large; width: $gap-large; - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); 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,'); + background-image: url('data:image/svg+xml;utf8,'); } &[disabled].depth-0::after { diff --git a/plugins/woocommerce-blocks/assets/js/components/product-control/index.js b/plugins/woocommerce-blocks/assets/js/components/product-control/index.js index 9990f358038..c98087608a4 100644 --- a/plugins/woocommerce-blocks/assets/js/components/product-control/index.js +++ b/plugins/woocommerce-blocks/assets/js/components/product-control/index.js @@ -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, '$&' ); +} + +const getInteractionIcon = ( isSelected = false ) => { + return isSelected ? : ; +}; 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 [ + { + onSelect( item )(); + this.onProductSelect( item, isSelected )(); + } } + > + + { getInteractionIcon( isSelected ) } + + + + + + + { item.count ? ( + + { sprintf( + _n( + '%d variation', + '%d variations', + item.count, + 'woo-gutenberg-products-block' + ), + item.count + ) } + + ) : null } + , + product === item.id && item.count > 0 && variationsLoading && ( +
+ +
+ ), + ]; + } + + if ( ! isEmpty( item.variation ) ) { + item.name = item.variation; + } + + return ( + + ); + } + 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 ( ); diff --git a/plugins/woocommerce-blocks/assets/js/components/product-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/product-control/style.scss new file mode 100644 index 00000000000..eb88c2e8271 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/components/product-control/style.scss @@ -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,'); + 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,'); + } +} diff --git a/plugins/woocommerce-blocks/composer.lock b/plugins/woocommerce-blocks/composer.lock index 8296e543db2..3339347fb0f 100644 --- a/plugins/woocommerce-blocks/composer.lock +++ b/plugins/woocommerce-blocks/composer.lock @@ -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", diff --git a/plugins/woocommerce-blocks/src/BlockTypes/FeaturedProduct.php b/plugins/woocommerce-blocks/src/BlockTypes/FeaturedProduct.php index 5b3de0a7f98..26dd0cc61d4 100644 --- a/plugins/woocommerce-blocks/src/BlockTypes/FeaturedProduct.php +++ b/plugins/woocommerce-blocks/src/BlockTypes/FeaturedProduct.php @@ -60,9 +60,16 @@ class FeaturedProduct extends AbstractDynamicBlock { wp_kses_post( $product->get_title() ) ); + if ( $product->is_type( 'variation' ) ) { + $title .= sprintf( + '', + wc_get_formatted_variation( $product, true, true, false ) + ); + } + $desc_str = sprintf( '', - 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( diff --git a/plugins/woocommerce-blocks/src/RestApi.php b/plugins/woocommerce-blocks/src/RestApi.php index 32174da30e5..ac658ac56a7 100644 --- a/plugins/woocommerce-blocks/src/RestApi.php +++ b/plugins/woocommerce-blocks/src/RestApi.php @@ -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', ]; } } diff --git a/plugins/woocommerce-blocks/src/RestApi/Controllers/Products.php b/plugins/woocommerce-blocks/src/RestApi/Controllers/Products.php index 562746389d7..645a35f342b 100644 --- a/plugins/woocommerce-blocks/src/RestApi/Controllers/Products.php +++ b/plugins/woocommerce-blocks/src/RestApi/Controllers/Products.php @@ -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' ), diff --git a/plugins/woocommerce-blocks/src/RestApi/Controllers/Variations.php b/plugins/woocommerce-blocks/src/RestApi/Controllers/Variations.php new file mode 100644 index 00000000000..1a836d8f6c4 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/Controllers/Variations.php @@ -0,0 +1,121 @@ +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 ); + } +}