* Inital block creation

* Update labels

* Columns, rows, sample data json

* Output data from API

* thumbnail_html

* Split into smaller components of grid

* Price handling

* Image handling

* Remove !

* frontend

* Work on cart api

* Cart error handling and product type conditions

* innerblock progress

* Implement layoutConfig as set from innerblocks shape.

Note:

- this is just a poc
- where things are configured likely needs changed
- will still need to work out how this gets persisted for the frontend and how things get displayed there (but likely will work fairly similar in terms of mapping to components).

* use correct prop name

* Working edit button

* Clean up block controls and edit view

* Add link with innerblocks

* update link description

* correctly handle components with inner blocks as children

* Re-organise atomic components and blocks into own directories

* Unique keys for components

* Fix default layout when inserting block for first time

* Working layoutconfig save

* Save attributes

* Move sale badge to image component

* Add disabled to render preview and blocks

* icons

* Editor view styling

* Update withComponetId to stop component ID incrementing too many times

Co-Authored-By: Darren Ethier <darren@roughsmootheng.in>

* Improve key generation

* done/cancel buttons on edit

* Create withProducts HOC for All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/994)

* Create withProducts HOC for All Products block

* Add order select and pagination to All Products block

* Go to first page when changing order

* Add HOC test

* Make sure block is updated whem columns/rows change in the editor

* Fix 'SALE' badge positioning

* Remove unnecessary key

* Honor 'perPage' value when generating placeholders

* Make placeholder sizes match rendered block item

* Several CSS improvements

* Remove unused CSS properties

* Move getProducts to hocs utils

* Remove All Products sample-data.json

* Fix order select wrong margin in the editor

* Refactor how loading image gets its size

* Clear products when loadProducts start

* Enhance pagination logic

* Fix placeholder width

* Fix regular_price check

* Set product link max-width

* Implement querystrings for the All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/997)

* Implement querystrings for the All Products block

* Create withWindow HOC

* Add with-window tests

* Use renderFrontend util in All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/1003)

* Add with-window tests

* Use renderFrontend util in All Products block

* Rename properties and components from 'order' to 'sort' (https://github.com/woocommerce/woocommerce-blocks/pull/1012)

* Rename properties and components from 'order' to 'sort'

* Rename onOrderChange to onSortChange

* Remove unnecessary stylint-disable-line (https://github.com/woocommerce/woocommerce-blocks/pull/1016)

* Create withBrowserLocation and withBrowserHistory HOC (https://github.com/woocommerce/woocommerce-blocks/pull/1022)

* Create withBrowserWindowProp HOC

* Remove unnecessary expect's

* Always pass window prop to propMap if it exists

* Scroll to top when switching pages in All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/1011)

* Scroll to top when switching pages in All Pages block

* Improve keyboard navigation in the All Products block

* Create withScrollToTop HOC

* Fix variable name typo

* Update paths with aliases

* Avoid first and last page being included in pagination 'pagesToDisplay' (https://github.com/woocommerce/woocommerce-blocks/pull/1015)

* Update lock

* constants

* linting

* Clean up styling

* Prettier

* Block options for title/image

* Prettier

* Remove shared content controls

* Update atomic block descriptions

* Summary block

* Prettier

* Impoved template edit appearance and added inline tips

* Apply prettier

* Reset and cancel buttons

* Improved keys

* Tweak tip placement

* Remove incorrect comments

* Remove disabled ofr non interactive elements

* Fragment not needed

* Update assets/js/atomic/components/product-list/title/index.js

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

* Move componentId hoc

* Comment

* Implement onImageLoaded

* Pagination: disable first/last page if they are active (https://github.com/woocommerce/woocommerce-blocks/pull/1041)

* Update assets/js/atomic/components/product-list/rating/index.js

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

* Sale badges refactoring

Adds sale badges blocks and improves how the default blocks are defined.

* revert changes in withComponentId

* fix flexbox alignment

* Create withQueryStringValues HOC and use it in ProductGrid component (https://github.com/woocommerce/woocommerce-blocks/pull/1040)

* Create withQueryStringValues HOC and use it in ProductGrid component

* Add tests

* Add comment explaining urlParameterSuffix

* Don't destructure props if only used once

* Move dependencies check outside the HOC

* Update test description

* Remove HOCs no longer used

* Update assets/js/atomic/blocks/product-list/image/index.js

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

* Update assets/js/atomic/components/product-list/sale-badge/index.js

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

* Update assets/js/atomic/utils/index.js

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

* Update assets/js/atomic/utils/index.js

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

* Feedback

* Grid to List

* Improved badge handling

* update package-lock after merge from master

* Add wp.data store for schema and lists. (https://github.com/woocommerce/woocommerce-blocks/pull/1008)

* install @wordpress/data-controls and deepFreeze

* add schema store

* add query-state store

* add collections store

* add bundle entry point (and export store keys and constants)

* add master README.md for new stores

* add bundle configuration and register asset php side

* Add missing param name

Co-Authored-By: Albert Juhé Lluveras <aljullu@gmail.com>

* code style fixes

* remove unnecessary period from test description

* Simplify conditional

* reorder imports (code style)

* reorder imports and codestyle

* refactor using lodash for state utils

* reorder imports

* reorder imports

* reorder dependencies

* reorder dependnecies an remove duplication block

* remove errant tab

* reorder dependencies

* Modify collections store to add headers to the store state. (https://github.com/woocommerce/woocommerce-blocks/pull/1073)

* add control for getting unparsed response from apiFetch

* Modify action so it recieves a response object.

* modify reducer to handle response object

* improve selectors to make headers accessible

- modifys existing `getCollection` to retrieve items from state.
- adds `getCollectionHeader` selector

* update resolvers to handle full response from request

- also adds resolver for `getCollectionHeader`

* update docs for changes

* Improve spacing in doc

Co-Authored-By: Albert Juhé Lluveras <aljullu@gmail.com>

* Fix spacing in inline docs

Co-Authored-By: Albert Juhé Lluveras <aljullu@gmail.com>

* change `getFromState` to receive an object instead of arguments list.

* Prepare All Products block to accept extension inner blocks (https://github.com/woocommerce/woocommerce-blocks/pull/1047)

* Simplify BLOCK_MAP

* Prepare All Products block to accept extension inner blocks

* Change filter name

* Update filter name

* Add check that block exists in BLOCK_MAP

* Create REVERSED_BLOCK_MAP instead of searching blocks each time

* Change how child blocks are registered for All Products block

* Refactor All Products block so reverse map isn't needed

* Remove getDefaultBlocks

* Make sure getRegisteredInnerBlocks always returns an object and add checks to registerInnerBlock

* Add missing blockName arg

* Add inline docs to block registry

* Move 'blockName' prop to context

* Typos

* Improve registerInnerBlock error messages and create a validation function

* Refactor context

* Rename validateOption to assertOption

* refactor where new context lives and add alias for base-context

* fix doc block

* remove todo block (there’s an issue for it)

* rename context

* Update assets/js/atomic/components/product-list/sale-badge/index.js

Co-Authored-By: Albert Juhé Lluveras <aljullu@gmail.com>

* HeadingToolbar comment

* Tweak bool comparison

* Improve how default layouts are set so all blocks can be removed and the no-content message is correct

* Sale centering

* Fix default template

* Product example switch to preview

* Update preview schema and image data to match latest schema

* Add @woocommerce/atomic-components alias

* Alias for previews to prevent need for relative paths

* Drop `align` for sale block

Align disrupts other blocks in unexpected and unavoidable ways. Drop support so the sale block is a block level item.

* Rename atomic components and blocks (and create ProductLayoutContext) (https://github.com/woocommerce/woocommerce-blocks/pull/1089)

* rename atomic blocks and components to be more generic (drop list)

* create new context for productlayout and implement provider

* Rename ProductSalesBadge to ProductSaleBadge

* Add php5.3 restriction for All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/1090)

* enable legacy builds and add handling for AllProducts to be wp5.3 only

* fix legacy builds

* Prevent filemtime notice

* Implement new data stores with All Products block (https://github.com/woocommerce/woocommerce-blocks/pull/1067)

* add new hooks and tests and alias for hooks

* various fixes for stores after implementation tests

* implement new stores for ProductList component

* add doc blocks for useStoreProducts hooks

* fix typo in property on returned object
This commit is contained in:
Mike Jolley 2019-10-28 13:53:09 +00:00 committed by Darren Ethier
parent dee8dcb296
commit f851a6ef9b
139 changed files with 6938 additions and 463 deletions

View File

@ -18,7 +18,7 @@
.wc-block-grid {
.wc-block-grid__products {
list-style: none;
margin: 0;
margin: 0 (-$gap/2) $gap;
.wp-block-button__link {
color: inherit;

View File

@ -1,253 +1 @@
.wc-block-grid__products {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 0;
margin: 0 0 $gap-large;
}
.wc-block-grid__product {
box-sizing: border-box;
padding: 0 $gap 0 0;
margin: 0 0 $gap-large 0;
float: none;
width: auto;
position: relative;
text-align: center;
}
.wc-block-grid__product-link {
text-decoration: none;
border: 0;
}
.wc-block-grid__product-image {
text-decoration: none;
margin-bottom: $gap;
display: block;
img {
vertical-align: middle;
margin-left: auto;
margin-right: auto;
}
}
.wc-block-grid__product-title {
line-height: 1.2;
font-weight: 700;
}
.wc-block-grid__product-title,
.wc-block-grid__product-price,
.wc-block-grid__product-rating {
margin-bottom: $gap-small;
display: block;
}
.wc-block-grid__product-add-to-cart {
margin: 0;
word-break: break-word;
white-space: normal;
a {
word-break: break-word;
white-space: normal;
margin: 0;
&.loading {
opacity: 0.25;
padding-right: 2.618em;
&::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e01c";
vertical-align: top;
font-weight: 400;
position: absolute;
top: 0.618em;
right: 1em;
animation: spin 2s linear infinite;
}
}
&.added::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e017";
margin-left: 0.53em;
vertical-align: bottom;
}
}
.added_to_cart {
text-align: center;
}
}
.wc-block-grid__product-onsale {
font-weight: 700;
position: absolute;
text-align: center;
top: 0;
left: 0;
margin: 0;
background-color: #000;
color: #fff;
display: inline-block;
font-size: 0.75em;
padding: 0.5em;
z-index: 9;
text-transform: uppercase;
}
.wc-block-grid__product-rating {
.star-rating {
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: inline-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;
}
}
}
.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;
}
}
&.has-1-columns {
.wc-block-grid__products {
display: block;
}
.wc-block-grid__product {
margin-left: auto;
margin-right: auto;
}
}
@for $i from 2 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-onsale {
line-height: 1;
}
}
/* Moved */

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';
/**
* Internal dependencies
*/
import { ProductButton } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Button', 'woo-gutenberg-products-block' ),
description: __(
'Display a call to action button which either adds the product to the cart, or links to the product page.',
'woo-gutenberg-products-block'
),
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/editor';
/**
* Internal dependencies
*/
import ToggleButtonControl from '@woocommerce/block-components/toggle-button-control';
import { ProductImage } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
import { previewProducts } from '@woocommerce/resource-previews';
const blockConfig = {
title: __( 'Product Image', 'woo-gutenberg-products-block' ),
description: __(
'Display the main product image',
'woo-gutenberg-products-block'
),
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',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Link to Product Page',
'woo-gutenberg-products-block'
) }
help={ __(
'Links the image to the single product listing.',
'woo-gutenberg-products-block'
) }
checked={ productLink }
onChange={ () =>
setAttributes( {
productLink: ! productLink,
} )
}
/>
<ToggleControl
label={ __(
'Show On-Sale Badge',
'woo-gutenberg-products-block'
) }
help={ __(
'Overlay a "sale" badge if the product is on-sale.',
'woo-gutenberg-products-block'
) }
checked={ showSaleBadge }
onChange={ () =>
setAttributes( {
showSaleBadge: ! showSaleBadge,
} )
}
/>
{ showSaleBadge && (
<ToggleButtonControl
label={ __(
'Sale Badge Alignment',
'woo-gutenberg-products-block'
) }
value={ saleBadgeAlign }
options={ [
{
label: __(
'Left',
'woo-gutenberg-products-block'
),
value: 'left',
},
{
label: __(
'Center',
'woo-gutenberg-products-block'
),
value: 'center',
},
{
label: __(
'Right',
'woo-gutenberg-products-block'
),
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';
/**
* Internal dependencies
*/
import { ProductPrice } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Price', 'woo-gutenberg-products-block' ),
description: __(
'Display the price of a product.',
'woo-gutenberg-products-block'
),
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';
/**
* Internal dependencies
*/
import { ProductRating } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Rating', 'woo-gutenberg-products-block' ),
description: __(
'Display the average rating of a product.',
'woo-gutenberg-products-block'
),
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';
/**
* Internal dependencies
*/
import { ProductSaleBadge } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
import { IconProductOnSale } from '@woocommerce/block-components/icons';
const blockConfig = {
title: __( 'On-Sale Badge', 'woo-gutenberg-products-block' ),
description: __(
'Displays an on-sale badge if the product is on-sale.',
'woo-gutenberg-products-block'
),
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,33 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Gridicon from 'gridicons';
/**
* Internal dependencies
*/
import { previewProducts } from '@woocommerce/resource-previews';
/**
* Holds default config for this collection of blocks.
*/
export default {
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
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';
/**
* Internal dependencies
*/
import { ProductSummary } from '@woocommerce/atomic-components/product';
import sharedConfig from '../shared-config';
const blockConfig = {
title: __( 'Product Summary', 'woo-gutenberg-products-block' ),
description: __(
'Display the short description of a product.',
'woo-gutenberg-products-block'
),
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,29 @@
/**
* WordPress dependencies
*/
import { Path, SVG } from '@wordpress/components';
export default function HeadingLevelIcon( { level } ) {
const levelToPath = {
1: 'M9 5h2v10H9v-4H5v4H3V5h2v4h4V5zm6.6 0c-.6.9-1.5 1.7-2.6 2v1h2v7h2V5h-1.4z',
2: 'M7 5h2v10H7v-4H3v4H1V5h2v4h4V5zm8 8c.5-.4.6-.6 1.1-1.1.4-.4.8-.8 1.2-1.3.3-.4.6-.8.9-1.3.2-.4.3-.8.3-1.3 0-.4-.1-.9-.3-1.3-.2-.4-.4-.7-.8-1-.3-.3-.7-.5-1.2-.6-.5-.2-1-.2-1.5-.2-.4 0-.7 0-1.1.1-.3.1-.7.2-1 .3-.3.1-.6.3-.9.5-.3.2-.6.4-.8.7l1.2 1.2c.3-.3.6-.5 1-.7.4-.2.7-.3 1.2-.3s.9.1 1.3.4c.3.3.5.7.5 1.1 0 .4-.1.8-.4 1.1-.3.5-.6.9-1 1.2-.4.4-1 .9-1.6 1.4-.6.5-1.4 1.1-2.2 1.6V15h8v-2H15z',
3: 'M12.1 12.2c.4.3.8.5 1.2.7.4.2.9.3 1.4.3.5 0 1-.1 1.4-.3.3-.1.5-.5.5-.8 0-.2 0-.4-.1-.6-.1-.2-.3-.3-.5-.4-.3-.1-.7-.2-1-.3-.5-.1-1-.1-1.5-.1V9.1c.7.1 1.5-.1 2.2-.4.4-.2.6-.5.6-.9 0-.3-.1-.6-.4-.8-.3-.2-.7-.3-1.1-.3-.4 0-.8.1-1.1.3-.4.2-.7.4-1.1.6l-1.2-1.4c.5-.4 1.1-.7 1.6-.9.5-.2 1.2-.3 1.8-.3.5 0 1 .1 1.6.2.4.1.8.3 1.2.5.3.2.6.5.8.8.2.3.3.7.3 1.1 0 .5-.2.9-.5 1.3-.4.4-.9.7-1.5.9v.1c.6.1 1.2.4 1.6.8.4.4.7.9.7 1.5 0 .4-.1.8-.3 1.2-.2.4-.5.7-.9.9-.4.3-.9.4-1.3.5-.5.1-1 .2-1.6.2-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1l1.1-1.4zM7 9H3V5H1v10h2v-4h4v4h2V5H7v4z',
4: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm10-2h-1v2h-2v-2h-5v-2l4-6h3v6h1v2zm-3-2V7l-2.8 4H16z',
5: 'M12.1 12.2c.4.3.7.5 1.1.7.4.2.9.3 1.3.3.5 0 1-.1 1.4-.4.4-.3.6-.7.6-1.1 0-.4-.2-.9-.6-1.1-.4-.3-.9-.4-1.4-.4H14c-.1 0-.3 0-.4.1l-.4.1-.5.2-1-.6.3-5h6.4v1.9h-4.3L14 8.8c.2-.1.5-.1.7-.2.2 0 .5-.1.7-.1.5 0 .9.1 1.4.2.4.1.8.3 1.1.6.3.2.6.6.8.9.2.4.3.9.3 1.4 0 .5-.1 1-.3 1.4-.2.4-.5.8-.9 1.1-.4.3-.8.5-1.3.7-.5.2-1 .3-1.5.3-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1-.1-.1 1-1.5 1-1.5zM9 15H7v-4H3v4H1V5h2v4h4V5h2v10z',
6: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm8.6-7.5c-.2-.2-.5-.4-.8-.5-.6-.2-1.3-.2-1.9 0-.3.1-.6.3-.8.5l-.6.9c-.2.5-.2.9-.2 1.4.4-.3.8-.6 1.2-.8.4-.2.8-.3 1.3-.3.4 0 .8 0 1.2.2.4.1.7.3 1 .6.3.3.5.6.7.9.2.4.3.8.3 1.3s-.1.9-.3 1.4c-.2.4-.5.7-.8 1-.4.3-.8.5-1.2.6-1 .3-2 .3-3 0-.5-.2-1-.5-1.4-.9-.4-.4-.8-.9-1-1.5-.2-.6-.3-1.3-.3-2.1s.1-1.6.4-2.3c.2-.6.6-1.2 1-1.6.4-.4.9-.7 1.4-.9.6-.3 1.1-.4 1.7-.4.7 0 1.4.1 2 .3.5.2 1 .5 1.4.8 0 .1-1.3 1.4-1.3 1.4zm-2.4 5.8c.2 0 .4 0 .6-.1.2 0 .4-.1.5-.2.1-.1.3-.3.4-.5.1-.2.1-.5.1-.7 0-.4-.1-.8-.4-1.1-.3-.2-.7-.3-1.1-.3-.3 0-.7.1-1 .2-.4.2-.7.4-1 .7 0 .3.1.7.3 1 .1.2.3.4.4.6.2.1.3.3.5.3.2.1.5.2.7.1z',
};
if ( ! levelToPath.hasOwnProperty( level ) ) {
return null;
}
return (
<SVG
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<Path d={ levelToPath[ level ] } />
</SVG>
);
}

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { range } from 'lodash';
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { Toolbar } from '@wordpress/components';
/**
* Internal dependencies
*/
import HeadingLevelIcon from './heading-level-icon';
/**
* HeadingToolbar component.
*
* Allows the heading level to be chosen for a title block.
*/
class HeadingToolbar extends Component {
createLevelControl( targetLevel, selectedLevel, onChange ) {
const isActive = targetLevel === selectedLevel;
return {
icon: <HeadingLevelIcon level={ targetLevel } />,
// translators: %s: heading level e.g: "1", "2", "3"
title: sprintf( __( 'Heading %d' ), targetLevel ),
isActive,
onClick: () => onChange( targetLevel ),
};
}
render() {
const {
isCollapsed = true,
minLevel,
maxLevel,
selectedLevel,
onChange,
} = this.props;
return (
<Toolbar
isCollapsed={ isCollapsed }
icon={ <HeadingLevelIcon level={ selectedLevel } /> }
controls={ range( minLevel, maxLevel ).map( ( index ) =>
this.createLevelControl( index, selectedLevel, onChange )
) }
/>
);
}
}
export default HeadingToolbar;

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

View File

@ -0,0 +1,131 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import apiFetch from '@wordpress/api-fetch';
import { __, sprintf } from '@wordpress/i18n';
import { Component } from 'react';
import { addQueryArgs } from '@wordpress/url';
class ProductButton extends Component {
static propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
state = {
addedToCart: false,
addingToCart: false,
cartQuantity: null,
};
onAddToCart = () => {
const { product } = this.props;
this.setState( { addingToCart: true } );
return apiFetch( {
method: 'POST',
path: '/wc/blocks/cart/add',
data: {
product_id: product.id,
quantity: 1,
},
cache: 'no-store',
} )
.then( ( response ) => {
const newQuantity = response.quantity;
this.setState( {
addedToCart: true,
addingToCart: false,
cartQuantity: newQuantity,
} );
} )
.catch( ( response ) => {
if ( response.code ) {
return ( document.location.href = addQueryArgs(
product.permalink,
{ wc_error: response.message }
) );
}
document.location.href = product.permalink;
} );
};
getButtonText = () => {
const { product } = this.props;
const { cartQuantity } = this.state;
if ( Number.isFinite( cartQuantity ) ) {
return sprintf(
__( '%d in cart', 'woo-gutenberg-products-block' ),
cartQuantity
);
}
return product.add_to_cart.text;
};
render = () => {
const { product, className } = this.props;
const { addingToCart, addedToCart } = this.state;
const wrapperClasses = classnames(
className,
'wc-block-grid__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 ) {
return (
<div className={ wrapperClasses }>
<button className={ buttonClasses } disabled={ true } />
</div>
);
}
const allowAddToCart =
! product.has_options &&
product.is_purchasable &&
product.is_in_stock;
const buttonText = this.getButtonText();
return (
<div className={ wrapperClasses }>
{ allowAddToCart ? (
<button
onClick={ this.onAddToCart }
aria-label={ product.add_to_cart.description }
className={ buttonClasses }
disabled={ addingToCart }
>
{ buttonText }
</button>
) : (
<a
href={ product.permalink }
aria-label={ product.add_to_cart.description }
className={ buttonClasses }
rel="nofollow"
>
{ buttonText }
</a>
) }
</div>
);
};
}
export default ProductButton;

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { Component, Fragment } from 'react';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/block-settings';
import { ProductSaleBadge } from '../../../components/product';
class ProductImage extends Component {
static propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
productLink: PropTypes.bool,
showSaleBadge: PropTypes.bool,
saleBadgeAlign: PropTypes.string,
};
static defaultProps = {
productLink: true,
showSaleBadge: true,
saleBadgeAlign: 'right',
};
state = {
loaded: false,
};
onImageLoaded = () => {
this.setState( {
loaded: true,
} );
};
renderSaleBadge = () => {
const { product, saleBadgeAlign } = this.props;
return (
<ProductSaleBadge product={ product } align={ saleBadgeAlign } />
);
};
renderImage = ( image ) => {
const { loaded } = this.state;
return (
<Fragment>
{ image && (
<img
className="wc-block-grid__product-image__image"
src={ image.thumbnail }
srcSet={ image.srcset }
sizes={ image.sizes }
alt={ image.alt }
onLoad={ this.onImageLoaded }
hidden={ ! loaded }
/>
) }
{ ! loaded && (
<img
className="wc-block-grid__product-image__image wc-block-grid__product-image__image_placeholder"
src={ PLACEHOLDER_IMG_SRC }
alt=""
/>
) }
</Fragment>
);
};
render() {
const { className, product, productLink, showSaleBadge } = this.props;
const image =
product.images && product.images.length ? product.images[ 0 ] : {};
return (
<div
className={ classnames(
className,
'wc-block-grid__product-image'
) }
>
{ productLink ? (
<a href={ product.permalink } rel="nofollow">
{ showSaleBadge && this.renderSaleBadge() }
{ this.renderImage( image ) }
</a>
) : (
<Fragment>
{ showSaleBadge && this.renderSaleBadge() }
{ this.renderImage( image ) }
</Fragment>
) }
</div>
);
}
}
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,67 @@
/**
* External dependencies
*/
import NumberFormat from 'react-number-format';
import classnames from 'classnames';
const ProductPrice = ( { className, product } ) => {
const prices = product.prices || {};
const numberFormatArgs = {
displayType: 'text',
thousandSeparator: prices.thousand_separator,
decimalSeparator: prices.decimal_separator,
decimalScale: prices.decimals,
prefix: prices.price_prefix,
suffix: prices.price_suffix,
};
if (
prices.price_range &&
prices.price_range.min_amount &&
prices.price_range.max_amount
) {
return (
<div
className={ classnames(
className,
'wc-block-grid__product-price'
) }
>
<span className="wc-block-grid__product-price__value">
<NumberFormat
value={ prices.price_range.min_amount }
{ ...numberFormatArgs }
/>
&nbsp;&mdash;&nbsp;
<NumberFormat
value={ prices.price_range.max_amount }
{ ...numberFormatArgs }
/>
</span>
</div>
);
}
return (
<div
className={ classnames(
className,
'wc-block-grid__product-price'
) }
>
{ prices.regular_price !== prices.price && (
<del className="wc-block-grid__product-price__regular">
<NumberFormat
value={ prices.regular_price }
{ ...numberFormatArgs }
/>
</del>
) }
<span className="wc-block-grid__product-price__value">
<NumberFormat value={ prices.price } { ...numberFormatArgs } />
</span>
</div>
);
};
export default ProductPrice;

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { __, sprintf } from '@wordpress/i18n';
import { Component } from 'react';
import classnames from 'classnames';
class ProductRating extends Component {
static propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
render = () => {
const { product, className } = this.props;
const rating = parseFloat( product.average_rating );
if ( ! Number.isFinite( rating ) || 0 === rating ) {
return null;
}
const starStyle = {
width: ( rating / 5 ) * 100 + '%',
};
return (
<div
className={ classnames(
className,
'wc-block-grid__product-rating'
) }
>
<div
className="wc-block-grid__product-rating__stars"
role="img"
>
<span style={ starStyle }>
{ sprintf(
__(
'Rated %d out of 5',
'woo-gutenberg-products-block'
),
rating
) }
</span>
</div>
</div>
);
};
}
export default ProductRating;

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
const ProductSaleBadge = ( { className, product, align } ) => {
const alignClass =
typeof align === 'string'
? 'wc-block-grid__product-onsale--align' + align
: '';
if ( product && product.onsale ) {
return (
<div
className={ classnames(
className,
alignClass,
'wc-block-grid__product-onsale'
) }
>
{ __( 'Sale', 'woo-gutenberg-products-block' ) }
</div>
);
}
return null;
};
export default ProductSaleBadge;

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
const ProductSummary = ( { className, product } ) => {
if ( ! product.description ) {
return null;
}
return (
<div
className={ classnames(
className,
'wc-block-grid__product-summary'
) }
dangerouslySetInnerHTML={ {
__html: product.description,
} }
/>
);
};
ProductSummary.propTypes = {
className: PropTypes.string,
product: PropTypes.object.isRequired,
};
export default ProductSummary;

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
const ProductTitle = ( { className, product, headingLevel, productLink } ) => {
if ( ! product.name ) {
return null;
}
const productName = product.name;
const TagName = `h${ headingLevel }`;
return (
<TagName
className={ classnames(
className,
'wc-block-grid__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,
};
ProductTitle.defaultProps = {
headingLevel: 2,
productLink: true,
};
export default ProductTitle;

View File

@ -20,26 +20,33 @@ const Pagination = ( {
onPageChange,
totalPages,
} ) => {
const { minIndex, maxIndex } = getIndexes(
let { minIndex, maxIndex } = getIndexes(
pagesToDisplay,
currentPage,
totalPages
);
const pages = [];
for ( let i = minIndex; i <= maxIndex; i++ ) {
pages.push( i );
}
const showFirstPage = displayFirstAndLastPages && Boolean( minIndex !== 1 );
const showLastPage =
displayFirstAndLastPages && Boolean( maxIndex !== totalPages );
const showFirstPageEllipsis =
displayFirstAndLastPages && Boolean( minIndex > 2 );
displayFirstAndLastPages && Boolean( minIndex > 3 );
const showLastPageEllipsis =
displayFirstAndLastPages && Boolean( maxIndex < totalPages - 1 );
const showPreviousArrow =
displayNextAndPreviousArrows && Boolean( currentPage !== 1 );
const showNextArrow =
displayNextAndPreviousArrows && Boolean( currentPage !== totalPages );
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">
@ -49,7 +56,7 @@ const Pagination = ( {
'woo-gutenberg-products-block'
) }
/>
{ showPreviousArrow && (
{ displayNextAndPreviousArrows && (
<button
className="wc-block-pagination-page"
onClick={ () => onPageChange( currentPage - 1 ) }
@ -57,6 +64,7 @@ const Pagination = ( {
'Previous page',
'woo-gutenberg-products-block'
) }
disabled={ currentPage <= 1 }
>
<Label
label="<"
@ -69,8 +77,11 @@ const Pagination = ( {
) }
{ showFirstPage && (
<button
className="wc-block-pagination-page"
className={ classNames( 'wc-block-pagination-page', {
'wc-block-pagination-page--active': currentPage === 1,
} ) }
onClick={ () => onPageChange( 1 ) }
disabled={ currentPage === 1 }
>
1
</button>
@ -96,6 +107,7 @@ const Pagination = ( {
? null
: () => onPageChange( page )
}
disabled={ currentPage === page }
>
{ page }
</button>
@ -111,17 +123,22 @@ const Pagination = ( {
) }
{ showLastPage && (
<button
className="wc-block-pagination-page"
className={ classNames( 'wc-block-pagination-page', {
'wc-block-pagination-page--active':
currentPage === totalPages,
} ) }
onClick={ () => onPageChange( totalPages ) }
disabled={ currentPage === totalPages }
>
{ totalPages }
</button>
) }
{ showNextArrow && (
{ displayNextAndPreviousArrows && (
<button
className="wc-block-pagination-page"
onClick={ () => onPageChange( currentPage + 1 ) }
title={ __( 'Next page', 'woo-gutenberg-products-block' ) }
disabled={ currentPage >= totalPages }
>
<Label
label=">"

View File

@ -1,3 +1,7 @@
.wc-block-pagination {
margin: 0 auto $gap;
}
.wc-block-pagination-page,
.wc-block-pagination-ellipsis {
color: #333;
@ -11,12 +15,30 @@
border-color: transparent;
padding: 0.3em 0.6em;
min-width: 2.2em;
@include breakpoint( "<782px" ) {
padding: 0.1em 0.2em;
min-width: 1.6em;
}
}
.wc-block-pagination-ellipsis {
padding: 0.3em;
@include breakpoint( "<782px" ) {
padding: 0.1em;
}
}
.wc-block-pagination-page--active {
.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

@ -7,15 +7,15 @@ describe( 'getIndexes', () => {
describe( 'when on the first page', () => {
test( 'indexes include the first pages available', () => {
expect( getIndexes( 5, 1, 100 ) ).toEqual( {
minIndex: 1,
maxIndex: 5,
minIndex: 2,
maxIndex: 6,
} );
} );
test( 'indexes include the only available page if there is only one', () => {
test( 'indexes are null if there are 2 pages or less', () => {
expect( getIndexes( 5, 1, 1 ) ).toEqual( {
minIndex: 1,
maxIndex: 1,
minIndex: null,
maxIndex: null,
} );
} );
} );
@ -32,8 +32,8 @@ describe( 'getIndexes', () => {
describe( 'when on the last page', () => {
test( 'indexes include the last pages available', () => {
expect( getIndexes( 5, 100, 100 ) ).toEqual( {
minIndex: 96,
maxIndex: 100,
minIndex: 95,
maxIndex: 99,
} );
} );
} );

View File

@ -8,23 +8,26 @@
* @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 ),
1
2
);
const maxIndex = Math.min(
Math.ceil(
currentPage +
( extraPagesToDisplay - ( currentPage - tentativeMinIndex ) )
),
totalPages
totalPages - 1
);
const minIndex = Math.max(
Math.floor(
currentPage - ( extraPagesToDisplay - ( maxIndex - currentPage ) )
),
1
2
);
return { minIndex, maxIndex };

View File

@ -0,0 +1,41 @@
/**
* External dependencies.
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
/**
* Internal dependencies.
*/
import { useInnerBlockParentNameContext } from '@woocommerce/base-context/inner-block-parent-name-context';
import withComponentId from '@woocommerce/base-hocs/with-component-id';
import { renderProductLayout } from './utils';
const ProductListItem = ( { product, attributes, componentId } ) => {
const { layoutConfig } = attributes;
const blockName = useInnerBlockParentNameContext();
const isLoading = ! Object.keys( product ).length > 0;
const classes = classnames( 'wc-block-grid__product', {
'is-loading': isLoading,
} );
return (
<li className={ classes } aria-hidden={ isLoading }>
{ renderProductLayout(
blockName,
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,59 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import ProductList from './index';
import withQueryStringValues from '@woocommerce/base-hocs/with-query-string-values';
class ProductListContainer extends Component {
onPageChange = ( newPage ) => {
this.props.updateQueryStringValues( {
product_page: newPage,
} );
};
onSortChange = ( event ) => {
const newSortValue = event.target.value;
this.props.updateQueryStringValues( {
product_sort: newSortValue,
product_page: 1,
} );
};
render() {
// eslint-disable-next-line camelcase
const { attributes, product_page, product_sort } = this.props;
const currentPage = parseInt( product_page );
const sortValue = product_sort || attributes.orderby; // eslint-disable-line camelcase
return (
<ProductList
attributes={ attributes }
currentPage={ currentPage }
onPageChange={ this.onPageChange }
onSortChange={ this.onSortChange }
sortValue={ sortValue }
/>
);
}
}
ProductListContainer.propTypes = {
attributes: PropTypes.object.isRequired,
// From withQueryStringValues
product_page: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ),
product_sort: PropTypes.string,
};
ProductListContainer.defaultProps = {
product_page: 1,
};
export default withQueryStringValues( [ 'product_page', 'product_sort' ] )(
ProductListContainer
);

View File

@ -0,0 +1,127 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
/**
* Internal dependencies
*/
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 {
useStoreProducts,
useSynchronizedQueryState,
} from '@woocommerce/base-hooks';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
import './style.scss';
const generateQuery = ( { sortValue, currentPage, attributes } ) => {
const { columns, rows } = attributes;
const 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',
};
}
};
return {
...getSortArgs( sortValue ),
per_page: columns * rows,
page: currentPage,
};
};
const ProductList = ( {
attributes,
currentPage,
onPageChange,
onSortChange,
sortValue,
scrollToTop,
} ) => {
// @todo query-state context (which is hardcoded 'product-grid' here) should
// be provided via a provider. This allows us to control query state context
// at the app level for anything nested within the provider.
const [ queryState ] = useSynchronizedQueryState(
'product-grid',
generateQuery( { attributes, sortValue, currentPage } )
);
// @todo should add an <ErrorBoundary> in parent component to handle any
// errors from the store and format etc.
const { products, totalProducts } = useStoreProducts( queryState );
const onPaginationChange = ( newPage ) => {
scrollToTop( { focusableSelector: 'a, button' } );
onPageChange( newPage );
};
const getClassnames = () => {
const { columns, rows, className, alignButtons, align } = attributes;
const alignClass = typeof align !== 'undefined' ? 'align' + align : '';
return classnames(
'wc-block-grid',
className,
alignClass,
'has-' + columns + '-columns',
{
'has-multiple-rows': rows > 1,
'has-aligned-buttons': alignButtons,
}
);
};
const { contentVisibility } = attributes;
const perPage = attributes.columns * attributes.rows;
const totalPages = Math.ceil( totalProducts / perPage );
const listProducts = products.length
? products
: Array.from( { length: perPage } );
return (
<div className={ getClassnames() }>
{ contentVisibility.orderBy && (
<ProductSortSelect
onChange={ onSortChange }
value={ sortValue }
/>
) }
<ul className="wc-block-grid__products">
{ listProducts.map( ( product = {}, i ) => (
<ProductListItem
key={ product.id || i }
attributes={ attributes }
product={ product }
/>
) ) }
</ul>
{ totalProducts > perPage && (
<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,353 @@
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wc-block-grid {
text-align: center;
}
.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 !important;
display: 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();
}
.is-loading &::before {
color: transparent;
content: ".";
display: inline-block;
width: 5em;
}
}
}
.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-onsale {
line-height: 1;
}
}

View File

@ -7,12 +7,13 @@ import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import OrderSelect from '@woocommerce/base-components/order-select';
import SortSelect from '@woocommerce/base-components/sort-select';
import './style.scss';
const ProductOrderSelect = ( { defaultValue, onChange, readOnly, value } ) => {
const ProductSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
return (
<OrderSelect
className="wc-block-product-order-select"
<SortSelect
className="wc-block-product-sort-select"
defaultValue={ defaultValue }
name="orderby"
onChange={ onChange }
@ -64,7 +65,7 @@ const ProductOrderSelect = ( { defaultValue, onChange, readOnly, value } ) => {
);
};
ProductOrderSelect.propTypes = {
ProductSortSelect.propTypes = {
defaultValue: PropTypes.oneOf( [
'menu_order',
'popularity',
@ -85,4 +86,4 @@ ProductOrderSelect.propTypes = {
] ),
};
export default ProductOrderSelect;
export default ProductSortSelect;

View File

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

View File

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

View File

@ -7,13 +7,13 @@ import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import OrderSelect from '@woocommerce/base-components/order-select';
import SortSelect from '@woocommerce/base-components/sort-select';
import './style.scss';
const ReviewOrderSelect = ( { defaultValue, onChange, readOnly, value } ) => {
const ReviewSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
return (
<OrderSelect
className="wc-block-review-order-select"
<SortSelect
className="wc-block-review-sort-select"
defaultValue={ defaultValue }
label={ __( 'Order by', 'woo-gutenberg-products-block' ) }
onChange={ onChange }
@ -47,7 +47,7 @@ const ReviewOrderSelect = ( { defaultValue, onChange, readOnly, value } ) => {
);
};
ReviewOrderSelect.propTypes = {
ReviewSortSelect.propTypes = {
defaultValue: PropTypes.oneOf( [
'most-recent',
'highest-rating',
@ -62,4 +62,4 @@ ReviewOrderSelect.propTypes = {
] ),
};
export default ReviewOrderSelect;
export default ReviewSortSelect;

View File

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

View File

@ -15,7 +15,7 @@ import './style.scss';
* Component used for 'Order by' selectors, which renders a label
* and a <select> with the options provided in the props.
*/
const OrderSelect = ( {
const SortSelect = ( {
className,
componentId,
defaultValue,
@ -26,22 +26,22 @@ const OrderSelect = ( {
readOnly,
value,
} ) => {
const selectId = `wc-block-order-select__select-${ componentId }`;
const selectId = `wc-block-sort-select__select-${ componentId }`;
return (
<p className={ classNames( 'wc-block-order-select', className ) }>
<div className={ classNames( 'wc-block-sort-select', className ) }>
<Label
label={ label }
screenReaderLabel={ screenReaderLabel }
wrapperElement="label"
wrapperProps={ {
className: 'wc-block-order-select__label',
className: 'wc-block-sort-select__label',
htmlFor: selectId,
} }
/>
<select // eslint-disable-line jsx-a11y/no-onchange
id={ selectId }
className="wc-block-order-select__select"
className="wc-block-sort-select__select"
defaultValue={ defaultValue }
onChange={ onChange }
readOnly={ readOnly }
@ -53,11 +53,11 @@ const OrderSelect = ( {
</option>
) ) }
</select>
</p>
</div>
);
};
OrderSelect.propTypes = {
SortSelect.propTypes = {
defaultValue: PropTypes.string,
label: PropTypes.string,
onChange: PropTypes.func,
@ -74,4 +74,4 @@ OrderSelect.propTypes = {
componentId: PropTypes.number.isRequired,
};
export default withComponentId( OrderSelect );
export default withComponentId( SortSelect );

View File

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

View File

@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
const InnerBlockParentNameContext = createContext( { blockName: null } );
export const useInnerBlockParentNameContext = () =>
useContext( InnerBlockParentNameContext );
export const InnerBlockParentNameProvider =
InnerBlockParentNameContext.Provider;

View File

@ -0,0 +1,13 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
const ProductLayoutContext = createContext( {
layoutStyleClassPrefix: '',
} );
export const useProductLayoutContext = () => {
useContext( ProductLayoutContext );
};
export const ProductLayoutContextProvider = ProductLayoutContext.Provider;

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,123 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withQueryStringValues from '../with-query-string-values';
delete global.window.location;
describe( 'withQueryStringValues Component', () => {
let TestComponent;
let render;
beforeEach( () => {
TestComponent = withQueryStringValues( [ 'name' ] )( ( props ) => {
return (
<div
name={ props.name }
updateQueryStringValues={ props.updateQueryStringValues }
/>
);
} );
} );
it( 'reads the correct query string value for each instance', () => {
global.window.location = {
href:
'https://www.wooocommerce.com/?name=Alice&name_2=Bob&name_3=Carol',
};
const renderer = TestRenderer.create(
<main>
<TestComponent />
<TestComponent />
<TestComponent />
</main>
);
const elements = renderer.root.findAllByType( 'div' );
expect( elements[ 0 ].props.name ).toBe( 'Alice' );
expect( elements[ 1 ].props.name ).toBe( 'Bob' );
expect( elements[ 2 ].props.name ).toBe( 'Carol' );
} );
describe( 'lifecycle methods', () => {
beforeEach( () => {
render = () => TestRenderer.create( <TestComponent /> );
window.addEventListener = jest.fn();
window.removeEventListener = jest.fn();
} );
afterEach( () => {
window.addEventListener.mockReset();
window.removeEventListener.mockReset();
} );
it( 'subscribes to popstate events on mount', () => {
render();
const { calls } = window.addEventListener.mock;
const addedPopStateEventListener = calls.reduce(
( acc, call ) => acc || call[ 0 ] === 'popstate',
false
);
expect( addedPopStateEventListener ).toBe( true );
} );
it( 'unsubscribes from popstate events on unmount', () => {
const renderer = render();
renderer.unmount();
const { calls } = window.removeEventListener.mock;
const removedPopStateEventListener = calls.reduce(
( acc, call ) => acc || call[ 0 ] === 'popstate',
false
);
expect( removedPopStateEventListener ).toBe( true );
} );
} );
describe( 'state', () => {
beforeEach( () => {
render = () => TestRenderer.create( <TestComponent /> );
global.window.location = {
href: 'https://www.wooocommerce.com/?name=Alice',
};
window.history.pushState = jest.fn();
} );
afterEach( () => {
window.history.pushState.mockReset();
} );
it( 'gets state from location', () => {
const renderer = render();
const props = renderer.root.findByType( 'div' ).props;
expect( props.name ).toBe( 'Alice' );
} );
it( 'pushes to history on values update', () => {
const renderer = render();
const initialProps = renderer.root.findByType( 'div' ).props;
initialProps.updateQueryStringValues( { name: 'Bob' } );
const finalProps = renderer.root.findByType( 'div' ).props;
expect( finalProps.name ).toBe( 'Bob' );
expect( window.history.pushState ).toHaveBeenCalledTimes( 1 );
expect(
window.history.pushState.mock.calls[ 0 ][ 2 ].endsWith(
'?name=Bob'
)
).toBe( true );
} );
} );
} );

View File

@ -11,7 +11,7 @@ import * as mockUtils from '../../../blocks/reviews/utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../../../blocks/reviews/utils', () => ( {
getOrderArgs: () => ( {
getSortArgs: () => ( {
order: 'desc',
orderby: 'date_gmt',
} ),

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,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,94 @@
import { Component } from 'react';
import { addQueryArgs, getQueryArg } from '@wordpress/url';
const hasWindowDependencies =
typeof window === 'object' &&
window.hasOwnProperty( 'history' ) &&
window.hasOwnProperty( 'location' ) &&
typeof window.addEventListener === 'function' &&
typeof window.removeEventListener === 'function';
/**
* HOC that keeps the state in sync with the URL query string.
*/
const withQueryStringValues = ( values ) => ( OriginalComponent ) => {
let instances = 0;
class WrappedComponent extends Component {
// In case there is more than one component reading the query string values in the same page,
// add a suffix to all of them but the first one, so they read the correct values.
urlParameterSuffix = instances++ > 0 ? `_${ instances }` : '';
getStateFromLocation = () => {
const state = {};
if ( hasWindowDependencies ) {
values.forEach( ( value ) => {
state[ value ] = getQueryArg(
window.location.href,
value + this.urlParameterSuffix
);
} );
}
return state;
};
state = this.getStateFromLocation();
componentDidMount = () => {
if ( hasWindowDependencies ) {
window.addEventListener(
'popstate',
this.updateStateFromLocation
);
}
};
componentWillUnmount = () => {
if ( hasWindowDependencies ) {
window.removeEventListener(
'popstate',
this.updateStateFromLocation
);
}
};
updateStateFromLocation = () => {
this.setState( this.getStateFromLocation() );
};
updateQueryStringValues = ( newValues ) => {
this.setState( newValues );
if ( hasWindowDependencies ) {
const queryStringValues = {};
Object.keys( newValues ).forEach( ( key ) => {
queryStringValues[ key + this.urlParameterSuffix ] =
newValues[ key ];
} );
window.history.pushState(
null,
'',
addQueryArgs( window.location.href, queryStringValues )
);
}
};
render() {
return (
<OriginalComponent
{ ...this.props }
{ ...this.state }
updateQueryStringValues={ this.updateQueryStringValues }
/>
);
}
}
WrappedComponent.displayName = 'withQueryStringValues';
return WrappedComponent;
};
export default withQueryStringValues;

View File

@ -0,0 +1,72 @@
/**
* External dependencies
*/
import { Component, createRef, Fragment } from 'react';
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,3 @@
export * from './use-query-state';
export * from './use-shallow-equal';
export * from './use-store-products';

View File

@ -0,0 +1,254 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
useQueryStateContext,
useQueryStateByKey,
useSynchronizedQueryState,
} from '../use-query-state';
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
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( 'useQueryStateContext', () => {
const TestComponent = getTestComponent( useQueryStateContext, [
'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( { foo: 'bar' } );
}
);
} );
describe( 'useQueryStateByKey', () => {
const TestComponent = getTestComponent( useQueryStateByKey, [
'context',
'queryKey',
] );
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( {
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, [
'context',
'synchronizedQuery',
] );
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,203 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { Component as ReactComponent } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreProducts } from '../use-store-products';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
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( '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 }>
<TestErrorBoundary>
<Component { ...props } />
</TestErrorBoundary>
</RegistryProvider>
);
const getTestComponent = ( options ) => ( { query } ) => {
const items = useStoreProducts( query, options );
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 throw an error if an options object is provided without ' +
'a namespace property',
() => {
const TestComponent = getTestComponent( { modelName: 'products' } );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
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 modelName property',
() => {
const TestComponent = getTestComponent( {
namespace: '/wc/blocks',
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
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 use the default options if options not provided', () => {
const TestComponent = getTestComponent();
const {
getCollection,
getCollectionHeader,
hasFinishedResolution,
} = mocks.selectors;
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
expect( getCollection ).toHaveBeenCalledWith(
{},
'/wc/blocks',
'products',
{ bar: 'foo' }
);
expect( getCollectionHeader ).toHaveBeenCalledWith(
{},
'x-wp-total',
'/wc/blocks',
'products',
{ bar: 'foo' }
);
expect( hasFinishedResolution ).toHaveBeenCalledWith(
{},
'getCollection',
[ '/wc/blocks', 'products', { bar: 'foo' } ]
);
renderer.unmount();
} );
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,101 @@
/**
* External dependencies
*/
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect } from '@wordpress/element';
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.
*
* @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 useQueryStateContext = ( context ) => {
const queryState = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryContext( context, undefined );
},
[ context ]
);
const { setValueForQueryContext: setQueryState } = useDispatch( storeKey );
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 {string} context What context to retrieve the query state for.
* @param {*} queryKey The specific query key to retrieve the value for.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
*/
export const useQueryStateByKey = ( context, queryKey ) => {
const queryValue = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryKey( context, queryKey, undefined );
},
[ context, queryKey ]
);
const { setQueryValue } = useDispatch( storeKey );
return [ queryValue, setQueryValue ];
};
/**
* A custom hook that works similarly to useQueryStateContext. 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 {string} context What context to retrieve the query state
* for.
* @param {Object} synchronizedQuery A provided query state object to
* synchronize internal query state with.
*/
export const useSynchronizedQueryState = ( context, synchronizedQuery ) => {
const [ queryState, setQueryState ] = useQueryStateContext( 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( context, {
...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,75 @@
/**
* 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';
const DEFAULT_OPTIONS = {
namespace: '/wc/blocks',
modelName: 'products',
};
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store. 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.
* @param {Object} options An optional object for adjusting the namespace and
* modelName for the products query.
*
* @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, options = DEFAULT_OPTIONS ) => {
const { namespace, modelName } = options;
if ( ! namespace || ! modelName ) {
throw new Error(
'If you provide an options object, you must have valid values ' +
'for the namespace and the modelName properties.'
);
}
// ensure we feed the previous reference object if it's equivalent
const currentQuery = useShallowEqual( query );
const {
products = [],
totalProducts = 0,
productsLoading = true,
} = useSelect(
( select ) => {
const store = select( storeKey );
// filter out query if it is undefined.
const args = [ namespace, modelName, currentQuery ].filter(
( item ) => typeof item !== undefined
);
return {
products: store.getCollection( ...args ),
totalProducts: store.getCollectionHeader(
'x-wp-total',
...args
),
productsLoading: store.hasFinishedResolution(
'getCollection',
args
),
};
},
[ namespace, modelName, currentQuery ]
);
return {
products,
totalProducts,
productsLoading,
};
};

View File

@ -0,0 +1,19 @@
/**
* Internal dependencies
*/
import { registeredBlocks } from './registered-blocks-init';
/**
* Retrieves the inner blocks registered as a child of a specific one.
*
* @export
* @param {string} main Name of the parent block to retrieve children of.
*
* @returns {Object}
*/
export function getRegisteredInnerBlocks( main ) {
return typeof registeredBlocks[ main ] === 'object' &&
Object.keys( registeredBlocks[ main ] ).length > 0
? registeredBlocks[ main ]
: {};
}

View File

@ -0,0 +1,2 @@
export { getRegisteredInnerBlocks } from './get-registered-inner-blocks';
export { registerInnerBlock } from './register-inner-block';

View File

@ -0,0 +1,43 @@
/**
* Internal dependencies
*/
import { registeredBlocks } from './registered-blocks-init';
/**
* Asserts that an option is of the given type. Otherwise, throws an error.
*
* @throws Will throw an error if the type of the option doesn't match the expected type.
* @param {Object} options Object containing the option to validate.
* @param {string} optionName Name of the option to validate.
* @param {string} expectedType Type expected for the option.
*/
const assertOption = ( options, optionName, expectedType ) => {
if ( typeof options[ optionName ] !== expectedType ) {
throw new Error(
`Incorrect value for the ${ optionName } argument when registering an inner block. It must be a ${ expectedType }.`
);
}
};
/**
* Registers an inner block that can be added as a child of another block.
*
* @export
* @param {Object} options Options to use when registering the block.
* @param {string} options.main Name of the parent block.
* @param {string} options.blockName Name of the child block being registered.
* @param {function} options.component React component used to render the child block.
*/
export function registerInnerBlock( options ) {
assertOption( options, 'main', 'string' );
assertOption( options, 'blockName', 'string' );
assertOption( options, 'component', 'function' );
const { main, blockName, component } = options;
if ( ! registeredBlocks[ main ] ) {
registeredBlocks[ main ] = {};
}
registeredBlocks[ main ][ blockName ] = component;
}

View File

@ -0,0 +1,3 @@
const registeredBlocks = {};
export { registeredBlocks };

View File

@ -0,0 +1,43 @@
/**
* Internal dependencies
*/
import { getRegisteredInnerBlocks, registerInnerBlock } from '../index';
describe( 'blocks registry', () => {
const main = '@woocommerce/all-products';
const blockName = '@woocommerce-extension/price-level';
const component = () => {};
describe( 'registerInnerBlock', () => {
const invokeTest = ( args ) => () => {
return registerInnerBlock( args );
};
it( 'throws an error when registered block is missing `main`', () => {
expect( invokeTest( { main: null } ) ).toThrowError( /main/ );
} );
it( 'throws an error when registered block is missing `blockName`', () => {
expect( invokeTest( { main, blockName: null } ) ).toThrowError(
/blockName/
);
} );
it( 'throws an error when registered block is missing `component`', () => {
expect(
invokeTest( { main, blockName, component: null } )
).toThrowError( /component/ );
} );
} );
describe( 'getRegisteredInnerBlocks', () => {
it( 'gets an empty object when parent has no inner blocks', () => {
expect(
getRegisteredInnerBlocks( '@woocommerce/all-products' )
).toEqual( {} );
} );
it( 'gets a block that was successfully registered', () => {
registerInnerBlock( { main, blockName, component } );
expect(
getRegisteredInnerBlocks( '@woocommerce/all-products' )
).toEqual( { [ blockName ]: component } );
} );
} );
} );

View File

@ -5,7 +5,7 @@ import { DEFAULT_HEIGHT } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { previewProducts } from '../../previews/products';
import { previewProducts } from '@woocommerce/resource-previews';
export const example = {
attributes: {

View File

@ -62,7 +62,7 @@ class ProductsBlock extends Component {
/>
<ToggleControl
label={ __(
'Align Add to Cart buttons',
'Align Buttons',
'woo-gutenberg-products-block'
) }
help={

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import ProductListContainer from '@woocommerce/base-components/product-list/container';
import { InnerBlockParentNameProvider } from '@woocommerce/base-context/inner-block-parent-name-context';
import { ProductLayoutContextProvider } from '@woocommerce/base-context/product-layout-context';
const layoutStyleContext = {
layoutStyleClassPrefix: 'wc-block-grid',
};
/**
* The All Products Block. @todo
*/
class Block extends Component {
static propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
};
render() {
const { attributes, urlParameterSuffix } = this.props;
/**
* Todo classes
*
* wp-block-{$this->block_name},
* wc-block-{$this->block_name},
*/
return (
<InnerBlockParentNameProvider value="woocommerce/all-products">
<ProductLayoutContextProvider value={ layoutStyleContext }>
<ProductListContainer
attributes={ attributes }
urlParameterSuffix={ urlParameterSuffix }
/>
</ProductLayoutContextProvider>
</InnerBlockParentNameProvider>
);
}
}
export default Block;

View File

@ -0,0 +1,305 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock } from '@wordpress/blocks';
import {
BlockControls,
InnerBlocks,
InspectorControls,
} from '@wordpress/editor';
import { withDispatch, withSelect } from '@wordpress/data';
import {
PanelBody,
withSpokenMessages,
Placeholder,
Button,
IconButton,
Toolbar,
Disabled,
Tip,
} from '@wordpress/components';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
import Gridicon from 'gridicons';
/**
* Internal dependencies
*/
import {
renderHiddenContentPlaceholder,
renderNoProductsPlaceholder,
getBlockClassName,
} from '../utils';
import {
DEFAULT_PRODUCT_LIST_LAYOUT,
getBlockMap,
getProductLayoutConfig,
} from '../base-utils';
import { getSharedContentControls, getSharedListControls } from '../edit';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import { HAS_PRODUCTS } from '@woocommerce/block-settings';
import Block from './block';
/**
* Component to handle edit mode of "All Products".
*/
class Editor extends Component {
static propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
/**
* From withSpokenMessages.
*/
debouncedSpeak: PropTypes.func.isRequired,
};
state = {
isEditing: false,
innerBlocks: [],
};
blockMap = getBlockMap( 'woocommerce/all-products' );
componentDidMount = () => {
const { block } = this.props;
this.setState( { innerBlocks: block.innerBlocks } );
};
getTitle = () => {
return __( 'All Products', 'woo-gutenberg-products-block' );
};
getIcon = () => {
return <Gridicon icon="grid" />;
};
togglePreview = () => {
const { debouncedSpeak } = this.props;
this.setState( { isEditing: ! this.state.isEditing } );
if ( ! this.state.isEditing ) {
debouncedSpeak(
__(
'Showing All Products block preview.',
'woo-gutenberg-products-block'
)
);
}
};
getInspectorControls = () => {
const { attributes, setAttributes } = this.props;
const { columns, rows, alignButtons } = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Layout Settings',
'woo-gutenberg-products-block'
) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __(
'Content Settings',
'woo-gutenberg-products-block'
) }
>
{ getSharedContentControls( attributes, setAttributes ) }
{ getSharedListControls( attributes, setAttributes ) }
</PanelBody>
</InspectorControls>
);
};
getBlockControls = () => {
const { isEditing } = this.state;
return (
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit', 'woo-gutenberg-products-block' ),
onClick: () => this.togglePreview(),
isActive: isEditing,
},
] }
/>
</BlockControls>
);
};
renderEditMode = () => {
const onDone = () => {
const { block, setAttributes } = this.props;
setAttributes( {
layoutConfig: getProductLayoutConfig(
this.blockMap,
block.innerBlocks
),
} );
this.setState( { innerBlocks: block.innerBlocks } );
this.togglePreview();
};
const onCancel = () => {
const { block, replaceInnerBlocks } = this.props;
const { innerBlocks } = this.state;
replaceInnerBlocks( block.clientId, innerBlocks, false );
this.togglePreview();
};
const onReset = () => {
const { block, replaceInnerBlocks } = this.props;
const newBlocks = [];
DEFAULT_PRODUCT_LIST_LAYOUT.map( ( [ name, attributes ] ) => {
newBlocks.push( createBlock( name, attributes ) );
return true;
} );
replaceInnerBlocks( block.clientId, newBlocks, false );
this.setState( { innerBlocks: block.innerBlocks } );
};
const InnerBlockProps = {
template: this.props.attributes.layoutConfig,
templateLock: false,
allowedBlocks: Object.keys( this.blockMap ),
};
if ( 0 !== this.props.attributes.layoutConfig.length ) {
InnerBlockProps.renderAppender = false;
}
return (
<Placeholder icon={ this.getIcon() } label={ this.getTitle() }>
{ __(
'Display all products from your store as a grid.',
'woo-gutenberg-products-block'
) }
<div className="wc-block-all-products-grid-item-template">
<Tip>
{ __(
'Edit the blocks inside the preview below to change the content displayed for each product within the product grid.',
'woo-gutenberg-products-block'
) }
</Tip>
<div className="wc-block-grid has-1-columns">
<ul className="wc-block-grid__products">
<li className="wc-block-grid__product">
<InnerBlocks { ...InnerBlockProps } />
</li>
</ul>
</div>
<div className="wc-block-all-products__actions">
<Button
className="wc-block-all-products__done-button"
isPrimary
isLarge
onClick={ onDone }
>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
<Button
className="wc-block-all-products__cancel-button"
isTertiary
onClick={ onCancel }
>
{ __( 'Cancel', 'woo-gutenberg-products-block' ) }
</Button>
<IconButton
className="wc-block-all-products__reset-button"
icon={ <Gridicon icon="grid" /> }
label={ __(
'Reset layout to default',
'woo-gutenberg-products-block'
) }
onClick={ onReset }
>
{ __(
'Reset Layout',
'woo-gutenberg-products-block'
) }
</IconButton>
</div>
</div>
</Placeholder>
);
};
renderViewMode = () => {
const { attributes } = this.props;
const { layoutConfig } = attributes;
const hasContent = layoutConfig && 0 !== layoutConfig.length;
const blockTitle = this.getTitle();
const blockIcon = this.getIcon();
if ( ! hasContent ) {
return renderHiddenContentPlaceholder( blockTitle, blockIcon );
}
return (
<Disabled>
<Block attributes={ attributes } />
</Disabled>
);
};
render = () => {
const { attributes } = this.props;
const { isEditing } = this.state;
const blockTitle = this.getTitle();
const blockIcon = this.getIcon();
if ( ! HAS_PRODUCTS ) {
return renderNoProductsPlaceholder( blockTitle, blockIcon );
}
return (
<div
className={ getBlockClassName(
'wc-block-all-products',
attributes
) }
>
{ this.getBlockControls() }
{ this.getInspectorControls() }
{ isEditing ? this.renderEditMode() : this.renderViewMode() }
</div>
);
};
}
export default compose(
withSpokenMessages,
withSelect( ( select, { clientId } ) => {
const { getBlock } = select( 'core/block-editor' );
return {
block: getBlock( clientId ),
};
} ),
withDispatch( ( dispatch ) => {
const { replaceInnerBlocks } = dispatch( 'core/block-editor' );
return {
replaceInnerBlocks,
};
} )
)( Editor );

View File

@ -0,0 +1,11 @@
/**
* Internal dependencies
*/
import Block from './block';
import renderFrontend from '../../../utils/render-frontend.js';
const getProps = ( el ) => ( {
attributes: JSON.parse( el.dataset.attributes ),
} );
renderFrontend( '.wp-block-woocommerce-all-products', Block, getProps );

View File

@ -0,0 +1,67 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/editor';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
/**
* Internal dependencies
*/
import Editor from './edit';
import sharedAttributes from '../attributes';
import { getBlockClassName } from '../utils.js';
import '../../../atomic/blocks/product';
/**
* Register and run the "All Products" block.
*/
registerBlockType( 'woocommerce/all-products', {
title: __( 'All Products', 'woo-gutenberg-products-block' ),
icon: {
src: <Gridicon icon="grid" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
description: __(
'Display all products from your store as a grid.',
'woo-gutenberg-products-block'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
attributes: {
...sharedAttributes,
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save( { attributes } ) {
const data = {
'data-attributes': JSON.stringify( attributes ),
};
return (
<div
className={ getBlockClassName(
'wc-block-all-products',
attributes
) }
{ ...data }
>
<InnerBlocks.Content />
</div>
);
},
} );

View File

@ -0,0 +1,57 @@
/**
* Internal dependencies
*/
import { DEFAULT_COLUMNS, DEFAULT_ROWS } from '@woocommerce/block-settings';
import { DEFAULT_PRODUCT_LIST_LAYOUT } from './base-utils';
export default {
/**
* Number of columns.
*/
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
/**
* Number of rows.
*/
rows: {
type: 'number',
default: DEFAULT_ROWS,
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
orderBy: true,
},
},
/**
* Order to use for the products listing.
*/
orderby: {
type: 'string',
default: 'date',
},
/**
* Layout config.
*/
layoutConfig: {
type: 'array',
default: DEFAULT_PRODUCT_LIST_LAYOUT,
},
};

View File

@ -0,0 +1,70 @@
/**
* External dependencies
*/
import { getRegisteredInnerBlocks } from '@woocommerce/blocks-registry';
/**
* Internal dependencies
*/
import {
ProductTitle,
ProductPrice,
ProductButton,
ProductImage,
ProductRating,
ProductSummary,
ProductSaleBadge,
} from '@woocommerce/atomic-components/product';
/**
* Map blocks names to components.
*
* @param {string} blockName Name of the parent block. Used to get extension children.
*/
export const getBlockMap = ( blockName ) => ( {
'woocommerce/product-price': ProductPrice,
'woocommerce/product-image': ProductImage,
'woocommerce/product-title': ProductTitle,
'woocommerce/product-rating': ProductRating,
'woocommerce/product-button': ProductButton,
'woocommerce/product-summary': ProductSummary,
'woocommerce/product-sale-badge': ProductSaleBadge,
...getRegisteredInnerBlocks( blockName ),
} );
/**
* The default layout built from the default template.
*/
export const DEFAULT_PRODUCT_LIST_LAYOUT = [
[ 'woocommerce/product-image' ],
[ 'woocommerce/product-title' ],
[ 'woocommerce/product-price' ],
[ 'woocommerce/product-rating' ],
[ 'woocommerce/product-button' ],
];
/**
* Converts innerblocks to a list of layout configs.
*
* @param {object} blockMap Map of blocks as returned by `getBlockMap`.
* @param {object[]} innerBlocks Inner block components.
*/
export const getProductLayoutConfig = ( blockMap, innerBlocks ) => {
if ( ! innerBlocks || innerBlocks.length === 0 ) {
return [];
}
return innerBlocks.map( ( block ) => {
return [
block.name,
{
...block.attributes,
product: undefined,
children:
block.innerBlocks.length > 0
? getProductLayoutConfig( blockMap, block.innerBlocks )
: [],
},
];
} );
};

View File

@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from '@wordpress/element';
import { ToggleControl, SelectControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import './editor.scss';
export const getSharedContentControls = ( attributes, setAttributes ) => {
const { contentVisibility } = attributes;
return (
<Fragment>
<ToggleControl
label={ __(
'Show Sorting Dropdown',
'woo-gutenberg-products-block'
) }
checked={ contentVisibility.orderBy }
onChange={ () =>
setAttributes( {
contentVisibility: {
...contentVisibility,
orderBy: ! contentVisibility.orderBy,
},
} )
}
/>
</Fragment>
);
};
export const getSharedListControls = ( attributes, setAttributes ) => {
return (
<Fragment>
<SelectControl
label={ __(
'Order Products By',
'woo-gutenberg-products-block'
) }
value={ attributes.orderby }
options={ [
{
label: __(
'Newness - newest first',
'woo-gutenberg-products-block'
),
value: 'date',
},
{
label: __(
'Price - low to high',
'woo-gutenberg-products-block'
),
value: 'price',
},
{
label: __(
'Price - high to low',
'woo-gutenberg-products-block'
),
value: 'price-desc',
},
{
label: __(
'Rating - highest first',
'woo-gutenberg-products-block'
),
value: 'rating',
},
{
label: __(
'Sales - most first',
'woo-gutenberg-products-block'
),
value: 'popularity',
},
{
label: __(
'Menu Order',
'woo-gutenberg-products-block'
),
value: 'menu_order',
},
] }
onChange={ ( orderby ) => setAttributes( { orderby } ) }
/>
</Fragment>
);
};

View File

@ -0,0 +1,122 @@
.wc-block-products {
.components-placeholder__instructions {
border-bottom: 1px solid #e0e2e6;
width: 100%;
padding-bottom: 1em;
margin-bottom: 2em;
}
.components-placeholder__label svg {
fill: currentColor;
margin-right: 1ch;
}
.components-placeholder__fieldset {
display: block; /* Disable flex box */
p {
font-size: 14px;
}
}
.wc-block-products__add_product_button {
margin: 0 0 1em;
line-height: 24px;
vertical-align: middle;
height: auto;
font-size: 14px;
padding: 0.5em 1em;
svg {
fill: currentColor;
margin-left: 0.5ch;
vertical-align: middle;
}
}
.wc-block-products__read_more_button {
display: block;
margin-bottom: 1em;
}
}
// Edit view for product list.
.wc-block-all-products {
.components-placeholder__fieldset {
max-width: initial;
overflow: hidden;
}
.wc-block-all-products-grid-item-template {
border-top: 1px solid #e2e4e7;
margin-top: 20px;
width: 100%;
overflow: hidden;
text-align: center;
.components-tip {
max-width: 450px;
margin: 20px auto;
text-align: left;
p {
margin: 1em 0;
}
}
.wc-block-all-products__actions {
display: flex;
margin: 20px auto;
padding: 1em 0 0;
align-items: center;
vertical-align: middle;
max-width: 450px;
.wc-block-all-products__done-button {
margin: 0;
order: 3;
line-height: 32px;
height: auto;
}
.wc-block-all-products__cancel-button {
margin: 0 1em 0 auto;
order: 2;
}
.wc-block-all-products__reset-button {
margin: 0;
order: 1;
}
}
.wc-block-grid__products {
margin: 0 auto !important;
text-align: center;
position: relative;
max-width: 450px;
}
.wc-block-grid__product {
padding: 1px 20px;
margin: 0 auto;
background: #fff;
box-shadow: 0 5px 7px -2px rgba(0, 0, 0, 0.2);
position: static;
.wp-block-button__link {
margin-top: 0;
}
&::before,
&::after {
content: "";
background: #e2e4e7;
display: block;
position: absolute;
width: 100%;
top: 20px;
bottom: 20px;
}
&::before {
right: 100%;
margin-right: 30px;
}
&::after {
left: 100%;
margin-left: 30px;
}
}
}
}

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Placeholder } from '@wordpress/components';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { adminUrl } from '@woocommerce/settings';
import { IconExternal } from '@woocommerce/block-components/icons';
export const getBlockClassName = ( blockClassName, attributes ) => {
const { className, contentVisibility } = attributes;
return classNames( blockClassName, className, {
'has-image': contentVisibility.image,
'has-title': contentVisibility.title,
'has-rating': contentVisibility.rating,
'has-price': contentVisibility.price,
'has-button': contentVisibility.button,
} );
};
export const renderNoProductsPlaceholder = ( blockTitle, blockIcon ) => (
<Placeholder
className="wc-block-products"
icon={ blockIcon }
label={ blockTitle }
>
<p>
{ __(
"You haven't published any products to list here yet.",
'woo-gutenberg-products-block'
) }
</p>
<Button
className="wc-block-products__add_product_button"
isDefault
isLarge
href={ adminUrl + 'post-new.php?post_type=product' }
>
{ __( 'Add new product', 'woo-gutenberg-products-block' ) + ' ' }
<IconExternal />
</Button>
<Button
className="wc-block-products__read_more_button"
isTertiary
href="https://docs.woocommerce.com/document/managing-products/"
>
{ __( 'Learn more', 'woo-gutenberg-products-block' ) }
</Button>
</Placeholder>
);
export const renderHiddenContentPlaceholder = ( blockTitle, blockIcon ) => (
<Placeholder
className="wc-block-products"
icon={ blockIcon }
label={ blockTitle }
>
{ __(
'The content for this block is hidden due to block settings.',
'woo-gutenberg-products-block'
) }
</Placeholder>
);

View File

@ -13,7 +13,7 @@ import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
import ErrorPlaceholder from '@woocommerce/block-components/error-placeholder';
import LoadMoreButton from '@woocommerce/base-components/load-more-button';
import ReviewList from '@woocommerce/base-components/review-list';
import ReviewOrderSelect from '@woocommerce/base-components/review-order-select';
import ReviewSortSelect from '@woocommerce/base-components/review-sort-select';
import withReviews from '@woocommerce/base-hocs/with-reviews';
/**
@ -57,7 +57,7 @@ class EditorBlock extends Component {
return (
<Disabled>
{ attributes.showOrderby && ENABLE_REVIEW_RATING && (
<ReviewOrderSelect readOnly value={ attributes.orderby } />
<ReviewSortSelect readOnly value={ attributes.orderby } />
) }
<ReviewList attributes={ attributes } reviews={ reviews } />
{ attributes.showLoadMore && totalReviews > reviews.length && (

View File

@ -11,7 +11,7 @@ import { Placeholder } from '@wordpress/components';
* Internal dependencies
*/
import EditorBlock from './editor-block.js';
import { getBlockClassName, getOrderArgs } from './utils.js';
import { getBlockClassName, getSortArgs } from './utils.js';
/**
* Container of the block rendered in the editor.
@ -43,7 +43,7 @@ class EditorContainerBlock extends Component {
showReviewImage,
showReviewRating,
} = attributes;
const { order, orderby } = getOrderArgs( attributes.orderby );
const { order, orderby } = getSortArgs( attributes.orderby );
const isAllContentHidden =
! showReviewContent &&
! showReviewRating &&

View File

@ -1,4 +1,4 @@
import { previewReviews } from '../../previews/reviews';
import { previewReviews } from '@woocommerce/resource-previews';
export const example = {
attributes: {

View File

@ -10,7 +10,7 @@ import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
* Internal dependencies
*/
import LoadMoreButton from '@woocommerce/base-components/load-more-button';
import ReviewOrderSelect from '@woocommerce/base-components/review-order-select';
import ReviewSortSelect from '@woocommerce/base-components/review-sort-select';
import ReviewList from '@woocommerce/base-components/review-list';
import withReviews from '@woocommerce/base-hocs/with-reviews';
@ -33,7 +33,7 @@ const FrontendBlock = ( {
return (
<Fragment>
{ attributes.showOrderby !== 'false' && ENABLE_REVIEW_RATING && (
<ReviewOrderSelect
<ReviewSortSelect
defaultValue={ orderby }
onChange={ onChangeOrderby }
/>

View File

@ -9,7 +9,7 @@ import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { getOrderArgs } from './utils';
import { getSortArgs } from './utils';
import FrontendBlock from './frontend-block';
/**
@ -79,7 +79,7 @@ class FrontendContainerBlock extends Component {
const { attributes } = this.props;
const { categoryIds, productId } = attributes;
const { reviewsToDisplay } = this.state;
const { order, orderby } = getOrderArgs( this.state.orderby );
const { order, orderby } = getSortArgs( this.state.orderby );
return (
<FrontendBlock

View File

@ -5,15 +5,15 @@ import apiFetch from '@wordpress/api-fetch';
import classNames from 'classnames';
import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
export const getOrderArgs = ( orderValue ) => {
export const getSortArgs = ( sortValue ) => {
if ( ENABLE_REVIEW_RATING ) {
if ( orderValue === 'lowest-rating' ) {
if ( sortValue === 'lowest-rating' ) {
return {
order: 'asc',
orderby: 'rating',
};
}
if ( orderValue === 'highest-rating' ) {
if ( sortValue === 'highest-rating' ) {
return {
order: 'desc',
orderby: 'rating',

View File

@ -49,10 +49,7 @@ const GridLayoutControl = ( {
max={ MAX_ROWS }
/>
<ToggleControl
label={ __(
'Align Add to Cart buttons',
'woo-gutenberg-products-block'
) }
label={ __( 'Align Buttons', 'woo-gutenberg-products-block' ) }
help={
alignButtons
? __(

View File

@ -0,0 +1,11 @@
This folder contains all the data stores registered with `wp.data` for use by various blocks. Store keys are exported as constants on the `wc.wcBlocksData` export (external registered as `@woocommerce/block-data` and enqueued via handle `wc-blocks-data-store`). For any block using the store, make sure you import the store key rather than using the reference directly to ensure dependencies are automatically extracted correctly.
It is assumed there is some familiarity already with interacting with the `wp.data` api. You can read more about that [here](https://github.com/WordPress/gutenberg/tree/master/packages/data).
The following stores are registered:
store | description | store key
------|----------|-------------
[schema](./schema/README.md) | Used for accessing routes. Has more internal usage. | SCHEMA_STORE_KEY
[collections](./collections//README.md) | Holds collections of data indexed by namespace, model name and query string | COLLECTIONS_STORE_KEY
[query-state](./query-state/README.md) | Holds arbitrary values indexed by context and key. Typically used for tracking state of query objects for a given context | QUERY_STATE_STORE_KEY

View File

@ -0,0 +1,53 @@
# Collections Store.
To utilize this store you will import the COLLECTIONS_STORE_KEY in any module referencing it. Assuming `@woocommerce/block-data` is registered as an external pointing to `wc.wcBlocksData` you can import the key via:
```js
import { COLLECTIONS_STORE_KEY } from '@woocommerce/block-data';
```
## Actions
### `receiveCollection( namespace, modelName, queryString, ids = [], items = [], replace = false )`
This will return an action object for the given arguments used in dispatching the collection results to the store.
> **Note**: You should rarely have to dispatch this action directly as it is used by the resolver for the `getCollection` selector.
| argument | type | description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `namespace` | string | The route namespace for the collection (eg. `/wc/blocks`) |
| `modelName` | string | The "model name" for the collection (eg. `products/attributes`) |
| `queryString` | string | An additional query string to add to the request for the collection. Note, collections are cached by the query string. (eg. '?order=ASC') |
| `ids` | array | If the collection route has placeholders for ids, you provide them via this argument in the order of how the placeholders appear in the route |
| `response` | Object | An object containing a `items` property with the collection items from the response (array), and a `headers` property that is matches the `window.Headers` interface containing the headers from the response. |
| `replace` | boolean | Whether or not to replace any existing items in the store for the given indexes (namespace, modelName, queryString) if there are already values in the store |
## Selectors
### `getCollection( namespace, modelName, query = null, ids=[] )`
This selector will return the collection for the given arguments. It has a sibling resolver, so if the selector has never been resolved, the resolver will make a request to the server for the collection and dispatch results to the store.
| argument | type | description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `namespace` | string | The route namespace for the collection (eg. `/wc/blocks`) |
| `modelName` | string | The "model name" for the collection (eg. `products/attributes`) |
| `query` | Object | The query arguments for the collection. Eg. `{ order: 'ASC', sortBy: Price }` |
| `ids` | Array | If the collection route has placeholders for ids you provide the values for those placeholders in this array (in order). |
### `getCollectionHeader( namespace, modelName, header, query = null, ids = [])
This selector will return a header from the collection response using the given arguments. It has a sibling resolver that will resolve `getCollection` using the arguments if that has never been resolved.
If the collection has headers but not a matching header for the given `header` argument, then `undefined` will be returned.
If the collection does not have any matching headers for the given arguments, then `null` is returned.
| argument | type | description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `namespace` | string | The route namespace for the collection (eg. `/wc/blocks`) |
| `modelName` | string | The "model name" for the collection (eg. `products/attributes`) |
| `header` | string | The header key for the header. |
| `query` | Object | The query arguments for the collection. Eg. `{ order: 'ASC', sortBy: Price }` |
| `ids` | Array | If the collection route has placeholders for ids you provide the values for those placeholders in this array (in order). |

View File

@ -0,0 +1,4 @@
export const ACTION_TYPES = {
RECEIVE_COLLECTION: 'RECEIVE_COLLECTION',
RESET_COLLECTION: 'RESET_COLLECTION',
};

View File

@ -0,0 +1,55 @@
import { ACTION_TYPES as types } from './action-types';
let Headers = window.Headers || null;
Headers = Headers
? new Headers()
: { get: () => undefined, has: () => undefined };
/**
* Returns an action object used in updating the store with the provided items
* retrieved from a request using the given querystring.
*
* This is a generic response action.
*
* @param {string} namespace The namespace for the collection route.
* @param {string} modelName The model name for the collection route.
* string generating them.
* @param {string} [queryString=''] The query string for the collection
* @param {array} [ids=[]] An array of ids (in correct order) for the
* model.
* @param {Object} [response={}] An object containing the response from the
* collection request.
* @param {Array<*>} response.items An array of items for the given collection.
* @param {Headers} response.headers A Headers object from the response
* @link https://developer.mozilla.org/en-US/docs/Web/API/Headers
* @param {bool} [replace=false] If true, signals to replace the current
* items in the state with the provided
* items.
* @return {
* {
* type: string,
* namespace: string,
* modelName: string,
* queryString: string,
* ids: Array<*>,
* items: Array<*>,
* }
* } Object for action.
*/
export function receiveCollection(
namespace,
modelName,
queryString = '',
ids = [],
response = { items: [], headers: Headers },
replace = false
) {
return {
type: replace ? types.RESET_COLLECTION : types.RECEIVE_COLLECTION,
namespace,
modelName,
queryString,
ids,
response,
};
}

View File

@ -0,0 +1,2 @@
export const STORE_KEY = 'wc/store/collections';
export const DEFAULT_EMPTY_ARRAY = [];

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import triggerFetch from '@wordpress/api-fetch';
/**
* Dispatched a control action for triggering an api fetch call with no parsing.
* Typically this would be used in scenarios where headers are needed.
*
* @param {string} path The path for the request.
*
* @return {Object} The control action descriptor.
*/
export const apiFetchWithHeaders = ( path ) => {
return {
type: 'API_FETCH_WITH_HEADERS',
path,
};
};
/**
* Default export for registering the controls with the store.
*
* @return {Object} An object with the controls to register with the store on
* the controls property of the registration object.
*/
export const controls = {
API_FETCH_WITH_HEADERS( { path } ) {
return new Promise( ( resolve, reject ) => {
triggerFetch( { path, parse: false } )
.then( ( response ) => {
response.json().then( ( items ) => {
resolve( { items, headers: response.headers } );
} );
} )
.catch( ( error ) => {
reject( error );
} );
} );
},
};

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
import { controls } from './controls';
registerStore( STORE_KEY, {
reducer,
actions,
controls: { ...dataControls, ...controls },
selectors,
resolvers,
} );
export const COLLECTIONS_STORE_KEY = STORE_KEY;

View File

@ -0,0 +1,44 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { hasInState, updateState } from '../utils';
/**
* Reducer for receiving items to a collection.
*
* @param {Object} state The current state in the store.
* @param {Object} action Action object.
*
* @return {Object} New or existing state depending on if there are
* any changes.
*/
const receiveCollection = ( state = {}, action ) => {
const { type, namespace, modelName, queryString, response } = action;
// ids are stringified so they can be used as an index.
const ids = action.ids ? JSON.stringify( action.ids ) : '[]';
switch ( type ) {
case types.RECEIVE_COLLECTION:
if (
hasInState( state, [ namespace, modelName, ids, queryString ] )
) {
return state;
}
state = updateState(
state,
[ namespace, modelName, ids, queryString ],
response
);
break;
case types.RESET_COLLECTION:
state = updateState(
state,
[ namespace, modelName, ids, queryString ],
response
);
break;
}
return state;
};
export default receiveCollection;

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data-controls';
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { receiveCollection, DEFAULT_EMPTY_ARRAY } from './actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
import { STORE_KEY } from './constants';
import { apiFetchWithHeaders } from './controls';
/**
* Resolver for retrieving a collection via a api route.
*
* @param {string} namespace
* @param {string} modelName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollection( namespace, modelName, query, ids ) {
const route = yield select(
SCHEMA_STORE_KEY,
'getRoute',
namespace,
modelName,
ids
);
const queryString = addQueryArgs( '', query );
if ( ! route ) {
yield receiveCollection( namespace, modelName, queryString, ids );
return;
}
const { items = DEFAULT_EMPTY_ARRAY, headers } = yield apiFetchWithHeaders(
route + queryString
);
yield receiveCollection( namespace, modelName, queryString, ids, {
items,
headers,
} );
}
/**
* Resolver for retrieving a specific collection header for the given arguments
*
* Note: This triggers the `getCollection` resolver if it hasn't been resolved
* yet.
*
* @param {string} header
* @param {string} namespace
* @param {string} modelName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollectionHeader(
header,
namespace,
modelName,
query,
ids
) {
// feed the correct number of args in for the select so we don't resolve
// unnecessarily. Any undefined args will be excluded. This is important
// because resolver resolution is cached by both number and value of args.
const args = [ namespace, modelName, query, ids ].filter(
( arg ) => typeof arg !== 'undefined'
);
//we call this simply to do any resolution of the collection if necessary.
yield select( STORE_KEY, 'getCollection', ...args );
}

View File

@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { hasInState } from '../utils';
import { DEFAULT_EMPTY_ARRAY } from './constants';
const getFromState = ( {
state,
namespace,
modelName,
query,
ids,
type = 'items',
fallback = DEFAULT_EMPTY_ARRAY,
} ) => {
// prep ids and query for state retrieval
ids = JSON.stringify( ids );
query = query !== null ? addQueryArgs( '', query ) : '';
if ( hasInState( state, [ namespace, modelName, ids, query, type ] ) ) {
return state[ namespace ][ modelName ][ ids ][ query ][ type ];
}
return fallback;
};
const getCollectionHeaders = (
state,
namespace,
modelName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( {
state,
namespace,
modelName,
query,
ids,
type: 'headers',
fallback: undefined,
} );
};
/**
* Retrieves the collection items from the state for the given arguments.
*
* @param {Object} state The current collections state.
* @param {string} namespace The namespace for the collection.
* @param {string} modelName The model name for the collection.
* @param {Object} [query=null] The query for the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
* @return {array} an array of items stored in the collection.
*/
export const getCollection = (
state,
namespace,
modelName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( { state, namespace, modelName, query, ids } );
};
/**
* This selector enables retrieving a specific header value from a given
* collection request.
*
* Example:
*
* ```js
* const totalProducts = wp.data.select( COLLECTION_STORE_KEY )
* .getCollectionHeader( '/wc/blocks', 'products', 'x-wp-total' )
* ```
*
* @param {string} state The current collection state.
* @param {string} header The header to retrieve.
* @param {string} namespace The namespace for the collection.
* @param {string} modelName The model name for the collection.
* @param {Object} [query=null] The query object on the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
*
* @return {*|null} The value for the specified header, null if there are no
* headers available and undefined if the header does not exist for the
* collection.
*/
export const getCollectionHeader = (
state,
header,
namespace,
modelName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
const headers = getCollectionHeaders(
state,
namespace,
modelName,
query,
ids
);
// Can't just do a truthy check because `getCollectionHeaders` resolver
// invokes the `getCollection` selector to trigger the resolution of the
// collection request. It's fallback is an empty array.
if ( headers && headers.get ) {
return headers.has( header ) ? headers.get( header ) : undefined;
}
return null;
};

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import receiveCollection from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'receiveCollection', () => {
const originalState = deepFreeze( {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: { 'x-wp-total': 22 },
},
},
},
},
} );
it(
'returns original state when there is already an entry in the state ' +
'for the given arguments',
() => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
modelName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'bar' ],
headers: { foo: 'bar' },
},
};
expect( receiveCollection( originalState, testAction ) ).toBe(
originalState
);
}
);
it(
'returns new state when items exist in collection but the type is ' +
'for a reset',
() => {
const testAction = {
type: types.RESET_COLLECTION,
namespace: 'wc/blocks',
modelName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
},
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=2' ]
).toEqual( {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
} );
}
);
it( 'returns new state when items do not exist in collection yet', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
modelName: 'products',
queryString: '?someQuery=3',
response: { items: [ 'cheeseburger' ], headers: { foo: 'bar' } },
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=3' ]
).toEqual( { items: [ 'cheeseburger' ], headers: { foo: 'bar' } } );
} );
it( 'sets expected state when ids are passed in', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
modelName: 'products/attributes',
queryString: '?something',
response: { items: [ 10, 20 ], headers: { foo: 'bar' } },
ids: [ 30, 42 ],
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ][ 'products/attributes' ][ '[30,42]' ][
'?something'
]
).toEqual( { items: [ 10, 20 ], headers: { foo: 'bar' } } );
} );
} );

View File

@ -0,0 +1,159 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../resolvers';
import { receiveCollection } from '../actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../../schema/constants';
import { STORE_KEY } from '../constants';
import { apiFetchWithHeaders } from '../controls';
jest.mock( '@wordpress/data-controls' );
describe( 'getCollection', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const testArgs = [
'wc/blocks',
'products',
{ foo: 'bar' },
[ 20, 30 ],
];
const rewind = () => ( fulfillment = getCollection( ...testArgs ) );
test( 'with getRoute call invoked to retrieve route', () => {
rewind();
fulfillment.next();
expect( select ).toHaveBeenCalledWith(
SCHEMA_STORE_KEY,
'getRoute',
testArgs[ 0 ],
testArgs[ 1 ],
testArgs[ 3 ]
);
} );
test(
'when no route is retrieved, yields receiveCollection and ' +
'returns',
() => {
const { value } = fulfillment.next();
const expected = receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [],
headers: {
get: () => undefined,
has: () => undefined,
},
}
);
expect( value.type ).toBe( expected.type );
expect( value.namespace ).toBe( expected.namespace );
expect( value.modelName ).toBe( expected.modelName );
expect( value.queryString ).toBe( expected.queryString );
expect( value.ids ).toEqual( expected.ids );
expect( Object.keys( value.response ) ).toEqual(
Object.keys( expected.response )
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
test(
'when route is retrieved, yields apiFetchWithHeaders control action with ' +
'expected route',
() => {
rewind();
fulfillment.next();
const { value } = fulfillment.next( 'https://example.org' );
expect( value ).toEqual(
apiFetchWithHeaders( 'https://example.org?foo=bar' )
);
}
);
test(
'when apiFetchWithHeaders does not return a valid response, ' +
'yields expected action',
() => {
const { value } = fulfillment.next( {} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{ items: undefined, headers: undefined }
)
);
}
);
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( {
items: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
}
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
} );
} );
describe( 'getCollectionHeader', () => {
let fulfillment;
const rewind = ( ...testArgs ) =>
( fulfillment = getCollectionHeader( ...testArgs ) );
it( 'yields expected select control when called with less args', () => {
rewind( 'x-wp-total', '/wc/blocks', 'products' );
const { value } = fulfillment.next();
expect( value ).toEqual(
select( STORE_KEY, 'getCollection', '/wc/blocks', 'products' )
);
} );
it( 'yields expected select control when called with all args', () => {
const args = [
'x-wp-total',
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ],
];
rewind( ...args );
const { value } = fulfillment.next();
expect( value ).toEqual(
select(
STORE_KEY,
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ]
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
} );
} );

View File

@ -0,0 +1,111 @@
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../selectors';
const getHeaderMock = ( total ) => {
const headers = { total };
return {
get: ( key ) => headers[ key ] || null,
has: ( key ) => !! headers[ key ],
};
};
const state = {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: getHeaderMock( 22 ),
},
},
},
'products/attributes': {
'[10]': {
'?someQuery=2': {
items: [ 'bar' ],
headers: getHeaderMock( 42 ),
},
},
},
'products/attributes/terms': {
'[10, 20]': {
'?someQuery=10': {
items: [ 42 ],
headers: getHeaderMock( 12 ),
},
},
},
},
};
describe( 'getCollection', () => {
it( 'returns empty array when namespace does not exist in state', () => {
expect( getCollection( state, 'invalid', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when modelName does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'invalid' ) ).toEqual( [] );
} );
it( 'returns empty array when query does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when ids do not exist in state', () => {
expect(
getCollection(
state,
'wc/blocks',
'products/attributes',
'?someQuery=2',
[ 20 ]
)
).toEqual( [] );
} );
describe( 'returns expected values for items existing in state', () => {
test.each`
modelName | ids | query | expected
${'products'} | ${'[]'} | ${{ someQuery: 2 }} | ${[ 'foo' ]}
${'products/attributes'} | ${'[10]'} | ${{ someQuery: 2 }} | ${[ 'bar' ]}
${'products/attributes/terms'} | ${'[10,30]'} | ${{ someQuery: 10 }} | ${[ 42 ]}
`(
'for "$modelName", "$ids", and "$query"',
( { modelName, ids, query } ) => {
expect(
getCollection( state, 'wc/blocks', modelName, query, ids )
);
}
);
} );
} );
describe( 'getCollectionHeader', () => {
it(
'returns undefined when there are headers but the specific header ' +
'does not exist',
() => {
expect(
getCollectionHeader(
state,
'invalid',
'wc/blocks',
'products',
{
someQuery: 2,
}
)
).toBeUndefined();
}
);
it( 'returns null when there are no headers for the given arguments', () => {
expect( getCollectionHeader( state, 'wc/blocks', 'invalid' ) ).toBe(
null
);
} );
it( 'returns expected header when it exists', () => {
expect(
getCollectionHeader( state, 'total', 'wc/blocks', 'products', {
someQuery: 2,
} )
).toBe( 22 );
} );
} );

View File

@ -0,0 +1,6 @@
/**
* REST API namespace for rest requests against blocks namespace.
*
* @var {string}
*/
export const API_BLOCK_NAMESPACE = 'wc/blocks';

View File

@ -0,0 +1,4 @@
export { SCHEMA_STORE_KEY } from './schema';
export { COLLECTIONS_STORE_KEY } from './collections';
export { QUERY_STATE_STORE_KEY } from './query-state';
export { API_BLOCK_NAMESPACE } from './constants';

View File

@ -0,0 +1,32 @@
# Query State Store.
To utilize this store you will import the `QUERY_STATE_STORE_KEY` in any module referencing it. Assuming `@woocommerce/block-data` is registered as an external pointing to `wc.wcBlocksData` you can import the key via:
```js
import { QUERY_STATE_STORE_KEY } from '@woocommerce/block-data';
```
## Actions
The following actions are used for dispatching data to this store state.
> **Note:**: New values will always overwrite any existing entry in the store.
### `setQueryValue( context, queryKey, value )`
This will set a single query-state value for a given context.
| Argument | Type | Description |
| ---------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| `context` | string | The context for the query state being stored (eg. might be a block name so you can keep query-state specific per block) |
| `queryKey` | string | The reference for the value being stored. |
| `value` | mixed | The actual value being stored for the query-state. |
### `setValueForQueryContext( context, value )`
This will set the query-state for a given context. Typically this is used to set/replace the entire query-state for a given context rather than the individual keys for the context via `setQueryValue`.
| Argument | Type | Description |
| --------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| `context` | string | The context for the query state being stored (eg. might be a block name so you can keep query-state specific per block) |
| `value` | Object | An object of key/value pairs for the query state being attached to the context. |

View File

@ -0,0 +1,4 @@
export const ACTION_TYPES = {
SET_QUERY_KEY_VALUE: 'SET_QUERY_KEY_VALUE',
SET_QUERY_CONTEXT_VALUE: 'SET_QUERY_CONTEXT_VALUE',
};

View File

@ -0,0 +1,38 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
/**
* Action creator for setting a single query-state value for a given context.
*
* @param {string} context Context for query state being stored.
* @param {string} queryKey Key for query item.
* @param {*} value The value for the query item.
*
* @return {Object} The action object.
*/
export const setQueryValue = ( context, queryKey, value ) => {
return {
type: types.SET_QUERY_KEY_VALUE,
context,
queryKey,
value,
};
};
/**
* Action creator for setting query-state for a given context.
*
* @param {string} context Context for query state being stored.
* @param {*} value Query state being stored for the given context.
*
* @return {Object} The action object.
*/
export const setValueForQueryContext = ( context, value ) => {
return {
type: types.SET_QUERY_CONTEXT_VALUE,
context,
value,
};
};

View File

@ -0,0 +1 @@
export const STORE_KEY = 'wc/store/query-state';

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import reducer from './reducers';
registerStore( STORE_KEY, {
reducer,
actions,
selectors,
} );
export const QUERY_STATE_STORE_KEY = STORE_KEY;

View File

@ -0,0 +1,50 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { getStateForContext } from './utils';
const DEFAULT_QUERY_STATE = {};
/**
* Reducer for processing actions related to the query state store.
*
* @param {Object} state Current state in store.
* @param {Object} action Action being processed.
*/
const queryStateReducer = ( state = DEFAULT_QUERY_STATE, action ) => {
const { type, context, queryKey, value } = action;
const prevState = getStateForContext( state, context );
let newState;
switch ( type ) {
case types.SET_QUERY_KEY_VALUE:
const prevStateObject =
prevState !== null
? JSON.parse( prevState )
: DEFAULT_QUERY_STATE;
// mutate it and JSON.stringify to compare
prevStateObject[ queryKey ] = value;
newState = JSON.stringify( prevStateObject );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
case types.SET_QUERY_CONTEXT_VALUE:
newState = JSON.stringify( value );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
}
return state;
};
export default queryStateReducer;

View File

@ -0,0 +1,48 @@
import { getStateForContext } from './utils';
/**
* Selector for retrieving a specific query-state for the given context.
*
* @param {Object} state Current state.
* @param {string} context Context for the query-state being retrieved.
* @param {string} queryKey Key for the specific query-state item.
* @param {*} defaultValue Default value for the query-state key if it doesn't
* currently exist in state.
*
* @return {*} The currently stored value or the defaultValue if not present.
*/
export const getValueForQueryKey = (
state,
context,
queryKey,
defaultValue = {}
) => {
let stateContext = getStateForContext( state, context );
if ( stateContext === null ) {
return defaultValue;
}
stateContext = JSON.parse( stateContext );
return typeof stateContext[ queryKey ] !== 'undefined'
? stateContext[ queryKey ]
: defaultValue;
};
/**
* Selector for retrieving the query-state for the given context.
*
* @param {Object} state The current state.
* @param {string} context The context for the query-state being retrieved.
* @param {*} defaultValue The default value to return if there is no state for
* the given context.
*
* @return {*} The currently stored query-state for the given context or
* defaultValue if not present in state.
*/
export const getValueForQueryContext = (
state,
context,
defaultValue = {}
) => {
const stateContext = getStateForContext( state, context );
return stateContext === null ? defaultValue : JSON.parse( stateContext );
};

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