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"