diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js
index 73083c3e8..230a79bb7 100644
--- a/src/actions/sponsor-actions.js
+++ b/src/actions/sponsor-actions.js
@@ -2279,22 +2279,26 @@ export const querySummitSponsorships = _.debounce(
DEBOUNCE_WAIT
);
-export const querySummitAddons = _.debounce(
- async (input, summitId, callback) => {
- const accessToken = await getAccessTokenSafely();
- const endpoint = URI(
- `${window.API_BASE_URL}/api/v1/summits/${summitId}/add-ons/metadata`
- );
- endpoint.addQuery("access_token", accessToken);
- fetch(endpoint)
- .then(fetchResponseHandler)
- .then((data) => {
- callback(data);
- })
- .catch(fetchErrorHandler);
- },
- DEBOUNCE_WAIT
-);
+export const querySummitAddons = async (
+ summitId,
+ callback
+) => {
+ const accessToken = await getAccessTokenSafely();
+ const endpoint = URI(
+ `${window.API_BASE_URL}/api/v1/summits/${summitId}/add-ons/metadata`
+ );
+ endpoint.addQuery("access_token", accessToken);
+ endpoint.addQuery("page", 1);
+ endpoint.addQuery("per_page", MAX_PER_PAGE);
+
+ return fetch(endpoint)
+ .then(fetchResponseHandler)
+ .then((data) => callback(data))
+ .catch((error) => {
+ fetchErrorHandler(error);
+ return [];
+ });
+};
export const querySponsorAddons = async (
summitId,
diff --git a/src/actions/sponsor-cart-actions.js b/src/actions/sponsor-cart-actions.js
index 117ef85cc..282defb5f 100644
--- a/src/actions/sponsor-cart-actions.js
+++ b/src/actions/sponsor-cart-actions.js
@@ -17,19 +17,30 @@ import {
getRequest,
deleteRequest,
putRequest,
+ postRequest,
startLoading,
stopLoading
} from "openstack-uicore-foundation/lib/utils/actions";
-
+import { amountToCents } from "openstack-uicore-foundation/lib/utils/money";
import T from "i18n-react";
import { escapeFilterValue, getAccessTokenSafely } from "../utils/methods";
import { snackbarErrorHandler, snackbarSuccessHandler } from "./base-actions";
-import { ERROR_CODE_404 } from "../utils/constants";
+import {
+ DEFAULT_CURRENT_PAGE,
+ DEFAULT_ORDER_DIR,
+ DEFAULT_PER_PAGE,
+ ERROR_CODE_404
+} from "../utils/constants";
export const REQUEST_SPONSOR_CART = "REQUEST_SPONSOR_CART";
export const RECEIVE_SPONSOR_CART = "RECEIVE_SPONSOR_CART";
export const SPONSOR_CART_FORM_DELETED = "SPONSOR_CART_FORM_DELETED";
export const SPONSOR_CART_FORM_LOCKED = "SPONSOR_CART_FORM_LOCKED";
+export const REQUEST_CART_AVAILABLE_FORMS = "REQUEST_CART_AVAILABLE_FORMS";
+export const RECEIVE_CART_AVAILABLE_FORMS = "RECEIVE_CART_AVAILABLE_FORMS";
+export const REQUEST_CART_SPONSOR_FORM = "REQUEST_CART_SPONSOR_FORM";
+export const RECEIVE_CART_SPONSOR_FORM = "RECEIVE_CART_SPONSOR_FORM";
+export const FORM_CART_SAVED = "FORM_CART_SAVED";
const customErrorHandler = (err, res) => (dispatch, state) => {
const code = err.status;
@@ -163,3 +174,130 @@ export const unlockSponsorCartForm = (formId) => async (dispatch, getState) => {
dispatch(stopLoading());
});
};
+
+export const getSponsorFormsForCart =
+ (
+ term = "",
+ currentPage = DEFAULT_CURRENT_PAGE,
+ order = "id",
+ orderDir = DEFAULT_ORDER_DIR
+ ) =>
+ async (dispatch, getState) => {
+ const { currentSummitState } = getState();
+ const { currentSummit } = currentSummitState;
+ const accessToken = await getAccessTokenSafely();
+ const filter = ["has_items==1"];
+
+ dispatch(startLoading());
+
+ if (term) {
+ const escapedTerm = escapeFilterValue(term);
+ filter.push(`name=@${escapedTerm},code=@${escapedTerm}`);
+ }
+
+ const params = {
+ page: currentPage,
+ fields: "id,code,name,items",
+ per_page: DEFAULT_PER_PAGE,
+ access_token: accessToken
+ };
+
+ 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_CART_AVAILABLE_FORMS),
+ createAction(RECEIVE_CART_AVAILABLE_FORMS),
+ `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms`,
+ authErrorHandler,
+ { term, order, orderDir, currentPage }
+ )(params)(dispatch).then(() => {
+ dispatch(stopLoading());
+ });
+ };
+
+// get sponsor show form by id USING V2 API
+export const getSponsorForm = (formId) => async (dispatch, getState) => {
+ const { currentSummitState } = getState();
+ const { currentSummit } = currentSummitState;
+ const accessToken = await getAccessTokenSafely();
+
+ dispatch(startLoading());
+
+ const params = {
+ access_token: accessToken
+ };
+
+ return getRequest(
+ createAction(REQUEST_CART_SPONSOR_FORM),
+ createAction(RECEIVE_CART_SPONSOR_FORM),
+ `${window.PURCHASES_API_URL}/api/v2/summits/${currentSummit.id}/show-forms/${formId}`,
+ authErrorHandler
+ )(params)(dispatch).then(() => {
+ dispatch(stopLoading());
+ });
+};
+
+const normalizeItems = (items) =>
+ items.map((item) => {
+ const { quantity, custom_rate, ...normalizedItem } = item;
+ const hasQtyFields = item.meta_fields.some(
+ (f) => f.class_field === "Form" && f.type_name === "Quantity"
+ );
+ const metaFields = item.meta_fields.filter(
+ (item) => item.current_value !== null
+ );
+
+ return {
+ ...normalizedItem,
+ ...(hasQtyFields ? {} : { quantity }),
+ ...(custom_rate > 0 ? { custom_rate: amountToCents(custom_rate) } : {}),
+ meta_fields: metaFields
+ };
+ });
+
+export const addCartForm =
+ (formId, addOnId, formValues) => async (dispatch, getState) => {
+ const { currentSummitState, currentSponsorState } = getState();
+ const accessToken = await getAccessTokenSafely();
+ const { currentSummit } = currentSummitState;
+ const { entity: sponsor } = currentSponsorState;
+
+ const params = {
+ access_token: accessToken
+ };
+
+ dispatch(startLoading());
+
+ const normalizedEntity = {
+ form_id: formId,
+ addon_id: addOnId,
+ discount_type: formValues.discount_type,
+ discount_value: formValues.discount_amount,
+ items: normalizeItems(formValues.items)
+ };
+
+ return postRequest(
+ null,
+ createAction(FORM_CART_SAVED),
+ `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/carts/current/forms`,
+ normalizedEntity,
+ snackbarErrorHandler
+ )(params)(dispatch)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("sponsor_list.sponsor_added")
+ })
+ );
+ })
+ .finally(() => dispatch(stopLoading()));
+ };
diff --git a/src/components/CustomTheme.js b/src/components/CustomTheme.js
index 646039a30..9eff33380 100644
--- a/src/components/CustomTheme.js
+++ b/src/components/CustomTheme.js
@@ -57,7 +57,10 @@ const theme = createTheme({
MuiFormHelperText: {
styleOverrides: {
root: {
- fontSize: ".8em"
+ fontSize: ".8em",
+ position: "absolute",
+ top: "100%",
+ marginTop: "4px"
}
}
},
diff --git a/src/components/forms/sponsor-general-form/manage-tier-addons-popup.js b/src/components/forms/sponsor-general-form/manage-tier-addons-popup.js
index 594833426..be750f25e 100644
--- a/src/components/forms/sponsor-general-form/manage-tier-addons-popup.js
+++ b/src/components/forms/sponsor-general-form/manage-tier-addons-popup.js
@@ -24,7 +24,7 @@ import EditIcon from "@mui/icons-material/Edit";
import useScrollToError from "../../../hooks/useScrollToError";
import MuiFormikTextField from "../../mui/formik-inputs/mui-formik-textfield";
-import SummitAddonSelect from "../../mui/formik-inputs/summit-addon-select";
+import MuiFormikSummitAddonSelect from "../../mui/formik-inputs/mui-formik-summit-addon-select";
const ManageTierAddonsPopup = ({
sponsorship,
@@ -223,14 +223,16 @@ const ManageTierAddonsPopup = ({
{editingRow === index ? (
-
) : (
@@ -318,15 +320,16 @@ const ManageTierAddonsPopup = ({
{T.translate("edit_sponsor.addon_type")}
-
diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
new file mode 100644
index 000000000..386010e59
--- /dev/null
+++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js
@@ -0,0 +1,842 @@
+/* eslint-env jest */
+import React from "react";
+import PropTypes from "prop-types";
+import { cleanup, fireEvent, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { FormikProvider, useFormik } from "formik";
+import FormItemTable from "../index";
+import { renderWithProviders } from "../../../utils/test-utils";
+import { MOCK_FORM_A } from "../../../utils/mock-data/mock-forms";
+import ItemTableField from "../components/ItemTableField";
+
+const EARLY_BIRD_DATE = 1751035704;
+const STANDARD_DATE = 1851035704;
+const ONSITE_DATE = 1951035704;
+const MOCK_TIME_BEFORE_EARLY_BIRD = 1650000000000;
+const MILLISECONDS_MULTIPLIER = 1000;
+const TIME_OFFSET = 100;
+const TWO_ITEMS = 2;
+const MOCK_RATE_DATES = {
+ early_bird_end_date: EARLY_BIRD_DATE,
+ standard_price_end_date: STANDARD_DATE,
+ onsite_end_date: ONSITE_DATE
+};
+
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (str) => str,
+ i18n: { changeLanguage: jest.fn() }
+ }),
+ initReactI18next: {
+ type: "3rdParty",
+ init: jest.fn()
+ }
+}));
+
+jest.mock("../../formik-inputs/mui-formik-textfield", () => {
+ const { useField } = require("formik");
+ return {
+ __esModule: true,
+ default: ({ name, label, type, slotProps, multiline, rows, ...props }) => {
+ const [field] = useField(name);
+ return (
+
+ );
+ }
+ };
+});
+
+jest.mock("../../formik-inputs/mui-formik-timepicker", () => ({
+ __esModule: true,
+ default: ({ name, label, timeZone }) => (
+
+ )
+}));
+
+jest.mock("../../formik-inputs/mui-formik-datepicker", () => ({
+ __esModule: true,
+ default: ({ name, label }) => (
+
+ )
+}));
+
+jest.mock("../../formik-inputs/mui-formik-select", () => ({
+ __esModule: true,
+ default: ({ name, label, options }) => (
+
+ )
+}));
+
+jest.mock("../../formik-inputs/mui-formik-checkbox", () => ({
+ __esModule: true,
+ default: ({ name, label }) => (
+
+ )
+}));
+
+jest.mock("../../formik-inputs/mui-formik-dropdown-checkbox", () => ({
+ __esModule: true,
+ default: ({ name, label, options }) => (
+
+ {label}
+ {options.map((opt) => (
+ // eslint-disable-next-line jsx-a11y/label-has-associated-control
+
+ ))}
+
+ )
+}));
+
+jest.mock("../../formik-inputs/mui-formik-dropdown-radio", () => ({
+ __esModule: true,
+ default: ({ name, label, options }) => (
+
+ {label}
+ {options.map((opt) => (
+ // eslint-disable-next-line jsx-a11y/label-has-associated-control
+
+ ))}
+
+ )
+}));
+
+afterEach(cleanup);
+
+// Wrapper component with Formik
+const FormItemTableWrapper = ({
+ data,
+ rateDates,
+ timeZone,
+ initialValues,
+ onNotesClick,
+ onSettingsClick
+}) => {
+ const formik = useFormik({
+ initialValues: initialValues || {},
+ onSubmit: () => {}
+ });
+
+ return (
+
+
+
+ );
+};
+
+FormItemTableWrapper.propTypes = {
+ data: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+ rateDates: PropTypes.shape({}).isRequired,
+ timeZone: PropTypes.string.isRequired,
+ initialValues: PropTypes.shape({}),
+ onNotesClick: PropTypes.func.isRequired,
+ onSettingsClick: PropTypes.func.isRequired
+};
+
+FormItemTableWrapper.defaultProps = {
+ initialValues: {}
+};
+
+describe("FormItemTable Component", () => {
+ const mockOnNotesClick = jest.fn();
+ const mockOnSettingsClick = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .spyOn(Date, "now")
+ .mockImplementation(() => MOCK_TIME_BEFORE_EARLY_BIRD);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("renders the table with correct structure", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("edit_form.code")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.description")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.early_bird_rate")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.standard_rate")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.onsite_rate")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.qty")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.total")).toBeInTheDocument();
+ expect(screen.getByText("edit_form.notes")).toBeInTheDocument();
+ });
+
+ it("renders all items from mock data", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("Installation")).toBeInTheDocument();
+ expect(screen.getByText("Dismantle")).toBeInTheDocument();
+ expect(screen.getByText("Installation Manpower")).toBeInTheDocument();
+ expect(screen.getByText("Dismantle Manpower")).toBeInTheDocument();
+ });
+
+ it("renders dynamic columns from meta_fields", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("Qty of People")).toBeInTheDocument();
+ expect(screen.getByText("Hour x Person")).toBeInTheDocument();
+ expect(screen.getByText("Arrival Time")).toBeInTheDocument();
+ });
+
+ it("displays rate values in cents to dollar format", () => {
+ renderWithProviders(
+
+ );
+
+ // early_bird: 15000 cents = $150.00
+ expect(screen.getAllByText("$150.00").length).toBeGreaterThan(0);
+ // standard: 18800 cents = $188.00
+ expect(screen.getAllByText("$188.00").length).toBeGreaterThan(0);
+ // onsite: 22400 cents = $224.00
+ expect(screen.getAllByText("$224.00").length).toBeGreaterThan(0);
+ });
+
+ it("renders TOTAL row at the bottom", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("edit_form.total_on_caps")).toBeInTheDocument();
+ });
+ });
+
+ describe("ITEM Class Fields", () => {
+ it("shows warning icon for items with ITEM class fields", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("edit_form.additional_info")).toBeInTheDocument();
+ });
+
+ it("renders settings button only for items with ITEM class fields", () => {
+ const { container } = renderWithProviders(
+
+ );
+
+ const settingsButtons = container.querySelectorAll(
+ "[data-testid=\"SettingsIcon\"]"
+ );
+ expect(settingsButtons.length).toBe(1);
+ });
+
+ it("calls onSettingsClick when settings button is clicked", () => {
+ const { container } = renderWithProviders(
+
+ );
+
+ const settingsButton = container.querySelector(
+ "[data-testid=\"SettingsIcon\"]"
+ ).parentElement;
+ fireEvent.click(settingsButton);
+
+ expect(mockOnSettingsClick).toHaveBeenCalledWith(MOCK_FORM_A.items[0]);
+ expect(mockOnSettingsClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Form Inputs", () => {
+ it("renders Quantity input fields with correct attributes", () => {
+ renderWithProviders(
+
+ );
+
+ const qtyPeopleInput = screen.getByTestId("textfield-i-1-c-Form-f-1");
+ expect(qtyPeopleInput).toHaveAttribute("type", "number");
+ expect(qtyPeopleInput).toHaveAttribute("min", "1");
+ expect(qtyPeopleInput).toHaveAttribute("max", "4");
+ });
+
+ it("renders Time input fields with timezone", () => {
+ renderWithProviders(
+
+ );
+
+ const timeInput = screen.getByTestId("timepicker-i-1-c-Form-f-3");
+ expect(timeInput).toBeInTheDocument();
+ expect(timeInput).toHaveAttribute("data-timezone", "America/New_York");
+ });
+
+ it("renders correct number of form inputs per item", () => {
+ renderWithProviders(
+
+ );
+
+ expect(
+ screen.getByTestId("textfield-i-1-c-Form-f-1")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("textfield-i-1-c-Form-f-2")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("timepicker-i-1-c-Form-f-3")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("textfield-i-2-c-Form-f-1")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("textfield-i-2-c-Form-f-2")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("timepicker-i-2-c-Form-f-3")
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Quantity Calculation", () => {
+ it("calculates quantity based on FORM Quantity fields", () => {
+ const initialValues = {
+ "i-1-c-Form-f-1": 2,
+ "i-1-c-Form-f-2": 4
+ };
+
+ renderWithProviders(
+
+ );
+
+ const qtyInput = screen.getByTestId("textfield-i-1-c-global-f-quantity");
+ // eslint-disable-next-line
+ expect(qtyInput).toHaveValue(8);
+ });
+
+ it("renders manual quantity input when no FORM Quantity fields exist", () => {
+ const itemsWithoutQuantityFields = [
+ {
+ form_item_id: 10,
+ code: "TEST",
+ name: "Test Item",
+ rates: {
+ early_bird: 10000,
+ standard: 12000,
+ onsite: 15000
+ },
+ meta_fields: [
+ {
+ type_id: 100,
+ class: "Form",
+ name: "Description",
+ type: "Text"
+ }
+ ]
+ }
+ ];
+
+ renderWithProviders(
+
+ );
+
+ expect(
+ screen.getByTestId("textfield-i-10-c-global-f-quantity")
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Total Calculation", () => {
+ it("calculates item total correctly based on quantity and rate", () => {
+ const initialValues = {
+ "i-1-c-Form-f-1": 2,
+ "i-1-c-Form-f-2": 4,
+ "i-2-c-Form-f-1": 1,
+ "i-2-c-Form-f-2": 2
+ };
+
+ renderWithProviders(
+
+ );
+
+ const allText = screen.getAllByText(/\$/);
+ const dollarValues = allText.map((el) => el.textContent);
+
+ expect(dollarValues).toContain("$1504.00");
+ expect(dollarValues).toContain("$376.00");
+ });
+
+ it("calculates total amount for all items", () => {
+ const initialValues = {
+ "i-1-c-Form-f-1": 2,
+ "i-1-c-Form-f-2": 4,
+ "i-2-c-Form-f-1": 1,
+ "i-2-c-Form-f-2": 2
+ };
+
+ renderWithProviders(
+
+ );
+
+ const allText = screen.getAllByText(/\$/);
+ const dollarValues = allText.map((el) => el.textContent);
+ expect(dollarValues).toContain("$1880.00");
+ });
+
+ it("shows $0.00 when no quantities are set", () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("edit_form.total_on_caps")).toBeInTheDocument();
+ });
+ });
+
+ describe("Rate Highlighting", () => {
+ it("highlights early_bird rate when current time is before early_bird_rate", () => {
+ jest
+ .spyOn(Date, "now")
+ .mockImplementation(() => MOCK_TIME_BEFORE_EARLY_BIRD);
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getAllByText("$150.00").length).toBeGreaterThan(0);
+ });
+
+ it("highlights standard rate when current time is between early_bird and standard", () => {
+ jest
+ .spyOn(Date, "now")
+ .mockImplementation(
+ () =>
+ (MOCK_RATE_DATES.early_bird_rate + TIME_OFFSET) *
+ MILLISECONDS_MULTIPLIER
+ );
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getAllByText("$188.00").length).toBeGreaterThan(0);
+ });
+
+ it("highlights onsite rate when current time is after standard_rate", () => {
+ jest
+ .spyOn(Date, "now")
+ .mockImplementation(
+ () =>
+ (MOCK_RATE_DATES.standard_rate + TIME_OFFSET) *
+ MILLISECONDS_MULTIPLIER
+ );
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getAllByText("$224.00").length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("Notes Functionality", () => {
+ it("renders edit/notes button for all items", () => {
+ const { container } = renderWithProviders(
+
+ );
+
+ const editButtons = container.querySelectorAll(
+ "[data-testid=\"EditIcon\"]"
+ );
+ expect(editButtons.length).toBe(TWO_ITEMS);
+ });
+
+ it("calls onNotesClick with correct item when notes button is clicked", () => {
+ const { container } = renderWithProviders(
+
+ );
+
+ const editButtons = container.querySelectorAll(
+ "[data-testid=\"EditIcon\"]"
+ );
+ fireEvent.click(editButtons[0].parentElement);
+
+ expect(mockOnNotesClick).toHaveBeenCalledWith(MOCK_FORM_A.items[0]);
+ expect(mockOnNotesClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onNotesClick for second item independently", () => {
+ const { container } = renderWithProviders(
+
+ );
+
+ const editButtons = container.querySelectorAll(
+ "[data-testid=\"EditIcon\"]"
+ );
+ fireEvent.click(editButtons[1].parentElement);
+
+ expect(mockOnNotesClick).toHaveBeenCalledWith(MOCK_FORM_A.items[1]);
+ expect(mockOnNotesClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("renderInput Helper Function", () => {
+ const RenderInputWrapper = ({ field, timeZone, label }) => {
+ const formik = useFormik({ initialValues: {}, onSubmit: () => {} });
+ return (
+
+
+
+ );
+ };
+
+ RenderInputWrapper.propTypes = {
+ field: PropTypes.shape({}).isRequired,
+ timeZone: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired
+ };
+
+ it("renders CheckBox input correctly", () => {
+ const field = {
+ type_id: 1,
+ class_field: "Form",
+ type: "CheckBox",
+ name: "Test Checkbox"
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"checkbox-i-1-c-Form-f-1\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders CheckBoxList input with options", () => {
+ const field = {
+ type_id: 2,
+ class_field: "Form",
+ type: "CheckBoxList",
+ name: "Test CheckBoxList",
+ values: [
+ { id: 1, value: "Option 1" },
+ { id: 2, value: "Option 2" }
+ ]
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector(
+ "[data-testid=\"dropdown-checkbox-i-1-c-Form-f-2\"]"
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("renders RadioButtonList input with options", () => {
+ const field = {
+ type_id: 3,
+ class_field: "Form",
+ type: "RadioButtonList",
+ name: "Test Radio",
+ values: [
+ { id: 1, value: "Radio 1" },
+ { id: 2, value: "Radio 2" }
+ ]
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"dropdown-radio-i-1-c-Form-f-3\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders DateTime input correctly", () => {
+ const field = {
+ type_id: 4,
+ class_field: "Item",
+ type: "DateTime",
+ name: "Test DateTime"
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"datepicker-i-1-c-Item-f-4\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders Time input with timezone", () => {
+ const field = {
+ type_id: 5,
+ class_field: "Form",
+ type: "Time",
+ name: "Test Time"
+ };
+ const { container } = renderWithProviders(
+
+ );
+ const input = container.querySelector(
+ "[data-testid=\"timepicker-i-1-c-Form-f-5\"]"
+ );
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("data-timezone", "America/Chicago");
+ });
+
+ it("renders ComboBox with options", () => {
+ const field = {
+ type_id: 6,
+ class_field: "Form",
+ type: "ComboBox",
+ name: "Test ComboBox",
+ values: [
+ { id: 1, value: "Combo 1" },
+ { id: 2, value: "Combo 2" }
+ ]
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"select-i-1-c-Form-f-6\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders Text input correctly", () => {
+ const field = {
+ type_id: 7,
+ class_field: "Form",
+ type: "Text",
+ name: "Test Text"
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"textfield-i-1-c-Form-f-7\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders TextArea input correctly", () => {
+ const field = {
+ type_id: 8,
+ class_field: "Form",
+ type: "TextArea",
+ name: "Test TextArea"
+ };
+ const { container } = renderWithProviders(
+
+ );
+ expect(
+ container.querySelector("[data-testid=\"textfield-i-1-c-Form-f-8\"]")
+ ).toBeInTheDocument();
+ });
+
+ it("renders Quantity input with min/max attributes", () => {
+ const field = {
+ type_id: 9,
+ class_field: "Form",
+ type: "Quantity",
+ name: "Test Quantity",
+ minimum_quantity: 5,
+ maximum_quantity: 100
+ };
+ const { container } = renderWithProviders(
+
+ );
+ const input = container.querySelector(
+ "[data-testid=\"textfield-i-1-c-Form-f-9\"]"
+ );
+ expect(input).toHaveAttribute("min", "5");
+ expect(input).toHaveAttribute("max", "100");
+ });
+ });
+});
diff --git a/src/components/mui/FormItemTable/components/GlobalQuantityField.js b/src/components/mui/FormItemTable/components/GlobalQuantityField.js
new file mode 100644
index 000000000..341d49ee3
--- /dev/null
+++ b/src/components/mui/FormItemTable/components/GlobalQuantityField.js
@@ -0,0 +1,52 @@
+import React, { useEffect } from "react";
+import { useField } from "formik";
+import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield";
+
+const GlobalQuantityField = ({ row, extraColumns, value }) => {
+ const name = `i-${row.form_item_id}-c-global-f-quantity`;
+ // eslint-disable-next-line
+ const [field, meta, helpers] = useField(name);
+
+ // using readOnly since formik won't validate disabled fields
+ const isReadOnly =
+ extraColumns.filter((eq) => eq.type === "Quantity").length > 0;
+
+ useEffect(() => {
+ helpers.setValue(value);
+ helpers.setTouched(true);
+ }, [value]);
+
+ return (
+
+ );
+};
+
+export default GlobalQuantityField;
diff --git a/src/components/mui/FormItemTable/components/ItemTableField.js b/src/components/mui/FormItemTable/components/ItemTableField.js
new file mode 100644
index 000000000..bf4df024e
--- /dev/null
+++ b/src/components/mui/FormItemTable/components/ItemTableField.js
@@ -0,0 +1,93 @@
+import React from "react";
+import MuiFormikCheckbox from "../../formik-inputs/mui-formik-checkbox";
+import MuiFormikDropdownCheckbox from "../../formik-inputs/mui-formik-dropdown-checkbox";
+import MuiFormikDropdownRadio from "../../formik-inputs/mui-formik-dropdown-radio";
+import MuiFormikDatepicker from "../../formik-inputs/mui-formik-datepicker";
+import MuiFormikTimepicker from "../../formik-inputs/mui-formik-timepicker";
+import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield";
+import MuiFormikSelect from "../../formik-inputs/mui-formik-select";
+import T from "i18n-react";
+import { METAFIELD_TYPES } from "../../../../utils/constants";
+import { MenuItem } from "@mui/material";
+
+const ItemTableField = ({ rowId, field, timeZone, label = "" }) => {
+ const name = `i-${rowId}-c-${field.class_field}-f-${field.type_id}`;
+
+ switch (field.type) {
+ case "CheckBox":
+ return ;
+ case "CheckBoxList":
+ return (
+ ({ value: v.id, label: v.value }))}
+ />
+ );
+ case "RadioButtonList":
+ return (
+ ({ value: v.id, label: v.value }))}
+ />
+ );
+ case "DateTime":
+ return ;
+ case "Time":
+ return (
+
+ );
+ case "Quantity":
+ return (
+ 0
+ ? { max: field.maximum_quantity }
+ : {})
+ }
+ }}
+ />
+ );
+ case "ComboBox":
+ return (
+
+ {field.values.map((v) => (
+
+ ))}
+
+ );
+ case "Text":
+ return (
+
+ );
+ case "TextArea":
+ return (
+
+ );
+ }
+};
+
+export default ItemTableField;
diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js
new file mode 100644
index 000000000..df6f2bd15
--- /dev/null
+++ b/src/components/mui/FormItemTable/index.js
@@ -0,0 +1,334 @@
+import React, { useCallback, useMemo } from "react";
+import {
+ IconButton,
+ InputAdornment,
+ MenuItem,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography
+} from "@mui/material";
+import EditIcon from "@mui/icons-material/Edit";
+import SettingsIcon from "@mui/icons-material/Settings";
+import ErrorIcon from "@mui/icons-material/Error";
+import T from "i18n-react/dist/i18n-react";
+import {
+ currencyAmountFromCents,
+ amountFromCents,
+ amountToCents
+} from "openstack-uicore-foundation/lib/utils/money";
+import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods";
+import {
+ FORM_DISCOUNT_OPTIONS,
+ MILLISECONDS_IN_SECOND
+} from "../../../utils/constants";
+import GlobalQuantityField from "./components/GlobalQuantityField";
+import ItemTableField from "./components/ItemTableField";
+import MuiFormikSelect from "../formik-inputs/mui-formik-select";
+import MuiFormikTextField from "../formik-inputs/mui-formik-textfield";
+
+const FormItemTable = ({
+ data,
+ rateDates,
+ timeZone,
+ values,
+ onNotesClick,
+ onSettingsClick
+}) => {
+ const valuesStr = JSON.stringify(values);
+ const extraColumns =
+ data[0]?.meta_fields?.filter((mf) => mf.class_field === "Form") || [];
+ const fixedColumns = 10;
+ const totalColumns = extraColumns.length + fixedColumns;
+
+ const currentApplicableRate = useMemo(() => {
+ const now = epochToMomentTimeZone(
+ Math.floor(new Date() / MILLISECONDS_IN_SECOND),
+ timeZone
+ );
+
+ const earlyBirdEndOfDay = epochToMomentTimeZone(
+ rateDates.early_bird_end_date,
+ timeZone
+ )?.endOf("day");
+ const standardEndOfDay = epochToMomentTimeZone(
+ rateDates.standard_price_end_date,
+ timeZone
+ )?.endOf("day");
+ const onsiteEndOfDay = epochToMomentTimeZone(
+ rateDates.onsite_price_end_date,
+ timeZone
+ )?.endOf("day");
+
+ if (earlyBirdEndOfDay && now.isSameOrBefore(earlyBirdEndOfDay))
+ return "early_bird";
+ if (standardEndOfDay && now.isSameOrBefore(standardEndOfDay))
+ return "standard";
+ if (!onsiteEndOfDay || now.isSameOrBefore(onsiteEndOfDay)) return "onsite";
+ return "expired";
+ }, [rateDates, timeZone]);
+
+ const calculateQuantity = useCallback(
+ (row) => {
+ const qtyEXC = extraColumns.filter((exc) => exc.type === "Quantity");
+ return qtyEXC.reduce((res, exc) => {
+ const start = res > 0 ? res : 1;
+ return (
+ start *
+ (values?.[
+ `i-${row.form_item_id}-c-${exc.class_field}-f-${exc.type_id}`
+ ] || 0)
+ );
+ }, 0);
+ },
+ [valuesStr]
+ );
+
+ const calculateTotal = (row) => {
+ const qty =
+ values[`i-${row.form_item_id}-c-global-f-quantity`] ||
+ calculateQuantity(row);
+ if (currentApplicableRate === "expired") return 0;
+ const customRate = amountToCents(
+ values[`i-${row.form_item_id}-c-global-f-custom_rate`] || 0
+ );
+ const rate = customRate || row.rates[currentApplicableRate];
+ return qty * rate;
+ };
+
+ const hasItemFields = (row) =>
+ row.meta_fields.filter((mf) => mf.class_field === "Item").length > 0;
+
+ const totalAmount = useMemo(() => {
+ const subtotal = data.reduce((acc, row) => acc + calculateTotal(row), 0);
+ const discount =
+ values.discount_type === FORM_DISCOUNT_OPTIONS.AMOUNT
+ ? amountToCents(values.discount_amount || 0)
+ : subtotal * amountFromCents(values.discount_amount || 0);
+
+ return subtotal - discount;
+ }, [data, valuesStr]);
+
+ const handleEdit = (row) => {
+ onNotesClick(row);
+ };
+
+ const handleEditItemFields = (row) => {
+ onSettingsClick(row);
+ };
+
+ return (
+
+
+
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.code")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.description")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.custom_rate")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.early_bird_rate")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.standard_rate")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.onsite_rate")}
+
+ {extraColumns.map((exc) => (
+ {exc.name}
+ ))}
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.qty")}
+
+
+ {/* item level extra field */}
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.total")}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.notes")}
+
+
+
+
+ {data.map((row) => (
+
+ {row.code}
+
+ {row.name}
+ {hasItemFields(row) && (
+
+ {" "}
+ {T.translate(
+ "edit_sponsor.cart_tab.edit_form.additional_info"
+ )}
+
+ )}
+
+
+ $
+ )
+ }
+ }}
+ />
+
+
+ {currencyAmountFromCents(row.rates.early_bird)}
+
+
+ {currencyAmountFromCents(row.rates.standard)}
+
+
+ {currencyAmountFromCents(row.rates.onsite)}
+
+ {extraColumns.map((exc) => (
+
+
+
+ ))}
+
+
+
+
+ {hasItemFields(row) && (
+ handleEditItemFields(row)}
+ >
+
+
+ )}
+
+
+ {currencyAmountFromCents(calculateTotal(row))}
+
+
+ handleEdit(row)}>
+
+
+
+
+ ))}
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.discount")}
+
+ {/* eslint-disable-next-line */}
+ {new Array(totalColumns - 5).fill(0).map((_, i) => (
+
+ ))}
+
+
+ {Object.values(FORM_DISCOUNT_OPTIONS).map((p) => (
+
+ ))}
+
+
+
+
+
+ {values.discount_type === FORM_DISCOUNT_OPTIONS.RATE
+ ? "%"
+ : "$"}
+
+ ),
+ ...(values.discount_type === FORM_DISCOUNT_OPTIONS.RATE
+ ? { max: 100 }
+ : {})
+ }
+ }}
+ />
+
+
+
+
+
+ {T.translate("edit_sponsor.cart_tab.edit_form.total_on_caps")}
+
+ {/* eslint-disable-next-line */}
+ {new Array(totalColumns - 3).fill(0).map((_, i) => (
+
+ ))}
+
+ {currencyAmountFromCents(totalAmount)}
+
+
+
+
+
+
+ );
+};
+
+export default FormItemTable;
diff --git a/src/components/mui/ItemSettingsModal/index.js b/src/components/mui/ItemSettingsModal/index.js
new file mode 100644
index 000000000..0e48deafd
--- /dev/null
+++ b/src/components/mui/ItemSettingsModal/index.js
@@ -0,0 +1,78 @@
+import React from "react";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogTitle from "@mui/material/DialogTitle";
+import { Divider, IconButton, Typography } from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import ItemTableField from "../FormItemTable/components/ItemTableField";
+
+const ItemSettingsModal = ({ item, timeZone, open, onClose }) => {
+ const itemFields =
+ item?.meta_fields.filter((f) => f.class_field === "Item") || [];
+
+ const handleSave = () => {
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+ItemSettingsModal.propTypes = {
+ item: PropTypes.object.isRequired,
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired
+};
+
+export default ItemSettingsModal;
diff --git a/src/components/mui/NotesModal/index.js b/src/components/mui/NotesModal/index.js
new file mode 100644
index 000000000..91ab9d355
--- /dev/null
+++ b/src/components/mui/NotesModal/index.js
@@ -0,0 +1,70 @@
+import React, { useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import PropTypes from "prop-types";
+import Button from "@mui/material/Button";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogContentText from "@mui/material/DialogContentText";
+import DialogTitle from "@mui/material/DialogTitle";
+import { useField } from "formik";
+import { Divider, IconButton, TextField } from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+
+const NotesModal = ({ item, open, onClose, onSave }) => {
+ const name = `i-${item?.form_item_id}-c-global-f-notes`;
+ // eslint-disable-next-line
+ const [field, meta, helpers] = useField(name);
+ const [notes, setNotes] = useState(field?.value || "");
+
+ const handleSave = () => {
+ helpers.setValue(notes);
+ onClose();
+ onSave();
+ };
+
+ return (
+
+ );
+};
+
+NotesModal.propTypes = {
+ item: PropTypes.object.isRequired,
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired
+};
+
+export default NotesModal;
diff --git a/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js
new file mode 100644
index 000000000..be19776ef
--- /dev/null
+++ b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js
@@ -0,0 +1,75 @@
+import React from "react";
+import {
+ Checkbox,
+ Divider,
+ FormControl,
+ ListItemText,
+ MenuItem,
+ Select
+} from "@mui/material";
+import { useField } from "formik";
+import T from "i18n-react/dist/i18n-react";
+
+const MuiFormikDropdownCheckbox = ({ name, options, ...rest }) => {
+ const [field, meta, helpers] = useField(name);
+ const allSelected = options.every(({ value }) =>
+ field.value?.includes(value)
+ );
+
+ const handleChange = (event) => {
+ const { value } = event.target;
+
+ // If "all" was clicked
+ if (value.includes("all")) {
+ if (allSelected) {
+ helpers.setValue([]);
+ } else {
+ helpers.setValue(options.map((opt) => opt.value));
+ }
+ } else {
+ helpers.setValue(value);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default MuiFormikDropdownCheckbox;
diff --git a/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js
new file mode 100644
index 000000000..1ae013007
--- /dev/null
+++ b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js
@@ -0,0 +1,51 @@
+import React from "react";
+import {
+ FormControl,
+ ListItemText,
+ MenuItem,
+ Radio,
+ Select
+} from "@mui/material";
+import { useField } from "formik";
+import T from "i18n-react/dist/i18n-react";
+
+const MuiFormikDropdownRadio = ({ name, options, placeholder, ...rest }) => {
+ const finalPlaceholder = placeholder || T.translate("general.select_an_option");
+ const [field, meta, helpers] = useField(name);
+
+ const handleChange = (event) => {
+ helpers.setValue(event.target.value);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default MuiFormikDropdownRadio;
diff --git a/src/components/mui/formik-inputs/mui-formik-summit-addon-select.js b/src/components/mui/formik-inputs/mui-formik-summit-addon-select.js
new file mode 100644
index 000000000..54035ea20
--- /dev/null
+++ b/src/components/mui/formik-inputs/mui-formik-summit-addon-select.js
@@ -0,0 +1,33 @@
+import React from "react";
+import PropTypes from "prop-types";
+import { useField } from "formik";
+import SummitAddonSelect from "../summit-addon-select";
+
+const MuiFormikSummitAddonSelect = ({
+ name,
+ summitId,
+ placeholder = "Select..."
+}) => {
+ const [field, meta, helpers] = useField(name);
+
+ return (
+
+ );
+};
+
+MuiFormikSummitAddonSelect.propTypes = {
+ name: PropTypes.string.isRequired,
+ summitId: PropTypes.number.isRequired,
+ placeholder: PropTypes.string
+};
+
+export default MuiFormikSummitAddonSelect;
diff --git a/src/components/mui/formik-inputs/mui-formik-timepicker.js b/src/components/mui/formik-inputs/mui-formik-timepicker.js
new file mode 100644
index 000000000..147b3eb2c
--- /dev/null
+++ b/src/components/mui/formik-inputs/mui-formik-timepicker.js
@@ -0,0 +1,49 @@
+import React from "react";
+import PropTypes from "prop-types";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
+import { TimePicker } from "@mui/x-date-pickers/TimePicker";
+
+import { useField } from "formik";
+
+const MuiFormikTimepicker = ({ name, minTime, maxTime, timeZone }) => {
+ const [field, meta, helpers] = useField(name);
+
+ return (
+
+
+
+ );
+};
+
+MuiFormikTimepicker.propTypes = {
+ name: PropTypes.string.isRequired
+};
+
+export default MuiFormikTimepicker;
diff --git a/src/components/mui/formik-inputs/summit-addon-select.js b/src/components/mui/formik-inputs/summit-addon-select.js
deleted file mode 100644
index 88e905118..000000000
--- a/src/components/mui/formik-inputs/summit-addon-select.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import React, { useState, useMemo } from "react";
-import { MenuItem, CircularProgress, Select, Box } from "@mui/material";
-import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
-import PropTypes from "prop-types";
-import { useField } from "formik";
-import { querySummitAddons } from "../../../actions/sponsor-actions";
-
-const getCustomIcon = (loading) => {
- const Icon = () => (
-
- {loading && }
-
-
- );
- return Icon;
-};
-
-const SummitAddonSelect = ({ name, summitId, placeholder = "Select..." }) => {
- const [field, meta, helpers] = useField(name);
- const [options, setOptions] = useState([]);
- const [loading, setLoading] = useState(false);
- const [hasOptions, setHasOptions] = useState(false);
-
- const value = field.value || "";
- const error = meta.touched && meta.error;
-
- const fetchOptions = async () => {
- if (hasOptions || loading) return;
-
- setLoading(true);
- await querySummitAddons("", summitId, (results) => {
- const normalized = results.map((r) => ({
- value: r,
- label: r
- }));
- setOptions(normalized);
- setHasOptions(true);
- setLoading(false);
- });
- };
-
- const handleChange = (event) => {
- helpers.setValue(event.target.value);
- };
-
- const IconWithLoading = useMemo(() => getCustomIcon(loading), [loading]);
-
- return (
- <>
-
- {error && (
-
- {error}
-
- )}
- >
- );
-};
-
-SummitAddonSelect.propTypes = {
- name: PropTypes.string.isRequired,
- summitId: PropTypes.number.isRequired,
- placeholder: PropTypes.string
-};
-
-export default SummitAddonSelect;
diff --git a/src/components/mui/sponsor-addon-select.js b/src/components/mui/sponsor-addon-select.js
new file mode 100644
index 000000000..dc2bd5395
--- /dev/null
+++ b/src/components/mui/sponsor-addon-select.js
@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from "react";
+import { MenuItem, Select } from "@mui/material";
+import PropTypes from "prop-types";
+import { querySponsorAddons } from "../../actions/sponsor-actions";
+
+const SponsorAddonSelect = ({
+ value,
+ summitId,
+ sponsor,
+ placeholder = "Select...",
+ onChange,
+ inputProps = {}
+}) => {
+ const [options, setOptions] = useState([]);
+ const sponsorshipIds = sponsor.sponsorships.map((e) => e.id);
+
+ useEffect(() => {
+ querySponsorAddons(summitId, sponsor.id, sponsorshipIds,(results) => {
+ const normalized = results.map((r) => ({
+ value: r.id,
+ label: r.name
+ }));
+ setOptions(normalized);
+ });
+ }, []);
+
+ const handleChange = (ev) => {
+ onChange({id: ev.target.value, name: ev.target.label});
+ };
+
+ return (
+
+ );
+};
+
+SponsorAddonSelect.propTypes = {
+ value: PropTypes.string,
+ summitId: PropTypes.number.isRequired,
+ sponsor: PropTypes.object.isRequired,
+ placeholder: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+export default SponsorAddonSelect;
diff --git a/src/components/mui/summit-addon-select.js b/src/components/mui/summit-addon-select.js
new file mode 100644
index 000000000..72ea88693
--- /dev/null
+++ b/src/components/mui/summit-addon-select.js
@@ -0,0 +1,61 @@
+import React, { useEffect, useState } from "react";
+import { MenuItem, Select } from "@mui/material";
+import PropTypes from "prop-types";
+import { querySummitAddons } from "../../actions/sponsor-actions";
+
+const SummitAddonSelect = ({
+ value,
+ summitId,
+ placeholder = "Select...",
+ onChange,
+ inputProps = {}
+}) => {
+ const [options, setOptions] = useState([]);
+
+ useEffect(() => {
+ querySummitAddons(summitId, (results) => {
+ const normalized = results.map((r) => ({
+ value: r,
+ label: r
+ }));
+ setOptions(normalized);
+ });
+ }, []);
+
+ const handleChange = (event) => {
+ onChange(event.target.value);
+ };
+
+ return (
+
+ );
+};
+
+SummitAddonSelect.propTypes = {
+ value: PropTypes.string,
+ summitId: PropTypes.number.isRequired,
+ placeholder: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+export default SummitAddonSelect;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 2f359e052..aa5eef32a 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -43,6 +43,7 @@
"delete": "Delete",
"clone": "Clone",
"new": "New",
+ "all": "All",
"new_summit": "New Event",
"drop_files": "Drop images or click to select files to upload.",
"search": "Search",
@@ -75,6 +76,7 @@
"error": "Error!",
"unarchive": "Unarchive",
"archive": "Archive",
+ "items": "items",
"placeholders": {
"search_speakers": "Search Speakers by Name, Email, Speaker Id or Member Id",
"select_acceptance_criteria": "Select acceptance criteria",
@@ -95,7 +97,11 @@
"date": "Wrong date format.",
"after": "'{field1}' must be after '{field2}'.",
"boolean": "Must be a boolean.",
- "mib_aligned": "Must be a MiB aligned value"
+ "mib_aligned": "Must be a MiB aligned value",
+ "minimum": "Must be at least {minimum}",
+ "maximum": "Must be at most {maximum}",
+ "time": "Wrong time format.",
+ "wrong_format": "Wrong format."
},
"landing": {
"os_summit_admin": "Show Admin",
@@ -2467,13 +2473,33 @@
"code": "Code",
"name": "Name",
"add_ons": "Add-ons",
+ "items": "Items",
"discount": "Discount",
"amount": "Amount",
"manage_items": "Manage Items",
"add_form": "Add Form",
"no_cart": "No cart found.",
"pay_cc": "pay with credit card or ach",
- "pay_invoice": "pay with invoice"
+ "pay_invoice": "pay with invoice",
+ "add_form_to_cart": "Add Form to Cart",
+ "add_selected_form": "Add selected form",
+ "select_addon": "Select Add-on...",
+ "edit_form": {
+ "code": "Code",
+ "description": "Description",
+ "custom_rate": "Custom Rate",
+ "early_bird_rate": "Early Bird Rate",
+ "standard_rate": "Standard Rate",
+ "onsite_rate": "Onsite Rate",
+ "qty": "Quantity",
+ "total": "Total",
+ "notes": "Notes",
+ "notes_placeholder": "Enter your notes here...",
+ "additional_info": "Additional Info",
+ "discount": "Discount",
+ "total_on_caps": "TOTAL",
+ "no_forms_found": "No forms found."
+ }
},
"purchase_tab": {
"order": "Order",
diff --git a/src/pages/sponsors/edit-sponsor-page.js b/src/pages/sponsors/edit-sponsor-page.js
index 20969fc99..43e621d1e 100644
--- a/src/pages/sponsors/edit-sponsor-page.js
+++ b/src/pages/sponsors/edit-sponsor-page.js
@@ -38,6 +38,7 @@ import {
updateExtraQuestionOrder,
getExtraQuestionMeta
} from "../../actions/sponsor-actions";
+import {getSponsorPurchasesMeta} from "../../actions/sponsor-settings-actions";
import SponsorGeneralForm from "../../components/forms/sponsor-general-form/index";
import SponsorUsersListPerSponsorPage from "./sponsor-users-list-per-sponsor";
import SponsorFormsTab from "./sponsor-forms-tab";
@@ -116,7 +117,8 @@ const EditSponsorPage = (props) => {
resetSponsorExtraQuestionForm,
deleteExtraQuestion,
updateExtraQuestionOrder,
- getExtraQuestionMeta
+ getExtraQuestionMeta,
+ getSponsorPurchasesMeta
} = props;
const [selectedTab, setSelectedTab] = useState(getTabFromFragment(location));
@@ -159,6 +161,7 @@ const EditSponsorPage = (props) => {
getSponsorLeadReportSettingsMeta(entity.id);
getSponsorTiers(entity.id);
getExtraQuestionMeta();
+ getSponsorPurchasesMeta();
} else {
resetSponsorForm();
}
@@ -260,7 +263,7 @@ const EditSponsorPage = (props) => {
)}
-
+
@@ -306,5 +309,6 @@ export default connect(mapStateToProps, {
resetSponsorExtraQuestionForm,
deleteExtraQuestion,
updateExtraQuestionOrder,
- getExtraQuestionMeta
+ getExtraQuestionMeta,
+ getSponsorPurchasesMeta
})(EditSponsorPage);
diff --git a/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js
new file mode 100644
index 000000000..a443edbaa
--- /dev/null
+++ b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js
@@ -0,0 +1,217 @@
+import React, { useEffect } from "react";
+import T from "i18n-react/dist/i18n-react";
+import { connect } from "react-redux";
+import {
+ Box,
+ Button,
+ Grid2,
+ IconButton,
+ Paper,
+ Typography
+} from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+import LockOpenIcon from "@mui/icons-material/LockOpen";
+import LockClosedIcon from "@mui/icons-material/Lock";
+import MuiTable from "../../../../components/mui/table/mui-table";
+import { TotalRow } from "../../../../components/mui/table/extra-rows";
+import SearchInput from "../../../../components/mui/search-input";
+import {
+ deleteSponsorCartForm,
+ getSponsorCart,
+ lockSponsorCartForm,
+ unlockSponsorCartForm
+} from "../../../../actions/sponsor-cart-actions";
+
+const CartView = ({
+ cart,
+ term,
+ getSponsorCart,
+ deleteSponsorCartForm,
+ lockSponsorCartForm,
+ unlockSponsorCartForm,
+ onEdit,
+ onAddForm
+}) => {
+ useEffect(() => {
+ getSponsorCart();
+ }, []);
+
+ const handleSearch = (searchTerm) => {
+ getSponsorCart(searchTerm);
+ };
+
+ const handleDelete = (itemId) => {
+ deleteSponsorCartForm(itemId);
+ };
+
+ const handleManageItems = (item) => {
+ console.log("MANAGE ITEMS : ", item);
+ };
+
+ const handleLock = (form) => {
+ if (form.is_locked) {
+ unlockSponsorCartForm(form.id);
+ } else {
+ lockSponsorCartForm(form.id);
+ }
+ };
+
+ const handlePayCreditCard = () => {
+ console.log("PAY CREDIT CARD");
+ };
+
+ const handlePayInvoice = () => {
+ console.log("PAY INVOICE");
+ };
+
+ const tableColumns = [
+ {
+ columnKey: "code",
+ header: T.translate("edit_sponsor.cart_tab.code")
+ },
+ {
+ columnKey: "name",
+ header: T.translate("edit_sponsor.cart_tab.name")
+ },
+ {
+ columnKey: "addon_name",
+ header: T.translate("edit_sponsor.cart_tab.add_ons")
+ },
+ {
+ columnKey: "manage_items",
+ header: "",
+ width: 100,
+ align: "center",
+ render: (row) => (
+
+ )
+ },
+ {
+ columnKey: "discount",
+ header: T.translate("edit_sponsor.cart_tab.discount")
+ },
+ {
+ columnKey: "amount",
+ header: T.translate("edit_sponsor.cart_tab.amount")
+ },
+ {
+ columnKey: "lock",
+ header: "",
+ render: (row) => (
+ handleLock(row)}>
+ {row.is_locked ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ ];
+
+ return (
+ <>
+
+
+ {cart && (
+ {cart?.forms.length} forms in Cart
+ )}
+
+
+
+
+
+ }
+ sx={{ height: "36px" }}
+ >
+ {T.translate("edit_sponsor.cart_tab.add_form")}
+
+
+
+ {!cart && (
+
+ {T.translate("edit_sponsor.cart_tab.no_cart")}
+
+ )}
+ {!!cart && (
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+const mapStateToProps = ({ sponsorPageCartListState }) => ({
+ ...sponsorPageCartListState
+});
+
+export default connect(mapStateToProps, {
+ getSponsorCart,
+ deleteSponsorCartForm,
+ lockSponsorCartForm,
+ unlockSponsorCartForm
+})(CartView);
diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js
new file mode 100644
index 000000000..5717c9bad
--- /dev/null
+++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js
@@ -0,0 +1,353 @@
+/**
+ * Copyright 2018 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, { useMemo, useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import { connect } from "react-redux";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import { Button, Typography } from "@mui/material";
+import Box from "@mui/material/Box";
+import moment from "moment-timezone";
+import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods";
+import FormItemTable from "../../../../../components/mui/FormItemTable";
+import {
+ FORM_DISCOUNT_OPTIONS,
+ MILLISECONDS_IN_SECOND
+} from "../../../../../utils/constants";
+import NotesModal from "../../../../../components/mui/NotesModal";
+import ItemSettingsModal from "../../../../../components/mui/ItemSettingsModal";
+
+const parseValue = (item, timeZone) => {
+ switch (item.type) {
+ case "Quantity":
+ return item.current_value
+ ? parseInt(item.current_value)
+ : item.minimum_quantity || 0;
+ case "ComboBox":
+ case "Text":
+ case "TextArea":
+ return item.current_value || "";
+ case "CheckBox":
+ return item.current_value ? item.current_value === "True" : false;
+ case "CheckBoxList":
+ return item.current_value || [];
+ case "RadioButtonList":
+ return item.current_value || "";
+ case "Time":
+ return item.current_value
+ ? moment.tz(item.current_value, "HH:mm", timeZone)
+ : null;
+ case "DateTime":
+ return item.current_value
+ ? epochToMomentTimeZone(item.current_value, timeZone)
+ : null;
+ default:
+ return null;
+ }
+};
+
+const getYupValidation = (field) => {
+ let schema;
+
+ switch (field.type) {
+ case "Quantity": {
+ schema = yup.number(T.translate("validation.number"));
+ if (field.minimum_quantity > 0) {
+ schema = schema.min(
+ field.minimum_quantity,
+ T.translate("validation.minimum", { minimum: field.minimum_quantity })
+ );
+ }
+ if (field.maximum_quantity > 0) {
+ schema = schema.max(
+ field.maximum_quantity,
+ T.translate("validation.maximum", { maximum: field.maximum_quantity })
+ );
+ }
+ if (field.is_required) {
+ schema = schema.required(T.translate("validation.required"));
+ }
+ break;
+ }
+ case "Text":
+ case "TextArea": {
+ schema = yup.string(T.translate("validation.string"));
+
+ if (field.is_required) {
+ schema = schema.required(T.translate("validation.required"));
+ }
+ break;
+ }
+ case "Time":
+ case "DateTime": {
+ schema = yup.date(T.translate("validation.date"));
+
+ if (field.is_required) {
+ schema = schema.required(T.translate("validation.required"));
+ } else {
+ schema = schema.nullable();
+ }
+ break;
+ }
+ case "CheckBoxList": {
+ schema = yup
+ .array()
+ .of(yup.string())
+ .typeError(T.translate("validation.wrong_format"));
+
+ if (field.is_required) {
+ schema = schema.required(T.translate("validation.required"));
+ }
+ break;
+ }
+ default: {
+ schema = yup.string(T.translate("validation.wrong_format"));
+
+ if (field.is_required) {
+ schema = schema.required(T.translate("validation.required"));
+ }
+ break;
+ }
+ }
+
+ return schema;
+};
+
+const buildInitialValues = (form, timeZone) => {
+ const items = form?.items || [];
+
+ const initialValues = items.reduce((acc, item) => {
+ item.meta_fields.map((f) => {
+ acc[`i-${item.form_item_id}-c-${f.class_field}-f-${f.type_id}`] =
+ parseValue(f, timeZone);
+ });
+ // add notes
+ acc[`i-${item.form_item_id}-c-global-f-notes`] = item.notes || "";
+ // if no quantity inputs we add the global quantity input
+ acc[`i-${item.form_item_id}-c-global-f-quantity`] =
+ item.quantity || item.default_quantity || 0;
+ // custom rate
+ acc[`i-${item.form_item_id}-c-global-f-custom_rate`] =
+ item.custom_rate || 0;
+
+ return acc;
+ }, {});
+
+ initialValues.discount_amount = form.discount_amount || 0;
+ initialValues.discount_type =
+ form.discount_type || FORM_DISCOUNT_OPTIONS.AMOUNT;
+
+ return initialValues;
+};
+
+const buildValidationSchema = (items) => {
+ const schema = items.reduce((acc, item) => {
+ item.meta_fields
+ .filter((f) => f.class_field === "Form")
+ .map((f) => {
+ acc[`i-${item.form_item_id}-c-${f.class_field}-f-${f.type_id}`] =
+ getYupValidation(f);
+ });
+ // notes
+ acc[`i-${item.form_item_id}-c-global-f-notes`] = yup.string(
+ T.translate("validation.string")
+ );
+ // validation for the global quantity input
+ let globalQtySchema = yup
+ .number(T.translate("validation.number"))
+ .min(1, `${T.translate("validation.minimum")} 1`);
+ if (item.quantity_limit_per_sponsor > 0) {
+ globalQtySchema = globalQtySchema.max(
+ item.quantity_limit_per_sponsor,
+ T.translate("validation.maximum", {
+ maximum: item.quantity_limit_per_sponsor
+ })
+ );
+ }
+ globalQtySchema = globalQtySchema.required(
+ T.translate("validation.required")
+ );
+ acc[`i-${item.form_item_id}-c-global-f-quantity`] = globalQtySchema;
+ // custom rate
+ acc[`i-${item.form_item_id}-c-global-f-custom_rate`] = yup.number(
+ T.translate("validation.number")
+ );
+
+ return acc;
+ }, {});
+
+ schema.discount = yup.number(T.translate("validation.number"));
+ schema.discount_type = yup
+ .string(T.translate("validation.string"))
+ .nullable();
+
+ return schema;
+};
+
+const EditForm = ({
+ form,
+ showMetadata,
+ showTimeZone,
+ onSaveForm,
+ onCancel
+}) => {
+ const [notesItem, setNotesItem] = useState(null);
+ const [settingsItem, setSettingsItem] = useState(null);
+ const hasRateExpired = useMemo(() => {
+ const now = epochToMomentTimeZone(
+ Math.floor(new Date() / MILLISECONDS_IN_SECOND),
+ showTimeZone
+ );
+ const onsiteEndOfDay = epochToMomentTimeZone(
+ showMetadata.onsite_price_end_date,
+ showTimeZone
+ )?.endOf("day");
+ if (!onsiteEndOfDay || now.isSameOrBefore(onsiteEndOfDay)) return false;
+ return true;
+ }, [showMetadata, showTimeZone]);
+
+ const handleCancel = () => {
+ onCancel();
+ };
+
+ const handleSave = (values) => {
+ const { discount_amount, discount_type, ...itemValues } = values;
+ // re-format form values to match the API format
+ const items = Object.entries(itemValues).reduce((res, [key, val]) => {
+ const match = key.split("-");
+ if (match) {
+ const formItemId = parseInt(match[1]);
+ const itemClass = match[3]; // quantity or notes
+ const itemTypeId = match[5];
+ const isItemProp = !["quantity", "notes", "custom_rate"].includes(
+ itemTypeId
+ );
+ let current_value = val;
+
+ let resItem = res.find((i) => i.form_item_id === formItemId);
+ if (!resItem) {
+ resItem = { form_item_id: formItemId, meta_fields: [] };
+ res.push(resItem);
+ }
+ if (isItemProp) {
+ const metaField = form.items
+ .find((i) => i.form_item_id === formItemId)
+ ?.meta_fields.find((mf) => mf.type_id === parseInt(itemTypeId));
+
+ if (metaField?.type === "DateTime") {
+ current_value = val ? moment(val).unix() : null;
+ } else if (metaField?.type === "Time") {
+ current_value = val ? moment(val).format("HH:mm") : null;
+ }
+
+ resItem.meta_fields.push({
+ type_id: parseInt(itemTypeId),
+ type_name: metaField?.type,
+ class_field: itemClass,
+ current_value
+ });
+ } else {
+ resItem[itemTypeId] = current_value;
+ }
+ }
+
+ return res;
+ }, []);
+
+ onSaveForm({ discount_amount, discount_type, items });
+ };
+
+ const formik = useFormik(
+ {
+ initialValues: buildInitialValues(form, showTimeZone),
+ validationSchema: yup.object({
+ ...buildValidationSchema(form?.items || [])
+ }),
+ onSubmit: (values) => {
+ handleSave(values);
+ },
+ enableReinitialize: true
+ },
+ [form?.items]
+ );
+
+ // wait for formik to re-initialize with form items
+ if (!form || Object.keys(formik.values).length === 0) return null;
+
+ return (
+ <>
+
+ {form.code} - {form.name}
+ {form.addon_name ? ` - ${form.addon_name}` : ""}
+
+
+ {form?.items.length} {T.translate("general.items")}
+
+
+
+
+
+
+
+
+
+ setNotesItem(null)}
+ onSave={formik.handleSubmit}
+ />
+ setSettingsItem(null)}
+ />
+
+ >
+ );
+};
+
+const mapStateToProps = ({ currentSummitState, sponsorSettingsState }) => ({
+ showMetadata: sponsorSettingsState.settings,
+ showTimeZone: currentSummitState.currentSummit.time_zone_id
+});
+
+export default connect(mapStateToProps, {})(EditForm);
diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js
new file mode 100644
index 000000000..3394c9291
--- /dev/null
+++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js
@@ -0,0 +1,59 @@
+/**
+ * Copyright 2018 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 } from "react";
+import { connect } from "react-redux";
+import {
+ addCartForm,
+ getSponsorForm
+} from "../../../../../actions/sponsor-cart-actions";
+import EditForm from "./index";
+
+const NewCartForm = ({
+ formId,
+ addOn,
+ sponsorForm,
+ onCancel,
+ onSaveCallback,
+ getSponsorForm,
+ addCartForm
+}) => {
+ useEffect(() => {
+ getSponsorForm(formId);
+ }, []);
+
+ const saveForm = (values) => {
+ addCartForm(formId, addOn?.addon_id, values).then(() => {
+ onSaveCallback();
+ });
+ };
+
+ if (!sponsorForm) return null;
+
+ return (
+
+ );
+};
+
+const mapStateToProps = ({ sponsorPageCartListState }) => ({
+ sponsorForm: sponsorPageCartListState.sponsorForm
+});
+
+export default connect(mapStateToProps, {
+ getSponsorForm,
+ addCartForm
+})(NewCartForm);
diff --git a/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/__tests__/select-page-template-dialog.test.js b/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/__tests__/select-page-template-dialog.test.js
new file mode 100644
index 000000000..6875e2811
--- /dev/null
+++ b/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/__tests__/select-page-template-dialog.test.js
@@ -0,0 +1,382 @@
+// ---- Mocks must come first ----
+
+// i18n translate: echo the key
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import { Provider } from "react-redux";
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import SelectPageTemplateDialog from "../index";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+// Mock Redux actions
+jest.mock("../../../actions/page-template-actions", () => ({
+ getPageTemplates: jest.fn(() => () => Promise.resolve())
+}));
+
+// Avoid MUI ripple noise
+jest.mock("@mui/material/IconButton", () => {
+ const React = require("react");
+ return {
+ __esModule: true,
+ default: ({ children, onClick, ...rest }) => (
+
+ )
+ };
+});
+jest.mock("@mui/material/ButtonBase/TouchRipple", () => ({
+ __esModule: true,
+ default: () => null
+}));
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+// Mock page templates data
+const mockPageTemplates = [
+ {
+ id: 1,
+ code: "TPL001",
+ name: "Template One",
+ info_mod: "Info Module 1",
+ download_mod: "Download Module 1",
+ upload_mod: "Upload Module 1"
+ },
+ {
+ id: 2,
+ code: "TPL002",
+ name: "Template Two",
+ info_mod: "Info Module 2",
+ download_mod: "Download Module 2",
+ upload_mod: "Upload Module 2"
+ },
+ {
+ id: 3,
+ code: "TPL003",
+ name: "Template Three",
+ info_mod: "Info Module 3",
+ download_mod: "Download Module 3",
+ upload_mod: "Upload Module 3"
+ }
+];
+
+// Helper function to render the component with Redux store
+const renderWithStore = (props, storeState = {}) => {
+ const defaultState = {
+ pageTemplateListState: {
+ pageTemplates: mockPageTemplates,
+ currentPage: 1,
+ term: "",
+ order: "id",
+ orderDir: 1,
+ total: mockPageTemplates.length,
+ ...storeState
+ }
+ };
+
+ const store = mockStore(defaultState);
+
+ return render(
+
+
+
+ );
+};
+
+describe("SelectPageTemplateDialog", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Component Initialization", () => {
+ test("renders the dialog with title", () => {
+ renderWithStore();
+
+ expect(
+ screen.getByText("sponsor_pages.global_page_popup.title")
+ ).toBeInTheDocument();
+ });
+
+ test("calls getPageTemplates on mount", () => {
+ const {
+ getPageTemplates
+ } = require("../../../actions/page-template-actions");
+
+ renderWithStore();
+
+ expect(getPageTemplates).toHaveBeenCalledWith("", 1, 10, "id", 1, true);
+ });
+
+ test("displays initial selection count as 0", () => {
+ renderWithStore();
+
+ expect(screen.getByText("0 items selected")).toBeInTheDocument();
+ });
+
+ test("renders close button", () => {
+ renderWithStore();
+
+ const closeButton = screen.getByRole("button", { name: "" });
+ expect(closeButton).toBeInTheDocument();
+ });
+
+ test("renders search input", () => {
+ renderWithStore();
+
+ expect(
+ screen.getByPlaceholderText("sponsor_pages.placeholders.search")
+ ).toBeInTheDocument();
+ });
+
+ test("renders save button as disabled initially", () => {
+ renderWithStore();
+
+ const saveButton = screen.getByRole("button", {
+ name: "sponsor_pages.global_page_popup.add_selected"
+ });
+ expect(saveButton).toBeDisabled();
+ });
+
+ test("renders all page templates in table", () => {
+ renderWithStore();
+
+ expect(screen.getByText("TPL001")).toBeInTheDocument();
+ expect(screen.getByText("Template One")).toBeInTheDocument();
+ expect(screen.getByText("TPL002")).toBeInTheDocument();
+ expect(screen.getByText("Template Two")).toBeInTheDocument();
+ });
+ });
+
+ describe("Multi-selection mode (isMulti=true)", () => {
+ test("renders checkboxes when isMulti is true", () => {
+ renderWithStore({ isMulti: true });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes.length).toBe(mockPageTemplates.length);
+ });
+
+ test("allows selecting multiple items", async () => {
+ renderWithStore({ isMulti: true });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ // Select first checkbox
+ await userEvent.click(checkboxes[0]);
+ expect(screen.getByText("1 items selected")).toBeInTheDocument();
+
+ // Select second checkbox
+ await userEvent.click(checkboxes[1]);
+ expect(screen.getByText("2 items selected")).toBeInTheDocument();
+ });
+
+ test("allows deselecting items", async () => {
+ renderWithStore({ isMulti: true });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ // Select and deselect first checkbox
+ await userEvent.click(checkboxes[0]);
+ expect(screen.getByText("1 items selected")).toBeInTheDocument();
+
+ await userEvent.click(checkboxes[0]);
+ expect(screen.getByText("0 items selected")).toBeInTheDocument();
+ });
+
+ test("enables save button when items are selected", async () => {
+ renderWithStore({ isMulti: true });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ const saveButton = screen.getByRole("button", {
+ name: "sponsor_pages.global_page_popup.add_selected"
+ });
+
+ expect(saveButton).toBeDisabled();
+
+ await userEvent.click(checkboxes[0]);
+
+ expect(saveButton).not.toBeDisabled();
+ });
+
+ test("calls onSave with all selected row IDs", async () => {
+ const onSave = jest.fn();
+ renderWithStore({ isMulti: true, onSave });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ // Select first and third items
+ await userEvent.click(checkboxes[0]);
+ await userEvent.click(checkboxes[2]);
+
+ const saveButton = screen.getByRole("button", {
+ name: "sponsor_pages.global_page_popup.add_selected"
+ });
+ await userEvent.click(saveButton);
+
+ expect(onSave).toHaveBeenCalledWith([1, 3]);
+ });
+ });
+
+ describe("Single-selection mode (isMulti=false)", () => {
+ test("renders radio buttons when isMulti is false", () => {
+ renderWithStore({ isMulti: false });
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons.length).toBe(mockPageTemplates.length);
+ });
+
+ test("allows selecting only one item at a time", async () => {
+ renderWithStore({ isMulti: false });
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Select first radio
+ await userEvent.click(radioButtons[0]);
+ expect(screen.getByText("1 items selected")).toBeInTheDocument();
+ expect(radioButtons[0]).toBeChecked();
+
+ // Select second radio - should deselect first
+ await userEvent.click(radioButtons[1]);
+ expect(screen.getByText("1 items selected")).toBeInTheDocument();
+ expect(radioButtons[0]).not.toBeChecked();
+ expect(radioButtons[1]).toBeChecked();
+ });
+
+ test("enables save button when one item is selected", async () => {
+ renderWithStore({ isMulti: false });
+
+ const radioButtons = screen.getAllByRole("radio");
+ const saveButton = screen.getByRole("button", {
+ name: "sponsor_pages.global_page_popup.add_selected"
+ });
+
+ expect(saveButton).toBeDisabled();
+
+ await userEvent.click(radioButtons[0]);
+
+ expect(saveButton).not.toBeDisabled();
+ });
+
+ test("calls onSave with single selected row ID", async () => {
+ const onSave = jest.fn();
+ renderWithStore({ isMulti: false, onSave });
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ await userEvent.click(radioButtons[1]);
+
+ const saveButton = screen.getByRole("button", {
+ name: "sponsor_pages.global_page_popup.add_selected"
+ });
+ await userEvent.click(saveButton);
+
+ expect(onSave).toHaveBeenCalledWith([2]);
+ });
+ });
+
+ describe("Search functionality", () => {
+ test("calls getPageTemplates with search term", async () => {
+ const {
+ getPageTemplates
+ } = require("../../../actions/page-template-actions");
+ renderWithStore();
+
+ const searchInput = screen.getByPlaceholderText(
+ "sponsor_pages.placeholders.search"
+ );
+
+ await userEvent.type(searchInput, "Template");
+ await userEvent.keyboard("{Enter}");
+
+ await waitFor(() => {
+ expect(getPageTemplates).toHaveBeenCalledWith(
+ "Template",
+ 1,
+ 10,
+ "id",
+ 1,
+ true
+ );
+ });
+ });
+ });
+
+ describe("Close functionality", () => {
+ test("calls onClose when close button is clicked", async () => {
+ const onClose = jest.fn();
+ renderWithStore({ onClose });
+
+ const closeButton = screen.getByRole("button", { name: "" });
+ await userEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ test("resets selected rows when closing", async () => {
+ const onClose = jest.fn();
+ renderWithStore({ isMulti: true, onClose });
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ // Select an item
+ await userEvent.click(checkboxes[0]);
+ expect(screen.getByText("1 items selected")).toBeInTheDocument();
+
+ // Close dialog
+ const closeButton = screen.getByRole("button", { name: "" });
+ await userEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ describe("Load more functionality", () => {
+ test("calls getPageTemplates to load more when scrolling", () => {
+ const {
+ getPageTemplates
+ } = require("../../../actions/page-template-actions");
+ renderWithStore({}, { total: 20 }); // More items available than displayed
+
+ // The component should be rendered with loadMoreData available
+ // This would typically be triggered by scrolling in the MuiInfiniteTable
+ // The test verifies the component is set up with the correct total
+ expect(getPageTemplates).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Sorting functionality", () => {
+ test("calls getPageTemplates with sort parameters", () => {
+ const {
+ getPageTemplates
+ } = require("../../../actions/page-template-actions");
+ renderWithStore();
+
+ // Initial call on mount
+ expect(getPageTemplates).toHaveBeenCalledWith("", 1, 10, "id", 1, true);
+
+ // The actual sort interaction would be handled by MuiInfiniteTable
+ // This test verifies the component is initialized with correct sort params
+ });
+ });
+
+ describe("Empty state", () => {
+ test("renders nothing when no templates are available", () => {
+ renderWithStore({}, { pageTemplates: [] });
+
+ // Table headers might still be present, but no template rows
+ expect(screen.queryByText("TPL001")).not.toBeInTheDocument();
+ expect(screen.queryByText("Template One")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/index.js b/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/index.js
new file mode 100644
index 000000000..ce5b3c88e
--- /dev/null
+++ b/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/index.js
@@ -0,0 +1,188 @@
+import React, { useEffect, useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import PropTypes from "prop-types";
+import { connect } from "react-redux";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ FormControlLabel,
+ Grid2,
+ IconButton,
+ Radio,
+ Typography
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import SearchInput from "../../../../../components/mui/search-input";
+import { getSponsorFormsForCart } from "../../../../../actions/sponsor-cart-actions";
+import MuiInfiniteTable from "../../../../../components/mui/infinite-table";
+import SponsorAddonSelect from "../../../../../components/mui/sponsor-addon-select";
+
+const SelectFormDialog = ({
+ availableForms,
+ summitId,
+ sponsor,
+ open,
+ onSave,
+ onClose,
+ getSponsorFormsForCart
+}) => {
+ const [selectedRows, setSelectedRows] = useState([]);
+ const [selectedAddon, setSelectedAddon] = useState(null);
+ const { forms, currentPage, term, order, orderDir, total } = availableForms;
+
+ useEffect(() => {
+ if (open) {
+ getSponsorFormsForCart();
+ }
+ }, [open]);
+
+ const handleSort = (key, dir) => {
+ getSponsorFormsForCart(term, 1, key, dir);
+ };
+
+ const handleLoadMore = () => {
+ if (total > forms.length) {
+ getSponsorFormsForCart(term, currentPage + 1, order, orderDir);
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedRows([]);
+ setSelectedAddon(null);
+ onClose();
+ };
+
+ const handleOnCheck = (rowId, checked) => {
+ setSelectedRows(checked ? [rowId] : []);
+ };
+
+ const handleOnSearch = (searchTerm) => {
+ getSponsorFormsForCart(searchTerm);
+ };
+
+ const handleOnSave = () => {
+ const form = forms.find((f) => f.id === selectedRows[0]);
+ onSave(form, selectedAddon);
+
+ // reset dialog
+ setSelectedRows([]);
+ setSelectedAddon(null);
+ };
+
+ const columns = [
+ {
+ columnKey: "select",
+ header: "",
+ width: 30,
+ align: "center",
+ render: (row) => (
+ handleOnCheck(row.id, ev.target.checked)}
+ />
+ }
+ />
+ )
+ },
+ {
+ columnKey: "code",
+ header: T.translate("edit_sponsor.cart_tab.code"),
+ sortable: true
+ },
+ {
+ columnKey: "name",
+ header: T.translate("edit_sponsor.cart_tab.name"),
+ sortable: true
+ },
+ {
+ columnKey: "item_count",
+ header: T.translate("general.items"),
+ sortable: false
+ }
+ ];
+
+ return (
+
+ );
+};
+
+SelectFormDialog.propTypes = {
+ summitId: PropTypes.number.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired
+};
+
+const mapStateToProps = ({ sponsorPageCartListState }) => ({
+ availableForms: sponsorPageCartListState.availableForms
+});
+
+export default connect(mapStateToProps, {
+ getSponsorFormsForCart
+})(SelectFormDialog);
diff --git a/src/pages/sponsors/sponsor-cart-tab/index.js b/src/pages/sponsors/sponsor-cart-tab/index.js
index f4765bb7d..2eede3152 100644
--- a/src/pages/sponsors/sponsor-cart-tab/index.js
+++ b/src/pages/sponsors/sponsor-cart-tab/index.js
@@ -11,231 +11,53 @@
* limitations under the License.
* */
-import React, { useEffect, useState } from "react";
-import { connect } from "react-redux";
-import T from "i18n-react/dist/i18n-react";
-import {
- Box,
- Button,
- Grid2,
- IconButton,
- Paper,
- Typography
-} from "@mui/material";
-import LockOpenIcon from "@mui/icons-material/LockOpen";
-import LockClosedIcon from "@mui/icons-material/Lock";
-import AddIcon from "@mui/icons-material/Add";
-import {
- deleteSponsorCartForm,
- getSponsorCart,
- lockSponsorCartForm,
- unlockSponsorCartForm
-} from "../../../actions/sponsor-cart-actions";
-import SearchInput from "../../../components/mui/search-input";
-import { TotalRow } from "../../../components/mui/table/extra-rows";
-import MuiTable from "../../../components/mui/table/mui-table";
-
-const SponsorCartTab = ({
- cart,
- term,
- sponsor,
- summitId,
- getSponsorCart,
- deleteSponsorCartForm,
- lockSponsorCartForm,
- unlockSponsorCartForm
-}) => {
- const [openPopup, setOpenPopup] = useState(null);
+import React, { useState } from "react";
+import { Box } from "@mui/material";
+import SelectFormDialog from "./components/select-form-dialog";
+import CartView from "./components/cart-view";
+import NewCartForm from "./components/edit-form/new-cart-form";
+
+const SponsorCartTab = ({ sponsor, summitId }) => {
+ const [openAddFormDialog, setOpenAddFormDialog] = useState(false);
const [formEdit, setFormEdit] = useState(null);
- useEffect(() => {
- getSponsorCart();
- }, []);
-
- const handleSearch = (searchTerm) => {
- getSponsorCart(searchTerm);
- };
-
- const handleManageItems = (item) => {
- console.log("MANAGE ITEMS : ", item);
- };
-
- const handleEdit = (item) => {
- setFormEdit(item);
- };
-
- const handleDelete = (itemId) => {
- deleteSponsorCartForm(itemId);
- };
-
- const handleLock = (form) => {
- if (form.is_locked) {
- unlockSponsorCartForm(form.id);
- } else {
- lockSponsorCartForm(form.id);
- }
+ const handleFormSelected = (form, addOn) => {
+ setFormEdit({ formId: form.id, addon: addOn });
+ setOpenAddFormDialog(false);
};
- const handlePayCreditCard = () => {
- console.log("PAY CREDIT CARD");
+ const handleOnFormAdded = () => {
+ setFormEdit(null);
};
- const handlePayInvoice = () => {
- console.log("PAY INVOICE");
- };
-
- const tableColumns = [
- {
- columnKey: "code",
- header: T.translate("edit_sponsor.cart_tab.code")
- },
- {
- columnKey: "name",
- header: T.translate("edit_sponsor.cart_tab.name")
- },
- {
- columnKey: "allowed_add_ons",
- header: T.translate("edit_sponsor.cart_tab.add_ons"),
- render: (row) =>
- row.allowed_add_ons?.length > 0
- ? row.allowed_add_ons.map((a) => `${a.type} ${a.name}`).join(", ")
- : "None"
- },
- {
- columnKey: "manage_items",
- header: "",
- width: 100,
- align: "center",
- render: (row) => (
-
- )
- },
- {
- columnKey: "discount",
- header: T.translate("edit_sponsor.cart_tab.discount")
- },
- {
- columnKey: "amount",
- header: T.translate("edit_sponsor.cart_tab.amount")
- },
- {
- columnKey: "lock",
- header: "",
- render: (row) => (
- handleLock(row)}>
- {row.is_locked ? (
-
- ) : (
-
- )}
-
- )
- }
- ];
-
return (
-
-
- {cart && (
- {cart?.forms.length} forms in Cart
- )}
-
-
-
-
-
-
-
-
- {!cart && (
-
- {T.translate("edit_sponsor.cart_tab.no_cart")}
-
+ {formEdit && (
+ setFormEdit(null)}
+ onSaveCallback={handleOnFormAdded}
+ />
)}
- {!!cart && (
-
-
-
-
-
-
-
-
-
+ {!formEdit && (
+ setOpenAddFormDialog(true)}
+ />
)}
+ setOpenAddFormDialog(false)}
+ />
);
};
-const mapStateToProps = ({ sponsorPageCartListState }) => ({
- ...sponsorPageCartListState
-});
-
-export default connect(mapStateToProps, {
- getSponsorCart,
- deleteSponsorCartForm,
- lockSponsorCartForm,
- unlockSponsorCartForm
-})(SponsorCartTab);
+export default SponsorCartTab;
diff --git a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js
index 6b4241d6c..ab16f5b65 100644
--- a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js
+++ b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js
@@ -15,8 +15,12 @@ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
import { amountFromCents } from "openstack-uicore-foundation/lib/utils/money";
import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
import {
- REQUEST_SPONSOR_CART,
+ RECEIVE_CART_AVAILABLE_FORMS,
+ RECEIVE_CART_SPONSOR_FORM,
RECEIVE_SPONSOR_CART,
+ REQUEST_CART_AVAILABLE_FORMS,
+ REQUEST_CART_SPONSOR_FORM,
+ REQUEST_SPONSOR_CART,
SPONSOR_CART_FORM_DELETED,
SPONSOR_CART_FORM_LOCKED
} from "../../actions/sponsor-cart-actions";
@@ -24,9 +28,24 @@ import {
const DEFAULT_STATE = {
cart: null,
term: "",
- summitTZ: ""
+ summitTZ: "",
+ availableForms: {
+ forms: [],
+ lastPage: 1,
+ total: 0,
+ currentPage: 1,
+ term: "",
+ order: "id",
+ orderDir: 1
+ },
+ sponsorForm: null
};
+const mapForm = (formData) => ({
+ ...formData,
+ item_count: `${formData.items.length} items`
+});
+
const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => {
const { type, payload } = action;
@@ -49,8 +68,13 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => {
const cart = payload.response;
cart.forms = cart.forms.map((form) => ({
...form,
+ addon_name: form.addon_name || "None",
amount: amountFromCents(form.net_amount),
- discount: amountFromCents(form.discount_amount)
+ discount: amountFromCents(form.discount_amount),
+ items: form.items.map((it) => ({
+ ...it,
+ custom_rate: amountFromCents(it.custom_rate || 0)
+ }))
}));
cart.total = amountFromCents(cart.net_amount);
@@ -76,7 +100,7 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => {
const forms = state.cart.forms.map((form) => {
if (form.id === formId) {
- return {...form, is_locked};
+ return { ...form, is_locked };
}
return form;
});
@@ -89,6 +113,44 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => {
}
};
}
+ case REQUEST_CART_AVAILABLE_FORMS: {
+ const { term, order, orderDir } = payload;
+ return {
+ ...state,
+ availableForms: { ...state.availableForms, term, order, orderDir },
+ sponsorForm: null
+ };
+ }
+ case RECEIVE_CART_AVAILABLE_FORMS: {
+ const {
+ data,
+ last_page: lastPage,
+ total,
+ current_page: currentPage
+ } = payload.response;
+
+ const forms =
+ currentPage === 1
+ ? data.map(mapForm)
+ : [...state.availableForms.forms, ...data.map(mapForm)];
+
+ const availableForms = {
+ ...state.availableForms,
+ forms,
+ lastPage,
+ total,
+ currentPage
+ };
+
+ return { ...state, availableForms };
+ }
+ case REQUEST_CART_SPONSOR_FORM: {
+ return { ...state, sponsorForm: null };
+ }
+ case RECEIVE_CART_SPONSOR_FORM: {
+ const sponsorForm = payload.response;
+ return { ...state, sponsorForm };
+ }
default:
return state;
}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 3983fbeb5..59123f8da 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -264,3 +264,8 @@ export const SPONSOR_USER_ASSIGNMENT_TYPE = {
EXISTING: "existing",
NEW: "new"
};
+
+export const FORM_DISCOUNT_OPTIONS = {
+ AMOUNT: "Amount",
+ RATE: "Rate"
+};