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.59",
"version": "5.0.59-beta.4",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
Expand Down
232 changes: 232 additions & 0 deletions src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1288,4 +1288,236 @@ 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 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(
<FormItemTableWrapper
data={[bothExhaustedItem]}
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();
});

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(
<FormItemTableWrapper
data={[sponsorLimitItem]}
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);
});

it("keeps the field enabled while retyping a sold-out item's quantity, using the saved quantity not the transient live value", () => {
render(
<FormItemTableWrapper
data={cappedItem({ is_sold_out: true, quantity: 5 })}
currentApplicableRate="early_bird"
timeZone="America/New_York"
initialValues={{ "i-20-c-global-f-quantity": 5 }}
/>
);

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(
<FormItemTableWrapper
data={[drivenItem]}
currentApplicableRate="early_bird"
timeZone="America/New_York"
initialValues={{
"i-1-c-Form-f-1": 2,
"i-1-c-Form-f-2": 4 // product = 8, exceeds remaining_quantity_show = 2
}}
validate={validate}
/>
);

await waitFor(() => {
const overLimitCall = validate.mock.calls.find(
([values]) => values[quantityKey] === 8
);
expect(overLimitCall).toBeDefined();
});
});
});
});
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 @@ -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(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, customRate = 0) =>
!!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,
// shared across all rows). Item-class metafields are per-row data entry
Expand Down
Loading
Loading