-
Notifications
You must be signed in to change notification settings - Fork 214
@W-18771949 Get rule based bonus products from API #2641
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
Changes from all commits
d35d40f
37abf08
36006e4
0c0350f
d1136a0
b2d0d0a
b02f97e
037675e
cd91851
77c9779
814c9ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| /* | ||
| * Copyright (c) 2021, salesforce.com, inc. | ||
| * All rights reserved. | ||
| * 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 from 'react' | ||
| import {fireEvent, screen, waitFor} from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import ProductView from '@salesforce/retail-react-app/app/components/product-view' | ||
| import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' | ||
| import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' | ||
| import {useBonusProductSearch} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-search' | ||
| import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal' | ||
| import * as useDerivedProductModule from '@salesforce/retail-react-app/app/hooks/use-derived-product' | ||
|
|
||
| // Mock scrollIntoView for jsdom | ||
| // eslint-disable-next-line @typescript-eslint/no-empty-function | ||
| global.HTMLElement.prototype.scrollIntoView = function () {} | ||
|
|
||
| // Shared mocks for modal context | ||
| const mockOnBonusProductModalOpen = jest.fn() | ||
| const mockAddBonusProducts = jest.fn() | ||
| const mockOnAddToCartModalOpen = jest.fn() | ||
|
|
||
| // Mocks must be at the very top before any imports | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-bonus-product-search', () => ({ | ||
| __esModule: true, | ||
| useBonusProductSearch: jest.fn() | ||
| })) | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ | ||
| useCurrentCustomer: () => ({ | ||
| data: { | ||
| authType: 'registered', | ||
| isRegistered: true | ||
| } | ||
| }) | ||
| })) | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-currency', () => ({ | ||
| useCurrency: () => ({ | ||
| currency: 'USD' | ||
| }) | ||
| })) | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal', () => ({ | ||
| useAddToCartModalContext: () => ({ | ||
| isOpen: false, | ||
| onOpen: mockOnAddToCartModalOpen, | ||
| onClose: jest.fn() | ||
| }), | ||
| AddToCartModalProvider: ({children}) => children | ||
| })) | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-bonus-product-modal', () => ({ | ||
| useBonusProductModalContext: () => ({ | ||
| isOpen: false, | ||
| onOpen: mockOnBonusProductModalOpen, | ||
| onClose: jest.fn(), | ||
| bonusProducts: [], | ||
| addBonusProducts: mockAddBonusProducts | ||
| }), | ||
| BonusProductModalProvider: ({children}) => children | ||
| })) | ||
| jest.mock('@salesforce/retail-react-app/app/hooks/use-toast', () => ({ | ||
| useToast: () => jest.fn() | ||
| })) | ||
|
|
||
| describe('ProductView Bonus Product Integration', () => { | ||
| beforeEach(() => { | ||
| mockOnBonusProductModalOpen.mockReset() | ||
| mockAddBonusProducts.mockReset() | ||
| mockOnAddToCartModalOpen.mockReset() | ||
|
|
||
| // Patch useDerivedProduct for these tests | ||
| jest.spyOn(useDerivedProductModule, 'useDerivedProduct').mockImplementation(() => ({ | ||
| showLoading: false, | ||
| showInventoryMessage: false, | ||
| inventoryMessage: '', | ||
| quantity: 1, | ||
| minOrderQuantity: 1, | ||
| setQuantity: jest.fn(), | ||
| variant: { | ||
| productId: 'p1-38', | ||
| orderable: true, | ||
| variationValues: {size: '38'} | ||
| }, | ||
| variationParams: {size: '38'}, | ||
| variationAttributes: [ | ||
| { | ||
| id: 'size', | ||
| name: 'Size', | ||
| selectedValue: {name: '38', value: '38'}, | ||
| values: [ | ||
| {name: '38', value: '38', orderable: true}, | ||
| {name: '39', value: '39', orderable: true} | ||
| ] | ||
| } | ||
| ], | ||
| stockLevel: 10, | ||
| stepQuantity: 1, | ||
| isOutOfStock: false, | ||
| unfulfillable: false | ||
| })) | ||
| }) | ||
|
|
||
| const product = { | ||
| id: 'p1', | ||
| name: 'Test Product', | ||
| type: {variant: true}, | ||
| variationAttributes: [ | ||
| { | ||
| id: 'size', | ||
| name: 'Size', | ||
| values: [ | ||
| {name: '38', value: '38', orderable: true}, | ||
| {name: '39', value: '39', orderable: true} | ||
| ] | ||
| } | ||
| ], | ||
| variants: [ | ||
| { | ||
| productId: 'p1-38', | ||
| orderable: true, | ||
| variationValues: {size: '38'} | ||
| }, | ||
| { | ||
| productId: 'p1-39', | ||
| orderable: true, | ||
| variationValues: {size: '39'} | ||
| } | ||
| ], | ||
| variationValues: {size: '38'} | ||
| } | ||
|
|
||
| test('calls bonus product modal open when addToCart returns rule-based bonusDiscountLineItems', async () => { | ||
| const user = userEvent.setup() | ||
|
|
||
| // Mock addToCart to return a rule-based bonus | ||
| const mockAddToCart = jest.fn().mockResolvedValue({ | ||
| productSelectionValues: [{id: 'item1'}], | ||
| bonusDiscountLineItems: [{id: 'bonus1', promotionId: 'promo123'}] | ||
| }) | ||
|
|
||
| // Mock useBonusProductSearch to return a hit for promo123 | ||
| useBonusProductSearch.mockImplementation((promotionId) => { | ||
| if (promotionId === 'promo123') { | ||
| return { | ||
| data: { | ||
| hits: [ | ||
| { | ||
| productId: 'prod1', | ||
| productName: 'Bonus Product 1', | ||
| c_productUrl: '/product/prod1' | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| return {data: null} | ||
| }) | ||
|
|
||
| renderWithProviders(<ProductView product={product} addToCart={mockAddToCart} />) | ||
|
|
||
| // Select the first size swatch | ||
| const sizeSwatch = screen.getByRole('radio', {name: /38/i}) | ||
| await user.click(sizeSwatch) | ||
|
|
||
| // Click Add to Cart | ||
| const addToCartButton = screen.getAllByText(/add to cart/i)[0] | ||
| await user.click(addToCartButton) | ||
|
|
||
| // Wait for modal open | ||
| await waitFor(() => { | ||
| expect(mockOnBonusProductModalOpen).toHaveBeenCalled() | ||
| const call = mockOnBonusProductModalOpen.mock.calls[0][0] | ||
| expect(call.newBonusItems).toEqual( | ||
| expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| bonusProducts: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| productId: 'prod1', | ||
| productName: 'Bonus Product 1' | ||
| }) | ||
| ]) | ||
| }) | ||
| ]) | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| test('calls add to cart modal open when no bonusDiscountLineItems', async () => { | ||
| const user = userEvent.setup() | ||
|
|
||
| const mockAddToCart = jest.fn().mockResolvedValue({productSelectionValues: [{id: 'item1'}]}) | ||
| useBonusProductSearch.mockImplementation(() => ({data: null})) | ||
|
|
||
| renderWithProviders(<ProductView product={product} addToCart={mockAddToCart} />) | ||
|
|
||
| // Select the first size swatch | ||
| const sizeSwatch = screen.getByRole('radio', {name: /38/i}) | ||
| await user.click(sizeSwatch) | ||
|
|
||
| const addToCartButton = screen.getAllByText(/add to cart/i)[0] | ||
| await user.click(addToCartButton) | ||
|
|
||
| await waitFor(() => { | ||
| expect(mockOnAddToCartModalOpen).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import { | |
| import {useCurrency, useDerivedProduct} from '@salesforce/retail-react-app/app/hooks' | ||
| import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal' | ||
| import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal' | ||
| import {useBonusProductSearch} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-search' | ||
|
|
||
| // project components | ||
| import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery' | ||
|
|
@@ -141,6 +142,64 @@ const ProductView = forwardRef( | |
| } = useBonusProductModalContext() | ||
| const theme = useTheme() | ||
| const [showOptionsMessage, toggleShowOptionsMessage] = useState(false) | ||
| const [promotionIdToSearch, setPromotionIdToSearch] = useState(null) | ||
| const {data: bonusProductSearchResult} = useBonusProductSearch(promotionIdToSearch) | ||
|
|
||
| // State to track all promotion IDs and their results | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The business logic here seems to be isolated to bonus products only. Is it possibel to extract it out into a hook to reduce code in this component? E.g useBonusProduct() |
||
| const [pendingPromotionIds, setPendingPromotionIds] = useState([]) | ||
| const [bonusItemsForModal, setBonusItemsForModal] = useState(null) | ||
| const ruleBasedPromotionsRef = useRef([]) | ||
|
|
||
| // Effect to handle multiple promotions sequentially | ||
| useEffect(() => { | ||
| if (bonusProductSearchResult?.hits?.length > 0 && promotionIdToSearch) { | ||
| // Format the bonus products for current promotion | ||
| let formattedBonusProducts = bonusProductSearchResult.hits.map((bonusProduct) => { | ||
| return { | ||
| productId: bonusProduct.productId, | ||
| productName: bonusProduct.productName, | ||
| c_productUrl: bonusProduct.c_productUrl | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is the data structure we are dealing for bonus product. You can avoid doing this here by taking advantage of react-query https://tanstack.com/query/v4/docs/framework/react/reference/useQuery P/s: You can definitely create promotion id map from here as well if that is more convenient and reduce the amount of data transformation logic in the handleAddToCart |
||
| }) | ||
|
|
||
| // Add current promotion result to ref | ||
| const currentPromotionResult = { | ||
| bonusProducts: formattedBonusProducts, | ||
| id: bonusItemsForModal?.promotionIdToIdMap?.[promotionIdToSearch], | ||
| maxBonusItems: | ||
| bonusItemsForModal?.promotionIdToMaxBonusItemsMap?.[promotionIdToSearch] | ||
| } | ||
| ruleBasedPromotionsRef.current.push(currentPromotionResult) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For React ref, we are re-assigning current |
||
|
|
||
| // Move to next promotion if any pending | ||
| if (pendingPromotionIds.length > 0) { | ||
| const nextPromotionId = pendingPromotionIds[0] | ||
| setPendingPromotionIds((prev) => prev.slice(1)) | ||
| setPromotionIdToSearch(nextPromotionId) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we filter out any pending promotion from |
||
| } else { | ||
| // All promotions processed, now open the modal | ||
| const bonusProductsToShow = [ | ||
| ...ruleBasedPromotionsRef.current, | ||
| ...bonusItemsForModal.listBasedBonusProducts | ||
| ] | ||
|
|
||
| addBonusProducts(bonusItemsForModal.newBonusItems) | ||
| onBonusProductModalOpen({ | ||
| newBonusItems: bonusProductsToShow, | ||
| openAddToCartModalIfNeeded: true, | ||
| product: bonusItemsForModal.product, | ||
| itemsAdded: bonusItemsForModal.itemsAdded, | ||
| selectedQuantity: bonusItemsForModal.selectedQuantity | ||
| }) | ||
|
|
||
| // Clear state | ||
| setBonusItemsForModal(null) | ||
| setPromotionIdToSearch(null) | ||
| ruleBasedPromotionsRef.current = [] | ||
| } | ||
| } | ||
| }, [bonusProductSearchResult]) | ||
|
|
||
| const { | ||
| showLoading, | ||
| showInventoryMessage, | ||
|
|
@@ -305,16 +364,59 @@ const ProductView = forwardRef( | |
| if (isValidResponse) { | ||
| // Show bonus product modal first if there are bonus items | ||
| if (newBonusItems?.length > 0) { | ||
| // Update bonusProducts list with the new bonus items | ||
| addBonusProducts(newBonusItems) | ||
| onBonusProductModalOpen({ | ||
| newBonusItems, | ||
| allBonusItems: addToCartResponse.bonusDiscountLineItems, | ||
| openAddToCartModalIfNeeded: true, | ||
| product, | ||
| itemsAdded, | ||
| selectedQuantity: quantity | ||
| }) | ||
| let listBasedBonusProducts = newBonusItems.filter( | ||
| (item) => item.bonusProducts | ||
| ) | ||
| // Collect all promotion IDs for rule-based promotions | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's clean up comment here unless it is necessary. Too much comments accumulated over time can affect bundle size if every line of code has one comment explaining what it does |
||
| const ruleBasedPromotionIds = newBonusItems | ||
| .filter((item) => !item.bonusProducts) // Rule-based promotions don't have bonusProducts | ||
| .map((item) => item.promotionId) | ||
| .filter(Boolean) | ||
|
|
||
| //create maps of promotionId to id and maxBonusItems for rule based promotions | ||
| const {promotionIdToIdMap, promotionIdToMaxBonusItemsMap} = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we reduce this logic here if we take advantage of useProductSearch hook select for data transformation? |
||
| newBonusItems | ||
| .filter((item) => !item.bonusProducts) | ||
| .reduce( | ||
| (acc, item) => { | ||
| acc.promotionIdToIdMap[item.promotionId] = item.id | ||
| acc.promotionIdToMaxBonusItemsMap[item.promotionId] = | ||
| item.maxBonusItems | ||
| return acc | ||
| }, | ||
| { | ||
| promotionIdToIdMap: {}, | ||
| promotionIdToMaxBonusItemsMap: {} | ||
| } | ||
| ) | ||
|
|
||
| // Start sequential processing if we have promotion IDs | ||
| if (ruleBasedPromotionIds.length > 0) { | ||
| // Store bonus items for processing in useEffect | ||
| setBonusItemsForModal({ | ||
| newBonusItems, | ||
| product, | ||
| itemsAdded, | ||
| selectedQuantity: quantity, | ||
| promotionIdToIdMap, | ||
| promotionIdToMaxBonusItemsMap, | ||
| listBasedBonusProducts | ||
| }) | ||
|
|
||
| // Set first promotion ID and queue the rest | ||
| setPromotionIdToSearch(ruleBasedPromotionIds[0]) | ||
| setPendingPromotionIds(ruleBasedPromotionIds.slice(1)) | ||
| } else { | ||
| // No rule-based promotions, just show list-based ones immediately | ||
| addBonusProducts(newBonusItems) | ||
| onBonusProductModalOpen({ | ||
| newBonusItems: listBasedBonusProducts, | ||
| openAddToCartModalIfNeeded: true, | ||
| product, | ||
| itemsAdded, | ||
| selectedQuantity: quantity | ||
| }) | ||
| } | ||
| } else { | ||
| // If no bonus items, just show add to cart modal | ||
| onAddToCartModalOpen({ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.