diff --git a/src/actions/__tests__/add-on-types-actions.test.js b/src/actions/__tests__/add-on-types-actions.test.js
new file mode 100644
index 000000000..b067cac61
--- /dev/null
+++ b/src/actions/__tests__/add-on-types-actions.test.js
@@ -0,0 +1,170 @@
+/**
+ * @jest-environment jsdom
+ */
+import configureStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import flushPromises from "flush-promises";
+import {
+ getRequest,
+ putRequest,
+ postRequest,
+ deleteRequest
+} from "openstack-uicore-foundation/lib/utils/actions";
+import {
+ getAddOnTypes,
+ saveAddOnType,
+ deleteAddOnType,
+ resetAddOnTypeForm,
+ REQUEST_ADD_ON_TYPES,
+ RECEIVE_ADD_ON_TYPES,
+ ADD_ON_TYPE_UPDATED,
+ ADD_ON_TYPE_ADDED,
+ RESET_ADD_ON_TYPE_FORM
+} from "../add-on-types-actions";
+import * as methods from "../../utils/methods";
+
+jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
+ __esModule: true,
+ ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
+ getRequest: jest.fn(),
+ putRequest: jest.fn(),
+ postRequest: jest.fn(),
+ deleteRequest: jest.fn()
+}));
+
+const requestMock =
+ (requestActionCreator, receiveActionCreator) => () => (dispatch) => {
+ if (requestActionCreator && typeof requestActionCreator === "function") {
+ dispatch(requestActionCreator({}));
+ }
+ return new Promise((resolve) => {
+ if (typeof receiveActionCreator === "function") {
+ dispatch(receiveActionCreator({ response: { id: 1 } }));
+ } else {
+ dispatch(receiveActionCreator);
+ }
+ resolve({ response: { id: 1 } });
+ });
+ };
+
+const rejectMock = () => () => () => Promise.reject(new Error("API error"));
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+describe("add-on-types actions", () => {
+ beforeEach(() => {
+ jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
+ getRequest.mockImplementation(requestMock);
+ putRequest.mockImplementation(requestMock);
+ postRequest.mockImplementation(requestMock);
+ deleteRequest.mockImplementation(requestMock);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe("getAddOnTypes", () => {
+ it("dispatches REQUEST and RECEIVE actions on success", async () => {
+ const store = mockStore({});
+ store.dispatch(getAddOnTypes());
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain(REQUEST_ADD_ON_TYPES);
+ expect(types).toContain(RECEIVE_ADD_ON_TYPES);
+ });
+
+ it("passes page in the extra data payload so the reducer can track current page", async () => {
+ let capturedExtra;
+ getRequest.mockImplementation(
+ (req, res, _url, _err, extra) => () => (dispatch) => {
+ capturedExtra = extra;
+ return requestMock(req, res)()(dispatch);
+ }
+ );
+
+ const store = mockStore({});
+ await store.dispatch(getAddOnTypes("foo", 2, 20, "name", -1));
+ await flushPromises();
+
+ expect(capturedExtra).toMatchObject({ page: 2, term: "foo" });
+ });
+
+ it("still dispatches STOP_LOADING when getRequest rejects", async () => {
+ getRequest.mockImplementation(rejectMock);
+ const store = mockStore({});
+ await store.dispatch(getAddOnTypes()).catch(() => {});
+ await flushPromises();
+
+ expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING");
+ });
+ });
+
+ describe("saveAddOnType", () => {
+ it("dispatches ADD_ON_TYPE_ADDED then STOP_LOADING when creating (no id)", async () => {
+ const store = mockStore({});
+ store.dispatch(saveAddOnType({ name: "VIP" }));
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain(ADD_ON_TYPE_ADDED);
+ expect(types.indexOf("STOP_LOADING")).toBeGreaterThan(
+ types.indexOf(ADD_ON_TYPE_ADDED)
+ );
+ });
+
+ it("dispatches ADD_ON_TYPE_UPDATED then STOP_LOADING when updating (has id)", async () => {
+ const store = mockStore({});
+ store.dispatch(saveAddOnType({ id: 5, name: "VIP" }));
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain(ADD_ON_TYPE_UPDATED);
+ expect(types.indexOf("STOP_LOADING")).toBeGreaterThan(
+ types.indexOf(ADD_ON_TYPE_UPDATED)
+ );
+ });
+
+ it("still dispatches STOP_LOADING when the request rejects", async () => {
+ postRequest.mockImplementation(rejectMock);
+ putRequest.mockImplementation(rejectMock);
+ const store = mockStore({});
+ await store.dispatch(saveAddOnType({ name: "VIP" })).catch(() => {});
+ await flushPromises();
+
+ expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING");
+ });
+ });
+
+ describe("deleteAddOnType", () => {
+ it("dispatches STOP_LOADING on success", async () => {
+ const store = mockStore({});
+ store.dispatch(deleteAddOnType(7));
+ await flushPromises();
+
+ expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING");
+ });
+
+ it("still dispatches STOP_LOADING when deleteRequest rejects", async () => {
+ deleteRequest.mockImplementation(rejectMock);
+ const store = mockStore({});
+ await store.dispatch(deleteAddOnType(7)).catch(() => {});
+ await flushPromises();
+
+ expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING");
+ });
+ });
+
+ describe("resetAddOnTypeForm", () => {
+ it("dispatches RESET_ADD_ON_TYPE_FORM", () => {
+ const store = mockStore({});
+ store.dispatch(resetAddOnTypeForm());
+
+ expect(store.getActions().map((a) => a.type)).toContain(
+ RESET_ADD_ON_TYPE_FORM
+ );
+ });
+ });
+});
diff --git a/src/actions/add-on-types-actions.js b/src/actions/add-on-types-actions.js
new file mode 100644
index 000000000..4814b23ec
--- /dev/null
+++ b/src/actions/add-on-types-actions.js
@@ -0,0 +1,183 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import T from "i18n-react/dist/i18n-react";
+import {
+ getRequest,
+ putRequest,
+ postRequest,
+ deleteRequest,
+ createAction,
+ stopLoading,
+ startLoading,
+ escapeFilterValue
+} from "openstack-uicore-foundation/lib/utils/actions";
+import { snackbarErrorHandler, snackbarSuccessHandler } from "./base-actions";
+import { getAccessTokenSafely } from "../utils/methods";
+import { DEFAULT_PER_PAGE } from "../utils/constants";
+
+export const REQUEST_ADD_ON_TYPES = "REQUEST_ADD_ON_TYPES";
+export const RECEIVE_ADD_ON_TYPES = "RECEIVE_ADD_ON_TYPES";
+export const RECEIVE_ADD_ON_TYPE = "RECEIVE_ADD_ON_TYPE";
+export const RESET_ADD_ON_TYPE_FORM = "RESET_ADD_ON_TYPE_FORM";
+export const UPDATE_ADD_ON_TYPE = "UPDATE_ADD_ON_TYPE";
+export const ADD_ON_TYPE_UPDATED = "ADD_ON_TYPE_UPDATED";
+export const ADD_ON_TYPE_ADDED = "ADD_ON_TYPE_ADDED";
+export const ADD_ON_TYPE_DELETED = "ADD_ON_TYPE_DELETED";
+
+export const getAddOnTypes =
+ (
+ term = null,
+ page = 1,
+ perPage = DEFAULT_PER_PAGE,
+ order = "order",
+ orderDir = 1
+ ) =>
+ async (dispatch) => {
+ const accessToken = await getAccessTokenSafely();
+ const filter = [];
+
+ dispatch(startLoading());
+
+ const params = {
+ page,
+ per_page: perPage,
+ access_token: accessToken
+ };
+
+ if (term) {
+ const escapedTerm = escapeFilterValue(term);
+ filter.push(`name=@${escapedTerm}`);
+ }
+
+ if (filter.length > 0) {
+ params["filter[]"] = filter;
+ }
+
+ // order
+ if (order != null && orderDir != null) {
+ const orderDirSign = orderDir === 1 ? "+" : "-";
+ params.order = `${orderDirSign}${order}`;
+ }
+
+ return getRequest(
+ createAction(REQUEST_ADD_ON_TYPES),
+ createAction(RECEIVE_ADD_ON_TYPES),
+ `${window.API_BASE_URL}/api/v1/summits/all/add-on-types`,
+ snackbarErrorHandler,
+ { order, orderDir, perPage, page, term }
+ )(params)(dispatch).finally(() => {
+ dispatch(stopLoading());
+ });
+ };
+
+export const getAddOnType = (addOnTypeId) => async (dispatch) => {
+ const accessToken = await getAccessTokenSafely();
+
+ dispatch(startLoading());
+
+ const params = {
+ access_token: accessToken
+ };
+
+ return getRequest(
+ null,
+ createAction(RECEIVE_ADD_ON_TYPE),
+ `${window.API_BASE_URL}/api/v1/summits/all/add-on-types/${addOnTypeId}`,
+ snackbarErrorHandler
+ )(params)(dispatch).finally(() => {
+ dispatch(stopLoading());
+ });
+};
+
+export const saveAddOnType = (entity) => async (dispatch) => {
+ const accessToken = await getAccessTokenSafely();
+ dispatch(startLoading());
+
+ const params = {
+ access_token: accessToken
+ };
+
+ const normalizedEntity = normalizeEntity(entity);
+
+ if (entity.id) {
+ return putRequest(
+ createAction(UPDATE_ADD_ON_TYPE),
+ createAction(ADD_ON_TYPE_UPDATED),
+ `${window.API_BASE_URL}/api/v1/summits/all/add-on-types/${entity.id}`,
+ normalizedEntity,
+ snackbarErrorHandler,
+ entity
+ )(params)(dispatch)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("add_on_types_list.add_on_type_saved")
+ })
+ );
+ })
+ .finally(() => dispatch(stopLoading()));
+ }
+
+ return postRequest(
+ createAction(UPDATE_ADD_ON_TYPE),
+ createAction(ADD_ON_TYPE_ADDED),
+ `${window.API_BASE_URL}/api/v1/summits/all/add-on-types`,
+ normalizedEntity,
+ snackbarErrorHandler,
+ entity
+ )(params)(dispatch)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("add_on_types_list.add_on_type_created")
+ })
+ );
+ })
+ .finally(() => dispatch(stopLoading()));
+};
+
+export const deleteAddOnType = (addOnTypeId) => async (dispatch) => {
+ const accessToken = await getAccessTokenSafely();
+
+ dispatch(startLoading());
+
+ const params = {
+ access_token: accessToken
+ };
+
+ return deleteRequest(
+ null,
+ createAction(ADD_ON_TYPE_DELETED)({ addOnTypeId }),
+ `${window.API_BASE_URL}/api/v1/summits/all/add-on-types/${addOnTypeId}`,
+ null,
+ snackbarErrorHandler
+ )(params)(dispatch).finally(() => {
+ dispatch(stopLoading());
+ });
+};
+
+export const resetAddOnTypeForm = () => (dispatch) => {
+ dispatch(createAction(RESET_ADD_ON_TYPE_FORM)({}));
+};
+
+const normalizeEntity = (entity) => {
+ const normalizedEntity = { ...entity };
+
+ delete normalizedEntity.created;
+ delete normalizedEntity.last_edited;
+
+ return normalizedEntity;
+};
diff --git a/src/components/menu/menu-definition.js b/src/components/menu/menu-definition.js
index 0baaa4985..cf69d27dd 100644
--- a/src/components/menu/menu-definition.js
+++ b/src/components/menu/menu-definition.js
@@ -52,6 +52,10 @@ export const getGlobalItems = () => [
{
name: "page_templates",
linkUrl: "page-templates"
+ },
+ {
+ name: "add_on_types",
+ linkUrl: "add-on-types"
}
]
},
diff --git a/src/i18n/en.json b/src/i18n/en.json
index d93332dee..4665be63c 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -223,7 +223,8 @@
"sponsors_inventory": "Sponsors",
"form_templates": "Form Templates",
"inventory": "Inventory",
- "page_templates": "Pages"
+ "page_templates": "Pages",
+ "add_on_types": "Add-On Types"
},
"schedule": {
"schedule": "Schedule",
@@ -4223,6 +4224,20 @@
},
"clone_success": "Page template cloned successfully."
},
+ "add_on_types_list": {
+ "add_on_types": "Add-On Types",
+ "add_on_type": "Add-On Type",
+ "id_column_label": "Id",
+ "name_column_label": "Name",
+ "add_add_on_type": "Add Add-On Type",
+ "add_on_type_created": "Add-On Type Created",
+ "add_on_type_saved": "Add-On Type Saved",
+ "delete_add_on_type_warning": "Are you sure you want to delete Add-On Type {name}",
+ "no_results": "No items found for this search criteria.",
+ "placeholders": {
+ "search_add_on_types": "Search by name"
+ }
+ },
"dropbox_sync": {
"panel_title": "Synchronization Settings",
"toggle_label": "Enable location-based Dropbox file synchronization",
diff --git a/src/layouts/add-on-types-layout.js b/src/layouts/add-on-types-layout.js
new file mode 100644
index 000000000..d6c1c0f53
--- /dev/null
+++ b/src/layouts/add-on-types-layout.js
@@ -0,0 +1,42 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React from "react";
+import { Switch, Route, withRouter } from "react-router-dom";
+import T from "i18n-react/dist/i18n-react";
+import { Breadcrumb } from "react-breadcrumbs";
+import Restrict from "../routes/restrict";
+import NoMatchPage from "../pages/no-match-page";
+import AddOnTypesListPage from "../pages/sponsors-global/add-on-types/add-on-types-list-page";
+
+const AddOnTypesLayout = ({ match }) => (
+
+
+
+
+
+
+
+);
+
+export default Restrict(withRouter(AddOnTypesLayout), "add-on-types");
diff --git a/src/layouts/primary-layout.js b/src/layouts/primary-layout.js
index ffe63a257..1de985d08 100644
--- a/src/layouts/primary-layout.js
+++ b/src/layouts/primary-layout.js
@@ -38,6 +38,7 @@ const SponsoredProjectLayout = React.lazy(() =>
const TagLayout = React.lazy(() => import("./tag-layout"));
const SponsorshipLayout = React.lazy(() => import("./sponsorship-layout"));
const PageTemplateLayout = React.lazy(() => import("./page-template-layout"));
+const AddOnTypesLayout = React.lazy(() => import("./add-on-types-layout"));
const PrimaryLayout = ({ match, currentSummit, location, member }) => {
let extraClass = "container";
@@ -76,6 +77,7 @@ const PrimaryLayout = ({ match, currentSummit, location, member }) => {
+
({
+ __esModule: true,
+ default: { translate: jest.fn((key) => key) }
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
+ () =>
+ function MockTextField({ name, ...rest }) {
+ return ;
+ }
+);
+
+jest.mock("../../../../hooks/useScrollToError", () => jest.fn());
+
+jest.mock("../../../../utils/yup", () => ({
+ requiredStringValidation: () => {
+ const yup = jest.requireActual("yup");
+ return yup.string().required("required");
+ }
+}));
+
+const BASE_ENTITY = { id: 0, name: "" };
+
+describe("AddOnTypesDialog", () => {
+ let onSave;
+ let onClose;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ onSave = jest.fn(() => Promise.resolve());
+ onClose = jest.fn();
+ });
+
+ it("shows \"Add\" in the title for a new entity", () => {
+ render(
+
+ );
+ expect(screen.getByText(/general\.add/i)).toBeInTheDocument();
+ });
+
+ it("shows \"Edit\" in the title when editing an existing entity", () => {
+ render(
+
+ );
+ expect(screen.getByText(/general\.edit/i)).toBeInTheDocument();
+ });
+
+ it("calls onSave with form values then onClose on valid submit", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await act(async () => {
+ await user.click(screen.getByText("general.save"));
+ });
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 2, name: "Early Bird" })
+ );
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ });
+
+ it("disables save and close while saving, re-enables after resolve", async () => {
+ let resolve;
+ onSave = jest.fn(
+ () =>
+ new Promise((res) => {
+ resolve = res;
+ })
+ );
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const saveBtn = screen.getByText("general.save").closest("button");
+
+ await act(async () => {
+ await user.click(saveBtn);
+ });
+ expect(saveBtn).toBeDisabled();
+ expect(screen.getByLabelText("close")).toBeDisabled();
+
+ await act(async () => {
+ resolve();
+ });
+ await waitFor(() => expect(saveBtn).not.toBeDisabled());
+ });
+
+ it("keeps dialog open and re-enables save when onSave rejects", async () => {
+ onSave = jest.fn(() => Promise.reject(new Error("server error")));
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const saveBtn = screen.getByText("general.save").closest("button");
+ await act(async () => {
+ await user.click(saveBtn);
+ });
+
+ await waitFor(() => expect(saveBtn).not.toBeDisabled());
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose when the close button is clicked and not saving", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await act(async () => {
+ await user.click(screen.getByLabelText("close"));
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/pages/sponsors-global/add-on-types/__tests__/add-on-types-list-page.test.js b/src/pages/sponsors-global/add-on-types/__tests__/add-on-types-list-page.test.js
new file mode 100644
index 000000000..197077ba4
--- /dev/null
+++ b/src/pages/sponsors-global/add-on-types/__tests__/add-on-types-list-page.test.js
@@ -0,0 +1,224 @@
+import React from "react";
+import { Provider } from "react-redux";
+import { MemoryRouter } from "react-router-dom";
+import {
+ act,
+ render,
+ screen,
+ fireEvent,
+ waitFor
+} from "@testing-library/react";
+import "@testing-library/jest-dom";
+import flushPromises from "flush-promises";
+import configureStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import AddOnTypesListPage from "../add-on-types-list-page";
+
+const mockGetAddOnTypes = jest.fn();
+const mockGetAddOnType = jest.fn();
+const mockResetAddOnTypeForm = jest.fn();
+const mockSaveAddOnType = jest.fn();
+const mockDeleteAddOnType = jest.fn();
+
+jest.mock("../../../../actions/add-on-types-actions", () => ({
+ getAddOnTypes: (...args) => mockGetAddOnTypes(...args),
+ getAddOnType: (...args) => mockGetAddOnType(...args),
+ resetAddOnTypeForm: (...args) => mockResetAddOnTypeForm(...args),
+ saveAddOnType: (...args) => mockSaveAddOnType(...args),
+ deleteAddOnType: (...args) => mockDeleteAddOnType(...args)
+}));
+
+jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => {
+ const MockMuiTable = ({ data = [], onEdit, onDelete }) => (
+
+ {data.map((row) => (
+
+
+
+
+ ))}
+
+ );
+ return MockMuiTable;
+});
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/search-input",
+ () => ({
+ __esModule: true,
+ default: ({ onSearch }) => (
+ onSearch(e.target.value)} />
+ )
+ })
+);
+
+jest.mock("../add-on-types-dialog", () => {
+ const MockDialog = ({ onSave, onClose }) => (
+
+
+
+
+ );
+ return MockDialog;
+});
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+const baseState = {
+ currentAddonTypesListState: {
+ addOnTypes: [{ id: 1, name: "Early Bird" }],
+ totalAddOnTypes: 1,
+ term: "",
+ order: "name",
+ orderDir: 1,
+ currentPage: 1,
+ lastPage: 1,
+ perPage: 10
+ },
+ currentAddOnTypeState: { entity: { id: 0, name: "" }, errors: {} }
+};
+
+const renderPage = (stateOverride = {}) => {
+ const store = mockStore({ ...baseState, ...stateOverride });
+ render(
+
+
+
+
+
+ );
+ return store;
+};
+
+describe("AddOnTypesListPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetAddOnTypes.mockReturnValue(() => Promise.resolve());
+ mockGetAddOnType.mockReturnValue(() => Promise.resolve());
+ mockResetAddOnTypeForm.mockReturnValue(() => Promise.resolve());
+ mockSaveAddOnType.mockReturnValue(() => Promise.resolve());
+ mockDeleteAddOnType.mockReturnValue(() => Promise.resolve());
+ });
+
+ it("renders the table and fetches data on mount", () => {
+ renderPage();
+ expect(screen.getByTestId("mui-table")).toBeInTheDocument();
+ expect(mockGetAddOnTypes).toHaveBeenCalled();
+ });
+
+ it("shows no-results message when list is empty", () => {
+ renderPage({
+ currentAddonTypesListState: {
+ ...baseState.currentAddonTypesListState,
+ addOnTypes: [],
+ totalAddOnTypes: 0
+ }
+ });
+ expect(
+ screen.getByText("add_on_types_list.no_results")
+ ).toBeInTheDocument();
+ });
+
+ it("resets form and opens dialog when Add button is clicked", async () => {
+ renderPage();
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: /add_on_types_list\.add_add_on_type/i
+ })
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("add-on-types-dialog")).toBeInTheDocument()
+ );
+ expect(mockResetAddOnTypeForm).toHaveBeenCalled();
+ });
+
+ it("fetches the item and opens dialog when edit is clicked", async () => {
+ renderPage();
+ fireEvent.click(screen.getByRole("button", { name: "edit" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("add-on-types-dialog")).toBeInTheDocument()
+ );
+ expect(mockGetAddOnType).toHaveBeenCalledWith(1);
+ });
+
+ it("reloads the list from page 1 after a successful save", async () => {
+ renderPage({
+ currentAddonTypesListState: {
+ ...baseState.currentAddonTypesListState,
+ currentPage: 3
+ }
+ });
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: /add_on_types_list\.add_add_on_type/i
+ })
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("add-on-types-dialog")).toBeInTheDocument()
+ );
+
+ const callsBefore = mockGetAddOnTypes.mock.calls.length;
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "submit-form" }));
+ await flushPromises();
+ });
+
+ expect(mockGetAddOnTypes.mock.calls.length).toBeGreaterThan(callsBefore);
+ const lastArgs = mockGetAddOnTypes.mock.calls.at(-1);
+ expect(lastArgs[1]).toBe(1);
+ });
+
+ it("reloads the list from page 1 after a successful delete", async () => {
+ renderPage({
+ currentAddonTypesListState: {
+ ...baseState.currentAddonTypesListState,
+ currentPage: 3
+ }
+ });
+
+ const callsBefore = mockGetAddOnTypes.mock.calls.length;
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "delete" }));
+ await flushPromises();
+ });
+
+ expect(mockGetAddOnTypes.mock.calls.length).toBeGreaterThan(callsBefore);
+ const lastArgs = mockGetAddOnTypes.mock.calls.at(-1);
+ expect(lastArgs[1]).toBe(1);
+ });
+
+ it("closes dialog and resets form when close is clicked", async () => {
+ renderPage();
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: /add_on_types_list\.add_add_on_type/i
+ })
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("add-on-types-dialog")).toBeInTheDocument()
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "close" }));
+ await waitFor(() =>
+ expect(
+ screen.queryByTestId("add-on-types-dialog")
+ ).not.toBeInTheDocument()
+ );
+ expect(mockResetAddOnTypeForm).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/sponsors-global/add-on-types/add-on-types-dialog.js b/src/pages/sponsors-global/add-on-types/add-on-types-dialog.js
new file mode 100644
index 000000000..aa9a24a2d
--- /dev/null
+++ b/src/pages/sponsors-global/add-on-types/add-on-types-dialog.js
@@ -0,0 +1,138 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React, { useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import PropTypes from "prop-types";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Button,
+ Box,
+ IconButton,
+ Divider,
+ Grid2
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
+import { requiredStringValidation } from "../../../utils/yup";
+import useScrollToError from "../../../hooks/useScrollToError";
+
+const AddOnTypesDialog = ({ entity: initialEntity, onSave, onClose }) => {
+ const [isSaving, setIsSaving] = useState(false);
+
+ const formik = useFormik({
+ initialValues: {
+ id: initialEntity?.id ?? 0,
+ name: initialEntity?.name ?? ""
+ },
+ validationSchema: yup.object().shape({
+ name: requiredStringValidation()
+ }),
+ onSubmit: (values) => {
+ if (isSaving) return;
+ setIsSaving(true);
+ onSave(values)
+ .then(() => onClose())
+ .catch(() => {})
+ .finally(() => setIsSaving(false));
+ }
+ });
+
+ useScrollToError(formik);
+
+ const handleClose = () => {
+ if (isSaving) return;
+ formik.resetForm();
+ onClose();
+ };
+
+ const title = initialEntity?.id
+ ? `${T.translate("general.edit")} ${T.translate(
+ "add_on_types_list.add_on_type"
+ )}`
+ : `${T.translate("general.add")} ${T.translate(
+ "add_on_types_list.add_on_type"
+ )}`;
+
+ return (
+
+ );
+};
+
+AddOnTypesDialog.propTypes = {
+ entity: PropTypes.object,
+ onClose: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired
+};
+
+export default AddOnTypesDialog;
diff --git a/src/pages/sponsors-global/add-on-types/add-on-types-list-page.js b/src/pages/sponsors-global/add-on-types/add-on-types-list-page.js
new file mode 100644
index 000000000..d3c5e5003
--- /dev/null
+++ b/src/pages/sponsors-global/add-on-types/add-on-types-list-page.js
@@ -0,0 +1,219 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React, { useEffect, useState } from "react";
+import { Button, Grid2 } from "@mui/material";
+import Box from "@mui/material/Box";
+import AddIcon from "@mui/icons-material/Add";
+import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input";
+import { connect } from "react-redux";
+import T from "i18n-react/dist/i18n-react";
+import MuiTable from "openstack-uicore-foundation/lib/components/mui/table";
+import {
+ getAddOnTypes,
+ getAddOnType,
+ resetAddOnTypeForm,
+ saveAddOnType,
+ deleteAddOnType
+} from "../../../actions/add-on-types-actions";
+import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants";
+import AddOnTypesDialog from "./add-on-types-dialog";
+
+const AddOnTypesListPage = ({
+ addOnTypes,
+ currentAddOnType,
+ currentPage,
+ perPage,
+ term,
+ order,
+ orderDir,
+ totalAddOnTypes,
+ getAddOnTypes,
+ getAddOnType,
+ saveAddOnType,
+ deleteAddOnType,
+ resetAddOnTypeForm
+}) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ resetAddOnTypeForm();
+ setOpen(false);
+ };
+
+ useEffect(() => {
+ getAddOnTypes(term, DEFAULT_CURRENT_PAGE, perPage, order, orderDir);
+ }, [getAddOnTypes]);
+
+ const handlePageChange = (page) => {
+ getAddOnTypes(term, page, perPage, order, orderDir);
+ };
+
+ const handlePerPageChange = (newPerPage) => {
+ getAddOnTypes(term, DEFAULT_CURRENT_PAGE, newPerPage, order, orderDir);
+ };
+
+ const handleSort = (key, dir) => {
+ getAddOnTypes(term, currentPage, perPage, key, dir);
+ };
+
+ const handleSearch = (searchTerm) => {
+ getAddOnTypes(searchTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir);
+ };
+
+ const handleNewAddOnType = () => {
+ resetAddOnTypeForm();
+ setOpen(true);
+ };
+
+ const handleEdit = (row) => {
+ if (row) {
+ getAddOnType(row.id).then(() => setOpen(true));
+ }
+ };
+
+ const handleDelete = (addOnTypeId) =>
+ deleteAddOnType(addOnTypeId).then(() =>
+ getAddOnTypes(term, DEFAULT_CURRENT_PAGE, perPage, order, orderDir)
+ );
+
+ const handleAddOnTypeSave = (item) =>
+ saveAddOnType(item).then(() =>
+ getAddOnTypes(term, DEFAULT_CURRENT_PAGE, perPage, order, orderDir)
+ );
+
+ const columns = [
+ {
+ columnKey: "id",
+ header: T.translate("add_on_types_list.id_column_label"),
+ width: 120,
+ sortable: true
+ },
+ {
+ columnKey: "name",
+ header: T.translate("add_on_types_list.name_column_label"),
+ sortable: true
+ }
+ ];
+
+ const tableOptions = {
+ sortCol: order,
+ sortDir: orderDir
+ };
+
+ return (
+
+
{T.translate("add_on_types_list.add_on_types")}
+
+
+
+ {totalAddOnTypes} {T.translate("add_on_types_list.add_on_types")}
+
+
+
+
+
+
+
+
+
+
+ {addOnTypes.length > 0 && (
+
+
+ T.translate("add_on_types_list.delete_add_on_type_warning", {
+ name
+ })
+ }
+ />
+
+ )}
+
+ {addOnTypes.length === 0 && (
+
{T.translate("add_on_types_list.no_results")}
+ )}
+
+ {open && (
+
+ )}
+
+ );
+};
+
+const mapStateToProps = ({
+ currentAddonTypesListState,
+ currentAddOnTypeState
+}) => ({
+ ...currentAddonTypesListState,
+ currentAddOnType: currentAddOnTypeState.entity
+});
+
+export default connect(mapStateToProps, {
+ getAddOnTypes,
+ getAddOnType,
+ resetAddOnTypeForm,
+ saveAddOnType,
+ deleteAddOnType
+})(AddOnTypesListPage);
diff --git a/src/reducers/__tests__/add-on-types-reducers.test.js b/src/reducers/__tests__/add-on-types-reducers.test.js
new file mode 100644
index 000000000..9c623ad80
--- /dev/null
+++ b/src/reducers/__tests__/add-on-types-reducers.test.js
@@ -0,0 +1,130 @@
+import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
+import addOnTypeReducer, {
+ DEFAULT_ENTITY
+} from "../sponsors_inventory/add-on-type-reducer";
+import addOnTypesListReducer from "../sponsors_inventory/add-on-types-list-reducer";
+import {
+ REQUEST_ADD_ON_TYPES,
+ RECEIVE_ADD_ON_TYPES,
+ RECEIVE_ADD_ON_TYPE,
+ RESET_ADD_ON_TYPE_FORM,
+ ADD_ON_TYPE_DELETED
+} from "../../actions/add-on-types-actions";
+
+// ─── addOnTypeReducer ─────────────────────────────────────────────────────────
+
+describe("addOnTypeReducer", () => {
+ it("RESET_ADD_ON_TYPE_FORM resets entity to DEFAULT_ENTITY", () => {
+ const dirty = { entity: { id: 3, name: "Foo" } };
+ const state = addOnTypeReducer(dirty, {
+ type: RESET_ADD_ON_TYPE_FORM,
+ payload: {}
+ });
+ expect(state.entity).toEqual(DEFAULT_ENTITY);
+ });
+
+ it("RECEIVE_ADD_ON_TYPE merges response into entity", () => {
+ const initial = { entity: DEFAULT_ENTITY, errors: {} };
+ const state = addOnTypeReducer(initial, {
+ type: RECEIVE_ADD_ON_TYPE,
+ payload: { response: { id: 5, name: "VIP Pass" } }
+ });
+ expect(state.entity.id).toBe(5);
+ expect(state.entity.name).toBe("VIP Pass");
+ });
+
+ it("LOGOUT_USER without persistStore resets entity", () => {
+ const dirty = { entity: { id: 3, name: "Foo" } };
+ const state = addOnTypeReducer(dirty, { type: LOGOUT_USER, payload: {} });
+ expect(state.entity).toEqual(DEFAULT_ENTITY);
+ });
+
+ it("LOGOUT_USER with persistStore preserves current state", () => {
+ const dirty = { entity: { id: 3, name: "Foo" } };
+ const state = addOnTypeReducer(dirty, {
+ type: LOGOUT_USER,
+ payload: { persistStore: true }
+ });
+ expect(state).toEqual(dirty);
+ });
+});
+
+// ─── addOnTypesListReducer ────────────────────────────────────────────────────
+
+describe("addOnTypesListReducer", () => {
+ const emptyState = addOnTypesListReducer(undefined, { type: "@@INIT" });
+
+ it("returns empty addOnTypes and zero total for unknown action", () => {
+ expect(emptyState.addOnTypes).toEqual([]);
+ expect(emptyState.totalAddOnTypes).toBe(0);
+ });
+
+ it("LOGOUT_USER resets list to empty", () => {
+ const dirty = {
+ ...emptyState,
+ addOnTypes: [{ id: 1 }],
+ totalAddOnTypes: 1
+ };
+ const state = addOnTypesListReducer(dirty, {
+ type: LOGOUT_USER,
+ payload: {}
+ });
+ expect(state.addOnTypes).toEqual([]);
+ expect(state.totalAddOnTypes).toBe(0);
+ });
+
+ it("REQUEST_ADD_ON_TYPES updates pagination and term without touching the list", () => {
+ const stateWithData = {
+ ...emptyState,
+ addOnTypes: [{ id: 1 }],
+ currentPage: 2
+ };
+ const state = addOnTypesListReducer(stateWithData, {
+ type: REQUEST_ADD_ON_TYPES,
+ payload: { order: "name", orderDir: 1, page: 1, perPage: 10, term: "vip" }
+ });
+ expect(state.currentPage).toBe(1);
+ expect(state.order).toBe("name");
+ expect(state.term).toBe("vip");
+ });
+
+ describe("RECEIVE_ADD_ON_TYPES", () => {
+ it("populates addOnTypes, totalAddOnTypes and currentPage from response", () => {
+ const state = addOnTypesListReducer(emptyState, {
+ type: RECEIVE_ADD_ON_TYPES,
+ payload: {
+ response: {
+ data: [
+ { id: 1, name: "Early Bird" },
+ { id: 2, name: "VIP" }
+ ],
+ current_page: 1,
+ last_page: 3,
+ total: 25
+ }
+ }
+ });
+ expect(state.addOnTypes).toHaveLength(2);
+ expect(state.totalAddOnTypes).toBe(25);
+ expect(state.currentPage).toBe(1);
+ });
+ });
+
+ describe("ADD_ON_TYPE_DELETED", () => {
+ it("removes the item with the matching id", () => {
+ const stateWithData = {
+ ...emptyState,
+ addOnTypes: [
+ { id: 1, name: "Early Bird" },
+ { id: 2, name: "VIP" }
+ ]
+ };
+ const state = addOnTypesListReducer(stateWithData, {
+ type: ADD_ON_TYPE_DELETED,
+ payload: { addOnTypeId: 1 }
+ });
+ expect(state.addOnTypes).toHaveLength(1);
+ expect(state.addOnTypes[0].id).toBe(2);
+ });
+ });
+});
diff --git a/src/reducers/sponsors_inventory/add-on-type-reducer.js b/src/reducers/sponsors_inventory/add-on-type-reducer.js
new file mode 100644
index 000000000..74e0f11dd
--- /dev/null
+++ b/src/reducers/sponsors_inventory/add-on-type-reducer.js
@@ -0,0 +1,60 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
+import {
+ RECEIVE_ADD_ON_TYPE,
+ RESET_ADD_ON_TYPE_FORM
+} from "../../actions/add-on-types-actions";
+
+export const DEFAULT_ENTITY = {
+ id: 0,
+ name: "",
+ created: null,
+ last_edited: null
+};
+
+const DEFAULT_STATE = {
+ entity: DEFAULT_ENTITY
+};
+
+const addOnTypeReducer = (state = DEFAULT_STATE, action) => {
+ const { type, payload = {} } = action;
+ switch (type) {
+ case LOGOUT_USER: {
+ // we need this in case the token expired while editing the form
+ if (Object.prototype.hasOwnProperty.call(payload, "persistStore")) {
+ return state;
+ }
+ return { ...state, entity: { ...DEFAULT_ENTITY } };
+ }
+ case RESET_ADD_ON_TYPE_FORM: {
+ return { ...state, entity: { ...DEFAULT_ENTITY } };
+ }
+ case RECEIVE_ADD_ON_TYPE: {
+ const entity = { ...payload.response };
+
+ return {
+ ...state,
+ entity: {
+ ...DEFAULT_ENTITY,
+ ...entity
+ }
+ };
+ }
+ default:
+ return state;
+ }
+};
+
+export default addOnTypeReducer;
diff --git a/src/reducers/sponsors_inventory/add-on-types-list-reducer.js b/src/reducers/sponsors_inventory/add-on-types-list-reducer.js
new file mode 100644
index 000000000..ff0795570
--- /dev/null
+++ b/src/reducers/sponsors_inventory/add-on-types-list-reducer.js
@@ -0,0 +1,65 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
+import {
+ ADD_ON_TYPE_DELETED,
+ RECEIVE_ADD_ON_TYPES,
+ REQUEST_ADD_ON_TYPES
+} from "../../actions/add-on-types-actions";
+
+const DEFAULT_STATE = {
+ addOnTypes: [],
+ term: "",
+ order: "name",
+ orderDir: 1,
+ currentPage: 1,
+ lastPage: 1,
+ perPage: 10,
+ totalAddOnTypes: 0
+};
+
+const addOnTypesListReducer = (state = DEFAULT_STATE, action = {}) => {
+ const { type, payload } = action;
+ switch (type) {
+ case LOGOUT_USER: {
+ return DEFAULT_STATE;
+ }
+ case REQUEST_ADD_ON_TYPES: {
+ const { order, orderDir, term, page, perPage } = payload;
+ return { ...state, order, orderDir, term, currentPage: page, perPage };
+ }
+ case RECEIVE_ADD_ON_TYPES: {
+ const { current_page, total, last_page, data } = payload.response;
+
+ return {
+ ...state,
+ addOnTypes: data,
+ currentPage: current_page,
+ totalAddOnTypes: total,
+ lastPage: last_page
+ };
+ }
+ case ADD_ON_TYPE_DELETED: {
+ const { addOnTypeId } = payload;
+ return {
+ ...state,
+ addOnTypes: state.addOnTypes.filter((a) => a.id !== addOnTypeId)
+ };
+ }
+ default:
+ return state;
+ }
+};
+
+export default addOnTypesListReducer;
diff --git a/src/store.js b/src/store.js
index 326f84399..74a00017d 100644
--- a/src/store.js
+++ b/src/store.js
@@ -174,6 +174,8 @@ import sponsorPagePurchaseListReducer from "./reducers/sponsors/sponsor-page-pur
import sponsorPagePagesListReducer from "./reducers/sponsors/sponsor-page-pages-list-reducer.js";
import sponsorPageMUListReducer from "./reducers/sponsors/sponsor-page-mu-list-reducer.js";
import dropboxSyncReducer from "./reducers/locations/dropbox-sync-reducer";
+import addOnTypeReducer from "./reducers/sponsors_inventory/add-on-type-reducer.js";
+import addOnTypesListReducer from "./reducers/sponsors_inventory/add-on-types-list-reducer.js";
// default: localStorage if web, AsyncStorage if react-native
@@ -343,6 +345,8 @@ const reducers = persistCombineReducers(config, {
sponsorSettingsState: sponsorSettingsReducer,
pageTemplateListState: pageTemplateListReducer,
pageTemplateState: pageTemplateReducer,
+ currentAddonTypesListState: addOnTypesListReducer,
+ currentAddOnTypeState: addOnTypeReducer,
dropboxSyncState: dropboxSyncReducer
});