diff --git a/package.json b/package.json
index 565cb8d7..0a2a36d4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.56",
+ "version": "5.0.59-beta.2",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
index 7d601bfb..e8474fcf 100644
--- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
+++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
@@ -1191,4 +1191,156 @@ 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();
+ });
+ });
+
+ 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/__tests__/helpers.test.js b/src/components/mui/FormItemTable/__tests__/helpers.test.js
index 3d80e9de..9f79e61c 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 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(true);
+ });
+
+ 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/components/GlobalQuantityField.js b/src/components/mui/FormItemTable/components/GlobalQuantityField.js
index 140bc90e..cc302a25 100644
--- a/src/components/mui/FormItemTable/components/GlobalQuantityField.js
+++ b/src/components/mui/FormItemTable/components/GlobalQuantityField.js
@@ -29,19 +29,22 @@ const GlobalQuantityField = ({
// using readOnly since formik won't validate disabled fields
const isReadOnly = hasDrivingQuantityField(extraColumns);
+ // 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);
}, [value]);
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; }
- 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);
+ clamped = Math.min(clamped, maxAllowed);
e.target.value = clamped;
helpers.setValue(clamped);
};
@@ -58,9 +61,7 @@ const GlobalQuantityField = ({
htmlInput: {
readOnly: isReadOnly,
min: 0,
- ...(row.quantity_limit_per_sponsor
- ? { max: row.quantity_limit_per_sponsor }
- : {})
+ ...(Number.isFinite(maxAllowed) ? { max: maxAllowed } : {})
}
}}
sx={
diff --git a/src/components/mui/FormItemTable/helpers.js b/src/components/mui/FormItemTable/helpers.js
index a2f7873c..c4cf7a46 100644
--- a/src/components/mui/FormItemTable/helpers.js
+++ b/src/components/mui/FormItemTable/helpers.js
@@ -42,6 +42,9 @@ export const getCurrentApplicableRate = (timeZone, rateDates) => {
export const isItemAvailable = (item, currentApplicableRate) =>
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,
// shared across all rows). Item-class metafields are per-row data entry
diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js
index 3e8aba60..efdddfe3 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";
@@ -39,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,
@@ -230,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 (
@@ -286,20 +292,29 @@ const FormItemTable = ({
{currencyAmountFromCents(calculateRowTotal(row))}
- toggleRow(row.form_item_id)}
- >
-
-
+ {hasStock ? (
+ toggleRow(row.form_item_id)}
+ >
+
+
+ ) : (
+
+ {row.remaining_quantity_sponsor === 0
+ ? T.translate("sponsor_edit_form.limit_reached")
+ : T.translate("sponsor_edit_form.sold_out")}
+
+ )}
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"