From 8d1cbd0e013baac75b666fb2e108420c56c34e3b Mon Sep 17 00:00:00 2001 From: "O.Bilenko" Date: Tue, 2 Apr 2019 12:04:05 +0200 Subject: [PATCH] PWA-1786 Not possible to implement product options implementation from extension level --- .../commerce/product/selectors/options.js | 8 +- .../Product/components/Content/connector.js | 5 +- .../Product/components/Content/index.jsx | 20 +- .../Header/components/Discount/connector.js | 4 +- .../Header/components/Price/connector.js | 6 +- .../Header/components/Price/index.jsx | 54 ++-- .../Header/components/PriceInfo/connector.js | 4 +- .../components/PriceStriked/connector.js | 4 +- .../Header/components/Tiers/connector.js | 4 +- .../Options/__snapshots__/spec.jsx.snap | 248 ------------------ .../Content/__snapshots__/spec.jsx.snap | 27 ++ .../{ => components/Content}/connector.js | 0 .../Options/components/Content/index.jsx | 113 ++++++++ .../Options/components/Content/spec.jsx | 72 +++++ .../Options/components/Option/index.jsx | 33 ++- .../OptionInfo/__snapshots__/spec.jsx.snap | 35 --- .../components/OptionInfo/index.jsx | 44 ++-- .../TextOption/components/OptionInfo/spec.jsx | 42 +-- .../Options/components/TextOption/index.jsx | 22 +- .../Product/components/Options/index.jsx | 140 ++-------- .../pages/Product/components/Options/spec.jsx | 98 ------- themes/theme-gmd/themeApi/index.js | 4 + .../Product/components/Content/connector.js | 5 +- .../Product/components/Content/index.jsx | 21 +- .../Header/components/Discount/connector.js | 4 +- .../Header/components/Price/connector.js | 6 +- .../Header/components/Price/index.jsx | 54 ++-- .../Header/components/PriceInfo/connector.js | 4 +- .../components/PriceStriked/connector.js | 4 +- .../Header/components/Tiers/connector.js | 4 +- .../Options/__snapshots__/spec.jsx.snap | 248 ------------------ .../Content/__snapshots__/spec.jsx.snap | 27 ++ .../{ => components/Content}/connector.js | 0 .../Options/components/Content/index.jsx | 113 ++++++++ .../Options/components/Content/spec.jsx | 72 +++++ .../Options/components/Option/index.jsx | 33 ++- .../OptionInfo/__snapshots__/spec.jsx.snap | 35 --- .../components/OptionInfo/index.jsx | 44 ++-- .../TextOption/components/OptionInfo/spec.jsx | 42 +-- .../Options/components/TextOption/index.jsx | 22 +- .../Product/components/Options/index.jsx | 139 ++-------- .../pages/Product/components/Options/spec.jsx | 98 ------- themes/theme-ios11/themeApi/index.js | 4 + 43 files changed, 760 insertions(+), 1206 deletions(-) delete mode 100644 themes/theme-gmd/pages/Product/components/Options/__snapshots__/spec.jsx.snap create mode 100644 themes/theme-gmd/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap rename themes/theme-gmd/pages/Product/components/Options/{ => components/Content}/connector.js (100%) create mode 100644 themes/theme-gmd/pages/Product/components/Options/components/Content/index.jsx create mode 100644 themes/theme-gmd/pages/Product/components/Options/components/Content/spec.jsx delete mode 100644 themes/theme-gmd/pages/Product/components/Options/spec.jsx delete mode 100644 themes/theme-ios11/pages/Product/components/Options/__snapshots__/spec.jsx.snap create mode 100644 themes/theme-ios11/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap rename themes/theme-ios11/pages/Product/components/Options/{ => components/Content}/connector.js (100%) create mode 100644 themes/theme-ios11/pages/Product/components/Options/components/Content/index.jsx create mode 100644 themes/theme-ios11/pages/Product/components/Options/components/Content/spec.jsx delete mode 100644 themes/theme-ios11/pages/Product/components/Options/spec.jsx diff --git a/libraries/commerce/product/selectors/options.js b/libraries/commerce/product/selectors/options.js index 62d460a6d9..3e8607a1d2 100644 --- a/libraries/commerce/product/selectors/options.js +++ b/libraries/commerce/product/selectors/options.js @@ -103,14 +103,14 @@ export const getProductOptions = createSelector( ...option.type === OPTION_TYPE_TEXT && { info: option.annotation, required: !!option.required, - price: { - currency, - price: option.unitPriceModifier, - }, + price: option.unitPriceModifier, }, })) // Move select type options on top, keep the rest .sort((a, b) => { + if (a.type === b.type) { + return 0; + } if (a.type === 'select') { return -1; } diff --git a/themes/theme-gmd/pages/Product/components/Content/connector.js b/themes/theme-gmd/pages/Product/components/Content/connector.js index 16bcce6154..44181bd770 100644 --- a/themes/theme-gmd/pages/Product/components/Content/connector.js +++ b/themes/theme-gmd/pages/Product/components/Content/connector.js @@ -2,7 +2,8 @@ import { connect } from 'react-redux'; import { getBaseProductId, getVariantId, -} from '@shopgate/pwa-common-commerce/product/selectors/product'; + getProductCurrency, +} from '@shopgate/pwa-common-commerce/product'; import addProductsToCart from '@shopgate/pwa-common-commerce/cart/actions/addProductsToCart'; /** @@ -14,11 +15,11 @@ import addProductsToCart from '@shopgate/pwa-common-commerce/cart/actions/addPro const mapStateToProps = (state, props) => ({ baseProductId: getBaseProductId(state, props), variantId: getVariantId(state, props), + currency: getProductCurrency(state, props), }); /** * @param {Function} dispatch The redux dispatch function. - * @param {Function} props The component props. * @return {Object} The extended component props. */ const mapDispatchToProps = dispatch => ({ diff --git a/themes/theme-gmd/pages/Product/components/Content/index.jsx b/themes/theme-gmd/pages/Product/components/Content/index.jsx index 729a3897a9..a1bc56902a 100644 --- a/themes/theme-gmd/pages/Product/components/Content/index.jsx +++ b/themes/theme-gmd/pages/Product/components/Content/index.jsx @@ -19,6 +19,7 @@ import { ProductContext } from '../../context'; class ProductContent extends PureComponent { static propTypes = { baseProductId: PropTypes.string, + currency: PropTypes.string, isVariant: PropTypes.bool, productId: PropTypes.string, variantId: PropTypes.string, @@ -26,6 +27,7 @@ class ProductContent extends PureComponent { static defaultProps = { baseProductId: null, + currency: null, isVariant: false, productId: null, variantId: null, @@ -42,7 +44,9 @@ class ProductContent extends PureComponent { }; this.state = { + currency: props.currency, options: {}, + optionsPrices: {}, productId: props.variantId ? props.baseProductId : props.productId, variantId: props.variantId ? props.variantId : null, }; @@ -71,6 +75,7 @@ class ProductContent extends PureComponent { this.setState({ productId, variantId, + currency: nextProps.currency, }); } @@ -78,13 +83,18 @@ class ProductContent extends PureComponent { * Stores the selected options in local state. * @param {string} optionId The ID of the option. * @param {string} value The option value. + * @param {number} [price=0] The option value. */ - storeOptionSelection = (optionId, value) => { + setOption = (optionId, value, price = 0) => { this.setState(prevState => ({ options: { ...prevState.options, [optionId]: value, }, + optionsPrices: { + ...prevState.optionsPrices, + [optionId]: !!value && price, + }, })); }; @@ -92,10 +102,10 @@ class ProductContent extends PureComponent { * @return {JSX} */ render() { - const id = this.state.variantId || this.state.productId; const contextValue = { ...this.state, ...this.baseContextValue, + setOption: this.setOption, }; return ( @@ -106,11 +116,7 @@ class ProductContent extends PureComponent {
- + diff --git a/themes/theme-gmd/pages/Product/components/Header/components/Discount/connector.js b/themes/theme-gmd/pages/Product/components/Header/components/Discount/connector.js index f2377c4843..82e74cff12 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/Discount/connector.js +++ b/themes/theme-gmd/pages/Product/components/Header/components/Discount/connector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import isEqual from 'lodash/isEqual'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = state => ({ - price: getProductPrice(state), + price: getProductPriceData(state), }); /** diff --git a/themes/theme-gmd/pages/Product/components/Header/components/Price/connector.js b/themes/theme-gmd/pages/Product/components/Header/components/Price/connector.js index 5e6df4a651..d947103b71 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/Price/connector.js +++ b/themes/theme-gmd/pages/Product/components/Header/components/Price/connector.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; -import { hasProductOptions } from '@shopgate/pwa-common-commerce/product/selectors/options'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -9,8 +8,7 @@ import { hasProductOptions } from '@shopgate/pwa-common-commerce/product/selecto * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), - showTotalPrice: hasProductOptions(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-gmd/pages/Product/components/Header/components/Price/index.jsx b/themes/theme-gmd/pages/Product/components/Header/components/Price/index.jsx index 02a6276d20..3fa8b3188e 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/Price/index.jsx +++ b/themes/theme-gmd/pages/Product/components/Header/components/Price/index.jsx @@ -1,6 +1,5 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import pure from 'recompose/pure'; import Portal from '@shopgate/pwa-common/components/Portal'; import { PRODUCT_PRICE, @@ -9,30 +8,53 @@ import { } from '@shopgate/pwa-common-commerce/product/constants/Portals'; import PlaceholderLabel from '@shopgate/pwa-ui-shared/PlaceholderLabel'; import PriceBase from '@shopgate/pwa-ui-shared/Price'; +import { ProductContext } from '../../../../context'; import connect from './connector'; import styles from './style'; +/** + * Calculate total price to show with additions + * @param {number} price unit amount + * @param {Object} additions price modifiers + * @returns {number} + */ +const getTotalPrice = (price, additions) => { + if (!additions) { + return price; + } + return price + Object.values(additions) + .reduce((p, val) => { + // eslint-disable-next-line no-param-reassign + p += val; + return p; + }, 0); +}; + /** * The Price component. * @param {Object} props The component props. * @return {JSX} */ -const Price = ({ showTotalPrice, price }) => ( +const Price = ({ price }) => ( - - - {(price && typeof price.unitPrice === 'number') && ( - + + + {({ optionsPrices }) => ( + + {(price && typeof price.unitPrice === 'number') && ( + + )} + )} - + @@ -40,12 +62,10 @@ const Price = ({ showTotalPrice, price }) => ( Price.propTypes = { price: PropTypes.shape(), - showTotalPrice: PropTypes.bool, }; Price.defaultProps = { price: null, - showTotalPrice: false, }; -export default connect(pure(Price)); +export default connect(Price); diff --git a/themes/theme-gmd/pages/Product/components/Header/components/PriceInfo/connector.js b/themes/theme-gmd/pages/Product/components/Header/components/PriceInfo/connector.js index 59b67705d6..d947103b71 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/PriceInfo/connector.js +++ b/themes/theme-gmd/pages/Product/components/Header/components/PriceInfo/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-gmd/pages/Product/components/Header/components/PriceStriked/connector.js b/themes/theme-gmd/pages/Product/components/Header/components/PriceStriked/connector.js index 76f90f4b17..469700e381 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/PriceStriked/connector.js +++ b/themes/theme-gmd/pages/Product/components/Header/components/PriceStriked/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * @param {Object} state The current application state. @@ -7,7 +7,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-gmd/pages/Product/components/Header/components/Tiers/connector.js b/themes/theme-gmd/pages/Product/components/Header/components/Tiers/connector.js index 59b67705d6..d947103b71 100644 --- a/themes/theme-gmd/pages/Product/components/Header/components/Tiers/connector.js +++ b/themes/theme-gmd/pages/Product/components/Header/components/Tiers/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-gmd/pages/Product/components/Options/__snapshots__/spec.jsx.snap b/themes/theme-gmd/pages/Product/components/Options/__snapshots__/spec.jsx.snap deleted file mode 100644 index e34bdf0cc0..0000000000 --- a/themes/theme-gmd/pages/Product/components/Options/__snapshots__/spec.jsx.snap +++ /dev/null @@ -1,248 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` Given the component was mounted to the DOM should match snapshot 1`] = ` - - - - -
- -
-
- -
-
-`; diff --git a/themes/theme-gmd/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap b/themes/theme-gmd/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap new file mode 100644 index 0000000000..a126c22eab --- /dev/null +++ b/themes/theme-gmd/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Given the component was mounted to the DOM should match snapshot 1`] = ` +
+
+`; diff --git a/themes/theme-gmd/pages/Product/components/Options/connector.js b/themes/theme-gmd/pages/Product/components/Options/components/Content/connector.js similarity index 100% rename from themes/theme-gmd/pages/Product/components/Options/connector.js rename to themes/theme-gmd/pages/Product/components/Options/components/Content/connector.js diff --git a/themes/theme-gmd/pages/Product/components/Options/components/Content/index.jsx b/themes/theme-gmd/pages/Product/components/Options/components/Content/index.jsx new file mode 100644 index 0000000000..a8cb328e68 --- /dev/null +++ b/themes/theme-gmd/pages/Product/components/Options/components/Content/index.jsx @@ -0,0 +1,113 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import Option from '../Option'; +import TextOption from '../TextOption'; +import { ProductContext } from '../../../../context'; +import connect from './connector'; + +/** + * The Product Options component. + */ +class Options extends PureComponent { + static contextType = ProductContext; + + static propTypes = { + currentOptions: PropTypes.shape(), + options: PropTypes.arrayOf(PropTypes.shape()), + }; + + static defaultProps = { + currentOptions: {}, + options: null, + }; + + /** + * Triggers storeSelections when the component is mounted and has options set. + */ + componentDidMount() { + if (!this.context || !this.context.setOption || !this.props.options) { + return; + } + + this.handleStoreSelection(this.props); + } + + /** + * When the component receives the product options + * it will set the first value of each option as active + * @param {Object} nextProps The incoming props. + */ + componentWillReceiveProps(nextProps) { + if (!this.props.options && nextProps.options) { + this.handleStoreSelection(nextProps); + } + } + + /** + * @param {Object} props The component props. + */ + handleStoreSelection = (props) => { + props.options.forEach((option) => { + // Only options of type 'select' have a default value. Type 'text' has no default. + if (option.type !== 'select') { + return; + } + + this.context.setOption(option.id, option.items[0].value, option.items[0].price); + }); + } + + /** + * Renders the component + * @returns {JSX} + */ + render() { + const { options, currentOptions } = this.props; + + if (!options) { + return null; + } + + return ( + + {({ setOption }) => ( +
+ {options.map((option) => { + switch (option.type) { + case 'text': + return ( + + ); + case 'select': + return ( +
+ )} +
+ ); + } +} + +export default connect(Options); diff --git a/themes/theme-gmd/pages/Product/components/Options/components/Content/spec.jsx b/themes/theme-gmd/pages/Product/components/Options/components/Content/spec.jsx new file mode 100644 index 0000000000..561fffb8b2 --- /dev/null +++ b/themes/theme-gmd/pages/Product/components/Options/components/Content/spec.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import Picker from 'Components/Picker'; +import Options from './index'; + +jest.mock('../../../../context', () => ({ + ProductContext: { + Consumer: jest.fn(({ children }) => children({ + setOption: jest.fn(), + currency: 'EUR', + })), + }, +})); + +// Mock the redux connect() method instead of providing a fake store. +jest.mock('./connector', () => (obj) => { + const newObj = obj; + + const mockOptions = [{ + id: 'test-id', + type: 'select', + label: 'label', + items: [ + { + currency: 'USD', + price: 10, + }, + { + currency: 'USD', + price: 10, + }, + ], + }]; + + newObj.defaultProps = { + options: mockOptions, + currentOptions: {}, + }; + + return newObj; +}); + +describe('', () => { + const mockOptions = [{ + id: 'test-id', + type: 'select', + label: 'label', + items: [ + { + currency: 'USD', + price: 10, + }, + { + currency: 'USD', + price: 10, + }, + ], + }]; + + describe('Given the component was mounted to the DOM', () => { + it('should match snapshot', () => { + const wrapper = shallow().dive(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render correct number of options', () => { + const wrapper = mount(); + const picker = wrapper.find(Picker); + expect(picker.length).toBe(mockOptions.length); + }); + }); +}); diff --git a/themes/theme-gmd/pages/Product/components/Options/components/Option/index.jsx b/themes/theme-gmd/pages/Product/components/Options/components/Option/index.jsx index 3f48b61cc3..553c423bda 100644 --- a/themes/theme-gmd/pages/Product/components/Options/components/Option/index.jsx +++ b/themes/theme-gmd/pages/Product/components/Options/components/Option/index.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import I18n from '@shopgate/pwa-common/components/I18n'; import Picker from 'Components/Picker'; +import { ProductContext } from '../../../../context'; import PriceDifference from '../PriceDifference'; import styles from './style'; @@ -16,20 +17,24 @@ const Option = ({ value, onChange, }) => ( -
- ({ - ...item, - rightComponent: ( - - ), - }))} - placeholder={} - value={value} - onChange={val => onChange(id, val)} - /> -
+ + {({ currency }) => ( +
+ ({ + ...item, + rightComponent: ( + + ), + }))} + placeholder={} + value={value} + onChange={val => onChange(id, val, items.find(item => item.value === val).price)} + /> +
+ )} +
); Option.propTypes = { diff --git a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap index 648df1948e..5b09bbabd4 100644 --- a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap +++ b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap @@ -1,40 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should render full info with required and price element 1`] = ` - - - - - - Label: - - - -`; - exports[` should render price element 1`] = ` { - if (!required && !price.price) { + if (!required && !price) { return null; } return ( - - {required && - - - - } - {!!price.price && - - {`${label}: `} - - - } - + + {({ currency }) => ( + + {required && + + + + } + {!!price && + + {`${label}: `} + + + } + + )} + ); }; OptionInfo.propTypes = { label: PropTypes.string.isRequired, - price: PropTypes.shape({ - price: PropTypes.number.isRequired, - currency: PropTypes.string.isRequired, - }).isRequired, + price: PropTypes.number.isRequired, required: PropTypes.bool.isRequired, }; diff --git a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx index 7d164dca0a..647b843255 100644 --- a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx +++ b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx @@ -2,45 +2,31 @@ import React from 'react'; import { shallow } from 'enzyme'; import { OptionInfo } from './index'; -describe('', () => { - const emptyPrice = { - price: 0, - currency: 'EUR', - }; - const notEmptyPrice = { - price: 10, - currency: 'EUR', - }; +jest.mock('../../../../../../context', () => ({ + ProductContext: { + Consumer: jest.fn(({ children }) => children({ + currency: 'EUR', + })), + }, +})); +describe('', () => { it('should not render when not required and no price', () => { const wrapper = shallow(( - + )); - expect(wrapper.type()).toBeNull(); + expect(wrapper).toBeEmptyRender(); }); it('should render required element', () => { const wrapper = shallow(( - - )); + + )).dive(); expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('Translate').prop('string')).toEqual('common.required'); - expect(wrapper.find('FormatPrice')).toHaveLength(0); }); it('should render price element', () => { const wrapper = shallow(( - - )); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('Translate')).toHaveLength(0); - - const price = wrapper.find('FormatPrice'); - expect(price.prop('price')).toEqual(notEmptyPrice.price); - expect(price.prop('currency')).toEqual('EUR'); - }); - it('should render full info with required and price element', () => { - const wrapper = shallow(( - - )); + + )).dive(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/index.jsx b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/index.jsx index fff27a20e6..27dc84f699 100644 --- a/themes/theme-gmd/pages/Product/components/Options/components/TextOption/index.jsx +++ b/themes/theme-gmd/pages/Product/components/Options/components/TextOption/index.jsx @@ -1,12 +1,13 @@ import React, { Fragment, PureComponent } from 'react'; import PropTypes from 'prop-types'; import Transition from 'react-transition-group/Transition'; +import debounce from 'lodash/debounce'; import TextField from '@shopgate/pwa-ui-shared/Form/TextField'; import InfoIcon from '@shopgate/pwa-ui-shared/icons/InfoIcon'; import withShowModal from '@shopgate/pwa-common/helpers/modal/withShowModal'; import transition from './../../../Characteristics/Characteristic/transition'; import { ProductContext } from '../../../../context'; -import OptionInfo from './components/OptionInfo'; +import OptionInformation from './components/OptionInfo'; import styles from './style'; /** @@ -18,10 +19,7 @@ class TextOption extends PureComponent { id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, - price: PropTypes.shape({ - currency: PropTypes.string.isRequired, - price: PropTypes.number.isRequired, - }).isRequired, + price: PropTypes.number.isRequired, required: PropTypes.bool.isRequired, showModal: PropTypes.func.isRequired, info: PropTypes.string, @@ -110,6 +108,15 @@ class TextOption extends PureComponent { } } + /** + * @param {string} val value. + */ + handleChange = (val) => { + this.props.onChange(this.props.id, val, this.props.price); + } + + handleDebounced = debounce(this.handleChange, 300) + /** * @return {JSX} */ @@ -118,7 +125,6 @@ class TextOption extends PureComponent { id, label, value, - onChange, required, price, } = this.props; @@ -133,7 +139,7 @@ class TextOption extends PureComponent { setRef={this.setRef} name={`text_${id}`} value={value} - onChange={val => onChange(id, val)} + onChange={this.handleDebounced} onKeyPress={this.handleKeyPress} placeholder={label} label={label} @@ -143,7 +149,7 @@ class TextOption extends PureComponent { className={styles.element} /> - + )} diff --git a/themes/theme-gmd/pages/Product/components/Options/index.jsx b/themes/theme-gmd/pages/Product/components/Options/index.jsx index cb48dc1771..c63ad77c8b 100644 --- a/themes/theme-gmd/pages/Product/components/Options/index.jsx +++ b/themes/theme-gmd/pages/Product/components/Options/index.jsx @@ -1,117 +1,35 @@ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import Portal from '@shopgate/pwa-common/components/Portal'; -import { - PRODUCT_OPTIONS, - PRODUCT_OPTIONS_AFTER, - PRODUCT_OPTIONS_BEFORE, -} from '@shopgate/pwa-common-commerce/product/constants/Portals'; +import React from 'react'; +import { PRODUCT_OPTIONS } from '@shopgate/pwa-common-commerce/product/constants/Portals'; +import SurroundPortals from '@shopgate/pwa-common/components/SurroundPortals'; +import { ProductContext } from '../../context'; +import Content from './components/Content'; import Option from './components/Option'; +import PriceDifference from './components/PriceDifference'; import TextOption from './components/TextOption'; -import connect from './connector'; + +// Export for theme api +export { TextOption, Option as SelectOption, PriceDifference }; /** * The Product Options component. + * @returns {JSX} */ -class Options extends PureComponent { - static propTypes = { - storeSelection: PropTypes.func.isRequired, - currentOptions: PropTypes.shape(), - options: PropTypes.arrayOf(PropTypes.shape()), - }; - - static defaultProps = { - currentOptions: {}, - options: null, - }; - - /** - * Triggers storeSelections when the component is mounted and has options set. - */ - componentDidMount() { - if (!this.props.storeSelection || !this.props.options) { - return; - } - - this.handleStoreSelection(this.props); - } - - /** - * When the component receives the product options - * it will set the first value of each option as active - * @param {Object} nextProps The incoming props. - */ - componentWillReceiveProps(nextProps) { - if (!this.props.options && nextProps.options) { - this.handleStoreSelection(nextProps); - } - } - - /** - * @param {Object} props The component props. - */ - handleStoreSelection = (props) => { - props.options.forEach((option) => { - // Only options of type 'select' have a default value. Type 'text' has no default. - if (option.type !== 'select') { - return; - } - - this.props.storeSelection(option.id, option.items[0].value); - }); - } - - /** - * Renders the component - * @returns {JSX} - */ - render() { - const { options, currentOptions, storeSelection } = this.props; - - return ( - - - - {(options !== null) && ( -
- {options.map((option) => { - switch (option.type) { - case 'text': - return ( - - ); - case 'select': - return ( -
- )} -
- -
- ); - } -} - -export default connect(Options); +const Options = () => ( + + {({ productId, variantId, options }) => ( + + + + )} + +); + +export default Options; diff --git a/themes/theme-gmd/pages/Product/components/Options/spec.jsx b/themes/theme-gmd/pages/Product/components/Options/spec.jsx deleted file mode 100644 index ad35f5937e..0000000000 --- a/themes/theme-gmd/pages/Product/components/Options/spec.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import I18n from '@shopgate/pwa-common/components/I18n'; -import Picker from 'Components/Picker'; -import mockRenderOptions from '@shopgate/pwa-common/helpers/mocks/mockRenderOptions'; -import Options from './index'; - -// Mock the redux connect() method instead of providing a fake store. -jest.mock('./connector', () => (obj) => { - const newObj = obj; - - const mockOptions = [{ - id: 'test-id', - type: 'select', - label: 'label', - items: [ - { - currency: 'USD', - price: 10, - }, - { - currency: 'USD', - price: 10, - }, - ], - }]; - - newObj.defaultProps = { - options: mockOptions, - currentOptions: {}, - }; - - return newObj; -}); - -describe('', () => { - const mockOptions = [{ - id: 'test-id', - type: 'select', - label: 'label', - items: [ - { - currency: 'USD', - price: 10, - }, - { - currency: 'USD', - price: 10, - }, - ], - }]; - - let mockSetProductOption; - let renderedElement; - - /** - * Renders the component. - * @param {Object} props The component props. - */ - const renderComponent = (props) => { - mockSetProductOption = jest.fn(); - - renderedElement = mount( - ( - - - ), - mockRenderOptions - ); - }; - - describe('Given the component was mounted to the DOM', () => { - beforeEach(() => { - renderComponent({ currentOptions: {} }); - }); - - it('should match snapshot', () => { - expect(renderedElement).toMatchSnapshot(); - }); - - describe('Given the component receives option props', () => { - beforeEach(() => { - renderedElement.setProps({ - options: mockOptions, - }); - }); - - it('should set first option values initially', () => { - expect(mockSetProductOption).toBeCalled(); - }); - - it('should render correct number of options', () => { - const picker = renderedElement.find(Picker); - expect(picker.length).toBe(mockOptions.length); - }); - }); - }); -}); diff --git a/themes/theme-gmd/themeApi/index.js b/themes/theme-gmd/themeApi/index.js index 55d18b148d..3e2a7c26fc 100644 --- a/themes/theme-gmd/themeApi/index.js +++ b/themes/theme-gmd/themeApi/index.js @@ -3,6 +3,7 @@ import Drawer from 'Components/Drawer'; import ProductSlider from 'Components/ProductSlider'; import View from 'Components/View'; import { ProductContext } from '../pages/Product/context'; +import { TextOption, SelectOption, PriceDifference } from '../pages/Product/components/Options'; import ProductCard from './ProductCard'; export default { @@ -11,6 +12,9 @@ export default { ProductSlider, ProductCard, View, + TextOption, + SelectOption, + PriceDifference, contexts: { ProductContext, }, diff --git a/themes/theme-ios11/pages/Product/components/Content/connector.js b/themes/theme-ios11/pages/Product/components/Content/connector.js index 16bcce6154..44181bd770 100644 --- a/themes/theme-ios11/pages/Product/components/Content/connector.js +++ b/themes/theme-ios11/pages/Product/components/Content/connector.js @@ -2,7 +2,8 @@ import { connect } from 'react-redux'; import { getBaseProductId, getVariantId, -} from '@shopgate/pwa-common-commerce/product/selectors/product'; + getProductCurrency, +} from '@shopgate/pwa-common-commerce/product'; import addProductsToCart from '@shopgate/pwa-common-commerce/cart/actions/addProductsToCart'; /** @@ -14,11 +15,11 @@ import addProductsToCart from '@shopgate/pwa-common-commerce/cart/actions/addPro const mapStateToProps = (state, props) => ({ baseProductId: getBaseProductId(state, props), variantId: getVariantId(state, props), + currency: getProductCurrency(state, props), }); /** * @param {Function} dispatch The redux dispatch function. - * @param {Function} props The component props. * @return {Object} The extended component props. */ const mapDispatchToProps = dispatch => ({ diff --git a/themes/theme-ios11/pages/Product/components/Content/index.jsx b/themes/theme-ios11/pages/Product/components/Content/index.jsx index 4024f75333..e597c7a406 100644 --- a/themes/theme-ios11/pages/Product/components/Content/index.jsx +++ b/themes/theme-ios11/pages/Product/components/Content/index.jsx @@ -20,6 +20,7 @@ import { ProductContext } from '../../context'; class ProductContent extends PureComponent { static propTypes = { baseProductId: PropTypes.string, + currency: PropTypes.string, isVariant: PropTypes.bool, productId: PropTypes.string, variantId: PropTypes.string, @@ -27,6 +28,7 @@ class ProductContent extends PureComponent { static defaultProps = { baseProductId: null, + currency: null, isVariant: false, productId: null, variantId: null, @@ -43,7 +45,9 @@ class ProductContent extends PureComponent { }; this.state = { + currency: props.currency, options: {}, + optionsPrices: {}, productId: props.variantId ? props.baseProductId : props.productId, variantId: props.variantId ? props.variantId : null, }; @@ -72,6 +76,7 @@ class ProductContent extends PureComponent { this.setState({ productId, variantId, + currency: nextProps.currency, }); } @@ -79,15 +84,20 @@ class ProductContent extends PureComponent { * Stores the selected options in local state. * @param {string} optionId The ID of the option. * @param {string} value The option value. + * @param {number} [price=0] The option value. */ - storeOptionSelection = (optionId, value) => { + setOption = (optionId, value, price = 0) => { this.setState(prevState => ({ options: { ...prevState.options, [optionId]: value, }, + optionsPrices: { + ...prevState.optionsPrices, + [optionId]: !!value && price, + }, })); - } + }; /** * @return {JSX} @@ -97,6 +107,7 @@ class ProductContent extends PureComponent { const contextValue = { ...this.state, ...this.baseContextValue, + setOption: this.setOption, }; return ( @@ -106,11 +117,7 @@ class ProductContent extends PureComponent {
- + diff --git a/themes/theme-ios11/pages/Product/components/Header/components/Discount/connector.js b/themes/theme-ios11/pages/Product/components/Header/components/Discount/connector.js index f2377c4843..82e74cff12 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/Discount/connector.js +++ b/themes/theme-ios11/pages/Product/components/Header/components/Discount/connector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import isEqual from 'lodash/isEqual'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = state => ({ - price: getProductPrice(state), + price: getProductPriceData(state), }); /** diff --git a/themes/theme-ios11/pages/Product/components/Header/components/Price/connector.js b/themes/theme-ios11/pages/Product/components/Header/components/Price/connector.js index 5e6df4a651..d947103b71 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/Price/connector.js +++ b/themes/theme-ios11/pages/Product/components/Header/components/Price/connector.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; -import { hasProductOptions } from '@shopgate/pwa-common-commerce/product/selectors/options'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -9,8 +8,7 @@ import { hasProductOptions } from '@shopgate/pwa-common-commerce/product/selecto * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), - showTotalPrice: hasProductOptions(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-ios11/pages/Product/components/Header/components/Price/index.jsx b/themes/theme-ios11/pages/Product/components/Header/components/Price/index.jsx index 02a6276d20..eeed6b6ae2 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/Price/index.jsx +++ b/themes/theme-ios11/pages/Product/components/Header/components/Price/index.jsx @@ -1,6 +1,5 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import pure from 'recompose/pure'; import Portal from '@shopgate/pwa-common/components/Portal'; import { PRODUCT_PRICE, @@ -9,30 +8,51 @@ import { } from '@shopgate/pwa-common-commerce/product/constants/Portals'; import PlaceholderLabel from '@shopgate/pwa-ui-shared/PlaceholderLabel'; import PriceBase from '@shopgate/pwa-ui-shared/Price'; +import { ProductContext } from '../../../../context'; import connect from './connector'; import styles from './style'; - +/** + * Calculate total price to show with additions + * @param {number} price unit amount + * @param {Object} additions price modifiers + * @returns {number} + */ +const getTotalPrice = (price, additions) => { + if (!additions) { + return price; + } + return price + Object.values(additions) + .reduce((p, val) => { + // eslint-disable-next-line no-param-reassign + p += val; + return p; + }, 0); +}; /** * The Price component. * @param {Object} props The component props. * @return {JSX} */ -const Price = ({ showTotalPrice, price }) => ( +const Price = ({ price }) => ( - - - {(price && typeof price.unitPrice === 'number') && ( - + + + {({ optionsPrices }) => ( + + {(price && typeof price.unitPrice === 'number') && ( + + )} + )} - + @@ -40,12 +60,10 @@ const Price = ({ showTotalPrice, price }) => ( Price.propTypes = { price: PropTypes.shape(), - showTotalPrice: PropTypes.bool, }; Price.defaultProps = { price: null, - showTotalPrice: false, }; -export default connect(pure(Price)); +export default connect(Price); diff --git a/themes/theme-ios11/pages/Product/components/Header/components/PriceInfo/connector.js b/themes/theme-ios11/pages/Product/components/Header/components/PriceInfo/connector.js index 59b67705d6..d947103b71 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/PriceInfo/connector.js +++ b/themes/theme-ios11/pages/Product/components/Header/components/PriceInfo/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-ios11/pages/Product/components/Header/components/PriceStriked/connector.js b/themes/theme-ios11/pages/Product/components/Header/components/PriceStriked/connector.js index 76f90f4b17..469700e381 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/PriceStriked/connector.js +++ b/themes/theme-ios11/pages/Product/components/Header/components/PriceStriked/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * @param {Object} state The current application state. @@ -7,7 +7,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-ios11/pages/Product/components/Header/components/Tiers/connector.js b/themes/theme-ios11/pages/Product/components/Header/components/Tiers/connector.js index 59b67705d6..d947103b71 100644 --- a/themes/theme-ios11/pages/Product/components/Header/components/Tiers/connector.js +++ b/themes/theme-ios11/pages/Product/components/Header/components/Tiers/connector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors/price'; +import { getProductPriceData } from '@shopgate/pwa-common-commerce/product'; /** * Maps the contents of the state to the component props. @@ -8,7 +8,7 @@ import { getProductPrice } from '@shopgate/pwa-common-commerce/product/selectors * @return {Object} The extended component props. */ const mapStateToProps = (state, props) => ({ - price: getProductPrice(state, props), + price: getProductPriceData(state, props), }); /** diff --git a/themes/theme-ios11/pages/Product/components/Options/__snapshots__/spec.jsx.snap b/themes/theme-ios11/pages/Product/components/Options/__snapshots__/spec.jsx.snap deleted file mode 100644 index 703d027a6d..0000000000 --- a/themes/theme-ios11/pages/Product/components/Options/__snapshots__/spec.jsx.snap +++ /dev/null @@ -1,248 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` Given the component was mounted to the DOM should match snapshot 1`] = ` - - - - -
- -
-
- -
-
-`; diff --git a/themes/theme-ios11/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap b/themes/theme-ios11/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap new file mode 100644 index 0000000000..a126c22eab --- /dev/null +++ b/themes/theme-ios11/pages/Product/components/Options/components/Content/__snapshots__/spec.jsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Given the component was mounted to the DOM should match snapshot 1`] = ` +
+
+`; diff --git a/themes/theme-ios11/pages/Product/components/Options/connector.js b/themes/theme-ios11/pages/Product/components/Options/components/Content/connector.js similarity index 100% rename from themes/theme-ios11/pages/Product/components/Options/connector.js rename to themes/theme-ios11/pages/Product/components/Options/components/Content/connector.js diff --git a/themes/theme-ios11/pages/Product/components/Options/components/Content/index.jsx b/themes/theme-ios11/pages/Product/components/Options/components/Content/index.jsx new file mode 100644 index 0000000000..a8cb328e68 --- /dev/null +++ b/themes/theme-ios11/pages/Product/components/Options/components/Content/index.jsx @@ -0,0 +1,113 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import Option from '../Option'; +import TextOption from '../TextOption'; +import { ProductContext } from '../../../../context'; +import connect from './connector'; + +/** + * The Product Options component. + */ +class Options extends PureComponent { + static contextType = ProductContext; + + static propTypes = { + currentOptions: PropTypes.shape(), + options: PropTypes.arrayOf(PropTypes.shape()), + }; + + static defaultProps = { + currentOptions: {}, + options: null, + }; + + /** + * Triggers storeSelections when the component is mounted and has options set. + */ + componentDidMount() { + if (!this.context || !this.context.setOption || !this.props.options) { + return; + } + + this.handleStoreSelection(this.props); + } + + /** + * When the component receives the product options + * it will set the first value of each option as active + * @param {Object} nextProps The incoming props. + */ + componentWillReceiveProps(nextProps) { + if (!this.props.options && nextProps.options) { + this.handleStoreSelection(nextProps); + } + } + + /** + * @param {Object} props The component props. + */ + handleStoreSelection = (props) => { + props.options.forEach((option) => { + // Only options of type 'select' have a default value. Type 'text' has no default. + if (option.type !== 'select') { + return; + } + + this.context.setOption(option.id, option.items[0].value, option.items[0].price); + }); + } + + /** + * Renders the component + * @returns {JSX} + */ + render() { + const { options, currentOptions } = this.props; + + if (!options) { + return null; + } + + return ( + + {({ setOption }) => ( +
+ {options.map((option) => { + switch (option.type) { + case 'text': + return ( + + ); + case 'select': + return ( +
+ )} +
+ ); + } +} + +export default connect(Options); diff --git a/themes/theme-ios11/pages/Product/components/Options/components/Content/spec.jsx b/themes/theme-ios11/pages/Product/components/Options/components/Content/spec.jsx new file mode 100644 index 0000000000..561fffb8b2 --- /dev/null +++ b/themes/theme-ios11/pages/Product/components/Options/components/Content/spec.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import Picker from 'Components/Picker'; +import Options from './index'; + +jest.mock('../../../../context', () => ({ + ProductContext: { + Consumer: jest.fn(({ children }) => children({ + setOption: jest.fn(), + currency: 'EUR', + })), + }, +})); + +// Mock the redux connect() method instead of providing a fake store. +jest.mock('./connector', () => (obj) => { + const newObj = obj; + + const mockOptions = [{ + id: 'test-id', + type: 'select', + label: 'label', + items: [ + { + currency: 'USD', + price: 10, + }, + { + currency: 'USD', + price: 10, + }, + ], + }]; + + newObj.defaultProps = { + options: mockOptions, + currentOptions: {}, + }; + + return newObj; +}); + +describe('', () => { + const mockOptions = [{ + id: 'test-id', + type: 'select', + label: 'label', + items: [ + { + currency: 'USD', + price: 10, + }, + { + currency: 'USD', + price: 10, + }, + ], + }]; + + describe('Given the component was mounted to the DOM', () => { + it('should match snapshot', () => { + const wrapper = shallow().dive(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render correct number of options', () => { + const wrapper = mount(); + const picker = wrapper.find(Picker); + expect(picker.length).toBe(mockOptions.length); + }); + }); +}); diff --git a/themes/theme-ios11/pages/Product/components/Options/components/Option/index.jsx b/themes/theme-ios11/pages/Product/components/Options/components/Option/index.jsx index 64fc457462..b6808049b6 100644 --- a/themes/theme-ios11/pages/Product/components/Options/components/Option/index.jsx +++ b/themes/theme-ios11/pages/Product/components/Options/components/Option/index.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import I18n from '@shopgate/pwa-common/components/I18n'; import Picker from 'Components/Picker'; +import { ProductContext } from '../../../../context'; import PriceDifference from '../PriceDifference'; /** @@ -15,20 +16,24 @@ const Option = ({ value, onChange, }) => ( -
- ({ - ...item, - rightComponent: ( - - ), - }))} - placeholder={} - value={value} - onChange={val => onChange(id, val)} - /> -
+ + {({ currency }) => ( +
+ ({ + ...item, + rightComponent: ( + + ), + }))} + placeholder={} + value={value} + onChange={val => onChange(id, val, items.find(item => item.value === val).price)} + /> +
+ )} +
); Option.propTypes = { diff --git a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap index b5ebac6e67..82168800ac 100644 --- a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap +++ b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/__snapshots__/spec.jsx.snap @@ -1,40 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should render full info with required and price element 1`] = ` - - - - - - Label: - - - -`; - exports[` should render price element 1`] = ` { - if (!required && !price.price) { + if (!required && !price) { return null; } return ( - - {required && - - - - } - {!!price.price && - - {`${label}: `} - - - } - + + {({ currency }) => ( + + {required && + + + + } + {!!price && + + {`${label}: `} + + + } + + )} + ); }; OptionInfo.propTypes = { label: PropTypes.string.isRequired, - price: PropTypes.shape({ - price: PropTypes.number.isRequired, - currency: PropTypes.string.isRequired, - }).isRequired, + price: PropTypes.number.isRequired, required: PropTypes.bool.isRequired, }; diff --git a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx index 7d164dca0a..647b843255 100644 --- a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx +++ b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/components/OptionInfo/spec.jsx @@ -2,45 +2,31 @@ import React from 'react'; import { shallow } from 'enzyme'; import { OptionInfo } from './index'; -describe('', () => { - const emptyPrice = { - price: 0, - currency: 'EUR', - }; - const notEmptyPrice = { - price: 10, - currency: 'EUR', - }; +jest.mock('../../../../../../context', () => ({ + ProductContext: { + Consumer: jest.fn(({ children }) => children({ + currency: 'EUR', + })), + }, +})); +describe('', () => { it('should not render when not required and no price', () => { const wrapper = shallow(( - + )); - expect(wrapper.type()).toBeNull(); + expect(wrapper).toBeEmptyRender(); }); it('should render required element', () => { const wrapper = shallow(( - - )); + + )).dive(); expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('Translate').prop('string')).toEqual('common.required'); - expect(wrapper.find('FormatPrice')).toHaveLength(0); }); it('should render price element', () => { const wrapper = shallow(( - - )); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('Translate')).toHaveLength(0); - - const price = wrapper.find('FormatPrice'); - expect(price.prop('price')).toEqual(notEmptyPrice.price); - expect(price.prop('currency')).toEqual('EUR'); - }); - it('should render full info with required and price element', () => { - const wrapper = shallow(( - - )); + + )).dive(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/index.jsx b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/index.jsx index fff27a20e6..27dc84f699 100644 --- a/themes/theme-ios11/pages/Product/components/Options/components/TextOption/index.jsx +++ b/themes/theme-ios11/pages/Product/components/Options/components/TextOption/index.jsx @@ -1,12 +1,13 @@ import React, { Fragment, PureComponent } from 'react'; import PropTypes from 'prop-types'; import Transition from 'react-transition-group/Transition'; +import debounce from 'lodash/debounce'; import TextField from '@shopgate/pwa-ui-shared/Form/TextField'; import InfoIcon from '@shopgate/pwa-ui-shared/icons/InfoIcon'; import withShowModal from '@shopgate/pwa-common/helpers/modal/withShowModal'; import transition from './../../../Characteristics/Characteristic/transition'; import { ProductContext } from '../../../../context'; -import OptionInfo from './components/OptionInfo'; +import OptionInformation from './components/OptionInfo'; import styles from './style'; /** @@ -18,10 +19,7 @@ class TextOption extends PureComponent { id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, - price: PropTypes.shape({ - currency: PropTypes.string.isRequired, - price: PropTypes.number.isRequired, - }).isRequired, + price: PropTypes.number.isRequired, required: PropTypes.bool.isRequired, showModal: PropTypes.func.isRequired, info: PropTypes.string, @@ -110,6 +108,15 @@ class TextOption extends PureComponent { } } + /** + * @param {string} val value. + */ + handleChange = (val) => { + this.props.onChange(this.props.id, val, this.props.price); + } + + handleDebounced = debounce(this.handleChange, 300) + /** * @return {JSX} */ @@ -118,7 +125,6 @@ class TextOption extends PureComponent { id, label, value, - onChange, required, price, } = this.props; @@ -133,7 +139,7 @@ class TextOption extends PureComponent { setRef={this.setRef} name={`text_${id}`} value={value} - onChange={val => onChange(id, val)} + onChange={this.handleDebounced} onKeyPress={this.handleKeyPress} placeholder={label} label={label} @@ -143,7 +149,7 @@ class TextOption extends PureComponent { className={styles.element} /> - + )} diff --git a/themes/theme-ios11/pages/Product/components/Options/index.jsx b/themes/theme-ios11/pages/Product/components/Options/index.jsx index 005e0ce927..c63ad77c8b 100644 --- a/themes/theme-ios11/pages/Product/components/Options/index.jsx +++ b/themes/theme-ios11/pages/Product/components/Options/index.jsx @@ -1,116 +1,35 @@ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import Portal from '@shopgate/pwa-common/components/Portal'; -import { - PRODUCT_OPTIONS, - PRODUCT_OPTIONS_AFTER, - PRODUCT_OPTIONS_BEFORE, -} from '@shopgate/pwa-common-commerce/product/constants/Portals'; +import React from 'react'; +import { PRODUCT_OPTIONS } from '@shopgate/pwa-common-commerce/product/constants/Portals'; +import SurroundPortals from '@shopgate/pwa-common/components/SurroundPortals'; +import { ProductContext } from '../../context'; +import Content from './components/Content'; import Option from './components/Option'; +import PriceDifference from './components/PriceDifference'; import TextOption from './components/TextOption'; -import connect from './connector'; + +// Export for theme api +export { TextOption, Option as SelectOption, PriceDifference }; /** * The Product Options component. + * @returns {JSX} */ -class Options extends PureComponent { - static propTypes = { - storeSelection: PropTypes.func.isRequired, - currentOptions: PropTypes.shape(), - options: PropTypes.arrayOf(PropTypes.shape()), - }; - - static defaultProps = { - currentOptions: {}, - options: null, - }; - - /** - * Triggers storeSelections when the component is mounted and has options set. - */ - componentDidMount() { - if (!this.props.storeSelection || !this.props.options) { - return; - } - - this.handleStoreSelection(this.props); - } - - /** - * When the component receives the product options - * it will set the first value of each option as active - * @param {Object} nextProps The incoming props. - */ - componentWillReceiveProps(nextProps) { - if (!this.props.options && nextProps.options) { - this.handleStoreSelection(nextProps); - } - } - - /** - * @param {Object} props The component props. - */ - handleStoreSelection = (props) => { - props.options.forEach((option) => { - // Only options of type 'select' have a default value. Type 'text' has no default. - if (option.type !== 'select') { - return; - } - - this.props.storeSelection(option.id, option.items[0].value); - }); - } - - /** - * Renders the component - * @returns {JSX} - */ - render() { - const { options, currentOptions, storeSelection } = this.props; - - return ( - - - - {(options !== null) && ( -
- {options.map((option) => { - switch (option.type) { - case 'text': - return ( - - ); - case 'select': - return ( -
- )} -
- -
- ); - } -} - -export default connect(Options); +const Options = () => ( + + {({ productId, variantId, options }) => ( + + + + )} + +); + +export default Options; diff --git a/themes/theme-ios11/pages/Product/components/Options/spec.jsx b/themes/theme-ios11/pages/Product/components/Options/spec.jsx deleted file mode 100644 index ad35f5937e..0000000000 --- a/themes/theme-ios11/pages/Product/components/Options/spec.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import I18n from '@shopgate/pwa-common/components/I18n'; -import Picker from 'Components/Picker'; -import mockRenderOptions from '@shopgate/pwa-common/helpers/mocks/mockRenderOptions'; -import Options from './index'; - -// Mock the redux connect() method instead of providing a fake store. -jest.mock('./connector', () => (obj) => { - const newObj = obj; - - const mockOptions = [{ - id: 'test-id', - type: 'select', - label: 'label', - items: [ - { - currency: 'USD', - price: 10, - }, - { - currency: 'USD', - price: 10, - }, - ], - }]; - - newObj.defaultProps = { - options: mockOptions, - currentOptions: {}, - }; - - return newObj; -}); - -describe('', () => { - const mockOptions = [{ - id: 'test-id', - type: 'select', - label: 'label', - items: [ - { - currency: 'USD', - price: 10, - }, - { - currency: 'USD', - price: 10, - }, - ], - }]; - - let mockSetProductOption; - let renderedElement; - - /** - * Renders the component. - * @param {Object} props The component props. - */ - const renderComponent = (props) => { - mockSetProductOption = jest.fn(); - - renderedElement = mount( - ( - - - ), - mockRenderOptions - ); - }; - - describe('Given the component was mounted to the DOM', () => { - beforeEach(() => { - renderComponent({ currentOptions: {} }); - }); - - it('should match snapshot', () => { - expect(renderedElement).toMatchSnapshot(); - }); - - describe('Given the component receives option props', () => { - beforeEach(() => { - renderedElement.setProps({ - options: mockOptions, - }); - }); - - it('should set first option values initially', () => { - expect(mockSetProductOption).toBeCalled(); - }); - - it('should render correct number of options', () => { - const picker = renderedElement.find(Picker); - expect(picker.length).toBe(mockOptions.length); - }); - }); - }); -}); diff --git a/themes/theme-ios11/themeApi/index.js b/themes/theme-ios11/themeApi/index.js index 939d50d43c..868edde1a7 100644 --- a/themes/theme-ios11/themeApi/index.js +++ b/themes/theme-ios11/themeApi/index.js @@ -4,6 +4,7 @@ import ProductSlider from 'Components/ProductSlider'; import View from 'Components/View'; import TabBar from 'Components/TabBar'; import { ProductContext } from '../pages/Product/context'; +import { TextOption, SelectOption, PriceDifference } from '../pages/Product/components/Options'; import ProductCard from './ProductCard'; export default { @@ -13,6 +14,9 @@ export default { ProductSlider, View, TabBar, + TextOption, + SelectOption, + PriceDifference, contexts: { ProductContext, },