diff --git a/src/pages/sponsors-global/form-templates/__tests__/sponsor-inventory-popup.test.js b/src/pages/sponsors-global/form-templates/__tests__/sponsor-inventory-popup.test.js
new file mode 100644
index 000000000..08e4b7d4d
--- /dev/null
+++ b/src/pages/sponsors-global/form-templates/__tests__/sponsor-inventory-popup.test.js
@@ -0,0 +1,326 @@
+import React from "react";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import SponsorItemDialog from "../sponsor-inventory-popup";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ translate: jest.fn((key) => key)
+}));
+
+jest.mock("../../../../hooks/useScrollToError", () => jest.fn());
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/upload",
+ () =>
+ function MockMuiFormikUpload({ name, onDelete }) {
+ return (
+
+
+
+
+ );
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/additional-input-list",
+ () =>
+ function MockAdditionalInputList({ name }) {
+ return ;
+ }
+);
+
+jest.mock(
+ "../../../../components/mui/formik-inputs/item-price-tiers",
+ () =>
+ function MockItemPriceTiers() {
+ return ;
+ }
+);
+
+jest.mock(
+ "../../../../components/inputs/formik-text-editor",
+ () =>
+ function MockFormikTextEditor({ name }) {
+ return ;
+ }
+);
+
+const BASE_ENTITY = {
+ id: 0,
+ code: "",
+ name: "",
+ description: "",
+ early_bird_rate: "",
+ standard_rate: "",
+ onsite_rate: "",
+ quantity_limit_per_show: "",
+ quantity_limit_per_sponsor: "",
+ meta_fields: [],
+ images: []
+};
+
+const fillRequiredTextFields = async (user) => {
+ await user.type(document.querySelector("input[name=\"code\"]"), "CODE-1");
+ await user.type(document.querySelector("input[name=\"name\"]"), "Item 1");
+};
+
+const submit = async (user) => {
+ await user.click(
+ screen.getByRole("button", { name: "edit_inventory_item.save_changes" })
+ );
+};
+
+describe("SponsorItemDialog", () => {
+ let onSave;
+ let onClose;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ onSave = jest.fn(() => Promise.resolve());
+ onClose = jest.fn();
+ });
+
+ it("titles itself by whether the entity has an id", () => {
+ const { rerender } = render(
+
+ );
+ expect(
+ screen.getByText("edit_inventory_item.new_item")
+ ).toBeInTheDocument();
+
+ rerender(
+
+ );
+ expect(
+ screen.getByText("edit_inventory_item.edit_item")
+ ).toBeInTheDocument();
+ });
+
+ it("blocks save when code/name are empty", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await submit(user);
+
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ describe("default_quantity requirement", () => {
+ it("is optional by default: saves with no value and shows no required marker", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ expect(
+ screen.queryByText("edit_inventory_item.default_quantity *")
+ ).not.toBeInTheDocument();
+
+ await fillRequiredTextFields(user);
+ await submit(user);
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks save, shows the error and the required marker when required and empty", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ expect(
+ screen.getByText("edit_inventory_item.default_quantity *")
+ ).toBeInTheDocument();
+
+ await fillRequiredTextFields(user);
+ await submit(user);
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(
+ await screen.findByText("validation.required")
+ ).toBeInTheDocument();
+ });
+
+ it("allows save once a value is provided when required", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await fillRequiredTextFields(user);
+ await user.type(
+ document.querySelector("input[name=\"default_quantity\"]"),
+ "5"
+ );
+ await submit(user);
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(onSave.mock.calls[0][0]).toEqual(
+ expect.objectContaining({ default_quantity: 5 })
+ );
+ });
+ });
+
+ describe("image deletion", () => {
+ it("calls onImageDeleted only for a persisted image (has an id)", async () => {
+ const user = userEvent.setup();
+ const onImageDeleted = jest.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByText("delete-unsaved-image"));
+ expect(onImageDeleted).not.toHaveBeenCalled();
+
+ await user.click(screen.getByText("delete-persisted-image"));
+ expect(onImageDeleted).toHaveBeenCalledWith(5);
+ expect(onImageDeleted).toHaveBeenCalledTimes(1);
+ });
+
+ it("does nothing when onImageDeleted is not provided", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await expect(
+ user.click(screen.getByText("delete-persisted-image"))
+ ).resolves.not.toThrow();
+ });
+ });
+
+ describe("save guard", () => {
+ const renderPending = (save) =>
+ render(
+
+ );
+
+ it("disables the save and close buttons while a save is in flight, and closes on success", async () => {
+ const user = userEvent.setup();
+ let resolveSave;
+ const pendingSave = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ renderPending(pendingSave);
+
+ await fillRequiredTextFields(user);
+ const saveButton = screen.getByRole("button", {
+ name: "edit_inventory_item.save_changes"
+ });
+ await user.click(saveButton);
+
+ await waitFor(() => expect(saveButton).toBeDisabled());
+ expect(screen.getByTestId("CloseIcon").closest("button")).toBeDisabled();
+
+ resolveSave();
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ expect(pendingSave).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call onSave twice when the save button is double-clicked before isSaving commits", async () => {
+ let resolveSave;
+ const pendingSave = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ renderPending(pendingSave);
+
+ fireEvent.change(document.querySelector("input[name=\"code\"]"), {
+ target: { value: "CODE-1" }
+ });
+ fireEvent.change(document.querySelector("input[name=\"name\"]"), {
+ target: { value: "Item 1" }
+ });
+
+ const saveButton = screen.getByRole("button", {
+ name: "edit_inventory_item.save_changes"
+ });
+
+ // Fired back-to-back with no await in between, so both clicks reach
+ // formik's validation before React commits the isSaving state update
+ // that disables the button - this is the actual race `if (isSaving)
+ // return` in handleOnSave guards against (validation is async, so a
+ // real fast double-click can land before the first render commits).
+ fireEvent.click(saveButton);
+ fireEvent.click(saveButton);
+
+ await waitFor(() => expect(pendingSave).toHaveBeenCalled());
+ resolveSave();
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ expect(pendingSave).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the dialog open and re-enables the form when onSave rejects", async () => {
+ const user = userEvent.setup();
+ const rejectingSave = jest.fn(() =>
+ Promise.reject(new Error("save failed"))
+ );
+ renderPending(rejectingSave);
+
+ await fillRequiredTextFields(user);
+ const saveButton = screen.getByRole("button", {
+ name: "edit_inventory_item.save_changes"
+ });
+ await user.click(saveButton);
+
+ await waitFor(() => expect(rejectingSave).toHaveBeenCalled());
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js
index 2fadffba1..ad8d2944a 100644
--- a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js
+++ b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js
@@ -44,7 +44,8 @@ const SponsorItemDialog = ({
onMetaFieldTypeDeleted,
onMetaFieldTypeValueDeleted,
onImageDeleted,
- entity: initialEntity
+ entity: initialEntity,
+ requireDefaultQuantity = false
}) => {
const [isSaving, setIsSaving] = useState(false);
@@ -63,7 +64,11 @@ const SponsorItemDialog = ({
early_bird_rate: nullableDecimalValidation(),
standard_rate: nullableDecimalValidation(),
onsite_rate: nullableDecimalValidation(),
- default_quantity: positiveNumberValidation(),
+ default_quantity: requireDefaultQuantity
+ ? positiveNumberValidation().required(
+ T.translate("validation.required")
+ )
+ : positiveNumberValidation(),
quantity_limit_per_sponsor: positiveNumberValidation(),
quantity_limit_per_show: positiveNumberValidation(),
meta_fields: formMetafieldsValidation()
@@ -178,6 +183,7 @@ const SponsorItemDialog = ({
{T.translate("edit_inventory_item.default_quantity")}
+ {requireDefaultQuantity && " *"}
- function MockSponsorFormItemPopup({ onRemoveImage }) {
+ function MockSponsorInventoryDialog({ onImageDeleted }) {
return (
-