Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
152 changes: 152 additions & 0 deletions src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FormItemTableWrapper
data={[soldOutItem]}
currentApplicableRate="early_bird"
timeZone="America/New_York"
/>
);

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(
<FormItemTableWrapper
data={[limitReachedItem]}
currentApplicableRate="early_bird"
timeZone="America/New_York"
/>
);

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(
<FormItemTableWrapper
data={[availableItem]}
currentApplicableRate="early_bird"
timeZone="America/New_York"
/>
);

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(
<FormItemTableWrapper
data={cappedItem({
remaining_quantity_show: 2,
remaining_quantity_sponsor: 5
})}
currentApplicableRate="early_bird"
timeZone="America/New_York"
initialValues={{ "i-20-c-global-f-quantity": 0 }}
/>
);

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(
<FormItemTableWrapper
data={cappedItem({
remaining_quantity_show: 8,
remaining_quantity_sponsor: 3
})}
currentApplicableRate="early_bird"
timeZone="America/New_York"
initialValues={{ "i-20-c-global-f-quantity": 0 }}
/>
);

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(
<FormItemTableWrapper
data={cappedItem({
remaining_quantity_show: null,
remaining_quantity_sponsor: null
})}
currentApplicableRate="early_bird"
timeZone="America/New_York"
initialValues={{ "i-20-c-global-f-quantity": 0 }}
/>
);

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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()) =>
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -123,18 +127,34 @@ 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 () => {
fireEvent.change(input, { target: { value: "3" } });
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()
);
});
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/components/mui/FormItemTable/__tests__/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 10 additions & 9 deletions src/components/mui/FormItemTable/components/GlobalQuantityField.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand All @@ -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={
Expand Down
3 changes: 3 additions & 0 deletions src/components/mui/FormItemTable/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 26 additions & 11 deletions src/components/mui/FormItemTable/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -230,7 +231,12 @@ const FormItemTable = ({
</TableHead>
<TableBody>
{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 (
Expand Down Expand Up @@ -286,20 +292,29 @@ const FormItemTable = ({
<GlobalQuantityField
row={row}
extraColumns={extraColumns}
value={calculateQuantity(row)}
value={currentQuantity}
disabled={disabled}
/>
</TableCell>
<TableCell>
{currencyAmountFromCents(calculateRowTotal(row))}
</TableCell>
<TableCell align="center" sx={{ verticalAlign: "middle" }}>
<IconButton
size="small"
aria-label="Toggle row details"
onClick={() => toggleRow(row.form_item_id)}
>
<InfoOutlinedIcon color={getDetailsIconColor(row)} />
</IconButton>
{hasStock ? (
<IconButton
size="small"
aria-label="Toggle row details"
onClick={() => toggleRow(row.form_item_id)}
>
<InfoOutlinedIcon color={getDetailsIconColor(row)} />
</IconButton>
) : (
<Typography variant="body2" noWrap sx={{ color: "error.main" }}>
{row.remaining_quantity_sponsor === 0
? T.translate("sponsor_edit_form.limit_reached")
: T.translate("sponsor_edit_form.sold_out")}
</Typography>
)}
</TableCell>
</TableRow>
<TableRow>
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading