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 ( + + + {title} + + + + + + + + + + + + + + + + + + + + + + ); +}; + +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 });