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) => ( + + {v.value} + + ))} + + ); + 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) => ( + + {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 ( + + {T.translate("edit_form.settings")} + ({ + position: "absolute", + right: 8, + top: 8, + color: theme.palette.grey[500] + })} + > + + + + + + {item?.name} + + + {itemFields.map((exc) => ( + + + + ))} + + + + + + ); +}; + +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 ( + + {T.translate("edit_sponsor.cart_tab.edit_form.notes")} + ({ + position: "absolute", + right: 8, + top: 8, + color: theme.palette.grey[500] + })} + > + + + + + {item?.name} + setNotes(ev.target.value)} + value={notes} + margin="normal" + multiline + fullWidth + rows={4} + placeholder={T.translate("edit_sponsor.cart_tab.edit_form.notes_placeholder")} + /> + + + + + + ); +}; + +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 + )} + + + + + + + + + {!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 ( + + + + {T.translate("edit_sponsor.cart_tab.add_form_to_cart")} + + handleClose()}> + + + + + + + + + + + {selectedRows.length} items selected + + + + + + {forms.length === 0 && ( + + {T.translate("edit_sponsor.cart_tab.edit_form.no_forms_found")} + + )} + {forms.length > 0 && ( + + + + )} + + + + + + + ); +}; + +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" +};