Skip to content
Merged
457 changes: 242 additions & 215 deletions packages/template-retail-react-app/app/pages/product-list/index.jsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,15 @@ test('should filter out refinements in the disallow list', async () => {
expect(screen.getByText('Price')).toBeInTheDocument()
})
})
test('should display Store Inventory Filter component', async () => {
window.history.pushState({}, 'ProductList', '/uk/en-GB/category/mens-clothing-jackets')
renderWithProviders(<MockedComponent />, {
wrapperProps: {siteAlias: 'uk', locale: {id: 'en-GB'}}
})

// Wait for the page to load
expect(await screen.findByTestId('sf-product-list-page')).toBeInTheDocument()

// Check that the Store Inventory Filter component is present
expect(await screen.findByTestId('sf-store-inventory-filter')).toBeInTheDocument()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025, Salesforce, 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, {useEffect, useState} from 'react'
import {useIntl, FormattedMessage} from 'react-intl'
import PropTypes from 'prop-types'
import {
Heading,
Checkbox,
Stack,
Text,
useDisclosure
} from '@salesforce/retail-react-app/app/components/shared/ui'
import StoreLocatorModal from '@salesforce/retail-react-app/app/components/store-locator-modal'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {getSelectedStoreData} from '@salesforce/retail-react-app/app/utils/store-locator-utils'

const StoreInventoryFilter = ({toggleFilter, selectedFilters}) => {
const [selectedStore, setSelectedStore] = useState(null)
const {isOpen, onOpen, onClose} = useDisclosure()
const {site} = useMultiSite()
const {formatMessage} = useIntl()

const isChecked = selectedFilters?.ilids !== undefined

useEffect(() => {
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 this is the common piece, which we will handle in refactor story

const storeInfo = getSelectedStoreData(site?.id)

if (storeInfo?.name && storeInfo?.inventoryId) {
setSelectedStore(storeInfo)
}
}, [site?.id])

const handleCheckboxChange = (e) => {
// If no store is selected or no inventoryId, open store locator
if (!selectedStore?.inventoryId) {
e.preventDefault()
onOpen()
return
}

// Normal checkbox behavior when store is selected
const checked = e.target.checked
toggleFilter({value: selectedStore.inventoryId}, 'ilids', !checked, false)
}

// Always open store locator when store name text is clicked
const handleStoreNameClick = (e) => {
e.stopPropagation()
e.preventDefault()
onOpen()
}

const handleStoreLocatorClose = () => {
const storeInfo = getSelectedStoreData(site?.id)

if (storeInfo?.name && storeInfo?.inventoryId) {
setSelectedStore(storeInfo)

// Apply the filter when a store is selected from the locator
toggleFilter({value: storeInfo.inventoryId}, 'ilids', false, false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we have a future WI, where we dont add the ilids refinement in the URL params?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}

onClose()
}

const storeLinkText =
selectedStore?.name ||
formatMessage({
defaultMessage: 'Select Store',
id: 'store_inventory_filter.action.select_store'
})

return (
<>
<Stack
spacing={4}
paddingTop={0}
paddingBottom={6}
borderBottom="1px solid gray.200"
data-testid="sf-store-inventory-filter"
>
<Heading as="h2" fontSize="md" fontWeight={600}>
<FormattedMessage
defaultMessage="Shop by Availability"
id="store_inventory_filter.heading.shop_by_availability"
/>
</Heading>
<Checkbox
isChecked={isChecked}
onChange={handleCheckboxChange}
aria-label={formatMessage(
{
defaultMessage: 'Filter products by store availability at {storeName}',
id: 'store_inventory_filter.checkbox.assistive_msg'
},
{
storeName: storeLinkText
}
)}
>
<FormattedMessage
defaultMessage="In stock at {storeLink}"
id="store_inventory_filter.checkbox.label"
values={{
storeLink: (
<Text
as="span"
textDecoration="underline"
cursor="pointer"
onClick={handleStoreNameClick}
_hover={{color: 'blue.500'}}
aria-label={formatMessage(
{
defaultMessage: 'Open store locator to {action}',
id: 'store_inventory_filter.link.assistive_msg'
},
{
action: selectedStore?.name
? formatMessage({
defaultMessage: 'change store',
id: 'store_inventory_filter.action.change_store'
})
: formatMessage({
defaultMessage: 'select a store',
id: 'store_inventory_filter.action.select_store_link'
})
}
)}
>
{storeLinkText}
</Text>
)
}}
/>
</Checkbox>
</Stack>

<StoreLocatorModal isOpen={isOpen} onClose={handleStoreLocatorClose} />
</>
)
}

StoreInventoryFilter.propTypes = {
toggleFilter: PropTypes.func.isRequired,
selectedFilters: PropTypes.object
}

export default StoreInventoryFilter
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025, Salesforce, 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 {screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import StoreInventoryFilter from '@salesforce/retail-react-app/app/pages/product-list/partials/inventory-filter'
import {getSelectedStoreData} from '@salesforce/retail-react-app/app/utils/store-locator-utils'

jest.mock('@salesforce/retail-react-app/app/utils/store-locator-utils', () => ({
getSelectedStoreData: jest.fn()
}))

jest.mock('@salesforce/retail-react-app/app/components/store-locator-modal', () => {
// eslint-disable-next-line react/prop-types
Copy link
Contributor

Choose a reason for hiding this comment

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

Why was there a need to disable the linting check?

Copy link
Contributor Author

@shauryemahajanSF shauryemahajanSF Jun 11, 2025

Choose a reason for hiding this comment

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

Without this we get linting errors around prop validation, but we don't need to define props here since its just a unit test

function MockStoreLocatorModal({isOpen, onClose}) {
return isOpen ? (
<div data-testid="store-locator-modal">
<button onClick={onClose}>Close Modal</button>
</div>
) : null
}

return MockStoreLocatorModal
})

const mockToggleFilter = jest.fn()

const defaultProps = {
toggleFilter: mockToggleFilter,
selectedFilters: {}
}

const mockStoreData = {
id: 'store-123',
name: 'Test Store Location',
inventoryId: 'inv-456'
}

describe('StoreInventoryFilter', () => {
beforeEach(() => {
jest.clearAllMocks()
localStorage.clear()
getSelectedStoreData.mockReturnValue(null)
})

test('renders component with default state', async () => {
renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

expect(screen.getByTestId('sf-store-inventory-filter')).toBeInTheDocument()
expect(screen.getByText('Shop by Availability')).toBeInTheDocument()
expect(screen.getByText('Select Store')).toBeInTheDocument()
expect(screen.getByRole('checkbox')).not.toBeChecked()
})

test('displays selected store name when store data exists', async () => {
getSelectedStoreData.mockReturnValue(mockStoreData)

renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

await waitFor(() => {
expect(screen.getByText('Test Store Location')).toBeInTheDocument()
})
})

test('shows checkbox as checked when ilids filter is selected', () => {
const propsWithFilter = {
...defaultProps,
selectedFilters: {ilids: 'inv-456'}
}

renderWithProviders(<StoreInventoryFilter {...propsWithFilter} />)

expect(screen.getByRole('checkbox')).toBeChecked()
})

test('opens store locator modal when checkbox clicked without selected store', async () => {
const user = userEvent.setup()
renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

const checkbox = screen.getByRole('checkbox')
await user.click(checkbox)

expect(screen.getByTestId('store-locator-modal')).toBeInTheDocument()
})

test('opens store locator modal when store name is clicked', async () => {
const user = userEvent.setup()
getSelectedStoreData.mockReturnValue(mockStoreData)

renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

await waitFor(() => {
expect(screen.getByText('Test Store Location')).toBeInTheDocument()
})

await user.click(screen.getByText('Test Store Location'))

expect(screen.getByTestId('store-locator-modal')).toBeInTheDocument()
})

test('calls toggleFilter when checkbox is changed with selected store', async () => {
const user = userEvent.setup()
getSelectedStoreData.mockReturnValue(mockStoreData)

renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

await waitFor(() => {
expect(screen.getByText('Test Store Location')).toBeInTheDocument()
})

const checkbox = screen.getByRole('checkbox')
await user.click(checkbox)

expect(mockToggleFilter).toHaveBeenCalledWith({value: 'inv-456'}, 'ilids', false, false)
})

test('calls toggleFilter to remove filter when checkbox is unchecked', async () => {
const user = userEvent.setup()
getSelectedStoreData.mockReturnValue(mockStoreData)

const propsWithFilter = {
...defaultProps,
selectedFilters: {ilids: 'inv-456'}
}

renderWithProviders(<StoreInventoryFilter {...propsWithFilter} />)

await waitFor(() => {
expect(screen.getByText('Test Store Location')).toBeInTheDocument()
})

const checkbox = screen.getByRole('checkbox')
expect(checkbox).toBeChecked()

await user.click(checkbox)

expect(mockToggleFilter).toHaveBeenCalledWith({value: 'inv-456'}, 'ilids', true, false)
})

test('applies filter when store is selected from locator modal', async () => {
const user = userEvent.setup()
// Initially no store
getSelectedStoreData.mockReturnValue(null)

renderWithProviders(<StoreInventoryFilter {...defaultProps} />)

// Click checkbox to open modal
await user.click(screen.getByRole('checkbox'))
expect(screen.getByTestId('store-locator-modal')).toBeInTheDocument()

// Simulate store selection by changing the mock return value
getSelectedStoreData.mockReturnValue(mockStoreData)

// Close modal
await user.click(screen.getByText('Close Modal'))

// Should have called toggleFilter to apply the filter
expect(mockToggleFilter).toHaveBeenCalledWith({value: 'inv-456'}, 'ilids', false, false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,46 @@ import PropTypes from 'prop-types'
import {Box, Button, Wrap, WrapItem} from '@salesforce/retail-react-app/app/components/shared/ui'
import {CloseIcon} from '@salesforce/retail-react-app/app/components/icons'
import {REMOVE_FILTER} from '@salesforce/retail-react-app/app/pages/product-list/partials/refinements-utils'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {getSelectedStoreData} from '@salesforce/retail-react-app/app/utils/store-locator-utils'

const SelectedRefinements = ({toggleFilter, selectedFilterValues, filters, handleReset}) => {
const {formatMessage} = useIntl()
const {site} = useMultiSite()
const priceFilterValues = filters?.find((filter) => filter.attributeId === 'price')

let selectedFilters = []
for (const key in selectedFilterValues) {
const filters = selectedFilterValues[key].split('|')
filters?.forEach((filter) => {
let uiLabel = filter

if (key === 'price') {
uiLabel =
priceFilterValues?.values?.find((priceFilter) => priceFilter.value === filter)
?.label || filter
} else if (key === 'ilids') {
// Fallback text for in stock selected filter
uiLabel = formatMessage({
id: 'selected_refinements.filter.in_stock',
defaultMessage: 'In Stock'
})

const storeInfo = getSelectedStoreData(site?.id)
if (storeInfo?.inventoryId === filter && storeInfo?.name) {
uiLabel = formatMessage(
{
id: 'store_inventory_filter.checkbox.label',
defaultMessage: 'In Stock at {storeName}'
},
{
storeName: storeInfo.name
}
)
}
}

const selected = {
uiLabel:
key === 'price'
? priceFilterValues?.values?.find(
(priceFilter) => priceFilter.value === filter
)?.label
: filter,
uiLabel,
value: key,
apiLabel: filter
}
Expand Down
Loading
Loading