From 143e37088c0e091aceb4d666a7cf6843b9721d94 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Wed, 17 Jun 2026 16:04:16 -0400 Subject: [PATCH 01/14] @W-22821837@ Add return-order-modal component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the item-level return flow per the storefront-next 2026-06-17 designs. Renders as a centered Chakra Modal at md+ and a bottom-sheet Drawer on base (mobile), with returnable items as collapsed rows that expand into a Quantity stepper + Reason + + + ) +} +QuantityField.propTypes = { + value: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, + ariaLabel: PropTypes.string.isRequired, + id: PropTypes.string.isRequired +} + +const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onReasonChange}) => { + const intl = useIntl() + const checkboxId = useId() + const quantityId = useId() + const reasonId = useId() + const max = item.omsData?.quantityAvailableToReturn ?? 1 + const variation = formatVariationSummary(item) + const displayName = variation ? `${item.productName} — ${variation}` : item.productName || '' + + return ( + + + onToggle(e.target.checked)} + aria-label={intl.formatMessage(messages.itemCheckboxLabel, { + name: displayName, + count: max + })} + /> + + + {displayName} + + + + + {row?.checked && ( + + + + + + + + + + + + + + + )} + + + + ) +} +ReturnableItemRow.propTypes = { + item: PropTypes.object.isRequired, + row: PropTypes.object, + reasons: PropTypes.array.isRequired, + onToggle: PropTypes.func.isRequired, + onQuantityChange: PropTypes.func.isRequired, + onReasonChange: PropTypes.func.isRequired +} + +/** + * Returns the OMS reason code marked `default: true`, or `undefined` if no + * default exists (defensive — the API guarantees one). + */ +const findDefaultReasonCode = (reasons = []) => reasons.find((r) => r.default)?.reason + +const isSelectionValid = (selection, returnableItems) => { + const selectedRows = Object.entries(selection || {}).filter(([, row]) => row?.checked) + if (selectedRows.length === 0) return false + return selectedRows.every(([itemId, row]) => { + const item = returnableItems.find((i) => i.itemId === itemId) + const max = item?.omsData?.quantityAvailableToReturn ?? 0 + const qty = Number(row.quantity) + return Number.isFinite(qty) && qty >= 1 && qty <= max && !!row.reasonCode + }) +} + +/** + * Step 1 of the return flow. Renders as a centered Modal at `md+` and a + * bottom-sheet Drawer on `base` (per design). Selection state lives in the + * parent (`order-detail.jsx`) so the wrapper swap on viewport resize doesn't + * reset what the shopper has chosen. + * + * The follow-up review step (W-22821838) is a sibling view inside the same + * modal stack. This component invokes `onReview(payload)` with the API-shaped + * `productItems` array when the shopper clicks **Review return**. + */ +const ReturnOrderModal = ({ + isOpen, + onClose, + order, + returnableItems, + selection, + onSelectionChange, + onReview +}) => { + const isMobile = useBreakpointValue({base: true, md: false}) + + const reviewQuery = useOmsMetaData( + {}, + { + enabled: isOpen && onClient + } + ) + const reasons = reviewQuery.data?.returnReasonCodes || [] + const defaultReasonCode = useMemo(() => findDefaultReasonCode(reasons), [reasons]) + + const updateRow = useCallback( + (itemId, patch) => { + const next = {...(selection || {})} + next[itemId] = {...(next[itemId] || {}), ...patch} + onSelectionChange(next) + }, + [selection, onSelectionChange] + ) + + const handleToggle = useCallback( + (item, checked) => { + const itemId = item.itemId + const existing = selection?.[itemId] + updateRow(itemId, { + checked, + quantity: existing?.quantity ?? 1, + reasonCode: existing?.reasonCode || (checked ? defaultReasonCode : undefined) + }) + }, + [selection, updateRow, defaultReasonCode] + ) + + const handleQuantityChange = useCallback( + (itemId, qty) => { + updateRow(itemId, {quantity: qty}) + }, + [updateRow] + ) + + const handleReasonChange = useCallback( + (itemId, reasonCode) => { + updateRow(itemId, {reasonCode}) + }, + [updateRow] + ) + + // Once reasons load, retro-fill default reason on already-checked rows + // that lacked a reasonCode (e.g. modal opened before metadata resolved). + const didBackfillRef = useRef(false) + useEffect(() => { + if (!isOpen) { + didBackfillRef.current = false + return + } + if (didBackfillRef.current || !defaultReasonCode || !selection) return + let next = selection + let changed = false + Object.entries(selection).forEach(([itemId, row]) => { + if (row?.checked && !row.reasonCode) { + if (!changed) { + next = {...selection} + changed = true + } + next[itemId] = {...row, reasonCode: defaultReasonCode} + } + }) + if (changed) onSelectionChange(next) + didBackfillRef.current = true + }, [isOpen, defaultReasonCode, selection, onSelectionChange]) + + const reviewEnabled = useMemo( + () => isSelectionValid(selection, returnableItems), + [selection, returnableItems] + ) + + const handleReview = useCallback(() => { + const payload = buildReturnPayload(selection, defaultReasonCode) + onReview(payload) + }, [selection, defaultReasonCode, onReview]) + + const reviewDisabledHintId = 'return-order-modal-review-disabled-hint' + + const body = reviewQuery.isLoading ? ( + + + + + ) : reviewQuery.isError ? ( + + + + + + + + + + ) : ( + + {returnableItems.map((item) => ( + handleToggle(item, checked)} + onQuantityChange={(qty) => handleQuantityChange(item.itemId, qty)} + onReasonChange={(code) => handleReasonChange(item.itemId, code)} + /> + ))} + + ) + + const header = ( + + + + + + + + + ) + + const footer = ( + + + + + + + + ) + + if (isMobile) { + return ( + + + + {header} + + {body} + {footer} + + + ) + } + + return ( + + + + {header} + + {body} + {footer} + + + ) +} + +ReturnOrderModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + order: PropTypes.object, + returnableItems: PropTypes.array.isRequired, + selection: PropTypes.object, + onSelectionChange: PropTypes.func.isRequired, + onReview: PropTypes.func.isRequired +} + +export default ReturnOrderModal diff --git a/packages/template-retail-react-app/app/components/return-order-modal/index.test.js b/packages/template-retail-react-app/app/components/return-order-modal/index.test.js new file mode 100644 index 0000000000..64fe983f78 --- /dev/null +++ b/packages/template-retail-react-app/app/components/return-order-modal/index.test.js @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, 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, {useState} from 'react' +import PropTypes from 'prop-types' +import {fireEvent, screen, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import ReturnOrderModal from '@salesforce/retail-react-app/app/components/return-order-modal' +import {buildReturnPayload} from '@salesforce/retail-react-app/app/components/return-order-modal/constants' + +let mockOmsMetaData = { + data: { + cancelReasonCodes: [], + returnReasonCodes: [ + {reason: 'Wrong size', default: true}, + {reason: 'Defect', default: false}, + {reason: 'Changed my mind', default: false} + ] + }, + isLoading: false, + isError: false, + refetch: jest.fn() +} +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useOmsMetaData: () => mockOmsMetaData + } +}) + +const baseOrder = { + orderNo: '00123456', + productItems: [ + { + itemId: 'item-1', + productId: 'prod-1', + productName: 'Cotton Crew T-Shirt', + quantity: 2, + omsData: {quantityAvailableToReturn: 2}, + variationAttributes: [ + {id: 'color', name: 'Color', values: [{value: 'BLACK', name: 'Black'}]}, + {id: 'size', name: 'Size', values: [{value: 'M', name: 'M'}]} + ], + variationValues: {color: 'BLACK', size: 'M'} + }, + { + itemId: 'item-2', + productId: 'prod-2', + productName: 'Slim Fit Chino Pants', + quantity: 1, + omsData: {quantityAvailableToReturn: 1} + } + ] +} + +const Harness = ({onReview = jest.fn(), onClose = jest.fn(), initialSelection = {}} = {}) => { + const [selection, setSelection] = useState(initialSelection) + return ( + + ) +} +Harness.propTypes = { + onReview: PropTypes.func, + onClose: PropTypes.func, + initialSelection: PropTypes.object +} + +afterEach(() => { + mockOmsMetaData = { + data: { + cancelReasonCodes: [], + returnReasonCodes: [ + {reason: 'Wrong size', default: true}, + {reason: 'Defect', default: false}, + {reason: 'Changed my mind', default: false} + ] + }, + isLoading: false, + isError: false, + refetch: jest.fn() + } + jest.clearAllMocks() +}) + +test('renders header with order number and a row per returnable item', async () => { + renderWithProviders() + expect(await screen.findByText(/return items from order #00123456/i)).toBeInTheDocument() + expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument() + expect(screen.getAllByTestId('return-modal-item-row')).toHaveLength(2) + expect(screen.getByText(/cotton crew t-shirt/i)).toBeInTheDocument() + expect(screen.getByText(/slim fit chino pants/i)).toBeInTheDocument() +}) + +test('Review return is disabled until at least one valid row is selected', async () => { + const user = userEvent.setup() + renderWithProviders() + const reviewButton = screen.getByTestId('return-modal-review') + expect(reviewButton).toBeDisabled() + expect(reviewButton).toHaveAttribute('aria-describedby') + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]) + expect(reviewButton).toBeEnabled() + expect(reviewButton).not.toHaveAttribute('aria-describedby') +}) + +test('toggling a row expands it and pre-selects the OMS default reason', async () => { + const user = userEvent.setup() + renderWithProviders() + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]) + + const row = screen.getAllByTestId('return-modal-item-row')[0] + expect(within(row).getByLabelText(/reason/i)).toHaveValue('Wrong size') + expect(within(row).getByLabelText(/quantity/i)).toHaveValue('1') +}) + +test('quantity field clamps to the available-to-return ceiling', async () => { + const user = userEvent.setup() + renderWithProviders() + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[0]) // item-1 has max 2 + + const row = screen.getAllByTestId('return-modal-item-row')[0] + const qty = within(row).getByLabelText(/quantity/i) + // Set value directly then blur — Chakra's useNumberInput clamps on blur, + // and userEvent.clear() doesn't propagate to a controlled NumberInput + // because the hook rejects empty intermediate values. + fireEvent.change(qty, {target: {value: '99'}}) + fireEvent.blur(qty) + expect(qty).toHaveValue('2') +}) + +test('Cancel calls onClose', async () => { + const user = userEvent.setup() + const onClose = jest.fn() + renderWithProviders() + await user.click(screen.getByTestId('return-modal-cancel')) + expect(onClose).toHaveBeenCalledTimes(1) +}) + +test('clicking Review return forwards a properly shaped payload', async () => { + const user = userEvent.setup() + const onReview = jest.fn() + renderWithProviders() + + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[1]) // item-2: max 1, should default-reason + + // Change reason away from default so it gets serialized + const reason = within(screen.getAllByTestId('return-modal-item-row')[1]).getByLabelText( + /reason/i + ) + await user.selectOptions(reason, 'Defect') + + await user.click(screen.getByTestId('return-modal-review')) + expect(onReview).toHaveBeenCalledWith([{itemId: 'item-2', quantity: 1, reason: 'Defect'}]) +}) + +test('omits reason from payload when shopper kept the OMS default', () => { + const selection = {'item-2': {checked: true, quantity: 1, reasonCode: 'Wrong size'}} + expect(buildReturnPayload(selection, 'Wrong size')).toEqual([{itemId: 'item-2', quantity: 1}]) +}) + +test('serializes quantity as a JS Number (not a string)', () => { + const selection = {'item-2': {checked: true, quantity: '3', reasonCode: 'Defect'}} + const [row] = buildReturnPayload(selection, 'Wrong size') + expect(typeof row.quantity).toBe('number') + expect(row.quantity).toBe(3) +}) + +test('renders skeleton placeholders while OMS metadata is loading', () => { + mockOmsMetaData = {data: undefined, isLoading: true, isError: false, refetch: jest.fn()} + renderWithProviders() + expect(screen.getByTestId('return-modal-loading')).toBeInTheDocument() +}) + +test('renders an error alert + Retry when OMS metadata fails', async () => { + const user = userEvent.setup() + const refetch = jest.fn() + mockOmsMetaData = {data: undefined, isLoading: false, isError: true, refetch} + renderWithProviders() + expect(screen.getByTestId('return-modal-error')).toBeInTheDocument() + await user.click(screen.getByTestId('return-modal-retry')) + expect(refetch).toHaveBeenCalledTimes(1) +}) From 0d638c97afc4464e923145c13f3244d15688bf46 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Wed, 17 Jun 2026 16:04:32 -0400 Subject: [PATCH 02/14] @W-22821837@ Wire return-order-modal into order detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the W-22821836 placeholder CTA from disabled to live: clicking it now opens the return-order-modal added in the previous commit. The button's visible label updates from "Start return" to "Return Items" to match the storefront-next designs, but the message id and `data-testid` stay stable so downstream extenders are not broken. Drops the `aria-describedby`/`VisuallyHidden`/`title` "Returns coming soon" plumbing (along with the unused `start_return_disabled_explanation` message) — they were specifically for the placeholder state. Added state on the page: - second `useDisclosure` for the return modal, alongside the existing cancel-order one - `returnSelection` held in `useState`, lifted to the page so the modal can swap between Modal and Drawer on viewport resize without losing shopper progress, and so step 2's review modal (W-22821838) can read the same payload directly - a gated `useProducts` (only fires when modal is open and there is at least one returnable line) merged into `enrichedReturnableItems` so the modal can render "" lines via `getDisplayVariationValues` `handleCloseReturnModal` resets the local selection on close, mirroring the cancel-order modal's reset semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/pages/account/order-detail.jsx | 103 +++++++++++++----- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx index f0583f66a9..c6e76e77b5 100644 --- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx +++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx @@ -20,8 +20,7 @@ import { Grid, SimpleGrid, Skeleton, - useDisclosure, - VisuallyHidden + useDisclosure } from '@salesforce/retail-react-app/app/components/shared/ui' import {getCreditCardIcon} from '@salesforce/retail-react-app/app/utils/cc-utils' import { @@ -51,6 +50,7 @@ import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constan import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {consolidateDuplicateBonusProducts} from '@salesforce/retail-react-app/app/utils/bonus-product/cart' import CancelOrderModal from '@salesforce/retail-react-app/app/components/cancel-order-modal' +import ReturnOrderModal from '@salesforce/retail-react-app/app/components/return-order-modal' import PropTypes from 'prop-types' const onClient = typeof window !== 'undefined' @@ -138,8 +138,17 @@ const AccountOrderDetail = () => { onOpen: openCancelModal, onClose: closeCancelModal } = useDisclosure() + const { + isOpen: isReturnModalOpen, + onOpen: openReturnModal, + onClose: closeReturnModal + } = useDisclosure() const [cancelFeedback, setCancelFeedback] = useState(null) const cancelMutation = useShopperOrdersMutation(ShopperOrdersMutations.CancelOmsOrder) + // Selection state lives on the parent so the Modal/Drawer wrapper swap on + // viewport resize doesn't lose the shopper's progress, and so W-22821838's + // review step can read the same payload without prop-drilling. + const [returnSelection, setReturnSelection] = useState({}) // expand: 'oms' returns order data from OMS if the order is successfully // ingested to OMS, otherwise returns data from ECOM @@ -186,6 +195,46 @@ const AccountOrderDetail = () => { const {data: omsMetaData} = useOmsMetaData({parameters: {}}, {enabled: isOmsOrder && onClient}) + // Fetch product detail for the items the shopper can return so the modal + // can render a "" line. Gated on modal open + at least + // one returnable item to keep the order-detail page itself cheap. + const returnableProductIds = useMemo( + () => returnableItems.map((i) => i.productId).filter(Boolean), + [returnableItems] + ) + const {data: returnableProducts} = useProducts( + {parameters: {ids: returnableProductIds.join(',')}}, + { + enabled: onClient && isReturnModalOpen && returnableProductIds.length > 0, + select: (result) => + result?.data?.reduce((acc, p) => { + acc[p.id] = p + return acc + }, {}) + } + ) + const enrichedReturnableItems = useMemo( + () => + returnableItems.map((item) => ({ + ...(returnableProducts?.[item.productId] || {}), + ...item + })), + [returnableItems, returnableProducts] + ) + + const handleCloseReturnModal = useCallback(() => { + closeReturnModal() + setReturnSelection({}) + }, [closeReturnModal]) + + const handleReviewReturn = useCallback( + (/* payload */) => { + // W-22821838 picks up here: swap the modal to its review view and use + // `returnSelection` (held above) to render the confirmation rows. + }, + [] + ) + const canCancel = useMemo(() => { if (!isRegistered || !order) return false if (!order.omsData) return false @@ -437,34 +486,17 @@ const AccountOrderDetail = () => { /> {showStartReturn && ( - // Phase 1 placeholder: button is disabled and announces - // "Returns coming soon" to assistive tech via an - // `aria-describedby` association. The full flow lands in - // a follow-up story. - <> - - - - - + )} @@ -747,6 +779,17 @@ const AccountOrderDetail = () => { reasonCodes={omsMetaData?.cancelReasonCodes} /> )} + {isOmsOrder && ( + + )} ) } From a37667ace6760ee071faeff722cecd3d0a73bf26 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Wed, 17 Jun 2026 16:04:43 -0400 Subject: [PATCH 03/14] @W-22821837@ Update orders.test.js for enabled Return Items CTA Replaces the W-22821836 placeholder assertions (disabled button + "Returns coming soon" accessible description + matching `title`) with checks for the now-enabled state and the renamed visible label. The `data-testid` and message id are stable, so the test still locates the button via `account-order-detail-start-return`. Adds a click-opens-modal smoke test asserting the modal header "Return items from order #..." appears after clicking the trigger. Renamed the describe block from "Start Return CTA (W-22821836)" to "Return Items CTA (W-22821836 / W-22821837)" to reflect that both WIs contribute to the behavior covered here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/pages/account/orders.test.js | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/account/orders.test.js b/packages/template-retail-react-app/app/pages/account/orders.test.js index b08642e50e..965d81858d 100644 --- a/packages/template-retail-react-app/app/pages/account/orders.test.js +++ b/packages/template-retail-react-app/app/pages/account/orders.test.js @@ -7,6 +7,7 @@ import React from 'react' import {Route, Switch} from 'react-router-dom' import {screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import {rest} from 'msw' import { renderWithProviders, @@ -464,7 +465,7 @@ describe('OMS/SOM Integration - Order Details', () => { }) }) -describe('Start Return CTA (W-22821836)', () => { +describe('Return Items CTA (W-22821836 / W-22821837)', () => { // Eligibility is OMS-driven: the CTA renders when at least one productItem has // omsData.quantityAvailableToReturn > 0. OMS computes that field per item; the // server returns 409 if the order is no longer in a returnable state, so there @@ -486,18 +487,15 @@ describe('Start Return CTA (W-22821836)', () => { ...overrides }) - test('renders the disabled Start Return CTA when an item has quantityAvailableToReturn > 0', async () => { + test('renders the enabled Return Items CTA when an item has quantityAvailableToReturn > 0', async () => { setupOrderDetailsPage(createReturnEligibleOmsOrder()) const cta = await screen.findByTestId('account-order-detail-start-return') expect(cta).toBeInTheDocument() - expect(cta).toBeDisabled() - // Accessible name preserves the visible "Start return" label so voice - // control users can activate the button by saying its visible text - // (WCAG 2.5.3 Label in Name). The "Returns coming soon" explanation is - // exposed via aria-describedby + a visually-hidden node. - expect(cta).toHaveAccessibleName('Start return') - expect(cta).toHaveAccessibleDescription('Returns coming soon') - expect(cta).toHaveAttribute('title', 'Returns coming soon') + expect(cta).toBeEnabled() + // The label was renamed from "Start return" to "Return Items" in + // W-22821837 to match the storefront-next designs; the underlying + // message id stays stable so downstream extenders aren't broken. + expect(cta).toHaveAccessibleName('Return Items') }) test('does NOT render the CTA when no item has quantityAvailableToReturn > 0', async () => { @@ -524,6 +522,14 @@ describe('Start Return CTA (W-22821836)', () => { expect(await screen.findByTestId('account-order-details-page')).toBeInTheDocument() expect(screen.queryByTestId('account-order-detail-start-return')).not.toBeInTheDocument() }) + + test('clicking Return Items opens the return-order modal (W-22821837)', async () => { + const user = userEvent.setup() + setupOrderDetailsPage(createReturnEligibleOmsOrder()) + const cta = await screen.findByTestId('account-order-detail-start-return') + await user.click(cta) + expect(await screen.findByText(/return items from order #/i)).toBeInTheDocument() + }) }) describe('OMS/SOM Integration - Order History', () => { From 41d6410315fcc9c186106f037aba8d5acd5a1d62 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Wed, 17 Jun 2026 16:05:04 -0400 Subject: [PATCH 04/14] @W-22821837@ Regenerate translations and add CHANGELOG entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran `npm run extract-default-translations` and `npm run compile-translations` after the string changes: - new `return_order_modal.*` messages (12 entries) for the modal - updated `defaultMessage` for `account_order_detail.button.start_return` from "Start return" to "Return Items" - removed the unused `account_order_detail.button.start_return_disabled_explanation` Tests via `app/utils/test-utils.js` consume the compiled JSON, so the generated files under `app/static/translations/compiled/` must be committed — a regen alone leaves test snapshots stale. Added a one-line CHANGELOG entry under v10.1.0-dev. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../template-retail-react-app/CHANGELOG.md | 1 + .../static/translations/compiled/en-GB.json | 162 +++++++++++++++++- .../static/translations/compiled/en-US.json | 162 +++++++++++++++++- .../translations/en-GB.json | 44 ++++- .../translations/en-US.json | 44 ++++- 5 files changed, 391 insertions(+), 22 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index c1297f009d..d020787ab9 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## v10.1.0-dev +- [Feature] Add return-item-selection modal on order detail. Registered users can select which items to return, set quantity, and pick a reason from `getOmsMetaData`. Gated by the per-item `quantityAvailableToReturn` eligibility signal and a customer-ownership check on the order. The "Return Items" button on order detail now opens this modal (W-22821837). - [Feature] Add cancel order modal on order detail page. Registered users with OMS enabled can cancel orders that are not yet shipped. Modal includes reason code selection and inline success/error feedback. Gated behind `app.oms.enabled` config flag. [#3861](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3861) - [Bugfix] Render the search refinements panels open during server-side rendering. ChakraUI v2's Accordion forces every item closed in SSR (its open state depends on a post-mount layout effect that never runs on the server), so the panels opened only after hydration — a late relayout that could become the PLP largest-contentful-paint element. Replaced the Accordion with a controlled disclosure that honors its open state server-side. - [Bugfix] Memoize the search refinements panel so it stops re-rendering on every parent render when the filter set is unchanged, improving PLP render stability. [#3855](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3855) diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index b476d25365..3a4b630d4d 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -242,13 +242,7 @@ "account_order_detail.button.start_return": [ { "type": 0, - "value": "Start return" - } - ], - "account_order_detail.button.start_return_disabled_explanation": [ - { - "type": 0, - "value": "Returns coming soon" + "value": "Return Items" } ], "account_order_detail.error.back_to_history": [ @@ -4281,6 +4275,160 @@ "value": "Reset Password" } ], + "return_order_modal.button.cancel": [ + { + "type": 0, + "value": "Cancel" + } + ], + "return_order_modal.button.retry": [ + { + "type": 0, + "value": "Retry" + } + ], + "return_order_modal.button.review_return": [ + { + "type": 0, + "value": "Review return" + } + ], + "return_order_modal.heading.return_items": [ + { + "type": 0, + "value": "Return items from order #" + }, + { + "type": 1, + "value": "orderNo" + } + ], + "return_order_modal.hint.review_disabled": [ + { + "type": 0, + "value": "Select at least one item and choose a reason to continue." + } + ], + "return_order_modal.label.item_checkbox": [ + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": ", up to " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " to return" + } + ], + "return_order_modal.label.quantity": [ + { + "type": 0, + "value": "Quantity" + } + ], + "return_order_modal.label.reason": [ + { + "type": 0, + "value": "Reason" + } + ], + "return_order_modal.placeholder.select_reason": [ + { + "type": 0, + "value": "Select a reason" + } + ], + "return_order_modal.text.available_to_return": [ + { + "type": 0, + "value": "Up to " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " to return" + } + ], + "return_order_modal.text.loading_reasons": [ + { + "type": 0, + "value": "Loading return reasons…" + } + ], + "return_order_modal.text.reasons_error": [ + { + "type": 0, + "value": "We could not load the return reasons. Please try again." + } + ], + "return_order_modal.text.select_items_description": [ + { + "type": 0, + "value": "Select the items you want to return and tell us why." + } + ], "search.action.cancel": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index b476d25365..3a4b630d4d 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -242,13 +242,7 @@ "account_order_detail.button.start_return": [ { "type": 0, - "value": "Start return" - } - ], - "account_order_detail.button.start_return_disabled_explanation": [ - { - "type": 0, - "value": "Returns coming soon" + "value": "Return Items" } ], "account_order_detail.error.back_to_history": [ @@ -4281,6 +4275,160 @@ "value": "Reset Password" } ], + "return_order_modal.button.cancel": [ + { + "type": 0, + "value": "Cancel" + } + ], + "return_order_modal.button.retry": [ + { + "type": 0, + "value": "Retry" + } + ], + "return_order_modal.button.review_return": [ + { + "type": 0, + "value": "Review return" + } + ], + "return_order_modal.heading.return_items": [ + { + "type": 0, + "value": "Return items from order #" + }, + { + "type": 1, + "value": "orderNo" + } + ], + "return_order_modal.hint.review_disabled": [ + { + "type": 0, + "value": "Select at least one item and choose a reason to continue." + } + ], + "return_order_modal.label.item_checkbox": [ + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": ", up to " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " to return" + } + ], + "return_order_modal.label.quantity": [ + { + "type": 0, + "value": "Quantity" + } + ], + "return_order_modal.label.reason": [ + { + "type": 0, + "value": "Reason" + } + ], + "return_order_modal.placeholder.select_reason": [ + { + "type": 0, + "value": "Select a reason" + } + ], + "return_order_modal.text.available_to_return": [ + { + "type": 0, + "value": "Up to " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " available" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " to return" + } + ], + "return_order_modal.text.loading_reasons": [ + { + "type": 0, + "value": "Loading return reasons…" + } + ], + "return_order_modal.text.reasons_error": [ + { + "type": 0, + "value": "We could not load the return reasons. Please try again." + } + ], + "return_order_modal.text.select_items_description": [ + { + "type": 0, + "value": "Select the items you want to return and tell us why." + } + ], "search.action.cancel": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 5a13f78f38..d0e4c95326 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -114,10 +114,7 @@ "defaultMessage": "Cancel order" }, "account_order_detail.button.start_return": { - "defaultMessage": "Start return" - }, - "account_order_detail.button.start_return_disabled_explanation": { - "defaultMessage": "Returns coming soon" + "defaultMessage": "Return Items" }, "account_order_detail.error.back_to_history": { "defaultMessage": "Back to Order History" @@ -1796,6 +1793,45 @@ "reset_password_form.title.reset_password": { "defaultMessage": "Reset Password" }, + "return_order_modal.button.cancel": { + "defaultMessage": "Cancel" + }, + "return_order_modal.button.retry": { + "defaultMessage": "Retry" + }, + "return_order_modal.button.review_return": { + "defaultMessage": "Review return" + }, + "return_order_modal.heading.return_items": { + "defaultMessage": "Return items from order #{orderNo}" + }, + "return_order_modal.hint.review_disabled": { + "defaultMessage": "Select at least one item and choose a reason to continue." + }, + "return_order_modal.label.item_checkbox": { + "defaultMessage": "{name}, up to {count, plural, one {# available} other {# available}} to return" + }, + "return_order_modal.label.quantity": { + "defaultMessage": "Quantity" + }, + "return_order_modal.label.reason": { + "defaultMessage": "Reason" + }, + "return_order_modal.placeholder.select_reason": { + "defaultMessage": "Select a reason" + }, + "return_order_modal.text.available_to_return": { + "defaultMessage": "Up to {count, plural, one {# available} other {# available}} to return" + }, + "return_order_modal.text.loading_reasons": { + "defaultMessage": "Loading return reasons…" + }, + "return_order_modal.text.reasons_error": { + "defaultMessage": "We could not load the return reasons. Please try again." + }, + "return_order_modal.text.select_items_description": { + "defaultMessage": "Select the items you want to return and tell us why." + }, "search.action.cancel": { "defaultMessage": "Cancel" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 5a13f78f38..d0e4c95326 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -114,10 +114,7 @@ "defaultMessage": "Cancel order" }, "account_order_detail.button.start_return": { - "defaultMessage": "Start return" - }, - "account_order_detail.button.start_return_disabled_explanation": { - "defaultMessage": "Returns coming soon" + "defaultMessage": "Return Items" }, "account_order_detail.error.back_to_history": { "defaultMessage": "Back to Order History" @@ -1796,6 +1793,45 @@ "reset_password_form.title.reset_password": { "defaultMessage": "Reset Password" }, + "return_order_modal.button.cancel": { + "defaultMessage": "Cancel" + }, + "return_order_modal.button.retry": { + "defaultMessage": "Retry" + }, + "return_order_modal.button.review_return": { + "defaultMessage": "Review return" + }, + "return_order_modal.heading.return_items": { + "defaultMessage": "Return items from order #{orderNo}" + }, + "return_order_modal.hint.review_disabled": { + "defaultMessage": "Select at least one item and choose a reason to continue." + }, + "return_order_modal.label.item_checkbox": { + "defaultMessage": "{name}, up to {count, plural, one {# available} other {# available}} to return" + }, + "return_order_modal.label.quantity": { + "defaultMessage": "Quantity" + }, + "return_order_modal.label.reason": { + "defaultMessage": "Reason" + }, + "return_order_modal.placeholder.select_reason": { + "defaultMessage": "Select a reason" + }, + "return_order_modal.text.available_to_return": { + "defaultMessage": "Up to {count, plural, one {# available} other {# available}} to return" + }, + "return_order_modal.text.loading_reasons": { + "defaultMessage": "Loading return reasons…" + }, + "return_order_modal.text.reasons_error": { + "defaultMessage": "We could not load the return reasons. Please try again." + }, + "return_order_modal.text.select_items_description": { + "defaultMessage": "Select the items you want to return and tell us why." + }, "search.action.cancel": { "defaultMessage": "Cancel" }, From c5fd8eb812c055b73c37fd9677046b32e7f63d15 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Wed, 17 Jun 2026 16:33:54 -0400 Subject: [PATCH 05/14] @W-22821837@ Rename return-order-modal to return-items-modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the folder, component, message-id namespace, and data-testid prefix with the W-22821838 plan, which assumes a single modal stack named `return-items-modal/` containing both the selection view (this WI) and the upcoming review view. Keeping the original `return-order-modal/` name would have forced W-22821838 to either move the directory mid-feature or add a same-purpose sibling — neither helpful for reviewers tracking the two WIs together. Changes: - `app/components/return-order-modal/` → `app/components/return-items-modal/` - `ReturnOrderModal` symbol → `ReturnItemsModal` - `data-testid="return-modal-*"` → `data-testid="return-items-modal-*"` (the page-level trigger `account-order-detail-start-return` is unchanged because downstream extenders depend on it via ccExtensibility) - `return_order_modal.*` message ids → `return_items_modal.*` - Regenerated `translations/` and `app/static/translations/compiled/` Tests: all 10 modal tests + 99 orders.test.js tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../constants.js | 26 ++++++++-------- .../index.jsx | 30 +++++++++---------- .../index.test.js | 26 ++++++++-------- .../app/pages/account/order-detail.jsx | 4 +-- .../static/translations/compiled/en-GB.json | 26 ++++++++-------- .../static/translations/compiled/en-US.json | 26 ++++++++-------- .../translations/en-GB.json | 26 ++++++++-------- .../translations/en-US.json | 26 ++++++++-------- 8 files changed, 95 insertions(+), 95 deletions(-) rename packages/template-retail-react-app/app/components/{return-order-modal => return-items-modal}/constants.js (78%) rename packages/template-retail-react-app/app/components/{return-order-modal => return-items-modal}/index.jsx (94%) rename packages/template-retail-react-app/app/components/{return-order-modal => return-items-modal}/index.test.js (87%) diff --git a/packages/template-retail-react-app/app/components/return-order-modal/constants.js b/packages/template-retail-react-app/app/components/return-items-modal/constants.js similarity index 78% rename from packages/template-retail-react-app/app/components/return-order-modal/constants.js rename to packages/template-retail-react-app/app/components/return-items-modal/constants.js index c2e2ca2559..97a0120a62 100644 --- a/packages/template-retail-react-app/app/components/return-order-modal/constants.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/constants.js @@ -10,56 +10,56 @@ import {defineMessages} from 'react-intl' export const messages = defineMessages({ title: { defaultMessage: 'Return items from order #{orderNo}', - id: 'return_order_modal.heading.return_items' + id: 'return_items_modal.heading.return_items' }, subhead: { defaultMessage: 'Select the items you want to return and tell us why.', - id: 'return_order_modal.text.select_items_description' + id: 'return_items_modal.text.select_items_description' }, availableToReturn: { defaultMessage: 'Up to {count, plural, one {# available} other {# available}} to return', - id: 'return_order_modal.text.available_to_return' + id: 'return_items_modal.text.available_to_return' }, quantityLabel: { defaultMessage: 'Quantity', - id: 'return_order_modal.label.quantity' + id: 'return_items_modal.label.quantity' }, reasonLabel: { defaultMessage: 'Reason', - id: 'return_order_modal.label.reason' + id: 'return_items_modal.label.reason' }, selectReasonPlaceholder: { defaultMessage: 'Select a reason', - id: 'return_order_modal.placeholder.select_reason' + id: 'return_items_modal.placeholder.select_reason' }, cancelButton: { defaultMessage: 'Cancel', - id: 'return_order_modal.button.cancel' + id: 'return_items_modal.button.cancel' }, reviewButton: { defaultMessage: 'Review return', - id: 'return_order_modal.button.review_return' + id: 'return_items_modal.button.review_return' }, reviewDisabledHint: { defaultMessage: 'Select at least one item and choose a reason to continue.', - id: 'return_order_modal.hint.review_disabled' + id: 'return_items_modal.hint.review_disabled' }, loadingReasons: { defaultMessage: 'Loading return reasons…', - id: 'return_order_modal.text.loading_reasons' + id: 'return_items_modal.text.loading_reasons' }, reasonsError: { defaultMessage: 'We could not load the return reasons. Please try again.', - id: 'return_order_modal.text.reasons_error' + id: 'return_items_modal.text.reasons_error' }, retryButton: { defaultMessage: 'Retry', - id: 'return_order_modal.button.retry' + id: 'return_items_modal.button.retry' }, itemCheckboxLabel: { defaultMessage: '{name}, up to {count, plural, one {# available} other {# available}} to return', - id: 'return_order_modal.label.item_checkbox' + id: 'return_items_modal.label.item_checkbox' } }) diff --git a/packages/template-retail-react-app/app/components/return-order-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx similarity index 94% rename from packages/template-retail-react-app/app/components/return-order-modal/index.jsx rename to packages/template-retail-react-app/app/components/return-items-modal/index.jsx index bd1f9e390b..c1f624a2fb 100644 --- a/packages/template-retail-react-app/app/components/return-order-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -47,7 +47,7 @@ import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/ import { messages, buildReturnPayload -} from '@salesforce/retail-react-app/app/components/return-order-modal/constants' +} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' const onClient = typeof window !== 'undefined' @@ -101,11 +101,11 @@ const QuantityField = ({value, max, onChange, ariaLabel, id}) => { return ( - - @@ -134,7 +134,7 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe border="1px solid" borderColor="gray.200" borderRadius="base" - data-testid="return-modal-item-row" + data-testid="return-items-modal-item-row" > { * modal stack. This component invokes `onReview(payload)` with the API-shaped * `productItems` array when the shopper clicks **Review return**. */ -const ReturnOrderModal = ({ +const ReturnItemsModal = ({ isOpen, onClose, order, @@ -320,15 +320,15 @@ const ReturnOrderModal = ({ onReview(payload) }, [selection, defaultReasonCode, onReview]) - const reviewDisabledHintId = 'return-order-modal-review-disabled-hint' + const reviewDisabledHintId = 'return-items-modal-review-disabled-hint' const body = reviewQuery.isLoading ? ( - + ) : reviewQuery.isError ? ( - + @@ -338,7 +338,7 @@ const ReturnOrderModal = ({ size="sm" variant="outline" onClick={() => reviewQuery.refetch()} - data-testid="return-modal-retry" + data-testid="return-items-modal-retry" > @@ -382,7 +382,7 @@ const ReturnOrderModal = ({ variant="outline" onClick={onClose} width={{base: 'full', md: 'auto'}} - data-testid="return-modal-cancel" + data-testid="return-items-modal-cancel" > @@ -392,7 +392,7 @@ const ReturnOrderModal = ({ isDisabled={!reviewEnabled} aria-describedby={reviewEnabled ? undefined : reviewDisabledHintId} width={{base: 'full', md: 'auto'}} - data-testid="return-modal-review" + data-testid="return-items-modal-review" > @@ -406,7 +406,7 @@ const ReturnOrderModal = ({ return ( - + {header} {body} @@ -419,7 +419,7 @@ const ReturnOrderModal = ({ return ( - + {header} {body} @@ -429,7 +429,7 @@ const ReturnOrderModal = ({ ) } -ReturnOrderModal.propTypes = { +ReturnItemsModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, order: PropTypes.object, @@ -439,4 +439,4 @@ ReturnOrderModal.propTypes = { onReview: PropTypes.func.isRequired } -export default ReturnOrderModal +export default ReturnItemsModal diff --git a/packages/template-retail-react-app/app/components/return-order-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js similarity index 87% rename from packages/template-retail-react-app/app/components/return-order-modal/index.test.js rename to packages/template-retail-react-app/app/components/return-items-modal/index.test.js index 64fe983f78..b290aaf3bf 100644 --- a/packages/template-retail-react-app/app/components/return-order-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js @@ -9,8 +9,8 @@ import PropTypes from 'prop-types' import {fireEvent, screen, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import ReturnOrderModal from '@salesforce/retail-react-app/app/components/return-order-modal' -import {buildReturnPayload} from '@salesforce/retail-react-app/app/components/return-order-modal/constants' +import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal' +import {buildReturnPayload} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' let mockOmsMetaData = { data: { @@ -61,7 +61,7 @@ const baseOrder = { const Harness = ({onReview = jest.fn(), onClose = jest.fn(), initialSelection = {}} = {}) => { const [selection, setSelection] = useState(initialSelection) return ( - ) expect(await screen.findByText(/return items from order #00123456/i)).toBeInTheDocument() expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument() - expect(screen.getAllByTestId('return-modal-item-row')).toHaveLength(2) + expect(screen.getAllByTestId('return-items-modal-item-row')).toHaveLength(2) expect(screen.getByText(/cotton crew t-shirt/i)).toBeInTheDocument() expect(screen.getByText(/slim fit chino pants/i)).toBeInTheDocument() }) @@ -107,7 +107,7 @@ test('renders header with order number and a row per returnable item', async () test('Review return is disabled until at least one valid row is selected', async () => { const user = userEvent.setup() renderWithProviders() - const reviewButton = screen.getByTestId('return-modal-review') + const reviewButton = screen.getByTestId('return-items-modal-review') expect(reviewButton).toBeDisabled() expect(reviewButton).toHaveAttribute('aria-describedby') @@ -123,7 +123,7 @@ test('toggling a row expands it and pre-selects the OMS default reason', async ( const checkboxes = screen.getAllByRole('checkbox') await user.click(checkboxes[0]) - const row = screen.getAllByTestId('return-modal-item-row')[0] + const row = screen.getAllByTestId('return-items-modal-item-row')[0] expect(within(row).getByLabelText(/reason/i)).toHaveValue('Wrong size') expect(within(row).getByLabelText(/quantity/i)).toHaveValue('1') }) @@ -134,7 +134,7 @@ test('quantity field clamps to the available-to-return ceiling', async () => { const checkboxes = screen.getAllByRole('checkbox') await user.click(checkboxes[0]) // item-1 has max 2 - const row = screen.getAllByTestId('return-modal-item-row')[0] + const row = screen.getAllByTestId('return-items-modal-item-row')[0] const qty = within(row).getByLabelText(/quantity/i) // Set value directly then blur — Chakra's useNumberInput clamps on blur, // and userEvent.clear() doesn't propagate to a controlled NumberInput @@ -148,7 +148,7 @@ test('Cancel calls onClose', async () => { const user = userEvent.setup() const onClose = jest.fn() renderWithProviders() - await user.click(screen.getByTestId('return-modal-cancel')) + await user.click(screen.getByTestId('return-items-modal-cancel')) expect(onClose).toHaveBeenCalledTimes(1) }) @@ -161,12 +161,12 @@ test('clicking Review return forwards a properly shaped payload', async () => { await user.click(checkboxes[1]) // item-2: max 1, should default-reason // Change reason away from default so it gets serialized - const reason = within(screen.getAllByTestId('return-modal-item-row')[1]).getByLabelText( + const reason = within(screen.getAllByTestId('return-items-modal-item-row')[1]).getByLabelText( /reason/i ) await user.selectOptions(reason, 'Defect') - await user.click(screen.getByTestId('return-modal-review')) + await user.click(screen.getByTestId('return-items-modal-review')) expect(onReview).toHaveBeenCalledWith([{itemId: 'item-2', quantity: 1, reason: 'Defect'}]) }) @@ -185,7 +185,7 @@ test('serializes quantity as a JS Number (not a string)', () => { test('renders skeleton placeholders while OMS metadata is loading', () => { mockOmsMetaData = {data: undefined, isLoading: true, isError: false, refetch: jest.fn()} renderWithProviders() - expect(screen.getByTestId('return-modal-loading')).toBeInTheDocument() + expect(screen.getByTestId('return-items-modal-loading')).toBeInTheDocument() }) test('renders an error alert + Retry when OMS metadata fails', async () => { @@ -193,7 +193,7 @@ test('renders an error alert + Retry when OMS metadata fails', async () => { const refetch = jest.fn() mockOmsMetaData = {data: undefined, isLoading: false, isError: true, refetch} renderWithProviders() - expect(screen.getByTestId('return-modal-error')).toBeInTheDocument() - await user.click(screen.getByTestId('return-modal-retry')) + expect(screen.getByTestId('return-items-modal-error')).toBeInTheDocument() + await user.click(screen.getByTestId('return-items-modal-retry')) expect(refetch).toHaveBeenCalledTimes(1) }) diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx index c6e76e77b5..da929b8e2b 100644 --- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx +++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx @@ -50,7 +50,7 @@ import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constan import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {consolidateDuplicateBonusProducts} from '@salesforce/retail-react-app/app/utils/bonus-product/cart' import CancelOrderModal from '@salesforce/retail-react-app/app/components/cancel-order-modal' -import ReturnOrderModal from '@salesforce/retail-react-app/app/components/return-order-modal' +import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal' import PropTypes from 'prop-types' const onClient = typeof window !== 'undefined' @@ -780,7 +780,7 @@ const AccountOrderDetail = () => { /> )} {isOmsOrder && ( - Date: Wed, 17 Jun 2026 17:01:34 -0400 Subject: [PATCH 06/14] @W-22821837@ Address review-cc feedback on the return-items modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight fixes from the dual Cursor + Claude review pass. **Ownership guard on Return Items CTA** (order-detail.jsx) The cancel-order CTA gates on `order.customerInfo.customerId === customerId` as defense-in-depth against a useOrder response somehow returning an order the registered shopper does not own; the return CTA was missing the same check. Added it, plus a covering test that asserts the trigger hides when ownership doesn't match. **Rename helper for an unambiguous call site** (constants.js) `buildReturnPayload` returned the bare productItems array but its JSDoc referenced `OmsReturnOrderRequest = {productItems: [...]}`, easy to misread. Renamed to `buildReturnProductItems` so callers wrap it explicitly: `body: {productItems: buildReturnProductItems(...)}`. Drop-in for W-22821838's submission step. **Defensive payload filter** (constants.js) `Number(row.quantity)` returned `0` for empty strings and `NaN` for non-numerics. The UI gates on `isSelectionValid`, but the helper is exported for reuse by W-22821838 and exercised standalone in tests, so hardened it: rows with non-positive or non-finite quantity are dropped. Test added. **Contextual a11y labels** (index.jsx, constants.js) The +/- stepper buttons rendered as bare "−"/"+", and Quantity / Reason form controls labelled identically across rows so screen-reader users couldn't tell rows apart. Added `messages.quantityFor`, `reasonFor`, `quantityIncrement`, `quantityDecrement` and threaded `productName` down to QuantityField. Visible FormLabels stay short ("Quantity", "Reason"); contextual labels live on `aria-label`. **`role="status"` + visible-or-hidden loading announcement** (index.jsx) Skeleton block now wrapped in `role="status"` with a `VisuallyHidden` "Loading return reasons…" message (the `loadingReasons` entry in the catalog was previously unused). **`useId()` for the disabled-hint id** (index.jsx) Replaced the hardcoded `'return-items-modal-review-disabled-hint'` string with `useId()` so two modal instances on the same page wouldn't collide. **Drop the no-op ICU plurals** (constants.js) `availableToReturn` and `itemCheckboxLabel` declared `{count, plural, one {…} other {…}}` with identical text in both branches — translators would flag this. Differentiated the strings ("# unit" vs "# units"). **Backfill regression test** (index.test.js) Added a test for the `didBackfillRef` retro-fill path: a row pre-checked without a reasonCode (e.g. selection rehydrated from URL state in a future WI) gets the OMS default applied on mount. Tests: 112 pass (12 modal + 100 orders). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../return-items-modal/constants.js | 55 ++++++++++++------- .../components/return-items-modal/index.jsx | 36 +++++++++--- .../return-items-modal/index.test.js | 55 ++++++++++++++++--- .../app/pages/account/order-detail.jsx | 3 +- .../app/pages/account/orders.test.js | 21 ++++++- .../static/translations/compiled/en-GB.json | 54 +++++++++++++++--- .../static/translations/compiled/en-US.json | 54 +++++++++++++++--- .../translations/en-GB.json | 16 +++++- .../translations/en-US.json | 16 +++++- 9 files changed, 254 insertions(+), 56 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/constants.js b/packages/template-retail-react-app/app/components/return-items-modal/constants.js index 97a0120a62..92482c56ad 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/constants.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/constants.js @@ -17,17 +17,33 @@ export const messages = defineMessages({ id: 'return_items_modal.text.select_items_description' }, availableToReturn: { - defaultMessage: 'Up to {count, plural, one {# available} other {# available}} to return', + defaultMessage: 'Up to {count, plural, one {# unit} other {# units}} available to return', id: 'return_items_modal.text.available_to_return' }, quantityLabel: { defaultMessage: 'Quantity', id: 'return_items_modal.label.quantity' }, + quantityFor: { + defaultMessage: 'Quantity for {name}', + id: 'return_items_modal.label.quantity_for' + }, + quantityIncrement: { + defaultMessage: 'Increase quantity for {name}', + id: 'return_items_modal.label.quantity_increment' + }, + quantityDecrement: { + defaultMessage: 'Decrease quantity for {name}', + id: 'return_items_modal.label.quantity_decrement' + }, reasonLabel: { defaultMessage: 'Reason', id: 'return_items_modal.label.reason' }, + reasonFor: { + defaultMessage: 'Reason for {name}', + id: 'return_items_modal.label.reason_for' + }, selectReasonPlaceholder: { defaultMessage: 'Select a reason', id: 'return_items_modal.placeholder.select_reason' @@ -57,33 +73,34 @@ export const messages = defineMessages({ id: 'return_items_modal.button.retry' }, itemCheckboxLabel: { - defaultMessage: - '{name}, up to {count, plural, one {# available} other {# available}} to return', + defaultMessage: '{name}, {count, plural, one {# unit} other {# units}} available to return', id: 'return_items_modal.label.item_checkbox' } }) /** - * Build the API payload from the modal selection. - * - * Shape (matches `OmsReturnOrderRequest` in shopper-orders-oas): - * { productItems: [{ itemId, quantity: Number, reason? }, ...] } + * Build the `productItems` array for an `OmsReturnOrderRequest`. Caller wraps + * it: `body: {productItems: buildReturnProductItems(selection, defaultReasonCode)}`. * - * Quantity is `number` / `format: double` per the schema (oms.yaml). UX is - * integer-valued, but we serialize as a JS Number rather than a string. Reason - * is omitted when the shopper kept the OMS-default code, so the server applies - * the default per the API contract. + * Quantity is `number` / `format: double` per oms.yaml. UX is integer-valued + * but we serialize as a JS Number, not a string. Reason is omitted when the + * shopper kept the OMS-default code so the server applies the default per the + * API contract. * - * Caller is responsible for ensuring at least one row is `checked`. The helper - * silently drops unchecked rows. + * Rows without a positive numeric quantity are dropped — the upstream UI is + * already gated by `isSelectionValid`, but this hardens reuse from elsewhere + * (e.g. step 2's review modal in W-22821838) against malformed state. */ -export const buildReturnPayload = (selection, defaultReasonCode) => +export const buildReturnProductItems = (selection, defaultReasonCode) => Object.entries(selection || {}) .filter(([, row]) => row?.checked) - .map(([itemId, row]) => { - const payload = {itemId, quantity: Number(row.quantity)} + .reduce((items, [itemId, row]) => { + const quantity = Number(row.quantity) + if (!Number.isFinite(quantity) || quantity <= 0) return items + const item = {itemId, quantity} if (row.reasonCode && row.reasonCode !== defaultReasonCode) { - payload.reason = row.reasonCode + item.reason = row.reasonCode } - return payload - }) + items.push(item) + return items + }, []) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index c1f624a2fb..6aba4820e7 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -46,7 +46,7 @@ import { import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/product-utils' import { messages, - buildReturnPayload + buildReturnProductItems } from '@salesforce/retail-react-app/app/components/return-items-modal/constants' const onClient = typeof window !== 'undefined' @@ -72,7 +72,8 @@ const formatVariationSummary = (item) => { * convention (Chakra's `NumberInput` is not exported from the shared UI). The * underlying field clamps on blur and on increment/decrement. */ -const QuantityField = ({value, max, onChange, ariaLabel, id}) => { +const QuantityField = ({value, max, onChange, ariaLabel, productName, id}) => { + const intl = useIntl() const {getInputProps, getIncrementButtonProps, getDecrementButtonProps} = useNumberInput({ value, min: 1, @@ -89,8 +90,16 @@ const QuantityField = ({value, max, onChange, ariaLabel, id}) => { } }) - const dec = getDecrementButtonProps({variant: 'outline', size: 'sm'}) - const inc = getIncrementButtonProps({variant: 'outline', size: 'sm'}) + const dec = getDecrementButtonProps({ + variant: 'outline', + size: 'sm', + 'aria-label': intl.formatMessage(messages.quantityDecrement, {name: productName}) + }) + const inc = getIncrementButtonProps({ + variant: 'outline', + size: 'sm', + 'aria-label': intl.formatMessage(messages.quantityIncrement, {name: productName}) + }) const input = getInputProps({ id, textAlign: 'center', @@ -116,6 +125,7 @@ QuantityField.propTypes = { max: PropTypes.number.isRequired, onChange: PropTypes.func.isRequired, ariaLabel: PropTypes.string.isRequired, + productName: PropTypes.string.isRequired, id: PropTypes.string.isRequired } @@ -164,7 +174,10 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe value={row.quantity} max={max} onChange={onQuantityChange} - ariaLabel={intl.formatMessage(messages.quantityLabel)} + ariaLabel={intl.formatMessage(messages.quantityFor, { + name: displayName + })} + productName={displayName} /> @@ -176,6 +189,9 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe size="sm" value={row.reasonCode || ''} onChange={(e) => onReasonChange(e.target.value)} + aria-label={intl.formatMessage(messages.reasonFor, { + name: displayName + })} placeholder={intl.formatMessage( messages.selectReasonPlaceholder )} @@ -240,6 +256,7 @@ const ReturnItemsModal = ({ onReview }) => { const isMobile = useBreakpointValue({base: true, md: false}) + const reviewDisabledHintId = useId() const reviewQuery = useOmsMetaData( {}, @@ -316,14 +333,15 @@ const ReturnItemsModal = ({ ) const handleReview = useCallback(() => { - const payload = buildReturnPayload(selection, defaultReasonCode) + const payload = buildReturnProductItems(selection, defaultReasonCode) onReview(payload) }, [selection, defaultReasonCode, onReview]) - const reviewDisabledHintId = 'return-items-modal-review-disabled-hint' - const body = reviewQuery.isLoading ? ( - + + + + diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js index b290aaf3bf..97c0444e02 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js @@ -6,11 +6,11 @@ */ import React, {useState} from 'react' import PropTypes from 'prop-types' -import {fireEvent, screen, within} from '@testing-library/react' +import {fireEvent, screen, waitFor, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal' -import {buildReturnPayload} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' +import {buildReturnProductItems} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' let mockOmsMetaData = { data: { @@ -124,8 +124,13 @@ test('toggling a row expands it and pre-selects the OMS default reason', async ( await user.click(checkboxes[0]) const row = screen.getAllByTestId('return-items-modal-item-row')[0] - expect(within(row).getByLabelText(/reason/i)).toHaveValue('Wrong size') - expect(within(row).getByLabelText(/quantity/i)).toHaveValue('1') + // Contextual aria-labels include the product name so screen readers can + // distinguish rows. Scope queries to the input element to avoid matching + // the +/- buttons, which also carry "Quantity for {name}"-style labels. + expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue( + 'Wrong size' + ) + expect(within(row).getByLabelText(/quantity for /i, {selector: 'input'})).toHaveValue('1') }) test('quantity field clamps to the available-to-return ceiling', async () => { @@ -135,7 +140,7 @@ test('quantity field clamps to the available-to-return ceiling', async () => { await user.click(checkboxes[0]) // item-1 has max 2 const row = screen.getAllByTestId('return-items-modal-item-row')[0] - const qty = within(row).getByLabelText(/quantity/i) + const qty = within(row).getByLabelText(/quantity for /i, {selector: 'input'}) // Set value directly then blur — Chakra's useNumberInput clamps on blur, // and userEvent.clear() doesn't propagate to a controlled NumberInput // because the hook rejects empty intermediate values. @@ -162,7 +167,8 @@ test('clicking Review return forwards a properly shaped payload', async () => { // Change reason away from default so it gets serialized const reason = within(screen.getAllByTestId('return-items-modal-item-row')[1]).getByLabelText( - /reason/i + /reason for /i, + {selector: 'select'} ) await user.selectOptions(reason, 'Defect') @@ -172,12 +178,14 @@ test('clicking Review return forwards a properly shaped payload', async () => { test('omits reason from payload when shopper kept the OMS default', () => { const selection = {'item-2': {checked: true, quantity: 1, reasonCode: 'Wrong size'}} - expect(buildReturnPayload(selection, 'Wrong size')).toEqual([{itemId: 'item-2', quantity: 1}]) + expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([ + {itemId: 'item-2', quantity: 1} + ]) }) test('serializes quantity as a JS Number (not a string)', () => { const selection = {'item-2': {checked: true, quantity: '3', reasonCode: 'Defect'}} - const [row] = buildReturnPayload(selection, 'Wrong size') + const [row] = buildReturnProductItems(selection, 'Wrong size') expect(typeof row.quantity).toBe('number') expect(row.quantity).toBe(3) }) @@ -188,6 +196,37 @@ test('renders skeleton placeholders while OMS metadata is loading', () => { expect(screen.getByTestId('return-items-modal-loading')).toBeInTheDocument() }) +test('drops malformed rows (non-numeric or zero quantity) from the payload', () => { + expect( + buildReturnProductItems( + { + 'item-a': {checked: true, quantity: '', reasonCode: 'Defect'}, + 'item-b': {checked: true, quantity: 'abc', reasonCode: 'Defect'}, + 'item-c': {checked: true, quantity: 0, reasonCode: 'Defect'}, + 'item-d': {checked: true, quantity: 2, reasonCode: 'Defect'} + }, + 'Wrong size' + ) + ).toEqual([{itemId: 'item-d', quantity: 2, reason: 'Defect'}]) +}) + +test('backfills the OMS default reason on already-checked rows when metadata is available', async () => { + // Models the case where the parent has a checked row whose reasonCode + // was never set (e.g. selection rehydrated from URL state in a future + // WI, or initial open where the user clicked the row before reasons + // resolved). The modal's mount-time backfill effect must apply the + // OMS default so the row is valid without forcing a re-pick. + const initial = {'item-1': {checked: true, quantity: 1, reasonCode: undefined}} + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('return-items-modal-review')).toBeEnabled()) + const row = screen.getAllByTestId('return-items-modal-item-row')[0] + expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue( + 'Wrong size' + ) +}) + test('renders an error alert + Retry when OMS metadata fails', async () => { const user = userEvent.setup() const refetch = jest.fn() diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx index da929b8e2b..e439c7e5e9 100644 --- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx +++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx @@ -191,7 +191,8 @@ const AccountOrderDetail = () => { const showMultiShipmentsFromOmsOnly = isOmsOrder && hasOmsShipment && isMultiShipmentOrder const returnableItems = useMemo(() => getReturnableItems(order), [order]) - const showStartReturn = isRegistered && returnableItems.length > 0 + const ownsOrder = order?.customerInfo?.customerId === customerId + const showStartReturn = isRegistered && ownsOrder && returnableItems.length > 0 const {data: omsMetaData} = useOmsMetaData({parameters: {}}, {enabled: isOmsOrder && onClient}) diff --git a/packages/template-retail-react-app/app/pages/account/orders.test.js b/packages/template-retail-react-app/app/pages/account/orders.test.js index 965d81858d..687aade54f 100644 --- a/packages/template-retail-react-app/app/pages/account/orders.test.js +++ b/packages/template-retail-react-app/app/pages/account/orders.test.js @@ -472,6 +472,12 @@ describe('Return Items CTA (W-22821836 / W-22821837)', () => { // is no client-side status allowlist. const createReturnEligibleOmsOrder = (overrides = {}) => createMockOmsOrder({ + // The return CTA gates on ownership: order.customerInfo.customerId + // must match the current shopper's id. With bypassAuth=true, the + // commerce-sdk-react auth init derives customer_id from the + // registered token's `rcid` claim (`abUMsavpD9Y6jW00di2SjxGCMU` — + // see registeredUserPayload.isb in test-utils.js). + customerInfo: {customerId: 'abUMsavpD9Y6jW00di2SjxGCMU'}, productItems: [ { productId: 'returnable-1', @@ -523,13 +529,26 @@ describe('Return Items CTA (W-22821836 / W-22821837)', () => { expect(screen.queryByTestId('account-order-detail-start-return')).not.toBeInTheDocument() }) - test('clicking Return Items opens the return-order modal (W-22821837)', async () => { + test('clicking Return Items opens the return-items modal (W-22821837)', async () => { const user = userEvent.setup() setupOrderDetailsPage(createReturnEligibleOmsOrder()) const cta = await screen.findByTestId('account-order-detail-start-return') await user.click(cta) expect(await screen.findByText(/return items from order #/i)).toBeInTheDocument() }) + + test('does NOT render the CTA when the order belongs to a different customer', async () => { + // Defense-in-depth: even if useOrder somehow returned an order owned + // by a different customer, the trigger must not render. Mirrors the + // ownership guard already in place on the cancel-order CTA. + setupOrderDetailsPage( + createReturnEligibleOmsOrder({ + customerInfo: {customerId: 'someone-else'} + }) + ) + expect(await screen.findByTestId('account-order-details-page')).toBeInTheDocument() + expect(screen.queryByTestId('account-order-detail-start-return')).not.toBeInTheDocument() + }) }) describe('OMS/SOM Integration - Order History', () => { diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 111b37861a..4c699b65b5 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -4316,7 +4316,7 @@ }, { "type": 0, - "value": ", up to " + "value": ", " }, { "offset": 0, @@ -4328,7 +4328,7 @@ }, { "type": 0, - "value": " available" + "value": " unit" } ] }, @@ -4339,7 +4339,7 @@ }, { "type": 0, - "value": " available" + "value": " units" } ] } @@ -4350,7 +4350,7 @@ }, { "type": 0, - "value": " to return" + "value": " available to return" } ], "return_items_modal.label.quantity": [ @@ -4359,12 +4359,52 @@ "value": "Quantity" } ], + "return_items_modal.label.quantity_decrement": [ + { + "type": 0, + "value": "Decrease quantity for " + }, + { + "type": 1, + "value": "name" + } + ], + "return_items_modal.label.quantity_for": [ + { + "type": 0, + "value": "Quantity for " + }, + { + "type": 1, + "value": "name" + } + ], + "return_items_modal.label.quantity_increment": [ + { + "type": 0, + "value": "Increase quantity for " + }, + { + "type": 1, + "value": "name" + } + ], "return_items_modal.label.reason": [ { "type": 0, "value": "Reason" } ], + "return_items_modal.label.reason_for": [ + { + "type": 0, + "value": "Reason for " + }, + { + "type": 1, + "value": "name" + } + ], "return_items_modal.placeholder.select_reason": [ { "type": 0, @@ -4386,7 +4426,7 @@ }, { "type": 0, - "value": " available" + "value": " unit" } ] }, @@ -4397,7 +4437,7 @@ }, { "type": 0, - "value": " available" + "value": " units" } ] } @@ -4408,7 +4448,7 @@ }, { "type": 0, - "value": " to return" + "value": " available to return" } ], "return_items_modal.text.loading_reasons": [ diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 111b37861a..4c699b65b5 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -4316,7 +4316,7 @@ }, { "type": 0, - "value": ", up to " + "value": ", " }, { "offset": 0, @@ -4328,7 +4328,7 @@ }, { "type": 0, - "value": " available" + "value": " unit" } ] }, @@ -4339,7 +4339,7 @@ }, { "type": 0, - "value": " available" + "value": " units" } ] } @@ -4350,7 +4350,7 @@ }, { "type": 0, - "value": " to return" + "value": " available to return" } ], "return_items_modal.label.quantity": [ @@ -4359,12 +4359,52 @@ "value": "Quantity" } ], + "return_items_modal.label.quantity_decrement": [ + { + "type": 0, + "value": "Decrease quantity for " + }, + { + "type": 1, + "value": "name" + } + ], + "return_items_modal.label.quantity_for": [ + { + "type": 0, + "value": "Quantity for " + }, + { + "type": 1, + "value": "name" + } + ], + "return_items_modal.label.quantity_increment": [ + { + "type": 0, + "value": "Increase quantity for " + }, + { + "type": 1, + "value": "name" + } + ], "return_items_modal.label.reason": [ { "type": 0, "value": "Reason" } ], + "return_items_modal.label.reason_for": [ + { + "type": 0, + "value": "Reason for " + }, + { + "type": 1, + "value": "name" + } + ], "return_items_modal.placeholder.select_reason": [ { "type": 0, @@ -4386,7 +4426,7 @@ }, { "type": 0, - "value": " available" + "value": " unit" } ] }, @@ -4397,7 +4437,7 @@ }, { "type": 0, - "value": " available" + "value": " units" } ] } @@ -4408,7 +4448,7 @@ }, { "type": 0, - "value": " to return" + "value": " available to return" } ], "return_items_modal.text.loading_reasons": [ diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index e779474565..fab606665e 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1809,19 +1809,31 @@ "defaultMessage": "Select at least one item and choose a reason to continue." }, "return_items_modal.label.item_checkbox": { - "defaultMessage": "{name}, up to {count, plural, one {# available} other {# available}} to return" + "defaultMessage": "{name}, {count, plural, one {# unit} other {# units}} available to return" }, "return_items_modal.label.quantity": { "defaultMessage": "Quantity" }, + "return_items_modal.label.quantity_decrement": { + "defaultMessage": "Decrease quantity for {name}" + }, + "return_items_modal.label.quantity_for": { + "defaultMessage": "Quantity for {name}" + }, + "return_items_modal.label.quantity_increment": { + "defaultMessage": "Increase quantity for {name}" + }, "return_items_modal.label.reason": { "defaultMessage": "Reason" }, + "return_items_modal.label.reason_for": { + "defaultMessage": "Reason for {name}" + }, "return_items_modal.placeholder.select_reason": { "defaultMessage": "Select a reason" }, "return_items_modal.text.available_to_return": { - "defaultMessage": "Up to {count, plural, one {# available} other {# available}} to return" + "defaultMessage": "Up to {count, plural, one {# unit} other {# units}} available to return" }, "return_items_modal.text.loading_reasons": { "defaultMessage": "Loading return reasons…" diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index e779474565..fab606665e 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1809,19 +1809,31 @@ "defaultMessage": "Select at least one item and choose a reason to continue." }, "return_items_modal.label.item_checkbox": { - "defaultMessage": "{name}, up to {count, plural, one {# available} other {# available}} to return" + "defaultMessage": "{name}, {count, plural, one {# unit} other {# units}} available to return" }, "return_items_modal.label.quantity": { "defaultMessage": "Quantity" }, + "return_items_modal.label.quantity_decrement": { + "defaultMessage": "Decrease quantity for {name}" + }, + "return_items_modal.label.quantity_for": { + "defaultMessage": "Quantity for {name}" + }, + "return_items_modal.label.quantity_increment": { + "defaultMessage": "Increase quantity for {name}" + }, "return_items_modal.label.reason": { "defaultMessage": "Reason" }, + "return_items_modal.label.reason_for": { + "defaultMessage": "Reason for {name}" + }, "return_items_modal.placeholder.select_reason": { "defaultMessage": "Select a reason" }, "return_items_modal.text.available_to_return": { - "defaultMessage": "Up to {count, plural, one {# available} other {# available}} to return" + "defaultMessage": "Up to {count, plural, one {# unit} other {# units}} available to return" }, "return_items_modal.text.loading_reasons": { "defaultMessage": "Loading return reasons…" From 0486e331abffda8c9e4f472baa6d8f9753feeb63 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 08:01:35 -0400 Subject: [PATCH 07/14] @W-22821837@ Remove CHANGELOG entry for return-items modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the W-22821836 cleanup (e382e897a) — reviewers prefer the modal work be tracked via the GUS WI rather than a separate CHANGELOG line. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/template-retail-react-app/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index d020787ab9..c1297f009d 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +1,4 @@ ## v10.1.0-dev -- [Feature] Add return-item-selection modal on order detail. Registered users can select which items to return, set quantity, and pick a reason from `getOmsMetaData`. Gated by the per-item `quantityAvailableToReturn` eligibility signal and a customer-ownership check on the order. The "Return Items" button on order detail now opens this modal (W-22821837). - [Feature] Add cancel order modal on order detail page. Registered users with OMS enabled can cancel orders that are not yet shipped. Modal includes reason code selection and inline success/error feedback. Gated behind `app.oms.enabled` config flag. [#3861](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3861) - [Bugfix] Render the search refinements panels open during server-side rendering. ChakraUI v2's Accordion forces every item closed in SSR (its open state depends on a post-mount layout effect that never runs on the server), so the panels opened only after hydration — a late relayout that could become the PLP largest-contentful-paint element. Replaced the Accordion with a controlled disclosure that honors its open state server-side. - [Bugfix] Memoize the search refinements panel so it stops re-rendering on every parent render when the filter set is unchanged, improving PLP render stability. [#3855](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3855) From 54b080ee66fb1e5eea62abd9895055f20d550840 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 15:27:04 -0400 Subject: [PATCH 08/14] @W-22821837@ Regenerate en-XA pseudo-locale translations CI runs `npm run build-translations` which chains `extract-default-translations` + `compile-translations` + `compile-translations:pseudo`, then the smoke-test job fails the build if `git diff --exit-code` finds uncommitted changes. I had only run the first two locally, leaving `en-XA.json` (the pseudo locale) stale relative to the renamed `return_items_modal.*` message ids and the new contextual a11y labels (`quantity_for`, `reason_for`, `quantity_increment`, `quantity_decrement`). Regenerated via `npm run build-translations`. No code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/translations/compiled/en-XA.json | 346 +++++++++++++++++- 1 file changed, 331 insertions(+), 15 deletions(-) diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 1c3413fb22..65ba584d15 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -550,21 +550,7 @@ }, { "type": 0, - "value": "Şŧȧȧřŧ řḗḗŧŭŭřƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "account_order_detail.button.start_return_disabled_explanation": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Řḗḗŧŭŭřƞş ƈǿǿḿīƞɠ şǿǿǿǿƞ" + "value": "Řḗḗŧŭŭřƞ Īŧḗḗḿş" }, { "type": 0, @@ -9033,6 +9019,336 @@ "value": "]" } ], + "return_items_modal.button.cancel": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƈȧȧƞƈḗḗŀ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.button.retry": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗŧřẏ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.button.review_return": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗṽīḗḗẇ řḗḗŧŭŭřƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.heading.return_items": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗŧŭŭřƞ īŧḗḗḿş ƒřǿǿḿ ǿǿřḓḗḗř #" + }, + { + "type": 1, + "value": "orderNo" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.hint.review_disabled": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Şḗḗŀḗḗƈŧ ȧȧŧ ŀḗḗȧȧşŧ ǿǿƞḗḗ īŧḗḗḿ ȧȧƞḓ ƈħǿǿǿǿşḗḗ ȧȧ řḗḗȧȧşǿǿƞ ŧǿǿ ƈǿǿƞŧīƞŭŭḗḗ." + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.item_checkbox": [ + { + "type": 0, + "value": "[" + }, + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": ", " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " ŭŭƞīŧ" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " ŭŭƞīŧş" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " ȧȧṽȧȧīŀȧȧƀŀḗḗ ŧǿǿ řḗḗŧŭŭřƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.quantity": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɋŭŭȧȧƞŧīŧẏ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.quantity_decrement": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḓḗḗƈřḗḗȧȧşḗḗ ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " + }, + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.quantity_for": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " + }, + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.quantity_increment": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Īƞƈřḗḗȧȧşḗḗ ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " + }, + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.reason": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗȧȧşǿǿƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.label.reason_for": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗȧȧşǿǿƞ ƒǿǿř " + }, + { + "type": 1, + "value": "name" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.placeholder.select_reason": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Şḗḗŀḗḗƈŧ ȧȧ řḗḗȧȧşǿǿƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.text.available_to_return": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŭƥ ŧǿǿ " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " ŭŭƞīŧ" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " ŭŭƞīŧş" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "count" + }, + { + "type": 0, + "value": " ȧȧṽȧȧīŀȧȧƀŀḗḗ ŧǿǿ řḗḗŧŭŭřƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.text.loading_reasons": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŀǿǿȧȧḓīƞɠ řḗḗŧŭŭřƞ řḗḗȧȧşǿǿƞş…" + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.text.reasons_error": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẇḗḗ ƈǿǿŭŭŀḓ ƞǿǿŧ ŀǿǿȧȧḓ ŧħḗḗ řḗḗŧŭŭřƞ řḗḗȧȧşǿǿƞş. Ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ." + }, + { + "type": 0, + "value": "]" + } + ], + "return_items_modal.text.select_items_description": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Şḗḗŀḗḗƈŧ ŧħḗḗ īŧḗḗḿş ẏǿǿŭŭ ẇȧȧƞŧ ŧǿǿ řḗḗŧŭŭřƞ ȧȧƞḓ ŧḗḗŀŀ ŭŭş ẇħẏ." + }, + { + "type": 0, + "value": "]" + } + ], "search.action.cancel": [ { "type": 0, From cddc1f3111805234c75a2bc2cd08979fe7b16d6f Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 21:42:20 -0400 Subject: [PATCH 09/14] @W-22821837@ Fix stale closure in return-items-modal updateRow Switch updateRow and handleToggle to functional setState updaters so two checkbox toggles dispatched in the same React batch both observe the latest selection. Add a regression test that fires two toggles inside a single act() and asserts both rows end up checked. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/return-items-modal/index.jsx | 30 ++++++++++++------- .../return-items-modal/index.test.js | 16 +++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index 6aba4820e7..2b89c09f78 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -267,26 +267,36 @@ const ReturnItemsModal = ({ const reasons = reviewQuery.data?.returnReasonCodes || [] const defaultReasonCode = useMemo(() => findDefaultReasonCode(reasons), [reasons]) + // Functional updater so two toggles dispatched in the same React batch + // both observe the latest selection. Closing over `selection` would cause + // the second update to spread a stale object and clobber the first. const updateRow = useCallback( (itemId, patch) => { - const next = {...(selection || {})} - next[itemId] = {...(next[itemId] || {}), ...patch} - onSelectionChange(next) + onSelectionChange((prev) => ({ + ...(prev || {}), + [itemId]: {...((prev || {})[itemId] || {}), ...patch} + })) }, - [selection, onSelectionChange] + [onSelectionChange] ) const handleToggle = useCallback( (item, checked) => { const itemId = item.itemId - const existing = selection?.[itemId] - updateRow(itemId, { - checked, - quantity: existing?.quantity ?? 1, - reasonCode: existing?.reasonCode || (checked ? defaultReasonCode : undefined) + onSelectionChange((prev) => { + const existing = (prev || {})[itemId] + return { + ...(prev || {}), + [itemId]: { + checked, + quantity: existing?.quantity ?? 1, + reasonCode: + existing?.reasonCode || (checked ? defaultReasonCode : undefined) + } + } }) }, - [selection, updateRow, defaultReasonCode] + [onSelectionChange, defaultReasonCode] ) const handleQuantityChange = useCallback( diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js index 97c0444e02..d3d9880de6 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js @@ -6,7 +6,7 @@ */ import React, {useState} from 'react' import PropTypes from 'prop-types' -import {fireEvent, screen, waitFor, within} from '@testing-library/react' +import {act, fireEvent, screen, waitFor, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal' @@ -227,6 +227,20 @@ test('backfills the OMS default reason on already-checked rows when metadata is ) }) +test('two toggles in the same React batch both stick (no stale closure)', async () => { + // Regression: updateRow used to close over `selection`, so two checkbox + // toggles dispatched in a single render cycle each spread the same stale + // object — only the second won, silently dropping the first row. + renderWithProviders() + const [first, second] = screen.getAllByRole('checkbox') + act(() => { + fireEvent.click(first) + fireEvent.click(second) + }) + await waitFor(() => expect(first).toBeChecked()) + expect(second).toBeChecked() +}) + test('renders an error alert + Retry when OMS metadata fails', async () => { const user = userEvent.setup() const refetch = jest.fn() From 8bafd33cd8f9d7134758bcc24fb0f949e5ad1e73 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 21:45:23 -0400 Subject: [PATCH 10/14] @W-22821837@ Reuse QuantityPicker in return-items modal Drop the inline QuantityField in favor of the shared QuantityPicker component, which already provides mobile-friendly select-on-focus, keyboard activation on the +/- buttons, and i18n strings for the increment/decrement aria-labels. Remove the now-unused quantity_for, quantity_increment, and quantity_decrement message ids from the return-items-modal catalog and regenerate en-US, en-GB, and en-XA translations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../return-items-modal/constants.js | 12 --- .../components/return-items-modal/index.jsx | 85 +++---------------- .../return-items-modal/index.test.js | 11 +-- .../static/translations/compiled/en-GB.json | 30 ------- .../static/translations/compiled/en-US.json | 30 ------- .../static/translations/compiled/en-XA.json | 54 ------------ .../translations/en-GB.json | 9 -- .../translations/en-US.json | 9 -- 8 files changed, 18 insertions(+), 222 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/constants.js b/packages/template-retail-react-app/app/components/return-items-modal/constants.js index 92482c56ad..798d304edd 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/constants.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/constants.js @@ -24,18 +24,6 @@ export const messages = defineMessages({ defaultMessage: 'Quantity', id: 'return_items_modal.label.quantity' }, - quantityFor: { - defaultMessage: 'Quantity for {name}', - id: 'return_items_modal.label.quantity_for' - }, - quantityIncrement: { - defaultMessage: 'Increase quantity for {name}', - id: 'return_items_modal.label.quantity_increment' - }, - quantityDecrement: { - defaultMessage: 'Decrease quantity for {name}', - id: 'return_items_modal.label.quantity_decrement' - }, reasonLabel: { defaultMessage: 'Reason', id: 'return_items_modal.label.reason' diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index 2b89c09f78..77c8655799 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -26,7 +26,6 @@ import { FormControl, FormLabel, HStack, - Input, Modal, ModalBody, ModalCloseButton, @@ -40,9 +39,9 @@ import { Stack, Text, VisuallyHidden, - useBreakpointValue, - useNumberInput + useBreakpointValue } from '@salesforce/retail-react-app/app/components/shared/ui' +import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity-picker' import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/product-utils' import { messages, @@ -67,68 +66,6 @@ const formatVariationSummary = (item) => { .join(' / ') } -/** - * Quantity stepper field. Uses `useNumberInput` + plain `Input` per repo - * convention (Chakra's `NumberInput` is not exported from the shared UI). The - * underlying field clamps on blur and on increment/decrement. - */ -const QuantityField = ({value, max, onChange, ariaLabel, productName, id}) => { - const intl = useIntl() - const {getInputProps, getIncrementButtonProps, getDecrementButtonProps} = useNumberInput({ - value, - min: 1, - max, - step: 1, - clampValueOnBlur: true, - precision: 0, - focusInputOnChange: false, - onChange: (_str, num) => { - // Chakra's onChange fires with both the string and numeric value; - // we only persist when we have a finite number to avoid storing - // "" while the field is being edited. - if (Number.isFinite(num)) onChange(num) - } - }) - - const dec = getDecrementButtonProps({ - variant: 'outline', - size: 'sm', - 'aria-label': intl.formatMessage(messages.quantityDecrement, {name: productName}) - }) - const inc = getIncrementButtonProps({ - variant: 'outline', - size: 'sm', - 'aria-label': intl.formatMessage(messages.quantityIncrement, {name: productName}) - }) - const input = getInputProps({ - id, - textAlign: 'center', - maxWidth: '64px', - size: 'sm', - 'aria-label': ariaLabel - }) - - return ( - - - - - - ) -} -QuantityField.propTypes = { - value: PropTypes.number.isRequired, - max: PropTypes.number.isRequired, - onChange: PropTypes.func.isRequired, - ariaLabel: PropTypes.string.isRequired, - productName: PropTypes.string.isRequired, - id: PropTypes.string.isRequired -} - const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onReasonChange}) => { const intl = useIntl() const checkboxId = useId() @@ -165,18 +102,20 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe {row?.checked && ( - - + + - { + if (Number.isFinite(num)) onQuantityChange(num) + }} productName={displayName} /> diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js index d3d9880de6..ac5d7f138f 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js @@ -124,13 +124,14 @@ test('toggling a row expands it and pre-selects the OMS default reason', async ( await user.click(checkboxes[0]) const row = screen.getAllByTestId('return-items-modal-item-row')[0] - // Contextual aria-labels include the product name so screen readers can - // distinguish rows. Scope queries to the input element to avoid matching - // the +/- buttons, which also carry "Quantity for {name}"-style labels. + // Reason carries a per-row aria-label (so screen readers can distinguish + // the dropdowns). Quantity reuses the shared QuantityPicker which sets + // aria-label="Quantity"; we scope to the row + the input element to + // disambiguate from the +/- buttons. expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue( 'Wrong size' ) - expect(within(row).getByLabelText(/quantity for /i, {selector: 'input'})).toHaveValue('1') + expect(within(row).getByLabelText(/^quantity$/i, {selector: 'input'})).toHaveValue('1') }) test('quantity field clamps to the available-to-return ceiling', async () => { @@ -140,7 +141,7 @@ test('quantity field clamps to the available-to-return ceiling', async () => { await user.click(checkboxes[0]) // item-1 has max 2 const row = screen.getAllByTestId('return-items-modal-item-row')[0] - const qty = within(row).getByLabelText(/quantity for /i, {selector: 'input'}) + const qty = within(row).getByLabelText(/^quantity$/i, {selector: 'input'}) // Set value directly then blur — Chakra's useNumberInput clamps on blur, // and userEvent.clear() doesn't propagate to a controlled NumberInput // because the hook rejects empty intermediate values. diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 4c699b65b5..7f9e176a9a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -4359,36 +4359,6 @@ "value": "Quantity" } ], - "return_items_modal.label.quantity_decrement": [ - { - "type": 0, - "value": "Decrease quantity for " - }, - { - "type": 1, - "value": "name" - } - ], - "return_items_modal.label.quantity_for": [ - { - "type": 0, - "value": "Quantity for " - }, - { - "type": 1, - "value": "name" - } - ], - "return_items_modal.label.quantity_increment": [ - { - "type": 0, - "value": "Increase quantity for " - }, - { - "type": 1, - "value": "name" - } - ], "return_items_modal.label.reason": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 4c699b65b5..7f9e176a9a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -4359,36 +4359,6 @@ "value": "Quantity" } ], - "return_items_modal.label.quantity_decrement": [ - { - "type": 0, - "value": "Decrease quantity for " - }, - { - "type": 1, - "value": "name" - } - ], - "return_items_modal.label.quantity_for": [ - { - "type": 0, - "value": "Quantity for " - }, - { - "type": 1, - "value": "name" - } - ], - "return_items_modal.label.quantity_increment": [ - { - "type": 0, - "value": "Increase quantity for " - }, - { - "type": 1, - "value": "name" - } - ], "return_items_modal.label.reason": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 65ba584d15..91aebabedb 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -9159,60 +9159,6 @@ "value": "]" } ], - "return_items_modal.label.quantity_decrement": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ḓḗḗƈřḗḗȧȧşḗḗ ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " - }, - { - "type": 1, - "value": "name" - }, - { - "type": 0, - "value": "]" - } - ], - "return_items_modal.label.quantity_for": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " - }, - { - "type": 1, - "value": "name" - }, - { - "type": 0, - "value": "]" - } - ], - "return_items_modal.label.quantity_increment": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Īƞƈřḗḗȧȧşḗḗ ɋŭŭȧȧƞŧīŧẏ ƒǿǿř " - }, - { - "type": 1, - "value": "name" - }, - { - "type": 0, - "value": "]" - } - ], "return_items_modal.label.reason": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index fab606665e..af68e09582 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1814,15 +1814,6 @@ "return_items_modal.label.quantity": { "defaultMessage": "Quantity" }, - "return_items_modal.label.quantity_decrement": { - "defaultMessage": "Decrease quantity for {name}" - }, - "return_items_modal.label.quantity_for": { - "defaultMessage": "Quantity for {name}" - }, - "return_items_modal.label.quantity_increment": { - "defaultMessage": "Increase quantity for {name}" - }, "return_items_modal.label.reason": { "defaultMessage": "Reason" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index fab606665e..af68e09582 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1814,15 +1814,6 @@ "return_items_modal.label.quantity": { "defaultMessage": "Quantity" }, - "return_items_modal.label.quantity_decrement": { - "defaultMessage": "Decrease quantity for {name}" - }, - "return_items_modal.label.quantity_for": { - "defaultMessage": "Quantity for {name}" - }, - "return_items_modal.label.quantity_increment": { - "defaultMessage": "Increase quantity for {name}" - }, "return_items_modal.label.reason": { "defaultMessage": "Reason" }, From 59fc68c27ac9d9ae4f4a3d9cb8a2e18537f989bc Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 21:46:54 -0400 Subject: [PATCH 11/14] @W-22821837@ Drop redundant useProducts call for return-items modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OMS-expanded order already carries productName, variationAttributes, and variationValues on each line item, so the gated useProducts fetch added nothing the modal could not derive locally. Removing it also avoids the SCAPI /products 24-id ceiling that the call would have hit on large orders. Pass returnableItems straight through to ReturnItemsModal — the formatVariationSummary helper already reads variationAttributes / variationValues directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/pages/account/order-detail.jsx | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx index e439c7e5e9..ad1de1cb37 100644 --- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx +++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx @@ -196,33 +196,6 @@ const AccountOrderDetail = () => { const {data: omsMetaData} = useOmsMetaData({parameters: {}}, {enabled: isOmsOrder && onClient}) - // Fetch product detail for the items the shopper can return so the modal - // can render a "" line. Gated on modal open + at least - // one returnable item to keep the order-detail page itself cheap. - const returnableProductIds = useMemo( - () => returnableItems.map((i) => i.productId).filter(Boolean), - [returnableItems] - ) - const {data: returnableProducts} = useProducts( - {parameters: {ids: returnableProductIds.join(',')}}, - { - enabled: onClient && isReturnModalOpen && returnableProductIds.length > 0, - select: (result) => - result?.data?.reduce((acc, p) => { - acc[p.id] = p - return acc - }, {}) - } - ) - const enrichedReturnableItems = useMemo( - () => - returnableItems.map((item) => ({ - ...(returnableProducts?.[item.productId] || {}), - ...item - })), - [returnableItems, returnableProducts] - ) - const handleCloseReturnModal = useCallback(() => { closeReturnModal() setReturnSelection({}) @@ -785,7 +758,7 @@ const AccountOrderDetail = () => { isOpen={isReturnModalOpen} onClose={handleCloseReturnModal} order={order} - returnableItems={enrichedReturnableItems} + returnableItems={returnableItems} selection={returnSelection} onSelectionChange={setReturnSelection} onReview={handleReviewReturn} From ebc09cd6d1d4262e154d5ede4263aba9e650fcbf Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 21:48:28 -0400 Subject: [PATCH 12/14] @W-22821837@ Memoize ReturnableItemRow and stabilize row callbacks Wrap ReturnableItemRow in React.memo so it skips re-rendering when neither its item nor its row state changed. Move the inline arrow wrappers (onToggle / onQuantityChange / onReasonChange) into useCallback inside the row itself, and pass the parent's stable handlers straight through, so the per-row props no longer change on every keystroke. This compounds with the FIX-1 functional-updater change: now the parent handlers depend only on [onSelectionChange, defaultReasonCode] and the child handlers depend only on [parent handler, itemId], so unrelated rows do not re-render while one row's quantity is being edited. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/return-items-modal/index.jsx | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index 77c8655799..d7084de492 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -66,7 +66,14 @@ const formatVariationSummary = (item) => { .join(' / ') } -const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onReasonChange}) => { +const ReturnableItemRow = React.memo(function ReturnableItemRow({ + item, + row, + reasons, + onToggle, + onQuantityChange, + onReasonChange +}) { const intl = useIntl() const checkboxId = useId() const quantityId = useId() @@ -74,6 +81,22 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe const max = item.omsData?.quantityAvailableToReturn ?? 1 const variation = formatVariationSummary(item) const displayName = variation ? `${item.productName} — ${variation}` : item.productName || '' + const itemId = item.itemId + + const handleCheckboxChange = useCallback( + (e) => onToggle(item, e.target.checked), + [onToggle, item] + ) + const handleQuantityPickerChange = useCallback( + (_str, num) => { + if (Number.isFinite(num)) onQuantityChange(itemId, num) + }, + [onQuantityChange, itemId] + ) + const handleReasonSelectChange = useCallback( + (e) => onReasonChange(itemId, e.target.value), + [onReasonChange, itemId] + ) return ( onToggle(e.target.checked)} + onChange={handleCheckboxChange} aria-label={intl.formatMessage(messages.itemCheckboxLabel, { name: displayName, count: max @@ -113,9 +136,7 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe step={1} clampValueOnBlur={true} precision={0} - onChange={(_str, num) => { - if (Number.isFinite(num)) onQuantityChange(num) - }} + onChange={handleQuantityPickerChange} productName={displayName} /> @@ -127,7 +148,7 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe id={reasonId} size="sm" value={row.reasonCode || ''} - onChange={(e) => onReasonChange(e.target.value)} + onChange={handleReasonSelectChange} aria-label={intl.formatMessage(messages.reasonFor, { name: displayName })} @@ -148,7 +169,7 @@ const ReturnableItemRow = ({item, row, reasons, onToggle, onQuantityChange, onRe ) -} +}) ReturnableItemRow.propTypes = { item: PropTypes.object.isRequired, row: PropTypes.object, @@ -319,9 +340,9 @@ const ReturnItemsModal = ({ item={item} row={selection?.[item.itemId]} reasons={reasons} - onToggle={(checked) => handleToggle(item, checked)} - onQuantityChange={(qty) => handleQuantityChange(item.itemId, qty)} - onReasonChange={(code) => handleReasonChange(item.itemId, code)} + onToggle={handleToggle} + onQuantityChange={handleQuantityChange} + onReasonChange={handleReasonChange} /> ))} From eb036e4cbb5808b3195e79cfb34acf32d240ae00 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Thu, 18 Jun 2026 21:50:31 -0400 Subject: [PATCH 13/14] @W-22821837@ Move buildReturnProductItems into return-utils Relocate the helper out of return-items-modal/constants.js (which now holds only intl message definitions) into the existing return-utils.js alongside getReturnableItems. Update the modal import and migrate the helper-only unit tests to return-utils.test.js so the modal test file stays focused on UI behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../return-items-modal/constants.js | 27 ---------- .../components/return-items-modal/index.jsx | 6 +-- .../return-items-modal/index.test.js | 29 ----------- .../app/utils/return-utils.js | 27 ++++++++++ .../app/utils/return-utils.test.js | 50 ++++++++++++++++++- 5 files changed, 78 insertions(+), 61 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/constants.js b/packages/template-retail-react-app/app/components/return-items-modal/constants.js index 798d304edd..eb7c8f840b 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/constants.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/constants.js @@ -65,30 +65,3 @@ export const messages = defineMessages({ id: 'return_items_modal.label.item_checkbox' } }) - -/** - * Build the `productItems` array for an `OmsReturnOrderRequest`. Caller wraps - * it: `body: {productItems: buildReturnProductItems(selection, defaultReasonCode)}`. - * - * Quantity is `number` / `format: double` per oms.yaml. UX is integer-valued - * but we serialize as a JS Number, not a string. Reason is omitted when the - * shopper kept the OMS-default code so the server applies the default per the - * API contract. - * - * Rows without a positive numeric quantity are dropped — the upstream UI is - * already gated by `isSelectionValid`, but this hardens reuse from elsewhere - * (e.g. step 2's review modal in W-22821838) against malformed state. - */ -export const buildReturnProductItems = (selection, defaultReasonCode) => - Object.entries(selection || {}) - .filter(([, row]) => row?.checked) - .reduce((items, [itemId, row]) => { - const quantity = Number(row.quantity) - if (!Number.isFinite(quantity) || quantity <= 0) return items - const item = {itemId, quantity} - if (row.reasonCode && row.reasonCode !== defaultReasonCode) { - item.reason = row.reasonCode - } - items.push(item) - return items - }, []) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index d7084de492..904154e5a2 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -43,10 +43,8 @@ import { } from '@salesforce/retail-react-app/app/components/shared/ui' import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity-picker' import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/product-utils' -import { - messages, - buildReturnProductItems -} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' +import {buildReturnProductItems} from '@salesforce/retail-react-app/app/utils/return-utils' +import {messages} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' const onClient = typeof window !== 'undefined' diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js index ac5d7f138f..43b30e9098 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.test.js @@ -10,7 +10,6 @@ import {act, fireEvent, screen, waitFor, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal' -import {buildReturnProductItems} from '@salesforce/retail-react-app/app/components/return-items-modal/constants' let mockOmsMetaData = { data: { @@ -177,40 +176,12 @@ test('clicking Review return forwards a properly shaped payload', async () => { expect(onReview).toHaveBeenCalledWith([{itemId: 'item-2', quantity: 1, reason: 'Defect'}]) }) -test('omits reason from payload when shopper kept the OMS default', () => { - const selection = {'item-2': {checked: true, quantity: 1, reasonCode: 'Wrong size'}} - expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([ - {itemId: 'item-2', quantity: 1} - ]) -}) - -test('serializes quantity as a JS Number (not a string)', () => { - const selection = {'item-2': {checked: true, quantity: '3', reasonCode: 'Defect'}} - const [row] = buildReturnProductItems(selection, 'Wrong size') - expect(typeof row.quantity).toBe('number') - expect(row.quantity).toBe(3) -}) - test('renders skeleton placeholders while OMS metadata is loading', () => { mockOmsMetaData = {data: undefined, isLoading: true, isError: false, refetch: jest.fn()} renderWithProviders() expect(screen.getByTestId('return-items-modal-loading')).toBeInTheDocument() }) -test('drops malformed rows (non-numeric or zero quantity) from the payload', () => { - expect( - buildReturnProductItems( - { - 'item-a': {checked: true, quantity: '', reasonCode: 'Defect'}, - 'item-b': {checked: true, quantity: 'abc', reasonCode: 'Defect'}, - 'item-c': {checked: true, quantity: 0, reasonCode: 'Defect'}, - 'item-d': {checked: true, quantity: 2, reasonCode: 'Defect'} - }, - 'Wrong size' - ) - ).toEqual([{itemId: 'item-d', quantity: 2, reason: 'Defect'}]) -}) - test('backfills the OMS default reason on already-checked rows when metadata is available', async () => { // Models the case where the parent has a checked row whose reasonCode // was never set (e.g. selection rehydrated from URL state in a future diff --git a/packages/template-retail-react-app/app/utils/return-utils.js b/packages/template-retail-react-app/app/utils/return-utils.js index 875a03b0b2..440513d9aa 100644 --- a/packages/template-retail-react-app/app/utils/return-utils.js +++ b/packages/template-retail-react-app/app/utils/return-utils.js @@ -27,3 +27,30 @@ export const getReturnableItems = (order) => { return Number.isFinite(qty) && qty > 0 }) } + +/** + * Build the `productItems` array for an `OmsReturnOrderRequest`. Caller wraps + * it: `body: {productItems: buildReturnProductItems(selection, defaultReasonCode)}`. + * + * Quantity is `number` / `format: double` per oms.yaml. UX is integer-valued + * but we serialize as a JS Number, not a string. Reason is omitted when the + * shopper kept the OMS-default code so the server applies the default per the + * API contract. + * + * Rows without a positive numeric quantity are dropped — the upstream UI is + * already gated by `isSelectionValid`, but this hardens reuse from elsewhere + * (e.g. step 2's review modal in W-22821838) against malformed state. + */ +export const buildReturnProductItems = (selection, defaultReasonCode) => + Object.entries(selection || {}) + .filter(([, row]) => row?.checked) + .reduce((items, [itemId, row]) => { + const quantity = Number(row.quantity) + if (!Number.isFinite(quantity) || quantity <= 0) return items + const item = {itemId, quantity} + if (row.reasonCode && row.reasonCode !== defaultReasonCode) { + item.reason = row.reasonCode + } + items.push(item) + return items + }, []) diff --git a/packages/template-retail-react-app/app/utils/return-utils.test.js b/packages/template-retail-react-app/app/utils/return-utils.test.js index 8d61f5ff8c..570dfe65c9 100644 --- a/packages/template-retail-react-app/app/utils/return-utils.test.js +++ b/packages/template-retail-react-app/app/utils/return-utils.test.js @@ -5,7 +5,10 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {getReturnableItems} from '@salesforce/retail-react-app/app/utils/return-utils' +import { + buildReturnProductItems, + getReturnableItems +} from '@salesforce/retail-react-app/app/utils/return-utils' const item = (id, qtyAvailableToReturn) => ({ productId: id, @@ -56,3 +59,48 @@ describe('getReturnableItems', () => { expect(getReturnableItems(order).map((i) => i.productId)).toEqual(['ok']) }) }) + +describe('buildReturnProductItems', () => { + test('omits reason from payload when shopper kept the OMS default', () => { + const selection = {'item-2': {checked: true, quantity: 1, reasonCode: 'Wrong size'}} + expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([ + {itemId: 'item-2', quantity: 1} + ]) + }) + + test('serializes quantity as a JS Number (not a string)', () => { + const selection = {'item-2': {checked: true, quantity: '3', reasonCode: 'Defect'}} + const [row] = buildReturnProductItems(selection, 'Wrong size') + expect(typeof row.quantity).toBe('number') + expect(row.quantity).toBe(3) + }) + + test('drops malformed rows (non-numeric or zero quantity) from the payload', () => { + expect( + buildReturnProductItems( + { + 'item-a': {checked: true, quantity: '', reasonCode: 'Defect'}, + 'item-b': {checked: true, quantity: 'abc', reasonCode: 'Defect'}, + 'item-c': {checked: true, quantity: 0, reasonCode: 'Defect'}, + 'item-d': {checked: true, quantity: 2, reasonCode: 'Defect'} + }, + 'Wrong size' + ) + ).toEqual([{itemId: 'item-d', quantity: 2, reason: 'Defect'}]) + }) + + test('skips unchecked rows', () => { + const selection = { + 'item-1': {checked: false, quantity: 2, reasonCode: 'Defect'}, + 'item-2': {checked: true, quantity: 1, reasonCode: 'Defect'} + } + expect(buildReturnProductItems(selection, 'Wrong size')).toEqual([ + {itemId: 'item-2', quantity: 1, reason: 'Defect'} + ]) + }) + + test('returns [] for null/undefined selection', () => { + expect(buildReturnProductItems(null, 'Wrong size')).toEqual([]) + expect(buildReturnProductItems(undefined, 'Wrong size')).toEqual([]) + }) +}) From 6e3174f9a8ac213edabc50de5b34ec9b361cd666 Mon Sep 17 00:00:00 2001 From: Jie Dai Date: Mon, 22 Jun 2026 14:06:25 -0400 Subject: [PATCH 14/14] fix(account): a11y review-comment fixes for return-items modal @W-22821837 Address review feedback on the return-item-selection modal: - Review button: use aria-disabled instead of isDisabled so the button stays in the tab order while invalid, letting keyboard/SR users reach the aria-describedby hint that explains why it's disabled. Chakra's _disabled styling still applies (pseudo matches [aria-disabled=true]); handleReview no-ops when invalid. - Subhead is now the modal's accessible description: moved it to lead the ModalBody/DrawerBody so Chakra's auto aria-describedby (-> body id) points at it. Setting aria-describedby on ModalContent is silently overridden by Chakra's getDialogProps, so this mirrors cancel-order-modal. - Tests: assert the disabled hint is reachable (not just present), add a focus-return-on-close assertion, and exercise the mobile Drawer branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/return-items-modal/index.jsx | 47 +++++++--- .../return-items-modal/index.test.js | 87 ++++++++++++++++++- 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx index 904154e5a2..66c1c405f0 100644 --- a/packages/template-retail-react-app/app/components/return-items-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/return-items-modal/index.jsx @@ -301,9 +301,13 @@ const ReturnItemsModal = ({ ) const handleReview = useCallback(() => { + // Guard the action: the button stays focusable while invalid (see the + // aria-disabled note on the Review button) so we must no-op the click + // ourselves rather than rely on the native disabled attribute. + if (!reviewEnabled) return const payload = buildReturnProductItems(selection, defaultReasonCode) onReview(payload) - }, [selection, defaultReasonCode, onReview]) + }, [reviewEnabled, selection, defaultReasonCode, onReview]) const body = reviewQuery.isLoading ? ( @@ -346,15 +350,22 @@ const ReturnItemsModal = ({ ) + // Title sits in the header so Chakra wires it as the dialog's + // `aria-labelledby`. The subhead leads the body — Chakra auto-sets the + // dialog's `aria-describedby` to the ModalBody/DrawerBody id, so keeping + // the subhead first in the body is what actually makes it the accessible + // description (passing `aria-describedby` to ModalContent is silently + // overridden by Chakra's getDialogProps). Mirrors cancel-order-modal. const header = ( - - - - - - - - + + + + ) + + const subhead = ( + + + ) const footer = ( @@ -375,7 +386,13 @@ const ReturnItemsModal = ({ + setIsOpen(false)} + order={baseOrder} + returnableItems={baseOrder.productItems} + selection={selection} + onSelectionChange={setSelection} + onReview={jest.fn()} + /> + + ) +} + +test('returns focus to the trigger when the modal closes', async () => { + const user = userEvent.setup() + renderWithProviders() + + const trigger = screen.getByTestId('return-items-trigger') + await user.click(trigger) + expect(await screen.findByTestId('return-items-modal')).toBeInTheDocument() + + await user.click(screen.getByTestId('return-items-modal-cancel')) + await waitFor(() => expect(screen.queryByTestId('return-items-modal')).not.toBeInTheDocument()) + await waitFor(() => expect(trigger).toHaveFocus()) +}) + +test('renders the bottom-sheet Drawer branch on mobile', async () => { + // base breakpoint → useBreakpointValue returns true → Drawer, not Modal. + useBreakpointValue.mockReturnValue(true) + renderWithProviders() + + expect(await screen.findByTestId('return-items-modal-drawer')).toBeInTheDocument() + expect(screen.queryByTestId('return-items-modal')).not.toBeInTheDocument() + // Same content + footer actions render inside the Drawer branch. + expect(screen.getByText(/return items from order #00123456/i)).toBeInTheDocument() + expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument() + expect(screen.getAllByTestId('return-items-modal-item-row')).toHaveLength(2) + expect(screen.getByTestId('return-items-modal-review')).toBeInTheDocument() +})