From 3f7c9d4245a536729ac1698a1032ee1473b1d3fd Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Fri, 28 Aug 2026 18:36:11 -0300 Subject: [PATCH 1/7] chore: replicate ss changes here --- .../__tests__/FormItemTable.test.js | 76 +++++++++++++++++++ .../FormItemTable/__tests__/helpers.test.js | 10 +++ src/components/mui/FormItemTable/helpers.js | 2 +- src/components/mui/FormItemTable/index.js | 25 ++++-- src/i18n/en.json | 4 +- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js index 7d601bfb..65db4b92 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -1191,4 +1191,80 @@ describe("FormItemTable Component", () => { expect(input).toHaveAttribute("max", "100"); }); }); + + describe("Sold Out", () => { + it("replaces the details icon with a Sold Out label and disables the quantity input when is_sold_out is true", () => { + const soldOutItem = { ...MOCK_FORM_A.items[0], is_sold_out: true }; + const { container } = render( + + ); + + expect(screen.getByText("sponsor_edit_form.sold_out")).toBeInTheDocument(); + expect( + screen.queryByText("sponsor_edit_form.limit_reached") + ).not.toBeInTheDocument(); + // Only the first column's collapse toggle remains - the details/info + // icon (also labelled "Toggle row details") is gone, replaced by the label. + expect( + screen.getAllByRole("button", { name: "Toggle row details" }) + ).toHaveLength(1); + expect( + container.querySelector( + `input[name="i-${soldOutItem.form_item_id}-c-global-f-quantity"]` + ) + ).toBeDisabled(); + }); + + it("shows Limit Reached instead of Sold Out when remaining_quantity_sponsor is 0", () => { + const limitReachedItem = { + ...MOCK_FORM_A.items[0], + is_sold_out: true, + remaining_quantity_sponsor: 0 + }; + render( + + ); + + expect( + screen.getByText("sponsor_edit_form.limit_reached") + ).toBeInTheDocument(); + expect( + screen.queryByText("sponsor_edit_form.sold_out") + ).not.toBeInTheDocument(); + }); + + it("keeps the details icon and quantity input enabled when is_sold_out is false", () => { + const availableItem = { ...MOCK_FORM_A.items[0], is_sold_out: false }; + const { container } = render( + + ); + + expect( + screen.queryByText("sponsor_edit_form.sold_out") + ).not.toBeInTheDocument(); + expect( + screen.queryByText("sponsor_edit_form.limit_reached") + ).not.toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Toggle row details" }) + ).toHaveLength(2); + expect( + container.querySelector( + `input[name="i-${availableItem.form_item_id}-c-global-f-quantity"]` + ) + ).not.toBeDisabled(); + }); + }); }); diff --git a/src/components/mui/FormItemTable/__tests__/helpers.test.js b/src/components/mui/FormItemTable/__tests__/helpers.test.js index 3d80e9de..ca35a8e0 100644 --- a/src/components/mui/FormItemTable/__tests__/helpers.test.js +++ b/src/components/mui/FormItemTable/__tests__/helpers.test.js @@ -53,6 +53,16 @@ describe("isItemAvailable", () => { const item = { rates: { early_bird: null } }; expect(isItemAvailable(item, "early_bird")).toBe(false); }); + + test("returns false when item is sold out even if it has a rate for the given period", () => { + const item = { rates: { early_bird: 100 }, is_sold_out: true }; + expect(isItemAvailable(item, "early_bird")).toBe(false); + }); + + test("returns true when item is explicitly not sold out and has a rate", () => { + const item = { rates: { early_bird: 100 }, is_sold_out: false }; + expect(isItemAvailable(item, "early_bird")).toBe(true); + }); }); describe("hasDrivingQuantityField", () => { diff --git a/src/components/mui/FormItemTable/helpers.js b/src/components/mui/FormItemTable/helpers.js index a2f7873c..1439eb9c 100644 --- a/src/components/mui/FormItemTable/helpers.js +++ b/src/components/mui/FormItemTable/helpers.js @@ -40,7 +40,7 @@ export const getCurrentApplicableRate = (timeZone, rateDates) => { }; export const isItemAvailable = (item, currentApplicableRate) => - item.rates?.[currentApplicableRate] != null; + !item.is_sold_out && item.rates?.[currentApplicableRate] != null; // The global quantity for a row is driven (and therefore read-only/computed) // when a Form-class metafield of type Quantity exists for it (extraColumns, diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index 3e8aba60..0d0a6312 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -22,7 +22,8 @@ import { TableCell, TableContainer, TableHead, - TableRow + TableRow, + Typography } from "@mui/material"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; @@ -293,13 +294,21 @@ const FormItemTable = ({ {currencyAmountFromCents(calculateRowTotal(row))} - toggleRow(row.form_item_id)} - > - - + {row.is_sold_out ? ( + + {row.remaining_quantity_sponsor === 0 + ? T.translate("sponsor_edit_form.limit_reached") + : T.translate("sponsor_edit_form.sold_out")} + + ) : ( + toggleRow(row.form_item_id)} + > + + + )} diff --git a/src/i18n/en.json b/src/i18n/en.json index 8086c50b..fba2e3cf 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -122,7 +122,9 @@ "notes_placeholder": "Enter your notes here...", "additional_info": "Additional Info", "discount": "Discount", - "total_on_caps": "TOTAL" + "total_on_caps": "TOTAL", + "sold_out": "Sold Out", + "limit_reached": "Limit Reached" }, "upload_input": { "upload_file": "Upload file" From 4866c2b449b75af4d6a6d73eb37661be1f071627 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 31 Aug 2026 18:32:08 -0300 Subject: [PATCH 2/7] chore: missing disabled prop in global qty --- src/components/mui/FormItemTable/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index 0d0a6312..f54c5410 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -288,6 +288,7 @@ const FormItemTable = ({ row={row} extraColumns={extraColumns} value={calculateQuantity(row)} + disabled={disabled} /> From 59b530241346cdb5f8339f9d6db4af5ebb71076f Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 31 Aug 2026 18:56:59 -0300 Subject: [PATCH 3/7] v5.0.59-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 565cb8d7..98530674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.56", + "version": "5.0.59-beta.0", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From baaf8a1730cd62ecd54787f76d18c2ef7ee623e7 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 10:45:28 -0300 Subject: [PATCH 4/7] chore: change logic - sold out is just for show, not sponsor. also fix bug on calculateTotal --- .../FormItemTable/__tests__/helpers.test.js | 4 +-- .../components/GlobalQuantityField.js | 25 ++++++++++++++----- src/components/mui/FormItemTable/helpers.js | 5 +++- src/components/mui/FormItemTable/index.js | 25 +++++++++++-------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/components/mui/FormItemTable/__tests__/helpers.test.js b/src/components/mui/FormItemTable/__tests__/helpers.test.js index ca35a8e0..9f79e61c 100644 --- a/src/components/mui/FormItemTable/__tests__/helpers.test.js +++ b/src/components/mui/FormItemTable/__tests__/helpers.test.js @@ -54,9 +54,9 @@ describe("isItemAvailable", () => { expect(isItemAvailable(item, "early_bird")).toBe(false); }); - test("returns false when item is sold out even if it has a rate for the given period", () => { + test("returns true when item is sold out but has a rate for the given period (stock is a separate concern, see itemHasStock)", () => { const item = { rates: { early_bird: 100 }, is_sold_out: true }; - expect(isItemAvailable(item, "early_bird")).toBe(false); + expect(isItemAvailable(item, "early_bird")).toBe(true); }); test("returns true when item is explicitly not sold out and has a rate", () => { diff --git a/src/components/mui/FormItemTable/components/GlobalQuantityField.js b/src/components/mui/FormItemTable/components/GlobalQuantityField.js index 140bc90e..4059ca51 100644 --- a/src/components/mui/FormItemTable/components/GlobalQuantityField.js +++ b/src/components/mui/FormItemTable/components/GlobalQuantityField.js @@ -14,7 +14,7 @@ import React, { useEffect } from "react"; import { useField } from "formik"; import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield"; -import { hasDrivingQuantityField } from "../helpers"; +import { hasDrivingQuantityField, itemHasStock } from "../helpers"; const GlobalQuantityField = ({ row, @@ -29,6 +29,13 @@ const GlobalQuantityField = ({ // using readOnly since formik won't validate disabled fields const isReadOnly = hasDrivingQuantityField(extraColumns); + // A row with no remaining stock can't accept a higher quantity, but a + // sponsor who already holds a non-zero quantity here must still be able + // to lower it - fully disabling the field would leave them unable to + // shed a stale quantity the backend will otherwise reject on save. + const hasStock = itemHasStock(row); + const sponsorLimit = row.quantity_limit_per_sponsor; + useEffect(() => { helpers.setValue(value); }, [value]); @@ -40,8 +47,12 @@ const GlobalQuantityField = ({ // forces the DOM to normalize the displayed value (e.g. strip leading zeros, // clamp to max) before React's reconciliation runs. if (isNaN(val)) { e.target.value = 0; helpers.setValue(0); return; } - const max = row.quantity_limit_per_sponsor; - const clamped = max ? Math.min(Math.max(val, 0), max) : Math.max(val, 0); + let clamped = Math.max(val, 0); + if (hasStock) { + if (sponsorLimit) clamped = Math.min(clamped, sponsorLimit); + } else { + clamped = Math.min(clamped, value); + } e.target.value = clamped; helpers.setValue(clamped); }; @@ -58,9 +69,11 @@ const GlobalQuantityField = ({ htmlInput: { readOnly: isReadOnly, min: 0, - ...(row.quantity_limit_per_sponsor - ? { max: row.quantity_limit_per_sponsor } - : {}) + ...(hasStock + ? sponsorLimit + ? { max: sponsorLimit } + : {} + : { max: value }) } }} sx={ diff --git a/src/components/mui/FormItemTable/helpers.js b/src/components/mui/FormItemTable/helpers.js index 1439eb9c..c4cf7a46 100644 --- a/src/components/mui/FormItemTable/helpers.js +++ b/src/components/mui/FormItemTable/helpers.js @@ -40,7 +40,10 @@ export const getCurrentApplicableRate = (timeZone, rateDates) => { }; export const isItemAvailable = (item, currentApplicableRate) => - !item.is_sold_out && item.rates?.[currentApplicableRate] != null; + item.rates?.[currentApplicableRate] != null; + +export const itemHasStock = (item) => + !item.is_sold_out && item.remaining_quantity_sponsor !== 0; // The global quantity for a row is driven (and therefore read-only/computed) // when a Form-class metafield of type Quantity exists for it (extraColumns, diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index f54c5410..efdddfe3 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -40,7 +40,7 @@ import MuiFormikSelect from "../formik-inputs/mui-formik-select"; import MuiFormikPriceField from "../formik-inputs/mui-formik-pricefield"; import MuiFormikDiscountField from "../formik-inputs/mui-formik-discountfield"; import ExpandedRowContent from "./components/ExpandedRowContent"; -import { hasDrivingQuantityField, isItemAvailable } from "./helpers"; +import { hasDrivingQuantityField, isItemAvailable, itemHasStock } from "./helpers"; const FormItemTable = ({ data, @@ -231,7 +231,12 @@ const FormItemTable = ({ {data.map((row) => { - const disabled = !isItemAvailable(row, currentApplicableRate); + const currentQuantity = calculateQuantity(row); + const hasStock = itemHasStock(row); + // User can always lower the quantity down to 0 + const disabled = + !isItemAvailable(row, currentApplicableRate) || + (!hasStock && currentQuantity === 0); const isOpen = !!openRows[row.form_item_id]; return ( @@ -287,7 +292,7 @@ const FormItemTable = ({ @@ -295,13 +300,7 @@ const FormItemTable = ({ {currencyAmountFromCents(calculateRowTotal(row))} - {row.is_sold_out ? ( - - {row.remaining_quantity_sponsor === 0 - ? T.translate("sponsor_edit_form.limit_reached") - : T.translate("sponsor_edit_form.sold_out")} - - ) : ( + {hasStock ? ( + ) : ( + + {row.remaining_quantity_sponsor === 0 + ? T.translate("sponsor_edit_form.limit_reached") + : T.translate("sponsor_edit_form.sold_out")} + )} From 84980a89a30565c2c2fb6459124d62fbddc443d3 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 10:47:42 -0300 Subject: [PATCH 5/7] v5.0.59-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 98530674..7f4d9955 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.59-beta.0", + "version": "5.0.59-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 6c88ab07cfaedf6f2e6993debcddd0f840c7a49a Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 16:00:47 -0300 Subject: [PATCH 6/7] chore: improve global qty input and tests --- .../__tests__/FormItemTable.test.js | 76 +++++++++++++++++++ .../__tests__/GlobalQuantityField.test.js | 34 +++++++-- .../components/GlobalQuantityField.js | 30 +++----- 3 files changed, 112 insertions(+), 28 deletions(-) diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js index 65db4b92..e8474fcf 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -1267,4 +1267,80 @@ describe("FormItemTable Component", () => { ).not.toBeDisabled(); }); }); + + describe("Remaining Quantity Caps", () => { + // No Form-class Quantity metafields, so the global quantity field is a + // plain editable input rather than driven/readOnly. + const cappedItem = (overrides) => [ + { + form_item_id: 20, + code: "CAP", + name: "Capped Item", + quantity: 0, + rates: { early_bird: 10000, standard: 12000, onsite: 15000 }, + meta_fields: [], + ...overrides + } + ]; + + it("clamps typed value to remaining_quantity_show when it is tighter than remaining_quantity_sponsor", () => { + render( + + ); + + const input = screen.getByTestId("textfield-i-20-c-global-f-quantity"); + expect(input).toHaveAttribute("max", "2"); + fireEvent.change(input, { target: { value: "10" } }); + // eslint-disable-next-line + expect(input).toHaveValue(2); + }); + + it("clamps typed value to remaining_quantity_sponsor when it is tighter than remaining_quantity_show", () => { + render( + + ); + + const input = screen.getByTestId("textfield-i-20-c-global-f-quantity"); + expect(input).toHaveAttribute("max", "3"); + fireEvent.change(input, { target: { value: "10" } }); + // eslint-disable-next-line + expect(input).toHaveValue(3); + }); + + it("does not apply an upper bound when both remaining quantities are null", () => { + render( + + ); + + const input = screen.getByTestId("textfield-i-20-c-global-f-quantity"); + expect(input).not.toHaveAttribute("max"); + fireEvent.change(input, { target: { value: "50" } }); + // eslint-disable-next-line + expect(input).toHaveValue(50); + }); + }); }); diff --git a/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js b/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js index 29d1953a..98384f85 100644 --- a/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js +++ b/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js @@ -18,7 +18,11 @@ import { Formik, Form } from "formik"; import "@testing-library/jest-dom"; import GlobalQuantityField from "../components/GlobalQuantityField"; -const row = { form_item_id: 1, quantity_limit_per_sponsor: 5 }; +const row = { + form_item_id: 1, + remaining_quantity_show: 5, + remaining_quantity_sponsor: 5 +}; const fieldName = `i-${row.form_item_id}-c-global-f-quantity`; const renderField = (props = {}, onSubmit = jest.fn()) => @@ -78,7 +82,7 @@ describe("GlobalQuantityField", () => { expect(input).not.toBeDisabled(); }); - test("clamps value to quantity_limit_per_sponsor when user types above it", async () => { + test("clamps value to remaining_quantity_sponsor when user types above it", async () => { const onSubmit = jest.fn(); renderField({}, onSubmit); const input = screen.getByRole("spinbutton"); @@ -123,10 +127,10 @@ describe("GlobalQuantityField", () => { ); }); - test("does not apply upper bound when quantity_limit_per_sponsor is 0 (unlimited)", async () => { + test("clamps to 0 when remaining_quantity_sponsor is 0 (exhausted)", async () => { const onSubmit = jest.fn(); - const zeroLimitRow = { ...row, quantity_limit_per_sponsor: 0 }; - renderField({ row: zeroLimitRow }, onSubmit); + const exhaustedRow = { ...row, remaining_quantity_sponsor: 0 }; + renderField({ row: exhaustedRow }, onSubmit); const input = screen.getByRole("spinbutton"); const submitButton = screen.getByText("submit"); await act(async () => { @@ -134,7 +138,23 @@ describe("GlobalQuantityField", () => { await userEvent.click(submitButton); }); expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ [fieldName]: 3 }), + expect.objectContaining({ [fieldName]: 0 }), + expect.anything() + ); + }); + + test("clamps to remaining_quantity_show when it is tighter than remaining_quantity_sponsor", async () => { + const onSubmit = jest.fn(); + const showLimitedRow = { ...row, remaining_quantity_show: 2 }; + renderField({ row: showLimitedRow }, onSubmit); + const input = screen.getByRole("spinbutton"); + const submitButton = screen.getByText("submit"); + await act(async () => { + fireEvent.change(input, { target: { value: "10" } }); + await userEvent.click(submitButton); + }); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ [fieldName]: 2 }), expect.anything() ); }); @@ -155,7 +175,7 @@ describe("GlobalQuantityField", () => { ); }); - test("does not apply upper bound when quantity_limit_per_sponsor is undefined", async () => { + test("does not apply upper bound when both remaining quantities are null/undefined", async () => { const onSubmit = jest.fn(); const unlimitedRow = { form_item_id: 1 }; renderField({ row: unlimitedRow }, onSubmit); diff --git a/src/components/mui/FormItemTable/components/GlobalQuantityField.js b/src/components/mui/FormItemTable/components/GlobalQuantityField.js index 4059ca51..cc302a25 100644 --- a/src/components/mui/FormItemTable/components/GlobalQuantityField.js +++ b/src/components/mui/FormItemTable/components/GlobalQuantityField.js @@ -14,7 +14,7 @@ import React, { useEffect } from "react"; import { useField } from "formik"; import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield"; -import { hasDrivingQuantityField, itemHasStock } from "../helpers"; +import { hasDrivingQuantityField } from "../helpers"; const GlobalQuantityField = ({ row, @@ -29,12 +29,11 @@ const GlobalQuantityField = ({ // using readOnly since formik won't validate disabled fields const isReadOnly = hasDrivingQuantityField(extraColumns); - // A row with no remaining stock can't accept a higher quantity, but a - // sponsor who already holds a non-zero quantity here must still be able - // to lower it - fully disabling the field would leave them unable to - // shed a stale quantity the backend will otherwise reject on save. - const hasStock = itemHasStock(row); - const sponsorLimit = row.quantity_limit_per_sponsor; + // if remaining quantities are null then there is no cap + const maxAllowed = Math.min( + row.remaining_quantity_show ?? Infinity, + row.remaining_quantity_sponsor ?? Infinity + ); useEffect(() => { helpers.setValue(value); @@ -42,17 +41,10 @@ const GlobalQuantityField = ({ const handleChange = (e) => { const val = parseInt(e.target.value, 10); - // React intentionally skips syncing controlled number inputs during typing - // to avoid cursor/composition issues. Setting e.target.value directly - // forces the DOM to normalize the displayed value (e.g. strip leading zeros, - // clamp to max) before React's reconciliation runs. + // Setting e.target.value directly forces the DOM to normalize the displayed value if (isNaN(val)) { e.target.value = 0; helpers.setValue(0); return; } let clamped = Math.max(val, 0); - if (hasStock) { - if (sponsorLimit) clamped = Math.min(clamped, sponsorLimit); - } else { - clamped = Math.min(clamped, value); - } + clamped = Math.min(clamped, maxAllowed); e.target.value = clamped; helpers.setValue(clamped); }; @@ -69,11 +61,7 @@ const GlobalQuantityField = ({ htmlInput: { readOnly: isReadOnly, min: 0, - ...(hasStock - ? sponsorLimit - ? { max: sponsorLimit } - : {} - : { max: value }) + ...(Number.isFinite(maxAllowed) ? { max: maxAllowed } : {}) } }} sx={ From 2365fbb83cb8a08fc3b339659540293d638fb408 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 16:02:04 -0300 Subject: [PATCH 7/7] v5.0.59-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f4d9955..0a2a36d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.59-beta.1", + "version": "5.0.59-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {