From 1ed14cdc01f3ed6244fba2e1e58a98a9388ceb23 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 15 Aug 2019 10:36:24 +0100 Subject: [PATCH] Introduce withCategory HOC for featured category block (https://github.com/woocommerce/woocommerce-blocks/pull/846) * Introduce withCategory hoc * Refactor featured category to use new hoc * Update docblocks --- .../js/blocks/featured-category/block.js | 360 ++++++++---------- .../js/blocks/featured-category/utils.js | 57 +++ .../assets/js/components/utils/index.js | 13 +- .../woocommerce-blocks/assets/js/constants.js | 1 + .../assets/js/hocs/index.js | 1 + .../assets/js/hocs/with-category.js | 76 ++++ 6 files changed, 297 insertions(+), 211 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/featured-category/utils.js create mode 100644 plugins/woocommerce-blocks/assets/js/hocs/with-category.js diff --git a/plugins/woocommerce-blocks/assets/js/blocks/featured-category/block.js b/plugins/woocommerce-blocks/assets/js/blocks/featured-category/block.js index e7eaafca767..84bc2b5e162 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/featured-category/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/featured-category/block.js @@ -2,7 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import apiFetch from '@wordpress/api-fetch'; import { AlignmentToolbar, BlockControls, @@ -27,130 +26,81 @@ import { withSpokenMessages, } from '@wordpress/components'; import classnames from 'classnames'; -import { Component, Fragment } from '@wordpress/element'; +import { Fragment } from '@wordpress/element'; import { compose } from '@wordpress/compose'; -import { debounce, isObject } from 'lodash'; import PropTypes from 'prop-types'; import { IconFolderStar } from '../../components/icons'; /** * Internal dependencies */ -import { ENDPOINTS } from '../../constants'; import ProductCategoryControl from '../../components/product-category-control'; +import ApiErrorPlaceholder from '../../components/api-error-placeholder'; +import { + dimRatioToClass, + getBackgroundImageStyles, + getCategoryImageId, + getCategoryImageSrc, +} from './utils'; +import { withCategory } from '../../hocs'; /** * The min-height for the block content. */ const MIN_HEIGHT = wc_product_block_data.min_height; -/** - * Get the src from a category object, unless null (no image). - * - * @param {object|null} category A product category object from the API. - * @return {string} The src of the category image. - */ -function getCategoryImageSrc( category ) { - if ( isObject( category.image ) ) { - return category.image.src; - } - return ''; -} - -/** - * Get the attachment ID from a category object, unless null (no image). - * - * @param {object|null} category A product category object from the API. - * @return {number} The id of the category image. - */ -function getCategoryImageID( category ) { - if ( isObject( category.image ) ) { - return category.image.id; - } - return 0; -} - -/** - * Generate a style object given either a product category image from the API or URL to an image. - * - * @param {string} url An image URL. - * @return {Object} A style object with a backgroundImage set (if a valid image is provided). - */ -function backgroundImageStyles( url ) { - if ( url ) { - return { backgroundImage: `url(${ url })` }; - } - return {}; -} - -/** - * Convert the selected ratio to the correct background class. - * - * @param {number} ratio Selected opacity from 0 to 100. - * @return {string} The class name, if applicable (not used for ratio 0 or 50). - */ -function dimRatioToClass( ratio ) { - return ratio === 0 || ratio === 50 ? - null : - `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`; -} - /** * Component to handle edit mode of "Featured Category". */ -class FeaturedCategory extends Component { - constructor() { - super( ...arguments ); - this.state = { - category: false, - loaded: false, - }; +const FeaturedCategory = ( { attributes, isSelected, setAttributes, error, getCategory, isLoading, category, overlayColor, setOverlayColor, debouncedSpeak } ) => { + const renderApiError = () => ( + + ); - this.debouncedGetCategory = debounce( this.getCategory.bind( this ), 200 ); - } + const getBlockControls = () => { + const { contentAlign } = attributes; + const mediaId = attributes.mediaId || getCategoryImageId( category ); - componentDidMount() { - this.getCategory(); - } - - componentWillUnmount() { - this.debouncedGetCategory.cancel(); - } - - componentDidUpdate( prevProps ) { - if ( prevProps.attributes.categoryId !== this.props.attributes.categoryId ) { - this.debouncedGetCategory(); - } - } - - getCategory() { - const { categoryId } = this.props.attributes; - if ( ! categoryId ) { - // We've removed the selected product, or no product is selected yet. - this.setState( { category: false, loaded: true } ); - return; - } - apiFetch( { - path: `${ ENDPOINTS.products }/categories/${ categoryId }`, - } ) - .then( ( category ) => { - this.setState( { category, loaded: true } ); - } ) - .catch( () => { - this.setState( { category: false, loaded: true } ); - } ); - } - - getInspectorControls() { - const { - attributes, - setAttributes, - overlayColor, - setOverlayColor, - } = this.props; + return ( + + { + setAttributes( { contentAlign: nextAlign } ); + } } + /> + + + { + setAttributes( { mediaId: media.id, mediaSrc: media.url } ); + } } + allowedTypes={ [ 'image' ] } + value={ mediaId } + render={ ( { open } ) => ( + + ) } + /> + + + + ); + }; + const getInspectorControls = () => { const url = - attributes.mediaSrc || getCategoryImageSrc( this.state.category ); + attributes.mediaSrc || getCategoryImageSrc( category ); const { focalPoint = { x: 0.5, y: 0.5 } } = attributes; // FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2), // so we need to check if it exists before using it. @@ -198,10 +148,9 @@ class FeaturedCategory extends Component { ); - } + }; - renderEditMode() { - const { attributes, debouncedSpeak, setAttributes } = this.props; + const renderEditMode = () => { const onDone = () => { setAttributes( { editMode: false } ); debouncedSpeak( @@ -237,36 +186,32 @@ class FeaturedCategory extends Component { ); - } + }; - render() { - const { attributes, isSelected, overlayColor, setAttributes } = this.props; + const renderCategory = () => { const { className, contentAlign, dimRatio, - editMode, focalPoint, height, showDesc, } = attributes; - const { loaded, category } = this.state; const classes = classnames( 'wc-block-featured-category', { 'is-selected': isSelected, - 'is-loading': ! category && ! loaded, - 'is-not-found': ! category && loaded, + 'is-loading': ! category && isLoading, + 'is-not-found': ! category && ! isLoading, 'has-background-dim': dimRatio !== 0, }, dimRatioToClass( dimRatio ), contentAlign !== 'center' && `has-${ contentAlign }-content`, className, ); - const mediaId = attributes.mediaId || getCategoryImageID( category ); - const mediaSrc = attributes.mediaSrc || getCategoryImageSrc( this.state.category ); + const mediaSrc = attributes.mediaSrc || getCategoryImageSrc( category ); const style = !! category ? - backgroundImageStyles( mediaSrc ) : + getBackgroundImageStyles( mediaSrc ) : {}; if ( overlayColor.color ) { style.backgroundColor = overlayColor.color; @@ -281,103 +226,88 @@ class FeaturedCategory extends Component { }; return ( - - - { - setAttributes( { contentAlign: nextAlign } ); + +
+

- - - { - setAttributes( { mediaId: media.id, mediaSrc: media.url } ); - } } - allowedTypes={ [ 'image' ] } - value={ mediaId } - render={ ( { open } ) => ( - - ) } - /> - - - - { ! attributes.editMode && this.getInspectorControls() } - { editMode ? ( - this.renderEditMode() - ) : ( - - { !! category ? ( - -
-

- { showDesc && ( -
- ) } -
- -
-
- - ) : ( - } - label={ __( 'Featured Category', 'woo-gutenberg-products-block' ) } - > - { ! loaded ? ( - - ) : ( - __( 'No product category is selected.', 'woo-gutenberg-products-block' ) - ) } - - ) } - - ) } - + { showDesc && ( +
+ ) } +
+ +
+
+ ); + }; + + const renderNoCategory = () => ( + } + label={ __( 'Featured Category', 'woo-gutenberg-products-block' ) } + > + { isLoading ? ( + + ) : ( + __( 'No product category is selected.', 'woo-gutenberg-products-block' ) + ) } + + ); + + const { editMode } = attributes; + + if ( error ) { + return renderApiError(); } -} + + if ( editMode ) { + return renderEditMode(); + } + + return ( + + { getBlockControls() } + { getInspectorControls() } + { category ? ( + renderCategory() + ) : ( + renderNoCategory() + ) } + + ); +}; FeaturedCategory.propTypes = { /** @@ -396,6 +326,15 @@ FeaturedCategory.propTypes = { * A callback to update attributes. */ setAttributes: PropTypes.func.isRequired, + // from withCategory + error: PropTypes.object, + getCategory: PropTypes.func, + isLoading: PropTypes.bool, + category: PropTypes.shape( { + name: PropTypes.node, + description: PropTypes.node, + permalink: PropTypes.string, + } ), // from withColors overlayColor: PropTypes.object, setOverlayColor: PropTypes.func.isRequired, @@ -404,6 +343,7 @@ FeaturedCategory.propTypes = { }; export default compose( [ + withCategory, withColors( { overlayColor: 'background-color' } ), withSpokenMessages, ] )( FeaturedCategory ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/featured-category/utils.js b/plugins/woocommerce-blocks/assets/js/blocks/featured-category/utils.js new file mode 100644 index 00000000000..967fc9b7151 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/featured-category/utils.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { isObject } from 'lodash'; + +/** + * Get the src from a category object, unless null (no image). + * + * @param {object|null} category A product category object from the API. + * @return {string} The src of the category image. + */ +function getCategoryImageSrc( category ) { + if ( category && isObject( category.image ) ) { + return category.image.src; + } + return ''; +} + +/** + * Get the attachment ID from a category object, unless null (no image). + * + * @param {object|null} category A product category object from the API. + * @return {number} The id of the category image. + */ +function getCategoryImageId( category ) { + if ( category && isObject( category.image ) ) { + return category.image.id; + } + return 0; +} + +/** + * Generate a style object given either a product category image from the API or URL to an image. + * + * @param {string} url An image URL. + * @return {Object} A style object with a backgroundImage set (if a valid image is provided). + */ +function getBackgroundImageStyles( url ) { + if ( url ) { + return { backgroundImage: `url(${ url })` }; + } + return {}; +} + +/** + * Convert the selected ratio to the correct background class. + * + * @param {number} ratio Selected opacity from 0 to 100. + * @return {string} The class name, if applicable (not used for ratio 0 or 50). + */ +function dimRatioToClass( ratio ) { + return ratio === 0 || ratio === 50 ? + null : + `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`; +} + +export { getCategoryImageSrc, getCategoryImageId, getBackgroundImageStyles, dimRatioToClass }; diff --git a/plugins/woocommerce-blocks/assets/js/components/utils/index.js b/plugins/woocommerce-blocks/assets/js/components/utils/index.js index 3d404b8766c..28624eabc5f 100644 --- a/plugins/woocommerce-blocks/assets/js/components/utils/index.js +++ b/plugins/woocommerce-blocks/assets/js/components/utils/index.js @@ -54,7 +54,7 @@ export const getProducts = ( { selected = [], search } ) => { /** * Get a promise that resolves to a product object from the API. * - * @param {Object} productId Id of the product to retrieve. + * @param {number} productId Id of the product to retrieve. */ export const getProduct = ( productId ) => { return apiFetch( { @@ -96,3 +96,14 @@ export const getProductTags = ( { selected = [], search } ) => { return uniqBy( flatten( data ), 'id' ); } ); }; + +/** + * Get a promise that resolves to a category object from the API. + * + * @param {number} categoryId Id of the product to retrieve. + */ +export const getCategory = ( categoryId ) => { + return apiFetch( { + path: `${ ENDPOINTS.categories }/${ categoryId }`, + } ); +}; diff --git a/plugins/woocommerce-blocks/assets/js/constants.js b/plugins/woocommerce-blocks/assets/js/constants.js index 742a1c27074..ab067b44cc7 100644 --- a/plugins/woocommerce-blocks/assets/js/constants.js +++ b/plugins/woocommerce-blocks/assets/js/constants.js @@ -2,4 +2,5 @@ const NAMESPACE = '/wc/blocks'; export const ENDPOINTS = { root: NAMESPACE, products: `${ NAMESPACE }/products`, + categories: `${ NAMESPACE }/products/categories`, }; diff --git a/plugins/woocommerce-blocks/assets/js/hocs/index.js b/plugins/woocommerce-blocks/assets/js/hocs/index.js index 5e0807a75ee..b9ad4cbc1f0 100644 --- a/plugins/woocommerce-blocks/assets/js/hocs/index.js +++ b/plugins/woocommerce-blocks/assets/js/hocs/index.js @@ -1,3 +1,4 @@ export { default as withComponentId } from './with-component-id'; export { default as withProduct } from './with-product'; +export { default as withCategory } from './with-category'; export { default as withSearchedProducts } from './with-searched-products'; diff --git a/plugins/woocommerce-blocks/assets/js/hocs/with-category.js b/plugins/woocommerce-blocks/assets/js/hocs/with-category.js new file mode 100644 index 00000000000..314fb3a4a49 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/hocs/with-category.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { getCategory } from '../components/utils'; + +const withCategory = createHigherOrderComponent( + ( OriginalComponent ) => { + return class WrappedComponent extends Component { + constructor() { + super( ...arguments ); + this.state = { + error: null, + loading: false, + category: null, + }; + this.loadCategory = this.loadCategory.bind( this ); + } + + componentDidMount() { + this.loadCategory(); + } + + componentDidUpdate( prevProps ) { + if ( prevProps.attributes.categoryId !== this.props.attributes.categoryId ) { + this.loadCategory(); + } + } + + loadCategory() { + const { categoryId } = this.props.attributes; + + if ( ! categoryId ) { + this.setState( { category: null, loading: false, error: null } ); + return; + } + + this.setState( { loading: true } ); + + getCategory( categoryId ).then( ( category ) => { + this.setState( { category, loading: false, error: null } ); + } ).catch( ( apiError ) => { + const error = typeof apiError === 'object' && apiError.hasOwnProperty( 'message' ) ? { + apiMessage: apiError.message, + } : { + // If we can't get any message from the API, set it to null and + // let handle the message to display. + apiMessage: null, + }; + + this.setState( { category: null, loading: false, error } ); + } ); + } + + render() { + const { error, loading, category } = this.state; + + return ; + } + }; + }, + 'withCategory' +); + +export default withCategory;