Skip to content
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Show Automatic Bonus Products on Cart Page [#2704](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2704)
- Support Standard Products [2697](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2697)
- Fix passwordless race conditions in form submission [#2758](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2758)
- Add cancel button and modal [#2775](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2775)

## v6.1.0 (May 22, 2025)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2023, 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 PropTypes from 'prop-types'
import {FormattedMessage} from 'react-intl'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Text
} from '@chakra-ui/react'

/**
* A Modal for requesting order cancellation
*/
const CancelOrderModal = ({isOpen, onClose, order, onRequestCancellation, ...props}) => {
const handleCancelOrder = () => {
if (!onRequestCancellation) {
console.error('Cancel order modal: onRequestCancellation is required')
return
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can safely remove this check now that it's marked as isRequired.


onRequestCancellation(order)
onClose()
}

return (
<Modal isOpen={isOpen} onClose={onClose} isCentered {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
<FormattedMessage
defaultMessage="Request Cancellation"
id="cancel_order_modal.title.request_cancellation"
/>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{/* TODO: Add order details here W-18998712 */}
<Text>
<FormattedMessage
defaultMessage="This is a blank modal for canceling the order."
id="cancel_order_modal.content.placeholder"
/>
</Text>
</ModalBody>
<ModalFooter>
<Button variant="solid" onClick={handleCancelOrder}>
<FormattedMessage
defaultMessage="Request Cancellation"
id="cancel_order_modal.button.confirm"
/>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

CancelOrderModal.propTypes = {
/**
* Whether the modal is open
*/
isOpen: PropTypes.bool.isRequired,
/**
* Callback to close the modal
*/
onClose: PropTypes.func.isRequired,
/**
* Order object for cancellation
*/
order: PropTypes.object.isRequired,
/**
* Callback when user confirms cancellation request
*/
onRequestCancellation: PropTypes.func.isRequired
}

export default CancelOrderModal
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* Copyright (c) 2023, 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 CancelOrderModal from '@salesforce/retail-react-app/app/components/cancel-order-modal/index'
import {Box, useDisclosure} from '@salesforce/retail-react-app/app/components/shared/ui'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import userEvent from '@testing-library/user-event'
import {screen} from '@testing-library/react'

const mockOrder = {
orderNo: '00028011',
status: 'completed',
creationDate: '2023-02-15T10:15:00.000Z',
currency: 'USD',
productItems: [
{
productId: 'test-product-1',
quantity: 2,
name: 'Test Product 1'
}
]
}

const MockedComponent = (props) => {
const modalProps = useDisclosure()

return (
<Box>
<button onClick={modalProps.onOpen}>Open Cancel Modal</button>
<CancelOrderModal {...modalProps} {...props} />
</Box>
)
}

afterEach(() => {
jest.resetModules()
})

test('renders cancel order modal when open', async () => {
renderWithProviders(<CancelOrderModal isOpen={true} onClose={jest.fn()} order={mockOrder} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getAllByText(/request cancellation/i)).toHaveLength(2)
})

test('does not render modal when closed', () => {
renderWithProviders(<MockedComponent order={mockOrder} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText(/request cancellation/i)).not.toBeInTheDocument()
})

test('renders modal with correct header text', async () => {
renderWithProviders(<CancelOrderModal isOpen={true} onClose={jest.fn()} order={mockOrder} />)
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(screen.getAllByText(/request cancellation/i)).toHaveLength(2)
})

test('renders modal with correct body content', async () => {
const user = userEvent.setup()
renderWithProviders(<MockedComponent order={mockOrder} />)

// Open the modal
const trigger = screen.getByText(/open cancel modal/i)
await user.click(trigger)

// Check body content
expect(screen.getByText(/this is a blank modal for canceling the order/i)).toBeInTheDocument()
})

test('renders request cancellation button', async () => {
const user = userEvent.setup()
renderWithProviders(<MockedComponent order={mockOrder} />)

// Open the modal
const trigger = screen.getByText(/open cancel modal/i)
await user.click(trigger)

// Check for request cancellation button
const requestButton = screen.getByRole('button', {name: /request cancellation/i})
expect(requestButton).toBeInTheDocument()
})

test('renders close button (X)', async () => {
const user = userEvent.setup()
renderWithProviders(<MockedComponent order={mockOrder} />)

// Open the modal
const trigger = screen.getByText(/open cancel modal/i)
await user.click(trigger)

// Check for close button
const closeButton = screen.getByRole('button', {name: /close/i})
expect(closeButton).toBeInTheDocument()
})

test('calls onClose when close button is clicked', async () => {
const user = userEvent.setup()
const onClose = jest.fn()
renderWithProviders(<MockedComponent order={mockOrder} onClose={onClose} />)

// Open the modal
renderWithProviders(<CancelOrderModal isOpen={true} onClose={onClose} order={mockOrder} />)

// Click close button
const closeButton = screen.getByRole('button', {name: /close/i})
await user.click(closeButton)

expect(onClose).toHaveBeenCalledTimes(1)
})

test('calls onRequestCancellation when request cancellation button is clicked', async () => {
const user = userEvent.setup()
const onRequestCancellation = jest.fn()
const onClose = jest.fn()

renderWithProviders(
<CancelOrderModal
isOpen={true}
onClose={onClose}
order={mockOrder}
onRequestCancellation={onRequestCancellation}
/>
)

// Click request cancellation button
const requestButton = screen.getByRole('button', {name: /request cancellation/i})
await user.click(requestButton)

expect(onRequestCancellation).toHaveBeenCalledTimes(1)
expect(onRequestCancellation).toHaveBeenCalledWith(mockOrder)
})

test('calls onClose when request cancellation button is clicked', async () => {
const user = userEvent.setup()
const onRequestCancellation = jest.fn()
const onClose = jest.fn()

renderWithProviders(
<CancelOrderModal
isOpen={true}
onClose={onClose}
order={mockOrder}
onRequestCancellation={onRequestCancellation}
/>
)

// Click request cancellation button
const requestButton = screen.getByRole('button', {name: /request cancellation/i})
await user.click(requestButton)

expect(onClose).toHaveBeenCalledTimes(1)
})

test('component works correctly with all required props provided', async () => {
const onRequestCancellation = jest.fn()
const onClose = jest.fn()

renderWithProviders(
<CancelOrderModal
isOpen={true}
onClose={onClose}
order={mockOrder}
onRequestCancellation={onRequestCancellation}
/>
)

expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('button', {name: /request cancellation/i})).toBeInTheDocument()
})

test('onRequestCancellation is called with correct order parameter', async () => {
const user = userEvent.setup()
const onRequestCancellation = jest.fn()
const onClose = jest.fn()

renderWithProviders(
<CancelOrderModal
isOpen={true}
onClose={onClose}
order={mockOrder}
onRequestCancellation={onRequestCancellation}
/>
)

const requestButton = screen.getByRole('button', {name: /request cancellation/i})
await user.click(requestButton)

// Verify the callback is called with the exact order object
expect(onRequestCancellation).toHaveBeenCalledWith(mockOrder)
expect(onRequestCancellation).toHaveBeenCalledTimes(1)
})

test('both onRequestCancellation and onClose are called in correct order', async () => {
const user = userEvent.setup()
const onRequestCancellation = jest.fn()
const onClose = jest.fn()
const callOrder = []

// Track call order
onRequestCancellation.mockImplementation(() => callOrder.push('onRequestCancellation'))
onClose.mockImplementation(() => callOrder.push('onClose'))

renderWithProviders(
<CancelOrderModal
isOpen={true}
onClose={onClose}
order={mockOrder}
onRequestCancellation={onRequestCancellation}
/>
)

const requestButton = screen.getByRole('button', {name: /request cancellation/i})
await user.click(requestButton)

// Verify both functions are called and in the correct order
expect(callOrder).toEqual(['onRequestCancellation', 'onClose'])
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import {
Divider,
Grid,
SimpleGrid,
Skeleton
} from '@salesforce/retail-react-app/app/components/shared/ui'
Skeleton,
useDisclosure
} from '@chakra-ui/react'
import {getCreditCardIcon} from '@salesforce/retail-react-app/app/utils/cc-utils'
import {useOrder, useProducts} from '@salesforce/commerce-sdk-react'
import Link from '@salesforce/retail-react-app/app/components/link'
Expand All @@ -31,6 +32,7 @@ import CartItemVariantImage from '@salesforce/retail-react-app/app/components/it
import CartItemVariantName from '@salesforce/retail-react-app/app/components/item-variant/item-name'
import CartItemVariantAttributes from '@salesforce/retail-react-app/app/components/item-variant/item-attributes'
import CartItemVariantPrice from '@salesforce/retail-react-app/app/components/item-variant/item-price'
import CancelOrderModal from '@salesforce/retail-react-app/app/components/cancel-order-modal'
import PropTypes from 'prop-types'
const onClient = typeof window !== 'undefined'

Expand Down Expand Up @@ -110,6 +112,12 @@ const AccountOrderDetail = () => {
const history = useHistory()
const {formatMessage, formatDate} = useIntl()

const {
isOpen: isCancelModalOpen,
onOpen: onCancelModalOpen,
onClose: onCancelModalClose
} = useDisclosure()

const {data: order, isLoading: isOrderLoading} = useOrder(
{
parameters: {orderNo: params.orderNo}
Expand Down Expand Up @@ -156,12 +164,21 @@ const AccountOrderDetail = () => {
</Box>

<Stack spacing={[1, 2]}>
<Heading as="h1" fontSize={['lg', '2xl']} tabIndex="0" ref={headingRef}>
<FormattedMessage
defaultMessage="Order Details"
id="account_order_detail.title.order_details"
/>
</Heading>
<Flex justify="space-between" align="center">
<Heading as="h1" fontSize={['lg', '2xl']} tabIndex="0" ref={headingRef}>
<FormattedMessage
defaultMessage="Order Details"
id="account_order_detail.title.order_details"
/>
</Heading>
{/* TODO: addcancel order elligibility logic */}
<Button variant="link" size="sm" onClick={onCancelModalOpen}>
Copy link
Contributor

Choose a reason for hiding this comment

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

Not all orders are cancellable, right? How do we make sure this doesn't show up at all times?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I haven't added that logic bit yet. I recently learned that SOM has a field called QuantityAvailableToCancel on each orderItemSummary. I will incorporate that in the next iteration, and add a todo comment to address that here if that works?

Copy link
Contributor

Choose a reason for hiding this comment

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

Other than quantity (why would quantity matter when canceling an order?), I'd imagine the order status also matters? I am good with doing it in the next iteration. Thanks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question, I'll follow up on that

<FormattedMessage
defaultMessage="Cancel order"
id="account_order_detail.button.cancel_order"
/>
</Button>
</Flex>

{!isLoading ? (
<Stack
Expand Down Expand Up @@ -398,6 +415,16 @@ const AccountOrderDetail = () => {
)}
</Stack>
</Stack>

<CancelOrderModal
isOpen={isCancelModalOpen}
onClose={onCancelModalClose}
order={order}
onRequestCancellation={(order) => {
// TODO: Add cancellation logic here
console.log('Requesting cancellation for order:', order?.orderNo)
}}
/>
</Stack>
)
}
Expand Down
Loading
Loading