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

Account's wishlist: integrate hooks #1042

Merged
merged 41 commits into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
11b6261
1st attempt at integrating the hooks
vmarta Mar 7, 2023
c862ffe
Fix iterating the `wishLists`
vmarta Mar 7, 2023
3923dc8
1st attempt at handling the qty change
vmarta Mar 7, 2023
0b444d5
Attempt at selective update
vmarta Mar 7, 2023
d72419c
Simplify the UX by not doing this optimistically
vmarta Mar 7, 2023
a82ae6d
Better loading states, and consider empty list
vmarta Mar 7, 2023
fbf6cba
More typescript friendly
vmarta Mar 7, 2023
428c3a3
Add comment
vmarta Mar 7, 2023
6d8d9bb
Clean up
vmarta Mar 8, 2023
a35170d
Some refactoring
vmarta Mar 8, 2023
4dd1661
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 9, 2023
7fe8af0
Remove debug code
vmarta Mar 9, 2023
dff593a
Add to cart button
vmarta Mar 9, 2023
e51439c
More button click handler
vmarta Mar 10, 2023
5524928
More concise way to selectively update
vmarta Mar 10, 2023
da6129e
Attempt at passing the 1st test
vmarta Mar 10, 2023
7900f52
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 13, 2023
907401a
Update useCurrentBasket call after merge
vmarta Mar 13, 2023
c6ad3fc
Remove redundant test
vmarta Mar 13, 2023
3de2b35
Update test for the Remove button
vmarta Mar 14, 2023
49eb095
Fix the rest of the tests
vmarta Mar 14, 2023
fa90831
Extract the mocked data
vmarta Mar 14, 2023
20d0c2e
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 14, 2023
1f307c0
Fix more tests
vmarta Mar 14, 2023
20c2186
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 14, 2023
a7fad37
PDP: fix broken add-to-wishlist button
vmarta Mar 14, 2023
1bd4a5c
Clarify why we're calling `onClick` twice
vmarta Mar 14, 2023
f5159e2
Small refactoring
vmarta Mar 14, 2023
6bd9593
Consider being undefined
vmarta Mar 14, 2023
3c47cf8
PR feedback
vmarta Mar 14, 2023
487c775
No longer does selective update for the cache
vmarta Mar 14, 2023
438a45c
Decreasing quantity to 0 would auto-remove the item
vmarta Mar 15, 2023
df6e823
PDP: add-to-wishlist now works properly again for product sets
vmarta Mar 15, 2023
3792643
Clean up
vmarta Mar 15, 2023
67c339f
Refactor onClick into 2 separate, clearer events
vmarta Mar 15, 2023
1643906
Update test, after props were updated recently
vmarta Mar 15, 2023
60aecdf
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 15, 2023
b5c1db5
Back to a 1 prop now, but with a promise instead
vmarta Mar 15, 2023
9fd05e0
Increase jest timeout, since waitFor timeout was increased too
vmarta Mar 15, 2023
c73b2e5
Update the test with mock responses
vmarta Mar 15, 2023
7c6e45e
Merge branch 'feature/integrate-commerce-sdk-react' into hooks-accoun…
vmarta Mar 15, 2023
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
28 changes: 26 additions & 2 deletions packages/commerce-sdk-react/src/hooks/ShopperCustomers/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {Query} from '@tanstack/react-query'
import {ShopperCustomersTypes} from 'commerce-sdk-isomorphic'
import {getCustomerProductListItem, QueryKeys} from './queryKeyHelpers'
import {ApiClients, CacheUpdate, CacheUpdateMatrix, Tail} from '../types'
import {
Expand Down Expand Up @@ -134,9 +135,32 @@ export const cacheUpdateMatrix: CacheUpdateMatrix<Client> = {
},
updateCustomerPassword: noop,
updateCustomerProductList: TODO('updateCustomerProductList'),
updateCustomerProductListItem(customerId, {parameters}) {
updateCustomerProductListItem(customerId, {parameters}, response) {
vmarta marked this conversation as resolved.
Show resolved Hide resolved
const {itemId, listId} = parameters
return {
update: [{queryKey: getCustomerProductListItem.queryKey(parameters)}],
update: [
{queryKey: getCustomerProductListItem.queryKey(parameters)},
{
// Also selectively update the lists
queryKey: getCustomerProductLists.queryKey(parameters),
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't forget to remove the query from the invalidate list; invalidate takes precedence over update. (Technically, it will update then invalidate, which negates the point of updating.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, after the list item is updated, the product lists become somewhat stale. For example, the lastModified time is no longer true.

So I prefer to also invalidate the product lists. Yes, most of the data can be redundant, but the refetch is going to be done in the background. In some way, I think of it as similar to stale-while-revalidate strategy.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we update and invalidate at the same time, does react query ever show the updated data? I thought that it just keeps the old data until the refetch completes. (I could very easily be wrong.)

Also, I just noticed that we update/invalidate the lists endpoint, but we just invalidate the list endpoint. We should follow the same pattern for both.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You brought up good points there. To make things simpler, I've reverted the cache-update logic.

This article mentions that it's safer and simpler to just invalidate, rather than update. And I can see that here in our case.

So what now? There's actually another way to achieve what I wanted: via local state update in the quantity picker component. Take a look at this commit for details: 487c775

updater: (oldData: ShopperCustomersTypes.CustomerProductListResult) => {
return {
...oldData,
data: oldData.data.map((list) =>
list.id !== listId
? list
: {
...list,
customerProductListItems:
list.customerProductListItems?.map((item) =>
item.id !== itemId ? item : response
)
}
)
}
}
}
],
// TODO: Rather than invalidate, can we selectively update?
invalidate: [
{queryKey: getCustomerProductList.queryKey(parameters)},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const ItemPrice = ({currency, align = 'right', baseDirection = 'column', ...prop

<FormattedNumber
style="currency"
currency={currency || basket.currency || activeCurrency}
currency={currency || basket?.currency || activeCurrency}
value={displayPrice}
/>
{hasDiscount && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useEffect, useState} from 'react'
import React, {useState} from 'react'
import {Stack, Heading} from '@chakra-ui/layout'
import {FormattedMessage, useIntl} from 'react-intl'
import {Box, Flex, Skeleton} from '@chakra-ui/react'
import {useProducts, useShopperCustomersMutation} from 'commerce-sdk-react-preview'

import useCustomer from '../../../commerce-api/hooks/useCustomer'
import useNavigation from '../../../hooks/use-navigation'
import useWishlist from '../../../hooks/use-wishlist'
import {useToast} from '../../../hooks/use-toast'
import {useWishList} from '../../../hooks/use-wish-list'
Copy link
Collaborator

Choose a reason for hiding this comment

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

On develop when the shopper clicks the "Add to Wishlist" button on one of the items of a product set, the individual item is added to the wishlist, whereas now the entire set is added to the wishlist instead of the individual product.

Example product set:
https://pwa-kit.mobify-storefront.com/global/en-GB/product/fall-lookM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm strange, that might be a regression after resolving merge conflicts.. let me investigate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok I've fixed this issue.


import PageActionPlaceHolder from '../../../components/page-action-placeholder'
import {HeartIcon} from '../../../components/icons'
Expand All @@ -21,69 +21,88 @@ import WishlistPrimaryAction from './partials/wishlist-primary-action'
import WishlistSecondaryButtonGroup from './partials/wishlist-secondary-button-group'

import {API_ERROR_MESSAGE} from '../../../constants'
import {useCurrentCustomer} from '../../../hooks/use-current-customer'

const numberOfSkeletonItems = 3

const AccountWishlist = () => {
const customer = useCustomer()
const navigate = useNavigation()
const {formatMessage} = useIntl()
const toast = useToast()

const [selectedItem, setSelectedItem] = useState(undefined)
const [localQuantity, setLocalQuantity] = useState({})
const [isWishlistItemLoading, setWishlistItemLoading] = useState(false)
Copy link
Collaborator

@kevinxh kevinxh Mar 14, 2023

Choose a reason for hiding this comment

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

Do we still need this state? can we replace this by the isLoading state from the mutation? (i might be missing some context)

Copy link
Contributor Author

@vmarta vmarta Mar 14, 2023

Choose a reason for hiding this comment

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

Good question. That state is still necessary in 1 case (removing an item), but for another case (updating quantity), I've updated it so it used the mutation's loading state instead.

(The one case where it's still necessary is because the mutation lives in a different file/component)

const wishlist = useWishlist()

const {data: wishListData, isLoading: isWishListLoading} = useWishList()
const productIds = wishListData?.customerProductListItems?.map((item) => item.productId)

const {data: productsData, isLoading: isProductsLoading} = useProducts(
{parameters: {ids: productIds?.join(','), allImages: true}},
{enabled: Boolean(productIds)}
Copy link
Contributor

Choose a reason for hiding this comment

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

This will send a request if productIds is an empty array. Is that desired behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, I'll update it to check for an array and that it's not empty.

)

const wishListItems = wishListData?.customerProductListItems?.map((item, i) => {
return {
...item,
product: productsData?.data?.[i]
}
})

const updateCustomerProductListItem = useShopperCustomersMutation(
'updateCustomerProductListItem'
)
const {data: customer} = useCurrentCustomer()

const handleActionClicked = (itemId) => {
setWishlistItemLoading(!!itemId)
setSelectedItem(itemId)
}

const handleItemQuantityChanged = async (quantity, item) => {
// This local state allows the dropdown to show the desired quantity
// while the API call to update it is happening.
setLocalQuantity({...localQuantity, [item.productId]: quantity})
setWishlistItemLoading(true)
setSelectedItem(item.productId)
try {
await wishlist.updateListItem({
...item,
quantity: parseInt(quantity)
})
} catch {
toast({
title: formatMessage(API_ERROR_MESSAGE),
status: 'error'
})

const body = {
...item,
quantity: parseInt(quantity)
}
setWishlistItemLoading(false)
setSelectedItem(undefined)
setLocalQuantity({...localQuantity, [item.productId]: undefined})
}
// To meet expected schema, remove the custom `product` we added
// TODO: better way?
delete body.product

useEffect(() => {
if (customer.isRegistered) {
// We want to reset the wishlist here
// because it is possible that a user
// adds an item to the wishlist on another page
// and the wishlist page may not have enough
// data to render the page.
// Reset the wishlist will make sure the
// initialization state is correct.
if (wishlist.isInitialized) {
wishlist.reset()
updateCustomerProductListItem.mutate(
{
body,
parameters: {
customerId: customer.customerId,
itemId: item.id,
listId: wishListData.id
vmarta marked this conversation as resolved.
Show resolved Hide resolved
}
},
{
onError: () => {
toast({
title: formatMessage(API_ERROR_MESSAGE),
status: 'error'
})
},
onSuccess: () => {
setWishlistItemLoading(false)
Copy link
Collaborator

Choose a reason for hiding this comment

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

setSelectedItem(undefined)
}
}
)
}

wishlist.init({detail: true})
}
}, [customer.isRegistered])
const isPageLoading = wishListItems ? isProductsLoading : isWishListLoading

return (
<Stack spacing={4} data-testid="account-wishlist-page">
<Heading as="h1" fontSize="2xl">
<FormattedMessage defaultMessage="Wishlist" id="account_wishlist.title.wishlist" />
</Heading>
{!wishlist.hasDetail && (

{isPageLoading && (
<Box data-testid="sf-wishlist-skeleton">
{new Array(numberOfSkeletonItems).fill(0).map((i, idx) => (
<Box
Expand All @@ -108,7 +127,7 @@ const AccountWishlist = () => {
</Box>
)}

{wishlist.hasDetail && wishlist.isEmpty && (
{!isPageLoading && !wishListItems && (
<PageActionPlaceHolder
data-testid="empty-wishlist"
icon={<HeartIcon boxSize={8} />}
Expand All @@ -129,16 +148,14 @@ const AccountWishlist = () => {
/>
)}

{wishlist.hasDetail &&
!wishlist.isEmpty &&
wishlist.items.map((item) => (
{!isPageLoading &&
wishListItems &&
wishListItems.map((item) => (
<ProductItem
key={item.id}
product={{
...item.product,
quantity: localQuantity[item.productId]
? localQuantity[item.productId]
: item.quantity
quantity: item.quantity
Copy link
Collaborator

Choose a reason for hiding this comment

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

The code in develop removes the item from the wishlist when the shopper decrements the product quantity < 1, when taking either of these two actions in the QuantityPicker:

  1. Typing the number '0' in the quantity Input field.
  2. Pressing the '-' button when the quantity is 1.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, the smart deletion logic originally comes from:

updateListItem: async (listId, item) => {
const {id, quantity} = item
if (quantity === 0) {
await removeListItem(listId, id)
actions.removeListItem(listId, id)
return
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

FYI I've implemented that.

}}
showLoading={isWishlistItemLoading && selectedItem === item.productId}
primaryAction={<WishlistPrimaryAction />}
Expand Down
Loading