From 9a61d0d70e0d12df239230165aef4f667304b9ea Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Fri, 28 Aug 2026 18:36:11 -0300 Subject: [PATCH 01/10] 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 34c2c656..e07f4507 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -1288,4 +1288,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 b5f99c8f..d546ba6e 100644 --- a/src/components/mui/FormItemTable/__tests__/helpers.test.js +++ b/src/components/mui/FormItemTable/__tests__/helpers.test.js @@ -76,6 +76,16 @@ describe("isItemAvailable", () => { const item = { rates: { early_bird: 100 } }; expect(isItemAvailable(item, "early_bird", 0)).toBe(true); }); + + 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 852bef34..be3109c0 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, customRate = 0) => - !!customRate || item.rates?.[currentApplicableRate] != null; +!item.is_sold_out && (!!customRate || 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 ffa1cedf..9337f219 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 df1453ab7509051418336aafc50704eee19b780e Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 31 Aug 2026 18:32:08 -0300 Subject: [PATCH 02/10] 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 9337f219..eb3c4300 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 1a1afa453436ad611c281e1f7a6d6a149b4b0d53 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 31 Aug 2026 18:56:59 -0300 Subject: [PATCH 03/10] v5.0.59-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fefe02ce..98530674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.59", + "version": "5.0.59-beta.0", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 83664bae5742b49df611dca7463eb125c0e4421e Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 10:45:28 -0300 Subject: [PATCH 04/10] chore: change logic - sold out is just for show, not sponsor. also fix bug on calculateTotal --- .../FormItemTable/__tests__/helpers.test.js | 2 +- .../components/GlobalQuantityField.js | 25 ++++++++++++++----- src/components/mui/FormItemTable/helpers.js | 5 +++- src/components/mui/FormItemTable/index.js | 25 +++++++++++-------- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/components/mui/FormItemTable/__tests__/helpers.test.js b/src/components/mui/FormItemTable/__tests__/helpers.test.js index d546ba6e..eff09496 100644 --- a/src/components/mui/FormItemTable/__tests__/helpers.test.js +++ b/src/components/mui/FormItemTable/__tests__/helpers.test.js @@ -79,7 +79,7 @@ describe("isItemAvailable", () => { 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); + 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 be3109c0..d44d0656 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, customRate = 0) => -!item.is_sold_out && (!!customRate || item.rates?.[currentApplicableRate] != null); + !!customRate || 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 eb3c4300..0dfd076a 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 customRate = values[`i-${row.form_item_id}-c-global-f-custom_rate`]; - const disabled = !isItemAvailable(row, currentApplicableRate, customRate); + const currentQuantity = calculateQuantity(row); + const hasStock = itemHasStock(row); + // User can always lower the quantity down to 0 + const disabled = + !isItemAvailable(row, currentApplicableRate, customRate) || + (!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 5d6c6476a8d642bc98a4643d5dc35ef28ddab64d Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 10:47:42 -0300 Subject: [PATCH 05/10] 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 0036aae00443c8ce17ae7f00e9f4bdb31316abaf Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 16:00:47 -0300 Subject: [PATCH 06/10] 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 e07f4507..fe82f566 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -1364,4 +1364,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 0723a129b9cebcf01e8da68bfd540be47f540296 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 16:02:04 -0300 Subject: [PATCH 07/10] 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": { From a2fae74f7212faf378089bcffd053bfaf43320a7 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 7 Sep 2026 16:11:17 -0300 Subject: [PATCH 08/10] chore: pr review - add tests and change sold out priority --- .../__tests__/FormItemTable.test.js | 86 ++++++++++++++++++- src/components/mui/FormItemTable/index.js | 10 ++- src/i18n/en.json | 2 +- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js index fe82f566..a59e7d46 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -1316,15 +1316,35 @@ describe("FormItemTable Component", () => { ).toBeDisabled(); }); - it("shows Limit Reached instead of Sold Out when remaining_quantity_sponsor is 0", () => { - const limitReachedItem = { + it("shows Sold Out (not Limit Reached) when both is_sold_out and remaining_quantity_sponsor === 0 are true", () => { + const bothExhaustedItem = { ...MOCK_FORM_A.items[0], is_sold_out: true, remaining_quantity_sponsor: 0 }; render( + ); + + expect(screen.getByText("sponsor_edit_form.sold_out")).toBeInTheDocument(); + expect( + screen.queryByText("sponsor_edit_form.limit_reached") + ).not.toBeInTheDocument(); + }); + + it("shows Limit Reached when only remaining_quantity_sponsor is 0 and is_sold_out is false", () => { + const sponsorLimitItem = { + ...MOCK_FORM_A.items[0], + is_sold_out: false, + remaining_quantity_sponsor: 0 + }; + render( + @@ -1439,5 +1459,65 @@ describe("FormItemTable Component", () => { // eslint-disable-next-line expect(input).toHaveValue(50); }); + + it("keeps the field enabled while retyping a sold-out item's quantity, using the saved quantity not the transient live value", () => { + render( + + ); + + const input = screen.getByTestId("textfield-i-20-c-global-f-quantity"); + expect(input).not.toBeDisabled(); + + fireEvent.change(input, { target: { value: "" } }); + + expect(input).not.toBeDisabled(); + }); + + it("carries a Form-class driven quantity that exceeds remaining_quantity_show into Formik state for validation to catch", async () => { + // GlobalQuantityField is read-only for driven rows, so calculateQuantity's + // raw product (not a clamped display value) is what a consumer's Yup + // schema has to reject - this proves that product actually reaches the + // exact Formik key such a schema would validate. + const drivenItem = { + ...MOCK_FORM_A.items[0], + remaining_quantity_show: 2, + remaining_quantity_sponsor: 5 + }; + const quantityKey = "i-1-c-global-f-quantity"; + const maxQty = Math.min( + drivenItem.remaining_quantity_show ?? Infinity, + drivenItem.remaining_quantity_sponsor ?? Infinity + ); + const validate = jest.fn((values) => { + const errors = {}; + if (values[quantityKey] > maxQty) errors[quantityKey] = "max exceeded"; + return errors; + }); + + render( + + ); + + await waitFor(() => { + const overLimitCall = validate.mock.calls.find( + ([values]) => values[quantityKey] === 8 + ); + expect(overLimitCall).toBeDefined(); + }); + }); }); }); diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index 0dfd076a..dc936c00 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -233,10 +233,12 @@ const FormItemTable = ({ const customRate = values[`i-${row.form_item_id}-c-global-f-custom_rate`]; const currentQuantity = calculateQuantity(row); const hasStock = itemHasStock(row); + // don't use live value, it will disable when 0 + const savedQuantity = row.quantity ?? 0; // User can always lower the quantity down to 0 const disabled = !isItemAvailable(row, currentApplicableRate, customRate) || - (!hasStock && currentQuantity === 0); + (!hasStock && savedQuantity === 0); const isOpen = !!openRows[row.form_item_id]; return ( @@ -310,9 +312,9 @@ const FormItemTable = ({ ) : ( - {row.remaining_quantity_sponsor === 0 - ? T.translate("sponsor_edit_form.limit_reached") - : T.translate("sponsor_edit_form.sold_out")} + {row.is_sold_out + ? T.translate("sponsor_edit_form.sold_out") + : T.translate("sponsor_edit_form.limit_reached")} )} diff --git a/src/i18n/en.json b/src/i18n/en.json index fba2e3cf..c4b5e96f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -124,7 +124,7 @@ "discount": "Discount", "total_on_caps": "TOTAL", "sold_out": "Sold Out", - "limit_reached": "Limit Reached" + "limit_reached": "Max Qty Hit" }, "upload_input": { "upload_file": "Upload file" From 73a4deed97e48359f6e6c11f54ceafd02d3592cd Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 7 Sep 2026 16:12:17 -0300 Subject: [PATCH 09/10] v5.0.59-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a2a36d4..e8e3add1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.59-beta.2", + "version": "5.0.59-beta.3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From bcc9dfe59cb331ad78d23d86b9889498de0a8a13 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 7 Sep 2026 16:47:58 -0300 Subject: [PATCH 10/10] v5.0.59-beta.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e8e3add1..eed361f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.59-beta.3", + "version": "5.0.59-beta.4", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {