From 36c1bb736116e0d0492118b34baa1ed887ebf3e3 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 30 Jul 2020 11:57:22 +0100 Subject: [PATCH] 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 * Update assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js Co-authored-by: Darren Ethier * Update assets/js/base/context/add-to-cart-form/form-state/reducer.js Co-authored-by: Darren Ethier * 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 --- .../add-to-cart/product-types/simple.js | 8 +- .../product-types/variable/index.js | 13 +- .../variation-attributes/attribute-picker.js | 122 +++--- .../attribute-select-control.js | 69 ++- .../variable/variation-attributes/index.js | 14 +- .../variable/variation-attributes/style.scss | 15 + .../variation-attributes/test/index.js | 395 ++++++++++++++++++ .../variable/variation-attributes/utils.js | 189 +++++++-- .../add-to-cart/shared/add-to-cart-button.js | 73 ++-- .../product-elements/add-to-cart/style.scss | 4 +- .../atomic/blocks/product/price/attributes.js | 40 -- .../js/atomic/blocks/product/price/index.js | 32 -- .../store-notices-container/style.scss | 8 +- .../base/context/add-to-cart-form-context.js | 156 ------- .../add-to-cart-form/form-state/actions.js | 58 +++ .../add-to-cart-form/form-state/constants.js | 32 ++ .../add-to-cart-form/form-state/event-emit.js | 52 +++ .../add-to-cart-form/form-state/index.js | 316 ++++++++++++++ .../add-to-cart-form/form-state/reducer.js | 151 +++++++ .../context/add-to-cart-form/form/index.js | 38 ++ .../add-to-cart-form/form/submit/index.js | 143 +++++++ .../js/base/context/add-to-cart-form/index.js | 2 + .../checkout-state/event-emit.js | 2 +- .../cart-checkout/checkout-state/index.js | 2 +- .../js/base/context/cart-checkout/index.js | 1 - .../payment-methods/event-emit.js | 2 +- .../payment-method-data-context.js | 2 +- .../cart-checkout/shipping/event-emit.js | 2 +- .../assets/js/base/context/index.js | 3 +- .../event-emit/emitter-callback.js | 0 .../event-emit/emitters.js | 5 +- .../event-emit/index.js | 0 .../event-emit/reducer.js | 0 .../event-emit/test/emitters.js | 0 .../assets/js/base/context/shared/index.js | 2 + .../validation/index.js | 0 .../assets/js/blocks/single-product/block.js | 6 +- .../js/blocks/single-product/frontend.js | 16 +- .../assets/js/type-defs/add-to-cart-form.js | 43 ++ .../assets/js/type-defs/contexts.js | 37 +- 40 files changed, 1604 insertions(+), 449 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.js delete mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product/price/attributes.js delete mode 100644 plugins/woocommerce-blocks/assets/js/atomic/blocks/product/price/index.js delete mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form-context.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form-state/actions.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form-state/constants.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form-state/event-emit.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form-state/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form-state/reducer.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/form/submit/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/add-to-cart-form/index.js rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/event-emit/emitter-callback.js (100%) rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/event-emit/emitters.js (95%) rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/event-emit/index.js (100%) rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/event-emit/reducer.js (100%) rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/event-emit/test/emitters.js (100%) create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/shared/index.js rename plugins/woocommerce-blocks/assets/js/base/context/{cart-checkout => shared}/validation/index.js (100%) create mode 100644 plugins/woocommerce-blocks/assets/js/type-defs/add-to-cart-form.js diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js index 0aff18b9ea8..a08fe69ee38 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js @@ -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 } /> diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js index f0a209dbd97..fb7d707e306 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js @@ -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 ( <> - + diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.js index 9b865838bd5..7a98817707a 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.js @@ -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 (
- { attributeNames.map( ( attributeName ) => ( + { Object.keys( currentAttributes ).map( ( attributeName ) => ( { setSelectedAttributes( { ...selectedAttributes, @@ -107,7 +94,6 @@ const AttributePicker = ( { attributes, variationAttributes } ) => { } } /> ) ) } -

Matched variation ID: { variationId }

); }; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js index 7c12d3b489d..c2092dab7df 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-select-control.js @@ -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 ( - +
+ + +
); }; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.js index cc8dee8091f..8509e7e518e 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.js @@ -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 } ) => { ); }; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss index 30c2a32efef..a9dbfd50c8a 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss @@ -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; + } + } + } } } diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.js new file mode 100644 index 00000000000..d17cc1e32ef --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.js @@ -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', + }, + ], + } ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.js index 132454b920a..e53eb5a9930 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.js @@ -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; +}; diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.js b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.js index 66c5763f007..58e992b1ec7 100644 --- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.js +++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/add-to-cart/shared/add-to-cart-button.js @@ -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 ( 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 (