diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/package.json.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/package.json.hbs index b06c69b71f..016ac33bb4 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/package.json.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/package.json.hbs @@ -21,7 +21,7 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "43 kB" + "maxSize": "44 kB" }, { "path": "build/vendor.js", diff --git a/packages/template-retail-react-app/app/components/product-tile/index.jsx b/packages/template-retail-react-app/app/components/product-tile/index.jsx index 20ad536632..9f3d8dd921 100644 --- a/packages/template-retail-react-app/app/components/product-tile/index.jsx +++ b/packages/template-retail-react-app/app/components/product-tile/index.jsx @@ -5,9 +5,8 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useRef} from 'react' +import React, {useMemo, useRef, useState} from 'react' import PropTypes from 'prop-types' -import {HeartIcon, HeartSolidIcon} from '@salesforce/retail-react-app/app/components/icons' import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price' // Components @@ -22,15 +21,28 @@ import { } from '@salesforce/retail-react-app/app/components/shared/ui' import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image' +// Project Components +import {HeartIcon, HeartSolidIcon} from '@salesforce/retail-react-app/app/components/icons' +import Link from '@salesforce/retail-react-app/app/components/link' +import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch' +import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group' +import withRegistration from '@salesforce/retail-react-app/app/components/with-registration' + // Hooks import {useIntl} from 'react-intl' // Other -import {productUrlBuilder} from '@salesforce/retail-react-app/app/utils/url' -import Link from '@salesforce/retail-react-app/app/components/link' -import withRegistration from '@salesforce/retail-react-app/app/components/with-registration' +import { + PRODUCT_TILE_IMAGE_VIEW_TYPE, + PRODUCT_TILE_SELECTABLE_ATTRIBUTE_ID +} from '@salesforce/retail-react-app/app/constants' +import {productUrlBuilder, rebuildPathWithParams} from '@salesforce/retail-react-app/app/utils/url' import {getPriceData} from '@salesforce/retail-react-app/app/utils/product-utils' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' +import { + filterImageGroups, + getDecoratedVariationAttributes +} from '@salesforce/retail-react-app/app/utils/product-utils' const IconButtonWithRegistration = withRegistration(IconButton) @@ -58,58 +70,153 @@ export const Skeleton = () => { * It also supports favourite products, controlled by a heart icon. */ const ProductTile = (props) => { - const intl = useIntl() const { - product, + dynamicImageProps, enableFavourite = false, + imageViewType = PRODUCT_TILE_IMAGE_VIEW_TYPE, isFavourite, onFavouriteToggle, - dynamicImageProps, + product, + selectableAttributeId = PRODUCT_TILE_SELECTABLE_ATTRIBUTE_ID, ...rest } = props + const {imageGroups, productId, representedProduct, variants} = product + + const intl = useIntl() const {currency} = useCurrency() + const isFavouriteLoading = useRef(false) + const styles = useMultiStyleConfig('ProductTile') + + const isMasterVariant = !!variants + const initialVariationValue = + isMasterVariant && !!representedProduct + ? variants?.find((variant) => variant.productId == product.representedProduct.id) + ?.variationValues?.[selectableAttributeId] + : undefined + + const [selectableAttributeValue, setSelectableAttributeValue] = useState(initialVariationValue) + + // Primary image for the tile, the image is determined from the product and selected variation attributes. + const image = useMemo(() => { + // NOTE: If the selectable variation attribute doesn't exist in the products variation attributes + // array, lets not filter the image groups on it. This ensures we always return an image for non-variant + // type products. + const hasSelectableAttribute = product?.variationAttributes?.find( + ({id}) => id === selectableAttributeId + ) + + const variationValues = {[selectableAttributeId]: selectableAttributeValue} + const filteredImageGroups = filterImageGroups(imageGroups, { + viewType: imageViewType, + variationValues: hasSelectableAttribute ? variationValues : {} + }) + + // Return the first image of the first group. + return filteredImageGroups?.[0]?.images[0] + }, [product, selectableAttributeId, selectableAttributeValue, imageViewType]) + + // Primary URL user to wrap the ProduceTile. + const productUrl = useMemo( + () => + rebuildPathWithParams(productUrlBuilder({id: productId}), { + [selectableAttributeId]: selectableAttributeValue + }), + [product, selectableAttributeId, selectableAttributeValue] + ) + + // NOTE: variationAttributes are only defined for master/variant type products. + const variationAttributes = useMemo(() => getDecoratedVariationAttributes(product), [product]) - const {image, productId} = product // ProductTile is used by two components, RecommendedProducts and ProductList. // RecommendedProducts provides a localized product name as `name` and non-localized product // name as `productName`. ProductList provides a localized name as `productName` and does not // use the `name` property. const localizedProductName = product.name ?? product.productName - const isFavouriteLoading = useRef(false) - const styles = useMultiStyleConfig('ProductTile') - - //TODO variants needs to be filter according to selectedAttribute value - const variants = product?.variants + // Pricing is dynamic! Ensure we are showing the right price for the selected variation attribute + // value. + const priceData = useMemo(() => { + const variants = product?.variants?.filter( + ({variationValues}) => + variationValues[selectableAttributeId] === selectableAttributeValue + ) - const priceData = getPriceData({...product, variants}) + return getPriceData({ + ...product, + variants + }) + }, [product, selectableAttributeId, selectableAttributeValue]) return ( - + - {image && ( - - - - )} + + + + {/* Swatches */} + {variationAttributes + ?.filter(({id}) => selectableAttributeId === id) + ?.map(({id, name, values}) => ( + { + setSelectableAttributeValue(value) + }} + > + {values?.map(({name, swatch, value}) => { + const content = swatch ? ( + + ) : ( + name + ) + + return ( + + {content} + + ) + })} + + ))} + {/* Title */} {localizedProductName} @@ -169,12 +276,12 @@ ProductTile.propTypes = { */ product: PropTypes.shape({ currency: PropTypes.string, - representedProduct: PropTypes.object, image: PropTypes.shape({ alt: PropTypes.string, disBaseLink: PropTypes.string, link: PropTypes.string }), + imageGroups: PropTypes.array, price: PropTypes.number, priceRanges: PropTypes.array, tieredPrices: PropTypes.array, @@ -191,7 +298,9 @@ ProductTile.propTypes = { // Note: useEinstein() transforms snake_case property names from the API response to camelCase productName: PropTypes.string, productId: PropTypes.string, + representedProduct: PropTypes.object, hitType: PropTypes.string, + variationAttributes: PropTypes.array, variants: PropTypes.array, type: PropTypes.shape({ set: PropTypes.bool, @@ -214,6 +323,15 @@ ProductTile.propTypes = { * interacts with favourite icon/button. */ onFavouriteToggle: PropTypes.func, + /** + * The `viewType` of the image component. This defaults to 'large'. + */ + imageViewType: PropTypes.string, + /** + * When displaying a master/variant product, this value represents the variation attribute that is displayed + * as a swatch below the main image. The default for this property is `color`. + */ + selectableAttributeId: PropTypes.string, dynamicImageProps: PropTypes.object } diff --git a/packages/template-retail-react-app/app/components/product-tile/index.test.js b/packages/template-retail-react-app/app/components/product-tile/index.test.js index b627ec47a5..1756638d48 100644 --- a/packages/template-retail-react-app/app/components/product-tile/index.test.js +++ b/packages/template-retail-react-app/app/components/product-tile/index.test.js @@ -7,7 +7,7 @@ import React from 'react' import ProductTile, {Skeleton} from '@salesforce/retail-react-app/app/components/product-tile/index' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import {fireEvent, within} from '@testing-library/react' +import {fireEvent, waitFor, within} from '@testing-library/react' import { mockMasterProductHitWithMultipleVariants, mockMasterProductHitWithOneVariant, @@ -51,6 +51,28 @@ test('Remove from wishlist cannot be muti-clicked', () => { expect(onClick).toHaveBeenCalledTimes(1) }) +test('Renders variation selection swatches', () => { + const {getAllByRole, getByTestId} = renderWithProviders( + + ) + const swatches = getAllByRole('radio') + const productImage = getByTestId('product-tile-image') + + // Initial render will show swatched and the image will be the represented product variation + expect(swatches).toHaveLength(2) + expect(productImage.firstChild.getAttribute('src')).toBe( + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw175c1a89/images/large/PG.33698RUBN4Q.CHARCWL.PZ.jpg' + ) + + // Hovering over color swatch changes the image. + fireEvent.mouseEnter(swatches[1]) + waitFor(() => { + expect(productImage.firstChild.getAttribute('src')).toBe( + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw29b7f226/images/large/PG.52002RUBN4Q.NAVYWL.PZ.jpg' + ) + }) +}) + test('renders exact price with strikethrough price for master product with multiple variants', () => { const {queryByText, getByText, container} = renderWithProviders( diff --git a/packages/template-retail-react-app/app/components/swatch-group/index.jsx b/packages/template-retail-react-app/app/components/swatch-group/index.jsx index 8b3fa715e7..51c6d0604b 100644 --- a/packages/template-retail-react-app/app/components/swatch-group/index.jsx +++ b/packages/template-retail-react-app/app/components/swatch-group/index.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React from 'react' +import React, {Children, useCallback, useEffect, useRef, useState} from 'react' import PropTypes from 'prop-types' import { Flex, @@ -14,34 +14,118 @@ import { useStyleConfig } from '@salesforce/retail-react-app/app/components/shared/ui' import {FormattedMessage} from 'react-intl' +import {noop} from '@salesforce/retail-react-app/app/utils/utils' + +const DIRECTIONS = { + FORWARD: 1, + BACKWARD: -1 +} /** * SwatchGroup allows you to create a list of swatches * Each Swatch is a link with will direct to a href passed to them */ const SwatchGroup = (props) => { - const {displayName, children, label = ''} = props + const {ariaLabel, displayName, children, label = '', value, handleChange = noop} = props + const styles = useStyleConfig('SwatchGroup') + const [selectedIndex, setSelectedIndex] = useState(0) + const wrapperRef = useRef(null) + + // Handle keyboard navigation. + const onKeyDown = useCallback( + (e) => { + const {key} = e + const move = (direction = DIRECTIONS.FORWARD) => { + let index = selectedIndex + direction // forward = +1 backwards = -1 + index = (selectedIndex + direction) % children.length // keep number in bounds of array with modulus + index = index < 0 ? children.length - Math.abs(index) : Math.abs(index) // We we are dealing with a negative we have to invert the index + + // Get a reference to the newly selected swatch as we are going to focus it later. + const swatchEl = wrapperRef?.current?.children[index] + + // Set the new index that is always in the arrays range. + setSelectedIndex(index) + + // Behave like a radio button a focus the new swatch. + swatchEl?.focus() + } + + switch (key) { + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault() + move(DIRECTIONS.BACKWARD) + break + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault() + move(DIRECTIONS.FORWARD) + break + default: + break + } + }, + [selectedIndex] + ) + + // Initialize the component state on mount, this includes the selected index value. + useEffect(() => { + if (!value) { + return + } + const childrenArray = Children.toArray(children) + const index = childrenArray.findIndex(({props}) => props?.value === value) + + setSelectedIndex(index) + }, []) + + // Whenever the selected index changes ensure that we call the change handler. + useEffect(() => { + const childrenArray = Children.toArray(children) + const newValue = childrenArray[selectedIndex].props.value + + handleChange(newValue) + }, [selectedIndex]) + return ( - - - - - - {displayName} - - {children} - + + + {label && ( + + + + + {displayName} + + )} + + {Children.toArray(children).map((child) => { + const selected = child.props.value === value + return React.cloneElement(child, { + handleSelect: handleChange, + selected, + isFocusable: selected + }) + })} + + + ) } SwatchGroup.displayName = 'SwatchGroup' SwatchGroup.propTypes = { + /** + * The aria label to be used on the group. If none is provided we will + * use the label prop. + */ + ariaLabel: PropTypes.string, /** * The attribute name of the swatch group. E.g color, size */ @@ -53,7 +137,15 @@ SwatchGroup.propTypes = { /** * The Swatch options to choose between */ - children: PropTypes.node + children: PropTypes.node, + /** + * This function is called whenever the selected swatch changes. + */ + handleChange: PropTypes.func, + /** + * The currentvalue for the option. + */ + value: PropTypes.string } export default SwatchGroup diff --git a/packages/template-retail-react-app/app/components/swatch-group/index.test.js b/packages/template-retail-react-app/app/components/swatch-group/index.test.js index a0dbabdb57..64816e302d 100644 --- a/packages/template-retail-react-app/app/components/swatch-group/index.test.js +++ b/packages/template-retail-react-app/app/components/swatch-group/index.test.js @@ -14,6 +14,11 @@ import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-grou import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +const arrowLeft = {key: 'ArrowLeft', code: 'ArrowRight', charCode: 37} +const arrowUp = {key: 'ArrowUp', code: 'ArrowRight', charCode: 38} +const arrowRight = {key: 'ArrowRight', code: 'ArrowRight', charCode: 39} +const arrowDown = {key: 'ArrowDown', code: 'ArrowDown', charCode: 40} + const data = { id: 'color', name: 'Color', @@ -135,4 +140,58 @@ describe('Swatch Component', () => { expect(history.search).toBe('?color=BLACKFB') }) }) + + test('swatch can be changed with arrow keys', () => { + const history = createMemoryHistory() + history.push('/en-GB/swatch-example?color=JJ2XNXX') + + renderWithProviders( + + + + ) + + expect(screen.getAllByRole('radio')).toHaveLength(data.values.length) + + const swatchGroup = screen.getByRole('radiogroup').parentNode + const keyDownEvents = [ + { + keyEvent: arrowRight, + expectedValue: 'JJ2XNXX' + }, + { + keyEvent: arrowDown, + expectedValue: 'JJ3HDXX' + }, + { + keyEvent: arrowRight, + expectedValue: 'BLACKFB' + }, + { + keyEvent: arrowLeft, + expectedValue: 'JJ3HDXX' + }, + { + keyEvent: arrowUp, + expectedValue: 'JJ2XNXX' + }, + { + keyEvent: arrowLeft, + expectedValue: 'BLACKFB' + } + ] + + // Test initial state + waitFor(() => { + expect(history.search).toBe('?color=BLACKFB') + }) + + // Navigate according to the event array. This also tests that looping over the end or front works. + keyDownEvents.forEach(({keyEvent, expectedValue}) => { + fireEvent.keyDown(swatchGroup, keyEvent) + waitFor(() => { + expect(history.search).toBe(`?color=${expectedValue}`) + }) + }) + }) }) diff --git a/packages/template-retail-react-app/app/components/swatch-group/swatch.jsx b/packages/template-retail-react-app/app/components/swatch-group/swatch.jsx index 78d01b674e..6585029c19 100644 --- a/packages/template-retail-react-app/app/components/swatch-group/swatch.jsx +++ b/packages/template-retail-react-app/app/components/swatch-group/swatch.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React from 'react' +import React, {useCallback, useEffect, useState} from 'react' import PropTypes from 'prop-types' import { Button, @@ -14,6 +14,7 @@ import { useMultiStyleConfig } from '@salesforce/retail-react-app/app/components/shared/ui' import {Link as RouteLink} from 'react-router-dom' +import {useBreakpointValue} from '@salesforce/retail-react-app/app/components/shared/ui' /** * The Swatch Component displays item inside `SwatchGroup`. For proper keyboard accessibility, @@ -27,46 +28,45 @@ const Swatch = ({ name, selected, isFocusable, + value, + handleSelect, variant = 'square' }) => { const styles = useMultiStyleConfig('SwatchGroup', {variant, disabled, selected}) - /** Mimic the behavior of native radio inputs by using arrow keys to select prev/next value. */ - const onKeyDown = (evt) => { - let sibling - // This is not a very react-y way implementation... ¯\_(ツ)_/¯ - switch (evt.key) { - case 'ArrowUp': - case 'ArrowLeft': - evt.preventDefault() - sibling = - evt.target.previousElementSibling || evt.target.parentElement.lastElementChild - break - case 'ArrowDown': - case 'ArrowRight': - evt.preventDefault() - sibling = - evt.target.nextElementSibling || evt.target.parentElement.firstElementChild - break - default: - break + const isDesktop = useBreakpointValue({base: false, lg: true}) + const [selectHandlers, setSelectHandlers] = useState({}) + + const onSelect = useCallback( + (e) => { + e.preventDefault() + handleSelect(value) + }, + [handleSelect] + ) + + useEffect(() => { + if (!handleSelect) { + return } - sibling?.click() - sibling?.focus() - } + + setSelectHandlers({ + [isDesktop ? 'onMouseEnter' : 'onClick']: onSelect + }) + }, [onSelect, isDesktop]) return (