* Provider progress

* Revert nonce change for debugging

* Working emitters

* Fix dismiss link alignment in notices

* Fix button state and double adds

* Remove old context file

* Add type defs

* Fix context name

* Leftovers from merge

* Hooks up the variation picker to cart context

* Group event emitters in context

* Fix external product display

* Pass product through to VariationAttributes

* Pass around dispatchers

* Update assets/js/base/context/add-to-cart-form/form-state/reducer.js

Co-authored-by: Darren Ethier <darren@roughsmootheng.in>

* Update assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js

Co-authored-by: Darren Ethier <darren@roughsmootheng.in>

* Update assets/js/base/context/add-to-cart-form/form-state/reducer.js

Co-authored-by: Darren Ethier <darren@roughsmootheng.in>

* remove placeholder comment

* content->container

* Clarify variation method comment

* update comment

* Switch nesting of providers

* Variation attribute utils test coverage

* If nothing is selected yet, just return all variations.

* Comments to explain loops

* Use refs to avoid recalculation of attributes on every render

* Update memo usage

* typo

* move mock data to test file

* Switch to useShallowEqual

* trigger cart fragment refresh after add to cart

* Decode option entities

Co-authored-by: Darren Ethier <darren@roughsmootheng.in>
This commit is contained in:
Mike Jolley 2020-07-30 11:57:22 +01:00 committed by GitHub
parent fc3735564a
commit 36c1bb7361
40 changed files with 1604 additions and 449 deletions

View File

@ -18,8 +18,8 @@ const Simple = () => {
quantity, quantity,
minQuantity, minQuantity,
maxQuantity, maxQuantity,
setQuantity, dispatchActions,
formDisabled, isDisabled,
} = useAddToCartFormContext(); } = useAddToCartFormContext();
if ( product.id && ! product.is_purchasable ) { if ( product.id && ! product.is_purchasable ) {
@ -43,8 +43,8 @@ const Simple = () => {
value={ quantity } value={ quantity }
min={ minQuantity } min={ minQuantity }
max={ maxQuantity } max={ maxQuantity }
disabled={ formDisabled } disabled={ isDisabled }
onChange={ setQuantity } onChange={ dispatchActions.setQuantity }
/> />
<AddToCartButton /> <AddToCartButton />
</> </>

View File

@ -23,8 +23,8 @@ const Variable = () => {
quantity, quantity,
minQuantity, minQuantity,
maxQuantity, maxQuantity,
setQuantity, dispatchActions,
formDisabled, isDisabled,
} = useAddToCartFormContext(); } = useAddToCartFormContext();
if ( product.id && ! product.is_purchasable ) { if ( product.id && ! product.is_purchasable ) {
@ -44,13 +44,16 @@ const Variable = () => {
return ( return (
<> <>
<VariationAttributes product={ product } /> <VariationAttributes
product={ product }
dispatchers={ dispatchActions }
/>
<QuantityInput <QuantityInput
value={ quantity } value={ quantity }
min={ minQuantity } min={ minQuantity }
max={ maxQuantity } max={ maxQuantity }
disabled={ formDisabled } disabled={ isDisabled }
onChange={ setQuantity } onChange={ dispatchActions.setQuantity }
/> />
<AddToCartButton /> <AddToCartButton />
</> </>

View File

@ -2,14 +2,15 @@
* External dependencies * External dependencies
*/ */
import { useState, useEffect, useMemo } from '@wordpress/element'; import { useState, useEffect, useMemo } from '@wordpress/element';
import { useShallowEqual } from '@woocommerce/base-hooks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import AttributeSelectControl from './attribute-select-control'; import AttributeSelectControl from './attribute-select-control';
import { import {
getVariationsMatchingSelectedAttributes, getVariationMatchingSelectedAttributes,
getSelectControlOptions, getActiveSelectControlOptions,
} from './utils'; } from './utils';
/** /**
@ -17,88 +18,74 @@ import {
* *
* @param {*} props Component props. * @param {*} props Component props.
*/ */
const AttributePicker = ( { attributes, variationAttributes } ) => { const AttributePicker = ( {
attributes,
variationAttributes,
setRequestParams,
} ) => {
const currentAttributes = useShallowEqual( attributes );
const currentVariationAttributes = useShallowEqual( variationAttributes );
const [ variationId, setVariationId ] = useState( 0 ); const [ variationId, setVariationId ] = useState( 0 );
const [ selectedAttributes, setSelectedAttributes ] = useState( {} );
// @todo Support default selected attributes in Variation Picker. // Get options for each attribute picker.
const [ selectedAttributes, setSelectedAttributes ] = useState( [] );
const attributeNames = Object.keys( attributes );
const hasSelectedAttributes =
Object.values( selectedAttributes ).filter( Boolean ).length > 0;
const hasSelectedAllAttributes =
Object.values( selectedAttributes ).filter(
( selected ) => selected !== ''
).length === attributeNames.length;
// Gets valid attribute options for the picker taking current selections into account.
const filteredAttributeOptions = useMemo( () => { const filteredAttributeOptions = useMemo( () => {
const options = []; return getActiveSelectControlOptions(
currentAttributes,
currentVariationAttributes,
selectedAttributes
);
}, [ selectedAttributes, currentAttributes, currentVariationAttributes ] );
attributeNames.forEach( ( attributeName ) => { // Select variations when selections are change.
const currentAttribute = attributes[ attributeName ];
const attributeNamesExcludingCurrentAttribute = attributeNames.filter(
( name ) => name !== attributeName
);
const matchingVariationIds = hasSelectedAttributes
? getVariationsMatchingSelectedAttributes( {
selectedAttributes,
variationAttributes,
attributeNames: attributeNamesExcludingCurrentAttribute,
} )
: null;
const validAttributeTerms =
matchingVariationIds !== null
? matchingVariationIds.map(
( varId ) =>
variationAttributes[ varId ][ attributeName ]
)
: null;
options[ attributeName ] = getSelectControlOptions(
currentAttribute.terms,
validAttributeTerms
);
} );
return options;
}, [
attributes,
variationAttributes,
attributeNames,
selectedAttributes,
hasSelectedAttributes,
] );
// Select variation when selections change.
useEffect( () => { useEffect( () => {
if ( ! hasSelectedAllAttributes ) { const hasSelectedAllAttributes =
Object.values( selectedAttributes ).filter(
( selected ) => selected !== ''
).length === Object.keys( currentAttributes ).length;
if ( hasSelectedAllAttributes ) {
setVariationId(
getVariationMatchingSelectedAttributes(
currentAttributes,
currentVariationAttributes,
selectedAttributes
)
);
} else if ( variationId > 0 ) {
// Unset variation when form is incomplete.
setVariationId( 0 ); setVariationId( 0 );
return;
} }
const matchingVariationIds = getVariationsMatchingSelectedAttributes( {
selectedAttributes,
variationAttributes,
attributeNames,
} );
setVariationId( matchingVariationIds[ 0 ] || 0 );
}, [ }, [
selectedAttributes, selectedAttributes,
variationAttributes, variationId,
attributeNames, currentAttributes,
hasSelectedAllAttributes, currentVariationAttributes,
] ); ] );
// @todo Hook up Variation Picker with Cart Form. // Set requests params as variation ID and data changes.
useEffect( () => {
setRequestParams( {
id: variationId,
variation: Object.keys( selectedAttributes ).map(
( attributeName ) => {
return {
attribute: attributeName,
value: selectedAttributes[ attributeName ],
};
}
),
} );
}, [ setRequestParams, variationId, selectedAttributes ] );
return ( return (
<div className="wc-block-components-product-add-to-cart-attribute-picker"> <div className="wc-block-components-product-add-to-cart-attribute-picker">
{ attributeNames.map( ( attributeName ) => ( { Object.keys( currentAttributes ).map( ( attributeName ) => (
<AttributeSelectControl <AttributeSelectControl
key={ attributeName } key={ attributeName }
attributeName={ attributeName } attributeName={ attributeName }
options={ filteredAttributeOptions[ attributeName ] } options={ filteredAttributeOptions[ attributeName ] }
selected={ selectedAttributes[ attributeName ] } value={ selectedAttributes[ attributeName ] }
onChange={ ( selected ) => { onChange={ ( selected ) => {
setSelectedAttributes( { setSelectedAttributes( {
...selectedAttributes, ...selectedAttributes,
@ -107,7 +94,6 @@ const AttributePicker = ( { attributes, variationAttributes } ) => {
} } } }
/> />
) ) } ) ) }
<p>Matched variation ID: { variationId }</p>
</div> </div>
); );
}; };

View File

@ -4,6 +4,10 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl } from '@wordpress/components'; import { SelectControl } from '@wordpress/components';
import { useValidationContext } from '@woocommerce/base-context';
import { useEffect } from 'react';
import classnames from 'classnames';
import { ValidationInputError } from '@woocommerce/base-components/validation';
// Default option for select boxes. // Default option for select boxes.
const selectAnOption = { const selectAnOption = {
@ -19,17 +23,66 @@ const selectAnOption = {
const AttributeSelectControl = ( { const AttributeSelectControl = ( {
attributeName, attributeName,
options = [], options = [],
selected = '', value = '',
onChange = () => {}, onChange = () => {},
errorMessage = __(
'Please select a value.',
'woo-gutenberg-products-block'
),
} ) => { } ) => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const errorId = attributeName;
const error = getValidationError( errorId ) || {};
useEffect( () => {
if ( value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
}, [
value,
errorId,
errorMessage,
clearValidationError,
setValidationErrors,
] );
// Remove validation errors when unmounted.
useEffect( () => () => void clearValidationError( errorId ), [
errorId,
clearValidationError,
] );
return ( return (
<SelectControl <div className="wc-block-components-product-add-to-cart-attribute-picker__container">
className="wc-block-components-product-add-to-cart-attribute-picker__select" <SelectControl
label={ decodeEntities( attributeName ) } label={ decodeEntities( attributeName ) }
value={ selected || '' } value={ value || '' }
options={ [ selectAnOption, ...options ] } options={ [ selectAnOption, ...options ] }
onChange={ onChange } onChange={ onChange }
/> required={ true }
className={ classnames(
'wc-block-components-product-add-to-cart-attribute-picker__select',
{
'has-error': error.message && ! error.hidden,
}
) }
/>
<ValidationInputError
propertyName={ errorId }
elementId={ errorId }
/>
</div>
); );
}; };

View File

@ -7,17 +7,10 @@ import { getAttributes, getVariationAttributes } from './utils';
/** /**
* VariationAttributes component. * VariationAttributes component.
*
* @param {*} props Component props.
*/ */
const VariationAttributes = ( { product } ) => { const VariationAttributes = ( { product, dispatchers } ) => {
const { const attributes = getAttributes( product.attributes );
attributes: productAttributes = {}, const variationAttributes = getVariationAttributes( product.variations );
variations: productVariations = [],
} = product;
const attributes = getAttributes( productAttributes );
const variationAttributes = getVariationAttributes( productVariations );
if ( if (
Object.keys( attributes ).length === 0 || Object.keys( attributes ).length === 0 ||
@ -30,6 +23,7 @@ const VariationAttributes = ( { product } ) => {
<AttributePicker <AttributePicker
attributes={ attributes } attributes={ attributes }
variationAttributes={ variationAttributes } variationAttributes={ variationAttributes }
setRequestParams={ dispatchers.setRequestParams }
/> />
); );
}; };

View File

@ -7,6 +7,10 @@
@include font-size(regular); @include font-size(regular);
} }
.wc-block-components-product-add-to-cart-attribute-picker__container {
position: relative;
}
.wc-block-components-product-add-to-cart-attribute-picker__select { .wc-block-components-product-add-to-cart-attribute-picker__select {
margin: 0 0 em($gap-small) 0; margin: 0 0 em($gap-small) 0;
@ -14,5 +18,16 @@
min-width: 60%; min-width: 60%;
min-height: 1.75em; min-height: 1.75em;
} }
&.has-error {
margin-bottom: $gap-large;
select {
border-color: $error-red;
&:focus {
outline-color: $error-red;
}
}
}
} }
} }

View File

@ -0,0 +1,395 @@
/**
* Internal dependencies
*/
import {
getAttributes,
getVariationAttributes,
getVariationsMatchingSelectedAttributes,
getVariationMatchingSelectedAttributes,
getActiveSelectControlOptions,
} from '../utils';
const rawAttributeData = [
{
id: 1,
name: 'Color',
taxonomy: 'pa_color',
has_variations: true,
terms: [
{
id: 22,
name: 'Blue',
slug: 'blue',
},
{
id: 23,
name: 'Green',
slug: 'green',
},
{
id: 24,
name: 'Red',
slug: 'red',
},
],
},
{
id: 0,
name: 'Logo',
taxonomy: null,
has_variations: true,
terms: [
{
id: 0,
name: 'Yes',
slug: 'Yes',
},
{
id: 0,
name: 'No',
slug: 'No',
},
],
},
{
id: 0,
name: 'Non-variable attribute',
taxonomy: null,
has_variations: false,
terms: [
{
id: 0,
name: 'Test',
slug: 'Test',
},
{
id: 0,
name: 'Test 2',
slug: 'Test 2',
},
],
},
];
const rawVariations = [
{
id: 35,
attributes: [
{
name: 'Color',
value: 'blue',
},
{
name: 'Logo',
value: 'Yes',
},
],
},
{
id: 28,
attributes: [
{
name: 'Color',
value: 'red',
},
{
name: 'Logo',
value: 'No',
},
],
},
{
id: 29,
attributes: [
{
name: 'Color',
value: 'green',
},
{
name: 'Logo',
value: 'No',
},
],
},
{
id: 30,
attributes: [
{
name: 'Color',
value: 'blue',
},
{
name: 'Logo',
value: 'No',
},
],
},
];
describe( 'Testing utils', () => {
describe( 'Testing getAttributes()', () => {
it( 'returns empty object if there are no attributes', () => {
const attributes = getAttributes( null );
expect( attributes ).toStrictEqual( {} );
} );
it( 'returns list of attributes when given valid data', () => {
const attributes = getAttributes( rawAttributeData );
expect( attributes ).toStrictEqual( {
Color: {
id: 1,
name: 'Color',
taxonomy: 'pa_color',
has_variations: true,
terms: [
{
id: 22,
name: 'Blue',
slug: 'blue',
},
{
id: 23,
name: 'Green',
slug: 'green',
},
{
id: 24,
name: 'Red',
slug: 'red',
},
],
},
Logo: {
id: 0,
name: 'Logo',
taxonomy: null,
has_variations: true,
terms: [
{
id: 0,
name: 'Yes',
slug: 'Yes',
},
{
id: 0,
name: 'No',
slug: 'No',
},
],
},
} );
} );
} );
describe( 'Testing getVariationAttributes()', () => {
it( 'returns empty object if there are no variations', () => {
const variationAttributes = getVariationAttributes( null );
expect( variationAttributes ).toStrictEqual( {} );
} );
it( 'returns list of attribute names and value pairs when given valid data', () => {
const variationAttributes = getVariationAttributes( rawVariations );
expect( variationAttributes ).toStrictEqual( {
'id:35': {
id: 35,
attributes: {
Color: 'blue',
Logo: 'Yes',
},
},
'id:28': {
id: 28,
attributes: {
Color: 'red',
Logo: 'No',
},
},
'id:29': {
id: 29,
attributes: {
Color: 'green',
Logo: 'No',
},
},
'id:30': {
id: 30,
attributes: {
Color: 'blue',
Logo: 'No',
},
},
} );
} );
} );
describe( 'Testing getVariationsMatchingSelectedAttributes()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns all variations, in the correct order, if no selections have been made yet', () => {
const selectedAttributes = {};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 35, 28, 29, 30 ] );
} );
it( 'returns correct subset of variations after a selection', () => {
const selectedAttributes = {
Color: 'blue',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 35, 30 ] );
} );
it( 'returns correct subset of variations after all selections', () => {
const selectedAttributes = {
Color: 'blue',
Logo: 'No',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 30 ] );
} );
it( 'returns no results if selection does not match or is invalid', () => {
const selectedAttributes = {
Color: 'brown',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [] );
} );
} );
describe( 'Testing getVariationMatchingSelectedAttributes()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns first match if no selections have been made yet', () => {
const selectedAttributes = {};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 35 );
} );
it( 'returns first match after single selection', () => {
const selectedAttributes = {
Color: 'blue',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 35 );
} );
it( 'returns correct match after all selections', () => {
const selectedAttributes = {
Color: 'blue',
Logo: 'No',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 30 );
} );
it( 'returns no match if invalid', () => {
const selectedAttributes = {
Color: 'brown',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 0 );
} );
} );
describe( 'Testing getActiveSelectControlOptions()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns all possible options if no selections have been made yet', () => {
const selectedAttributes = {};
const controlOptions = getActiveSelectControlOptions(
attributes,
variationAttributes,
selectedAttributes
);
expect( controlOptions ).toStrictEqual( {
Color: [
{
value: 'blue',
label: 'Blue',
},
{
value: 'green',
label: 'Green',
},
{
value: 'red',
label: 'Red',
},
],
Logo: [
{
value: 'Yes',
label: 'Yes',
},
{
value: 'No',
label: 'No',
},
],
} );
} );
it( 'returns only valid options if color is selected', () => {
const selectedAttributes = {
Color: 'green',
};
const controlOptions = getActiveSelectControlOptions(
attributes,
variationAttributes,
selectedAttributes
);
expect( controlOptions ).toStrictEqual( {
Color: [
{
value: 'blue',
label: 'Blue',
},
{
value: 'green',
label: 'Green',
},
{
value: 'red',
label: 'Red',
},
],
Logo: [
{
value: 'No',
label: 'No',
},
],
} );
} );
} );
} );

View File

@ -2,6 +2,7 @@
* External dependencies * External dependencies
*/ */
import { keyBy } from 'lodash'; import { keyBy } from 'lodash';
import { decodeEntities } from '@wordpress/html-entities';
/** /**
* Key an array of attributes by name, * Key an array of attributes by name,
@ -16,13 +17,16 @@ export const getAttributes = ( attributes ) => {
), ),
'name' 'name'
) )
: []; : {};
}; };
/** /**
* Format variations from the API into a map of just the attribute names and values. * Format variations from the API into a map of just the attribute names and values.
* *
* @param {Array} variations Variations array. * Note, each item is keyed by the variation ID with an id: prefix. This is to prevent the object
* being reordered when iterated.
*
* @param {Object} variations List of Variation objects and attributes keyed by variation ID.
*/ */
export const getVariationAttributes = ( variations ) => { export const getVariationAttributes = ( variations ) => {
if ( ! variations ) { if ( ! variations ) {
@ -32,65 +36,56 @@ export const getVariationAttributes = ( variations ) => {
const attributesMap = {}; const attributesMap = {};
variations.forEach( ( { id, attributes } ) => { variations.forEach( ( { id, attributes } ) => {
attributesMap[ id ] = attributes.reduce( ( acc, { name, value } ) => { attributesMap[ `id:${ id }` ] = {
acc[ name ] = value; id,
return acc; attributes: attributes.reduce( ( acc, { name, value } ) => {
}, [] ); acc[ name ] = value;
return acc;
}, {} ),
};
} ); } );
return attributesMap; return attributesMap;
}; };
/**
* Given a list of terms, filter them and return options for the select boxes.
*
* @param {Object} attributeTerms List of attribute term objects.
* @param {?Array} validAttributeTerms Valid values if selections have been made already.
* @return {Array} Value/Label pairs of select box options.
*/
export const getSelectControlOptions = (
attributeTerms,
validAttributeTerms = null
) => {
return Object.values( attributeTerms )
.map( ( { name, slug } ) => {
if (
validAttributeTerms === null ||
validAttributeTerms.includes( null ) ||
validAttributeTerms.includes( slug )
) {
return {
value: slug,
label: name,
};
}
return null;
} )
.filter( Boolean );
};
/** /**
* Given a list of variations and a list of attribute values, return variations which match. * Given a list of variations and a list of attribute values, return variations which match.
* *
* Allows an attribute to be excluded by name. This is used to filter displayed options for * Allows an attribute to be excluded by name. This is used to filter displayed options for
* individual attribute selects. * individual attribute selects.
* *
* @param {Object} props * @param {Object} attributes List of attribute names and terms.
* @param {Object} props.selectedAttributes List of selected attributes. * @param {Object} variationAttributes Attributes for each variation keyed by variation ID.
* @param {Object} props.variationAttributes List of variations and their attributes. * @param {Object} selectedAttributes Attribute Name Value pairs of current selections by the user.
* @param {Object} props.attributeNames List of all possible attribute names.
* @return {Array} List of matching variation IDs. * @return {Array} List of matching variation IDs.
*/ */
export const getVariationsMatchingSelectedAttributes = ( { export const getVariationsMatchingSelectedAttributes = (
selectedAttributes, attributes,
variationAttributes, variationAttributes,
attributeNames, selectedAttributes
} ) => { ) => {
return Object.keys( variationAttributes ).filter( ( variationId ) => const variationIds = Object.values( variationAttributes ).map(
( { id: variationId } ) => {
return variationId;
}
);
// If nothing is selected yet, just return all variations.
if (
Object.values( selectedAttributes ).every( ( value ) => value === '' )
) {
return variationIds;
}
const attributeNames = Object.keys( attributes );
return variationIds.filter( ( variationId ) =>
attributeNames.every( ( attributeName ) => { attributeNames.every( ( attributeName ) => {
const selectedAttribute = selectedAttributes[ attributeName ] || ''; const selectedAttribute = selectedAttributes[ attributeName ] || '';
const variationAttribute = const variationAttribute =
variationAttributes[ variationId ][ attributeName ]; variationAttributes[ 'id:' + variationId ].attributes[
attributeName
];
// If there is no selected attribute, consider this a match. // If there is no selected attribute, consider this a match.
if ( selectedAttribute === '' ) { if ( selectedAttribute === '' ) {
@ -105,3 +100,109 @@ export const getVariationsMatchingSelectedAttributes = ( {
} ) } )
); );
}; };
/**
* Given a list of variations and a list of attribute values, returns the first matched variation ID.
*
* @param {Object} attributes List of attribute names and terms.
* @param {Object} variationAttributes Attributes for each variation keyed by variation ID.
* @param {Object} selectedAttributes Attribute Name Value pairs of current selections by the user.
* @return {number} Variation ID.
*/
export const getVariationMatchingSelectedAttributes = (
attributes,
variationAttributes,
selectedAttributes
) => {
const matchingVariationIds = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
return matchingVariationIds[ 0 ] || 0;
};
/**
* Given a list of terms, filter them and return valid options for the select boxes.
*
* @see getActiveSelectControlOptions
* @param {Object} attributeTerms List of attribute term objects.
* @param {?Array} validAttributeTerms Valid values if selections have been made already.
* @return {Array} Value/Label pairs of select box options.
*/
const getValidSelectControlOptions = (
attributeTerms,
validAttributeTerms = null
) => {
return Object.values( attributeTerms )
.map( ( { name, slug } ) => {
if (
validAttributeTerms === null ||
validAttributeTerms.includes( null ) ||
validAttributeTerms.includes( slug )
) {
return {
value: slug,
label: decodeEntities( name ),
};
}
return null;
} )
.filter( Boolean );
};
/**
* Given a list of terms, filter them and return active options for the select boxes. This factors in
* which options should be hidden due to current selections.
*
* @param {Object} attributes List of attribute names and terms.
* @param {Object} variationAttributes Attributes for each variation keyed by variation ID.
* @param {Object} selectedAttributes Attribute Name Value pairs of current selections by the user.
* @return {Object} Select box options.
*/
export const getActiveSelectControlOptions = (
attributes,
variationAttributes,
selectedAttributes
) => {
const options = {};
const attributeNames = Object.keys( attributes );
const hasSelectedAttributes =
Object.values( selectedAttributes ).filter( Boolean ).length > 0;
attributeNames.forEach( ( attributeName ) => {
const currentAttribute = attributes[ attributeName ];
const selectedAttributesExcludingCurrentAttribute = {
...selectedAttributes,
[ attributeName ]: null,
};
// This finds matching variations for selected attributes apart from this one. This will be
// used to get valid attribute terms of the current attribute narrowed down by those matching
// variation IDs. For example, if I had Large Blue Shirts and Medium Red Shirts, I want to only
// show Red shirts if Medium is selected.
const matchingVariationIds = hasSelectedAttributes
? getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributesExcludingCurrentAttribute
)
: null;
// Uses the above matching variation IDs to get the attributes from just those variations.
const validAttributeTerms =
matchingVariationIds !== null
? matchingVariationIds.map(
( varId ) =>
variationAttributes[ 'id:' + varId ].attributes[
attributeName
]
)
: null;
// Intersects attributes with valid attributes.
options[ attributeName ] = getValidSelectControlOptions(
currentAttribute.terms,
validAttributeTerms
);
} );
return options;
};

View File

@ -4,8 +4,9 @@
import { __, _n, sprintf } from '@wordpress/i18n'; import { __, _n, sprintf } from '@wordpress/i18n';
import Button from '@woocommerce/base-components/button'; import Button from '@woocommerce/base-components/button';
import { Icon, done as doneIcon } from '@woocommerce/icons'; import { Icon, done as doneIcon } from '@woocommerce/icons';
import { useState } from '@wordpress/element'; import { useState, useEffect } from '@wordpress/element';
import { useAddToCartFormContext } from '@woocommerce/base-context'; import { useAddToCartFormContext } from '@woocommerce/base-context';
import { useStoreAddToCart } from '@woocommerce/base-hooks';
/** /**
* Add to Cart Form Button Component. * Add to Cart Form Button Component.
@ -14,20 +15,37 @@ const AddToCartButton = () => {
const { const {
showFormElements, showFormElements,
product, product,
quantityInCart, isDisabled,
formDisabled, isProcessing,
formSubmitting, eventRegistration,
onSubmit, hasError,
dispatchActions,
} = useAddToCartFormContext(); } = useAddToCartFormContext();
const { cartQuantity } = useStoreAddToCart( product.id || 0 );
const [ addedToCart, setAddedToCart ] = useState( false );
const isPurchasable = product.is_purchasable;
const hasOptions = product.has_options;
const addToCartButtonData = product.add_to_cart || {
url: '',
text: '',
};
const { // Subscribe to emitter for after processing.
is_purchasable: isPurchasable = true, useEffect( () => {
has_options: hasOptions, const onSuccess = () => {
add_to_cart: addToCartButtonData = { if ( ! hasError ) {
url: '', setAddedToCart( true );
text: '', }
}, return true;
} = product; };
const unsubscribeProcessing = eventRegistration.onAddToCartAfterProcessingWithSuccess(
onSuccess,
0
);
return () => {
unsubscribeProcessing();
};
}, [ eventRegistration, hasError ] );
// If we are showing form elements, OR if the product has no additional form options, we can show // If we are showing form elements, OR if the product has no additional form options, we can show
// a functional direct add to cart button, provided that the product is purchasable. // a functional direct add to cart button, provided that the product is purchasable.
@ -36,10 +54,11 @@ const AddToCartButton = () => {
return ( return (
<ButtonComponent <ButtonComponent
className="wc-block-components-product-add-to-cart-button" className="wc-block-components-product-add-to-cart-button"
quantityInCart={ quantityInCart } quantityInCart={ cartQuantity }
disabled={ formDisabled } isDisabled={ isDisabled }
loading={ formSubmitting } isProcessing={ isProcessing }
onClick={ onSubmit } isDone={ addedToCart }
onClick={ () => dispatchActions.submitForm() }
/> />
); );
} }
@ -73,23 +92,19 @@ const LinkComponent = ( { className, href, text } ) => {
const ButtonComponent = ( { const ButtonComponent = ( {
className, className,
quantityInCart, quantityInCart,
loading, isProcessing,
disabled, isDisabled,
isDone,
onClick, onClick,
} ) => { } ) => {
const [ wasClicked, setWasClicked ] = useState( false );
return ( return (
<Button <Button
className={ className } className={ className }
disabled={ disabled } disabled={ isDisabled }
showSpinner={ loading } showSpinner={ isProcessing }
onClick={ () => { onClick={ onClick }
onClick();
setWasClicked( true );
} }
> >
{ quantityInCart > 0 { isDone && quantityInCart > 0
? sprintf( ? sprintf(
// translators: %s number of products in cart. // translators: %s number of products in cart.
_n( _n(
@ -101,7 +116,7 @@ const ButtonComponent = ( {
quantityInCart quantityInCart
) )
: __( 'Add to cart', 'woo-gutenberg-products-block' ) } : __( 'Add to cart', 'woo-gutenberg-products-block' ) }
{ wasClicked && ( { !! isDone && (
<Icon <Icon
srcElement={ doneIcon } srcElement={ doneIcon }
alt={ __( 'Done', 'woo-gutenberg-products-block' ) } alt={ __( 'Done', 'woo-gutenberg-products-block' ) }

View File

@ -21,7 +21,7 @@
.wc-block-components-product-add-to-cart-quantity { .wc-block-components-product-add-to-cart-quantity {
margin: 0 1em em($gap-small) 0; margin: 0 1em em($gap-small) 0;
width: 5em; flex-basis: 5em;
padding: 0.618em; padding: 0.618em;
background: $white; background: $white;
border: 1px solid #ccc; border: 1px solid #ccc;
@ -31,6 +31,8 @@
text-align: center; text-align: center;
} }
} }
.is-loading .wc-block-components-product-add-to-cart,
.wc-block-components-product-add-to-cart--placeholder { .wc-block-components-product-add-to-cart--placeholder {
.wc-block-components-product-add-to-cart-quantity, .wc-block-components-product-add-to-cart-quantity,
.wc-block-components-product-add-to-cart-button { .wc-block-components-product-add-to-cart-button {

View File

@ -1,40 +0,0 @@
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
let blockAttributes = {};
if ( isFeaturePluginBuild() ) {
blockAttributes = {
...blockAttributes,
align: {
type: 'string',
},
fontSize: {
type: 'string',
},
customFontSize: {
type: 'number',
},
saleFontSize: {
type: 'string',
},
customSaleFontSize: {
type: 'number',
},
color: {
type: 'string',
},
saleColor: {
type: 'string',
},
customColor: {
type: 'string',
},
customSaleColor: {
type: 'string',
},
};
}
export default blockAttributes;

View File

@ -1,32 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { CURRENCY } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import sharedConfig from '../shared-config';
import attributes from './attributes';
import edit from './edit';
const blockConfig = {
title: __( 'Product Price', 'woo-gutenberg-products-block' ),
description: __(
'Display the price of a product.',
'woo-gutenberg-products-block'
),
icon: {
src: <b style={ { color: '$96588a' } }>{ CURRENCY.symbol }</b>,
foreground: '#96588a',
},
edit,
attributes,
};
registerBlockType( 'woocommerce/product-price', {
...sharedConfig,
...blockConfig,
} );

View File

@ -3,17 +3,15 @@
margin-bottom: 2em; margin-bottom: 2em;
.wc-block-components-notices__notice { .wc-block-components-notices__notice {
margin: 0; margin: 0;
.components-notice__content { display: flex;
display: inline-block; flex-wrap: nowrap;
}
.components-notice__dismiss { .components-notice__dismiss {
background: transparent none; background: transparent none;
padding: 0; padding: 0;
margin: 0; margin: 0 0 0 0.5em;
border: 0; border: 0;
outline: 0; outline: 0;
color: #fff; color: #fff;
float: right;
svg { svg {
fill: #fff; fill: #fff;
vertical-align: text-top; vertical-align: text-top;

View File

@ -1,156 +0,0 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useState,
useCallback,
} from '@wordpress/element';
import {
useStoreAddToCart,
useTriggerFragmentRefresh,
} from '@woocommerce/base-hooks';
/**
* @typedef {import('@woocommerce/type-defs/contexts').AddToCartFormContext} AddToCartFormContext
*/
const AddToCartFormContext = createContext( {
product: {},
productId: 0,
variationId: 0,
variationData: {},
cartItemData: {},
quantity: 1,
minQuantity: 1,
maxQuantity: 99,
quantityInCart: 0,
setQuantity: ( quantity ) => void { quantity },
setVariationId: ( variationId ) => void { variationId },
setVariationData: ( variationData ) => void { variationData },
setCartItemData: ( cartItemData ) => void { cartItemData },
showFormElements: false,
formInitialized: false,
formDisabled: true,
formSubmitting: false,
onChange: () => void null,
onSubmit: () => void null,
onSuccess: () => void null,
onFail: () => void null,
} );
/**
* @return {AddToCartFormContext} Returns the add to cart form context value.
*/
export const useAddToCartFormContext = () => {
return useContext( AddToCartFormContext );
};
/**
* Provides an interface for blocks to control the add to cart form for a product.
*
* @param {Object} props Incoming props for the provider.
* @param {*} props.children The children being wrapped.
* @param {Object} [props.product] The product for which the form belongs to.
* @param {boolean} [props.showFormElements] Should form elements be shown.
*/
export const AddToCartFormContextProvider = ( {
children,
product: productProp,
showFormElements,
} ) => {
const product = productProp || {};
const productId = product.id || 0;
const [ variationId, setVariationId ] = useState( 0 );
const [ variationData, setVariationData ] = useState( {} );
const [ cartItemData, setCartItemData ] = useState( {} );
const [ quantity, setQuantity ] = useState( 1 );
const {
addToCart: storeAddToCart,
addingToCart: formSubmitting,
cartQuantity: quantityInCart,
cartIsLoading,
} = useStoreAddToCart( productId );
// This will ensure any add to cart events update legacy fragments using jQuery.
useTriggerFragmentRefresh( quantityInCart );
/**
* @todo Introduce Validation Emitter for the Add to Cart Form
*
* The add to cart form may have several inner form elements which need to run validation and
* change whether or not the form can be submitted. They may also need to show errors and
* validation notices.
*/
const formInitialized = ! cartIsLoading && productId > 0;
const formDisabled =
formSubmitting ||
! formInitialized ||
! productIsPurchasable( product );
// Events.
const onSubmit = useCallback( () => {
/**
* @todo Surface add to cart errors in the single product block.
*
* If the addToCart function within useStoreAddToCart fails, a notice should be shown on the product page.
*/
storeAddToCart( quantity );
}, [ storeAddToCart, quantity ] );
/**
* @todo Add Event Callbacks to the Add to Cart Form.
*
* - onChange should trigger when a form element changes, so for example, a variation picker could indicate that it's ready.
* - onSuccess should trigger after a successful add to cart. This could be used to reset form elements, do a redirect, or show something to the user.
* - onFail should trigger when adding to cart fails. Form elements might show extra notices or reset. A fallback might be to redirect to the core product page in case of incompatibilities.
*/
const onChange = useCallback( () => {}, [] );
const onSuccess = useCallback( () => {}, [] );
const onFail = useCallback( () => {}, [] );
/**
* @type {AddToCartFormContext}
*/
const contextValue = {
product,
productId,
variationId,
variationData,
cartItemData,
quantity,
minQuantity: 1,
maxQuantity: product.quantity_limit || 99,
quantityInCart,
setQuantity,
setVariationId,
setVariationData,
setCartItemData,
showFormElements,
formInitialized,
formDisabled,
formSubmitting,
onChange,
onSubmit,
onSuccess,
onFail,
};
return (
<AddToCartFormContext.Provider value={ contextValue }>
{ children }
</AddToCartFormContext.Provider>
);
};
/**
* Check a product object to see if it can be purchased.
*
* @param {Object} product Product object.
*/
const productIsPurchasable = ( product ) => {
const { is_purchasable: isPurchasable = false } = product;
return isPurchasable;
};

View File

@ -0,0 +1,58 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES } from './constants';
const {
SET_PRISTINE,
SET_IDLE,
SET_DISABLED,
SET_PROCESSING,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_HAS_ERROR,
SET_NO_ERROR,
SET_QUANTITY,
SET_REQUEST_PARAMS,
} = ACTION_TYPES;
/**
* All the actions that can be dispatched for the checkout.
*/
export const actions = {
setPristine: () => ( {
type: SET_PRISTINE,
} ),
setIdle: () => ( {
type: SET_IDLE,
} ),
setDisabled: () => ( {
type: SET_DISABLED,
} ),
setProcessing: () => ( {
type: SET_PROCESSING,
} ),
setBeforeProcessing: () => ( {
type: SET_BEFORE_PROCESSING,
} ),
setAfterProcessing: () => ( {
type: SET_AFTER_PROCESSING,
} ),
setProcessingResponse: ( data ) => ( {
type: SET_PROCESSING_RESPONSE,
data,
} ),
setHasError: ( hasError = true ) => {
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;
return { type };
},
setQuantity: ( quantity ) => ( {
type: SET_QUANTITY,
quantity,
} ),
setRequestParams: ( data ) => ( {
type: SET_REQUEST_PARAMS,
data,
} ),
};

View File

@ -0,0 +1,32 @@
/**
* @type {import("@woocommerce/type-defs/add-to-cart-form").AddToCartFormStatusConstants}
*/
export const STATUS = {
PRISTINE: 'pristine',
IDLE: 'idle',
DISABLED: 'disabled',
PROCESSING: 'processing',
BEFORE_PROCESSING: 'before_processing',
AFTER_PROCESSING: 'after_processing',
};
export const DEFAULT_STATE = {
status: STATUS.PRISTINE,
hasError: false,
quantity: 1,
processingResponse: null,
requestParams: {},
};
export const ACTION_TYPES = {
SET_PRISTINE: 'set_pristine',
SET_IDLE: 'set_idle',
SET_DISABLED: 'set_disabled',
SET_PROCESSING: 'set_processing',
SET_BEFORE_PROCESSING: 'set_before_processing',
SET_AFTER_PROCESSING: 'set_after_processing',
SET_PROCESSING_RESPONSE: 'set_processing_response',
SET_HAS_ERROR: 'set_has_error',
SET_NO_ERROR: 'set_no_error',
SET_QUANTITY: 'set_quantity',
SET_REQUEST_PARAMS: 'set_request_params',
};

View File

@ -0,0 +1,52 @@
/**
* Internal dependencies
*/
import {
emitterCallback,
reducer,
emitEvent,
emitEventWithAbort,
} from '../../shared/event-emit';
const EMIT_TYPES = {
ADD_TO_CART_BEFORE_PROCESSING: 'add_to_cart_before_processing',
ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS:
'add_to_cart_after_processing_with_success',
ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR:
'add_to_cart_after_processing_with_error',
};
/**
* Receives a reducer dispatcher and returns an object with the callback registration function for
* the add to cart emit events.
*
* Calling the event registration function with the callback will register it for the event emitter
* and will return a dispatcher for removing the registered callback (useful for implementation
* in `useEffect`).
*
* @param {Function} dispatcher The emitter reducer dispatcher.
*
* @return {Object} An object with the add to cart form emitter registration
*/
const emitterSubscribers = ( dispatcher ) => ( {
onAddToCartAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
dispatcher
),
onAddToCartProcessingWithError: emitterCallback(
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
dispatcher
),
onAddToCartBeforeProcessing: emitterCallback(
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
dispatcher
),
} );
export {
EMIT_TYPES,
emitterSubscribers,
reducer,
emitEvent,
emitEventWithAbort,
};

View File

@ -0,0 +1,316 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { actions } from './actions';
import { reducer } from './reducer';
import { DEFAULT_STATE, STATUS } from './constants';
import {
EMIT_TYPES,
emitterSubscribers,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../shared/validation';
/**
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormDispatchActions} AddToCartFormDispatchActions
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
* @typedef {import('@woocommerce/type-defs/contexts').AddToCartFormContext} AddToCartFormContext
*/
const AddToCartFormContext = createContext( {
showFormElements: false,
product: {},
quantity: 0,
minQuantity: 1,
maxQuantity: 99,
requestParams: {},
isIdle: false,
isDisabled: false,
isProcessing: false,
isBeforeProcessing: false,
isAfterProcessing: false,
hasError: false,
eventRegistration: {
onAddToCartAfterProcessingWithSuccess: ( callback ) => void callback,
onAddToCartAfterProcessingWithError: ( callback ) => void callback,
onAddToCartBeforeProcessing: ( callback ) => void callback,
},
dispatchActions: {
resetForm: () => void null,
submitForm: () => void null,
setQuantity: ( quantity ) => void quantity,
setHasError: ( hasError ) => void hasError,
setAfterProcessing: ( response ) => void response,
setRequestParams: ( data ) => void data,
},
} );
/**
* @return {AddToCartFormContext} Returns the add to cart form data context value
*/
export const useAddToCartFormContext = () => {
// @ts-ignore
return useContext( AddToCartFormContext );
};
/**
* Add to cart form state provider.
*
* This provides provides an api interface exposing add to cart form state.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {Object} [props.product] The product for which the form belongs to.
* @param {boolean} [props.showFormElements] Should form elements be shown.
*/
export const AddToCartFormStateContextProvider = ( {
children,
product,
showFormElements,
} ) => {
const [ addToCartFormState, dispatch ] = useReducer(
reducer,
DEFAULT_STATE
);
const [ observers, subscriber ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const { addErrorNotice, removeNotices } = useStoreNotices();
const { setValidationErrors } = useValidationContext();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
/**
* @type {AddToCartFormEventRegistration}
*/
const eventRegistration = useMemo(
() => ( {
onAddToCartAfterProcessingWithSuccess: emitterSubscribers(
subscriber
).onAddToCartAfterProcessingWithSuccess,
onAddToCartAfterProcessingWithError: emitterSubscribers(
subscriber
).onAddToCartAfterProcessingWithError,
onAddToCartBeforeProcessing: emitterSubscribers( subscriber )
.onAddToCartBeforeProcessing,
} ),
[ subscriber ]
);
/**
* @type {AddToCartFormDispatchActions}
*/
const dispatchActions = useMemo(
() => ( {
resetForm: () => void dispatch( actions.setPristine() ),
submitForm: () => void dispatch( actions.setBeforeProcessing() ),
setQuantity: ( quantity ) =>
void dispatch( actions.setQuantity( quantity ) ),
setHasError: ( hasError ) =>
void dispatch( actions.setHasError( hasError ) ),
setRequestParams: ( data ) =>
void dispatch( actions.setRequestParams( data ) ),
setAfterProcessing: ( response ) => {
dispatch( actions.setProcessingResponse( response ) );
void dispatch( actions.setAfterProcessing() );
},
} ),
[]
);
/**
* This Effect is responsible for disabling or enabling the form based on the provided product.
*/
useEffect( () => {
const status = addToCartFormState.status;
const willBeDisabled =
! product.id || ! productIsPurchasable( product );
if ( status === STATUS.DISABLED && ! willBeDisabled ) {
dispatch( actions.setIdle() );
} else if ( status !== STATUS.DISABLED && willBeDisabled ) {
dispatch( actions.setDisabled() );
}
}, [ addToCartFormState.status, product, dispatch ] );
/**
* This Effect performs events before processing starts.
*/
useEffect( () => {
const status = addToCartFormState.status;
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNotices( 'error' );
emitEvent(
currentObservers.current,
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
if ( errorMessage ) {
addErrorNotice( errorMessage );
}
if ( validationErrors ) {
setValidationErrors( validationErrors );
}
}
);
}
dispatch( actions.setIdle() );
} else {
dispatch( actions.setProcessing() );
}
} );
}
}, [
addToCartFormState.status,
setValidationErrors,
addErrorNotice,
removeNotices,
dispatch,
] );
/**
* This Effect performs events after processing is complete.
*/
useEffect( () => {
if ( addToCartFormState.status === STATUS.AFTER_PROCESSING ) {
const data = {
processingResponse: addToCartFormState.processingResponse,
};
const handleErrorResponse = ( response ) => {
if ( response.message ) {
const errorOptions = response.messageContext
? { context: response.messageContext }
: undefined;
addErrorNotice( response.message, errorOptions );
}
};
if ( addToCartFormState.hasError ) {
// allow things to customize the error with a fallback if nothing customizes it.
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
data
).then( ( response ) => {
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
handleErrorResponse( response );
} else {
// no error handling in place by anything so let's fall back to default
const message =
data.processingResponse?.message ||
__(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
addErrorNotice( message, {
id: 'add-to-cart',
} );
}
dispatch( actions.setIdle() );
} );
return;
}
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
data
).then( ( response ) => {
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
handleErrorResponse( response );
// this will set an error which will end up
// triggering the onAddToCartAfterProcessingWithError emitter.
// and then setting to IDLE state.
dispatch( actions.setHasError( true ) );
} else {
dispatch( actions.setIdle() );
}
} );
}
}, [
addToCartFormState.status,
addToCartFormState.hasError,
addToCartFormState.processingResponse,
dispatchActions,
addErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
] );
/**
* @type {AddToCartFormContext}
*/
const contextData = {
showFormElements: showFormElements && productIsPurchasable( product ),
product,
quantity: addToCartFormState.quantity,
minQuantity: 1,
maxQuantity: product.quantity_limit || 99,
requestParams: addToCartFormState.requestParams,
isIdle: addToCartFormState.status === STATUS.IDLE,
isDisabled: addToCartFormState.status === STATUS.DISABLED,
isProcessing: addToCartFormState.status === STATUS.PROCESSING,
isBeforeProcessing:
addToCartFormState.status === STATUS.BEFORE_PROCESSING,
isAfterProcessing:
addToCartFormState.status === STATUS.AFTER_PROCESSING,
hasError: addToCartFormState.hasError,
eventRegistration,
dispatchActions,
};
return (
<AddToCartFormContext.Provider
// @ts-ignore
value={ contextData }
>
{ children }
</AddToCartFormContext.Provider>
);
};
/**
* Check a product object to see if it can be purchased.
*
* @param {Object} product Product object.
*/
const productIsPurchasable = ( product ) => {
const { is_purchasable: isPurchasable = false } = product;
return isPurchasable;
};

View File

@ -0,0 +1,151 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES, DEFAULT_STATE, STATUS } from './constants';
const {
SET_PRISTINE,
SET_IDLE,
SET_DISABLED,
SET_PROCESSING,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_HAS_ERROR,
SET_NO_ERROR,
SET_QUANTITY,
SET_REQUEST_PARAMS,
} = ACTION_TYPES;
const {
PRISTINE,
IDLE,
DISABLED,
PROCESSING,
BEFORE_PROCESSING,
AFTER_PROCESSING,
} = STATUS;
/**
* Reducer for the checkout state
*
* @param {Object} state Current state.
* @param {Object} action Incoming action object.
*/
export const reducer = ( state = DEFAULT_STATE, { quantity, type, data } ) => {
let newState;
switch ( type ) {
case SET_PRISTINE:
newState = DEFAULT_STATE;
break;
case SET_IDLE:
newState =
state.status !== IDLE
? {
...state,
status: IDLE,
}
: state;
break;
case SET_DISABLED:
newState =
state.status !== DISABLED
? {
...state,
status: DISABLED,
}
: state;
break;
case SET_QUANTITY:
newState =
quantity !== state.quantity
? {
...state,
quantity,
}
: state;
break;
case SET_REQUEST_PARAMS:
newState = {
...state,
requestParams: {
...state.requestParams,
...data,
},
};
break;
case SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data,
};
break;
case SET_PROCESSING:
newState =
state.status !== PROCESSING
? {
...state,
status: PROCESSING,
hasError: false,
}
: state;
// clear any error state.
newState =
newState.hasError === false
? newState
: { ...newState, hasError: false };
break;
case SET_BEFORE_PROCESSING:
newState =
state.status !== BEFORE_PROCESSING
? {
...state,
status: BEFORE_PROCESSING,
hasError: false,
}
: state;
break;
case SET_AFTER_PROCESSING:
newState =
state.status !== AFTER_PROCESSING
? {
...state,
status: AFTER_PROCESSING,
}
: state;
break;
case SET_HAS_ERROR:
newState = state.hasError
? state
: {
...state,
hasError: true,
};
newState =
state.status === PROCESSING ||
state.status === BEFORE_PROCESSING
? {
...newState,
status: IDLE,
}
: newState;
break;
case SET_NO_ERROR:
newState = state.hasError
? {
...state,
hasError: false,
}
: state;
break;
}
// automatically update state to idle from pristine as soon as it initially changes.
if (
newState !== state &&
type !== SET_PRISTINE &&
newState.status === PRISTINE
) {
newState.status = IDLE;
}
return newState;
};

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { ValidationContextProvider } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { AddToCartFormStateContextProvider } from '../form-state';
import FormSubmit from './submit';
/**
* Add to cart form provider.
*
* This wraps the add to cart form and provides an api interface for children via various hooks.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {Object} [props.product] The product for which the form belongs to.
* @param {boolean} [props.showFormElements] Should form elements be shown.
*/
export const AddToCartFormContextProvider = ( {
children,
product,
showFormElements,
} ) => {
return (
<ValidationContextProvider>
<AddToCartFormStateContextProvider
product={ product }
showFormElements={ showFormElements }
>
{ children }
<FormSubmit />
</AddToCartFormStateContextProvider>
</ValidationContextProvider>
);
};

View File

@ -0,0 +1,143 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useAddToCartFormContext,
useValidationContext,
} from '@woocommerce/base-context';
import { useEffect, useCallback, useState } from '@wordpress/element';
import { useStoreCart, useStoreNotices } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { triggerFragmentRefresh } from '@woocommerce/base-utils';
/**
* FormSubmit.
*
* Subscribes to add to cart form context and triggers processing via the API.
*/
const FormSubmit = () => {
const {
dispatchActions,
product,
quantity,
eventRegistration,
hasError,
isProcessing,
requestParams,
} = useAddToCartFormContext();
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const { addErrorNotice, removeNotice } = useStoreNotices();
const { receiveCart } = useStoreCart();
const [ isSubmitting, setIsSubmitting ] = useState( false );
const doSubmit = ! hasError && isProcessing;
const checkValidationContext = useCallback( () => {
if ( hasValidationErrors ) {
showAllValidationErrors();
return {
type: 'error',
};
}
return true;
}, [ hasValidationErrors, showAllValidationErrors ] );
// Subscribe to emitter before processing.
useEffect( () => {
const unsubscribeProcessing = eventRegistration.onAddToCartBeforeProcessing(
checkValidationContext,
0
);
return () => {
unsubscribeProcessing();
};
}, [ eventRegistration, checkValidationContext ] );
// Triggers form submission to the API.
const submitFormCallback = useCallback( () => {
setIsSubmitting( true );
removeNotice( 'add-to-cart' );
const fetchData = {
id: product.id || 0,
quantity,
...requestParams,
};
triggerFetch( {
path: '/wc/store/cart/add-item',
method: 'POST',
data: fetchData,
cache: 'no-store',
parse: false,
} )
.then( ( fetchResponse ) => {
// Update nonce.
triggerFetch.setNonce( fetchResponse.headers );
// Handle response.
fetchResponse.json().then( function( response ) {
if ( ! fetchResponse.ok ) {
// We received an error response.
if ( response.body && response.body.message ) {
addErrorNotice(
decodeEntities( response.body.message ),
{
id: 'add-to-cart',
}
);
} else {
addErrorNotice(
__(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
),
{
id: 'add-to-cart',
}
);
}
dispatchActions.setHasError();
} else {
receiveCart( response );
}
dispatchActions.setAfterProcessing( response );
setIsSubmitting( false );
triggerFragmentRefresh();
} );
} )
.catch( ( error ) => {
error.json().then( function( response ) {
// If updated cart state was returned, also update that.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
dispatchActions.setHasError();
dispatchActions.setAfterProcessing( response );
setIsSubmitting( false );
} );
} );
}, [
product,
addErrorNotice,
removeNotice,
receiveCart,
dispatchActions,
quantity,
requestParams,
] );
useEffect( () => {
if ( doSubmit && ! isSubmitting ) {
submitFormCallback();
}
}, [ doSubmit, submitFormCallback, isSubmitting ] );
return null;
};
export default FormSubmit;

View File

@ -0,0 +1,2 @@
export * from './form';
export * from './form-state';

View File

@ -6,7 +6,7 @@ import {
reducer, reducer,
emitEvent, emitEvent,
emitEventWithAbort, emitEventWithAbort,
} from '../event-emit'; } from '../../shared/event-emit';
const EMIT_TYPES = { const EMIT_TYPES = {
CHECKOUT_BEFORE_PROCESSING: 'checkout_before_processing', CHECKOUT_BEFORE_PROCESSING: 'checkout_before_processing',

View File

@ -26,7 +26,7 @@ import {
emitEventWithAbort, emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
import { useValidationContext } from '../validation'; import { useValidationContext } from '../../shared/validation';
/** /**
* @typedef {import('@woocommerce/type-defs/checkout').CheckoutDispatchActions} CheckoutDispatchActions * @typedef {import('@woocommerce/type-defs/checkout').CheckoutDispatchActions} CheckoutDispatchActions

View File

@ -3,5 +3,4 @@ export * from './shipping';
export * from './billing'; export * from './billing';
export * from './checkout'; export * from './checkout';
export * from './cart'; export * from './cart';
export * from './validation';
export { useCheckoutContext } from './checkout-state'; export { useCheckoutContext } from './checkout-state';

View File

@ -6,7 +6,7 @@ import {
emitEvent, emitEvent,
emitEventWithAbort, emitEventWithAbort,
emitterCallback, emitterCallback,
} from '../event-emit'; } from '../../shared/event-emit';
const EMIT_TYPES = { const EMIT_TYPES = {
PAYMENT_PROCESSING: 'payment_processing', PAYMENT_PROCESSING: 'payment_processing',

View File

@ -46,7 +46,7 @@ import {
emitEventWithAbort, emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
import { useValidationContext } from '../validation'; import { useValidationContext } from '../../shared/validation';
/** /**
* @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext * @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext

View File

@ -1,7 +1,7 @@
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { emitterCallback, reducer, emitEvent } from '../event-emit'; import { emitterCallback, reducer, emitEvent } from '../../shared/event-emit';
const EMIT_TYPES = { const EMIT_TYPES = {
SHIPPING_RATES_SUCCESS: 'shipping_rates_success', SHIPPING_RATES_SUCCESS: 'shipping_rates_success',

View File

@ -3,4 +3,5 @@ export * from './container-width-context';
export * from './query-state-context'; export * from './query-state-context';
export * from './store-notices-context'; export * from './store-notices-context';
export * from './editor'; export * from './editor';
export * from './add-to-cart-form-context'; export * from './add-to-cart-form';
export * from './shared';

View File

@ -46,7 +46,7 @@ export const emitEvent = async ( observers, eventType, data ) => {
/** /**
* Emits events on registered observers for the provided type and passes along * Emits events on registered observers for the provided type and passes along
* the provided data. This event emitter will abort and return any value from * the provided data. This event emitter will abort and return any value from
* observers that do not return true. * observers that return an object which should contain a type property.
* *
* @param {Object} observers The registered observers to omit to. * @param {Object} observers The registered observers to omit to.
* @param {string} eventType The event type being emitted. * @param {string} eventType The event type being emitted.
@ -75,8 +75,7 @@ export const emitEventWithAbort = async ( observers, eventType, data ) => {
return emitterResponse; return emitterResponse;
} }
} catch ( e ) { } catch ( e ) {
// we don't handle thrown errors but just console.log for // We don't handle thrown errors but just console.log for troubleshooting.
// troubleshooting
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( e ); console.error( e );
return { type: 'error' }; return { type: 'error' };

View File

@ -0,0 +1,2 @@
export * from './validation';
export * from './event-emit';

View File

@ -6,6 +6,7 @@ import {
InnerBlockLayoutContextProvider, InnerBlockLayoutContextProvider,
ProductDataContextProvider, ProductDataContextProvider,
} from '@woocommerce/shared-context'; } from '@woocommerce/shared-context';
import { StoreNoticesProvider } from '@woocommerce/base-context';
/** /**
* Internal dependencies * Internal dependencies
@ -17,6 +18,7 @@ import { BLOCK_NAME } from './constants';
*/ */
const Block = ( { isLoading, product, children } ) => { const Block = ( { isLoading, product, children } ) => {
const className = 'wc-block-single-product wc-block-layout'; const className = 'wc-block-single-product wc-block-layout';
const noticeContext = `woocommerce/single-product/${ product?.id || 0 }`;
return ( return (
<InnerBlockLayoutContextProvider <InnerBlockLayoutContextProvider
@ -27,7 +29,9 @@ const Block = ( { isLoading, product, children } ) => {
product={ product } product={ product }
isLoading={ isLoading } isLoading={ isLoading }
> >
<div className={ className }>{ children }</div> <StoreNoticesProvider context={ noticeContext }>
<div className={ className }>{ children }</div>
</StoreNoticesProvider>
</ProductDataContextProvider> </ProductDataContextProvider>
</InnerBlockLayoutContextProvider> </InnerBlockLayoutContextProvider>
); );

View File

@ -1,7 +1,6 @@
/** /**
* External dependencies * External dependencies
*/ */
import { StoreNoticesProvider } from '@woocommerce/base-context';
import { getValidBlockAttributes } from '@woocommerce/base-utils'; import { getValidBlockAttributes } from '@woocommerce/base-utils';
import { import {
renderParentBlock, renderParentBlock,
@ -15,19 +14,6 @@ import Block from './block';
import blockAttributes from './attributes'; import blockAttributes from './attributes';
import { BLOCK_NAME } from './constants'; import { BLOCK_NAME } from './constants';
/**
* Wrapper component to supply the notice provider.
*
* @param {*} props
*/
const FrontendBlock = ( props ) => {
return (
<StoreNoticesProvider context="woocommerce/single-product">
<Block { ...props } />
</StoreNoticesProvider>
);
};
const getProps = ( el ) => { const getProps = ( el ) => {
return { return {
attributes: getValidBlockAttributes( blockAttributes, el.dataset ), attributes: getValidBlockAttributes( blockAttributes, el.dataset ),
@ -35,7 +21,7 @@ const getProps = ( el ) => {
}; };
renderParentBlock( { renderParentBlock( {
Block: FrontendBlock, Block,
blockName: BLOCK_NAME, blockName: BLOCK_NAME,
selector: '.wp-block-woocommerce-single-product', selector: '.wp-block-woocommerce-single-product',
getProps, getProps,

View File

@ -0,0 +1,43 @@
/**
* @typedef {Object} AddToCartFormDispatchActions
*
* @property {function():void} resetForm Dispatches an action that resets the form to a
* pristine state.
* @property {function():void} submitForm Dispatches an action that tells the form to submit.
* @property {function(number):void} setQuantity Dispatches an action that sets the quantity to
* the given value.
* @property {function(Object):void} setRequestParams Dispatches an action that sets params for the
* add to cart request (key value pairs).
* @property {function(boolean=):void} setHasError Dispatches an action that sets the status to
* having an error.
* @property {function(Object):void} setAfterProcessing Dispatches an action that sets the status to
* after processing and also sets the response
* data accordingly.
*/
/**
* @typedef {Object} AddToCartFormEventRegistration
*
* @property {function(function():boolean|Object,number=):function():void} onAddToCartAfterProcessingWithSuccess Used to register a callback that will fire after form has been processed and there are no errors.
* @property {function(function():boolean|Object,number=):function():void} onAddToCartAfterProcessingWithError Used to register a callback that will fire when the form has been processed and has an error.
* @property {function(function():boolean|Object,number=):function():void} onAddToCartBeforeProcessing Used to register a callback that will fire when the form has been submitted before being sent off to the server.
*/
/**
* @typedef {Object} AddToCartFormStatusConstants
*
* @property {string} PRISTINE Form is in it's initialized state.
* @property {string} IDLE When form state has changed but there is no
* activity happening.
* @property {string} DISABLED If the form cannot be submitted due to missing
* constraints, this status is assigned.
* @property {string} BEFORE_PROCESSING This is the state before form processing
* begins after the add to cart button has been
* pressed. Validation occurs at point point.
* @property {string} PROCESSING After BEFORE_PROCESSING status emitters have
* finished successfully.
* @property {string} AFTER_PROCESSING After server side processing is completed
* this status is set.
*/
export {};

View File

@ -4,6 +4,8 @@
* @typedef {import('./cart').CartShippingAddress} CartShippingAddress * @typedef {import('./cart').CartShippingAddress} CartShippingAddress
* @typedef {import('./cart').CartData} CartData * @typedef {import('./cart').CartData} CartData
* @typedef {import('./checkout').CheckoutDispatchActions} CheckoutDispatchActions * @typedef {import('./checkout').CheckoutDispatchActions} CheckoutDispatchActions
* @typedef {import('./add-to-cart-form').AddToCartFormDispatchActions} AddToCartFormDispatchActions
* @typedef {import('./add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
*/ */
/** /**
@ -260,27 +262,20 @@
/** /**
* @typedef {Object} AddToCartFormContext * @typedef {Object} AddToCartFormContext
* *
* @property {Object} product The product object. * @property {boolean} showFormElements True if showing a full add to cart form.
* @property {number} productId The product ID being added to the cart. * @property {Object} product The product object to add to the cart.
* @property {number} variationId The variation ID being added to the cart, or 0. * @property {number} quantity Stores the quantity being added to the cart.
* @property {Object} variationData Object containing variation attribute/value data. * @property {number} minQuantity Min quantity that can be added to the cart.
* @property {Object} cartItemData Object containing custom cart item data. * @property {number} maxQuantity Max quantity than can be added to the cart.
* @property {number} quantity Stores the quantity being added to the cart. * @property {Object} requestParams List of params to send to the API.
* @property {number} minQuantity Min quantity that can be added to the cart. * @property {boolean} isIdle True when the form state has changed and has no activity.
* @property {number} maxQuantity Max quantity than can be added to the cart. * @property {boolean} isDisabled True when the form cannot be submitted.
* @property {number} quantityInCart Stores how many of a product are already in the cart. * @property {boolean} isProcessing True when the form has been submitted and is being processed.
* @property {function(number):void} setQuantity Sets the quantity being added to the cart. * @property {boolean} isBeforeProcessing True during any observers executing logic before form processing (eg. validation).
* @property {function(number):void} setVariationId Sets the variation ID being added to the cart. * @property {boolean} isAfterProcessing True when form status is AFTER_PROCESSING.
* @property {function(Object):void} setVariationData Sets variation data attribute=>value pairs. * @property {boolean} hasError True when the form is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
* @property {function(Object):void} setCartItemData Sets cart item data attribute=>value pairs. * @property {AddToCartFormEventRegistration} eventRegistration Event emitters that can be subscribed to.
* @property {boolean} showFormElements True if showing a full add to cart form. * @property {AddToCartFormDispatchActions} dispatchActions Various actions that can be dispatched for the add to cart form context data.
* @property {boolean} formInitialized True once the cart form is ready.
* @property {boolean} formDisabled True if the cart form cannot yet be submitted.
* @property {boolean} formSubmitting True when the cart form is busy adding to the cart.
* @property {function():void} onChange Triggered when a form element changes.
* @property {function():void} onSubmit Submits the form.
* @property {function():void} onSuccess Triggered when the add to cart request is successful.
* @property {function():void} onFail Triggered when the add to cart request fails.
*/ */
/** /**