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 22 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"bundlesize": [
{
"path": "build/main.js",
"maxSize": "43 kB"
"maxSize": "44 kB"
},
{
"path": "build/vendor.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {useQuery} from '@tanstack/react-query'
import {
useAccessToken,
useCategory,
useCustomerBaskets,
useShopperBasketsMutation
} from '@salesforce/commerce-sdk-react'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// Components
import {
Expand All @@ -21,14 +20,24 @@ 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 {
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 {useCurrency} from '@salesforce/retail-react-app/app/hooks'
import {filterImageGroups, getDecoratedVariationAttributes} from '../../utils/product-utils'
bendvc marked this conversation as resolved.
Show resolved Hide resolved

const IconButtonWithRegistration = withRegistration(IconButton)

Expand Down Expand Up @@ -56,54 +65,135 @@ 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 = PRODUCT_TILE_IMAGE_VIEW_TYPE,
isFavourite,
onFavouriteToggle,
dynamicImageProps,
product,
selectableAttributeId = PRODUCT_TILE_SELECTABLE_ATTRIBUTE_ID,
...rest
} = props
const {currency, imageGroups, price, productId, hitType} = product

const intl = useIntl()
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.


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

// 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}>
<DynamicImage
src={`${image.disBaseLink || image.link}[?sw={width}&q=60]`}
widths={dynamicImageProps?.widths}
imageProps={{
// treat img as a decorative item, we don't need to pass `image.alt`
// since it is the same as product name
// which can cause confusion for individuals who uses screen readers
alt: '',
...dynamicImageProps?.imageProps
}}
/>
</AspectRatio>
)}
<AspectRatio {...styles.image}>
<DynamicImage
src={`${
image?.disBaseLink ||
image?.link ||
product?.image?.disBaseLink ||
product?.image?.disBaseLink
bendvc marked this conversation as resolved.
Show resolved Hide resolved
}[?sw={width}&q=60]`}
Copy link
Contributor

Choose a reason for hiding this comment

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

duplicate value for product?.image?.disBaseLink || product?.image?.disBaseLink

widths={dynamicImageProps?.widths}
imageProps={{
// treat img as a decorative item, we don't need to pass `image.alt`
// since it is the same as product name
// which can cause confusion for individuals who uses screen readers
alt: '',
...dynamicImageProps?.imageProps
}}
bendvc marked this conversation as resolved.
Show resolved Hide resolved
/>
</AspectRatio>
</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)
?.map(({id, values}) => (
<SwatchGroup
Copy link
Collaborator

@alexvuong alexvuong May 13, 2024

Choose a reason for hiding this comment

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

Nit: For a11y, we should make sure the attribute name (color/size) is included in the label for swatch. This is because not all color names are easy to regconize (e.g like seagrass or Light Seabreeze), without the context seeing them as colors, it may be hard for screen reader users to know what it is actually about.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, it looks like we were overloading the "label" prop to be used for both the label, and the aria-label. As I didn't need or want the label to be displayed in this scenario I didn't provide a the label to the swatch group. This as expected stopped the label from showing, but also meant there was no aria-label.

So resolve this I've added an explicit "ariaLabel" prop that will take precedence over the label when passed in. This also won't effect the showing or hiding of the visual label.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@bendvc the name here is still just the color value. The screen reader will says something like this "Seagrass, selected, radiogroup, 1 out of 2". It still misses the context of attribution type. I think it will be more helpful to have it like this so the screen will read out loud "Color Seagrass, select, ..."

ariaLabel={`${selectableAttributeId}-${name}`}

key={id}
value={selectableAttributeValue}
handleChange={(value) => {
setSelectableAttributeValue(value)
}}
>
{values?.map(({name, swatch, value}) => {
const content = swatch ? (
<Box
height="100%"
width="100%"
minWidth="32px"
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundColor={name.toLowerCase()}
backgroundImage={`url(${
swatch?.disBaseLink || swatch.link
})`}
/>
) : (
name
)

return (
<Swatch
key={value}
value={value}
name={name}
variant={'circle'}
isFocusable={true}
>
{content}
</Swatch>
)
})}
</SwatchGroup>
))}

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

Expand Down Expand Up @@ -186,6 +276,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 +291,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 +310,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