Add to cart context provider (https://github.com/woocommerce/woocommerce-blocks/pull/2903)
* 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:
parent
fc3735564a
commit
36c1bb7361
|
@ -18,8 +18,8 @@ const Simple = () => {
|
|||
quantity,
|
||||
minQuantity,
|
||||
maxQuantity,
|
||||
setQuantity,
|
||||
formDisabled,
|
||||
dispatchActions,
|
||||
isDisabled,
|
||||
} = useAddToCartFormContext();
|
||||
|
||||
if ( product.id && ! product.is_purchasable ) {
|
||||
|
@ -43,8 +43,8 @@ const Simple = () => {
|
|||
value={ quantity }
|
||||
min={ minQuantity }
|
||||
max={ maxQuantity }
|
||||
disabled={ formDisabled }
|
||||
onChange={ setQuantity }
|
||||
disabled={ isDisabled }
|
||||
onChange={ dispatchActions.setQuantity }
|
||||
/>
|
||||
<AddToCartButton />
|
||||
</>
|
||||
|
|
|
@ -23,8 +23,8 @@ const Variable = () => {
|
|||
quantity,
|
||||
minQuantity,
|
||||
maxQuantity,
|
||||
setQuantity,
|
||||
formDisabled,
|
||||
dispatchActions,
|
||||
isDisabled,
|
||||
} = useAddToCartFormContext();
|
||||
|
||||
if ( product.id && ! product.is_purchasable ) {
|
||||
|
@ -44,13 +44,16 @@ const Variable = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<VariationAttributes product={ product } />
|
||||
<VariationAttributes
|
||||
product={ product }
|
||||
dispatchers={ dispatchActions }
|
||||
/>
|
||||
<QuantityInput
|
||||
value={ quantity }
|
||||
min={ minQuantity }
|
||||
max={ maxQuantity }
|
||||
disabled={ formDisabled }
|
||||
onChange={ setQuantity }
|
||||
disabled={ isDisabled }
|
||||
onChange={ dispatchActions.setQuantity }
|
||||
/>
|
||||
<AddToCartButton />
|
||||
</>
|
||||
|
|
|
@ -2,14 +2,15 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from '@wordpress/element';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import AttributeSelectControl from './attribute-select-control';
|
||||
import {
|
||||
getVariationsMatchingSelectedAttributes,
|
||||
getSelectControlOptions,
|
||||
getVariationMatchingSelectedAttributes,
|
||||
getActiveSelectControlOptions,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
|
@ -17,88 +18,74 @@ import {
|
|||
*
|
||||
* @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 [ selectedAttributes, setSelectedAttributes ] = useState( {} );
|
||||
|
||||
// @todo Support default selected attributes in Variation 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.
|
||||
// Get options for each attribute picker.
|
||||
const filteredAttributeOptions = useMemo( () => {
|
||||
const options = [];
|
||||
return getActiveSelectControlOptions(
|
||||
currentAttributes,
|
||||
currentVariationAttributes,
|
||||
selectedAttributes
|
||||
);
|
||||
}, [ selectedAttributes, currentAttributes, currentVariationAttributes ] );
|
||||
|
||||
attributeNames.forEach( ( attributeName ) => {
|
||||
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.
|
||||
// Select variations when selections are change.
|
||||
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 );
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingVariationIds = getVariationsMatchingSelectedAttributes( {
|
||||
selectedAttributes,
|
||||
variationAttributes,
|
||||
attributeNames,
|
||||
} );
|
||||
|
||||
setVariationId( matchingVariationIds[ 0 ] || 0 );
|
||||
}, [
|
||||
selectedAttributes,
|
||||
variationAttributes,
|
||||
attributeNames,
|
||||
hasSelectedAllAttributes,
|
||||
variationId,
|
||||
currentAttributes,
|
||||
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 (
|
||||
<div className="wc-block-components-product-add-to-cart-attribute-picker">
|
||||
{ attributeNames.map( ( attributeName ) => (
|
||||
{ Object.keys( currentAttributes ).map( ( attributeName ) => (
|
||||
<AttributeSelectControl
|
||||
key={ attributeName }
|
||||
attributeName={ attributeName }
|
||||
options={ filteredAttributeOptions[ attributeName ] }
|
||||
selected={ selectedAttributes[ attributeName ] }
|
||||
value={ selectedAttributes[ attributeName ] }
|
||||
onChange={ ( selected ) => {
|
||||
setSelectedAttributes( {
|
||||
...selectedAttributes,
|
||||
|
@ -107,7 +94,6 @@ const AttributePicker = ( { attributes, variationAttributes } ) => {
|
|||
} }
|
||||
/>
|
||||
) ) }
|
||||
<p>Matched variation ID: { variationId }</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
import { __ } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
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.
|
||||
const selectAnOption = {
|
||||
|
@ -19,17 +23,66 @@ const selectAnOption = {
|
|||
const AttributeSelectControl = ( {
|
||||
attributeName,
|
||||
options = [],
|
||||
selected = '',
|
||||
value = '',
|
||||
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 (
|
||||
<SelectControl
|
||||
className="wc-block-components-product-add-to-cart-attribute-picker__select"
|
||||
label={ decodeEntities( attributeName ) }
|
||||
value={ selected || '' }
|
||||
options={ [ selectAnOption, ...options ] }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
<div className="wc-block-components-product-add-to-cart-attribute-picker__container">
|
||||
<SelectControl
|
||||
label={ decodeEntities( attributeName ) }
|
||||
value={ value || '' }
|
||||
options={ [ selectAnOption, ...options ] }
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,17 +7,10 @@ import { getAttributes, getVariationAttributes } from './utils';
|
|||
|
||||
/**
|
||||
* VariationAttributes component.
|
||||
*
|
||||
* @param {*} props Component props.
|
||||
*/
|
||||
const VariationAttributes = ( { product } ) => {
|
||||
const {
|
||||
attributes: productAttributes = {},
|
||||
variations: productVariations = [],
|
||||
} = product;
|
||||
|
||||
const attributes = getAttributes( productAttributes );
|
||||
const variationAttributes = getVariationAttributes( productVariations );
|
||||
const VariationAttributes = ( { product, dispatchers } ) => {
|
||||
const attributes = getAttributes( product.attributes );
|
||||
const variationAttributes = getVariationAttributes( product.variations );
|
||||
|
||||
if (
|
||||
Object.keys( attributes ).length === 0 ||
|
||||
|
@ -30,6 +23,7 @@ const VariationAttributes = ( { product } ) => {
|
|||
<AttributePicker
|
||||
attributes={ attributes }
|
||||
variationAttributes={ variationAttributes }
|
||||
setRequestParams={ dispatchers.setRequestParams }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,6 +7,10 @@
|
|||
@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 {
|
||||
margin: 0 0 em($gap-small) 0;
|
||||
|
||||
|
@ -14,5 +18,16 @@
|
|||
min-width: 60%;
|
||||
min-height: 1.75em;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
margin-bottom: $gap-large;
|
||||
|
||||
select {
|
||||
border-color: $error-red;
|
||||
&:focus {
|
||||
outline-color: $error-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -2,6 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { keyBy } from 'lodash';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Key an array of attributes by name,
|
||||
|
@ -16,13 +17,16 @@ export const getAttributes = ( attributes ) => {
|
|||
),
|
||||
'name'
|
||||
)
|
||||
: [];
|
||||
: {};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 ) => {
|
||||
if ( ! variations ) {
|
||||
|
@ -32,65 +36,56 @@ export const getVariationAttributes = ( variations ) => {
|
|||
const attributesMap = {};
|
||||
|
||||
variations.forEach( ( { id, attributes } ) => {
|
||||
attributesMap[ id ] = attributes.reduce( ( acc, { name, value } ) => {
|
||||
acc[ name ] = value;
|
||||
return acc;
|
||||
}, [] );
|
||||
attributesMap[ `id:${ id }` ] = {
|
||||
id,
|
||||
attributes: attributes.reduce( ( acc, { name, value } ) => {
|
||||
acc[ name ] = value;
|
||||
return acc;
|
||||
}, {} ),
|
||||
};
|
||||
} );
|
||||
|
||||
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.
|
||||
*
|
||||
* Allows an attribute to be excluded by name. This is used to filter displayed options for
|
||||
* individual attribute selects.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.selectedAttributes List of selected attributes.
|
||||
* @param {Object} props.variationAttributes List of variations and their attributes.
|
||||
* @param {Object} props.attributeNames List of all possible attribute names.
|
||||
* @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 {Array} List of matching variation IDs.
|
||||
*/
|
||||
export const getVariationsMatchingSelectedAttributes = ( {
|
||||
selectedAttributes,
|
||||
export const getVariationsMatchingSelectedAttributes = (
|
||||
attributes,
|
||||
variationAttributes,
|
||||
attributeNames,
|
||||
} ) => {
|
||||
return Object.keys( variationAttributes ).filter( ( variationId ) =>
|
||||
selectedAttributes
|
||||
) => {
|
||||
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 ) => {
|
||||
const selectedAttribute = selectedAttributes[ attributeName ] || '';
|
||||
const variationAttribute =
|
||||
variationAttributes[ variationId ][ attributeName ];
|
||||
variationAttributes[ 'id:' + variationId ].attributes[
|
||||
attributeName
|
||||
];
|
||||
|
||||
// If there is no selected attribute, consider this a match.
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import Button from '@woocommerce/base-components/button';
|
||||
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 { useStoreAddToCart } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Add to Cart Form Button Component.
|
||||
|
@ -14,20 +15,37 @@ const AddToCartButton = () => {
|
|||
const {
|
||||
showFormElements,
|
||||
product,
|
||||
quantityInCart,
|
||||
formDisabled,
|
||||
formSubmitting,
|
||||
onSubmit,
|
||||
isDisabled,
|
||||
isProcessing,
|
||||
eventRegistration,
|
||||
hasError,
|
||||
dispatchActions,
|
||||
} = 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 {
|
||||
is_purchasable: isPurchasable = true,
|
||||
has_options: hasOptions,
|
||||
add_to_cart: addToCartButtonData = {
|
||||
url: '',
|
||||
text: '',
|
||||
},
|
||||
} = product;
|
||||
// Subscribe to emitter for after processing.
|
||||
useEffect( () => {
|
||||
const onSuccess = () => {
|
||||
if ( ! hasError ) {
|
||||
setAddedToCart( true );
|
||||
}
|
||||
return true;
|
||||
};
|
||||
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
|
||||
// a functional direct add to cart button, provided that the product is purchasable.
|
||||
|
@ -36,10 +54,11 @@ const AddToCartButton = () => {
|
|||
return (
|
||||
<ButtonComponent
|
||||
className="wc-block-components-product-add-to-cart-button"
|
||||
quantityInCart={ quantityInCart }
|
||||
disabled={ formDisabled }
|
||||
loading={ formSubmitting }
|
||||
onClick={ onSubmit }
|
||||
quantityInCart={ cartQuantity }
|
||||
isDisabled={ isDisabled }
|
||||
isProcessing={ isProcessing }
|
||||
isDone={ addedToCart }
|
||||
onClick={ () => dispatchActions.submitForm() }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -73,23 +92,19 @@ const LinkComponent = ( { className, href, text } ) => {
|
|||
const ButtonComponent = ( {
|
||||
className,
|
||||
quantityInCart,
|
||||
loading,
|
||||
disabled,
|
||||
isProcessing,
|
||||
isDisabled,
|
||||
isDone,
|
||||
onClick,
|
||||
} ) => {
|
||||
const [ wasClicked, setWasClicked ] = useState( false );
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={ className }
|
||||
disabled={ disabled }
|
||||
showSpinner={ loading }
|
||||
onClick={ () => {
|
||||
onClick();
|
||||
setWasClicked( true );
|
||||
} }
|
||||
disabled={ isDisabled }
|
||||
showSpinner={ isProcessing }
|
||||
onClick={ onClick }
|
||||
>
|
||||
{ quantityInCart > 0
|
||||
{ isDone && quantityInCart > 0
|
||||
? sprintf(
|
||||
// translators: %s number of products in cart.
|
||||
_n(
|
||||
|
@ -101,7 +116,7 @@ const ButtonComponent = ( {
|
|||
quantityInCart
|
||||
)
|
||||
: __( 'Add to cart', 'woo-gutenberg-products-block' ) }
|
||||
{ wasClicked && (
|
||||
{ !! isDone && (
|
||||
<Icon
|
||||
srcElement={ doneIcon }
|
||||
alt={ __( 'Done', 'woo-gutenberg-products-block' ) }
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
.wc-block-components-product-add-to-cart-quantity {
|
||||
margin: 0 1em em($gap-small) 0;
|
||||
width: 5em;
|
||||
flex-basis: 5em;
|
||||
padding: 0.618em;
|
||||
background: $white;
|
||||
border: 1px solid #ccc;
|
||||
|
@ -31,6 +31,8 @@
|
|||
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-quantity,
|
||||
.wc-block-components-product-add-to-cart-button {
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
} );
|
|
@ -3,17 +3,15 @@
|
|||
margin-bottom: 2em;
|
||||
.wc-block-components-notices__notice {
|
||||
margin: 0;
|
||||
.components-notice__content {
|
||||
display: inline-block;
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
.components-notice__dismiss {
|
||||
background: transparent none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin: 0 0 0 0.5em;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #fff;
|
||||
float: right;
|
||||
svg {
|
||||
fill: #fff;
|
||||
vertical-align: text-top;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
} ),
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export * from './form';
|
||||
export * from './form-state';
|
|
@ -6,7 +6,7 @@ import {
|
|||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
} from '../event-emit';
|
||||
} from '../../shared/event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
CHECKOUT_BEFORE_PROCESSING: 'checkout_before_processing',
|
||||
|
|
|
@ -26,7 +26,7 @@ import {
|
|||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { useValidationContext } from '../validation';
|
||||
import { useValidationContext } from '../../shared/validation';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/checkout').CheckoutDispatchActions} CheckoutDispatchActions
|
||||
|
|
|
@ -3,5 +3,4 @@ export * from './shipping';
|
|||
export * from './billing';
|
||||
export * from './checkout';
|
||||
export * from './cart';
|
||||
export * from './validation';
|
||||
export { useCheckoutContext } from './checkout-state';
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
emitterCallback,
|
||||
} from '../event-emit';
|
||||
} from '../../shared/event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
PAYMENT_PROCESSING: 'payment_processing',
|
||||
|
|
|
@ -46,7 +46,7 @@ import {
|
|||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { useValidationContext } from '../validation';
|
||||
import { useValidationContext } from '../../shared/validation';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { emitterCallback, reducer, emitEvent } from '../event-emit';
|
||||
import { emitterCallback, reducer, emitEvent } from '../../shared/event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
SHIPPING_RATES_SUCCESS: 'shipping_rates_success',
|
||||
|
|
|
@ -3,4 +3,5 @@ export * from './container-width-context';
|
|||
export * from './query-state-context';
|
||||
export * from './store-notices-context';
|
||||
export * from './editor';
|
||||
export * from './add-to-cart-form-context';
|
||||
export * from './add-to-cart-form';
|
||||
export * from './shared';
|
||||
|
|
|
@ -46,7 +46,7 @@ export const emitEvent = async ( observers, eventType, data ) => {
|
|||
/**
|
||||
* 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
|
||||
* 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 {string} eventType The event type being emitted.
|
||||
|
@ -75,8 +75,7 @@ export const emitEventWithAbort = async ( observers, eventType, data ) => {
|
|||
return emitterResponse;
|
||||
}
|
||||
} catch ( e ) {
|
||||
// we don't handle thrown errors but just console.log for
|
||||
// troubleshooting
|
||||
// We don't handle thrown errors but just console.log for troubleshooting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error( e );
|
||||
return { type: 'error' };
|
|
@ -0,0 +1,2 @@
|
|||
export * from './validation';
|
||||
export * from './event-emit';
|
|
@ -6,6 +6,7 @@ import {
|
|||
InnerBlockLayoutContextProvider,
|
||||
ProductDataContextProvider,
|
||||
} from '@woocommerce/shared-context';
|
||||
import { StoreNoticesProvider } from '@woocommerce/base-context';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -17,6 +18,7 @@ import { BLOCK_NAME } from './constants';
|
|||
*/
|
||||
const Block = ( { isLoading, product, children } ) => {
|
||||
const className = 'wc-block-single-product wc-block-layout';
|
||||
const noticeContext = `woocommerce/single-product/${ product?.id || 0 }`;
|
||||
|
||||
return (
|
||||
<InnerBlockLayoutContextProvider
|
||||
|
@ -27,7 +29,9 @@ const Block = ( { isLoading, product, children } ) => {
|
|||
product={ product }
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
<div className={ className }>{ children }</div>
|
||||
<StoreNoticesProvider context={ noticeContext }>
|
||||
<div className={ className }>{ children }</div>
|
||||
</StoreNoticesProvider>
|
||||
</ProductDataContextProvider>
|
||||
</InnerBlockLayoutContextProvider>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { StoreNoticesProvider } from '@woocommerce/base-context';
|
||||
import { getValidBlockAttributes } from '@woocommerce/base-utils';
|
||||
import {
|
||||
renderParentBlock,
|
||||
|
@ -15,19 +14,6 @@ import Block from './block';
|
|||
import blockAttributes from './attributes';
|
||||
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 ) => {
|
||||
return {
|
||||
attributes: getValidBlockAttributes( blockAttributes, el.dataset ),
|
||||
|
@ -35,7 +21,7 @@ const getProps = ( el ) => {
|
|||
};
|
||||
|
||||
renderParentBlock( {
|
||||
Block: FrontendBlock,
|
||||
Block,
|
||||
blockName: BLOCK_NAME,
|
||||
selector: '.wp-block-woocommerce-single-product',
|
||||
getProps,
|
||||
|
|
|
@ -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 {};
|
|
@ -4,6 +4,8 @@
|
|||
* @typedef {import('./cart').CartShippingAddress} CartShippingAddress
|
||||
* @typedef {import('./cart').CartData} CartData
|
||||
* @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
|
||||
*
|
||||
* @property {Object} product The product object.
|
||||
* @property {number} productId The product ID being added to the cart.
|
||||
* @property {number} variationId The variation ID being added to the cart, or 0.
|
||||
* @property {Object} variationData Object containing variation attribute/value data.
|
||||
* @property {Object} cartItemData Object containing custom cart item data.
|
||||
* @property {number} quantity Stores the quantity being added to the cart.
|
||||
* @property {number} minQuantity Min quantity that can be added to the cart.
|
||||
* @property {number} maxQuantity Max quantity than can be added to the cart.
|
||||
* @property {number} quantityInCart Stores how many of a product are already in the cart.
|
||||
* @property {function(number):void} setQuantity Sets the quantity being added to the cart.
|
||||
* @property {function(number):void} setVariationId Sets the variation ID being added to the cart.
|
||||
* @property {function(Object):void} setVariationData Sets variation data attribute=>value pairs.
|
||||
* @property {function(Object):void} setCartItemData Sets cart item data attribute=>value pairs.
|
||||
* @property {boolean} showFormElements True if showing a full add to cart form.
|
||||
* @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.
|
||||
* @property {boolean} showFormElements True if showing a full add to cart form.
|
||||
* @property {Object} product The product object to add to the cart.
|
||||
* @property {number} quantity Stores the quantity being added to the cart.
|
||||
* @property {number} minQuantity Min quantity that can be added to the cart.
|
||||
* @property {number} maxQuantity Max quantity than can be added to the cart.
|
||||
* @property {Object} requestParams List of params to send to the API.
|
||||
* @property {boolean} isIdle True when the form state has changed and has no activity.
|
||||
* @property {boolean} isDisabled True when the form cannot be submitted.
|
||||
* @property {boolean} isProcessing True when the form has been submitted and is being processed.
|
||||
* @property {boolean} isBeforeProcessing True during any observers executing logic before form processing (eg. validation).
|
||||
* @property {boolean} isAfterProcessing True when form status is AFTER_PROCESSING.
|
||||
* @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 {AddToCartFormEventRegistration} eventRegistration Event emitters that can be subscribed to.
|
||||
* @property {AddToCartFormDispatchActions} dispatchActions Various actions that can be dispatched for the add to cart form context data.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue