WooCommerce Gutenberg Products Block - Version 2.4.0

Version Description

  • Feature: A new block named 'All Reviews' was added in order to display a list of reviews from all products and categories of your store. #902
  • Feature: Added Reviews by Product block.
  • Feature: Added Reviews by Category block.
  • Feature: Added a new product search block to insert a product search field on a page.
  • Enhancement: Add error handling for API requests to the featured product block.
  • Enhancement: Allow hidden products in handpicked products block.
  • Fix: Prevented block settings being output on every route. Now they are only needed when the route has blocks requiring them.
  • Dev: Introduced higher order components, global data handlers, and refactored some blocks.
  • Dev: Created new HOCs for retrieving data: withProduct, withComponentId, withCategory.
  • Dev: Export block settings to an external global wc.blockSettings that can be reliably used by extensions by enqueuing their script with the wc-block-settings as the handle. #903
  • Dev: Added new generic base components: <OrderSelect /> and <Label /> so they can be shared between different blocks. #905
Download this release

Release Info

Developer mikejolley
Plugin Icon 128x128 WooCommerce Gutenberg Products Block
Version 2.4.0
Comparing to
See all releases

Code changes from version 2.3.1 to 2.4.0

Files changed (163) hide show
  1. assets/css/abstracts/_mixins.scss +16 -0
  2. assets/css/editor.scss +14 -0
  3. assets/js/base/components/label/index.js +63 -0
  4. assets/js/base/components/label/test/__snapshots__/index.js.snap +62 -0
  5. assets/js/base/components/label/test/index.js +85 -0
  6. assets/js/base/components/load-more-button/index.js +39 -0
  7. assets/js/base/components/load-more-button/style.scss +4 -0
  8. assets/js/base/components/order-select/index.js +65 -0
  9. assets/js/base/components/order-select/style.scss +9 -0
  10. assets/js/base/components/read-more/index.js +163 -0
  11. assets/js/base/components/read-more/test/index.js +30 -0
  12. assets/js/base/components/read-more/utils.js +76 -0
  13. assets/js/base/components/review-list-item/index.js +131 -0
  14. assets/js/base/components/review-list-item/style.scss +197 -0
  15. assets/js/base/components/review-list/index.js +42 -0
  16. assets/js/base/components/review-list/style.scss +4 -0
  17. assets/js/base/components/review-order-select/index.js +39 -0
  18. assets/js/base/components/review-order-select/style.scss +3 -0
  19. assets/js/base/hocs/test/with-reviews.js +129 -0
  20. assets/js/{utils → base/hocs}/with-component-id.js +0 -0
  21. assets/js/base/hocs/with-reviews.js +178 -0
  22. assets/js/blocks/featured-category/block.js +152 -211
  23. assets/js/blocks/featured-category/index.js +2 -1
  24. assets/js/blocks/featured-category/utils.js +57 -0
  25. assets/js/blocks/featured-product/block.js +212 -237
  26. assets/js/blocks/featured-product/editor.scss +3 -0
  27. assets/js/blocks/featured-product/index.js +2 -1
  28. assets/js/blocks/featured-product/utils.js +42 -0
  29. assets/js/blocks/handpicked-products/block.js +3 -2
  30. assets/js/blocks/handpicked-products/index.js +3 -2
  31. assets/js/blocks/product-categories/block.js +3 -2
  32. assets/js/blocks/product-categories/get-categories.js +6 -1
  33. assets/js/blocks/product-categories/style.scss +0 -1
  34. assets/js/blocks/product-category/block.js +25 -32
  35. assets/js/blocks/product-search/block.js +137 -0
  36. assets/js/blocks/product-search/editor.scss +10 -0
  37. assets/js/blocks/product-search/index.js +113 -0
  38. assets/js/blocks/product-search/style.scss +59 -0
  39. assets/js/blocks/product-tag/block.js +2 -2
  40. assets/js/blocks/product-tag/index.js +3 -2
  41. assets/js/blocks/products-by-attribute/index.js +5 -4
  42. assets/js/blocks/reviews/all-reviews/edit.js +71 -0
  43. assets/js/blocks/reviews/all-reviews/index.js +53 -0
  44. assets/js/blocks/reviews/all-reviews/no-reviews-placeholder.js +24 -0
  45. assets/js/blocks/reviews/attributes.js +97 -0
  46. assets/js/blocks/reviews/edit-utils.js +150 -0
  47. assets/js/blocks/reviews/editor-block.js +72 -0
  48. assets/js/blocks/reviews/editor-container-block.js +68 -0
  49. assets/js/blocks/reviews/editor.scss +13 -0
  50. assets/js/blocks/reviews/frontend-block.js +61 -0
  51. assets/js/blocks/reviews/frontend-container-block.js +103 -0
  52. assets/js/blocks/reviews/frontend.js +32 -0
  53. assets/js/blocks/reviews/reviews-by-category/edit.js +172 -0
  54. assets/js/blocks/reviews/reviews-by-category/index.js +59 -0
  55. assets/js/blocks/reviews/reviews-by-category/no-reviews-placeholder.js +24 -0
  56. assets/js/blocks/reviews/reviews-by-product/edit.js +166 -0
  57. assets/js/blocks/reviews/reviews-by-product/index.js +51 -0
  58. assets/js/blocks/reviews/reviews-by-product/no-reviews-placeholder.js +60 -0
  59. assets/js/blocks/reviews/save.js +33 -0
  60. assets/js/blocks/reviews/utils.js +57 -0
  61. assets/js/components/api-error-placeholder/index.js +86 -0
  62. assets/js/components/grid-layout-control/index.js +9 -8
  63. assets/js/components/icons/all-reviews.js +22 -0
  64. assets/js/components/icons/index.js +3 -0
  65. assets/js/components/icons/reviews-by-category.js +19 -0
  66. assets/js/components/icons/reviews-by-product.js +18 -0
  67. assets/js/components/product-attribute-control/index.js +7 -2
  68. assets/js/components/product-category-control/index.js +2 -1
  69. assets/js/components/product-control/index.js +26 -12
  70. assets/js/components/product-preview/index.js +4 -7
  71. assets/js/components/product-preview/test/index.js +8 -3
  72. assets/js/components/product-tag-control/index.js +3 -2
  73. assets/js/components/products-control/index.js +69 -90
  74. assets/js/components/utils/index.js +49 -24
  75. assets/js/hocs/index.js +3 -0
  76. assets/js/hocs/test/with-product.js +103 -0
  77. assets/js/hocs/test/with-searched-products.js +90 -0
  78. assets/js/hocs/with-category.js +76 -0
  79. assets/js/hocs/with-product.js +76 -0
  80. assets/js/hocs/with-searched-products.js +87 -0
  81. assets/js/settings/blocks/endpoints.js +6 -0
  82. assets/js/settings/blocks/index.js +25 -0
  83. assets/js/settings/shared/currency.js +16 -0
  84. assets/js/settings/shared/index.js +2 -0
  85. assets/js/utils/get-query.js +3 -2
  86. assets/js/utils/get-shortcode.js +7 -2
  87. assets/js/utils/products.js +14 -10
  88. assets/js/utils/shared-attributes.js +6 -2
  89. assets/js/utils/test/products.js +84 -0
  90. build/all-reviews.deps.json +1 -0
  91. build/all-reviews.js +1 -0
  92. build/blocks.js +1 -1
  93. build/editor.css +11 -14
  94. build/featured-category.deps.json +1 -1
  95. build/featured-category.js +1 -1
  96. build/featured-product.deps.json +1 -1
  97. build/featured-product.js +1 -1
  98. build/frontend.deps.json +0 -1
  99. build/frontend.js +0 -12
  100. build/handpicked-products.deps.json +1 -1
  101. build/handpicked-products.js +1 -1
  102. build/product-best-sellers.deps.json +1 -1
  103. build/product-best-sellers.js +1 -1
  104. build/product-categories-frontend.deps.json +1 -0
  105. build/product-categories-frontend.js +6 -0
  106. build/product-categories.deps.json +1 -1
  107. build/product-categories.js +1 -1
  108. build/product-category.deps.json +1 -1
  109. build/product-category.js +1 -1
  110. build/product-new.deps.json +1 -1
  111. build/product-new.js +1 -1
  112. build/product-on-sale.deps.json +1 -1
  113. build/product-on-sale.js +1 -1
  114. build/product-search.deps.json +1 -0
  115. build/product-search.js +1 -0
  116. build/product-tag.deps.json +1 -1
  117. build/product-tag.js +1 -1
  118. build/product-top-rated.deps.json +1 -1
  119. build/product-top-rated.js +1 -1
  120. build/products-by-attribute.deps.json +1 -1
  121. build/products-by-attribute.js +1 -1
  122. build/reviews-by-category.deps.json +1 -0
  123. build/reviews-by-category.js +1 -0
  124. build/reviews-by-product.deps.json +1 -0
  125. build/reviews-by-product.js +1 -0
  126. build/reviews-frontend.deps.json +1 -0
  127. build/reviews-frontend.js +6 -0
  128. build/style.css +15 -4
  129. build/vendors.js +6 -12
  130. build/wc-block-settings.deps.json +1 -0
  131. build/wc-block-settings.js +1 -0
  132. build/wc-shared-settings.deps.json +1 -0
  133. build/wc-shared-settings.js +1 -0
  134. readme.txt +14 -1
  135. src/Assets.php +90 -69
  136. src/BlockTypes/AbstractProductGrid.php +7 -9
  137. src/BlockTypes/AllReviews.php +51 -0
  138. src/BlockTypes/HandpickedProducts.php +17 -0
  139. src/BlockTypes/ProductCategories.php +2 -2
  140. src/BlockTypes/ProductSearch.php +23 -0
  141. src/BlockTypes/ReviewsByCategory.php +51 -0
  142. src/BlockTypes/ReviewsByProduct.php +51 -0
  143. src/Library.php +4 -0
  144. src/Package.php +2 -2
  145. src/RestApi.php +1 -0
  146. src/RestApi/Controllers/ProductReviews.php +429 -0
  147. src/RestApi/Controllers/Products.php +9 -2
  148. vendor/autoload.php +1 -1
  149. vendor/autoload_packages.php +2 -2
  150. vendor/composer/autoload_classmap_package.php +150 -114
  151. vendor/composer/autoload_real.php +4 -4
  152. vendor/composer/autoload_static.php +3 -3
  153. vendor/composer/installed.json +8 -6
  154. vendor/composer/installers/src/Composer/Installers/DframeInstaller.php +10 -0
  155. vendor/composer/installers/src/Composer/Installers/DrupalInstaller.php +12 -8
  156. vendor/composer/installers/src/Composer/Installers/Installer.php +4 -0
  157. vendor/composer/installers/src/Composer/Installers/KnownInstaller.php +11 -0
  158. vendor/composer/installers/src/Composer/Installers/MicroweberInstaller.php +54 -46
  159. vendor/composer/installers/src/Composer/Installers/MoodleInstaller.php +1 -0
  160. vendor/composer/installers/src/Composer/Installers/Redaxo5Installer.php +10 -0
  161. vendor/composer/installers/src/Composer/Installers/TaoInstaller.php +12 -0
  162. vendor/composer/installers/src/Composer/Installers/WHMCSInstaller.php +12 -1
  163. woocommerce-gutenberg-products-block.php +1 -1
assets/css/abstracts/_mixins.scss CHANGED
@@ -15,6 +15,18 @@
15
}
16
}
17
18
// Adds animation to placeholder section
19
@mixin placeholder( $lighten-percentage: 30% ) {
20
animation: loading-fade 1.6s ease-in-out infinite;
@@ -24,6 +36,10 @@
24
&::after {
25
content: "\00a0";
26
}
27
}
28
29
// Adds animation to transforms
15
}
16
}
17
18
+ @keyframes loading-fade {
19
+ 0% {
20
+ opacity: 0.7;
21
+ }
22
+ 50% {
23
+ opacity: 1;
24
+ }
25
+ 100% {
26
+ opacity: 0.7;
27
+ }
28
+ }
29
+
30
// Adds animation to placeholder section
31
@mixin placeholder( $lighten-percentage: 30% ) {
32
animation: loading-fade 1.6s ease-in-out infinite;
36
&::after {
37
content: "\00a0";
38
}
39
+
40
+ @media screen and (prefers-reduced-motion: reduce) {
41
+ animation: none;
42
+ }
43
}
44
45
// Adds animation to transforms
assets/css/editor.scss CHANGED
@@ -35,3 +35,17 @@
35
}
36
}
37
}
35
}
36
}
37
}
38
+
39
+ .wc-block-api-error {
40
+ .components-placeholder__fieldset {
41
+ display: block;
42
+ margin: 0;
43
+ padding: 0;
44
+ }
45
+ .wc-block-error__message {
46
+ margin-bottom: 16px;
47
+ }
48
+ .components-spinner {
49
+ float: none;
50
+ }
51
+ }
assets/js/base/components/label/index.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import PropTypes from 'prop-types';
5
+ import { Fragment } from 'react';
6
+ import classNames from 'classnames';
7
+
8
+ /**
9
+ * Component used to render an accessible text given a label and/or a
10
+ * screenReaderLabel. The wrapper element and wrapper props can also be
11
+ * specified via props.
12
+ */
13
+ const Label = ( { label, screenReaderLabel, wrapperElement, wrapperProps } ) => {
14
+ let Wrapper;
15
+
16
+ if ( ! label && screenReaderLabel ) {
17
+ Wrapper = wrapperElement || 'span';
18
+ wrapperProps = {
19
+ ...wrapperProps,
20
+ className: classNames( wrapperProps.className, 'screen-reader-text' ),
21
+ };
22
+
23
+ return (
24
+ <Wrapper { ...wrapperProps }>
25
+ { screenReaderLabel }
26
+ </Wrapper>
27
+ );
28
+ }
29
+
30
+ Wrapper = wrapperElement || Fragment;
31
+
32
+ if ( label && screenReaderLabel && label !== screenReaderLabel ) {
33
+ return (
34
+ <Wrapper { ...wrapperProps }>
35
+ <span aria-hidden>
36
+ { label }
37
+ </span>
38
+ <span className="screen-reader-text">
39
+ { screenReaderLabel }
40
+ </span>
41
+ </Wrapper>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <Wrapper { ...wrapperProps }>
47
+ { label }
48
+ </Wrapper>
49
+ );
50
+ };
51
+
52
+ Label.propTypes = {
53
+ label: PropTypes.string,
54
+ screenReaderLabel: PropTypes.string,
55
+ wrapperElement: PropTypes.elementType,
56
+ wrapperProps: PropTypes.object,
57
+ };
58
+
59
+ Label.defaultProps = {
60
+ wrapperProps: {},
61
+ };
62
+
63
+ export default Label;
assets/js/base/components/label/test/__snapshots__/index.js.snap ADDED
@@ -0,0 +1,62 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`Label with wrapperElement should render both label and screen reader label 1`] = `
4
+ <label
5
+ className="foo-bar"
6
+ data-foo="bar"
7
+ >
8
+ <span
9
+ aria-hidden={true}
10
+ >
11
+ Lorem
12
+ </span>
13
+ <span
14
+ className="screen-reader-text"
15
+ >
16
+ Ipsum
17
+ </span>
18
+ </label>
19
+ `;
20
+
21
+ exports[`Label with wrapperElement should render only the label 1`] = `
22
+ <label
23
+ className="foo-bar"
24
+ data-foo="bar"
25
+ >
26
+ Lorem
27
+ </label>
28
+ `;
29
+
30
+ exports[`Label with wrapperElement should render only the screen reader label 1`] = `
31
+ <label
32
+ className="foo-bar screen-reader-text"
33
+ data-foo="bar"
34
+ >
35
+ Ipsum
36
+ </label>
37
+ `;
38
+
39
+ exports[`Label without wrapperElement should render both label and screen reader label 1`] = `
40
+ Array [
41
+ <span
42
+ aria-hidden={true}
43
+ >
44
+ Lorem
45
+ </span>,
46
+ <span
47
+ className="screen-reader-text"
48
+ >
49
+ Ipsum
50
+ </span>,
51
+ ]
52
+ `;
53
+
54
+ exports[`Label without wrapperElement should render only the label 1`] = `"Lorem"`;
55
+
56
+ exports[`Label without wrapperElement should render only the screen reader label 1`] = `
57
+ <span
58
+ className="screen-reader-text"
59
+ >
60
+ Ipsum
61
+ </span>
62
+ `;
assets/js/base/components/label/test/index.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import TestRenderer from 'react-test-renderer';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import Label from '../';
10
+
11
+ describe( 'Label', () => {
12
+ describe( 'without wrapperElement', () => {
13
+ test( 'should render both label and screen reader label', () => {
14
+ const component = TestRenderer.create(
15
+ <Label label="Lorem" screenReaderLabel="Ipsum" />
16
+ );
17
+
18
+ expect( component.toJSON() ).toMatchSnapshot();
19
+ } );
20
+
21
+ test( 'should render only the label', () => {
22
+ const component = TestRenderer.create(
23
+ <Label label="Lorem" />
24
+ );
25
+
26
+ expect( component.toJSON() ).toMatchSnapshot();
27
+ } );
28
+
29
+ test( 'should render only the screen reader label', () => {
30
+ const component = TestRenderer.create(
31
+ <Label screenReaderLabel="Ipsum" />
32
+ );
33
+
34
+ expect( component.toJSON() ).toMatchSnapshot();
35
+ } );
36
+ } );
37
+
38
+ describe( 'with wrapperElement', () => {
39
+ test( 'should render both label and screen reader label', () => {
40
+ const component = TestRenderer.create(
41
+ <Label
42
+ label="Lorem"
43
+ screenReaderLabel="Ipsum"
44
+ wrapperElement="label"
45
+ wrapperProps={ {
46
+ className: 'foo-bar',
47
+ 'data-foo': 'bar',
48
+ } }
49
+ />
50
+ );
51
+
52
+ expect( component.toJSON() ).toMatchSnapshot();
53
+ } );
54
+
55
+ test( 'should render only the label', () => {
56
+ const component = TestRenderer.create(
57
+ <Label
58
+ label="Lorem"
59
+ wrapperElement="label"
60
+ wrapperProps={ {
61
+ className: 'foo-bar',
62
+ 'data-foo': 'bar',
63
+ } }
64
+ />
65
+ );
66
+
67
+ expect( component.toJSON() ).toMatchSnapshot();
68
+ } );
69
+
70
+ test( 'should render only the screen reader label', () => {
71
+ const component = TestRenderer.create(
72
+ <Label
73
+ screenReaderLabel="Ipsum"
74
+ wrapperElement="label"
75
+ wrapperProps={ {
76
+ className: 'foo-bar',
77
+ 'data-foo': 'bar',
78
+ } }
79
+ />
80
+ );
81
+
82
+ expect( component.toJSON() ).toMatchSnapshot();
83
+ } );
84
+ } );
85
+ } );
assets/js/base/components/load-more-button/index.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { __ } from '@wordpress/i18n';
5
+ import PropTypes from 'prop-types';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import Label from '../label';
11
+ import './style.scss';
12
+
13
+ export const LoadMoreButton = ( { onClick, label, screenReaderLabel } ) => {
14
+ return (
15
+ <div className="wp-block-button wc-block-load-more">
16
+ <button
17
+ className="wp-block-button__link"
18
+ onClick={ onClick }
19
+ >
20
+ <Label
21
+ label={ label }
22
+ screenReaderLabel={ screenReaderLabel }
23
+ />
24
+ </button>
25
+ </div>
26
+ );
27
+ };
28
+
29
+ LoadMoreButton.propTypes = {
30
+ label: PropTypes.string,
31
+ onClick: PropTypes.func,
32
+ screenReaderLabel: PropTypes.string,
33
+ };
34
+
35
+ LoadMoreButton.defaultProps = {
36
+ label: __( 'Load more', 'woo-gutenberg-products-block' ),
37
+ };
38
+
39
+ export default LoadMoreButton;
assets/js/base/components/load-more-button/style.scss ADDED
@@ -0,0 +1,4 @@
1
+ .wc-block-load-more {
2
+ text-align: center;
3
+ width: 100%;
4
+ }
assets/js/base/components/order-select/index.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import PropTypes from 'prop-types';
5
+ import classNames from 'classnames';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import Label from '../label';
11
+ import withComponentId from '../../hocs/with-component-id';
12
+ import './style.scss';
13
+
14
+ /**
15
+ * Component used for 'Order by' selectors, which renders a label
16
+ * and a <select> with the options provided in the props.
17
+ */
18
+ const OrderSelect = ( { className, componentId, defaultValue, label, onChange, options, screenReaderLabel, readOnly, value } ) => {
19
+ const selectId = `wc-block-order-select__select-${ componentId }`;
20
+
21
+ return (
22
+ <p className={ classNames( 'wc-block-order-select', className ) }>
23
+ <Label
24
+ label={ label }
25
+ screenReaderLabel={ screenReaderLabel }
26
+ wrapperElement="label"
27
+ wrapperProps={ {
28
+ className: 'wc-block-order-select__label',
29
+ htmlFor: selectId,
30
+ } }
31
+ />
32
+ <select // eslint-disable-line jsx-a11y/no-onchange
33
+ id={ selectId }
34
+ className="wc-block-order-select__select"
35
+ defaultValue={ defaultValue }
36
+ onChange={ onChange }
37
+ readOnly={ readOnly }
38
+ value={ value }
39
+ >
40
+ { options.map( ( option ) => (
41
+ <option key={ option.key } value={ option.key }>
42
+ { option.label }
43
+ </option>
44
+ ) ) }
45
+ </select>
46
+ </p>
47
+ );
48
+ };
49
+
50
+ OrderSelect.propTypes = {
51
+ defaultValue: PropTypes.string,
52
+ label: PropTypes.string,
53
+ onChange: PropTypes.func,
54
+ options: PropTypes.arrayOf( PropTypes.shape( {
55
+ key: PropTypes.string.isRequired,
56
+ label: PropTypes.string.isRequired,
57
+ } ) ),
58
+ readOnly: PropTypes.bool,
59
+ screenReaderLabel: PropTypes.string,
60
+ value: PropTypes.string,
61
+ // from withComponentId
62
+ componentId: PropTypes.number.isRequired,
63
+ };
64
+
65
+ export default withComponentId( OrderSelect );
assets/js/base/components/order-select/style.scss ADDED
@@ -0,0 +1,9 @@
1
+ .wc-block-order-select {
2
+ margin-bottom: $gap-small;
3
+ }
4
+
5
+ .wc-block-order-select__label {
6
+ margin-right: $gap-small;
7
+ display: inline-block;
8
+ font-weight: normal;
9
+ }
assets/js/base/components/read-more/index.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Show text based content, limited to a number of lines, with a read more link.
3
+ *
4
+ * Based on https://github.com/zoltantothcom/react-clamp-lines.
5
+ */
6
+ import React, { createRef, Component } from 'react';
7
+ import PropTypes from 'prop-types';
8
+ import { __ } from '@wordpress/i18n';
9
+
10
+ /**
11
+ * Internal dependencies
12
+ */
13
+ import { clampLines } from './utils';
14
+
15
+ class ReadMore extends Component {
16
+ constructor( props ) {
17
+ super( ...arguments );
18
+
19
+ this.state = {
20
+ /**
21
+ * This is true when read more has been pressed and the full review is shown.
22
+ */
23
+ isExpanded: false,
24
+ /**
25
+ * True if we are clamping content. False if the review is short. Null during init.
26
+ */
27
+ clampEnabled: null,
28
+ /**
29
+ * Content is passed in via children.
30
+ */
31
+ content: props.children,
32
+ /**
33
+ * Summary content generated from content HTML.
34
+ */
35
+ summary: '.',
36
+ };
37
+
38
+ this.reviewSummary = createRef();
39
+ this.reviewContent = createRef();
40
+ this.getButton = this.getButton.bind( this );
41
+ this.onClick = this.onClick.bind( this );
42
+ }
43
+
44
+ componentDidMount() {
45
+ if ( this.props.children ) {
46
+ const { maxLines, ellipsis } = this.props;
47
+
48
+ const lineHeight = this.reviewSummary.current.clientHeight + 1;
49
+ const reviewHeight = this.reviewContent.current.clientHeight + 1;
50
+ const maxHeight = ( lineHeight * maxLines ) + 1;
51
+ const clampEnabled = reviewHeight > maxHeight;
52
+
53
+ this.setState( {
54
+ clampEnabled,
55
+ } );
56
+
57
+ if ( clampEnabled ) {
58
+ this.setState( {
59
+ summary: clampLines( this.reviewContent.current.innerHTML, this.reviewSummary.current, maxHeight, ellipsis ),
60
+ } );
61
+ }
62
+ }
63
+ }
64
+
65
+ getButton() {
66
+ const { isExpanded } = this.state;
67
+ const { className, lessText, moreText } = this.props;
68
+
69
+ const buttonText = isExpanded ? lessText : moreText;
70
+
71
+ if ( ! buttonText ) {
72
+ return;
73
+ }
74
+
75
+ return (
76
+ <a
77
+ href="#more"
78
+ className={ className + '__read_more' }
79
+ onClick={ this.onClick }
80
+ aria-expanded={ ! isExpanded }
81
+ role="button"
82
+ >
83
+ { buttonText }
84
+ </a>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Handles the click event for the read more/less button.
90
+ *
91
+ * @param {obj} e event
92
+ */
93
+ onClick( e ) {
94
+ e.preventDefault();
95
+
96
+ const { isExpanded } = this.state;
97
+
98
+ this.setState( {
99
+ isExpanded: ! isExpanded,
100
+ } );
101
+ }
102
+
103
+ render() {
104
+ const { className } = this.props;
105
+ const { content, summary, clampEnabled, isExpanded } = this.state;
106
+
107
+ if ( ! content ) {
108
+ return null;
109
+ }
110
+
111
+ if ( false === clampEnabled ) {
112
+ return (
113
+ <div className={ className }>
114
+ <div ref={ this.reviewContent }>
115
+ { content }
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className={ className }>
123
+ { ( ! isExpanded || null === clampEnabled ) && (
124
+ <div
125
+ ref={ this.reviewSummary }
126
+ aria-hidden={ isExpanded }
127
+ dangerouslySetInnerHTML={ {
128
+ __html: summary,
129
+ } }
130
+ />
131
+ ) }
132
+ { ( isExpanded || null === clampEnabled ) && (
133
+ <div
134
+ ref={ this.reviewContent }
135
+ aria-hidden={ ! isExpanded }
136
+ >
137
+ { content }
138
+ </div>
139
+ ) }
140
+ { this.getButton() }
141
+ </div>
142
+ );
143
+ }
144
+ }
145
+
146
+ ReadMore.propTypes = {
147
+ children: PropTypes.node.isRequired,
148
+ maxLines: PropTypes.number,
149
+ ellipsis: PropTypes.string,
150
+ moreText: PropTypes.string,
151
+ lessText: PropTypes.string,
152
+ className: PropTypes.string,
153
+ };
154
+
155
+ ReadMore.defaultProps = {
156
+ maxLines: 3,
157
+ ellipsis: '&hellip;',
158
+ moreText: __( 'Read more', 'woo-gutenberg-products-block' ),
159
+ lessText: __( 'Read less', 'woo-gutenberg-products-block' ),
160
+ className: 'read-more-content',
161
+ };
162
+
163
+ export default ReadMore;
assets/js/base/components/read-more/test/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Internal dependencies
3
+ */
4
+ import { truncateHtml } from '../utils';
5
+ const shortContent =
6
+ '<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>';
7
+
8
+ const longContent =
9
+ '<p>Lorem ipsum dolor sit amet, <strong>consectetur adipiscing elit. Nullam a condimentum diam.</strong> Donec finibus enim eros, et lobortis magna varius quis. Nulla lacinia tellus ac neque aliquet, in porttitor metus interdum. Maecenas vestibulum nisi et auctor vestibulum. Maecenas vehicula, lacus et pellentesque tempor, orci nulla mattis purus, id porttitor augue magna et metus. Aenean hendrerit aliquet massa ac convallis. Mauris vestibulum neque in condimentum porttitor. Donec viverra, orci a accumsan vehicula, dui massa lobortis lorem, et cursus est purus pulvinar elit. Vestibulum vitae tincidunt ex, ut vulputate nisi.</p>' +
10
+ '<p>Morbi tristique iaculis felis, sed porta urna tincidunt vitae. Etiam nisl sem, eleifend non varius quis, placerat a arcu. Donec consectetur nunc at orci fringilla pulvinar. Nam hendrerit tellus in est aliquet varius id in diam. Donec eu ullamcorper ante. Ut ultricies, felis vel sodales aliquet, nibh massa vestibulum ipsum, sed dignissim mi nunc eget lacus. Curabitur mattis placerat magna a aliquam. Nullam diam elit, cursus nec erat ullamcorper, tempor eleifend mauris. Nunc placerat nunc ut enim ornare tempus. Fusce porta molestie ante eget faucibus. Fusce eu lectus sit amet diam auctor lacinia et in diam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris eu lacus lobortis, faucibus est vel, pulvinar odio. Duis feugiat tortor quis dui euismod varius.</p>';
11
+
12
+ describe( 'ReadMore Component', () => {
13
+ describe( 'Test the truncateHtml function', () => {
14
+ it( 'Truncate long HTML content to length of 10', async () => {
15
+ const truncatedContent = truncateHtml( longContent, 10 );
16
+
17
+ expect( truncatedContent ).toEqual( '<p>Lorem ipsum...</p>' );
18
+ } );
19
+ it( 'Truncate long HTML content, but avoid cutting off HTML tags.', async () => {
20
+ const truncatedContent = truncateHtml( longContent, 40 );
21
+
22
+ expect( truncatedContent ).toEqual( '<p>Lorem ipsum dolor sit amet, <strong>consectetur...</strong></p>' );
23
+ } );
24
+ it( 'No need to truncate short HTML content.', async () => {
25
+ const truncatedContent = truncateHtml( shortContent, 100 );
26
+
27
+ expect( truncatedContent ).toEqual( '<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>' );
28
+ } );
29
+ } );
30
+ } );
assets/js/base/components/read-more/utils.js ADDED
@@ -0,0 +1,76 @@
1
+ import trimHtml from 'trim-html';
2
+
3
+ /**
4
+ * Truncate some HTML content to a given length.
5
+ *
6
+ * @param {string} html HTML that will be truncated.
7
+ * @param {int} length Legth to truncate the string to.
8
+ * @param {string} ellipsis Character to append to truncated content.
9
+ */
10
+ export const truncateHtml = ( html, length, ellipsis = '...' ) => {
11
+ const trimmed = trimHtml( html, {
12
+ suffix: ellipsis,
13
+ limit: length,
14
+ } );
15
+
16
+ return trimmed.html;
17
+ };
18
+
19
+ /**
20
+ * Clamp lines calculates the height of a line of text and then limits it to the
21
+ * value of the lines prop. Content is updated once limited.
22
+ *
23
+ * @param {string} originalContent Content to be clamped.
24
+ * @param {object} targetElement Element which will contain the clamped content.
25
+ * @param {integer} maxHeight Max height of the clamped content.
26
+ * @param {string} ellipsis Character to append to clamped content.
27
+ * @return {string} clamped content
28
+ */
29
+ export const clampLines = ( originalContent, targetElement, maxHeight, ellipsis ) => {
30
+ const length = calculateLength( originalContent, targetElement, maxHeight );
31
+
32
+ return truncateHtml( originalContent, length - ellipsis.length, ellipsis );
33
+ };
34
+
35
+ /**
36
+ * Calculate how long the content can be based on the maximum number of lines allowed, and client height.
37
+ *
38
+ * @param {string} originalContent Content to be clamped.
39
+ * @param {object} targetElement Element which will contain the clamped content.
40
+ * @param {integer} maxHeight Max height of the clamped content.
41
+ */
42
+ const calculateLength = ( originalContent, targetElement, maxHeight ) => {
43
+ let markers = {
44
+ start: 0,
45
+ middle: 0,
46
+ end: originalContent.length,
47
+ };
48
+
49
+ while ( markers.start <= markers.end ) {
50
+ markers.middle = Math.floor( ( markers.start + markers.end ) / 2 );
51
+
52
+ // We set the innerHTML directly in the DOM here so we can reliably check the clientHeight later in moveMarkers.
53
+ targetElement.innerHTML = truncateHtml( originalContent, markers.middle );
54
+
55
+ markers = moveMarkers( markers, targetElement.clientHeight, maxHeight );
56
+ }
57
+
58
+ return markers.middle;
59
+ };
60
+
61
+ /**
62
+ * Move string markers. Used by calculateLength.
63
+ *
64
+ * @param {object} markers Markers for clamped content.
65
+ * @param {integer} currentHeight Current height of clamped content.
66
+ * @param {integer} maxHeight Max height of the clamped content.
67
+ */
68
+ const moveMarkers = ( markers, currentHeight, maxHeight ) => {
69
+ if ( currentHeight <= maxHeight ) {
70
+ markers.start = markers.middle + 1;
71
+ } else {
72
+ markers.end = markers.middle - 1;
73
+ }
74
+
75
+ return markers;
76
+ };
assets/js/base/components/review-list-item/index.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { __, sprintf } from '@wordpress/i18n';
5
+ import PropTypes from 'prop-types';
6
+ import classNames from 'classnames';
7
+
8
+ /**
9
+ * Internal dependencies
10
+ */
11
+ import ReadMore from '../read-more';
12
+ import './style.scss';
13
+
14
+ function getReviewImage( review, imageType, isLoading ) {
15
+ if ( isLoading || ! review ) {
16
+ return (
17
+ <div className="wc-block-review-list-item__image" width="48" height="48" />
18
+ );
19
+ }
20
+
21
+ return (
22
+ <div className="wc-block-review-list-item__image">
23
+ { imageType === 'product' ? (
24
+ <img aria-hidden="true" alt="" src={ review.product_picture || '' } className="wc-block-review-list-item__image" width="48" height="48" />
25
+ ) : (
26
+ <img aria-hidden="true" alt="" src={ review.reviewer_avatar_urls[ '48' ] || '' } srcSet={ review.reviewer_avatar_urls[ '96' ] + ' 2x' } className="wc-block-review-list-item__image" width="48" height="48" />
27
+ ) }
28
+ { review.verified && (
29
+ <div className="wc-block-review-list-item__verified" title={ __( 'Verified buyer', 'woo-gutenberg-products-block' ) }>{ __( 'Verified buyer', 'woo-gutenberg-products-block' ) }</div>
30
+ ) }
31
+ </div>
32
+ );
33
+ }
34
+
35
+ function getReviewContent( review ) {
36
+ return (
37
+ <ReadMore
38
+ maxLines={ 10 }
39
+ moreText={ __( 'Read full review', 'woo-gutenberg-products-block' ) }
40
+ lessText={ __( 'Hide full review', 'woo-gutenberg-products-block' ) }
41
+ className="wc-block-review-list-item__text"
42
+ >
43
+ <div
44
+ dangerouslySetInnerHTML={ {
45
+ // `content` is the `review` parameter returned by the `reviews` endpoint.
46
+ // It's filtered with `wp_filter_post_kses()`, which removes dangerous HTML tags,
47
+ // so using it inside `dangerouslySetInnerHTML` is safe.
48
+ __html: review.review || '',
49
+ } }
50
+ />
51
+ </ReadMore>
52
+ );
53
+ }
54
+
55
+ function getReviewProductName( review ) {
56
+ return (
57
+ <div className="wc-block-review-list-item__product">
58
+ <a href={ review.product_permalink }>
59
+ { review.product_name }
60
+ </a>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ function getReviewerName( review ) {
66
+ const { reviewer = '' } = review;
67
+ return (
68
+ <div className="wc-block-review-list-item__author">
69
+ { reviewer }
70
+ </div>
71
+ );
72
+ }
73
+
74
+ function getReviewDate( review ) {
75
+ const { date_created: dateCreated, formatted_date_created: formattedDateCreated } = review;
76
+ return (
77
+ <time className="wc-block-review-list-item__published-date" dateTime={ dateCreated }>
78
+ { formattedDateCreated }
79
+ </time>
80
+ );
81
+ }
82
+
83
+ function getReviewRating( review ) {
84
+ const { rating } = review;
85
+ const starStyle = {
86
+ width: ( rating / 5 * 100 ) + '%', /* stylelint-disable-line */
87
+ };
88
+ return (
89
+ <div className="wc-block-review-list-item__rating">
90
+ <div className="wc-block-review-list-item__rating__stars" role="img">
91
+ <span style={ starStyle }>{ sprintf( __( 'Rated %d out of 5', 'woo-gutenberg-products-block' ), rating ) }</span>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const ReviewListItem = ( { attributes, review = {} } ) => {
98
+ const { imageType, showReviewDate, showReviewerName, showReviewImage, showReviewRating: showReviewRatingAttr, showReviewContent, showProductName } = attributes;
99
+ const { rating } = review;
100
+ const isLoading = ! Object.keys( review ).length > 0;
101
+ const showReviewRating = Number.isFinite( rating ) && showReviewRatingAttr;
102
+
103
+ return (
104
+ <li
105
+ className={ classNames( 'wc-block-review-list-item__item', { 'is-loading': isLoading } ) }
106
+ aria-hidden={ isLoading }
107
+ >
108
+ { ( showProductName || showReviewDate || showReviewerName || showReviewImage || showReviewRating ) && (
109
+ <div className="wc-block-review-list-item__info">
110
+ { showReviewImage && getReviewImage( review, imageType, isLoading ) }
111
+ { ( showProductName || showReviewerName || showReviewRating || showReviewDate ) && (
112
+ <div className="wc-block-review-list-item__meta">
113
+ { showReviewRating && getReviewRating( review ) }
114
+ { showProductName && getReviewProductName( review ) }
115
+ { showReviewerName && getReviewerName( review ) }
116
+ { showReviewDate && getReviewDate( review ) }
117
+ </div>
118
+ ) }
119
+ </div>
120
+ ) }
121
+ { showReviewContent && getReviewContent( review ) }
122
+ </li>
123
+ );
124
+ };
125
+
126
+ ReviewListItem.propTypes = {
127
+ attributes: PropTypes.object.isRequired,
128
+ review: PropTypes.object,
129
+ };
130
+
131
+ export default ReviewListItem;
assets/js/base/components/review-list-item/style.scss ADDED
@@ -0,0 +1,197 @@
1
+ .is-loading {
2
+ .wc-block-review-list-item__text {
3
+ @include placeholder();
4
+ display: block;
5
+ width: 60%;
6
+ }
7
+
8
+ .wc-block-review-list-item__info {
9
+ .wc-block-review-list-item__image {
10
+ @include placeholder();
11
+ }
12
+
13
+ .wc-block-review-list-item__meta {
14
+ .wc-block-review-list-item__author {
15
+ @include placeholder();
16
+ font-size: 1em;
17
+ width: 80px;
18
+ }
19
+
20
+ .wc-block-review-list-item__product {
21
+ display: none;
22
+ }
23
+
24
+ .wc-block-review-list-item__rating {
25
+ .wc-block-review-list-item__rating__stars > span {
26
+ display: none;
27
+ }
28
+ }
29
+ }
30
+
31
+ .wc-block-review-list-item__published-date {
32
+ @include placeholder();
33
+ height: 1em;
34
+ width: 120px;
35
+ }
36
+ }
37
+ }
38
+
39
+ .editor-styles-wrapper .wc-block-review-list-item__item,
40
+ .wc-block-review-list-item__item {
41
+ margin: 0 0 $gap-large * 2;
42
+ list-style: none;
43
+ }
44
+
45
+ .wc-block-review-list-item__info {
46
+ display: grid;
47
+ grid-template-columns: 1fr;
48
+ margin-bottom: $gap-large;
49
+ }
50
+
51
+ .wc-block-review-list-item__meta {
52
+ grid-column: 1;
53
+ grid-row: 1;
54
+ }
55
+
56
+ .has-image {
57
+ .wc-block-review-list-item__info {
58
+ grid-template-columns: #{48px + $gap} 1fr;
59
+ }
60
+ .wc-block-review-list-item__meta {
61
+ grid-column: 2;
62
+ }
63
+ }
64
+
65
+ .wc-block-review-list-item__image {
66
+ height: 48px;
67
+ grid-column: 1;
68
+ grid-row: 1 / 3;
69
+ width: 48px;
70
+ position: relative;
71
+
72
+ img {
73
+ width: 100%;
74
+ height: 100%;
75
+ display: block;
76
+ }
77
+ }
78
+
79
+ .wc-block-review-list-item__verified {
80
+ width: 21px;
81
+ height: 21px;
82
+ text-indent: 21px;
83
+ margin: 0;
84
+ line-height: 21px;
85
+ overflow: hidden;
86
+ position: absolute;
87
+ right: -7px;
88
+ bottom: -7px;
89
+
90
+ &::before {
91
+ width: 21px;
92
+ height: 21px;
93
+ background: transparent url('data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="21" height="21" fill="none"%3E%3Ccircle cx="10.5" cy="10.5" r="10.5" fill="%23fff"/%3E%3Cpath fill="%23008A21" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3Cmask id="a" width="17" height="17" x="2" y="2" maskUnits="userSpaceOnUse"%3E%3Cpath fill="%23fff" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3C/mask%3E%3Cg mask="url(%23a)"%3E%3Cpath fill="%23008A21" d="M.5.5h20v20H.5z"/%3E%3C/g%3E%3C/svg%3E') center center no-repeat; /* stylelint-disable-line */
94
+ display: block;
95
+ content: "";
96
+ }
97
+ }
98
+
99
+ .wc-block-review-list-item__meta {
100
+ display: flex;
101
+ align-items: center;
102
+ flex-flow: row wrap;
103
+
104
+ &::after {
105
+ // Force wrap after star rating.
106
+ order: 3;
107
+ content: "";
108
+ flex-basis: 100%;
109
+ }
110
+ }
111
+
112
+ .wc-block-review-list-item__product {
113
+ display: block;
114
+ font-weight: bold;
115
+ order: 1;
116
+ margin-right: $gap/2;
117
+ }
118
+
119
+ .wc-block-review-list-item__author {
120
+ display: block;
121
+ font-weight: bold;
122
+ order: 1;
123
+ margin-right: $gap/2;
124
+ }
125
+
126
+ .wc-block-review-list-item__product + .wc-block-review-list-item__author {
127
+ font-weight: normal;
128
+ color: #808080;
129
+ order: 4;
130
+ }
131
+
132
+ .wc-block-review-list-item__published-date {
133
+ color: #808080;
134
+ order: 5;
135
+ }
136
+
137
+ .wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
138
+ &::before {
139
+ content: "";
140
+ display: inline-block;
141
+ margin-right: $gap/2;
142
+ border-right: 1px solid #ddd;
143
+ height: 1em;
144
+ vertical-align: middle;
145
+ }
146
+ }
147
+
148
+ .wc-block-review-list-item__author:first-child + .wc-block-review-list-item__published-date,
149
+ .wc-block-review-list-item__rating + .wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
150
+ &::before {
151
+ display: none;
152
+ }
153
+ }
154
+
155
+ .wc-block-review-list-item__rating {
156
+ order: 2;
157
+
158
+ > .wc-block-review-list-item__rating__stars {
159
+ display: inline-block;
160
+ top: 0;
161
+ overflow: hidden;
162
+ position: relative;
163
+ height: 1.618em;
164
+ line-height: 1.618;
165
+ font-size: 1em;
166
+ width: 5.3em;
167
+ font-family: star; /* stylelint-disable-line */
168
+ font-weight: 400;
169
+ vertical-align: top;
170
+ }
171
+
172
+ > .wc-block-review-list-item__rating__stars::before {
173
+ content: "\53\53\53\53\53";
174
+ opacity: 0.25;
175
+ float: left;
176
+ top: 0;
177
+ left: 0;
178
+ position: absolute;
179
+ }
180
+
181
+ > .wc-block-review-list-item__rating__stars span {
182
+ overflow: hidden;
183
+ float: left;
184
+ top: 0;
185
+ left: 0;
186
+ position: absolute;
187
+ padding-top: 1.5em;
188
+ }
189
+
190
+ > .wc-block-review-list-item__rating__stars span::before {
191
+ content: "\53\53\53\53\53";
192
+ top: 0;
193
+ position: absolute;
194
+ left: 0;
195
+ color: #e6a237;
196
+ }
197
+ }
assets/js/base/components/review-list/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import PropTypes from 'prop-types';
5
+ import { ENABLE_REVIEW_RATING, SHOW_AVATARS } from '@woocommerce/block-settings';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import ReviewListItem from '../review-list-item';
11
+ import './style.scss';
12
+
13
+ const ReviewList = ( { attributes, reviews } ) => {
14
+ const showReviewImage = ( SHOW_AVATARS || attributes.imageType === 'product' ) && attributes.showReviewImage;
15
+ const showReviewRating = ENABLE_REVIEW_RATING && attributes.showReviewRating;
16
+ const attrs = {
17
+ ...attributes,
18
+ showReviewImage,
19
+ showReviewRating,
20
+ };
21
+
22
+ return (
23
+ <ul className="wc-block-review-list">
24
+ { reviews.length === 0 ?
25
+ (
26
+ <ReviewListItem attributes={ attrs } />
27
+ ) : (
28
+ reviews.map( ( review, i ) => (
29
+ <ReviewListItem key={ review.id || i } attributes={ attrs } review={ review } />
30
+ ) )
31
+ )
32
+ }
33
+ </ul>
34
+ );
35
+ };
36
+
37
+ ReviewList.propTypes = {
38
+ attributes: PropTypes.object.isRequired,
39
+ reviews: PropTypes.array.isRequired,
40
+ };
41
+
42
+ export default ReviewList;
assets/js/base/components/review-list/style.scss ADDED
@@ -0,0 +1,4 @@
1
+ .wc-block-review-list,
2
+ .editor-styles .wc-block-review-list {
3
+ margin: 0;
4
+ }
assets/js/base/components/review-order-select/index.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { __ } from '@wordpress/i18n';
5
+ import PropTypes from 'prop-types';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import OrderSelect from '../order-select';
11
+ import './style.scss';
12
+
13
+ const ReviewOrderSelect = ( { defaultValue, onChange, readOnly, value } ) => {
14
+ return (
15
+ <OrderSelect
16
+ className="wc-block-review-order-select"
17
+ defaultValue={ defaultValue }
18
+ label={ __( 'Order by', 'woo-gutenberg-products-block' ) }
19
+ onChange={ onChange }
20
+ options={ [
21
+ { key: 'most-recent', label: __( 'Most recent', 'woo-gutenberg-products-block' ) },
22
+ { key: 'highest-rating', label: __( 'Highest rating', 'woo-gutenberg-products-block' ) },
23
+ { key: 'lowest-rating', label: __( 'Lowest rating', 'woo-gutenberg-products-block' ) },
24
+ ] }
25
+ readOnly={ readOnly }
26
+ screenReaderLabel={ __( 'Order reviews by', 'woo-gutenberg-products-block' ) }
27
+ value={ value }
28
+ />
29
+ );
30
+ };
31
+
32
+ ReviewOrderSelect.propTypes = {
33
+ defaultValue: PropTypes.oneOf( [ 'most-recent', 'highest-rating', 'lowest-rating' ] ),
34
+ onChange: PropTypes.func,
35
+ readOnly: PropTypes.bool,
36
+ value: PropTypes.oneOf( [ 'most-recent', 'highest-rating', 'lowest-rating' ] ),
37
+ };
38
+
39
+ export default ReviewOrderSelect;
assets/js/base/components/review-order-select/style.scss ADDED
@@ -0,0 +1,3 @@
1
+ .wc-block-review-order-select {
2
+ text-align: right;
3
+ }
assets/js/base/hocs/test/with-reviews.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import TestRenderer from 'react-test-renderer';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import withReviews from '../with-reviews';
10
+ import * as mockUtils from '../../../blocks/reviews/utils';
11
+
12
+ jest.mock( '../../../blocks/reviews/utils', () => ( {
13
+ getOrderArgs: () => ( {
14
+ order: 'desc',
15
+ orderby: 'date_gmt',
16
+ } ),
17
+ getReviews: jest.fn(),
18
+ } ) );
19
+
20
+ const mockReviews = [
21
+ { reviewer: 'Alice', review: 'Lorem ipsum', rating: 2 },
22
+ { reviewer: 'Bob', review: 'Dolor sit amet', rating: 3 },
23
+ { reviewer: 'Carol', review: 'Consectetur adipiscing elit', rating: 5 },
24
+ ];
25
+ const defaultArgs = {
26
+ offset: 0,
27
+ order: 'desc',
28
+ orderby: 'date_gmt',
29
+ per_page: 2,
30
+ product_id: 1,
31
+ };
32
+ const TestComponent = withReviews( ( props ) => {
33
+ return <div
34
+ error={ props.error }
35
+ getReviews={ props.getReviews }
36
+ appendReviews={ props.appendReviews }
37
+ onChangeArgs={ props.onChangeArgs }
38
+ isLoading={ props.isLoading }
39
+ reviews={ props.reviews }
40
+ totalReviews={ props.totalReviews }
41
+ />;
42
+ } );
43
+ const render = () => {
44
+ return TestRenderer.create(
45
+ <TestComponent
46
+ order="desc"
47
+ orderby="date_gmt"
48
+ productId={ 1 }
49
+ reviewsToDisplay={ 2 }
50
+ />
51
+ );
52
+ };
53
+
54
+ describe( 'withReviews Component', () => {
55
+ let renderer;
56
+ afterEach( () => {
57
+ mockUtils.getReviews.mockReset();
58
+ } );
59
+
60
+ describe( 'lifecycle events', () => {
61
+ beforeEach( () => {
62
+ mockUtils.getReviews.mockImplementationOnce(
63
+ () => Promise.resolve( { reviews: mockReviews.slice( 0, 2 ), totalReviews: mockReviews.length } )
64
+ ).mockImplementationOnce(
65
+ () => Promise.resolve( { reviews: mockReviews.slice( 2, 3 ), totalReviews: mockReviews.length } )
66
+ );
67
+ renderer = render();
68
+ } );
69
+
70
+ it( 'getReviews is called on mount with default args', () => {
71
+ const { getReviews } = mockUtils;
72
+
73
+ expect( getReviews ).toHaveBeenCalledWith( defaultArgs );
74
+ expect( getReviews ).toHaveBeenCalledTimes( 1 );
75
+ } );
76
+
77
+ it( 'getReviews is called on component update', () => {
78
+ const { getReviews } = mockUtils;
79
+ renderer.update(
80
+ <TestComponent
81
+ order="desc"
82
+ orderby="date_gmt"
83
+ productId={ 1 }
84
+ reviewsToDisplay={ 3 }
85
+ />
86
+ );
87
+
88
+ expect( getReviews ).toHaveBeenNthCalledWith( 2, { ...defaultArgs, offset: 2, per_page: 1 } );
89
+ expect( getReviews ).toHaveBeenCalledTimes( 2 );
90
+ } );
91
+ } );
92
+
93
+ describe( 'when the API returns product data', () => {
94
+ beforeEach( () => {
95
+ mockUtils.getReviews.mockImplementation(
96
+ () => Promise.resolve( { reviews: mockReviews.slice( 0, 2 ), totalReviews: mockReviews.length } )
97
+ );
98
+ renderer = render();
99
+ } );
100
+
101
+ it( 'sets reviews based on API response', () => {
102
+ const props = renderer.root.findByType( 'div' ).props;
103
+
104
+ expect( props.error ).toBeNull();
105
+ expect( props.isLoading ).toBe( false );
106
+ expect( props.reviews ).toEqual( mockReviews.slice( 0, 2 ) );
107
+ expect( props.totalReviews ).toEqual( mockReviews.length );
108
+ } );
109
+ } );
110
+
111
+ describe( 'when the API returns an error', () => {
112
+ beforeEach( () => {
113
+ mockUtils.getReviews.mockImplementation(
114
+ () => Promise.reject( {
115
+ json: () => Promise.resolve( { message: 'There was an error.' } ),
116
+ } )
117
+ );
118
+ renderer = render();
119
+ } );
120
+
121
+ it( 'sets the error prop', () => {
122
+ const props = renderer.root.findByType( 'div' ).props;
123
+
124
+ expect( props.error ).toEqual( { apiMessage: 'There was an error.' } );
125
+ expect( props.isLoading ).toBe( false );
126
+ expect( props.reviews ).toEqual( [] );
127
+ } );
128
+ } );
129
+ } );
assets/js/{utils → base/hocs}/with-component-id.js RENAMED
File without changes
assets/js/base/hocs/with-reviews.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { Component } from 'react';
5
+ import PropTypes from 'prop-types';
6
+ import isShallowEqual from '@wordpress/is-shallow-equal';
7
+
8
+ /**
9
+ * Internal dependencies
10
+ */
11
+ import { getReviews } from '../../blocks/reviews/utils';
12
+
13
+ const withReviews = ( OriginalComponent ) => {
14
+ class WrappedComponent extends Component {
15
+ constructor() {
16
+ super( ...arguments );
17
+
18
+ this.state = {
19
+ error: null,
20
+ loading: true,
21
+ reviews: [],
22
+ totalReviews: 0,
23
+ };
24
+
25
+ this.setError = this.setError.bind( this );
26
+ this.delayedAppendReviews = this.props.delayFunction( this.appendReviews );
27
+ }
28
+
29
+ componentDidMount() {
30
+ this.replaceReviews();
31
+ }
32
+
33
+ componentDidUpdate( prevProps ) {
34
+ if ( prevProps.reviewsToDisplay < this.props.reviewsToDisplay ) {
35
+ // Since this attribute might be controlled via something with
36
+ // short intervals between value changes, this allows for optionally
37
+ // delaying review fetches via the provided delay function.
38
+ this.delayedAppendReviews();
39
+ } else if (
40
+ this.shouldReplaceReviews( prevProps, this.props )
41
+ ) {
42
+ this.replaceReviews();
43
+ }
44
+ }
45
+
46
+ shouldReplaceReviews( prevProps, nextProps ) {
47
+ return (
48
+ prevProps.orderby !== nextProps.orderby ||
49
+ prevProps.order !== nextProps.order ||
50
+ prevProps.productId !== nextProps.productId ||
51
+ ! isShallowEqual( prevProps.categoryIds, nextProps.categoryIds )
52
+ );
53
+ }
54
+
55
+ componentWillUnMount() {
56
+ if ( this.delayedAppendReviews.cancel ) {
57
+ this.delayedAppendReviews.cancel();
58
+ }
59
+ }
60
+
61
+ getArgs( reviewsToSkip ) {
62
+ const { categoryIds, order, orderby, productId, reviewsToDisplay } = this.props;
63
+ const args = {
64
+ order,
65
+ orderby,
66
+ per_page: reviewsToDisplay - reviewsToSkip,
67
+ offset: reviewsToSkip,
68
+ };
69
+
70
+ if ( categoryIds && categoryIds.length ) {
71
+ args.category_id = Array.isArray( categoryIds ) ? categoryIds.join( ',' ) : categoryIds;
72
+ }
73
+
74
+ if ( productId ) {
75
+ args.product_id = productId;
76
+ }
77
+
78
+ return args;
79
+ }
80
+
81
+ replaceReviews() {
82
+ const { onReviewsReplaced } = this.props;
83
+
84
+ this.updateListOfReviews().then( onReviewsReplaced );
85
+ }
86
+
87
+ appendReviews() {
88
+ const { onReviewsAppended, reviewsToDisplay } = this.props;
89
+ const { reviews } = this.state;
90
+
91
+ // Given that this function is delayed, props might have been updated since
92
+ // it was called so we need to check again if fetching new reviews is necessary.
93
+ if ( reviewsToDisplay <= reviews.length ) {
94
+ return;
95
+ }
96
+
97
+ this.updateListOfReviews( reviews ).then( onReviewsAppended );
98
+ }
99
+
100
+ updateListOfReviews( oldReviews = [] ) {
101
+ const { reviewsToDisplay } = this.props;
102
+ const { totalReviews } = this.state;
103
+ const reviewsToLoad = Math.min( totalReviews, reviewsToDisplay ) - oldReviews.length;
104
+
105
+ this.setState( {
106
+ loading: true,
107
+ reviews: oldReviews.concat( Array( reviewsToLoad ).fill( {} ) ),
108
+ } );
109
+
110
+ return getReviews( this.getArgs( oldReviews.length ) )
111
+ .then( ( { reviews: newReviews, totalReviews: newTotalReviews } ) => {
112
+ this.setState( {
113
+ reviews: oldReviews.filter( ( review ) => Object.keys( review ).length ).concat( newReviews ),
114
+ totalReviews: newTotalReviews,
115
+ loading: false,
116
+ error: null,
117
+ } );
118
+
119
+ return { newReviews };
120
+ } )
121
+ .catch( this.setError );
122
+ }
123
+
124
+ setError( errorResponse ) {
125
+ errorResponse.json().then( ( apiError ) => {
126
+ const { onReviewsLoadError } = this.props;
127
+ const error = typeof apiError === 'object' && apiError.hasOwnProperty( 'message' ) ? {
128
+ apiMessage: apiError.message,
129
+ } : {
130
+ apiMessage: null,
131
+ };
132
+
133
+ this.setState( { reviews: [], loading: false, error } );
134
+
135
+ onReviewsLoadError();
136
+ } );
137
+ }
138
+
139
+ render() {
140
+ const { reviewsToDisplay } = this.props;
141
+ const { error, loading, reviews, totalReviews } = this.state;
142
+
143
+ return <OriginalComponent
144
+ { ...this.props }
145
+ error={ error }
146
+ isLoading={ loading }
147
+ reviews={ reviews.slice( 0, reviewsToDisplay ) }
148
+ totalReviews={ totalReviews }
149
+ />;
150
+ }
151
+ }
152
+
153
+ WrappedComponent.propTypes = {
154
+ order: PropTypes.oneOf( [ 'asc', 'desc' ] ).isRequired,
155
+ orderby: PropTypes.string.isRequired,
156
+ reviewsToDisplay: PropTypes.number.isRequired,
157
+ categoryIds: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
158
+ delayFunction: PropTypes.func,
159
+ onReviewsAppended: PropTypes.func,
160
+ onReviewsLoadError: PropTypes.func,
161
+ onReviewsReplaced: PropTypes.func,
162
+ productId: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ),
163
+ };
164
+
165
+ WrappedComponent.defaultProps = {
166
+ delayFunction: ( f ) => f,
167
+ onReviewsAppended: () => {},
168
+ onReviewsLoadError: () => {},
169
+ onReviewsReplaced: () => {},
170
+ };
171
+
172
+ const { displayName = OriginalComponent.name || 'Component' } = OriginalComponent;
173
+ WrappedComponent.displayName = `WithReviews( ${ displayName } )`;
174
+
175
+ return WrappedComponent;
176
+ };
177
+
178
+ export default withReviews;
assets/js/blocks/featured-category/block.js CHANGED
@@ -2,7 +2,6 @@
2
* External dependencies
3
*/
4
import { __ } from '@wordpress/i18n';
5
- import apiFetch from '@wordpress/api-fetch';
6
import {
7
AlignmentToolbar,
8
BlockControls,
@@ -27,125 +26,77 @@ import {
27
withSpokenMessages,
28
} from '@wordpress/components';
29
import classnames from 'classnames';
30
- import { Component, Fragment } from '@wordpress/element';
31
import { compose } from '@wordpress/compose';
32
- import { debounce, isObject } from 'lodash';
33
import PropTypes from 'prop-types';
34
- import { IconFolderStar } from '../../components/icons';
35
36
/**
37
* Internal dependencies
38
*/
39
import ProductCategoryControl from '../../components/product-category-control';
40
-
41
- /**
42
- * The min-height for the block content.
43
- */
44
- const MIN_HEIGHT = wc_product_block_data.min_height;
45
-
46
- /**
47
- * Get the src from a category object, unless null (no image).
48
- *
49
- * @param {object|null} category A product category object from the API.
50
- * @return {string}
51
- */
52
- function getCategoryImageSrc( category ) {
53
- if ( isObject( category.image ) ) {
54
- return category.image.src;
55
- }
56
- return '';
57
- }
58
-
59
- /**
60
- * Get the attachment ID from a category object, unless null (no image).
61
- *
62
- * @param {object|null} category A product category object from the API.
63
- * @return {int}
64
- */
65
- function getCategoryImageID( category ) {
66
- if ( isObject( category.image ) ) {
67
- return category.image.id;
68
- }
69
- return 0;
70
- }
71
-
72
- /**
73
- * Generate a style object given either a product category image from the API or URL to an image.
74
- *
75
- * @param {string} url An image URL.
76
- * @return {object} A style object with a backgroundImage set (if a valid image is provided).
77
- */
78
- function backgroundImageStyles( url ) {
79
- if ( url ) {
80
- return { backgroundImage: `url(${ url })` };
81
- }
82
- return {};
83
- }
84
-
85
- /**
86
- * Convert the selected ratio to the correct background class.
87
- *
88
- * @param {number} ratio Selected opacity from 0 to 100.
89
- * @return {string} The class name, if applicable (not used for ratio 0 or 50).
90
- */
91
- function dimRatioToClass( ratio ) {
92
- return ratio === 0 || ratio === 50 ?
93
- null :
94
- `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
95
- }
96
97
/**
98
* Component to handle edit mode of "Featured Category".
99
*/
100
- class FeaturedCategory extends Component {
101
- constructor() {
102
- super( ...arguments );
103
- this.state = {
104
- category: false,
105
- loaded: false,
106
- };
107
108
- this.debouncedGetCategory = debounce( this.getCategory.bind( this ), 200 );
109
- }
110
-
111
- componentDidMount() {
112
- this.getCategory();
113
- }
114
115
- componentDidUpdate( prevProps ) {
116
- if ( prevProps.attributes.categoryId !== this.props.attributes.categoryId ) {
117
- this.debouncedGetCategory();
118
- }
119
- }
120
-
121
- getCategory() {
122
- const { categoryId } = this.props.attributes;
123
- if ( ! categoryId ) {
124
- // We've removed the selected product, or no product is selected yet.
125
- this.setState( { category: false, loaded: true } );
126
- return;
127
- }
128
- apiFetch( {
129
- path: `/wc/blocks/products/categories/${ categoryId }`,
130
- } )
131
- .then( ( category ) => {
132
- this.setState( { category, loaded: true } );
133
- } )
134
- .catch( () => {
135
- this.setState( { category: false, loaded: true } );
136
- } );
137
- }
138
-
139
- getInspectorControls() {
140
- const {
141
- attributes,
142
- setAttributes,
143
- overlayColor,
144
- setOverlayColor,
145
- } = this.props;
146
147
const url =
148
- attributes.mediaSrc || getCategoryImageSrc( this.state.category );
149
const { focalPoint = { x: 0.5, y: 0.5 } } = attributes;
150
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
151
// so we need to check if it exists before using it.
@@ -193,10 +144,9 @@ class FeaturedCategory extends Component {
193
</PanelColorSettings>
194
</InspectorControls>
195
);
196
- }
197
198
- renderEditMode() {
199
- const { attributes, debouncedSpeak, setAttributes } = this.props;
200
const onDone = () => {
201
setAttributes( { editMode: false } );
202
debouncedSpeak(
@@ -232,36 +182,32 @@ class FeaturedCategory extends Component {
232
</div>
233
</Placeholder>
234
);
235
- }
236
237
- render() {
238
- const { attributes, isSelected, overlayColor, setAttributes } = this.props;
239
const {
240
className,
241
contentAlign,
242
dimRatio,
243
- editMode,
244
focalPoint,
245
height,
246
showDesc,
247
} = attributes;
248
- const { loaded, category } = this.state;
249
const classes = classnames(
250
'wc-block-featured-category',
251
{
252
'is-selected': isSelected,
253
- 'is-loading': ! category && ! loaded,
254
- 'is-not-found': ! category && loaded,
255
'has-background-dim': dimRatio !== 0,
256
},
257
dimRatioToClass( dimRatio ),
258
contentAlign !== 'center' && `has-${ contentAlign }-content`,
259
className,
260
);
261
- const mediaId = attributes.mediaId || getCategoryImageID( category );
262
- const mediaSrc = attributes.mediaSrc || getCategoryImageSrc( this.state.category );
263
const style = !! category ?
264
- backgroundImageStyles( mediaSrc ) :
265
{};
266
if ( overlayColor.color ) {
267
style.backgroundColor = overlayColor.color;
@@ -276,103 +222,88 @@ class FeaturedCategory extends Component {
276
};
277
278
return (
279
- <Fragment>
280
- <BlockControls>
281
- <AlignmentToolbar
282
- value={ contentAlign }
283
- onChange={ ( nextAlign ) => {
284
- setAttributes( { contentAlign: nextAlign } );
285
} }
286
/>
287
- <MediaUploadCheck>
288
- <Toolbar>
289
- <MediaUpload
290
- onSelect={ ( media ) => {
291
- setAttributes( { mediaId: media.id, mediaSrc: media.url } );
292
- } }
293
- allowedTypes={ [ 'image' ] }
294
- value={ mediaId }
295
- render={ ( { open } ) => (
296
- <IconButton
297
- className="components-toolbar__control"
298
- label={ __( 'Edit media' ) }
299
- icon="format-image"
300
- onClick={ open }
301
- disabled={ ! this.state.category }
302
- />
303
- ) }
304
- />
305
- </Toolbar>
306
- </MediaUploadCheck>
307
- </BlockControls>
308
- { ! attributes.editMode && this.getInspectorControls() }
309
- { editMode ? (
310
- this.renderEditMode()
311
- ) : (
312
- <Fragment>
313
- { !! category ? (
314
- <ResizableBox
315
- className={ classes }
316
- size={ { height } }
317
- minHeight={ MIN_HEIGHT }
318
- enable={ { bottom: true } }
319
- onResizeStop={ onResizeStop }
320
- style={ style }
321
- >
322
- <div className="wc-block-featured-category__wrapper">
323
- <h2
324
- className="wc-block-featured-category__title"
325
- dangerouslySetInnerHTML={ {
326
- __html: category.name,
327
- } }
328
- />
329
- { showDesc && (
330
- <div
331
- className="wc-block-featured-category__description"
332
- dangerouslySetInnerHTML={ {
333
- __html: category.description,
334
- } }
335
- />
336
- ) }
337
- <div className="wc-block-featured-category__link">
338
- <InnerBlocks
339
- template={ [
340
- [
341
- 'core/button',
342
- {
343
- text: __(
344
- 'Shop now',
345
- 'woo-gutenberg-products-block'
346
- ),
347
- url: category.permalink,
348
- align: 'center',
349
- },
350
- ],
351
- ] }
352
- templateLock="all"
353
- />
354
- </div>
355
- </div>
356
- </ResizableBox>
357
- ) : (
358
- <Placeholder
359
- className="wc-block-featured-category"
360
- icon={ <IconFolderStar /> }
361
- label={ __( 'Featured Category', 'woo-gutenberg-products-block' ) }
362
- >
363
- { ! loaded ? (
364
- <Spinner />
365
- ) : (
366
- __( 'No product category is selected.', 'woo-gutenberg-products-block' )
367
- ) }
368
- </Placeholder>
369
- ) }
370
- </Fragment>
371
- ) }
372
- </Fragment>
373
);
374
}
375
- }
376
377
FeaturedCategory.propTypes = {
378
/**
@@ -391,6 +322,15 @@ FeaturedCategory.propTypes = {
391
* A callback to update attributes.
392
*/
393
setAttributes: PropTypes.func.isRequired,
394
// from withColors
395
overlayColor: PropTypes.object,
396
setOverlayColor: PropTypes.func.isRequired,
@@ -399,6 +339,7 @@ FeaturedCategory.propTypes = {
399
};
400
401
export default compose( [
402
withColors( { overlayColor: 'background-color' } ),
403
withSpokenMessages,
404
] )( FeaturedCategory );
2
* External dependencies
3
*/
4
import { __ } from '@wordpress/i18n';
5
import {
6
AlignmentToolbar,
7
BlockControls,
26
withSpokenMessages,
27
} from '@wordpress/components';
28
import classnames from 'classnames';
29
+ import { Fragment } from '@wordpress/element';
30
import { compose } from '@wordpress/compose';
31
import PropTypes from 'prop-types';
32
+ import { MIN_HEIGHT } from '@woocommerce/block-settings';
33
34
/**
35
* Internal dependencies
36
*/
37
+ import { IconFolderStar } from '../../components/icons';
38
import ProductCategoryControl from '../../components/product-category-control';
39
+ import ApiErrorPlaceholder from '../../components/api-error-placeholder';
40
+ import {
41
+ dimRatioToClass,
42
+ getBackgroundImageStyles,
43
+ getCategoryImageId,
44
+ getCategoryImageSrc,
45
+ } from './utils';
46
+ import { withCategory } from '../../hocs';
47
48
/**
49
* Component to handle edit mode of "Featured Category".
50
*/
51
+ const FeaturedCategory = ( { attributes, isSelected, setAttributes, error, getCategory, isLoading, category, overlayColor, setOverlayColor, debouncedSpeak } ) => {
52
+ const renderApiError = () => (
53
+ <ApiErrorPlaceholder
54
+ className="wc-block-featured-category-error"
55
+ error={ error }
56
+ isLoading={ isLoading }
57
+ onRetry={ getCategory }
58
+ />
59
+ );
60
61
+ const getBlockControls = () => {
62
+ const { contentAlign } = attributes;
63
+ const mediaId = attributes.mediaId || getCategoryImageId( category );
64
65
+ return (
66
+ <BlockControls>
67
+ <AlignmentToolbar
68
+ value={ contentAlign }
69
+ onChange={ ( nextAlign ) => {
70
+ setAttributes( { contentAlign: nextAlign } );
71
+ } }
72
+ />
73
+ <MediaUploadCheck>
74
+ <Toolbar>
75
+ <MediaUpload
76
+ onSelect={ ( media ) => {
77
+ setAttributes( { mediaId: media.id, mediaSrc: media.url } );
78
+ } }
79
+ allowedTypes={ [ 'image' ] }
80
+ value={ mediaId }
81
+ render={ ( { open } ) => (
82
+ <IconButton
83
+ className="components-toolbar__control"
84
+ label={ __( 'Edit media' ) }
85
+ icon="format-image"
86
+ onClick={ open }
87
+ disabled={ ! category }
88
+ />
89
+ ) }
90
+ />
91
+ </Toolbar>
92
+ </MediaUploadCheck>
93
+ </BlockControls>
94
+ );
95
+ };
96
97
+ const getInspectorControls = () => {
98
const url =
99
+ attributes.mediaSrc || getCategoryImageSrc( category );
100
const { focalPoint = { x: 0.5, y: 0.5 } } = attributes;
101
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
102
// so we need to check if it exists before using it.
144
</PanelColorSettings>
145
</InspectorControls>
146
);
147
+ };
148
149
+ const renderEditMode = () => {
150
const onDone = () => {
151
setAttributes( { editMode: false } );
152
debouncedSpeak(
182
</div>
183
</Placeholder>
184
);
185
+ };
186
187
+ const renderCategory = () => {
188
const {
189
className,
190
contentAlign,
191
dimRatio,
192
focalPoint,
193
height,
194
showDesc,
195
} = attributes;
196
const classes = classnames(
197
'wc-block-featured-category',
198
{
199
'is-selected': isSelected,
200
+ 'is-loading': ! category && isLoading,
201
+ 'is-not-found': ! category && ! isLoading,
202
'has-background-dim': dimRatio !== 0,
203
},
204
dimRatioToClass( dimRatio ),
205
contentAlign !== 'center' && `has-${ contentAlign }-content`,
206
className,
207
);
208
+ const mediaSrc = attributes.mediaSrc || getCategoryImageSrc( category );
209
const style = !! category ?
210
+ getBackgroundImageStyles( mediaSrc ) :
211
{};
212
if ( overlayColor.color ) {
213
style.backgroundColor = overlayColor.color;
222
};
223
224
return (
225
+ <ResizableBox
226
+ className={ classes }
227
+ size={ { height } }
228
+ minHeight={ MIN_HEIGHT }
229
+ enable={ { bottom: true } }
230
+ onResizeStop={ onResizeStop }
231
+ style={ style }
232
+ >
233
+ <div className="wc-block-featured-category__wrapper">
234
+ <h2
235
+ className="wc-block-featured-category__title"
236
+ dangerouslySetInnerHTML={ {
237
+ __html: category.name,
238
} }
239
/>
240
+ { showDesc && (
241
+ <div
242
+ className="wc-block-featured-category__description"
243
+ dangerouslySetInnerHTML={ {
244
+ __html: category.description,
245
+ } }
246
+ />
247
+ ) }
248
+ <div className="wc-block-featured-category__link">
249
+ <InnerBlocks
250
+ template={ [
251
+ [
252
+ 'core/button',
253
+ {
254
+ text: __(
255
+ 'Shop now',
256
+ 'woo-gutenberg-products-block'
257
+ ),
258
+ url: category.permalink,
259
+ align: 'center',
260
+ },
261
+ ],
262
+ ] }
263
+ templateLock="all"
264
+ />
265
+ </div>
266
+ </div>
267
+ </ResizableBox>
268
);
269
+ };
270
+
271
+ const renderNoCategory = () => (
272
+ <Placeholder
273
+ className="wc-block-featured-category"
274
+ icon={ <IconFolderStar /> }
275
+ label={ __( 'Featured Category', 'woo-gutenberg-products-block' ) }
276
+ >
277
+ { isLoading ? (
278
+ <Spinner />
279 </