This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
/* stylelint-disable block-closing-brace-newline-after */
// Breakpoints
// Forked from https://github.com/Automattic/wp-calypso/blob/46ae24d8800fb85da6acf057a640e60dac988a38/assets/stylesheets/shared/mixins/_breakpoints.scss
// Think very carefully before adding a new breakpoint.
// The list below is based on wp-admin's main breakpoints
// See https://github.com/WordPress/gutenberg/tree/master/packages/viewport#breakpoints
$breakpoints: 480px, 600px, 782px, 960px, 1280px, 1440px;
@mixin breakpoint( $sizes... ) {
@each $size in $sizes {
@if type-of( $size ) == string {
$approved-value: 0;
@each $breakpoint in $breakpoints {
$and-larger: ">" + $breakpoint;
$and-smaller: "<" + $breakpoint;
@if $size == $and-smaller {
$approved-value: 1;
@media (max-width: $breakpoint) {
@content;
}
}
@else {
@if $size == $and-larger {
$approved-value: 2;
@media (min-width: $breakpoint + 1) {
@content;
}
}
@else {
@each $breakpoint-end in $breakpoints {
$range: $breakpoint + "-" + $breakpoint-end;
@if $size == $range {
$approved-value: 3;
@media (min-width: $breakpoint + 1) and (max-width: $breakpoint-end) {
@content;
}
}
}
}
}
}
@if $approved-value == 0 {
$sizes: "";
@each $breakpoint in $breakpoints {
$sizes: $sizes + " " + $breakpoint;
}
@warn "ERROR in breakpoint( #{ $size } ) : You can only use these sizes[ #{$sizes} ] using the following syntax [ <#{ nth( $breakpoints, 1 ) } >#{ nth( $breakpoints, 1 ) } #{ nth( $breakpoints, 1 ) }-#{ nth( $breakpoints, 2 ) } ]";
}
}
@else {
$sizes: "";
@each $breakpoint in $breakpoints {
$sizes: $sizes + " " + $breakpoint;
}
@error "ERROR in breakpoint( #{ $size } ) : Please wrap the breakpoint $size in parenthesis. You can use these sizes[ #{$sizes} ] using the following syntax [ <#{ nth( $breakpoints, 1 ) } >#{ nth( $breakpoints, 1 ) } #{ nth( $breakpoints, 1 ) }-#{ nth( $breakpoints, 2 ) } ]";
}
}
}
/* stylelint-enable */

View File

@@ -0,0 +1,45 @@
// Greys
$core-grey-light-100: #f8f9f9;
$core-grey-light-200: #f3f4f5;
$core-grey-light-300: #edeff0;
$core-grey-light-400: #e8eaeb;
$core-grey-light-500: #e2e4e7;
$core-grey-light-600: #d7dade;
$core-grey-light-700: #ccd0d4;
$core-grey-light-800: #b5bcc2;
$core-grey-light-900: #a2aab2;
$core-grey-dark-100: #86909b;
$core-grey-dark-200: #78848f;
$core-grey-dark-300: #6c7781; // This & below have 4.5+ contrast against white
$core-grey-dark-400: #606a73;
$core-grey-dark-500: #555d66;
$core-grey-dark-600: #40464d;
$core-grey-dark-700: #32373c;
$core-grey-dark-800: #23282d;
$core-grey-dark-900: #191e23;
$gray-text: $core-grey-dark-500;
// WooCommerce Purples
$woocommerce-100: #ffd7ff;
$woocommerce-200: #e2a5d7;
$woocommerce-300: #c88bbd;
$woocommerce-400: #af72a4;
$woocommerce-500: #95588a;
$woocommerce-600: #7c3f71;
$woocommerce-700: #622557;
$woocommerce-800: #490c3e;
$woocommerce-900: #2f0024;
$woocommerce: $woocommerce-500;
$wp-admin-background: #f1f1f1;
$black: #24292d; // same as wp-admin sidebar
$white: #fff;
// Bright colors
$valid-green: #4ab866;
$notice-yellow: #ffb900;
$error-red: #d94f4f;
$box-shadow-blue: #5b9dd9;
$core-orange: #ca4a1f;

View File

@@ -0,0 +1,77 @@
// Rem output with px fallback
@mixin font-size($sizeValue: 16, $lineHeight: false ) {
font-size: $sizeValue + px;
font-size: ($sizeValue / 16) + rem;
@if ($lineHeight) {
line-height: $lineHeight;
}
}
@mixin hover-state {
&:hover,
&:active,
&:focus {
@content;
}
}
@keyframes loading-fade {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
// Adds animation to placeholder section
@mixin placeholder() {
animation: loading-fade 1.2s ease-in-out infinite;
background-color: $core-grey-light-500 !important;
color: transparent;
border: 0;
box-shadow: none;
&::after {
content: "\00a0";
}
@media screen and (prefers-reduced-motion: reduce) {
animation: none;
}
}
// Adds animation to transforms
@mixin animate-transform( $duration: 0.2s ) {
transition: transform ease $duration;
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
// Hide an element from sighted users, but availble to screen reader users.
@mixin visually-hidden() {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
/* Many screen reader and browser combinations announce broken words as they would appear visually. */
overflow-wrap: normal !important;
word-wrap: normal !important;
}
// Unhide a visually hidden element
@mixin visually-shown() {
clip: auto;
clip-path: none;
height: auto;
width: auto;
margin: unset;
overflow: hidden;
}

View File

@@ -0,0 +1,19 @@
$gap-largest: 40px;
$gap-larger: 36px;
$gap-large: 24px;
$gap: 16px;
$gap-small: 12px;
$gap-smaller: 8px;
$gap-smallest: 4px;
// Variables pulled from Gutenberg.
// Editor Widths
$sidebar-width: 280px;
$content-width: 610px; // For the visual width, subtract 30px (2 * $block-padding + 2px borders). This comes to 580px, which is optimized for 70 characters.
// Blocks
$block-padding: 14px; // Space between block footprint and focus boundaries. These are drawn outside the block footprint, and do not affect the size.
$block-spacing: 4px; // Vertical space between blocks.
$block-side-ui-width: 28px; // Width of the movers/drag handle UI.
$block-side-ui-clearance: 2px; // Space between movers/drag handle UI, and block.
$block-container-side-padding: $block-side-ui-width + $block-padding + 2 * $block-side-ui-clearance; // Total space left and right of the block footprint.

View File

@@ -0,0 +1,42 @@
// Import the woocommerce components stylesheet
@import "~@woocommerce/components/build-style/style.css";
// Hack to hide preview overflow.
.editor-block-preview__content {
overflow: hidden;
}
// Align the block icons in edit mode
.components-placeholder__label .gridicon,
.components-placeholder__label .material-icon {
margin-right: 1ch;
fill: currentColor;
}
// Remove the list styling, which is added back by core GB styles.
.editor-styles-wrapper {
.wc-block-grid {
.wc-block-grid__products {
list-style: none;
margin: 0 (-$gap/2) $gap;
.wp-block-button__link {
color: inherit;
}
.wc-block-grid__product {
margin: 0 0 $gap-large 0;
}
}
&.components-placeholder {
padding: 2em 1em;
}
&.is-loading,
&.is-not-found {
display: block;
}
}
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="231" height="252" fill="none">
<rect width="65.374" height="65.374" x=".162" y=".779" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="9.216" y="76.153" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="1.565" y="101.448" fill="#E1E3E6" rx="5"/>
<rect width="65.374" height="65.374" x=".162" y="136.277" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="9.216" y="211.651" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="1.565" y="236.946" fill="#E1E3E6" rx="5"/>
<rect width="65.374" height="65.374" x="82.478" y=".779" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="91.532" y="76.153" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="83.882" y="101.448" fill="#E1E3E6" rx="5"/>
<rect width="65.374" height="65.374" x="82.478" y="136.277" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="91.532" y="211.651" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="83.882" y="236.946" fill="#E1E3E6" rx="5"/>
<rect width="65.374" height="65.374" x="164.788" y=".779" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="173.843" y="76.153" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="166.192" y="101.448" fill="#E1E3E6" rx="5"/>
<rect width="65.374" height="65.374" x="164.788" y="136.277" fill="#E1E3E6" rx="3"/>
<rect width="47.266" height="5.148" x="173.843" y="211.651" fill="#E1E3E6" rx="2.574"/>
<rect width="62.8" height="15" x="166.192" y="236.946" fill="#E1E3E6" rx="5"/>
<rect width="6.177" height="6.177" x="13.283" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="21.498" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="29.713" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="37.927" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="46.238" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="95.599" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="103.814" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="112.029" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="120.243" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="128.554" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="177.909" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="186.124" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="194.339" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="202.553" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="210.864" y="86.301" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="13.283" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="21.498" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="29.713" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="37.927" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="46.238" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="95.599" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="103.814" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="112.029" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="120.243" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="128.554" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="177.909" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="186.124" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="194.339" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="202.553" y="221.798" fill="#E1E3E6" rx="3"/>
<rect width="6.177" height="6.177" x="210.864" y="221.798" fill="#E1E3E6" rx="3"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { Disabled } from '@wordpress/components';
import Gridicon from 'gridicons';
import { ProductButton } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Button', 'woocommerce' ),
description: __(
'Display a call to action button which either adds the product to the cart, or links to the product page.',
'woocommerce'
),
icon: {
src: <Gridicon icon="cart" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return (
<Disabled>
<ProductButton product={ attributes.product } />
</Disabled>
);
},
};
registerBlockType( 'woocommerce/product-button', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,145 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { Fragment } from '@wordpress/element';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import ToggleButtonControl from '@woocommerce/block-components/toggle-button-control';
import { ProductImage } from '@woocommerce/atomic-components/product';
import { previewProducts } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Image', 'woocommerce' ),
description: __(
'Display the main product image',
'woocommerce'
),
icon: {
src: <Gridicon icon="image" />,
foreground: '#96588a',
},
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
productLink: {
type: 'boolean',
default: true,
},
showSaleBadge: {
type: 'boolean',
default: true,
},
saleBadgeAlign: {
type: 'string',
default: 'right',
},
},
edit( props ) {
const { attributes, setAttributes } = props;
const { productLink, showSaleBadge, saleBadgeAlign } = attributes;
return (
<Fragment>
<InspectorControls>
<PanelBody
title={ __(
'Content',
'woocommerce'
) }
>
<ToggleControl
label={ __(
'Link to Product Page',
'woocommerce'
) }
help={ __(
'Links the image to the single product listing.',
'woocommerce'
) }
checked={ productLink }
onChange={ () =>
setAttributes( {
productLink: ! productLink,
} )
}
/>
<ToggleControl
label={ __(
'Show On-Sale Badge',
'woocommerce'
) }
help={ __(
'Overlay a "sale" badge if the product is on-sale.',
'woocommerce'
) }
checked={ showSaleBadge }
onChange={ () =>
setAttributes( {
showSaleBadge: ! showSaleBadge,
} )
}
/>
{ showSaleBadge && (
<ToggleButtonControl
label={ __(
'Sale Badge Alignment',
'woocommerce'
) }
value={ saleBadgeAlign }
options={ [
{
label: __(
'Left',
'woocommerce'
),
value: 'left',
},
{
label: __(
'Center',
'woocommerce'
),
value: 'center',
},
{
label: __(
'Right',
'woocommerce'
),
value: 'right',
},
] }
onChange={ ( value ) =>
setAttributes( { saleBadgeAlign: value } )
}
/>
) }
</PanelBody>
</InspectorControls>
<Disabled>
<ProductImage
product={ attributes.product }
productLink={ productLink }
showSaleBadge={ showSaleBadge }
saleBadgeAlign={ saleBadgeAlign }
/>
</Disabled>
</Fragment>
);
},
};
registerBlockType( 'woocommerce/product-image', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,7 @@
export { default as ProductTitle } from './title';
export { default as ProductPrice } from './price';
export { default as ProductImage } from './image';
export { default as ProductRating } from './rating';
export { default as ProductButton } from './button';
export { default as ProductSummary } from './summary';
export { default as ProductSaleBadge } from './sale-badge';

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductPrice } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Price', 'woocommerce' ),
description: __(
'Display the price of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="money" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductPrice product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-price', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductRating } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Rating', 'woocommerce' ),
description: __(
'Display the average rating of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="star-outline" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductRating product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-rating', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { ProductSaleBadge } from '@woocommerce/atomic-components/product';
import { IconProductOnSale } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'On-Sale Badge', 'woocommerce' ),
description: __(
'Displays an on-sale badge if the product is on-sale.',
'woocommerce'
),
icon: {
src: <IconProductOnSale />,
foreground: '#96588a',
},
supports: {
html: false,
},
edit( props ) {
const { align, product } = props.attributes;
return <ProductSaleBadge product={ product } align={ align } />;
},
};
registerBlockType( 'woocommerce/product-sale-badge', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Gridicon from 'gridicons';
import { previewProducts } from '@woocommerce/resource-previews';
/**
* Holds default config for this collection of blocks.
*/
export default {
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
icon: {
src: <Gridicon icon="grid" />,
foreground: '#96588a',
},
supports: {
html: false,
},
parent: [ 'woocommerce/all-products' ],
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
},
save() {},
};

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { ProductSummary } from '@woocommerce/atomic-components/product';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Summary', 'woocommerce' ),
description: __(
'Display the short description of a product.',
'woocommerce'
),
icon: {
src: <Gridicon icon="aside" />,
foreground: '#96588a',
},
edit( props ) {
const { attributes } = props;
return <ProductSummary product={ attributes.product } />;
},
};
registerBlockType( 'woocommerce/product-summary', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { Fragment } from 'react';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import { ProductTitle } from '@woocommerce/atomic-components/product';
import { previewProducts } from '@woocommerce/resource-previews';
import HeadingToolbar from '@woocommerce/block-components/heading-toolbar';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Title', 'woocommerce' ),
description: __(
'Display the name of a product.',
'woocommerce'
),
icon: {
src: 'heading',
foreground: '#96588a',
},
attributes: {
product: {
type: 'object',
default: previewProducts[ 0 ],
},
headingLevel: {
type: 'number',
default: 2,
},
productLink: {
type: 'boolean',
default: true,
},
},
edit( props ) {
const { attributes, setAttributes } = props;
const { headingLevel, productLink } = attributes;
return (
<Fragment>
<InspectorControls>
<PanelBody
title={ __(
'Content',
'woocommerce'
) }
>
<p>{ __( 'Level', 'woocommerce' ) }</p>
<HeadingToolbar
isCollapsed={ false }
minLevel={ 2 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
<ToggleControl
label={ __(
'Link to Product Page',
'woocommerce'
) }
help={ __(
'Links the image to the single product listing.',
'woocommerce'
) }
checked={ productLink }
onChange={ () =>
setAttributes( {
productLink: ! productLink,
} )
}
/>
</PanelBody>
</InspectorControls>
<Disabled>
<ProductTitle
headingLevel={ headingLevel }
productLink={ productLink }
product={ attributes.product }
/>
</Disabled>
</Fragment>
);
},
};
registerBlockType( 'woocommerce/product-title', {
...sharedConfig,
...blockConfig,
} );

View File

@@ -0,0 +1,199 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { _n, sprintf } from '@wordpress/i18n';
import {
useMemo,
useCallback,
useState,
useEffect,
useRef,
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { find } from 'lodash';
import { useCollection } from '@woocommerce/base-hooks';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
import { decodeEntities } from '@wordpress/html-entities';
/**
* A custom hook for exposing cart related data for a given product id and an
* action for adding a single quantity of the product _to_ the cart.
*
* Currently this is internal only to the ProductButton component until we have
* a clearer idea of the pattern that should emerge for a cart hook.
*
* @param {number} productId The product id for the product connection to the
* cart.
*
* @return {Object} Returns an object with the following properties:
* @type {number} cartQuantity The quantity of the product currently in
* the cart.
* @type {bool} addingToCart Whether the product is currently being
* added to the cart (true).
* @type {bool} cartIsLoading Whether the cart is being loaded.
* @type {Function} addToCart An action dispatcher for adding a single
* quantity of the product to the cart.
* Receives no arguments, it operates on the
* current product.
*/
const useAddToCart = ( productId ) => {
const { results: cartResults, isLoading: cartIsLoading } = useCollection( {
namespace: '/wc/store',
resourceName: 'cart/items',
} );
const currentCartResults = useRef( null );
const { __experimentalPersistItemToCollection } = useDispatch( storeKey );
const cartQuantity = useMemo( () => {
const productItem = find( cartResults, { id: productId } );
return productItem ? productItem.quantity : 0;
}, [ cartResults, productId ] );
const [ addingToCart, setAddingToCart ] = useState( false );
const addToCart = useCallback( () => {
setAddingToCart( true );
// exclude this item from the cartResults for adding to the new
// collection (so it's updated correctly!)
const collection = cartResults.filter( ( cartItem ) => {
return cartItem.id !== productId;
} );
__experimentalPersistItemToCollection(
'/wc/store',
'cart/items',
collection,
{ id: productId, quantity: 1 }
);
}, [ productId, cartResults ] );
useEffect( () => {
if ( currentCartResults.current !== cartResults ) {
if ( addingToCart ) {
setAddingToCart( false );
}
currentCartResults.current = cartResults;
}
}, [ cartResults, addingToCart ] );
return {
cartQuantity,
addingToCart,
cartIsLoading,
addToCart,
};
};
const Event = window.Event || {};
const ProductButton = ( { product, className } ) => {
const {
id,
permalink,
add_to_cart: productCartDetails,
has_options: hasOptions,
is_purchasable: isPurchasable,
is_in_stock: isInStock,
} = product;
const {
cartQuantity,
addingToCart,
cartIsLoading,
addToCart,
} = useAddToCart( id );
const { layoutStyleClassPrefix } = useProductLayoutContext();
const addedToCart = cartQuantity > 0;
const firstMount = useRef( true );
const getButtonText = () => {
if ( Number.isFinite( cartQuantity ) && addedToCart ) {
return sprintf(
// translators: %s number of products in cart.
_n(
'%d in cart',
'%d in cart',
cartQuantity,
'woocommerce'
),
cartQuantity
);
}
return decodeEntities( productCartDetails.text );
};
// This is a hack to trigger cart updates till we migrate to block based card
// that relies on the store, see
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1247
useEffect( () => {
if ( firstMount.current ) {
firstMount.current = false;
return;
}
// Test if we have our Event defined
if ( Object.entries( Event ).length !== 0 ) {
const event = new Event( 'wc_fragment_refresh', {
bubbles: true,
cancelable: true,
} );
document.body.dispatchEvent( event );
} else {
const event = document.createEvent( 'Event' );
event.initEvent( 'wc_fragment_refresh', true, true );
document.body.dispatchEvent( event );
}
}, [ cartQuantity ] );
const wrapperClasses = classnames(
className,
`${ layoutStyleClassPrefix }__product-add-to-cart`,
'wp-block-button'
);
const buttonClasses = classnames(
'wp-block-button__link',
'add_to_cart_button',
{
loading: addingToCart,
added: addedToCart,
}
);
if ( Object.keys( product ).length === 0 || cartIsLoading ) {
return (
<div className={ wrapperClasses }>
<button className={ buttonClasses } disabled={ true } />
</div>
);
}
const allowAddToCart = ! hasOptions && isPurchasable && isInStock;
return (
<div className={ wrapperClasses }>
{ allowAddToCart ? (
<button
onClick={ addToCart }
aria-label={ decodeEntities(
productCartDetails.description
) }
className={ buttonClasses }
disabled={ addingToCart }
>
{ getButtonText() }
</button>
) : (
<a
href={ permalink }
aria-label={ decodeEntities(
productCartDetails.description
) }
className={ buttonClasses }
rel="nofollow"
>
{ getButtonText() }
</a>
) }
</div>
);
};
ProductButton.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductButton;

View File

@@ -0,0 +1,105 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { Fragment, useState } from '@wordpress/element';
import classnames from 'classnames';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/block-settings';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
/**
* Internal dependencies
*/
import { ProductSaleBadge } from '../../../components/product';
const SaleBadge = ( { product, saleBadgeAlign, shouldRender } ) => {
return shouldRender ? (
<ProductSaleBadge product={ product } align={ saleBadgeAlign } />
) : null;
};
const Image = ( { layoutPrefix, loaded, image, onLoad } ) => {
const cssClass = classnames( `${ layoutPrefix }__product-image__image`, {
[ `${ layoutPrefix }__product-image__image_placeholder` ]:
! loaded && ! image,
} );
const { thumbnail, srcset, sizes, alt } = image || {};
return (
<Fragment>
{ image && (
<img
className={ cssClass }
src={ thumbnail }
srcSet={ srcset }
sizes={ sizes }
alt={ alt }
onLoad={ onLoad }
hidden={ ! loaded }
/>
) }
{ ! loaded && (
<img
className={ cssClass }
src={ PLACEHOLDER_IMG_SRC }
alt=""
/>
) }
</Fragment>
);
};
const ProductImage = ( {
className,
product,
productLink = true,
showSaleBadge = true,
saleBadgeAlign = 'right',
} ) => {
const [ imageLoaded, setImageLoaded ] = useState( false );
const { layoutStyleClassPrefix } = useProductLayoutContext();
const image =
product.images && product.images.length ? product.images[ 0 ] : null;
const renderedSalesAndImage = (
<Fragment>
<SaleBadge
product={ product }
saleBadgeAlign={ saleBadgeAlign }
shouldRender={ showSaleBadge }
/>
<Image
layoutPrefix={ layoutStyleClassPrefix }
loaded={ imageLoaded }
image={ image }
onLoad={ () => setImageLoaded( true ) }
/>
</Fragment>
);
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-image`
) }
>
{ productLink ? (
<a href={ product.permalink } rel="nofollow">
{ renderedSalesAndImage }
</a>
) : (
renderedSalesAndImage
) }
</div>
);
};
ProductImage.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
productLink: PropTypes.bool,
showSaleBadge: PropTypes.bool,
saleBadgeAlign: PropTypes.string,
};
export default ProductImage;

View File

@@ -0,0 +1,7 @@
export { default as ProductButton } from './button';
export { default as ProductImage } from './image';
export { default as ProductRating } from './rating';
export { default as ProductTitle } from './title';
export { default as ProductPrice } from './price';
export { default as ProductSummary } from './summary';
export { default as ProductSaleBadge } from './sale-badge';

View File

@@ -0,0 +1,72 @@
/**
* External dependencies
*/
import NumberFormat from 'react-number-format';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductPrice = ( { className, product } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
const prices = product.prices || {};
const numberFormatArgs = {
displayType: 'text',
thousandSeparator: prices.thousand_separator,
decimalSeparator: prices.decimal_separator,
decimalScale: prices.decimals,
fixedDecimalScale: true,
prefix: prices.price_prefix,
suffix: prices.price_suffix,
};
if (
prices.price_range &&
prices.price_range.min_amount &&
prices.price_range.max_amount
) {
const minAmount = parseFloat( prices.price_range.min_amount );
const maxAmount = parseFloat( prices.price_range.max_amount );
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-price`
) }
>
<span
className={ `${ layoutStyleClassPrefix }__product-price__value` }
>
<NumberFormat value={ minAmount } { ...numberFormatArgs } />
&nbsp;&mdash;&nbsp;
<NumberFormat value={ maxAmount } { ...numberFormatArgs } />
</span>
</div>
);
}
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-price`
) }
>
{ prices.regular_price !== prices.price && (
<del
className={ `${ layoutStyleClassPrefix }__product-price__regular` }
>
<NumberFormat
value={ prices.regular_price }
{ ...numberFormatArgs }
/>
</del>
) }
<span
className={ `${ layoutStyleClassPrefix }__product-price__value` }
>
<NumberFormat value={ prices.price } { ...numberFormatArgs } />
</span>
</div>
);
};
export default ProductPrice;

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductRating = ( { className, product } ) => {
const rating = parseFloat( product.average_rating );
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! Number.isFinite( rating ) || rating === 0 ) {
return null;
}
const starStyle = {
width: ( rating / 5 ) * 100 + '%',
};
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-rating`
) }
>
<div
className={ `${ layoutStyleClassPrefix }__product-rating__stars` }
role="img"
>
<span style={ starStyle }>
{ sprintf(
__(
'Rated %d out of 5',
'woocommerce'
),
rating
) }
</span>
</div>
</div>
);
};
ProductRating.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductRating;

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductSaleBadge = ( { className, product, align } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
const alignClass =
typeof align === 'string'
? `${ layoutStyleClassPrefix }__product-onsale--align${ align }`
: '';
if ( product && product.on_sale ) {
return (
<div
className={ classnames(
className,
alignClass,
`${ layoutStyleClassPrefix }__product-onsale`
) }
>
{ __( 'Sale', 'woocommerce' ) }
</div>
);
}
return null;
};
export default ProductSaleBadge;

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const ProductSummary = ( { className, product } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! product.description ) {
return null;
}
return (
<div
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-summary`
) }
dangerouslySetInnerHTML={ {
__html: product.description,
} }
/>
);
};
ProductSummary.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductSummary;

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
import { decodeEntities } from '@wordpress/html-entities';
const ProductTitle = ( {
className,
product,
headingLevel = 2,
productLink = true,
} ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
if ( ! product.name ) {
return null;
}
const productName = decodeEntities( product.name );
const TagName = `h${ headingLevel }`;
return (
<TagName
className={ classnames(
className,
`${ layoutStyleClassPrefix }__product-title`
) }
>
{ productLink ? (
<a href={ product.permalink } rel="nofollow">
{ productName }
</a>
) : (
productName
) }
</TagName>
);
};
ProductTitle.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
headingLevel: PropTypes.number,
productLink: PropTypes.bool,
};
export default ProductTitle;

View File

@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
import PropTypes from 'prop-types';
const BlockError = ( {
imageUrl = `${ WC_BLOCKS_ASSET_URL }img/block-error.svg`,
header = __( 'Oops!', 'woocommerce' ),
text = __(
'There was an error with loading this content.',
'woocommerce'
),
errorMessage,
} ) => {
return (
<div className="wc-block-error">
{ imageUrl && (
<img
className="wc-block-error__image"
src={ imageUrl }
alt=""
/>
) }
<div className="wc-block-error__content">
{ header && (
<p className="wc-block-error__header">{ header }</p>
) }
{ text && <p className="wc-block-error__text">{ text }</p> }
{ errorMessage && (
<p className="wc-block-error__message">{ errorMessage }</p>
) }
</div>
</div>
);
};
BlockError.propTypes = {
/**
* Error message to display below the content.
*/
errorMessage: PropTypes.string,
/**
* Text to display as the heading of the error block.
* If it's `null` or an empty string, no header will be displayed.
* If it's not defined, the default header will be used.
*/
header: PropTypes.string,
/**
* URL of the image to display.
* If it's `null` or an empty string, no image will be displayed.
* If it's not defined, the default image will be used.
*/
imageUrl: PropTypes.string,
/**
* Text to display in the error block below the header.
* If it's `null` or an empty string, nothing will be displayed.
* If it's not defined, the default text will be used.
*/
text: PropTypes.string,
};
export default BlockError;

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import BlockError from './block-error';
import './style.scss';
class BlockErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError( error ) {
return { errorMessage: error.message, hasError: true };
}
render() {
const { header, imageUrl, showErrorMessage, text } = this.props;
const { errorMessage, hasError } = this.state;
if ( hasError ) {
return (
<BlockError
errorMessage={ showErrorMessage ? errorMessage : null }
header={ header }
imageUrl={ imageUrl }
text={ text }
/>
);
}
return this.props.children;
}
}
BlockErrorBoundary.propTypes = {
/**
* Text to display as the heading of the error block.
* If it's `null` or an empty string, no header will be displayed.
* If it's not defined, the default header will be used.
*/
header: PropTypes.string,
/**
* URL of the image to display.
* If it's `null` or an empty string, no image will be displayed.
* If it's not defined, the default image will be used.
*/
imageUrl: PropTypes.string,
/**
* Whether to display the JS error message.
*/
showErrorMessage: PropTypes.bool,
/**
* Text to display in the error block below the header.
* If it's `null` or an empty string, nothing will be displayed.
* If it's not defined, the default text will be used.
*/
text: PropTypes.string,
};
BlockErrorBoundary.defaultProps = {
showErrorMessage: false,
};
export default BlockErrorBoundary;

View File

@@ -0,0 +1,30 @@
.wc-block-error {
display: flex;
background-color: #f3f3f4;
border-left: 4px solid #6d6d6d;
padding: $gap-larger $gap;
align-items: center;
justify-content: center;
flex-direction: column;
}
.wc-block-error__header {
font-size: 2em;
font-weight: bold;
margin: 0;
}
.wc-block-error__text,
.wc-block-error__message {
margin: 0;
}
@include breakpoint( ">480px" ) {
.wc-block-error {
flex-direction: row;
}
.wc-block-error__image + .wc-block-error__content {
margin-left: $gap;
}
}

View File

@@ -0,0 +1,171 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { Fragment, useMemo, useState } from '@wordpress/element';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
/**
* Component used to show a list of checkboxes in a group.
*/
const CheckboxList = ( {
className,
onChange = () => {},
options = [],
checked = [],
isLoading = false,
isDisabled = false,
limit = 10,
} ) => {
const [ showExpanded, setShowExpanded ] = useState( false );
const placeholder = useMemo( () => {
return [ ...Array( 5 ) ].map( ( x, i ) => (
<li
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * 75 ) + 25 + '%',
} }
/>
) );
}, [] );
const renderedShowMore = useMemo( () => {
const optionCount = options.length;
const remainingOptionsCount = optionCount - limit;
return (
! showExpanded && (
<li key="show-more" className="show-more">
<button
onClick={ () => {
setShowExpanded( true );
} }
aria-expanded={ false }
aria-label={ sprintf(
_n(
'Show %s more option',
'Show %s more options',
remainingOptionsCount,
'woocommerce'
),
remainingOptionsCount
) }
>
{ sprintf(
// translators: %s number of options to reveal.
_n(
'Show %s more',
'Show %s more',
remainingOptionsCount,
'woocommerce'
),
remainingOptionsCount
) }
</button>
</li>
)
);
}, [ options, limit, showExpanded ] );
const renderedShowLess = useMemo( () => {
return (
showExpanded && (
<li key="show-less" className="show-less">
<button
onClick={ () => {
setShowExpanded( false );
} }
aria-expanded={ true }
aria-label={ __(
'Show less options',
'woocommerce'
) }
>
{ __( 'Show less', 'woocommerce' ) }
</button>
</li>
)
);
}, [ showExpanded ] );
const renderedOptions = useMemo( () => {
// Truncate options if > the limit + 5.
const optionCount = options.length;
const shouldTruncateOptions = optionCount > limit + 5;
return (
<Fragment>
{ options.map( ( option, index ) => (
<Fragment key={ option.key }>
<li
{ ...( shouldTruncateOptions &&
! showExpanded &&
index >= limit && { hidden: true } ) }
>
<input
type="checkbox"
id={ option.key }
value={ option.key }
onChange={ onChange }
checked={ checked.includes( option.key ) }
disabled={ isDisabled }
/>
<label htmlFor={ option.key }>
{ option.label }
</label>
</li>
{ shouldTruncateOptions &&
index === limit - 1 &&
renderedShowMore }
</Fragment>
) ) }
{ shouldTruncateOptions && renderedShowLess }
</Fragment>
);
}, [
options,
checked,
showExpanded,
limit,
renderedShowLess,
renderedShowMore,
isDisabled,
] );
const classes = classNames(
'wc-block-checkbox-list',
{
'is-loading': isLoading,
},
className
);
return (
<ul className={ classes }>
{ isLoading ? placeholder : renderedOptions }
</ul>
);
};
CheckboxList.propTypes = {
onChange: PropTypes.func,
options: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
} )
),
checked: PropTypes.array,
className: PropTypes.string,
isLoading: PropTypes.bool,
isDisabled: PropTypes.bool,
limit: PropTypes.number,
};
export default CheckboxList;

View File

@@ -0,0 +1,29 @@
.editor-styles-wrapper .wc-block-checkbox-list,
.wc-block-checkbox-list {
margin: 0;
padding: 0;
list-style: none outside;
li {
margin: 0 0 $gap-smallest;
padding: 0;
list-style: none outside;
}
li.show-more,
li.show-less {
button {
background: none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
}
&.is-loading {
li {
@include placeholder();
}
}
}

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Label from '../../label';
import './style.scss';
const StepNumber = ( { stepNumber } ) => {
return (
<div className="wc-components-checkout-step__number">
<Label
label={ stepNumber }
screenReaderLabel={ sprintf(
__(
// translators: %s is a step number (1, 2, 3...)
'Step %d',
'woocommerce'
),
stepNumber
) }
/>
</div>
);
};
const StepHeading = ( { title, stepHeadingContent } ) => (
<div className="wc-components-checkout-step__heading">
<h4 className="wc-components-checkout-step__title">{ title }</h4>
<span className="wc-components-checkout-step__heading-content">
{ stepHeadingContent }
</span>
</div>
);
const FormStep = ( {
id,
className,
stepNumber,
title,
description,
children,
stepHeadingContent = () => null,
} ) => {
return (
<div
className={ classnames( className, 'wc-components-checkout-step' ) }
id={ id }
>
<StepNumber stepNumber={ stepNumber } />
<StepHeading
title={ title }
stepHeadingContent={ stepHeadingContent() }
/>
<span className="wc-components-checkout-step__description">
{ description }
</span>
<div className="wc-components-checkout-step__content">
{ children }
</div>
</div>
);
};
FormStep.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
stepNumber: PropTypes.number,
title: PropTypes.string,
description: PropTypes.string,
children: PropTypes.node,
stepHeadingContent: PropTypes.func,
};
export default FormStep;

View File

@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { Fragment } from 'react';
import classNames from 'classnames';
/**
* Component used to render an accessible text given a label and/or a
* screenReaderLabel. The wrapper element and wrapper props can also be
* specified via props.
*/
const Label = ( {
label,
screenReaderLabel,
wrapperElement,
wrapperProps,
} ) => {
let Wrapper;
if ( ! label && screenReaderLabel ) {
Wrapper = wrapperElement || 'span';
wrapperProps = {
...wrapperProps,
className: classNames(
wrapperProps.className,
'screen-reader-text'
),
};
return <Wrapper { ...wrapperProps }>{ screenReaderLabel }</Wrapper>;
}
Wrapper = wrapperElement || Fragment;
if ( label && screenReaderLabel && label !== screenReaderLabel ) {
return (
<Wrapper { ...wrapperProps }>
<span aria-hidden="true">{ label }</span>
<span className="screen-reader-text">
{ screenReaderLabel }
</span>
</Wrapper>
);
}
return <Wrapper { ...wrapperProps }>{ label }</Wrapper>;
};
Label.propTypes = {
label: PropTypes.string,
screenReaderLabel: PropTypes.string,
wrapperElement: PropTypes.elementType,
wrapperProps: PropTypes.object,
};
Label.defaultProps = {
wrapperProps: {},
};
export default Label;

View File

@@ -0,0 +1,62 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Label with wrapperElement should render both label and screen reader label 1`] = `
<label
className="foo-bar"
data-foo="bar"
>
<span
aria-hidden="true"
>
Lorem
</span>
<span
className="screen-reader-text"
>
Ipsum
</span>
</label>
`;
exports[`Label with wrapperElement should render only the label 1`] = `
<label
className="foo-bar"
data-foo="bar"
>
Lorem
</label>
`;
exports[`Label with wrapperElement should render only the screen reader label 1`] = `
<label
className="foo-bar screen-reader-text"
data-foo="bar"
>
Ipsum
</label>
`;
exports[`Label without wrapperElement should render both label and screen reader label 1`] = `
Array [
<span
aria-hidden="true"
>
Lorem
</span>,
<span
className="screen-reader-text"
>
Ipsum
</span>,
]
`;
exports[`Label without wrapperElement should render only the label 1`] = `"Lorem"`;
exports[`Label without wrapperElement should render only the screen reader label 1`] = `
<span
className="screen-reader-text"
>
Ipsum
</span>
`;

View File

@@ -0,0 +1,83 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import Label from '../';
describe( 'Label', () => {
describe( 'without wrapperElement', () => {
test( 'should render both label and screen reader label', () => {
const component = TestRenderer.create(
<Label label="Lorem" screenReaderLabel="Ipsum" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render only the label', () => {
const component = TestRenderer.create( <Label label="Lorem" /> );
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render only the screen reader label', () => {
const component = TestRenderer.create(
<Label screenReaderLabel="Ipsum" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );
describe( 'with wrapperElement', () => {
test( 'should render both label and screen reader label', () => {
const component = TestRenderer.create(
<Label
label="Lorem"
screenReaderLabel="Ipsum"
wrapperElement="label"
wrapperProps={ {
className: 'foo-bar',
'data-foo': 'bar',
} }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render only the label', () => {
const component = TestRenderer.create(
<Label
label="Lorem"
wrapperElement="label"
wrapperProps={ {
className: 'foo-bar',
'data-foo': 'bar',
} }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render only the screen reader label', () => {
const component = TestRenderer.create(
<Label
screenReaderLabel="Ipsum"
wrapperElement="label"
wrapperProps={ {
className: 'foo-bar',
'data-foo': 'bar',
} }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );
} );

View File

@@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import Label from '@woocommerce/base-components/label';
/**
* Internal dependencies
*/
import './style.scss';
export const LoadMoreButton = ( { onClick, label, screenReaderLabel } ) => {
return (
<div className="wp-block-button wc-block-load-more">
<button className="wp-block-button__link" onClick={ onClick }>
<Label
label={ label }
screenReaderLabel={ screenReaderLabel }
/>
</button>
</div>
);
};
LoadMoreButton.propTypes = {
label: PropTypes.string,
onClick: PropTypes.func,
screenReaderLabel: PropTypes.string,
};
LoadMoreButton.defaultProps = {
label: __( 'Load more', 'woocommerce' ),
};
export default LoadMoreButton;

View File

@@ -0,0 +1,4 @@
.wc-block-load-more {
text-align: center;
width: 100%;
}

View File

@@ -0,0 +1,191 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Label from '@woocommerce/base-components/label';
/**
* Internal dependencies
*/
import { getIndexes } from './utils.js';
import './style.scss';
const Pagination = ( {
currentPage,
displayFirstAndLastPages,
displayNextAndPreviousArrows,
pagesToDisplay,
onPageChange,
totalPages,
} ) => {
let { minIndex, maxIndex } = getIndexes(
pagesToDisplay,
currentPage,
totalPages
);
const showFirstPage = displayFirstAndLastPages && Boolean( minIndex !== 1 );
const showLastPage =
displayFirstAndLastPages && Boolean( maxIndex !== totalPages );
const showFirstPageEllipsis =
displayFirstAndLastPages && Boolean( minIndex > 3 );
const showLastPageEllipsis =
displayFirstAndLastPages && Boolean( maxIndex < totalPages - 2 );
// Handle the cases where there would be an ellipsis replacing one single page
if ( showFirstPage && minIndex === 3 ) {
minIndex = minIndex - 1;
}
if ( showLastPage && maxIndex === totalPages - 2 ) {
maxIndex = maxIndex + 1;
}
const pages = [];
if ( minIndex && maxIndex ) {
for ( let i = minIndex; i <= maxIndex; i++ ) {
pages.push( i );
}
}
return (
<div className="wc-block-pagination">
<Label
screenReaderLabel={ __(
'Navigate to another page',
'woocommerce'
) }
/>
{ displayNextAndPreviousArrows && (
<button
className="wc-block-pagination-page"
onClick={ () => onPageChange( currentPage - 1 ) }
title={ __(
'Previous page',
'woocommerce'
) }
disabled={ currentPage <= 1 }
>
<Label
label="<"
screenReaderLabel={ __(
'Previous page',
'woocommerce'
) }
/>
</button>
) }
{ showFirstPage && (
<button
className={ classNames( 'wc-block-pagination-page', {
'wc-block-pagination-page--active': currentPage === 1,
} ) }
onClick={ () => onPageChange( 1 ) }
disabled={ currentPage === 1 }
>
1
</button>
) }
{ showFirstPageEllipsis && (
<span
className="wc-block-pagination-ellipsis"
aria-hidden="true"
>
{ __( '…', 'woocommerce' ) }
</span>
) }
{ pages.map( ( page ) => {
return (
<button
key={ page }
className={ classNames( 'wc-block-pagination-page', {
'wc-block-pagination-page--active':
currentPage === page,
} ) }
onClick={
currentPage === page
? null
: () => onPageChange( page )
}
disabled={ currentPage === page }
>
{ page }
</button>
);
} ) }
{ showLastPageEllipsis && (
<span
className="wc-block-pagination-ellipsis"
aria-hidden="true"
>
{ __( '…', 'woocommerce' ) }
</span>
) }
{ showLastPage && (
<button
className={ classNames( 'wc-block-pagination-page', {
'wc-block-pagination-page--active':
currentPage === totalPages,
} ) }
onClick={ () => onPageChange( totalPages ) }
disabled={ currentPage === totalPages }
>
{ totalPages }
</button>
) }
{ displayNextAndPreviousArrows && (
<button
className="wc-block-pagination-page"
onClick={ () => onPageChange( currentPage + 1 ) }
title={ __( 'Next page', 'woocommerce' ) }
disabled={ currentPage >= totalPages }
>
<Label
label=">"
screenReaderLabel={ __(
'Next page',
'woocommerce'
) }
/>
</button>
) }
</div>
);
};
Pagination.propTypes = {
/**
* Number of the page currently being displayed.
*/
currentPage: PropTypes.number.isRequired,
/**
* Total number of pages.
*/
totalPages: PropTypes.number.isRequired,
/**
* Displays first and last pages if they are not in the current range of pages displayed.
*/
displayFirstAndLastPages: PropTypes.bool,
/**
* Displays arrows to navigate to the previous and next pages.
*/
displayNextAndPreviousArrows: PropTypes.bool,
/**
* Callback function called when the user triggers a page change.
*/
onPageChange: PropTypes.func,
/**
* Number of pages to display at the same time, including the active page
* and the pages displayed before and after it. It doesn't include the first
* and last pages.
*/
pagesToDisplay: PropTypes.number,
};
Pagination.defaultProps = {
displayFirstAndLastPages: true,
displayNextAndPreviousArrows: true,
pagesToDisplay: 3,
};
export default Pagination;

View File

@@ -0,0 +1,51 @@
.wc-block-pagination {
margin: 0 auto $gap;
}
.wc-block-pagination-page,
.wc-block-pagination-ellipsis {
color: #333;
display: inline-block;
font-size: 1em;
font-weight: normal;
}
.wc-block-pagination-page {
border-color: transparent;
padding: 0.3em 0.6em;
min-width: 2.2em;
@include breakpoint( "<782px" ) {
padding: 0.1em 0.2em;
min-width: 1.6em;
}
// Twenty Twenty register a background color for buttons that is too specific
// and broad at the same time `button:not(.toggle)` so we're engaing in a
// specify war with them here.
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1203
&:not(.toggle) {
background-color: transparent;
}
}
.wc-block-pagination-ellipsis {
padding: 0.3em;
@include breakpoint( "<782px" ) {
padding: 0.1em;
}
}
.wc-block-pagination-page--active[disabled] {
color: #333;
font-weight: bold;
opacity: 1 !important;
&:hover,
&:focus {
background-color: inherit;
color: #333;
opacity: 1 !important;
}
}

View File

@@ -0,0 +1,40 @@
/**
* Internal dependencies
*/
import { getIndexes } from '../utils.js';
describe( 'getIndexes', () => {
describe( 'when on the first page', () => {
test( 'indexes include the first pages available', () => {
expect( getIndexes( 5, 1, 100 ) ).toEqual( {
minIndex: 2,
maxIndex: 6,
} );
} );
test( 'indexes are null if there are 2 pages or less', () => {
expect( getIndexes( 5, 1, 1 ) ).toEqual( {
minIndex: null,
maxIndex: null,
} );
} );
} );
describe( 'when on a page in the middle', () => {
test( 'indexes include pages before and after the current page', () => {
expect( getIndexes( 5, 50, 100 ) ).toEqual( {
minIndex: 48,
maxIndex: 52,
} );
} );
} );
describe( 'when on the last page', () => {
test( 'indexes include the last pages available', () => {
expect( getIndexes( 5, 100, 100 ) ).toEqual( {
minIndex: 95,
maxIndex: 99,
} );
} );
} );
} );

View File

@@ -0,0 +1,34 @@
/**
* Given the number of pages to display, the current page and the total pages,
* returns the min and max index of the pages to display in the pagination component.
*
* @param {integer} pagesToDisplay Maximum number of pages to display in the pagination component.
* @param {integer} currentPage Page currently visible.
* @param {integer} totalPages Total pages available.
* @return {Object} Object containing the min and max index to display in the pagination component.
*/
export const getIndexes = ( pagesToDisplay, currentPage, totalPages ) => {
if ( totalPages <= 2 ) {
return { minIndex: null, maxIndex: null };
}
const extraPagesToDisplay = pagesToDisplay - 1;
const tentativeMinIndex = Math.max(
Math.floor( currentPage - extraPagesToDisplay / 2 ),
2
);
const maxIndex = Math.min(
Math.ceil(
currentPage +
( extraPagesToDisplay - ( currentPage - tentativeMinIndex ) )
),
totalPages - 1
);
const minIndex = Math.max(
Math.floor(
currentPage - ( extraPagesToDisplay - ( maxIndex - currentPage ) )
),
2
);
return { minIndex, maxIndex };
};

View File

@@ -0,0 +1,54 @@
/**
* Validate a min and max value for a range slider againt defined constraints (min, max, step).
*
* @param {Array} values Array containing min and max values.
* @param {int} min Min allowed value for the sliders.
* @param {int} max Max allowed value for the sliders.
* @param {step} step Step value for the sliders.
* @param {boolean} isMin Whether we're currently interacting with the min range slider or not, so we update the correct values.
* @returns {Array} Validated and updated min/max values that fit within the range slider constraints.
*/
export const constrainRangeSliderValues = (
values,
min,
max,
step = 1,
isMin = false
) => {
let minValue = parseInt( values[ 0 ], 10 );
let maxValue = parseInt( values[ 1 ], 10 );
if ( ! Number.isFinite( minValue ) ) {
minValue = min || 0;
}
if ( ! Number.isFinite( maxValue ) ) {
maxValue = max || step;
}
if ( Number.isFinite( min ) && min > minValue ) {
minValue = min;
}
if ( Number.isFinite( max ) && max <= minValue ) {
minValue = max - step;
}
if ( Number.isFinite( min ) && min >= maxValue ) {
maxValue = min + step;
}
if ( Number.isFinite( max ) && max < maxValue ) {
maxValue = max;
}
if ( ! isMin && minValue >= maxValue ) {
minValue = maxValue - step;
}
if ( isMin && maxValue <= minValue ) {
maxValue = minValue + step;
}
return [ minValue, maxValue ];
};

View File

@@ -0,0 +1,386 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
Fragment,
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from '@wordpress/element';
import PropTypes from 'prop-types';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
import { constrainRangeSliderValues } from './constrain-range-slider-values';
import { formatPrice } from '../../utils/price';
import SubmitButton from './submit-button';
import PriceLabel from './price-label';
import PriceInput from './price-input';
const PriceSlider = ( {
minPrice,
maxPrice,
minConstraint,
maxConstraint,
onChange = () => {},
step = 10,
currencySymbol = '$',
priceFormat = '%1$s%2$s',
showInputFields = true,
showFilterButton = false,
isLoading = false,
onSubmit = () => {},
} ) => {
const minRange = useRef();
const maxRange = useRef();
const [ formattedMinPrice, setFormattedMinPrice ] = useState(
formatPrice( minPrice, priceFormat, currencySymbol )
);
const [ formattedMaxPrice, setFormattedMaxPrice ] = useState(
formatPrice( maxPrice, priceFormat, currencySymbol )
);
useEffect( () => {
setFormattedMinPrice(
formatPrice( minPrice, priceFormat, currencySymbol )
);
}, [ minPrice, priceFormat, currencySymbol ] );
useEffect( () => {
setFormattedMaxPrice(
formatPrice( maxPrice, priceFormat, currencySymbol )
);
}, [ maxPrice, priceFormat, currencySymbol ] );
/**
* Checks if the min and max constraints are valid.
*/
const hasValidConstraints = useMemo( () => {
return isFinite( minConstraint ) && isFinite( maxConstraint );
}, [ minConstraint, maxConstraint ] );
/**
* Handles styles for the shaded area of the range slider.
*/
const progressStyles = useMemo( () => {
if (
! isFinite( minPrice ) ||
! isFinite( maxPrice ) ||
! hasValidConstraints
) {
return {
'--low': '0%',
'--high': '100%',
};
}
const low =
Math.round(
100 *
( ( minPrice - minConstraint ) /
( maxConstraint - minConstraint ) )
) - 0.5;
const high =
Math.round(
100 *
( ( maxPrice - minConstraint ) /
( maxConstraint - minConstraint ) )
) + 0.5;
return {
'--low': low + '%',
'--high': high + '%',
};
}, [
minPrice,
maxPrice,
minConstraint,
maxConstraint,
hasValidConstraints,
step,
] );
/**
* Works around an IE issue where only one range selector is visible by changing the display order
* based on the mouse position.
*
* @param {obj} event event data.
*/
const findClosestRange = useCallback(
( event ) => {
if ( isLoading || ! hasValidConstraints ) {
return;
}
const bounds = event.target.getBoundingClientRect();
const x = event.clientX - bounds.left;
const minWidth = minRange.current.offsetWidth;
const minValue = minRange.current.value;
const maxWidth = maxRange.current.offsetWidth;
const maxValue = maxRange.current.value;
const minX = minWidth * ( minValue / maxConstraint );
const maxX = maxWidth * ( maxValue / maxConstraint );
const minXDiff = Math.abs( x - minX );
const maxXDiff = Math.abs( x - maxX );
/**
* The default z-index in the stylesheet as 20. 20 vs 21 is just for determining which range
* slider should be at the front and has no meaning beyond
*/
if ( minXDiff > maxXDiff ) {
minRange.current.style.zIndex = 20;
maxRange.current.style.zIndex = 21;
} else {
minRange.current.style.zIndex = 21;
maxRange.current.style.zIndex = 20;
}
},
[ isLoading, maxConstraint, hasValidConstraints ]
);
/**
* Called when the slider is dragged.
* @param {obj} event Event object.
*/
const rangeInputOnChange = useCallback(
( event ) => {
const isMin = event.target.classList.contains(
'wc-block-price-filter__range-input--min'
);
const targetValue = event.target.value;
const currentValues = isMin
? [ Math.round( targetValue / step ) * step, maxPrice ]
: [ minPrice, Math.round( targetValue / step ) * step ];
const values = constrainRangeSliderValues(
currentValues,
minConstraint,
maxConstraint,
step,
isMin
);
onChange( [
parseInt( values[ 0 ], 10 ),
parseInt( values[ 1 ], 10 ),
] );
},
[ minPrice, maxPrice, minConstraint, maxConstraint, step ]
);
/**
* Called when a price input loses focus - commit changes to slider.
* @param {obj} event Event object.
*/
const priceInputOnBlur = useCallback(
( event ) => {
const isMin = event.target.classList.contains(
'wc-block-price-filter__amount--min'
);
const targetValue = event.target.value.replace( /[^0-9.-]+/g, '' );
const currentValues = isMin
? [ targetValue, maxPrice ]
: [ minPrice, targetValue ];
const values = constrainRangeSliderValues(
currentValues,
null,
null,
step,
isMin
);
onChange( [
parseInt( values[ 0 ], 10 ),
parseInt( values[ 1 ], 10 ),
] );
setFormattedMinPrice(
formatPrice(
parseInt( values[ 0 ], 10 ),
priceFormat,
currencySymbol
)
);
setFormattedMaxPrice(
formatPrice(
parseInt( values[ 1 ], 10 ),
priceFormat,
currencySymbol
)
);
},
[ minPrice, maxPrice, minConstraint, maxConstraint, step ]
);
/**
* Called when a price input is typed in - store value but don't update slider.
* @param {obj} event Event object.
*/
const priceInputOnChange = useCallback(
( event ) => {
const newValue = event.target.value.replace( /[^0-9.-]+/g, '' );
const isMin = event.target.classList.contains(
'wc-block-price-filter__amount--min'
);
if ( isMin ) {
setFormattedMinPrice(
formatPrice( newValue, priceFormat, currencySymbol )
);
} else {
setFormattedMaxPrice(
formatPrice( newValue, priceFormat, currencySymbol )
);
}
},
[ priceFormat, currencySymbol ]
);
const classes = classnames(
'wc-block-price-filter',
showInputFields && 'wc-block-price-filter--has-input-fields',
showFilterButton && 'wc-block-price-filter--has-filter-button',
isLoading && 'is-loading',
! hasValidConstraints && 'is-disabled'
);
const minRangeStep =
minRange && document.activeElement === minRange.current ? step : 1;
const maxRangeStep =
maxRange && document.activeElement === maxRange.current ? step : 1;
return (
<div className={ classes }>
<div
className="wc-block-price-filter__range-input-wrapper"
onMouseMove={ findClosestRange }
onFocus={ findClosestRange }
>
{ hasValidConstraints && (
<Fragment>
<div
className="wc-block-price-filter__range-input-progress"
style={ progressStyles }
/>
<input
type="range"
className="wc-block-price-filter__range-input wc-block-price-filter__range-input--min"
aria-label={ __(
'Filter products by minimum price',
'woocommerce'
) }
value={
Number.isFinite( minPrice )
? minPrice
: minConstraint
}
onChange={ rangeInputOnChange }
step={ minRangeStep }
min={ minConstraint }
max={ maxConstraint }
ref={ minRange }
disabled={ isLoading }
/>
<input
type="range"
className="wc-block-price-filter__range-input wc-block-price-filter__range-input--max"
aria-label={ __(
'Filter products by maximum price',
'woocommerce'
) }
value={
Number.isFinite( maxPrice )
? maxPrice
: maxConstraint
}
onChange={ rangeInputOnChange }
step={ maxRangeStep }
min={ minConstraint }
max={ maxConstraint }
ref={ maxRange }
disabled={ isLoading }
/>
</Fragment>
) }
</div>
<div className="wc-block-price-filter__controls">
{ showInputFields ? (
<PriceInput
disabled={ isLoading || ! hasValidConstraints }
onChange={ priceInputOnChange }
onBlur={ priceInputOnBlur }
minPrice={ formattedMinPrice }
maxPrice={ formattedMaxPrice }
/>
) : (
<PriceLabel
minPrice={ formattedMinPrice }
maxPrice={ formattedMaxPrice }
/>
) }
{ showFilterButton && (
<SubmitButton
disabled={ isLoading || ! hasValidConstraints }
onClick={ onSubmit }
/>
) }
</div>
</div>
);
};
PriceSlider.propTypes = {
/**
* Callback fired when prices changes.
*/
onChange: PropTypes.func.isRequired,
/**
* Callback fired when the filter button is pressed.
*/
onSubmit: PropTypes.func,
/**
* Min value.
*/
minPrice: PropTypes.number,
/**
* Max value.
*/
maxPrice: PropTypes.number,
/**
* Minimum allowed price.
*/
minConstraint: PropTypes.number,
/**
* Maximum allowed price.
*/
maxConstraint: PropTypes.number,
/**
* Step for slider inputs.
*/
step: PropTypes.number,
/**
* Currency symbol to use when formatting prices for display.
*/
currencySymbol: PropTypes.string,
/**
* Price format to use when formatting prices for display.
*/
priceFormat: PropTypes.string,
/**
* Whether or not to show input fields above the slider.
*/
showInputFields: PropTypes.bool,
/**
* Whether or not to show filter button above the slider.
*/
showFilterButton: PropTypes.bool,
/**
* Whether or not to show filter button above the slider.
*/
isLoading: PropTypes.bool,
};
export default PriceSlider;

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { Fragment } from '@wordpress/element';
const PriceInput = ( { disabled, onBlur, onChange, minPrice, maxPrice } ) => {
return (
<Fragment>
<input
type="text"
size="5"
className="wc-block-price-filter__amount wc-block-price-filter__amount--min wc-block-form-text-input"
aria-label={ __(
'Filter products by minimum price',
'woocommerce'
) }
onChange={ onChange }
onBlur={ onBlur }
disabled={ disabled }
value={ minPrice }
/>
<input
type="text"
size="5"
className="wc-block-price-filter__amount wc-block-price-filter__amount--max wc-block-form-text-input"
aria-label={ __(
'Filter products by maximum price',
'woocommerce'
) }
onChange={ onChange }
onBlur={ onBlur }
disabled={ disabled }
value={ maxPrice }
/>
</Fragment>
);
};
PriceInput.propTypes = {
/**
* Is the text input disabled?
*/
disabled: PropTypes.bool,
/**
* Callback fired on input.
*/
onBlur: PropTypes.func,
/**
* Callback fired on input.
*/
onChange: PropTypes.func,
/**
* Min price to display. This is a string because it contains currency e.g. $10.00.
*/
minPrice: PropTypes.string.isRequired,
/**
* Max price to display. This is a string because it contains currency e.g. $10.00.
*/
maxPrice: PropTypes.string.isRequired,
};
PriceInput.defaultProps = {
disabled: false,
onBlur: () => {},
onChange: () => {},
};
export default PriceInput;

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { sprintf, __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
const PriceLabel = ( { minPrice, maxPrice } ) => {
if ( ! minPrice && ! maxPrice ) {
return null;
}
return (
<div className="wc-block-price-filter__range-text">
{ sprintf(
// translators: %s: low price, %s: high price.
__( 'Price: %s — %s', 'woocommerce' ),
minPrice,
maxPrice
) }
</div>
);
};
PriceLabel.propTypes = {
/**
* Min price to display.
*/
minPrice: PropTypes.string.isRequired,
/**
* Max price to display.
*/
maxPrice: PropTypes.string.isRequired,
};
export default PriceLabel;

View File

@@ -0,0 +1,280 @@
/* stylelint-disable */
@mixin thumb {
background-color: transparent;
background-position: 0 0;
width: 26px;
height: 21px;
border: 0;
padding: 0;
margin: 0;
vertical-align: top;
cursor: pointer;
z-index: 20;
pointer-events: auto;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='52' height='42'%3E%3Cdefs%3E%3Cpath id='a' d='M23.3176 7.9423l-8.4163-6.1432C13.1953.5706 11.2618-.0997 9.2146.0121h-.1137C4.2103.347.1159 4.368.0022 9.2827-.1115 14.644 4.2102 19 9.6696 19h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3Cpath id='b' d='M23.3176 7.9423l-8.4163-6.1432C13.1953.5706 11.2618-.0997 9.2146.0121h-.1137C4.2103.347.1159 4.368.0022 9.2827-.1115 14.644 4.2102 19 9.6696 19h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3C/defs%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='%23FFF' fill-rule='nonzero' stroke='%2395588A' d='M24.3176 8.9423l-8.4163-6.1432c-1.706-1.2285-3.6395-1.8988-5.6867-1.787h-.1137c-4.8906.335-8.985 4.356-9.0987 9.2706C.8885 15.644 5.2102 20 10.6696 20h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3Cpath stroke='%23B8B8B8' d='M9 6v9m3-9v9'/%3E%3Cg fill-rule='nonzero' transform='translate(1 22)'%3E%3Cuse fill='%23F8F3F7' stroke='%23FFF' stroke-opacity='.75' stroke-width='3' xlink:href='%23a'/%3E%3Cuse stroke='%2395588A' xlink:href='%23a'/%3E%3C/g%3E%3Cpath stroke='%2395588A' d='M9 27v9m3-9v9'/%3E%3Cg%3E%3Cpath fill='%23FFF' fill-rule='nonzero' stroke='%2395588A' d='M27.6824 8.9423l8.4163-6.1432c1.706-1.2285 3.6395-1.8988 5.6867-1.787h.1137c4.8906.335 8.985 4.356 9.0987 9.2706C51.1115 15.644 46.7898 20 41.3304 20h-.1137c-1.8197 0-3.6395-.6702-5.118-1.787l-8.4163-6.255c-.9099-.8935-.9099-2.2338 0-3.0157z'/%3E%3Cpath stroke='%23B8B8B8' d='M43 6v9m-3-9v9'/%3E%3C/g%3E%3Cg%3E%3Cg fill-rule='nonzero' transform='matrix(-1 0 0 1 51 22)'%3E%3Cuse fill='%23F8F3F7' stroke='%23FFF' stroke-opacity='.75' stroke-width='3' xlink:href='%23b'/%3E%3Cuse stroke='%2395588A' xlink:href='%23b'/%3E%3C/g%3E%3Cpath stroke='%2395588A' d='M43 27v9m-3-9v9'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
transition: transform .2s ease-in-out;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
&:hover {
@include thumbFocus;
transform: scale(1.1);
}
}
@mixin thumbFocus {
background-position-y: -21px;
filter: drop-shadow(3px 0 0 rgba(255, 255, 255, .75)) drop-shadow(-3px 0 0 rgba(255, 255, 255, .75));
}
/* stylelint-enable */
@mixin track {
cursor: default;
height: 1px; /* Required for Samsung internet based browsers */
outline: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
@mixin reset {
margin: 0;
padding: 0;
border: 0;
outline: none;
background: transparent;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.wc-block-price-filter {
.wc-block-price-filter__range-input-wrapper {
@include reset;
height: 9px;
clear: both;
position: relative;
box-shadow: 0 0 0 1px inset rgba(0, 0, 0, 0.1);
background: #e1e1e1;
margin: 15px 0;
.wc-block-price-filter__range-input-progress {
height: 9px;
width: 100%;
position: absolute;
left: 0;
top: 0;
--track-background: linear-gradient(to right, transparent var(--low), var(--range-color) 0, var(--range-color) var(--high), transparent 0) no-repeat 0 100% / 100% 100%;
--range-color: #a8739d;
background: var(--track-background);
}
}
.wc-block-price-filter__controls {
display: flex;
margin: 0 0 20px;
.wc-block-price-filter__amount {
margin: 0;
border-radius: 4px;
width: auto;
max-width: 100px;
min-width: 0;
&.wc-block-price-filter__amount--min {
margin-right: 10px;
}
&.wc-block-price-filter__amount--max {
margin-left: auto;
}
}
}
&.wc-block-price-filter--has-filter-button {
.wc-block-price-filter__controls {
justify-content: flex-end;
.wc-block-price-filter__amount.wc-block-price-filter__amount--max {
margin-left: 0;
margin-right: 10px;
}
.wc-block-price-filter__button {
margin-left: auto;
white-space: nowrap;
}
}
}
.wc-block-price-filter__range-input {
@include reset;
width: 100%;
height: 0;
display: block;
position: relative;
pointer-events: none;
outline: none !important;
&::-webkit-slider-runnable-track {
@include track;
}
&::-webkit-slider-thumb {
@include thumb;
margin: -6px 0 0 0;
}
&::-webkit-slider-progress {
@include reset;
}
&::-moz-focus-outer {
border: 0;
}
&::-moz-range-track {
@include track;
}
&::-moz-range-progress {
@include reset;
}
&::-moz-range-thumb {
@include thumb;
}
&::-ms-thumb {
@include thumb;
}
&:focus {
&::-webkit-slider-thumb {
@include thumbFocus;
}
&::-moz-range-thumb {
@include thumbFocus;
}
&::-ms-thumb {
@include thumbFocus;
}
}
&.wc-block-price-filter__range-input--min {
z-index: 21;
&::-webkit-slider-thumb {
margin-left: -2px;
}
&::-moz-range-thumb {
transform: translate(-2px, 4px);
}
}
&.wc-block-price-filter__range-input--max {
z-index: 20;
&::-webkit-slider-thumb {
background-position-x: 26px;
margin-left: 2px;
}
&::-moz-range-thumb {
background-position-x: 26px;
transform: translate(2px, 4px);
}
&::-ms-thumb {
background-position-x: 26px;
}
}
}
&.is-loading,
&.is-disabled {
.wc-block-price-filter__range-input-wrapper,
.wc-block-price-filter__amount,
.wc-block-price-filter__button {
@include placeholder();
box-shadow: none;
}
}
&.is-disabled:not(.is-loading) {
.wc-block-price-filter__range-input-wrapper,
.wc-block-price-filter__amount,
.wc-block-price-filter__button {
animation: none;
}
}
}
@mixin ie {
.wc-block-price-filter {
.wc-block-price-filter__range-input-wrapper {
background: transparent;
box-shadow: none;
height: 24px;
.wc-block-price-filter__range-input-progress {
background: #a8739d;
box-shadow: 0 0 0 1px inset #95588a;
width: 100%;
top: 7px;
}
}
.wc-block-price-filter__range-input {
height: 24px;
pointer-events: auto;
position: absolute;
left: 0;
top: 0;
&::-ms-track {
/*remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead */
background: transparent;
/*leave room for the larger thumb to overflow with a transparent border */
border-color: transparent;
border-width: 7px 0;
/*remove default tick marks*/
color: transparent;
}
&::-ms-fill-lower {
background: #e1e1e1;
box-shadow: 0 0 0 1px inset #b8b8b8;
}
&::-ms-fill-upper {
background: transparent;
}
&::-ms-tooltip {
display: none;
}
&::-ms-thumb {
transform: translate(1px, 0);
pointer-events: auto;
}
}
.wc-block-price-filter__range-input--max {
&::-ms-fill-upper {
background: #e1e1e1;
box-shadow: 0 0 0 1px inset #b8b8b8;
}
&::-ms-fill-lower {
background: transparent;
}
}
&.is-loading,
&.is-disabled {
.wc-block-price-filter__range-input-wrapper {
@include placeholder();
box-shadow: none;
}
}
&.is-disabled:not(.is-loading) {
.wc-block-price-filter__range-input-wrapper {
animation: none;
}
}
}
}
/* IE 11 will not support multi-range slider due to poor pointer-events support on the thumb. Reverts to 2 sliders. */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
@include ie;
}
@supports (-ms-ime-align:auto) {
@include ie;
}

View File

@@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
const SubmitButton = ( { disabled, onClick } ) => {
return (
<button
type="submit"
className="wc-block-price-filter__button wc-block-form-button"
disabled={ disabled }
onClick={ onClick }
>
{ // translators: Submit button text for the price filter.
__( 'Go', 'woocommerce' ) }
</button>
);
};
SubmitButton.propTypes = {
/**
* Is the button disabled?
*/
disabled: PropTypes.bool,
/**
* On click callback.
*/
onClick: PropTypes.func.isRequired,
};
SubmitButton.defaultProps = {
disabled: false,
};
export default SubmitButton;

View File

@@ -0,0 +1,39 @@
/**
* Internal dependencies
*/
import { constrainRangeSliderValues } from '../constrain-range-slider-values';
describe( 'constrainRangeSliderValues', () => {
test.each`
values | min | max | step | isMin | expected
${[ 20, 60 ]} | ${0} | ${70} | ${10} | ${true} | ${[ 20, 60 ]}
${[ 20, 60 ]} | ${20} | ${60} | ${10} | ${true} | ${[ 20, 60 ]}
${[ 20, 60 ]} | ${30} | ${50} | ${10} | ${true} | ${[ 30, 50 ]}
${[ 50, 50 ]} | ${20} | ${60} | ${10} | ${true} | ${[ 50, 60 ]}
${[ 50, 50 ]} | ${20} | ${60} | ${10} | ${false} | ${[ 40, 50 ]}
${[ 20, 60 ]} | ${null} | ${null} | ${10} | ${true} | ${[ 20, 60 ]}
${[ null, null ]} | ${20} | ${60} | ${10} | ${true} | ${[ 20, 60 ]}
${[ '20', '60' ]} | ${30} | ${50} | ${10} | ${true} | ${[ 30, 50 ]}
${[ -60, -20 ]} | ${-70} | ${0} | ${10} | ${true} | ${[ -60, -20 ]}
${[ -60, -20 ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -60, -20 ]}
${[ -60, -20 ]} | ${-50} | ${-30} | ${10} | ${true} | ${[ -50, -30 ]}
${[ -50, -50 ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -50, -40 ]}
${[ -50, -50 ]} | ${-60} | ${-20} | ${10} | ${false} | ${[ -60, -50 ]}
${[ -60, -20 ]} | ${null} | ${null} | ${10} | ${true} | ${[ -60, -20 ]}
${[ null, null ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -60, -20 ]}
${[ '-60', '-20' ]} | ${-50} | ${-30} | ${10} | ${true} | ${[ -50, -30 ]}
`(
`correctly sets prices to its constraints with arguments values: $values, min: $min, max: $max, step: $step and isMin: $isMin`,
( { values, min, max, step, isMin, expected } ) => {
const constrainedValues = constrainRangeSliderValues(
values,
min,
max,
step,
isMin
);
expect( constrainedValues ).toEqual( expected );
}
);
} );

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useInnerBlockConfigurationContext } from '@woocommerce/base-context/inner-block-configuration-context';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
import withComponentId from '@woocommerce/base-hocs/with-component-id';
/**
* Internal dependencies
*/
import { renderProductLayout } from './utils';
const ProductListItem = ( { product, attributes, componentId } ) => {
const { layoutConfig } = attributes;
const { parentName } = useInnerBlockConfigurationContext();
const { layoutStyleClassPrefix } = useProductLayoutContext();
const isLoading = ! Object.keys( product ).length > 0;
const classes = classnames( `${ layoutStyleClassPrefix }__product`, {
'is-loading': isLoading,
} );
return (
<li className={ classes } aria-hidden={ isLoading }>
{ renderProductLayout(
parentName,
product,
layoutConfig,
componentId
) }
</li>
);
};
ProductListItem.propTypes = {
attributes: PropTypes.object.isRequired,
product: PropTypes.object,
// from withComponentId
componentId: PropTypes.number.isRequired,
};
export default withComponentId( ProductListItem );

View File

@@ -0,0 +1,56 @@
/**
* Internal dependencies
*/
import { getBlockMap } from '../../../blocks/products/base-utils';
/**
* Maps a layout config into atomic components.
*
* @param {string} blockName Name of the parent block. Used to get extension children.
* @param {Object} product Product object to pass to atomic components.
* @param {Object[]} layoutConfig Object with component data.
* @param {number} componentId Parent component ID needed for key generation.
*/
export const renderProductLayout = (
blockName,
product,
layoutConfig,
componentId
) => {
if ( ! layoutConfig ) {
return;
}
const blockMap = getBlockMap( blockName );
return layoutConfig.map( ( [ name, props = {} ], index ) => {
let children = [];
if ( !! props.children && props.children.length > 0 ) {
children = renderProductLayout(
blockName,
product,
props.children,
componentId
);
}
const LayoutComponent = blockMap[ name ];
if ( ! LayoutComponent ) {
return null;
}
const productID = product.id || 0;
const keyParts = [ 'layout', name, index, componentId, productID ];
return (
<LayoutComponent
key={ keyParts.join( '_' ) }
{ ...props }
children={ children }
product={ product }
/>
);
} );
};

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import ProductList from './index';
const ProductListContainer = ( { attributes } ) => {
const [ currentPage, setPage ] = useState( 1 );
const [ currentSort, setSort ] = useState( attributes.orderby );
useEffect( () => {
// if default sort is changed in editor
setSort( attributes.orderby );
}, [ attributes.orderby ] );
const onPageChange = ( newPage ) => {
setPage( newPage );
};
const onSortChange = ( event ) => {
const newSortValue = event.target.value;
setSort( newSortValue );
setPage( 1 );
};
return (
<ProductList
attributes={ attributes }
currentPage={ currentPage }
onPageChange={ onPageChange }
onSortChange={ onSortChange }
sortValue={ currentSort }
/>
);
};
ProductListContainer.propTypes = {
attributes: PropTypes.object.isRequired,
};
export default ProductListContainer;

View File

@@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { isEqual } from 'lodash';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Pagination from '@woocommerce/base-components/pagination';
import ProductSortSelect from '@woocommerce/base-components/product-sort-select';
import ProductListItem from '@woocommerce/base-components/product-list-item';
import { useEffect } from '@wordpress/element';
import {
usePrevious,
useStoreProducts,
useSynchronizedQueryState,
useQueryStateByKey,
} from '@woocommerce/base-hooks';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
/**
* Internal dependencies
*/
import './style.scss';
import NoProducts from './no-products';
import NoMatchingProducts from './no-matching-products';
const generateQuery = ( { sortValue, currentPage, attributes } ) => {
const { columns, rows } = attributes;
const getSortArgs = ( orderName ) => {
switch ( orderName ) {
case 'menu_order':
case 'popularity':
case 'rating':
case 'price':
return {
orderby: orderName,
order: 'asc',
};
case 'price-desc':
return {
orderby: 'price',
order: 'desc',
};
case 'date':
return {
orderby: 'date',
order: 'desc',
};
}
};
return {
...getSortArgs( sortValue ),
per_page: columns * rows,
page: currentPage,
};
};
/**
* Given a query state, returns the same query without the attributes related to
* pagination and sorting.
*
* @param {Object} query Query to extract the attributes from.
*
* @return {Object} Same query without pagination and sorting attributes.
*/
const extractPaginationAndSortAttributes = ( query ) => {
/* eslint-disable-next-line no-unused-vars, camelcase */
const { order, orderby, page, per_page, ...totalQuery } = query;
return totalQuery;
};
const ProductList = ( {
attributes,
currentPage,
onPageChange,
onSortChange,
sortValue,
scrollToTop,
} ) => {
const [ queryState ] = useSynchronizedQueryState(
generateQuery( {
attributes,
sortValue,
currentPage,
} )
);
const results = useStoreProducts( queryState );
const { products, productsLoading } = results;
const totalProducts = parseInt( results.totalProducts );
const { layoutStyleClassPrefix } = useProductLayoutContext();
const totalQuery = extractPaginationAndSortAttributes( queryState );
// These are possible filters.
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
'attributes',
[]
);
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
// Only update previous query totals if the query is different and
// the total number of products is a finite number.
const previousQueryTotals = usePrevious(
{ totalQuery, totalProducts },
(
{ totalQuery: nextQuery, totalProducts: nextProducts },
{ totalQuery: currentQuery } = {}
) =>
! isEqual( nextQuery, currentQuery ) &&
Number.isFinite( nextProducts )
);
const isPreviousTotalQueryEqual =
typeof previousQueryTotals === 'object' &&
isEqual( totalQuery, previousQueryTotals.totalQuery );
useEffect( () => {
// If query state (excluding pagination/sorting attributes) changed,
// reset pagination to the first page.
if ( ! isPreviousTotalQueryEqual ) {
onPageChange( 1 );
}
}, [ queryState ] );
const onPaginationChange = ( newPage ) => {
scrollToTop( { focusableSelector: 'a, button' } );
onPageChange( newPage );
};
const getClassnames = () => {
const { columns, rows, alignButtons, align } = attributes;
const alignClass = typeof align !== 'undefined' ? 'align' + align : '';
return classnames(
layoutStyleClassPrefix,
alignClass,
'has-' + columns + '-columns',
{
'has-multiple-rows': rows > 1,
'has-aligned-buttons': alignButtons,
}
);
};
const { contentVisibility } = attributes;
const perPage = attributes.columns * attributes.rows;
const totalPages =
! Number.isFinite( totalProducts ) && isPreviousTotalQueryEqual
? Math.ceil( previousQueryTotals.totalProducts / perPage )
: Math.ceil( totalProducts / perPage );
const listProducts = products.length
? products
: Array.from( { length: perPage } );
const hasProducts = products.length !== 0 || productsLoading;
const hasFilters =
productAttributes.length > 0 ||
Number.isFinite( minPrice ) ||
Number.isFinite( maxPrice );
return (
<div className={ getClassnames() }>
{ contentVisibility.orderBy && hasProducts && (
<ProductSortSelect
onChange={ onSortChange }
value={ sortValue }
/>
) }
{ ! hasProducts && hasFilters && (
<NoMatchingProducts
resetCallback={ () => {
setProductAttributes( [] );
setMinPrice( null );
setMaxPrice( null );
} }
/>
) }
{ ! hasProducts && ! hasFilters && <NoProducts /> }
{ hasProducts && (
<ul className={ `${ layoutStyleClassPrefix }__products` }>
{ listProducts.map( ( product = {}, i ) => (
<ProductListItem
key={ product.id || i }
attributes={ attributes }
product={ product }
/>
) ) }
</ul>
) }
{ totalPages > 1 && (
<Pagination
currentPage={ currentPage }
onPageChange={ onPaginationChange }
totalPages={ totalPages }
/>
) }
</div>
);
};
ProductList.propTypes = {
attributes: PropTypes.object.isRequired,
// From withScrollToTop.
scrollToTop: PropTypes.func,
};
export default withScrollToTop( ProductList );

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const NoMatchingProducts = ( { resetCallback = () => {} } ) => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
return (
<div className={ `${ layoutStyleClassPrefix }__no-products` }>
<img
src={ WC_BLOCKS_ASSET_URL + 'img/no-matching-products.svg' }
alt={ __( 'No products', 'woocommerce' ) }
className={ `${ layoutStyleClassPrefix }__no-products-image` }
/>
<strong
className={ `${ layoutStyleClassPrefix }__no-products-title` }
>
{ __( 'No products found', 'woocommerce' ) }
</strong>
<p
className={ `${ layoutStyleClassPrefix }__no-products-description` }
>
{ __(
'We were unable to find any results based on your search.',
'woocommerce'
) }
</p>
<button onClick={ resetCallback }>
{ __( 'Reset Search', 'woocommerce' ) }
</button>
</div>
);
};
export default NoMatchingProducts;

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
const NoProducts = () => {
const { layoutStyleClassPrefix } = useProductLayoutContext();
return (
<div className={ `${ layoutStyleClassPrefix }__no-products` }>
<img
src={ WC_BLOCKS_ASSET_URL + 'img/no-products.svg' }
alt={ __( 'No products', 'woocommerce' ) }
className={ `${ layoutStyleClassPrefix }__no-products-image` }
/>
<strong
className={ `${ layoutStyleClassPrefix }__no-products-title` }
>
{ __( 'No products', 'woocommerce' ) }
</strong>
<p
className={ `${ layoutStyleClassPrefix }__no-products-description` }
>
{ __(
'There are currently no products available to display.',
'woocommerce'
) }
</p>
</div>
);
};
export default NoProducts;

View File

@@ -0,0 +1,371 @@
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wc-block-grid {
text-align: center;
}
.wc-block-grid__no-products {
padding: $gap-largest;
text-align: center;
width: 100%;
.wc-block-grid__no-products-image {
max-width: 150px;
margin: 0 auto 1em;
display: block;
}
.wc-block-grid__no-products-title {
display: block;
margin: 0;
font-weight: bold;
}
.wc-block-grid__no-products-description {
display: block;
margin: 0.25em 0 1em 0;
}
}
.wc-block-grid__products {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 0;
margin: 0 (-$gap/2) $gap;
background-clip: padding-box;
}
.wc-block-grid__product {
box-sizing: border-box;
padding: 0;
margin: 0;
float: none;
width: auto;
position: relative;
text-align: center;
border-left: $gap/2 solid transparent;
border-right: $gap/2 solid transparent;
border-bottom: $gap solid transparent;
}
// Extra specificity to avoid editor styles on linked images.
.entry-content .wc-block-grid__product-image,
.wc-block-grid__product-image {
text-decoration: none;
display: block;
position: relative;
a {
text-decoration: none;
border: 0;
outline: 0;
box-shadow: none;
}
.wc-block-grid__product-image__image {
vertical-align: middle;
width: 100%;
&[hidden] {
display: none;
}
.is-loading & {
@include placeholder();
height: 0;
padding-bottom: 100%;
}
}
}
.edit-post-visual-editor .editor-block-list__block .wc-block-grid__product-title,
.editor-styles-wrapper .wc-block-grid__product-title,
.wc-block-grid__product-title {
line-height: 1.2em;
font-weight: 700;
padding: 0;
color: inherit;
font-size: inherit;
display: block;
.is-loading &::before {
@include placeholder();
content: ".";
display: inline-block;
width: 6em;
}
}
.wc-block-grid__product-price {
display: block;
.wc-block-grid__product-price__regular {
font-size: 0.8em;
line-height: 1;
color: #aaa;
margin-top: -0.25em;
display: block;
}
.wc-block-grid__product-price__value {
letter-spacing: -1px;
font-weight: 600;
display: block;
font-size: 1.25em;
line-height: 1.25;
color: #000;
span {
white-space: nowrap;
}
.is-loading &::before {
@include placeholder();
content: ".";
display: inline-block;
width: 3em;
}
}
}
.wc-block-grid__product-add-to-cart {
word-break: break-word;
white-space: normal;
a,
button {
word-break: break-word;
white-space: normal;
margin: 0 auto !important;
display: inline-flex;
flex-direction: column;
justify-content: center;
&.loading {
opacity: 0.25;
}
&::after {
margin-left: 0.5em;
display: inline-block;
}
&.added::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e017";
}
&.loading::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e031";
animation: spin 2s linear infinite;
}
.is-loading & {
@include placeholder();
min-width: 7em;
}
}
}
.wc-block-grid__product-rating {
display: block;
.wc-block-grid__product-rating__stars {
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
font-family: star; /* stylelint-disable-line */
font-weight: 400;
display: -block;
margin: 0 auto;
text-align: left;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: #aaa;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: #000;
}
}
}
.editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-grid__product-onsale,
.wc-block-grid__product-onsale {
border: 1px solid #43454b;
color: #43454b;
background: #fff;
padding: 0.202em 0.6180469716em;
font-size: 0.875rem;
text-align: center;
text-transform: uppercase;
font-weight: 600;
display: inline-block;
width: auto;
border-radius: 3px;
z-index: 9;
position: relative;
margin: $gap-smaller auto;
}
.editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-grid__product-image,
.wc-block-grid__product-image {
.wc-block-grid__product-onsale {
&.wc-block-grid__product-onsale--alignleft {
position: absolute;
left: $gap-smaller/2;
top: $gap-smaller/2;
right: auto;
margin: 0;
}
&.wc-block-grid__product-onsale--aligncenter {
position: absolute;
top: $gap-smaller/2;
left: 50%;
transform: translateX(-50%);
margin: 0;
}
&.wc-block-grid__product-onsale--alignright {
position: absolute;
right: $gap-smaller/2;
top: $gap-smaller/2;
left: auto;
margin: 0;
}
}
}
// Element spacing.
.wc-block-grid__product {
.wc-block-grid__product-image,
.wc-block-grid__product-title,
.wc-block-grid__product-price,
.wc-block-grid__product-rating {
margin-top: 0;
margin-bottom: $gap-small;
}
}
.wc-block-grid {
&.has-aligned-buttons {
.wc-block-grid__product {
display: flex;
flex-direction: column;
}
.wc-block-grid__product-add-to-cart {
margin-top: auto !important;
}
}
@for $i from 1 to 9 {
&.has-#{$i}-columns .wc-block-grid__product {
flex: 1 0 calc(#{ 100% / $i });
max-width: 100% / $i;
}
}
&.has-4-columns:not(.alignwide):not(.alignfull),
&.has-5-columns:not(.alignfull),
&.has-6-columns:not(.alignfull),
&.has-7-columns,
&.has-8-columns {
.wc-block-grid__product {
font-size: 0.8em;
}
}
}
// Responsive media styles.
@include breakpoint( "<480px" ) {
.wc-block-grid {
@for $i from 2 to 9 {
&.has-#{$i}-columns {
.wc-block-grid__products {
display: block;
}
.wc-block-grid__product {
margin-left: auto;
margin-right: auto;
flex: 1 0 100%;
max-width: 100%;
padding: 0;
}
}
}
.wc-block-grid__product-image img {
width: 100%;
}
}
}
@include breakpoint( "480px-600px" ) {
.wc-block-grid {
@for $i from 2 to 9 {
&.has-#{$i}-columns {
.wc-block-grid__product {
flex: 1 0 50%;
max-width: 50%;
padding: 0;
margin: 0 0 $gap-large 0;
}
.wc-block-grid__product:nth-child(odd) {
padding-right: $gap/2;
}
.wc-block-grid__product:nth-child(even) {
padding-left: $gap/2;
.wc-block-grid__product-onsale {
left: $gap/2;
}
}
}
}
.wc-block-grid__product-image img {
width: 100%;
}
}
}
.theme-twentysixteen {
.wc-block-grid {
// Prevent white theme styles.
.price ins {
color: #77a464;
}
}
}
.theme-twentynineteen {
.wc-block-grid__product {
font-size: 0.88889em;
}
// Change the title font to match headings.
.wc-block-grid__product-title,
.wc-block-grid__product-onsale {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.wc-block-grid__product-title::before {
display: none;
}
.wc-block-grid__product-onsale {
line-height: 1;
}
}

View File

@@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import SortSelect from '@woocommerce/base-components/sort-select';
/**
* Internal dependencies
*/
import './style.scss';
const ProductSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
return (
<SortSelect
className="wc-block-product-sort-select"
defaultValue={ defaultValue }
name="orderby"
onChange={ onChange }
options={ [
{
key: 'menu_order',
label: __(
'Default sorting',
'woocommerce'
),
},
{
key: 'popularity',
label: __( 'Popularity', 'woocommerce' ),
},
{
key: 'rating',
label: __(
'Average rating',
'woocommerce'
),
},
{
key: 'date',
label: __( 'Latest', 'woocommerce' ),
},
{
key: 'price',
label: __(
'Price: low to high',
'woocommerce'
),
},
{
key: 'price-desc',
label: __(
'Price: high to low',
'woocommerce'
),
},
] }
readOnly={ readOnly }
screenReaderLabel={ __(
'Order products by',
'woocommerce'
) }
value={ value }
/>
);
};
ProductSortSelect.propTypes = {
defaultValue: PropTypes.oneOf( [
'menu_order',
'popularity',
'rating',
'date',
'price',
'price-desc',
] ),
onChange: PropTypes.func,
readOnly: PropTypes.bool,
value: PropTypes.oneOf( [
'menu_order',
'popularity',
'rating',
'date',
'price',
'price-desc',
] ),
};
export default ProductSortSelect;

View File

@@ -0,0 +1,4 @@
.wc-block-product-sort-select {
margin-bottom: $gap-large;
text-align: left;
}

View File

@@ -0,0 +1,169 @@
/**
* External dependencies
*/
import React, { createRef, Component } from 'react';
import PropTypes from 'prop-types';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { clampLines } from './utils';
/**
* Show text based content, limited to a number of lines, with a read more link.
*
* Based on https://github.com/zoltantothcom/react-clamp-lines.
*/
class ReadMore extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
/**
* This is true when read more has been pressed and the full review is shown.
*/
isExpanded: false,
/**
* True if we are clamping content. False if the review is short. Null during init.
*/
clampEnabled: null,
/**
* Content is passed in via children.
*/
content: props.children,
/**
* Summary content generated from content HTML.
*/
summary: '.',
};
this.reviewSummary = createRef();
this.reviewContent = createRef();
this.getButton = this.getButton.bind( this );
this.onClick = this.onClick.bind( this );
}
componentDidMount() {
if ( this.props.children ) {
const { maxLines, ellipsis } = this.props;
const lineHeight = this.reviewSummary.current.clientHeight + 1;
const reviewHeight = this.reviewContent.current.clientHeight + 1;
const maxHeight = lineHeight * maxLines + 1;
const clampEnabled = reviewHeight > maxHeight;
this.setState( {
clampEnabled,
} );
if ( clampEnabled ) {
this.setState( {
summary: clampLines(
this.reviewContent.current.innerHTML,
this.reviewSummary.current,
maxHeight,
ellipsis
),
} );
}
}
}
getButton() {
const { isExpanded } = this.state;
const { className, lessText, moreText } = this.props;
const buttonText = isExpanded ? lessText : moreText;
if ( ! buttonText ) {
return;
}
return (
<a
href="#more"
className={ className + '__read_more' }
onClick={ this.onClick }
aria-expanded={ ! isExpanded }
role="button"
>
{ buttonText }
</a>
);
}
/**
* Handles the click event for the read more/less button.
*
* @param {obj} e event
*/
onClick( e ) {
e.preventDefault();
const { isExpanded } = this.state;
this.setState( {
isExpanded: ! isExpanded,
} );
}
render() {
const { className } = this.props;
const { content, summary, clampEnabled, isExpanded } = this.state;
if ( ! content ) {
return null;
}
if ( clampEnabled === false ) {
return (
<div className={ className }>
<div ref={ this.reviewContent }>{ content }</div>
</div>
);
}
return (
<div className={ className }>
{ ( ! isExpanded || clampEnabled === null ) && (
<div
ref={ this.reviewSummary }
aria-hidden={ isExpanded }
dangerouslySetInnerHTML={ {
__html: summary,
} }
/>
) }
{ ( isExpanded || clampEnabled === null ) && (
<div
ref={ this.reviewContent }
aria-hidden={ ! isExpanded }
>
{ content }
</div>
) }
{ this.getButton() }
</div>
);
}
}
ReadMore.propTypes = {
children: PropTypes.node.isRequired,
maxLines: PropTypes.number,
ellipsis: PropTypes.string,
moreText: PropTypes.string,
lessText: PropTypes.string,
className: PropTypes.string,
};
ReadMore.defaultProps = {
maxLines: 3,
ellipsis: '&hellip;',
moreText: __( 'Read more', 'woocommerce' ),
lessText: __( 'Read less', 'woocommerce' ),
className: 'read-more-content',
};
export default ReadMore;

View File

@@ -0,0 +1,34 @@
/**
* Internal dependencies
*/
import { truncateHtml } from '../utils';
const shortContent =
'<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>';
const longContent =
'<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>' +
'<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>';
describe( 'ReadMore Component', () => {
describe( 'Test the truncateHtml function', () => {
it( 'Truncate long HTML content to length of 10', async () => {
const truncatedContent = truncateHtml( longContent, 10 );
expect( truncatedContent ).toEqual( '<p>Lorem ipsum...</p>' );
} );
it( 'Truncate long HTML content, but avoid cutting off HTML tags.', async () => {
const truncatedContent = truncateHtml( longContent, 40 );
expect( truncatedContent ).toEqual(
'<p>Lorem ipsum dolor sit amet, <strong>consectetur...</strong></p>'
);
} );
it( 'No need to truncate short HTML content.', async () => {
const truncatedContent = truncateHtml( shortContent, 100 );
expect( truncatedContent ).toEqual(
'<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>'
);
} );
} );
} );

View File

@@ -0,0 +1,87 @@
/**
* External dependencies
*/
import trimHtml from 'trim-html';
/**
* Truncate some HTML content to a given length.
*
* @param {string} html HTML that will be truncated.
* @param {int} length Legth to truncate the string to.
* @param {string} ellipsis Character to append to truncated content.
*/
export const truncateHtml = ( html, length, ellipsis = '...' ) => {
const trimmed = trimHtml( html, {
suffix: ellipsis,
limit: length,
} );
return trimmed.html;
};
/**
* Clamp lines calculates the height of a line of text and then limits it to the
* value of the lines prop. Content is updated once limited.
*
* @param {string} originalContent Content to be clamped.
* @param {Object} targetElement Element which will contain the clamped content.
* @param {integer} maxHeight Max height of the clamped content.
* @param {string} ellipsis Character to append to clamped content.
* @return {string} clamped content
*/
export const clampLines = (
originalContent,
targetElement,
maxHeight,
ellipsis
) => {
const length = calculateLength( originalContent, targetElement, maxHeight );
return truncateHtml( originalContent, length - ellipsis.length, ellipsis );
};
/**
* Calculate how long the content can be based on the maximum number of lines allowed, and client height.
*
* @param {string} originalContent Content to be clamped.
* @param {Object} targetElement Element which will contain the clamped content.
* @param {integer} maxHeight Max height of the clamped content.
*/
const calculateLength = ( originalContent, targetElement, maxHeight ) => {
let markers = {
start: 0,
middle: 0,
end: originalContent.length,
};
while ( markers.start <= markers.end ) {
markers.middle = Math.floor( ( markers.start + markers.end ) / 2 );
// We set the innerHTML directly in the DOM here so we can reliably check the clientHeight later in moveMarkers.
targetElement.innerHTML = truncateHtml(
originalContent,
markers.middle
);
markers = moveMarkers( markers, targetElement.clientHeight, maxHeight );
}
return markers.middle;
};
/**
* Move string markers. Used by calculateLength.
*
* @param {Object} markers Markers for clamped content.
* @param {integer} currentHeight Current height of clamped content.
* @param {integer} maxHeight Max height of the clamped content.
*/
const moveMarkers = ( markers, currentHeight, maxHeight ) => {
if ( currentHeight <= maxHeight ) {
markers.start = markers.middle + 1;
} else {
markers.end = markers.middle - 1;
}
return markers;
};

View File

@@ -0,0 +1,213 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ReadMore from '@woocommerce/base-components/read-more';
/**
* Internal dependencies
*/
import './style.scss';
function getReviewImage( review, imageType, isLoading ) {
if ( isLoading || ! review ) {
return (
<div
className="wc-block-review-list-item__image"
width="48"
height="48"
/>
);
}
return (
<div className="wc-block-review-list-item__image">
{ imageType === 'product' ? (
<img
aria-hidden="true"
alt=""
src={ review.product_picture || '' }
className="wc-block-review-list-item__image"
width="48"
height="48"
/>
) : (
<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"
/>
) }
{ review.verified && (
<div
className="wc-block-review-list-item__verified"
title={ __(
'Verified buyer',
'woocommerce'
) }
>
{ __( 'Verified buyer', 'woocommerce' ) }
</div>
) }
</div>
);
}
function getReviewContent( review ) {
return (
<ReadMore
maxLines={ 10 }
moreText={ __(
'Read full review',
'woocommerce'
) }
lessText={ __(
'Hide full review',
'woocommerce'
) }
className="wc-block-review-list-item__text"
>
<div
dangerouslySetInnerHTML={ {
// `content` is the `review` parameter returned by the `reviews` endpoint.
// It's filtered with `wp_filter_post_kses()`, which removes dangerous HTML tags,
// so using it inside `dangerouslySetInnerHTML` is safe.
__html: review.review || '',
} }
/>
</ReadMore>
);
}
function getReviewProductName( review ) {
return (
<div className="wc-block-review-list-item__product">
<a
href={ review.product_permalink }
dangerouslySetInnerHTML={ {
// `product_name` might have html entities for things like
// emdash. So to display properly we need to allow the
// browser to render.
__html: review.product_name,
} }
/>
</div>
);
}
function getReviewerName( review ) {
const { reviewer = '' } = review;
return (
<div className="wc-block-review-list-item__author">{ reviewer }</div>
);
}
function getReviewDate( review ) {
const {
date_created: dateCreated,
formatted_date_created: formattedDateCreated,
} = review;
return (
<time
className="wc-block-review-list-item__published-date"
dateTime={ dateCreated }
>
{ formattedDateCreated }
</time>
);
}
function getReviewRating( review ) {
const { rating } = review;
const starStyle = {
width: ( rating / 5 ) * 100 + '%' /* stylelint-disable-line */,
};
return (
<div className="wc-block-review-list-item__rating">
<div
className="wc-block-review-list-item__rating__stars"
role="img"
>
<span style={ starStyle }>
{ sprintf(
__(
'Rated %d out of 5',
'woocommerce'
),
rating
) }
</span>
</div>
</div>
);
}
const ReviewListItem = ( { attributes, review = {} } ) => {
const {
imageType,
showReviewDate,
showReviewerName,
showReviewImage,
showReviewRating: showReviewRatingAttr,
showReviewContent,
showProductName,
} = attributes;
const { rating } = review;
const isLoading = ! Object.keys( review ).length > 0;
const showReviewRating = Number.isFinite( rating ) && showReviewRatingAttr;
return (
<li
className={ classNames( 'wc-block-review-list-item__item', {
'is-loading': isLoading,
} ) }
aria-hidden={ isLoading }
>
{ ( showProductName ||
showReviewDate ||
showReviewerName ||
showReviewImage ||
showReviewRating ) && (
<div className="wc-block-review-list-item__info">
{ showReviewImage &&
getReviewImage( review, imageType, isLoading ) }
{ ( showProductName ||
showReviewerName ||
showReviewRating ||
showReviewDate ) && (
<div className="wc-block-review-list-item__meta">
{ showReviewRating && getReviewRating( review ) }
{ showProductName &&
getReviewProductName( review ) }
{ showReviewerName && getReviewerName( review ) }
{ showReviewDate && getReviewDate( review ) }
</div>
) }
</div>
) }
{ showReviewContent && getReviewContent( review ) }
</li>
);
};
ReviewListItem.propTypes = {
attributes: PropTypes.object.isRequired,
review: PropTypes.object,
};
/**
* BE AWARE. ReviewListItem expects product data that is equivalent to what is
* made available for output in a public view. Thus content that may contain
* html data is not sanitized further.
*
* Currently the following data is trusted (assumed to already be sanitized):
* - `review.review` (review content)
* - `review.product_name` (the product title)
*/
export default ReviewListItem;

View File

@@ -0,0 +1,197 @@
.is-loading {
.wc-block-review-list-item__text {
@include placeholder();
display: block;
width: 60%;
}
.wc-block-review-list-item__info {
.wc-block-review-list-item__image {
@include placeholder();
}
.wc-block-review-list-item__meta {
.wc-block-review-list-item__author {
@include placeholder();
font-size: 1em;
width: 80px;
}
.wc-block-review-list-item__product {
display: none;
}
.wc-block-review-list-item__rating {
.wc-block-review-list-item__rating__stars > span {
display: none;
}
}
}
.wc-block-review-list-item__published-date {
@include placeholder();
height: 1em;
width: 120px;
}
}
}
.editor-styles-wrapper .wc-block-review-list-item__item,
.wc-block-review-list-item__item {
margin: 0 0 $gap-large * 2;
list-style: none;
}
.wc-block-review-list-item__info {
display: grid;
grid-template-columns: 1fr;
margin-bottom: $gap-large;
}
.wc-block-review-list-item__meta {
grid-column: 1;
grid-row: 1;
}
.has-image {
.wc-block-review-list-item__info {
grid-template-columns: #{48px + $gap} 1fr;
}
.wc-block-review-list-item__meta {
grid-column: 2;
}
}
.wc-block-review-list-item__image {
height: 48px;
grid-column: 1;
grid-row: 1 / 3;
width: 48px;
position: relative;
img {
width: 100%;
height: 100%;
display: block;
}
}
.wc-block-review-list-item__verified {
width: 21px;
height: 21px;
text-indent: 21px;
margin: 0;
line-height: 21px;
overflow: hidden;
position: absolute;
right: -7px;
bottom: -7px;
&::before {
width: 21px;
height: 21px;
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 */
display: block;
content: "";
}
}
.wc-block-review-list-item__meta {
display: flex;
align-items: center;
flex-flow: row wrap;
&::after {
// Force wrap after star rating.
order: 3;
content: "";
flex-basis: 100%;
}
}
.wc-block-review-list-item__product {
display: block;
font-weight: bold;
order: 1;
margin-right: $gap/2;
}
.wc-block-review-list-item__author {
display: block;
font-weight: bold;
order: 1;
margin-right: $gap/2;
}
.wc-block-review-list-item__product + .wc-block-review-list-item__author {
font-weight: normal;
color: #808080;
order: 4;
}
.wc-block-review-list-item__published-date {
color: #808080;
order: 5;
}
.wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
&::before {
content: "";
display: inline-block;
margin-right: $gap/2;
border-right: 1px solid #ddd;
height: 1em;
vertical-align: middle;
}
}
.wc-block-review-list-item__author:first-child + .wc-block-review-list-item__published-date,
.wc-block-review-list-item__rating + .wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
&::before {
display: none;
}
}
.wc-block-review-list-item__rating {
order: 2;
> .wc-block-review-list-item__rating__stars {
display: inline-block;
top: 0;
overflow: hidden;
position: relative;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
width: 5.3em;
font-family: star; /* stylelint-disable-line */
font-weight: 400;
vertical-align: top;
}
> .wc-block-review-list-item__rating__stars::before {
content: "\53\53\53\53\53";
opacity: 0.25;
float: left;
top: 0;
left: 0;
position: absolute;
}
> .wc-block-review-list-item__rating__stars span {
overflow: hidden;
float: left;
top: 0;
left: 0;
position: absolute;
padding-top: 1.5em;
}
> .wc-block-review-list-item__rating__stars span::before {
content: "\53\53\53\53\53";
top: 0;
position: absolute;
left: 0;
color: #e6a237;
}
}

View File

@@ -0,0 +1,50 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import {
ENABLE_REVIEW_RATING,
SHOW_AVATARS,
} from '@woocommerce/block-settings';
import ReviewListItem from '@woocommerce/base-components/review-list-item';
/**
* Internal dependencies
*/
import './style.scss';
const ReviewList = ( { attributes, reviews } ) => {
const showReviewImage =
( SHOW_AVATARS || attributes.imageType === 'product' ) &&
attributes.showReviewImage;
const showReviewRating =
ENABLE_REVIEW_RATING && attributes.showReviewRating;
const attrs = {
...attributes,
showReviewImage,
showReviewRating,
};
return (
<ul className="wc-block-review-list">
{ reviews.length === 0 ? (
<ReviewListItem attributes={ attrs } />
) : (
reviews.map( ( review, i ) => (
<ReviewListItem
key={ review.id || i }
attributes={ attrs }
review={ review }
/>
) )
) }
</ul>
);
};
ReviewList.propTypes = {
attributes: PropTypes.object.isRequired,
reviews: PropTypes.array.isRequired,
};
export default ReviewList;

View File

@@ -0,0 +1,4 @@
.wc-block-review-list,
.editor-styles .wc-block-review-list {
margin: 0;
}

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import SortSelect from '@woocommerce/base-components/sort-select';
/**
* Internal dependencies
*/
import './style.scss';
const ReviewSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
return (
<SortSelect
className="wc-block-review-sort-select"
defaultValue={ defaultValue }
label={ __( 'Order by', 'woocommerce' ) }
onChange={ onChange }
options={ [
{
key: 'most-recent',
label: __( 'Most recent', 'woocommerce' ),
},
{
key: 'highest-rating',
label: __(
'Highest rating',
'woocommerce'
),
},
{
key: 'lowest-rating',
label: __(
'Lowest rating',
'woocommerce'
),
},
] }
readOnly={ readOnly }
screenReaderLabel={ __(
'Order reviews by',
'woocommerce'
) }
value={ value }
/>
);
};
ReviewSortSelect.propTypes = {
defaultValue: PropTypes.oneOf( [
'most-recent',
'highest-rating',
'lowest-rating',
] ),
onChange: PropTypes.func,
readOnly: PropTypes.bool,
value: PropTypes.oneOf( [
'most-recent',
'highest-rating',
'lowest-rating',
] ),
};
export default ReviewSortSelect;

View File

@@ -0,0 +1,3 @@
.wc-block-review-sort-select {
text-align: right;
}

View File

@@ -0,0 +1,77 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Label from '@woocommerce/base-components/label';
import withComponentId from '@woocommerce/base-hocs/with-component-id';
/**
* Internal dependencies
*/
import './style.scss';
/**
* Component used for 'Order by' selectors, which renders a label
* and a <select> with the options provided in the props.
*/
const SortSelect = ( {
className,
componentId,
defaultValue,
label,
onChange,
options,
screenReaderLabel,
readOnly,
value,
} ) => {
const selectId = `wc-block-sort-select__select-${ componentId }`;
return (
<div className={ classNames( 'wc-block-sort-select', className ) }>
<Label
label={ label }
screenReaderLabel={ screenReaderLabel }
wrapperElement="label"
wrapperProps={ {
className: 'wc-block-sort-select__label',
htmlFor: selectId,
} }
/>
<select // eslint-disable-line jsx-a11y/no-onchange
id={ selectId }
className="wc-block-sort-select__select"
defaultValue={ defaultValue }
onChange={ onChange }
readOnly={ readOnly }
value={ value }
>
{ options.map( ( option ) => (
<option key={ option.key } value={ option.key }>
{ option.label }
</option>
) ) }
</select>
</div>
);
};
SortSelect.propTypes = {
defaultValue: PropTypes.string,
label: PropTypes.string,
onChange: PropTypes.func,
options: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
} )
),
readOnly: PropTypes.bool,
screenReaderLabel: PropTypes.string,
value: PropTypes.string,
// from withComponentId
componentId: PropTypes.number.isRequired,
};
export default withComponentId( SortSelect );

View File

@@ -0,0 +1,9 @@
.wc-block-sort-select {
margin-bottom: $gap-small;
}
.wc-block-sort-select__label {
margin-right: $gap-small;
display: inline-block;
font-weight: normal;
}

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { createContext, useContext, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { assertValidContextValue } from './utils';
const validationMap = {
parentName: {
required: true,
type: 'string',
},
};
/**
* This context is a configuration object used for connecting
* all children blocks in a given tree contained in the context with information
* about the parent block. Typically this is used for extensibility features.
*
* @var {React.Context} InnerBlockConfigurationContext A react context object
*/
const InnerBlockConfigurationContext = createContext( { parentName: null } );
export const useInnerBlockConfigurationContext = () =>
useContext( InnerBlockConfigurationContext );
export const InnerBlockConfigurationProvider = ( { value, children } ) => {
useEffect( () => {
assertValidContextValue(
'InnerBlockConfigurationProvider',
validationMap,
value
);
}, [ value ] );
return (
<InnerBlockConfigurationContext.Provider value={ value }>
{ children }
</InnerBlockConfigurationContext.Provider>
);
};

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { createContext, useContext, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { assertValidContextValue } from './utils';
const validationMap = {
layoutStyleClassPrefix: {
required: true,
type: 'string',
},
};
/**
* ProductLayoutContext is an configuration object for layout options shared
* among all components in a tree.
*
* @var {React.Context} ProductLayoutContext A react context object
*/
const ProductLayoutContext = createContext( {
layoutStyleClassPrefix: '',
} );
export const useProductLayoutContext = () => useContext( ProductLayoutContext );
export const ProductLayoutContextProvider = ( { value, children } ) => {
useEffect( () => {
assertValidContextValue(
'ProductLayoutContextProvider',
validationMap,
value
);
}, [ value ] );
return (
<ProductLayoutContext.Provider value={ value }>
{ children }
</ProductLayoutContext.Provider>
);
};

View File

@@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Query state context is the index for used for a query state store. By
* exposing this via context, it allows for all children blocks to be
* synchronized to the same query state defined by the parent in the tree.
*
* Defaults to 'page' for general global querystate shared among all blocks
* in a view.
*
* @var {React.Context} QueryStateContext A react context object
*/
const QueryStateContext = createContext( 'page' );
export const useQueryStateContext = () => useContext( QueryStateContext );
export const QueryStateContextProvider = QueryStateContext.Provider;

View File

@@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import { assertValidContextValue } from '../utils';
describe( 'assertValidContextValue', () => {
const contextName = 'testContext';
const validationMap = {
cheeseburger: {
required: false,
type: 'string',
},
amountKetchup: {
required: true,
type: 'number',
},
};
it.each`
testValue | expectedMessage | expectError
${{}} | ${'expected'} | ${true}
${10} | ${'expected'} | ${true}
${{ amountKetchup: 20 }} | ${'not expected'} | ${false}
${{ amountKetchup: '10' }} | ${'expected'} | ${true}
${{ cheeseburger: 'fries', amountKetchup: 20 }} | ${'not expected'} | ${false}
`(
'The value of $testValue is $expectedMessage to trigger an Error',
( { testValue, expectError } ) => {
const invokeTest = () => {
assertValidContextValue(
contextName,
validationMap,
testValue
);
};
if ( expectError ) {
expect( invokeTest ).toThrow();
} else {
expect( invokeTest ).not.toThrow();
}
}
);
} );

View File

@@ -0,0 +1,61 @@
/**
* This is an assertion utility for validating that the incoming value prop
* value on a given context provider is valid and throws an error if it isn't.
*
* Note: this asserts values that are expected to be an object.
*
* The validationMap is expected to be an object in the following shape.
*
* {
* [expectedPropertyName<String>]: {
* required: [expectedRequired<Boolean>]
* type: [expectedType<String>]
* }
* }
*
* @param {string} contextName The name of the context provider being
* validated.
* @param {Object} validationMap A map for validating the incoming value against.
* @param {Object} value The value being validated.
*
* @throws {Error}
*/
export const assertValidContextValue = (
contextName,
validationMap,
value
) => {
if ( typeof value !== 'object' ) {
throw new Error(
`${ contextName } expects an object for its context value`
);
}
const errors = [];
for ( const expectedProperty in validationMap ) {
if (
validationMap[ expectedProperty ].required &&
typeof value[ expectedProperty ] === 'undefined'
) {
errors.push(
`The ${ expectedProperty } is required and is not present.`
);
} else if (
typeof value[ expectedProperty ] !== 'undefined' &&
typeof value[ expectedProperty ] !==
validationMap[ expectedProperty ].type
) {
errors.push(
`The ${ expectedProperty } must be of ${
validationMap[ expectedProperty ].type
} and instead was ${ typeof value[ expectedProperty ] }`
);
}
}
if ( errors.length > 0 ) {
throw new Error(
`There was a problem with the value passed in on ${ contextName }:\n ${ errors.join(
'\n'
) }`
);
}
};

View File

@@ -0,0 +1,36 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withComponentId from '../with-component-id';
const TestComponent = withComponentId( ( props ) => {
return <div componentId={ props.componentId } />;
} );
const render = () => {
return TestRenderer.create( <TestComponent /> );
};
describe( 'withComponentId Component', () => {
let renderer;
it( 'initializes with expected id on initial render', () => {
renderer = render();
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 0 );
} );
it( 'does not increment on re-render', () => {
renderer.update( <TestComponent someValue={ 3 } /> );
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 0 );
} );
it( 'increments on a new component instance', () => {
renderer.update( <TestComponent key={ 42 } /> );
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 1 );
} );
} );

View File

@@ -0,0 +1,140 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withProducts from '../with-products';
import * as mockUtils from '../utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../utils', () => ( {
getProducts: jest.fn(),
} ) );
jest.mock( '../../utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProducts = [ { id: 10, name: 'foo' }, { id: 20, name: 'bar' } ];
const defaultArgs = {
orderby: 'menu_order',
order: 'asc',
per_page: 9,
page: 1,
};
const TestComponent = withProducts( ( props ) => {
return (
<div
error={ props.error }
getProducts={ props.getProducts }
appendReviews={ props.appendReviews }
onChangeArgs={ props.onChangeArgs }
isLoading={ props.isLoading }
products={ props.products }
totalProducts={ props.totalProducts }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ {
columns: 3,
rows: 3,
} }
currentPage={ 1 }
sortValue="menu_order"
productId={ 1 }
productsToDisplay={ 2 }
/>
);
};
describe( 'withProducts Component', () => {
let renderer;
afterEach( () => {
mockUtils.getProducts.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getProducts
.mockImplementationOnce( () =>
Promise.resolve( {
products: mockProducts.slice( 0, 2 ),
totalProducts: mockProducts.length,
} )
)
.mockImplementationOnce( () =>
Promise.resolve( {
products: mockProducts.slice( 2, 3 ),
totalProducts: mockProducts.length,
} )
);
renderer = render();
} );
it( 'getProducts is called on mount', () => {
const { getProducts } = mockUtils;
expect( getProducts ).toHaveBeenCalledWith( defaultArgs );
expect( getProducts ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getProducts.mockImplementation( () =>
Promise.resolve( {
products: mockProducts,
totalProducts: mockProducts.length,
} )
);
renderer = render();
} );
it( 'sets products based on API response', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.products ).toEqual( mockProducts );
expect( props.totalProducts ).toEqual( mockProducts.length );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProducts.mockImplementation(
() => getProductsPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getProductsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.products ).toEqual( [] );
expect( props.totalProducts ).toEqual( 0 );
done();
} );
} );
} );
} );

View File

@@ -0,0 +1,162 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withReviews from '../with-reviews';
import * as mockUtils from '../../../blocks/reviews/utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../../../blocks/reviews/utils', () => ( {
getSortArgs: () => ( {
order: 'desc',
orderby: 'date_gmt',
} ),
getReviews: jest.fn(),
} ) );
jest.mock( '../../utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockReviews = [
{ reviewer: 'Alice', review: 'Lorem ipsum', rating: 2 },
{ reviewer: 'Bob', review: 'Dolor sit amet', rating: 3 },
{ reviewer: 'Carol', review: 'Consectetur adipiscing elit', rating: 5 },
];
const defaultArgs = {
offset: 0,
order: 'desc',
orderby: 'date_gmt',
per_page: 2,
product_id: 1,
};
const TestComponent = withReviews( ( props ) => {
return (
<div
error={ props.error }
getReviews={ props.getReviews }
appendReviews={ props.appendReviews }
onChangeArgs={ props.onChangeArgs }
isLoading={ props.isLoading }
reviews={ props.reviews }
totalReviews={ props.totalReviews }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ {} }
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 2 }
/>
);
};
describe( 'withReviews Component', () => {
let renderer;
afterEach( () => {
mockUtils.getReviews.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getReviews
.mockImplementationOnce( () =>
Promise.resolve( {
reviews: mockReviews.slice( 0, 2 ),
totalReviews: mockReviews.length,
} )
)
.mockImplementationOnce( () =>
Promise.resolve( {
reviews: mockReviews.slice( 2, 3 ),
totalReviews: mockReviews.length,
} )
);
renderer = render();
} );
it( 'getReviews is called on mount with default args', () => {
const { getReviews } = mockUtils;
expect( getReviews ).toHaveBeenCalledWith( defaultArgs );
expect( getReviews ).toHaveBeenCalledTimes( 1 );
} );
it( 'getReviews is called on component update', () => {
const { getReviews } = mockUtils;
renderer.update(
<TestComponent
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 3 }
/>
);
expect( getReviews ).toHaveBeenNthCalledWith( 2, {
...defaultArgs,
offset: 2,
per_page: 1,
} );
expect( getReviews ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getReviews.mockImplementation( () =>
Promise.resolve( {
reviews: mockReviews.slice( 0, 2 ),
totalReviews: mockReviews.length,
} )
);
renderer = render();
} );
it( 'sets reviews based on API response', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( mockReviews.slice( 0, 2 ) );
expect( props.totalReviews ).toEqual( mockReviews.length );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getReviewsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getReviews.mockImplementation( () => getReviewsPromise );
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getReviewsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( [] );
done();
} );
} );
} );
} );

View File

@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
export const getProducts = ( queryArgs ) => {
const args = {
catalog_visibility: 'visible',
status: 'publish',
...queryArgs,
};
return apiFetch( {
path:
'/wc/blocks/products?' +
Object.entries( args )
.map( ( arg ) => arg.join( '=' ) )
.join( '&' ),
parse: false,
} ).then( ( response ) => {
return response.json().then( ( products ) => {
const totalProducts = parseInt(
response.headers.get( 'x-wp-total' ),
10
);
return { products, totalProducts };
} );
} );
};

View File

@@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { Component } from 'react';
/**
* HOC that gives a component a unique ID.
*
* This is an alternative for withInstanceId from @wordpress/compose to avoid
* using that dependency on the frontend.
*/
const withComponentId = ( OriginalComponent ) => {
let instances = 0;
class WrappedComponent extends Component {
instanceId = instances++;
render() {
return (
<OriginalComponent
{ ...this.props }
componentId={ this.instanceId }
/>
);
}
}
WrappedComponent.displayName = 'withComponentId';
return WrappedComponent;
};
export default withComponentId;

View File

@@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { Component } from 'react';
/**
* Internal dependencies
*/
import { getProducts } from './utils';
import { formatError } from '../utils/errors.js';
const withProducts = ( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
products: [],
error: null,
loading: true,
totalProducts: 0,
};
this.loadProducts = this.loadProducts.bind( this );
}
componentDidMount() {
this.loadProducts();
}
componentDidUpdate( prevProps ) {
if (
prevProps.currentPage !== this.props.currentPage ||
prevProps.sortValue !== this.props.sortValue ||
prevProps.attributes.columns !==
this.props.attributes.columns ||
prevProps.attributes.rows !== this.props.attributes.rows
) {
this.loadProducts();
}
}
getSortArgs( orderName ) {
switch ( orderName ) {
case 'menu_order':
case 'popularity':
case 'rating':
case 'date':
case 'price':
return {
orderby: orderName,
order: 'asc',
};
case 'price-desc':
return {
orderby: 'price',
order: 'desc',
};
}
}
loadProducts() {
const { attributes, currentPage, sortValue } = this.props;
this.setState( { loading: true, products: [] } );
const args = {
...this.getSortArgs( sortValue ),
per_page: attributes.columns * attributes.rows,
page: currentPage,
};
getProducts( args )
.then( ( productsData ) => {
this.setState( {
products: productsData.products,
totalProducts: productsData.totalProducts,
loading: false,
error: null,
} );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( {
products: [],
totalProducts: 0,
loading: false,
error,
} );
} );
}
render() {
const { error, loading, products, totalProducts } = this.state;
return (
<OriginalComponent
{ ...this.props }
products={ products }
totalProducts={ totalProducts }
error={ error }
isLoading={ loading }
/>
);
}
}
return WrappedComponent;
};
export default withProducts;

View File

@@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { getReviews } from '../../blocks/reviews/utils';
import { formatError } from '../utils/errors.js';
const withReviews = ( OriginalComponent ) => {
class WrappedComponent extends Component {
static propTypes = {
order: PropTypes.oneOf( [ 'asc', 'desc' ] ).isRequired,
orderby: PropTypes.string.isRequired,
reviewsToDisplay: PropTypes.number.isRequired,
categoryIds: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
] ),
delayFunction: PropTypes.func,
onReviewsAppended: PropTypes.func,
onReviewsLoadError: PropTypes.func,
onReviewsReplaced: PropTypes.func,
productId: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
};
static defaultProps = {
delayFunction: ( f ) => f,
onReviewsAppended: () => {},
onReviewsLoadError: () => {},
onReviewsReplaced: () => {},
};
isPreview = !! this.props.attributes.previewReviews;
delayedAppendReviews = this.props.delayFunction( this.appendReviews );
state = {
error: null,
loading: true,
reviews: this.isPreview ? this.props.attributes.previewReviews : [],
totalReviews: this.isPreview
? this.props.attributes.previewReviews.length
: 0,
};
componentDidMount() {
this.replaceReviews();
}
componentDidUpdate( prevProps ) {
if ( prevProps.reviewsToDisplay < this.props.reviewsToDisplay ) {
// Since this attribute might be controlled via something with
// short intervals between value changes, this allows for optionally
// delaying review fetches via the provided delay function.
this.delayedAppendReviews();
} else if ( this.shouldReplaceReviews( prevProps, this.props ) ) {
this.replaceReviews();
}
}
shouldReplaceReviews( prevProps, nextProps ) {
return (
prevProps.orderby !== nextProps.orderby ||
prevProps.order !== nextProps.order ||
prevProps.productId !== nextProps.productId ||
! isShallowEqual( prevProps.categoryIds, nextProps.categoryIds )
);
}
componentWillUnMount() {
if ( this.delayedAppendReviews.cancel ) {
this.delayedAppendReviews.cancel();
}
}
getArgs( reviewsToSkip ) {
const {
categoryIds,
order,
orderby,
productId,
reviewsToDisplay,
} = this.props;
const args = {
order,
orderby,
per_page: reviewsToDisplay - reviewsToSkip,
offset: reviewsToSkip,
};
if ( categoryIds && categoryIds.length ) {
args.category_id = Array.isArray( categoryIds )
? categoryIds.join( ',' )
: categoryIds;
}
if ( productId ) {
args.product_id = productId;
}
return args;
}
replaceReviews() {
if ( this.isPreview ) {
return;
}
const { onReviewsReplaced } = this.props;
this.updateListOfReviews().then( onReviewsReplaced );
}
appendReviews() {
if ( this.isPreview ) {
return;
}
const { onReviewsAppended, reviewsToDisplay } = this.props;
const { reviews } = this.state;
// Given that this function is delayed, props might have been updated since
// it was called so we need to check again if fetching new reviews is necessary.
if ( reviewsToDisplay <= reviews.length ) {
return;
}
this.updateListOfReviews( reviews ).then( onReviewsAppended );
}
updateListOfReviews( oldReviews = [] ) {
const { reviewsToDisplay } = this.props;
const { totalReviews } = this.state;
const reviewsToLoad =
Math.min( totalReviews, reviewsToDisplay ) - oldReviews.length;
this.setState( {
loading: true,
reviews: oldReviews.concat( Array( reviewsToLoad ).fill( {} ) ),
} );
return getReviews( this.getArgs( oldReviews.length ) )
.then(
( {
reviews: newReviews,
totalReviews: newTotalReviews,
} ) => {
this.setState( {
reviews: oldReviews
.filter(
( review ) => Object.keys( review ).length
)
.concat( newReviews ),
totalReviews: newTotalReviews,
loading: false,
error: null,
} );
return { newReviews };
}
)
.catch( this.setError );
}
setError = async ( e ) => {
const { onReviewsLoadError } = this.props;
const error = await formatError( e );
this.setState( { reviews: [], loading: false, error } );
onReviewsLoadError( error );
};
render() {
const { reviewsToDisplay } = this.props;
const { error, loading, reviews, totalReviews } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error }
isLoading={ loading }
reviews={ reviews.slice( 0, reviewsToDisplay ) }
totalReviews={ totalReviews }
/>
);
}
}
const {
displayName = OriginalComponent.name || 'Component',
} = OriginalComponent;
WrappedComponent.displayName = `WithReviews( ${ displayName } )`;
return WrappedComponent;
};
export default withReviews;

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { Component, createRef, Fragment } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
/**
* HOC that provides a function to scroll to the top of the component.
*/
const withScrollToTop = ( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super();
this.scrollPointRef = createRef();
}
scrollToTopIfNeeded = () => {
const scrollPointRefYPosition = this.scrollPointRef.current.getBoundingClientRect()
.bottom;
const isScrollPointRefVisible =
scrollPointRefYPosition >= 0 &&
scrollPointRefYPosition <= window.innerHeight;
if ( ! isScrollPointRefVisible ) {
this.scrollPointRef.current.scrollIntoView();
}
};
moveFocusToTop = ( focusableSelector ) => {
const focusableElements = this.scrollPointRef.current.parentElement.querySelectorAll(
focusableSelector
);
if ( focusableElements.length ) {
focusableElements[ 0 ].focus();
}
};
scrollToTop = ( args ) => {
if ( ! window || ! Number.isFinite( window.innerHeight ) ) {
return;
}
this.scrollToTopIfNeeded();
if ( args && args.focusableSelector ) {
this.moveFocusToTop( args.focusableSelector );
}
};
render() {
return (
<Fragment>
<div
className="with-scroll-to-top__scroll-point"
ref={ this.scrollPointRef }
aria-hidden
/>
<OriginalComponent
{ ...this.props }
scrollToTop={ this.scrollToTop }
/>
</Fragment>
);
}
}
WrappedComponent.displayName = 'withScrollToTop';
return WrappedComponent;
};
export default withScrollToTop;

View File

@@ -0,0 +1,4 @@
.with-scroll-to-top__scroll-point {
position: relative;
top: -$gap-larger;
}

View File

@@ -0,0 +1,81 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withScrollToTop from '../index';
const TestComponent = withScrollToTop( ( props ) => (
<span { ...props }>
<button />
</span>
) );
const focusedMock = jest.fn();
const scrollIntoViewMock = jest.fn();
const mockedButton = {
focus: focusedMock,
};
const render = ( { inView } ) => {
return TestRenderer.create( <TestComponent />, {
createNodeMock: ( element ) => {
if ( element.type === 'button' ) {
return mockedButton;
}
if ( element.type === 'div' ) {
return {
getBoundingClientRect: () => ( {
bottom: inView ? 0 : -10,
} ),
parentElement: {
querySelectorAll: () => [ mockedButton ],
},
scrollIntoView: scrollIntoViewMock,
};
}
return null;
},
} );
};
describe( 'withScrollToTop Component', () => {
afterEach( () => {
focusedMock.mockReset();
scrollIntoViewMock.mockReset();
} );
describe( 'if component is not in view', () => {
beforeEach( () => {
const renderer = render( { inView: false } );
const props = renderer.root.findByType( 'span' ).props;
props.scrollToTop( { focusableSelector: 'button' } );
} );
it( 'scrolls to top of the component when scrollToTop is called', () => {
expect( scrollIntoViewMock ).toHaveBeenCalledTimes( 1 );
} );
it( 'moves focus to top of the component when scrollToTop is called', () => {
expect( focusedMock ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'if component is in view', () => {
beforeEach( () => {
const renderer = render( { inView: true } );
const props = renderer.root.findByType( 'span' ).props;
props.scrollToTop( { focusableSelector: 'button' } );
} );
it( "doesn't scroll to top of the component when scrollToTop is called", () => {
expect( scrollIntoViewMock ).toHaveBeenCalledTimes( 0 );
} );
it( 'moves focus to top of the component when scrollToTop is called', () => {
expect( focusedMock ).toHaveBeenCalledTimes( 1 );
} );
} );
} );

View File

@@ -0,0 +1,7 @@
export * from './use-query-state';
export * from './use-shallow-equal';
export * from './use-store-products';
export * from './use-collection';
export * from './use-collection-header';
export * from './use-collection-data';
export * from './use-previous';

View File

@@ -0,0 +1,297 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { Component as ReactComponent } from '@wordpress/element';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useCollection } from '../use-collection';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
class TestErrorBoundary extends ReactComponent {
constructor( props ) {
super( props );
this.state = { hasError: false, error: {} };
}
static getDerivedStateFromError( error ) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}
render() {
if ( this.state.hasError ) {
return <div error={ this.state.error } />;
}
return this.props.children;
}
}
describe( 'useCollection', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const { results, isLoading } = testRenderer.root.findByType(
'div'
).props;
return {
results,
isLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<TestErrorBoundary>
<Component { ...props } />
</TestErrorBoundary>
</RegistryProvider>
);
const getTestComponent = () => ( { options } ) => {
const items = useCollection( options );
return <div { ...items } />;
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
mocks = {};
renderer = null;
setUpMocks();
} );
it(
'should throw an error if an options object is provided without ' +
'a namespace property',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
const props = renderer.root.findByType( 'div' ).props;
expect( props.error.message ).toMatch( /options object/ );
expect( console ).toHaveErrored( /your React components:/ );
renderer.unmount();
}
);
it(
'should throw an error if an options object is provided without ' +
'a resourceName property',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
query: { bar: 'foo' },
},
} )
);
} );
const props = renderer.root.findByType( 'div' ).props;
expect( props.error.message ).toMatch( /options object/ );
expect( console ).toHaveErrored( /your React components:/ );
renderer.unmount();
}
);
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
const { results } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { results: newResults } = getProps( renderer );
expect( newResults ).toBe( results );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { foo: 'bar' },
},
} )
);
} );
const { results: resultsVerification } = getProps( renderer );
expect( resultsVerification ).not.toBe( results );
expect( resultsVerification ).toEqual( results );
renderer.unmount();
}
);
it(
'should return expected behaviour for equivalent resourceValues on' +
' props across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
const { results } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { results: newResults } = getProps( renderer );
expect( newResults ).toBe( results );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 20, 10 ],
},
} )
);
} );
const { results: resultsVerification } = getProps( renderer );
expect( resultsVerification ).not.toBe( results );
expect( resultsVerification ).toEqual( results );
renderer.unmount();
}
);
it( 'should return previous query results if `shouldSelect` is false', () => {
mocks.selectors.getCollection.mockImplementation(
( state, ...args ) => {
return args;
}
);
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
const { results } = getProps( renderer );
// rerender but with shouldSelect to false
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: false,
},
} )
);
} );
const { results: results2 } = getProps( renderer );
expect( results2 ).toBe( results );
// expect 2 calls because internally, useSelect invokes callback twice
// on mount.
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
// rerender again but set shouldSelect to true again and we should see
// new results
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: true,
},
} )
);
} );
const { results: results3 } = getProps( renderer );
expect( results3 ).not.toEqual( results );
expect( results3 ).toEqual( [
'test/store',
'productsb',
{},
[ 10, 30 ],
] );
} );
} );

View File

@@ -0,0 +1,89 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { usePrevious } from '../use-previous';
describe( 'usePrevious', () => {
const TestComponent = ( { testValue, validation } ) => {
const previousValue = usePrevious( testValue, validation );
return <div testValue={ testValue } previousValue={ previousValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it( 'should be undefined at first pass', () => {
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
const testValue = renderer.root.findByType( 'div' ).props.testValue;
const testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 1 );
expect( testPreviousValue ).toBe( undefined );
} );
it( 'test new and previous value', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
act( () => {
renderer.update( <TestComponent testValue={ 2 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 2 );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update( <TestComponent testValue={ 3 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 2 );
} );
it( 'should not update value if validation fails', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ 1 } validation={ Number.isFinite } />
);
} );
act( () => {
renderer.update(
<TestComponent testValue="abc" validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 'abc' );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update(
<TestComponent testValue={ 3 } validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 1 );
} );
} );

View File

@@ -0,0 +1,259 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import {
useQueryStateByContext,
useQueryStateByKey,
useSynchronizedQueryState,
} from '../use-query-state';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
QUERY_STATE_STORE_KEY: 'test/store',
} ) );
describe( 'Testing Query State Hooks', () => {
let registry, mocks;
beforeAll( () => {
registry = createRegistry();
mocks = {};
} );
/**
* Test helper to return a tuple containing the expected query value and the
* expected query state action creator from the given rendered test instance.
*
* @param {Object} testRenderer An instance of the created test component.
*
* @return {Array} A tuple containing the expected query value as the first
* element and the expected query action creator as the
* second argument.
*/
const getProps = ( testRenderer ) => {
const props = testRenderer.root.findByType( 'div' ).props;
return [ props.queryState, props.setQueryState ];
};
/**
* Returns the given component wrapped in the registry provider for
* instantiating using the TestRenderer using the current prepared registry
* for the TestRenderer to instantiate with.
*
* @param {React.Component} Component The test component to wrap.
* @param {Object} props Props to feed the wrapped component.
*
* @return {React.Component}
*/
const getWrappedComponent = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
/**
* Returns a TestComponent for the provided hook to test with, and the
* expected PropKeys for obtaining the values to be fed to the hook as
* arguments.
*
* @param {Function} hookTested The hook being tested to use in the
* test comopnent.
* @param {Array} propKeysForArgs An array of keys for the props that
* will be used on the test component that
* will have values fed to the tested
* hook.
*
* @return {React.Component} A component ready for testing with!
*/
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
const args = propKeysForArgs.map( ( key ) => props[ key ] );
const [ queryValue, setQueryValue ] = hookTested( ...args );
return (
<div queryState={ queryValue } setQueryState={ setQueryValue } />
);
};
/**
* A helper for setting up the `mocks` object and the `registry` mock before
* each test.
*
* @param {string} actionMockName This should be the name of the action
* that the hook returns. This will be
* mocked using `mocks.action` when
* registered in the mock registry.
* @param {string} selectorMockName This should be the mame of the selector
* that the hook uses. This will be mocked
* using `mocks.selector` when registered
* in the mock registry.
*/
const setupMocks = ( actionMockName, selectorMockName ) => {
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
registry.registerStore( storeKey, {
reducer: () => ( {} ),
actions: {
[ actionMockName ]: mocks.action,
},
selectors: {
[ selectorMockName ]: mocks.selector,
},
} );
};
describe( 'useQueryStateByContext', () => {
const TestComponent = getTestComponent( useQueryStateByContext, [
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
} );
}
);
} );
describe( 'useQueryStateByKey', () => {
const TestComponent = getTestComponent( useQueryStateByKey, [
'queryKey',
undefined,
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { selector, action } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
queryKey: 'someValue',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
'someValue',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith(
'test-context',
'someValue',
{ foo: 'bar' }
);
}
);
} );
// @todo, these tests only add partial coverage because the state is not
// actually updated by the action dispatch via our mocks.
describe( 'useSynchronizedQueryState', () => {
const TestComponent = getTestComponent( useSynchronizedQueryState, [
'synchronizedQuery',
'context',
] );
const initialQuery = { a: 'b' };
let renderer;
beforeEach( () => {
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
it( 'returns provided query state on initial render', () => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
const [ queryState ] = getProps( renderer );
expect( queryState ).toBe( initialQuery );
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
a: 'b',
} );
} );
it( 'returns merged queryState on subsequent render', () => {
act( () => {
renderer.update(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
// note our test doesn't interact with an actual reducer so the
// store state is not updated. Here we're just verifying that
// what is is returned by the state selector mock is returned.
// However we DO expect this to be a new object.
const [ queryState ] = getProps( renderer );
expect( queryState ).not.toBe( initialQuery );
expect( queryState ).toEqual( { foo: 'bar' } );
} );
} );
} );

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { useShallowEqual } from '../use-shallow-equal';
describe( 'useShallowEqual', () => {
const TestComponent = ( { testValue } ) => {
const newValue = useShallowEqual( testValue );
return <div newValue={ newValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it.each`
testValueA | aType | testValueB | bType | expectEqual
${{ a: 'b', foo: 'bar' }} | ${'object'} | ${{ foo: 'bar', a: 'b' }} | ${'object'} | ${true}
${{ a: 'b', foo: 'bar' }} | ${'object'} | ${{ foo: 'bar', a: 'c' }} | ${'object'} | ${false}
${[ 'b', 'bar' ]} | ${'array'} | ${[ 'b', 'bar' ]} | ${'array'} | ${true}
${[ 'b', 'bar' ]} | ${'array'} | ${[ 'bar', 'b' ]} | ${'array'} | ${false}
${1} | ${'number'} | ${1} | ${'number'} | ${true}
${1} | ${'number'} | ${'1'} | ${'string'} | ${false}
${'1'} | ${'string'} | ${'1'} | ${'string'} | ${true}
${1} | ${'number'} | ${2} | ${'number'} | ${false}
${1} | ${'number'} | ${true} | ${'bool'} | ${false}
${0} | ${'number'} | ${false} | ${'bool'} | ${false}
${true} | ${'bool'} | ${true} | ${'bool'} | ${true}
`(
'$testValueA ($aType) and $testValueB ($bType) are expected to be equal ($expectEqual)',
( { testValueA, testValueB, expectEqual } ) => {
let testPropValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ testValueA } />
);
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueA );
// do update
act( () => {
renderer.update( <TestComponent testValue={ testValueB } /> );
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
if ( expectEqual ) {
expect( testPropValue ).toBe( testValueA );
} else {
expect( testPropValue ).toBe( testValueB );
}
}
);
} );

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useStoreProducts } from '../use-store-products';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
describe( 'useStoreProducts', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const {
products,
totalProducts,
productsLoading,
} = testRenderer.root.findByType( 'div' ).props;
return {
products,
totalProducts,
productsLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
const getTestComponent = () => ( { query } ) => {
const items = useStoreProducts( query );
return <div { ...items } />;
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
mocks = {};
renderer = null;
setUpMocks();
} );
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
const { products } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { products: newProducts } = getProps( renderer );
expect( newProducts ).toBe( products );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { foo: 'bar' },
} )
);
} );
const { products: productsVerification } = getProps( renderer );
expect( productsVerification ).not.toBe( products );
expect( productsVerification ).toEqual( products );
renderer.unmount();
}
);
} );

View File

@@ -0,0 +1,124 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import {
useQueryStateByContext,
useQueryStateByKey,
useCollection,
} from '@woocommerce/base-hooks';
import { useQueryStateContext } from '@woocommerce/base-context/query-state-context';
import { useDebounce } from 'use-debounce';
import { sortBy } from 'lodash';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
const buildCollectionDataQuery = ( collectionDataQueryState ) => {
const query = collectionDataQueryState;
if ( collectionDataQueryState.calculate_attribute_counts ) {
query.calculate_attribute_counts = sortBy(
collectionDataQueryState.calculate_attribute_counts.map(
( { taxonomy, queryType } ) => {
return {
taxonomy,
query_type: queryType,
};
}
),
[ 'taxonomy', 'query_type' ]
);
}
return query;
};
export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryState,
} ) => {
let context = useQueryStateContext();
context = `${ context }-collection-data`;
const [ collectionDataQueryState ] = useQueryStateByContext( context );
const [
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] = useQueryStateByKey( 'calculate_attribute_counts', [], context );
const [
calculatePriceRangeQueryState,
setCalculatePriceRangeQueryState,
] = useQueryStateByKey( 'calculate_price_range', null, context );
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
const currentQueryPrices = useShallowEqual( queryPrices );
useEffect( () => {
if (
typeof currentQueryAttribute === 'object' &&
Object.keys( currentQueryAttribute ).length
) {
const foundAttribute = calculateAttributesQueryState.find(
( attribute ) => {
return (
attribute.taxonomy === currentQueryAttribute.taxonomy
);
}
);
if ( ! foundAttribute ) {
setCalculateAttributesQueryState( [
...calculateAttributesQueryState,
currentQueryAttribute,
] );
}
}
}, [
currentQueryAttribute,
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] );
useEffect( () => {
if (
calculatePriceRangeQueryState !== currentQueryPrices &&
currentQueryPrices !== undefined
) {
setCalculatePriceRangeQueryState( currentQueryPrices );
}
}, [
currentQueryPrices,
setCalculatePriceRangeQueryState,
calculatePriceRangeQueryState,
] );
// Defer the select query so all collection-data query vars can be gathered.
const [ shouldSelect, setShouldSelect ] = useState( false );
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
if ( ! shouldSelect ) {
setShouldSelect( true );
}
const collectionDataQueryVars = useMemo( () => {
return buildCollectionDataQuery( collectionDataQueryState );
}, [ collectionDataQueryState ] );
return useCollection( {
namespace: '/wc/store',
resourceName: 'products/collection-data',
query: {
...queryState,
page: undefined,
per_page: undefined,
orderby: undefined,
order: undefined,
...collectionDataQueryVars,
},
shouldSelect: debouncedShouldSelect,
} );
};

View File

@@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store. Given a header key and a collections option object, this will ensure a
* component is kept up to date with the collection header value matching that
* query in the store state.
*
* @param {string} headerKey Used to indicate which header value to
* return for the given collection query.
* Example: `'x-wp-total'`
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} options.resourceValues An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} options.query An object of key value pairs for the
* query to execute on the collection
* (optional). Example:
* `{ order: 'ASC', order_by: 'price' }`
*
* @return {Object} This hook will return an object with two properties:
* - value Whatever value is attached to the specified
* header.
* - isLoading A boolean indicating whether the header is
* loading (true) or not.
*/
export const useCollectionHeader = ( headerKey, options ) => {
const {
namespace,
resourceName,
resourceValues = [],
query = {},
} = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
'the resource name properties.'
);
}
// ensure we feed the previous reference if it's equivalent
const currentQuery = useShallowEqual( query );
const currentResourceValues = useShallowEqual( resourceValues );
const { value, isLoading = true } = useSelect(
( select ) => {
const store = select( storeKey );
// filter out query if it is undefined.
const args = [
headerKey,
namespace,
resourceName,
currentQuery,
currentResourceValues,
];
return {
value: store.getCollectionHeader( ...args ),
isLoading: store.hasFinishedResolution(
'getCollectionHeader',
args
),
};
},
[
headerKey,
namespace,
resourceName,
currentResourceValues,
currentQuery,
]
);
return {
value,
isLoading,
};
};

View File

@@ -0,0 +1,95 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store. Given a collections option object, this will ensure a component is
* kept up to date with the collection matching that query in the store state.
*
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} options.resourceValues An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} options.query An object of key value pairs for the
* query to execute on the collection
* (optional). Example:
* `{ order: 'ASC', order_by: 'price' }`
* @param {boolean} options.shouldSelect If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @return {Object} This hook will return an object with two properties:
* - results An array of collection items returned.
* - isLoading A boolean indicating whether the collection is
* loading (true) or not.
*/
export const useCollection = ( options ) => {
const {
namespace,
resourceName,
resourceValues = [],
query = {},
shouldSelect = true,
} = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
'the resource properties.'
);
}
const currentResults = useRef( { results: [], isLoading: true } );
// ensure we feed the previous reference if it's equivalent
const currentQuery = useShallowEqual( query );
const currentResourceValues = useShallowEqual( resourceValues );
const results = useSelect(
( select ) => {
if ( ! shouldSelect ) {
return null;
}
const store = select( storeKey );
// filter out query if it is undefined.
const args = [
namespace,
resourceName,
currentQuery,
currentResourceValues,
];
return {
results: store.getCollection( ...args ),
isLoading: ! store.hasFinishedResolution(
'getCollection',
args
),
};
},
[
namespace,
resourceName,
currentResourceValues,
currentQuery,
shouldSelect,
]
);
// if selector was not bailed, then update current results. Otherwise return
// previous results
if ( results !== null ) {
currentResults.current = results;
}
return currentResults.current;
};

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useRef, useEffect } from 'react';
/**
* Use Previous based on https://usehooks.com/usePrevious/.
* @param {mixed} value
* @param {Function} [validation] Function that needs to validate for the value
* to be updated.
*/
export const usePrevious = ( value, validation ) => {
const ref = useRef();
useEffect( () => {
if (
ref.current !== value &&
( ! validation || validation( value, ref.current ) )
) {
ref.current = value;
}
}, [ value, ref.current ] );
return ref.current;
};

View File

@@ -0,0 +1,132 @@
/**
* External dependencies
*/
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { useQueryStateContext } from '@woocommerce/base-context/query-state-context';
import { assign } from 'lodash';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
/**
* A custom hook that exposes the current query state and a setter for the query
* state store for the given context.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {string} [context] What context to retrieve the query state for. If not
* provided, this hook will attempt to get the context
* from the query state context provided by the
* QueryStateContextProvider
*
* @return {Array} An array that has two elements. The first element is the
* query state value for the given context. The second element
* is a dispatcher function for setting the query state.
*/
export const useQueryStateByContext = ( context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryState = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryContext( context, undefined );
},
[ context ]
);
const { setValueForQueryContext } = useDispatch( storeKey );
const setQueryState = useCallback(
( value ) => {
setValueForQueryContext( context, value );
},
[ context ]
);
return [ queryState, setQueryState ];
};
/**
* A custom hook that exposes the current query state value and a setter for the
* given context and query key.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {*} queryKey The specific query key to retrieve the value for.
* @param {*} defaultValue Default value if query does not exist.
* @param {string} [context] What context to retrieve the query state for. If
* not provided will attempt to use what is provided
* by query state context.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
*/
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryValue = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryKey( context, queryKey, defaultValue );
},
[ context, queryKey ]
);
const { setQueryValue } = useDispatch( storeKey );
const setQueryValueByKey = useCallback(
( value ) => {
setQueryValue( context, queryKey, value );
},
[ context, queryKey ]
);
return [ queryValue, setQueryValueByKey ];
};
/**
* A custom hook that works similarly to useQueryStateByContext. However, this
* hook allows for synchronizing with a provided queryState object.
*
* This hook does the following things with the provided `synchronizedQuery`
* object:
*
* - whenever synchronizedQuery varies between renders, the queryState will be
* updated to a merged object of the internal queryState and the provided
* object. Note, any values from the same properties between objects will
* be set from synchronizedQuery.
* - if there are no changes between renders, then the existing internal
* queryState is always returned.
* - on initial render, the synchronizedQuery value is returned.
*
* Typically, this hook would be used in a scenario where there may be external
* triggers for updating the query state (i.e. initial population of query
* state by hydration or component attributes, or routing url changes that
* affect query state).
*
* @param {Object} synchronizedQuery A provided query state object to
* synchronize internal query state with.
* @param {string} [context] What context to retrieve the query state
* for. If not provided, will be pulled from
* the QueryStateContextProvider in the tree.
*/
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const [ queryState, setQueryState ] = useQueryStateByContext( context );
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
// used to ensure we allow initial synchronization to occur before
// returning non-synced state.
const isInitialized = useRef( false );
// update queryState anytime incoming synchronizedQuery changes
useEffect( () => {
setQueryState( assign( {}, queryState, currentSynchronizedQuery ) );
isInitialized.current = true;
}, [ currentSynchronizedQuery ] );
return isInitialized.current
? [ queryState, setQueryState ]
: [ synchronizedQuery, setQueryState ];
};

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useRef } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* A custom hook that compares the provided value across renders and returns the
* previous instance if shallow equality with previous instance exists.
*
* This is particularly useful when non-primitive types are used as
* dependencies for react hooks.
*
* @param {mixed} value Value to keep the same if satisfies shallow equality.
*
* @return {mixed} The previous cached instance of the value if the current has
* shallow equality with it.
*/
export const useShallowEqual = ( value ) => {
const ref = useRef();
if ( ! isShallowEqual( value, ref.current ) ) {
ref.current = value;
}
return ref.current;
};

View File

@@ -0,0 +1,44 @@
/**
* Internal dependencies
*/
import { useCollection } from './use-collection';
import { useCollectionHeader } from './use-collection-header';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store for the `'wc/store/products'` route. Given a query object, this
* will ensure a component is kept up to date with the products matching that
* query in the store state.
*
* @param {Object} query An object containing any query arguments to be
* included with the collection request for the
* products. Does not have to be included.
*
* @return {Object} This hook will return an object with three properties:
* - products An array of product objects.
* - totalProducts The total number of products that match
* the given query parameters.
* - productsLoading A boolean indicating whether the products
* are still loading or not.
*/
export const useStoreProducts = ( query ) => {
// @todo see @https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1097
// where the namespace is going to be changed. Not doing in this pull.
const collectionOptions = {
namespace: '/wc/store',
resourceName: 'products',
};
const { results: products, isLoading: productsLoading } = useCollection( {
...collectionOptions,
query,
} );
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
...collectionOptions,
query,
} );
return {
products,
totalProducts,
productsLoading,
};
};

View File

@@ -0,0 +1,30 @@
/**
* Given a JS error or a fetch response error, parse and format it so it can be displayed to the user.
*
* @param {Object} error Error object.
* @param {Function} [error.json] If a json method is specified, it will try parsing the error first.
* @param {string} [error.message] If a message is specified, it will be shown to the user.
* @param {string} [error.type] The context in which the error was triggered.
* @return {Object} Error object containing a message and type.
*/
export const formatError = async ( error ) => {
if ( typeof error.json === 'function' ) {
try {
const parsedError = await error.json();
return {
message: parsedError.message,
type: parsedError.type || 'api',
};
} catch ( e ) {
return {
message: e.message,
type: 'general',
};
}
}
return {
message: error.message,
type: error.type || 'general',
};
};

View File

@@ -0,0 +1,2 @@
export * from './errors';
export * from './price';

View File

@@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { sprintf } from '@wordpress/i18n';
import { CURRENCY } from '@woocommerce/settings';
/**
* Format a price with currency data.
*
* @param {number} value Number to format.
* @param {string} priceFormat Price format string.
* @param {string} currencySymbol Currency symbol.
*/
export const formatPrice = (
value,
priceFormat = CURRENCY.priceFormat,
currencySymbol = CURRENCY.symbol
) => {
const formattedNumber = parseInt( value, 10 );
if ( ! isFinite( formattedNumber ) ) {
return '';
}
const formattedValue = sprintf(
priceFormat,
currencySymbol,
formattedNumber
);
// This uses a textarea to magically decode HTML currency symbols.
const txt = document.createElement( 'textarea' );
txt.innerHTML = formattedValue;
return txt.value;
};

Some files were not shown because too many files have changed in this diff Show More