From d7693a4c8ce6f5a398bfb2edc35a11693d4a0a61 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 25 Aug 2026 15:42:24 -0300 Subject: [PATCH 1/6] chore: add cancellations list and cancel - revert modal --- src/components/mui/MuiBaseCustomTheme.js | 8 +- .../__tests__/SponsorOrderGrid.test.js | 193 ++++++++++++++++-- .../components/CancelledItems.jsx | 36 ++-- .../components/ChangeQuantityModal.jsx | 117 +++++++++++ .../__tests__/CancelledItems.test.jsx | 8 +- src/components/mui/SponsorOrderGrid/index.js | 132 ++++++------ src/i18n/en.json | 12 +- 7 files changed, 405 insertions(+), 101 deletions(-) create mode 100644 src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx diff --git a/src/components/mui/MuiBaseCustomTheme.js b/src/components/mui/MuiBaseCustomTheme.js index b8689919..27ac10dd 100644 --- a/src/components/mui/MuiBaseCustomTheme.js +++ b/src/components/mui/MuiBaseCustomTheme.js @@ -44,6 +44,12 @@ export const MuiBaseCustomTheme = { height: "24px" } } - } + }, + MuiTooltip: { + tooltip: { + fontSize: "1em", + fontWeight: "400" + }, + }, } }; diff --git a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js index 2579de42..ffa41471 100644 --- a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js +++ b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js @@ -30,15 +30,17 @@ jest.mock("../../../../utils/methods", () => ({ })); import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; +import T from "i18n-react/dist/i18n-react"; import SponsorOrderGrid from "../index"; const makeItem = (overrides = {}) => ({ line_id: 841, quantity: 1, + canceled_quantity: 0, amount: 10000, - canceled_by_id: null, + cancellations: [], type: { id: 146, name: "Booth", code: "BOOTH" }, meta_fields: [], ...overrides @@ -118,53 +120,86 @@ describe("SponsorOrderGrid", () => { expect(screen.getByText("sponsor_order_grid.action")).toBeInTheDocument(); }); - test("renders delete button for active item and calls onCancelForm on click", () => { + test("clicking the action icon opens the change-quantity modal instead of calling callbacks directly", () => { const onCancelForm = jest.fn(); + const onUndoCancelForm = jest.fn(); render( ); const button = document.querySelector("tbody button"); fireEvent.click(button); - expect(onCancelForm).toHaveBeenCalledTimes(1); + expect( + screen.getByText("sponsor_order_grid.change_quantity_modal.title") + ).toBeInTheDocument(); + expect(onCancelForm).not.toHaveBeenCalled(); + expect(onUndoCancelForm).not.toHaveBeenCalled(); }); - test("renders undo button for cancelled item and calls onUndoCancelForm on click", () => { - const onUndoCancelForm = jest.fn(); - const order = { forms: [makeForm({ items: [makeItem({ canceled_by_id: 99 })] })], total: 0 }; + test("reset is disabled in the modal when nothing has been cancelled yet", () => { render( ); const button = document.querySelector("tbody button"); fireEvent.click(button); - expect(onUndoCancelForm).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { + name: "sponsor_order_grid.change_quantity_modal.reset" + }) + ).toBeDisabled(); }); - test("passes the order line id to onCancelForm", () => { + test("lowering the quantity and applying calls onCancelForm with the delta, item and reason", async () => { const onCancelForm = jest.fn(); + const order = { + forms: [makeForm({ items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 0 })] })], + total: 0 + }; render( ); const button = document.querySelector("tbody button"); fireEvent.click(button); - expect(onCancelForm).toHaveBeenCalledWith(expect.objectContaining({ id: 841 })); + + const quantityField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.quantity" + ); + fireEvent.change(quantityField, { target: { value: "2" } }); + const reasonField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.reason" + ); + fireEvent.change(reasonField, { target: { value: "Damaged" } }); + + fireEvent.click(screen.getByRole("button", { name: "general.apply" })); + + await waitFor(() => + expect(onCancelForm).toHaveBeenCalledWith( + expect.objectContaining({ id: 841 }), + 3, + "Damaged" + ) + ); }); - test("passes the order line id to onUndoCancelForm", () => { + test("clicking reset then applying calls onUndoCancelForm with the item", async () => { const onUndoCancelForm = jest.fn(); const order = { - forms: [makeForm({ items: [makeItem({ line_id: 841, canceled_by_id: 99 })] })], + forms: [ + makeForm({ + items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 2 })] + }) + ], total: 0 }; render( @@ -176,7 +211,131 @@ describe("SponsorOrderGrid", () => { ); const button = document.querySelector("tbody button"); fireEvent.click(button); - expect(onUndoCancelForm).toHaveBeenCalledWith(expect.objectContaining({ id: 841 })); + + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_order_grid.change_quantity_modal.reset" + }) + ); + fireEvent.click(screen.getByRole("button", { name: "general.apply" })); + + await waitFor(() => + expect(onUndoCancelForm).toHaveBeenCalledWith( + expect.objectContaining({ id: 841 }) + ) + ); + }); + + test("clicking reset disables the reason field, typing a lower quantity re-enables it", () => { + const order = { + forms: [ + makeForm({ + items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 2 })] + }) + ], + total: 0 + }; + render( + + ); + const button = document.querySelector("tbody button"); + fireEvent.click(button); + + const reasonField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.reason" + ); + expect(reasonField).toBeEnabled(); + + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_order_grid.change_quantity_modal.reset" + }) + ); + expect(reasonField).toBeDisabled(); + + const quantityField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.quantity" + ); + fireEvent.change(quantityField, { target: { value: "1" } }); + expect(reasonField).toBeEnabled(); + }); + + test("does not strikethrough or show Cancelled type when only partially cancelled", () => { + const order = { + forms: [makeForm({ items: [makeItem({ quantity: 5, canceled_quantity: 2 })] })], + total: 0 + }; + render(); + expect(screen.queryByText("Cancelled")).not.toBeInTheDocument(); + expect(screen.getByText(/Booth/).closest("p")).not.toHaveStyle({ + textDecoration: "line-through" + }); + }); + + test("strikes through and shows Cancelled type only when fully cancelled", () => { + const order = { + forms: [makeForm({ items: [makeItem({ quantity: 5, canceled_quantity: 5 })] })], + total: 0 + }; + render(); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + expect(screen.getByText(/Booth/).closest("p")).toHaveStyle({ + textDecoration: "line-through" + }); + }); + + test("renders a list of all cancellations with date, author and reason", () => { + const order = { + forms: [ + makeForm({ + items: [ + makeItem({ + quantity: 5, + canceled_quantity: 3, + cancellations: [ + { + id: 1, + quantity: 2, + amount: 200, + reason: "Too many", + canceled_by_id: 5, + canceled_by_email: "a@test.com", + canceled_by_full_name: "Alice Admin", + created: 1000 + }, + { + id: 2, + quantity: 1, + amount: 100, + reason: "", + canceled_by_id: 6, + canceled_by_email: "b@test.com", + canceled_by_full_name: "Bob Admin", + created: 2000 + } + ] + }) + ] + }) + ], + total: 0 + }; + const translateSpy = jest.spyOn(T, "translate"); + render(); + + expect(translateSpy).toHaveBeenCalledWith( + "sponsor_order_grid.cancelled_by", + expect.objectContaining({ x: 2, y: 5, user: "Alice Admin", date: "2026-01-01" }) + ); + expect(translateSpy).toHaveBeenCalledWith( + "sponsor_order_grid.cancelled_by", + expect.objectContaining({ x: 1, y: 5, user: "Bob Admin", date: "2026-01-01" }) + ); + expect(screen.getByText(/Too many/)).toBeInTheDocument(); }); test("gives rows from different forms with the same item type distinct ids", () => { diff --git a/src/components/mui/SponsorOrderGrid/components/CancelledItems.jsx b/src/components/mui/SponsorOrderGrid/components/CancelledItems.jsx index 583d7593..2ffd5724 100644 --- a/src/components/mui/SponsorOrderGrid/components/CancelledItems.jsx +++ b/src/components/mui/SponsorOrderGrid/components/CancelledItems.jsx @@ -18,26 +18,30 @@ import DoNotDisturbIcon from "@mui/icons-material/DoNotDisturb"; import Box from "@mui/material/Box"; import Link from "@mui/material/Link"; -const CancelledItems = ({cancelledItems, sx = {}}) => { - +const CancelledItems = ({ cancelledItems, sx = {} }) => { if (cancelledItems.length === 0) return null; return ( - - - - {T.translate("sponsor_order_grid.cancelled_items", {count: cancelledItems.length})} + + + + {T.translate("sponsor_order_grid.cancelled_items")} - {cancelledItems.map((item) => ( - - {item.formCode} - {item.itemCode} - - ))} + {cancelledItems.map((item) => { + const fullyCanceled = item.canceled_quantity === item.quantity; + const fontWeight = fullyCanceled ? "bold" : "normal"; + + return ( + + {item.formCode} - {item.itemCode} ({item.canceled_quantity}/{item.quantity}) + + ) + })} ); } diff --git a/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx new file mode 100644 index 00000000..7f1a9754 --- /dev/null +++ b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx @@ -0,0 +1,117 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CustomDialog from "../../CustomDialog"; +import MuiFormikQuantityField from "../../formik-inputs/mui-formik-quantity-field"; +import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield"; + +const ChangeQuantityModal = ({ + open, + onClose, + item, + onCancelForm, + onUndoCancelForm +}) => { + const originalQuantity = item.quantity; + const currentQuantity = originalQuantity - (item.canceled_quantity ?? 0); + + const formik = useFormik({ + initialValues: { + quantity: currentQuantity, + reason: "" + }, + onSubmit: ({ quantity, reason }) => { + if (quantity < currentQuantity) { + onCancelForm(item, currentQuantity - quantity, reason); + } else if (quantity > currentQuantity) { + onUndoCancelForm(item); + } + onClose(); + }, + enableReinitialize: true + }); + + const handleClose = () => { + formik.resetForm(); + onClose(); + }; + + const handleReset = () => { + formik.setFieldValue("quantity", originalQuantity); + formik.setFieldValue("reason", ""); + }; + + const canReset = originalQuantity !== currentQuantity; + const hasChanged = formik.values.quantity !== currentQuantity; + const isRestoring = formik.values.quantity > currentQuantity; + + return ( + + + + + + + + + + + + + + ); +}; + +export default ChangeQuantityModal; diff --git a/src/components/mui/SponsorOrderGrid/components/__tests__/CancelledItems.test.jsx b/src/components/mui/SponsorOrderGrid/components/__tests__/CancelledItems.test.jsx index b96f0b26..175b356f 100644 --- a/src/components/mui/SponsorOrderGrid/components/__tests__/CancelledItems.test.jsx +++ b/src/components/mui/SponsorOrderGrid/components/__tests__/CancelledItems.test.jsx @@ -29,12 +29,12 @@ describe("CancelledItems", () => { test("renders a link for each cancelled item", () => { const items = [ - { id: 1, formCode: "GOLD", itemCode: "BOOTH" }, - { id: 2, formCode: "SILVER", itemCode: "TABLE" } + { id: 1, formCode: "GOLD", itemCode: "BOOTH", canceled_quantity: 1, quantity: 2 }, + { id: 2, formCode: "SILVER", itemCode: "TABLE", canceled_quantity: 3, quantity: 3 } ]; render(); - expect(screen.getByText("GOLD - BOOTH")).toBeInTheDocument(); - expect(screen.getByText("SILVER - TABLE")).toBeInTheDocument(); + expect(screen.getByText("GOLD - BOOTH (1/2)")).toBeInTheDocument(); + expect(screen.getByText("SILVER - TABLE (3/3)")).toBeInTheDocument(); }); test("each link href anchors to the item id", () => { diff --git a/src/components/mui/SponsorOrderGrid/index.js b/src/components/mui/SponsorOrderGrid/index.js index 5069ba3f..eeeb4fc1 100644 --- a/src/components/mui/SponsorOrderGrid/index.js +++ b/src/components/mui/SponsorOrderGrid/index.js @@ -23,18 +23,19 @@ import TableHead from "@mui/material/TableHead"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; -import UndoIcon from "@mui/icons-material/Undo"; -import DeleteIcon from "@mui/icons-material/Delete"; -import {DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow} from "../table/extra-rows"; -import {SPONSOR_ORDER_GRID_ITEM_TYPES} from "../../../utils/constants"; +import Tooltip from "@mui/material/Tooltip"; +import RuleIcon from "@mui/icons-material/Rule"; +import { DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow } from "../table/extra-rows"; +import { SPONSOR_ORDER_GRID_ITEM_TYPES } from "../../../utils/constants"; import InfoNote from "../InfoNote"; -import {currencyAmountFromCents} from "../../../utils/money"; +import { currencyAmountFromCents } from "../../../utils/money"; import TransactionType from "./components/TransactionType"; -import {formatEpoch} from "../../../utils/methods"; +import { formatEpoch } from "../../../utils/methods"; import TotalFooter from "./components/TotalFooter"; import ReconciliationBox from "./components/ReconciliationBox"; import CancelledItems from "./components/CancelledItems"; import BalanceValue from "./components/BalanceValue"; +import ChangeQuantityModal from "./components/ChangeQuantityModal"; const mapOrderData = (forms) => { if (!forms) return []; @@ -46,7 +47,8 @@ const mapOrderData = (forms) => { .map((it, i) => { const amount = currencyAmountFromCents(it.amount || 0); const itemId = it.line_id ?? `${form.id}-${i}`; - const cancelled = !!it.canceled_by_id; + const canceledQuantity = it.canceled_quantity ?? 0; + const cancelled = canceledQuantity > 0 && canceledQuantity === it.quantity; const type = cancelled ? SPONSOR_ORDER_GRID_ITEM_TYPES.CANCELLED : SPONSOR_ORDER_GRID_ITEM_TYPES.CHARGE; return { @@ -55,27 +57,25 @@ const mapOrderData = (forms) => { itemName: it.type?.name, itemCode: it.type?.code, quantity: it.quantity, + canceled_quantity: canceledQuantity, type, amount, amountValue: it.amount, cancelled, - cancelledBy: T.translate("sponsor_order_grid.cancelled_by", { - user: it.canceled_by_full_name, - date: formatEpoch(it.canceled_at) - }), + cancellations: it.cancellations ?? [] }; }) })); }; const SponsorOrderGrid = ({ - title = T.translate("sponsor_order_grid.title"), - order, - withReconciliation = false, - withCancelledItemsHeader = false, - onCancelForm, - onUndoCancelForm - }) => { + title = T.translate("sponsor_order_grid.title"), + order, + withReconciliation = false, + withCancelledItemsHeader = false, + onCancelForm, + onUndoCancelForm +}) => { const { forms = [], @@ -90,9 +90,10 @@ const SponsorOrderGrid = ({ refunds_total: refundsTotal = 0 } = order || {}; const data = mapOrderData(forms); - const cancelledItems = data.flatMap((form) => form.items.filter((it) => it.cancelled)); + const cancelledItems = data.flatMap((form) => form.items.filter((it) => it.cancellations.length > 0)); const canCancel = onCancelForm && onUndoCancelForm; const trailingCols = canCancel ? 1 : 0; + const [changeQuantityRow, setChangeQuantityRow] = React.useState(null); let balance = 0; const calculateBalance = (rowAmount, op = 1) => { @@ -108,21 +109,33 @@ const SponsorOrderGrid = ({ { columnKey: "type", header: T.translate("sponsor_order_grid.type"), - render: (row) => () + render: (row) => () }, { columnKey: "details", header: T.translate("sponsor_order_grid.details"), render: (row) => ( <> - - {row.itemName} - {T.translate("sponsor_order_grid.total")}: {row.quantity} + + {row.itemName} - {T.translate("sponsor_order_grid.total")}: {row.quantity - row.canceled_quantity} - {row.cancelled && - - {row.cancelledBy} - - } + {row.cancellations.map((cancellation) => ( + + + {T.translate("sponsor_order_grid.cancelled_by", { + x: cancellation.quantity, + y: row.quantity, + user: cancellation.canceled_by_full_name, + date: formatEpoch(cancellation.created, "M/D/YY [@] h:mm A") + })} + + {cancellation.reason && + + ↳ {cancellation.reason} + + } + + ))} ) }, @@ -130,33 +143,32 @@ const SponsorOrderGrid = ({ columnKey: "amount", header: T.translate("sponsor_order_grid.amount"), align: "right", - strikethrough: true, } ]; const colCount = columns.length + 1 + trailingCols; // 1 for balance, 1 for action col const paymentsAndRefundsOrdered = [ - ...payments?.map((payment) => ({...payment, type: "payment"})) || [], - ...refunds?.map((refund) => ({...refund, type: "refund"})) || [] + ...payments?.map((payment) => ({ ...payment, type: "payment" })) || [], + ...refunds?.map((refund) => ({ ...refund, type: "refund" })) || [] ].sort((a, b) => a.created - b.created); return ( - - + + {title && ( - + {title} )} {withCancelledItemsHeader && ( - + )} {canCancel && ( {/* TABLE HEADER */} - + {columns.map((col) => ( @@ -185,27 +197,20 @@ const SponsorOrderGrid = ({ )} - + {data.map((form) => { const rows = form.items.map((row) => ( {(() => { const cols = columns.map((col) => ( {col.render ? ( col.render(row) @@ -221,10 +226,10 @@ const SponsorOrderGrid = ({ key={`grid-col-${row.id}-balance`} align="right" sx={{ - ...(row.cancelled && {color: "text.disabled"}) + ...(row.cancelled && { color: "text.disabled" }) }} > - + ) @@ -235,15 +240,11 @@ const SponsorOrderGrid = ({ key="action" align="right" > - {row.cancelled ? ( - onUndoCancelForm(row)}> - + + setChangeQuantityRow(row)}> + - ) : ( - onCancelForm(row)}> - - - )} + ) } @@ -314,7 +315,7 @@ const SponsorOrderGrid = ({ total={total} label={T.translate("sponsor_order_grid.amount_due")} trailing={trailingCols} - rowSx={{bgcolor: "#F1F3F5", "& td": {borderBottom: "none"}}} + rowSx={{ bgcolor: "#F1F3F5", "& td": { borderBottom: "none" } }} /> } {data.length === 0 && ( @@ -329,17 +330,26 @@ const SponsorOrderGrid = ({ {withReconciliation && - - + + - + } + {changeQuantityRow && ( + setChangeQuantityRow(null)} + item={changeQuantityRow} + onCancelForm={onCancelForm} + onUndoCancelForm={onUndoCancelForm} + /> + )} ); }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 1fca0c25..837c9226 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -31,6 +31,7 @@ "edit": "Edit", "delete": "Delete", "cancel": "Cancel", + "apply": "Apply", "n_a": "N/A", "not_available": "N/A", "notes": "Notes", @@ -148,9 +149,16 @@ "total": "Total", "rate": "Rate", "action": "Action", + "change_quantity_tooltip": "restore or remove items", + "change_quantity_modal": { + "title": "Change Quantity", + "quantity": "New Quantity", + "reset": "Reset to Original", + "reason": "Reason" + }, "cancel_info_note": "Active order items can be canceled. Canceled items show an undo action to restore them. Refund and payment rows are display-only.", - "cancelled_by": "Cancelled {date} by {user}", - "cancelled_items": "Cancelled items ({count}):", + "cancelled_by": "Cancelled ({x} of {y}) • {date} • {user}", + "cancelled_items": "Cancelled items:", "reconciliation": "Reconciliation", "cancelled": "Cancelled", "refunded": "Refunded", From 706995e51c80c40b60783e3362dde64f8659ed12 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 25 Aug 2026 15:53:48 -0300 Subject: [PATCH 2/6] chore: change version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 735d65a5..57a0d6ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.51", + "version": "5.0.53-beta.0", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From c17eabe303bb2424b57e5e678d0e67c37c56bf1a Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 25 Aug 2026 17:52:11 -0300 Subject: [PATCH 3/6] chore: pr review --- src/components/mui/MuiBaseCustomTheme.js | 7 +++---- .../SponsorOrderGrid/components/ChangeQuantityModal.jsx | 2 ++ src/i18n/en.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/mui/MuiBaseCustomTheme.js b/src/components/mui/MuiBaseCustomTheme.js index 27ac10dd..7f2f1228 100644 --- a/src/components/mui/MuiBaseCustomTheme.js +++ b/src/components/mui/MuiBaseCustomTheme.js @@ -46,10 +46,9 @@ export const MuiBaseCustomTheme = { } }, MuiTooltip: { - tooltip: { - fontSize: "1em", - fontWeight: "400" - }, + styleOverrides: { + tooltip: { fontSize: "1em", fontWeight: "400" } + } }, } }; diff --git a/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx index 7f1a9754..f4e1501b 100644 --- a/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx +++ b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx @@ -84,6 +84,8 @@ const ChangeQuantityModal = ({ fullWidth size="small" margin="none" + // if fully canceled you can only reset to original quantity + disabled={currentQuantity === 0} min={0} max={currentQuantity} label={T.translate("sponsor_order_grid.change_quantity_modal.quantity")} diff --git a/src/i18n/en.json b/src/i18n/en.json index 837c9226..a8da85c6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -156,7 +156,7 @@ "reset": "Reset to Original", "reason": "Reason" }, - "cancel_info_note": "Active order items can be canceled. Canceled items show an undo action to restore them. Refund and payment rows are display-only.", + "cancel_info_note": "Active order items can be partially or fully canceled, or reverted to original quantity. Refund and payment rows are display-only.", "cancelled_by": "Cancelled ({x} of {y}) • {date} • {user}", "cancelled_items": "Cancelled items:", "reconciliation": "Reconciliation", From 9f3f6c7cfd41480b28e3b0febafc03b7e0b4c62a Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 25 Aug 2026 17:54:15 -0300 Subject: [PATCH 4/6] v5.0.53-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57a0d6ea..e9c9e2bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.53-beta.0", + "version": "5.0.53-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From ff0780a89c0c60bee471ac23024a14306f702e58 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 26 Aug 2026 11:53:51 -0300 Subject: [PATCH 5/6] chore: pr review --- .../__tests__/SponsorOrderGrid.test.js | 98 +++++++++++++++++-- .../components/ChangeQuantityModal.jsx | 14 ++- src/components/mui/SponsorOrderGrid/index.js | 2 +- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js index ffa41471..b66ffe7c 100644 --- a/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js +++ b/src/components/mui/SponsorOrderGrid/__tests__/SponsorOrderGrid.test.js @@ -30,7 +30,8 @@ jest.mock("../../../../utils/methods", () => ({ })); import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import T from "i18n-react/dist/i18n-react"; import SponsorOrderGrid from "../index"; @@ -156,8 +157,8 @@ describe("SponsorOrderGrid", () => { ).toBeDisabled(); }); - test("lowering the quantity and applying calls onCancelForm with the delta, item and reason", async () => { - const onCancelForm = jest.fn(); + test("lowering the quantity and applying calls onCancelForm with the delta, item and reason, then closes the dialog", async () => { + const onCancelForm = jest.fn(() => Promise.resolve()); const order = { forms: [makeForm({ items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 0 })] })], total: 0 @@ -166,7 +167,7 @@ describe("SponsorOrderGrid", () => { Promise.resolve())} /> ); const button = document.querySelector("tbody button"); @@ -190,10 +191,51 @@ describe("SponsorOrderGrid", () => { "Damaged" ) ); + await waitFor(() => + expect( + screen.queryByText("sponsor_order_grid.change_quantity_modal.title") + ).not.toBeInTheDocument() + ); }); - test("clicking reset then applying calls onUndoCancelForm with the item", async () => { - const onUndoCancelForm = jest.fn(); + test("dialog stays open and the typed reason is preserved when onCancelForm rejects", async () => { + const onCancelForm = jest.fn(() => Promise.reject(new Error("Cannot cancel 3 units; only 2 remain"))); + const order = { + forms: [makeForm({ items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 0 })] })], + total: 0 + }; + render( + Promise.resolve())} + /> + ); + const button = document.querySelector("tbody button"); + fireEvent.click(button); + + const quantityField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.quantity" + ); + fireEvent.change(quantityField, { target: { value: "2" } }); + const reasonField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.reason" + ); + fireEvent.change(reasonField, { target: { value: "Damaged" } }); + + fireEvent.click(screen.getByRole("button", { name: "general.apply" })); + + await waitFor(() => expect(onCancelForm).toHaveBeenCalled()); + expect( + screen.getByText("sponsor_order_grid.change_quantity_modal.title") + ).toBeInTheDocument(); + expect(screen.getByLabelText("sponsor_order_grid.change_quantity_modal.reason")).toHaveValue( + "Damaged" + ); + }); + + test("clicking reset then applying calls onUndoCancelForm with the item, then closes the dialog", async () => { + const onUndoCancelForm = jest.fn(() => Promise.resolve()); const order = { forms: [ makeForm({ @@ -205,7 +247,7 @@ describe("SponsorOrderGrid", () => { render( Promise.resolve())} onUndoCancelForm={onUndoCancelForm} /> ); @@ -224,6 +266,11 @@ describe("SponsorOrderGrid", () => { expect.objectContaining({ id: 841 }) ) ); + await waitFor(() => + expect( + screen.queryByText("sponsor_order_grid.change_quantity_modal.title") + ).not.toBeInTheDocument() + ); }); test("clicking reset disables the reason field, typing a lower quantity re-enables it", () => { @@ -264,6 +311,43 @@ describe("SponsorOrderGrid", () => { expect(reasonField).toBeEnabled(); }); + test("a fully cancelled line keeps the quantity field disabled, so it cannot be typed back to a non-zero value that would trigger a silent restore", async () => { + const user = userEvent.setup(); + const onUndoCancelForm = jest.fn(); + const order = { + forms: [ + makeForm({ + items: [makeItem({ line_id: 841, quantity: 5, canceled_quantity: 5 })] + }) + ], + total: 0 + }; + render( + + ); + const button = document.querySelector("tbody button"); + fireEvent.click(button); + + const quantityField = screen.getByLabelText( + "sponsor_order_grid.change_quantity_modal.quantity" + ); + await user.type(quantityField, "4"); + + const applyButton = screen.getByRole("button", { name: "general.apply" }); + await act(async () => { + fireEvent.click(applyButton); + // flush the formik async submit pipeline so a call would have landed by now + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onUndoCancelForm).not.toHaveBeenCalled(); + }); + test("does not strikethrough or show Cancelled type when only partially cancelled", () => { const order = { forms: [makeForm({ items: [makeItem({ quantity: 5, canceled_quantity: 2 })] })], diff --git a/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx index f4e1501b..310b0d4a 100644 --- a/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx +++ b/src/components/mui/SponsorOrderGrid/components/ChangeQuantityModal.jsx @@ -37,9 +37,11 @@ const ChangeQuantityModal = ({ }, onSubmit: ({ quantity, reason }) => { if (quantity < currentQuantity) { - onCancelForm(item, currentQuantity - quantity, reason); - } else if (quantity > currentQuantity) { - onUndoCancelForm(item); + return onCancelForm(item, currentQuantity - quantity, reason).then(onClose); + } + // can only call undo if quantity restored to original + if (quantity === originalQuantity) { + return onUndoCancelForm(item).then(onClose); } onClose(); }, @@ -58,7 +60,9 @@ const ChangeQuantityModal = ({ const canReset = originalQuantity !== currentQuantity; const hasChanged = formik.values.quantity !== currentQuantity; - const isRestoring = formik.values.quantity > currentQuantity; + const isRestoring = formik.values.quantity === originalQuantity; + // between currentQuantity and originalQuantity there is no valid action + const isInGap = !isRestoring && formik.values.quantity > currentQuantity; return ( form.items.filter((it) => it.cancellations.length > 0)); + const cancelledItems = data.flatMap((form) => form.items.filter((it) => it.canceled_quantity > 0)); const canCancel = onCancelForm && onUndoCancelForm; const trailingCols = canCancel ? 1 : 0; const [changeQuantityRow, setChangeQuantityRow] = React.useState(null); From 897185b7a2d10060243511422f844d71f430c1d8 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 26 Aug 2026 12:28:49 -0300 Subject: [PATCH 6/6] chore: publish --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9c9e2bd..f3e0b1c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.53-beta.1", + "version": "5.0.53-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {