Skip to content

Commit 291ba18

Browse files
authored
@W-18990330: Made a new Product-List component for cart page (#2760)
* Made a new Product-List component that is used on the cart page
1 parent 25fee06 commit 291ba18

File tree

3 files changed

+293
-52
lines changed

3 files changed

+293
-52
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import React from 'react'
8+
import PropTypes from 'prop-types'
9+
10+
// Chakra Components
11+
import {Stack} from '@salesforce/retail-react-app/app/components/shared/ui'
12+
13+
// Project Components
14+
import ProductItem from '@salesforce/retail-react-app/app/components/product-item'
15+
16+
/**
17+
* Component that renders a list of product items with consistent props and behavior.
18+
* Extracted from cart page to be reusable across different contexts.
19+
*/
20+
const ProductItemList = ({
21+
productItems = [],
22+
renderSecondaryActions,
23+
onItemQuantityChange,
24+
onRemoveItemClick,
25+
// Optional props with defaults
26+
productsByItemId = {},
27+
isProductsLoading = false,
28+
localQuantity = {},
29+
localIsGiftItems = {},
30+
isCartItemLoading = false,
31+
selectedItem = null
32+
}) => {
33+
return (
34+
<Stack spacing={4}>
35+
{productItems.map((productItem) => {
36+
const isBonusProductItem = productItem.bonusProductLineItem
37+
38+
return (
39+
<ProductItem
40+
key={productItem.itemId}
41+
isBonusProduct={isBonusProductItem}
42+
secondaryActions={
43+
renderSecondaryActions
44+
? renderSecondaryActions({
45+
productItem,
46+
isAGift: localIsGiftItems[productItem.itemId]
47+
? localIsGiftItems[productItem.itemId]
48+
: productItem.gift
49+
})
50+
: null
51+
}
52+
product={{
53+
...productItem,
54+
...(productsByItemId && productsByItemId[productItem.itemId]),
55+
isProductUnavailable: !isProductsLoading
56+
? !productsByItemId?.[productItem.itemId]
57+
: undefined,
58+
price: productItem.price,
59+
quantity: localQuantity[productItem.itemId]
60+
? localQuantity[productItem.itemId]
61+
: productItem.quantity
62+
}}
63+
onItemQuantityChange={onItemQuantityChange?.bind(this, productItem)}
64+
showLoading={
65+
isCartItemLoading && selectedItem?.itemId === productItem.itemId
66+
}
67+
handleRemoveItem={onRemoveItemClick}
68+
/>
69+
)
70+
})}
71+
</Stack>
72+
)
73+
}
74+
75+
ProductItemList.propTypes = {
76+
productItems: PropTypes.arrayOf(PropTypes.object),
77+
renderSecondaryActions: PropTypes.func,
78+
onItemQuantityChange: PropTypes.func.isRequired,
79+
onRemoveItemClick: PropTypes.func,
80+
productsByItemId: PropTypes.object,
81+
isProductsLoading: PropTypes.bool,
82+
localQuantity: PropTypes.object,
83+
localIsGiftItems: PropTypes.object,
84+
isCartItemLoading: PropTypes.bool,
85+
selectedItem: PropTypes.object
86+
}
87+
88+
export default ProductItemList
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import React from 'react'
8+
import {screen} from '@testing-library/react'
9+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
10+
import ProductItemList from '@salesforce/retail-react-app/app/components/product-item-list'
11+
12+
const mockProductItems = [
13+
{
14+
itemId: 'item1',
15+
productId: 'prod1',
16+
name: 'Test Product 1',
17+
quantity: 1,
18+
price: 10.99,
19+
gift: false
20+
},
21+
{
22+
itemId: 'item2',
23+
productId: 'prod2',
24+
name: 'Test Product 2',
25+
quantity: 2,
26+
price: 15.99,
27+
gift: true
28+
}
29+
]
30+
31+
const mockProductsByItemId = {
32+
item1: {
33+
id: 'prod1',
34+
name: 'Test Product 1',
35+
inventory: {stockLevel: 10}
36+
},
37+
item2: {
38+
id: 'prod2',
39+
name: 'Test Product 2',
40+
inventory: {stockLevel: 5}
41+
}
42+
}
43+
44+
const defaultProps = {
45+
productItems: mockProductItems,
46+
productsByItemId: mockProductsByItemId,
47+
isProductsLoading: false,
48+
localQuantity: {},
49+
localIsGiftItems: {},
50+
isCartItemLoading: false,
51+
selectedItem: null,
52+
onItemQuantityChange: jest.fn(),
53+
onRemoveItemClick: jest.fn()
54+
}
55+
56+
describe('ProductItemList Component', () => {
57+
beforeEach(() => {
58+
jest.clearAllMocks()
59+
})
60+
61+
test('renders all product items', () => {
62+
renderWithProviders(<ProductItemList {...defaultProps} />)
63+
64+
// Check that the product items are rendered by looking for their names
65+
expect(screen.getByText('Test Product 1')).toBeInTheDocument()
66+
expect(screen.getByText('Test Product 2')).toBeInTheDocument()
67+
})
68+
69+
test('renders empty list when no product items provided', () => {
70+
renderWithProviders(<ProductItemList {...defaultProps} productItems={[]} />)
71+
72+
expect(screen.queryByText('Test Product 1')).not.toBeInTheDocument()
73+
expect(screen.queryByText('Test Product 2')).not.toBeInTheDocument()
74+
})
75+
76+
test('shows loading state for selected item', () => {
77+
const propsWithLoading = {
78+
...defaultProps,
79+
isCartItemLoading: true,
80+
selectedItem: mockProductItems[0]
81+
}
82+
83+
renderWithProviders(<ProductItemList {...propsWithLoading} />)
84+
85+
// The real ProductItem component shows a LoadingSpinner when showLoading is true
86+
// We can check for the loading state by looking for the spinner or the loading overlay
87+
expect(screen.getByText('Test Product 1')).toBeInTheDocument()
88+
})
89+
90+
test('calls onItemQuantityChange when quantity is changed', () => {
91+
const mockOnItemQuantityChange = jest.fn()
92+
renderWithProviders(
93+
<ProductItemList {...defaultProps} onItemQuantityChange={mockOnItemQuantityChange} />
94+
)
95+
96+
// The real ProductItem component uses ProductQuantityPicker which has its own UI
97+
// We'll just verify the component renders and the function is available
98+
expect(screen.getByText('Test Product 1')).toBeInTheDocument()
99+
expect(screen.getByText('Test Product 2')).toBeInTheDocument()
100+
})
101+
102+
test('renders with custom secondary actions', () => {
103+
const mockRenderSecondaryActions = jest.fn(() => <div>Custom Actions</div>)
104+
renderWithProviders(
105+
<ProductItemList
106+
{...defaultProps}
107+
renderSecondaryActions={mockRenderSecondaryActions}
108+
/>
109+
)
110+
111+
expect(mockRenderSecondaryActions).toHaveBeenCalledTimes(2)
112+
// Verify the function was called with correct parameters
113+
expect(mockRenderSecondaryActions).toHaveBeenCalledWith(
114+
expect.objectContaining({
115+
productItem: mockProductItems[0],
116+
isAGift: false
117+
})
118+
)
119+
})
120+
121+
test('handles bonus products correctly', () => {
122+
const bonusProductItems = [
123+
{
124+
...mockProductItems[0],
125+
bonusProductLineItem: true
126+
}
127+
]
128+
129+
renderWithProviders(<ProductItemList {...defaultProps} productItems={bonusProductItems} />)
130+
131+
// Bonus products should still render as ProductItem components
132+
expect(screen.getByText('Test Product 1')).toBeInTheDocument()
133+
})
134+
135+
test('handles local quantity state', () => {
136+
const localQuantity = {
137+
item1: 3
138+
}
139+
140+
renderWithProviders(<ProductItemList {...defaultProps} localQuantity={localQuantity} />)
141+
142+
// The real component will use the local quantity instead of the product's quantity
143+
expect(screen.getByText('Test Product 1')).toBeInTheDocument()
144+
expect(screen.getByText('Test Product 2')).toBeInTheDocument()
145+
})
146+
147+
test('handles local gift items state', () => {
148+
const localIsGiftItems = {
149+
item1: true
150+
}
151+
152+
const mockRenderSecondaryActions = jest.fn(() => <div>Actions</div>)
153+
renderWithProviders(
154+
<ProductItemList
155+
{...defaultProps}
156+
localIsGiftItems={localIsGiftItems}
157+
renderSecondaryActions={mockRenderSecondaryActions}
158+
/>
159+
)
160+
161+
expect(mockRenderSecondaryActions).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
isAGift: true
164+
})
165+
)
166+
})
167+
})

packages/template-retail-react-app/app/pages/cart/index.jsx

Lines changed: 38 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import BonusProductsTitle from '@salesforce/retail-react-app/app/pages/cart/part
2828
import ConfirmationModal from '@salesforce/retail-react-app/app/components/confirmation-modal'
2929
import EmptyCart from '@salesforce/retail-react-app/app/pages/cart/partials/empty-cart'
3030
import OrderSummary from '@salesforce/retail-react-app/app/components/order-summary'
31-
import ProductItem from '@salesforce/retail-react-app/app/components/product-item'
31+
import ProductItemList from '@salesforce/retail-react-app/app/components/product-item-list'
3232
import ProductViewModal from '@salesforce/retail-react-app/app/components/product-view-modal'
3333
import BundleProductViewModal from '@salesforce/retail-react-app/app/components/product-view-modal/bundle'
3434
import RecommendedProducts from '@salesforce/retail-react-app/app/components/recommended-products'
@@ -600,40 +600,19 @@ const Cart = () => {
600600
)
601601
}, [basket?.productItems])
602602

603-
// Function to create product items
604-
const createProductItemProps = (productItem, isBonusProduct = false) => ({
605-
isBonusProduct,
606-
secondaryActions: (
607-
<CartSecondaryButtonGroup
608-
isAGift={
609-
localIsGiftItems[productItem.itemId]
610-
? localIsGiftItems[productItem.itemId]
611-
: productItem.gift
612-
}
613-
onIsAGiftChange={handleIsAGiftChange}
614-
onAddToWishlistClick={handleAddToWishlist}
615-
onEditClick={(product) => {
616-
setSelectedItem(product)
617-
onOpen()
618-
}}
619-
onRemoveItemClick={handleRemoveItem}
620-
/>
621-
),
622-
product: {
623-
...productItem,
624-
...(productsByItemId && productsByItemId[productItem.itemId]),
625-
isProductUnavailable: !isProductsLoading
626-
? !productsByItemId?.[productItem.itemId]
627-
: undefined,
628-
price: productItem.price,
629-
quantity: localQuantity[productItem.itemId]
630-
? localQuantity[productItem.itemId]
631-
: productItem.quantity
632-
},
633-
onItemQuantityChange: handleChangeItemQuantity.bind(this, productItem),
634-
showLoading: isCartItemLoading && selectedItem?.itemId === productItem.itemId,
635-
handleRemoveItem
636-
})
603+
// Function to render secondary actions for product items
604+
const renderSecondaryActions = ({productItem, isAGift}) => (
605+
<CartSecondaryButtonGroup
606+
isAGift={isAGift}
607+
onIsAGiftChange={handleIsAGiftChange}
608+
onAddToWishlistClick={handleAddToWishlist}
609+
onEditClick={(product) => {
610+
setSelectedItem(product)
611+
onOpen()
612+
}}
613+
onRemoveItemClick={handleRemoveItem}
614+
/>
615+
)
637616

638617
/********* Rendering UI **********/
639618
if (isLoading) {
@@ -685,30 +664,37 @@ const Cart = () => {
685664
</Box>
686665
)}
687666
{/* Regular Products */}
688-
{categorizedProducts.regularProducts.map((productItem) => (
689-
<ProductItem
690-
key={productItem.itemId}
691-
{...createProductItemProps(productItem, false)}
692-
/>
693-
))}
667+
<ProductItemList
668+
productItems={categorizedProducts.regularProducts}
669+
productsByItemId={productsByItemId}
670+
isProductsLoading={isProductsLoading}
671+
localQuantity={localQuantity}
672+
localIsGiftItems={localIsGiftItems}
673+
isCartItemLoading={isCartItemLoading}
674+
selectedItem={selectedItem}
675+
onItemQuantityChange={handleChangeItemQuantity}
676+
onRemoveItemClick={handleRemoveItem}
677+
renderSecondaryActions={renderSecondaryActions}
678+
/>
694679

695680
{/* Bonus Products */}
696681
{categorizedProducts.bonusProducts.length > 0 && (
697682
<>
698683
<Box>
699684
<BonusProductsTitle />
700685
</Box>
701-
{categorizedProducts.bonusProducts.map(
702-
(productItem) => (
703-
<ProductItem
704-
key={productItem.itemId}
705-
{...createProductItemProps(
706-
productItem,
707-
true
708-
)}
709-
/>
710-
)
711-
)}
686+
<ProductItemList
687+
productItems={categorizedProducts.bonusProducts}
688+
productsByItemId={productsByItemId}
689+
isProductsLoading={isProductsLoading}
690+
localQuantity={localQuantity}
691+
localIsGiftItems={localIsGiftItems}
692+
isCartItemLoading={isCartItemLoading}
693+
selectedItem={selectedItem}
694+
onItemQuantityChange={handleChangeItemQuantity}
695+
onRemoveItemClick={handleRemoveItem}
696+
renderSecondaryActions={renderSecondaryActions}
697+
/>
712698
</>
713699
)}
714700
</Stack>

0 commit comments

Comments
 (0)