Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-13974769 - Show Color Attribute on ProductTile 🀄️ #1773

Merged
merged 34 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6de0fa7
Initial commit
bendvc Apr 29, 2024
3f52c3c
Image changes now
bendvc Apr 30, 2024
0561a0a
Working with single selectable attribute
bendvc Apr 30, 2024
314f410
Update index.jsx
bendvc Apr 30, 2024
3dd556b
Move helper to utils
bendvc May 2, 2024
48e648b
Merge branch 'develop' into bendvc/W-13974769
bendvc May 2, 2024
958b72d
Merge branch 'v3/product-tile-revamp' into bendvc/W-13974769
bendvc May 2, 2024
cfdc3fb
Adding comments
bendvc May 2, 2024
dc0cc9a
Expose props as constants
bendvc May 6, 2024
731c9ca
Always default to image defined on product if no image group is found
bendvc May 6, 2024
dd23007
Only use circle swatches
bendvc May 6, 2024
328e951
Add tests
bendvc May 6, 2024
c881f34
Refactor `getDecoratedVariationAttributes` and add tests
bendvc May 7, 2024
16996cd
Bump main bundle size limit
bendvc May 7, 2024
703ac48
Dynamic event handling for device type
bendvc May 7, 2024
8b88143
Use useBreakpointValue to determine device type
bendvc May 7, 2024
eee2aaf
Rework the swatch component
bendvc May 9, 2024
6f5a72d
Render swatch as button when no href is supplied
bendvc May 9, 2024
7a08fe3
Update index.jsx
bendvc May 10, 2024
f6c6244
Fix constant used for tile image on plp
bendvc May 10, 2024
d3e9000
Fix test and lint warnings
bendvc May 10, 2024
85027b7
Bump bundle size again
bendvc May 10, 2024
2a216de
Update packages/template-retail-react-app/app/components/product-tile…
bendvc May 14, 2024
51c3ef1
Use module imports
bendvc May 14, 2024
3a59c8b
Include image information for other pages where product tiles are used.
bendvc May 14, 2024
8fb7208
Add tests
bendvc May 14, 2024
dc8dae0
Add explicit ariaLabel prop
bendvc May 14, 2024
a72613c
Update from base and update price on selected variation attribute
bendvc May 16, 2024
0c025d4
Lint!
bendvc May 16, 2024
ca733fe
fix test data after merge
bendvc May 16, 2024
7694a50
Bump bundle size
bendvc May 16, 2024
e71cf8e
Don't use "hitType" to determine if variants exists
bendvc May 16, 2024
fa3506b
lint
bendvc May 16, 2024
4412af4
Update packages/template-retail-react-app/app/components/product-tile…
bendvc May 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 134 additions & 20 deletions packages/template-retail-react-app/app/components/product-tile/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* 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 useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'

// Components
import {
Expand All @@ -21,14 +21,20 @@ import {
} from '@salesforce/retail-react-app/app/components/shared/ui'
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'

// Project Components
alexvuong marked this conversation as resolved.
Show resolved Hide resolved
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 {productUrlBuilder, rebuildPathWithParams} from '@salesforce/retail-react-app/app/utils/url'
import {useCurrency} from '@salesforce/retail-react-app/app/hooks'
import {filterImageGroups} from '../../utils/product-utils'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created this little util as I saw that we were doing similar logic in other places. Thats why you'll see that I've changed another hook to use this util too.


const IconButtonWithRegistration = withRegistration(IconButton)

Expand Down Expand Up @@ -56,36 +62,87 @@ export const Skeleton = () => {
* It also supports favourite products, controlled by a heart icon.
*/
const ProductTile = (props) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are the updates to the unit tests for ProductTile (and SwatchGroup/Swatch)?

const intl = useIntl()
const {
product,
dynamicImageProps,
enableFavourite = false,
imageViewType = 'large',
isFavourite,
onFavouriteToggle,
dynamicImageProps,
product,
selectableAttributeId = 'color',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm planning on adding this to the config file.. or maybe just the constant, and apply it from the product list component. Unless you have other ideas on how you think we might want to do this.

...rest
} = props
const {currency, imageGroups, price, productId, hitType} = product

const intl = useIntl()
const {buildUrl} = useMultiSite()
const {currency: activeCurrency} = useCurrency()
const isFavouriteLoading = useRef(false)
const styles = useMultiStyleConfig('ProductTile')

const {currency, image, price, productId, hitType} = product
const isMasterVariant = ['master', 'variant'].includes(hitType)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Product Tile is used for both productSearch and einstein (which uses getProducts). The data may be a bit different when it comes to determine product type. We should consider for both cases here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at the recommended products on both the home page and the product details and the data looks like it's consistent to the point that this logic works. Do you know of any other specific uses where I'd want to check ?

Copy link
Collaborator

@alexvuong alexvuong May 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Einstein recomendation, getProducts data does not have hitType instead it will be product.type.master or product.type.set

const initialVariationValue = isMasterVariant
? product?.variants?.find((variant) => variant.productId == product.representedProduct.id)
?.variationValues?.[selectableAttributeId]
: undefined
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we use the representedProduct to determine what the initial selected variation attribute value is.


const [selectableAttributeValue, setSelectableAttributeValue] = useState(initialVariationValue)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: I've been thinking about making this state and object instead of a single value. This might allow use some flexibility in the future as well as clean up some of the use's of that image utility that I created.


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])

const productUrl = useMemo(() => {
return rebuildPathWithParams(productUrlBuilder({id: productId}), {
[selectableAttributeId]: selectableAttributeValue
})
}, [product, selectableAttributeId, selectableAttributeValue])

// NOTE: variationAttributes are only defined for master/variant type products.
const variationAttributes = useMemo(() => {
// NOTE: Decorate the product variant attributes to easily access images and hrefs.
return product?.variationAttributes?.map((variationAttribute) => ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic here looks familiar with the useVariantionAttribute.js logic, can we extract or refactor that hook to accommodate both PLP and PDP. If not, can we extract this logic into it owns util function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I made a utility that we can use in the future and use to refactor what we are already using in the hooks. At this time, that hook is too deeply engrained that I would prefer to do that work outside of this PR as it could possible bloat this PR to the point where the intended work gets hidden in a refactor.

...variationAttribute,
values: variationAttribute.values.map((value) => {
const variationValues = {[selectableAttributeId]: value.value}
const swatchImage = filterImageGroups(product.imageGroups, {
viewType: 'swatch',
variationValues
})?.[0]?.images[0]
const productHref = buildUrl(rebuildPathWithParams(productUrl, variationValues))

return {
...value,
image: swatchImage,
href: productHref
}
})
}))
}, [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 {currency: activeCurrency} = useCurrency()
const isFavouriteLoading = useRef(false)
const styles = useMultiStyleConfig('ProductTile')

return (
<Box {...styles.container}>
<Link
data-testid="product-tile"
to={productUrlBuilder({id: productId}, intl.local)}
{...styles.link}
{...rest}
>
<Link data-testid="product-tile" to={productUrl} {...styles.link} {...rest}>
<Box {...styles.imageWrapper}>
{image && (
<AspectRatio {...styles.image}>
Expand All @@ -104,6 +161,50 @@ const ProductTile = (props) => {
)}
</Box>

{/* Swatches */}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question on the design - Can we make SwatchGroup a more independent component by passing all the required information to it? In this case it looks like it needs product to be passed may be as a prop.

This way we can move any code/logic specific to swatches into the SwatchGroup (Example: const variationAttributes = useMemo(() => getDecoratedVariationAttributes(product), [product]). It is also easy for the users to extend/overide the component as it has all the information. And product tile will also be clean.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These components are designed to be "dumb" lets say. You can use them to display anything you want really, it doesn't have to be product information at all. This is why you don't see that kind of logic inside the component definition.

{variationAttributes
?.filter(({id}) => selectableAttributeId == id)
alexvuong marked this conversation as resolved.
Show resolved Hide resolved
?.map(({id, values}) => {
const attributeId = id
return (
<SwatchGroup key={id}>
{values?.map(({href, name, image, value}) => {
const content = image ? (
<Box
height="100%"
width="100%"
minWidth="32px"
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundColor={name.toLowerCase()}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assumes name is 'color', what happens if the name is different?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what we should do here is select 'circle' as the only current option. I only say this because I don't want too much scope creep in making everything under the rainbow configurable. We can open it up for configurability after the initial version

backgroundImage={`url(${
image.disBaseLink || image.link
})`}
/>
) : (
name
)

return (
<Swatch
key={value}
href={href}
handleMouseEnter={(value) =>
setSelectableAttributeValue(value)
}
value={value}
name={name}
variant={attributeId === 'color' ? 'circle' : 'square'}
alexvuong marked this conversation as resolved.
Show resolved Hide resolved
selected={value === selectableAttributeValue}
>
{content}
</Swatch>
)
})}
</SwatchGroup>
)
})}

{/* Title */}
<Text {...styles.title}>{localizedProductName}</Text>

Expand Down Expand Up @@ -186,6 +287,7 @@ ProductTile.propTypes = {
disBaseLink: PropTypes.string,
link: PropTypes.string
}),
imageGroups: PropTypes.array,
price: PropTypes.number,
// `name` is present and localized when `product` is provided by a RecommendedProducts component
// (from Shopper Products `getProducts` endpoint), but is not present when `product` is
Expand All @@ -200,7 +302,10 @@ ProductTile.propTypes = {
// Note: useEinstein() transforms snake_case property names from the API response to camelCase
productName: PropTypes.string,
productId: PropTypes.string,
hitType: PropTypes.string
representedProduct: PropTypes.object,
hitType: PropTypes.string,
variationAttributes: PropTypes.array,
variants: PropTypes.array
}),
/**
* Enable adding/removing product as a favourite.
Expand All @@ -216,6 +321,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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ const SwatchGroup = (props) => {
const styles = useStyleConfig('SwatchGroup')
return (
<Flex {...styles.swatchGroup} role="radiogroup" aria-label={label}>
<HStack {...styles.swatchLabel}>
<Box fontWeight="semibold">
<FormattedMessage
id="swatch_group.selected.label"
defaultMessage="{label}:"
values={{label}}
/>
</Box>
<Box>{displayName}</Box>
</HStack>
{label && (
<HStack {...styles.swatchLabel}>
<Box fontWeight="semibold">
<FormattedMessage
id="swatch_group.selected.label"
defaultMessage="{label}:"
values={{label}}
/>
</Box>
<Box>{displayName}</Box>
</HStack>
)}
<Flex {...styles.swatchesWrapper}>{children}</Flex>
</Flex>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} from 'react'
import PropTypes from 'prop-types'
import {
Button,
Expand All @@ -14,6 +14,7 @@ import {
useMultiStyleConfig
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {Link as RouteLink} from 'react-router-dom'
import {noop} from '@salesforce/retail-react-app/app/utils/utils'

/**
* The Swatch Component displays item inside `SwatchGroup`. For proper keyboard accessibility,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you had done some work for accessibility in this PR. How should we test-drive this change? Thanks.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-implemented the existing logic we had to be more react-y. It didn't look like there were any tests for it tho. I've added some basic tests for using arrow keys to select swatches.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I meant manual tests.. how should we manually test the accessibility changes? I'm not familiar with how we did that last time.

Expand All @@ -27,11 +28,13 @@ const Swatch = ({
name,
selected,
isFocusable,
value,
handleMouseEnter = noop,
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) => {

const onKeyDown = useCallback((evt) => {
let sibling
// This is not a very react-y way implementation... ¯\_(ツ)_/¯
switch (evt.key) {
Expand All @@ -52,7 +55,12 @@ const Swatch = ({
}
sibling?.click()
sibling?.focus()
}
}, [])

const onMouseEnter = useCallback((e) => {
e.preventDefault()
handleMouseEnter(value)
}, [])

return (
<Button
Expand All @@ -61,9 +69,10 @@ const Swatch = ({
to={href}
aria-label={name}
aria-checked={selected}
onMouseEnter={onMouseEnter}
onKeyDown={onKeyDown}
variant="outline"
role="radio"
onKeyDown={onKeyDown}
// To mimic the behavior of native radio inputs, only one input should be focusable.
// (The rest are selectable via arrow keys.)
tabIndex={isFocusable ? 0 : -1}
Expand Down Expand Up @@ -115,7 +124,11 @@ Swatch.propTypes = {
/**
* Whether the swatch can receive tab focus
*/
isFocusable: PropTypes.bool
isFocusable: PropTypes.bool,
/**
* This function is called whenever the mouse enters the swatch. The values is passed as the first argument.
*/
handleMouseEnter: PropTypes.func
}

export default Swatch
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {useVariationParams} from '@salesforce/retail-react-app/app/hooks/use-var
// Utils
import {updateSearchParams} from '@salesforce/retail-react-app/app/utils/url'
import {usePDPSearchParams} from '@salesforce/retail-react-app/app/hooks/use-pdp-search-params'

import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'
/**
* Return the first image in the `swatch` type image group for a given
* variation value of a product.
Expand All @@ -23,21 +23,15 @@ import {usePDPSearchParams} from '@salesforce/retail-react-app/app/hooks/use-pdp
* @param {Object} variationValue
* @returns {Object} image
*/
const getVariantValueSwatch = (product, variationValue) => {
export const getVariantValueSwatch = (product, variationValue) => {
const {imageGroups = []} = product

const imageGroup = imageGroups
.filter(({viewType}) => viewType === 'swatch')
.find(({variationAttributes = []}) => {
const colorAttribute = variationAttributes.find(({id}) => id === 'color')
const colorValues = colorAttribute?.values || []

// A single image can represent multiple variation values, so we only need
// ensure the variation values appears in once of the images represented values.
return colorValues.some(({value}) => value === variationValue.value)
})

return imageGroup?.images?.[0]
return filterImageGroups(imageGroups, {
viewType: 'swatch',
variationValues: {
['color']: variationValue.value
}
})?.[0]?.images?.[0]
}

/**
Expand All @@ -48,7 +42,7 @@ const getVariantValueSwatch = (product, variationValue) => {
* @param {Object} location
* @returns {String} a product url for the current variation value.
*/
const buildVariantValueHref = ({
export const buildVariantValueHref = ({
pathname,
existingParams,
newParams,
Expand Down Expand Up @@ -76,7 +70,7 @@ const buildVariantValueHref = ({
* @param {Object} variationParams
* @returns
*/
const isVariantValueOrderable = (product, variationParams) => {
export const isVariantValueOrderable = (product, variationParams) => {
return product.variants
.filter(({variationValues}) =>
Object.keys(variationParams).every(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ const ProductList = (props) => {
{
parameters: {
...restOfParams,
perPricebook: true,
allVariationProperties: true,
expand: ['promotions', 'variations', 'prices', 'images'],
allImages: true,
refine: _refine
}
},
Expand Down
Loading
Loading