From bc3d060684085b8fc1c223f88a9affb95d7113c7 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 19 Feb 2026 17:12:38 -0300 Subject: [PATCH 1/3] feat: cart tab - add form flow - WIP --- src/actions/sponsor-actions.js | 36 +- src/actions/sponsor-cart-actions.js | 98 +- .../manage-tier-addons-popup.js | 19 +- .../__tests__/FormItemTable.test.js | 842 ++++++++++++++++++ .../components/GlobalQuantityField.js | 52 ++ .../components/ItemTableField.js | 82 ++ src/components/mui/FormItemTable/index.js | 225 +++++ .../mui-formik-dropdown-checkbox.js | 76 ++ .../mui-formik-dropdown-radio.js | 52 ++ .../mui-formik-summit-addon-select.js | 33 + .../formik-inputs/mui-formik-timepicker.js | 49 + .../mui/formik-inputs/summit-addon-select.js | 99 -- src/components/mui/sponsor-addon-select.js | 65 ++ src/components/mui/summit-addon-select.js | 61 ++ src/i18n/en.json | 6 +- .../sponsor-cart-tab/components/cart-view.js | 221 +++++ .../sponsor-cart-tab/components/edit-form.js | 9 + .../components/edit-form/edit-cart-form.js | 42 + .../components/edit-form/edit-form.js | 331 +++++++ .../select-page-template-dialog.test.js | 382 ++++++++ .../components/select-form-dialog/index.js | 185 ++++ src/pages/sponsors/sponsor-cart-tab/index.js | 234 +---- .../sponsor-page-cart-list-reducer.js | 54 +- 23 files changed, 2913 insertions(+), 340 deletions(-) create mode 100644 src/components/mui/FormItemTable/__tests__/FormItemTable.test.js create mode 100644 src/components/mui/FormItemTable/components/GlobalQuantityField.js create mode 100644 src/components/mui/FormItemTable/components/ItemTableField.js create mode 100644 src/components/mui/FormItemTable/index.js create mode 100644 src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js create mode 100644 src/components/mui/formik-inputs/mui-formik-dropdown-radio.js create mode 100644 src/components/mui/formik-inputs/mui-formik-summit-addon-select.js create mode 100644 src/components/mui/formik-inputs/mui-formik-timepicker.js delete mode 100644 src/components/mui/formik-inputs/summit-addon-select.js create mode 100644 src/components/mui/sponsor-addon-select.js create mode 100644 src/components/mui/summit-addon-select.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/cart-view.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/edit-form.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/__tests__/select-page-template-dialog.test.js create mode 100644 src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/index.js 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..30ca6ed27 100644 --- a/src/actions/sponsor-cart-actions.js +++ b/src/actions/sponsor-cart-actions.js @@ -17,6 +17,7 @@ import { getRequest, deleteRequest, putRequest, + postRequest, startLoading, stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; @@ -24,12 +25,20 @@ import { 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 FORM_CART_SAVED = "FORM_CART_SAVED"; const customErrorHandler = (err, res) => (dispatch, state) => { const code = err.status; @@ -163,3 +172,90 @@ 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", + relations: "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()); + }); + }; + +export const saveCartForm = (formId, addOnId, items) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + const params = { + access_token: accessToken + }; + + dispatch(startLoading()); + + const normalizedEntity = { + form_id: formId, + add_on_id: addOnId, + items: items.map((item) => ({ + quantity: item.quantity, + add_on_item_id: item.id + })) + } + + return postRequest( + null, + createAction(FORM_CART_SAVED), + `${window.API_BASE_URL}/api/v2/summits/${currentSummit.id}/sponsors`, + normalizedEntity, + snackbarErrorHandler + )(params)(dispatch).then(() => { + dispatch(stopLoading()); + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("sponsor_list.sponsor_added") + }) + ); + }); +}; \ No newline at end of file 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..ae623293c --- /dev/null +++ b/src/components/mui/FormItemTable/components/ItemTableField.js @@ -0,0 +1,82 @@ +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"; + +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 ( + ({ value: v.id, label: 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..6c6d12660 --- /dev/null +++ b/src/components/mui/FormItemTable/index.js @@ -0,0 +1,225 @@ +import React, { useCallback, useMemo } from "react"; +import { + IconButton, + 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 } from "openstack-uicore-foundation/lib/utils/money"; +import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import { MILLISECONDS_IN_SECOND } from "../../../utils/constants"; +import GlobalQuantityField from "./components/GlobalQuantityField"; +import ItemTableField from "./components/ItemTableField"; + +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 = 9; + 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; + return qty * row.rates[currentApplicableRate]; + }; + + const hasItemFields = (row) => + row.meta_fields.filter((mf) => mf.class_field === "Item").length > 0; + + const totalAmount = useMemo( + () => data.reduce((acc, row) => acc + calculateTotal(row), 0), + [data, valuesStr] + ); + + const handleEdit = (row) => { + onNotesClick(row); + }; + + const handleEditItemFields = (row) => { + onSettingsClick(row); + }; + + return ( + + + + + {T.translate("edit_form.code")} + {T.translate("edit_form.description")} + {T.translate("edit_form.early_bird_rate")} + {T.translate("edit_form.standard_rate")} + {T.translate("edit_form.onsite_rate")} + {extraColumns.map((exc) => ( + {exc.name} + ))} + {T.translate("edit_form.qty")} + + {/* item level extra field */} + {T.translate("edit_form.total")} + {T.translate("edit_form.notes")} + + + + {data.map((row) => ( + + {row.code} + +
{row.name}
+ {hasItemFields(row) && ( + + {" "} + {T.translate("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_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/formik-inputs/mui-formik-dropdown-checkbox.js b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js new file mode 100644 index 000000000..5e2d11ccd --- /dev/null +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js @@ -0,0 +1,76 @@ +import React from "react"; +import { + Checkbox, + Divider, + FormControl, + ListItemText, + MenuItem, + Select +} from "@mui/material"; +import { useField } from "formik"; +import { useTranslation } from "react-i18next"; + +const MuiFormikDropdownCheckbox = ({ name, options, ...rest }) => { + const { t } = useTranslation(); + 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..0313e8874 --- /dev/null +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js @@ -0,0 +1,52 @@ +import React from "react"; +import { + FormControl, + ListItemText, + MenuItem, + Radio, + Select +} from "@mui/material"; +import { useField } from "formik"; +import { useTranslation } from "react-i18next"; + +const MuiFormikDropdownRadio = ({ name, options, placeholder, ...rest }) => { + const { t } = useTranslation(); + const finalPlaceholder = placeholder || t("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..dd5a3666e --- /dev/null +++ b/src/components/mui/sponsor-addon-select.js @@ -0,0 +1,65 @@ +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) => { + console.log("RESULTS: ", results); + const normalized = results.map((r) => ({ + value: r.id, + label: r.name + })); + setOptions(normalized); + }); + }, []); + + const handleChange = (event) => { + onChange(event.target.value); + }; + + 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..018d817df 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2467,13 +2467,17 @@ "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..." }, "purchase_tab": { "order": "Order", 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..27d424c94 --- /dev/null +++ b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js @@ -0,0 +1,221 @@ +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: "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")} + + )} + {!!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.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form.js new file mode 100644 index 000000000..ce7636b97 --- /dev/null +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form.js @@ -0,0 +1,9 @@ +import React from "react"; +import T from "i18n-react/dist/i18n-react"; + +const EditForm = () => { + + return null; +} + +export default EditForm; \ No newline at end of file diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js new file mode 100644 index 000000000..dc42b8cf0 --- /dev/null +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js @@ -0,0 +1,42 @@ +/** + * 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 from "react"; +import {connect} from "react-redux"; +import { + updateCartForm, + getCartForm +} from "../../../../../actions/sponsor-cart-actions"; +import EditForm from "./edit-form"; + +const EditCartForm = ({ + form, + getCartForm, + updateCartForm +}) => { + + const getForm = () => getCartForm(form.id); + + const saveForm = (values) => updateCartForm(form_id, values); + + return ( + + ); +}; + +const mapStateToProps = () => ({}); + +export default connect(mapStateToProps, { + getCartForm, + updateCartForm +})(EditCartForm); diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js new file mode 100644 index 000000000..3fdf224b5 --- /dev/null +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js @@ -0,0 +1,331 @@ +/** + * 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, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import { Button, Card, CardContent, Typography } from "@mui/material"; +import Box from "@mui/material/Box"; +import { useNavigate } from "react-router-dom"; +import moment from "moment-timezone"; +import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import FormItemTable from "../../../../../components/FormItemTable"; +import { MILLISECONDS_IN_SECOND } from "../../../../../utils/constants"; +import { saveCartForm } from "../../../../../actions/sponsor-cart-actions"; + +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, t) => { + let schema; + + switch (field.type) { + case "Quantity": { + schema = yup.number(t("validation.number")); + if (field.minimum_quantity > 0) { + schema = schema.min( + field.minimum_quantity, + t("validation.minimum", { minimum: field.minimum_quantity }) + ); + } + if (field.maximum_quantity > 0) { + schema = schema.max( + field.maximum_quantity, + t("validation.maximum", { maximum: field.maximum_quantity }) + ); + } + if (field.is_required) { + schema = schema.required(t("validation.required")); + } + break; + } + case "Text": + case "TextArea": { + schema = yup.string(t("validation.string")); + + if (field.is_required) { + schema = schema.required(t("validation.required")); + } + break; + } + case "Time": + case "DateTime": { + schema = yup.date(t("validation.date")); + + if (field.is_required) { + schema = schema.required(t("validation.required")); + } else { + schema = schema.nullable(); + } + break; + } + case "CheckBoxList": { + schema = yup + .array() + .of(yup.string()) + .typeError(t("validation.wrong_format")); + + if (field.is_required) { + schema = schema.required(t("validation.required")); + } + break; + } + default: { + schema = yup.string(t("validation.wrong_format")); + + if (field.is_required) { + schema = schema.required(t("validation.required")); + } + break; + } + } + + return schema; +}; + +const buildInitialValues = (items, timeZone) => + 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; + return acc; + }, {}); + +const buildValidationSchema = (items, t) => + 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, t); + }); + // notes + acc[`i-${item.form_item_id}-c-global-f-notes`] = yup.string( + t("validation.string") + ); + // validation for the global quantity input + let globalQtySchema = yup + .number(t("validation.number")) + .min(1, `${t("validation.minimum")} 1`); + if (item.quantity_limit_per_sponsor > 0) { + globalQtySchema = globalQtySchema.max( + item.quantity_limit_per_sponsor, + t("validation.maximum", { maximum: item.quantity_limit_per_sponsor }) + ); + } + globalQtySchema = globalQtySchema.required(t("validation.required")); + acc[`i-${item.form_item_id}-c-global-f-quantity`] = globalQtySchema; + return acc; + }, {}); + +const EditForm = ({ + form, + addOnId, + cartFormId, + showMetadata, + showTimeZone, + saveCartForm +}) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + 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]); + + useEffect(() => { + if (cartFormId) { + console.log("GET CART FORM ", cartFormId); + } + }, [cartFormId]); + + const handleCancel = () => { + navigate(-1); + }; + + const handleSave = (values) => { + // re-format form values to match the API format + const items = Object.entries(values).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"].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 = moment(val).unix(); + } else if (metaField.type === "Time") { + current_value = moment(val).format("HH:mm"); + } + + resItem.meta_fields.push({ + type_id: parseInt(itemTypeId), + class_field: itemClass, + current_value + }); + } else { + resItem[itemTypeId] = current_value; + } + } + + return res; + }, []); + + saveCartForm(formId, addOnId, items); + }; + + const formik = useFormik( + { + initialValues: buildInitialValues(form?.items || [], showTimeZone), + validationSchema: yup.object({ + ...buildValidationSchema(form?.items || [], t) + }), + 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.tier_name} + {form.addon_name ? ` - ${form.addon_name}` : ""} + + + + + + + + + + setNotesItem(null)} + onSave={formik.handleSubmit} + /> + setSettingsItem(null)} + /> + + + + ); +}; + +const mapStateToProps = ({ sponsorFormState, showAccessState }) => ({ + ...sponsorFormState, + showMetadata: showAccessState.showMetadata, + showTimeZone: showAccessState.showAccess.summit.time_zone_id +}); + +export default connect(mapStateToProps, { + saveCartForm +})(EditForm); 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..5b85249ea --- /dev/null +++ b/src/pages/sponsors/sponsor-cart-tab/components/select-form-dialog/index.js @@ -0,0 +1,185 @@ +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(() => { + getSponsorFormsForCart(); + }, []); + + const handleSort = (key, dir) => { + getSponsorFormsForCart(term, 1, key, dir); + }; + + const handleLoadMore = () => { + if (total > forms.length) { + getSponsorFormsForCart( + term, + currentPage + 1, + order, + orderDir + ); + } + }; + + const handleClose = () => { + setSelectedRows([]); + 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); + }; + + 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: "items", + header: T.translate("edit_sponsor.cart_tab.items"), + sortable: false + } + ]; + + return ( + + + + {T.translate("edit_sponsor.cart_tab.add_form_to_cart")} + + handleClose()}> + + + + + + + + + + + {selectedRows.length} items selected + + + + + + + {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..f76c02904 100644 --- a/src/pages/sponsors/sponsor-cart-tab/index.js +++ b/src/pages/sponsors/sponsor-cart-tab/index.js @@ -11,231 +11,41 @@ * 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"; +import React, { useState } from "react"; +import { Box } from "@mui/material"; +import SelectFormDialog from "./components/select-form-dialog"; +import CartView from "./components/cart-view"; +import EditForm from "./components/edit-form"; const SponsorCartTab = ({ - cart, - term, sponsor, summitId, - getSponsorCart, - deleteSponsorCartForm, - lockSponsorCartForm, - unlockSponsorCartForm }) => { - const [openPopup, setOpenPopup] = useState(null); + 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 handlePayCreditCard = () => { - console.log("PAY CREDIT CARD"); - }; - - const handlePayInvoice = () => { - console.log("PAY INVOICE"); + const handleAddForm = (form, addOnId) => { + setFormEdit({ form, addOnId }); }; - 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")} - - )} - {!!cart && ( - - - - - - - - - + {formEdit && } + {!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..9b3cc5066 100644 --- a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js +++ b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js @@ -15,8 +15,10 @@ 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_SPONSOR_CART, + REQUEST_CART_AVAILABLE_FORMS, + REQUEST_SPONSOR_CART, SPONSOR_CART_FORM_DELETED, SPONSOR_CART_FORM_LOCKED } from "../../actions/sponsor-cart-actions"; @@ -24,9 +26,25 @@ import { const DEFAULT_STATE = { cart: null, term: "", - summitTZ: "" + summitTZ: "", + availableForms: { + forms: [], + lastPage: 1, + total: 0, + currentPage: 1, + term: "", + order: "id", + orderDir: 1 + } }; +const mapForms = (formData) => ({ + id: formData.id, + code: formData.code, + name: formData.name, + items: `${formData.items.length} items` +}); + const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { const { type, payload } = action; @@ -76,7 +94,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 +107,36 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { } }; } + case REQUEST_CART_AVAILABLE_FORMS: { + const { term, order, orderDir } = payload; + return { + ...state, + availableForms: { ...state.availableForms, term, order, orderDir } + }; + } + case RECEIVE_CART_AVAILABLE_FORMS: { + const { + data, + last_page: lastPage, + total, + current_page: currentPage + } = payload.response; + + const forms = + currentPage === 1 + ? data.map(mapForms) + : [...state.availableForms.forms, ...data.map(mapForms)]; + + const availableForms = { + ...state.availableForms, + forms, + lastPage, + total, + currentPage + }; + + return { ...state, availableForms }; + } default: return state; } From 63933598f6c5ff8d4c4bcb5db0285ac55116f919 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Fri, 20 Feb 2026 16:39:38 -0300 Subject: [PATCH 2/3] feat: cart form edit - WIP --- src/actions/sponsor-cart-actions.js | 28 ++- src/components/CustomTheme.js | 5 +- .../components/ItemTableField.js | 21 +- src/components/mui/FormItemTable/index.js | 89 +++++-- src/components/mui/ItemSettingsModal/index.js | 78 ++++++ src/components/mui/NotesModal/index.js | 70 ++++++ .../mui-formik-dropdown-checkbox.js | 7 +- .../mui-formik-dropdown-radio.js | 5 +- src/components/mui/sponsor-addon-select.js | 5 +- src/i18n/en.json | 18 +- src/pages/sponsors/edit-sponsor-page.js | 10 +- .../sponsor-cart-tab/components/edit-form.js | 9 - .../edit-form/{edit-form.js => index.js} | 224 +++++++++--------- .../{edit-cart-form.js => new-cart-form.js} | 44 ++-- .../components/select-form-dialog/index.js | 6 +- src/pages/sponsors/sponsor-cart-tab/index.js | 22 +- .../sponsor-page-cart-list-reducer.js | 24 +- src/utils/constants.js | 5 + 18 files changed, 473 insertions(+), 197 deletions(-) create mode 100644 src/components/mui/ItemSettingsModal/index.js create mode 100644 src/components/mui/NotesModal/index.js delete mode 100644 src/pages/sponsors/sponsor-cart-tab/components/edit-form.js rename src/pages/sponsors/sponsor-cart-tab/components/edit-form/{edit-form.js => index.js} (57%) rename src/pages/sponsors/sponsor-cart-tab/components/edit-form/{edit-cart-form.js => new-cart-form.js} (51%) diff --git a/src/actions/sponsor-cart-actions.js b/src/actions/sponsor-cart-actions.js index 30ca6ed27..4a52ff8f3 100644 --- a/src/actions/sponsor-cart-actions.js +++ b/src/actions/sponsor-cart-actions.js @@ -38,6 +38,7 @@ 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 RECEIVE_CART_SPONSOR_FORM = "RECEIVE_CART_SPONSOR_FORM"; export const FORM_CART_SAVED = "FORM_CART_SAVED"; const customErrorHandler = (err, res) => (dispatch, state) => { @@ -196,8 +197,7 @@ export const getSponsorFormsForCart = const params = { page: currentPage, - fields: "id,code,name", - relations: "items", + fields: "id,code,name,items", per_page: DEFAULT_PER_PAGE, access_token: accessToken }; @@ -223,7 +223,29 @@ export const getSponsorFormsForCart = }); }; -export const saveCartForm = (formId, addOnId, items) => async (dispatch, getState) => { +// 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( + null, + createAction(RECEIVE_CART_SPONSOR_FORM), + `${window.PURCHASES_API_URL}/api/v2/summits/${currentSummit.id}/show-forms/${formId}`, + authErrorHandler + )(params)(dispatch).then(() => { + dispatch(stopLoading()); + }); +}; + +export const addCartForm = (formId, addOnId, items) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; 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/mui/FormItemTable/components/ItemTableField.js b/src/components/mui/FormItemTable/components/ItemTableField.js index ae623293c..bf4df024e 100644 --- a/src/components/mui/FormItemTable/components/ItemTableField.js +++ b/src/components/mui/FormItemTable/components/ItemTableField.js @@ -6,6 +6,9 @@ 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}`; @@ -18,6 +21,7 @@ const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { ({ value: v.id, label: v.value }))} /> ); @@ -26,6 +30,7 @@ const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { ({ value: v.id, label: v.value }))} /> ); @@ -55,11 +60,17 @@ const ItemTableField = ({ rowId, field, timeZone, label = "" }) => { ); case "ComboBox": return ( - ({ value: v.id, label: v.value }))} - /> + + {field.values.map((v) => ( + + {v.value} + + ))} + ); case "Text": return ( diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index 6c6d12660..0c8571c4b 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from "react"; import { IconButton, + MenuItem, Paper, Table, TableBody, @@ -16,9 +17,14 @@ import ErrorIcon from "@mui/icons-material/Error"; import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { MILLISECONDS_IN_SECOND } from "../../../utils/constants"; +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, @@ -106,42 +112,42 @@ const FormItemTable = ({ - {T.translate("edit_form.code")} - {T.translate("edit_form.description")} - {T.translate("edit_form.early_bird_rate")} - {T.translate("edit_form.standard_rate")} - {T.translate("edit_form.onsite_rate")} + {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.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_form.qty")} - + {T.translate("edit_sponsor.cart_tab.edit_form.qty")} + {/* item level extra field */} - {T.translate("edit_form.total")} - {T.translate("edit_form.notes")} + {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_form.additional_info")} + {T.translate("edit_sponsor.cart_tab.edit_form.additional_info")} )}
@@ -180,29 +186,70 @@ const FormItemTable = ({ value={calculateQuantity(row)} />
- + {hasItemFields(row) && ( handleEditItemFields(row)} > - + )} {currencyAmountFromCents(calculateTotal(row))} - + handleEdit(row)}> - +
))} - {T.translate("edit_form.total_on_caps")} + {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} + + ))} + + + + + + + + + + + {T.translate("edit_sponsor.cart_tab.edit_form.total_on_caps")} {/* eslint-disable-next-line */} {new Array(totalColumns - 3).fill(0).map((_, i) => ( 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 index 5e2d11ccd..be19776ef 100644 --- a/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js @@ -8,10 +8,9 @@ import { Select } from "@mui/material"; import { useField } from "formik"; -import { useTranslation } from "react-i18next"; +import T from "i18n-react/dist/i18n-react"; const MuiFormikDropdownCheckbox = ({ name, options, ...rest }) => { - const { t } = useTranslation(); const [field, meta, helpers] = useField(name); const allSelected = options.every(({ value }) => field.value?.includes(value) @@ -49,7 +48,7 @@ const MuiFormikDropdownCheckbox = ({ name, options, ...rest }) => { return rest.placeholder || ""; } if (allSelected) { - return t("general.all"); + return T.translate("general.all"); } const selectedNames = options .filter(({ value }) => selected?.includes(value)) @@ -59,7 +58,7 @@ const MuiFormikDropdownCheckbox = ({ name, options, ...rest }) => { > - + {options.map(({ label, value }) => ( diff --git a/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js index 0313e8874..1ae013007 100644 --- a/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js @@ -7,11 +7,10 @@ import { Select } from "@mui/material"; import { useField } from "formik"; -import { useTranslation } from "react-i18next"; +import T from "i18n-react/dist/i18n-react"; const MuiFormikDropdownRadio = ({ name, options, placeholder, ...rest }) => { - const { t } = useTranslation(); - const finalPlaceholder = placeholder || t("general.select_an_option"); + const finalPlaceholder = placeholder || T.translate("general.select_an_option"); const [field, meta, helpers] = useField(name); const handleChange = (event) => { diff --git a/src/components/mui/sponsor-addon-select.js b/src/components/mui/sponsor-addon-select.js index dd5a3666e..dc2bd5395 100644 --- a/src/components/mui/sponsor-addon-select.js +++ b/src/components/mui/sponsor-addon-select.js @@ -16,7 +16,6 @@ const SponsorAddonSelect = ({ useEffect(() => { querySponsorAddons(summitId, sponsor.id, sponsorshipIds,(results) => { - console.log("RESULTS: ", results); const normalized = results.map((r) => ({ value: r.id, label: r.name @@ -25,8 +24,8 @@ const SponsorAddonSelect = ({ }); }, []); - const handleChange = (event) => { - onChange(event.target.value); + const handleChange = (ev) => { + onChange({id: ev.target.value, name: ev.target.label}); }; return ( diff --git a/src/i18n/en.json b/src/i18n/en.json index 018d817df..c2c7e80ed 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", @@ -2477,7 +2479,21 @@ "pay_invoice": "pay with invoice", "add_form_to_cart": "Add Form to Cart", "add_selected_form": "Add selected form", - "select_addon": "Select Add-on..." + "select_addon": "Select Add-on...", + "edit_form": { + "code": "Code", + "description": "Description", + "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" + } }, "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/edit-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form.js deleted file mode 100644 index ce7636b97..000000000 --- a/src/pages/sponsors/sponsor-cart-tab/components/edit-form.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; -import T from "i18n-react/dist/i18n-react"; - -const EditForm = () => { - - return null; -} - -export default EditForm; \ No newline at end of file diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js similarity index 57% rename from src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js rename to src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js index 3fdf224b5..9dcc74a43 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-form.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js @@ -11,19 +11,22 @@ * limitations under the License. * */ -import React, { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +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, Card, CardContent, Typography } from "@mui/material"; +import { Button, Typography } from "@mui/material"; import Box from "@mui/material/Box"; -import { useNavigate } from "react-router-dom"; import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import FormItemTable from "../../../../../components/FormItemTable"; -import { MILLISECONDS_IN_SECOND } from "../../../../../utils/constants"; -import { saveCartForm } from "../../../../../actions/sponsor-cart-actions"; +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) { @@ -54,44 +57,44 @@ const parseValue = (item, timeZone) => { } }; -const getYupValidation = (field, t) => { +const getYupValidation = (field) => { let schema; switch (field.type) { case "Quantity": { - schema = yup.number(t("validation.number")); + schema = yup.number(T.translate("validation.number")); if (field.minimum_quantity > 0) { schema = schema.min( field.minimum_quantity, - t("validation.minimum", { minimum: field.minimum_quantity }) + T.translate("validation.minimum", { minimum: field.minimum_quantity }) ); } if (field.maximum_quantity > 0) { schema = schema.max( field.maximum_quantity, - t("validation.maximum", { maximum: field.maximum_quantity }) + T.translate("validation.maximum", { maximum: field.maximum_quantity }) ); } if (field.is_required) { - schema = schema.required(t("validation.required")); + schema = schema.required(T.translate("validation.required")); } break; } case "Text": case "TextArea": { - schema = yup.string(t("validation.string")); + schema = yup.string(T.translate("validation.string")); if (field.is_required) { - schema = schema.required(t("validation.required")); + schema = schema.required(T.translate("validation.required")); } break; } case "Time": case "DateTime": { - schema = yup.date(t("validation.date")); + schema = yup.date(T.translate("validation.date")); if (field.is_required) { - schema = schema.required(t("validation.required")); + schema = schema.required(T.translate("validation.required")); } else { schema = schema.nullable(); } @@ -101,18 +104,18 @@ const getYupValidation = (field, t) => { schema = yup .array() .of(yup.string()) - .typeError(t("validation.wrong_format")); + .typeError(T.translate("validation.wrong_format")); if (field.is_required) { - schema = schema.required(t("validation.required")); + schema = schema.required(T.translate("validation.required")); } break; } default: { - schema = yup.string(t("validation.wrong_format")); + schema = yup.string(T.translate("validation.wrong_format")); if (field.is_required) { - schema = schema.required(t("validation.required")); + schema = schema.required(T.translate("validation.required")); } break; } @@ -121,8 +124,10 @@ const getYupValidation = (field, t) => { return schema; }; -const buildInitialValues = (items, timeZone) => - items.reduce((acc, item) => { +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); @@ -132,46 +137,65 @@ const buildInitialValues = (items, timeZone) => // 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; + // discount return acc; }, {}); -const buildValidationSchema = (items, t) => - items.reduce((acc, item) => { + initialValues.discount = form.discount || 0; + initialValues.discount_type = form.discount_type || FORM_DISCOUNT_OPTIONS.PERCENTAGE; + + return initialValues; +} + +const buildValidationSchema = (form) => { + const items = form.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, t); + getYupValidation(f); }); // notes acc[`i-${item.form_item_id}-c-global-f-notes`] = yup.string( - t("validation.string") + T.translate("validation.string") ); // validation for the global quantity input let globalQtySchema = yup - .number(t("validation.number")) - .min(1, `${t("validation.minimum")} 1`); + .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("validation.maximum", { maximum: item.quantity_limit_per_sponsor }) + T.translate("validation.maximum", { + maximum: item.quantity_limit_per_sponsor + }) ); } - globalQtySchema = globalQtySchema.required(t("validation.required")); + globalQtySchema = globalQtySchema.required( + T.translate("validation.required") + ); acc[`i-${item.form_item_id}-c-global-f-quantity`] = globalQtySchema; 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, - addOnId, - cartFormId, showMetadata, showTimeZone, - saveCartForm + onSaveForm, + onCancel }) => { - const { t } = useTranslation(); - const navigate = useNavigate(); + console.log("METADATA: ", showMetadata); + console.log("FORM : ", form); + const [notesItem, setNotesItem] = useState(null); const [settingsItem, setSettingsItem] = useState(null); const hasRateExpired = useMemo(() => { @@ -187,14 +211,8 @@ const EditForm = ({ return true; }, [showMetadata, showTimeZone]); - useEffect(() => { - if (cartFormId) { - console.log("GET CART FORM ", cartFormId); - } - }, [cartFormId]); - const handleCancel = () => { - navigate(-1); + onCancel(); }; const handleSave = (values) => { @@ -242,9 +260,9 @@ const EditForm = ({ const formik = useFormik( { - initialValues: buildInitialValues(form?.items || [], showTimeZone), + initialValues: buildInitialValues(form, showTimeZone), validationSchema: yup.object({ - ...buildValidationSchema(form?.items || [], t) + ...buildValidationSchema(form?.items || []) }), onSubmit: (values) => { handleSave(values); @@ -258,74 +276,68 @@ const EditForm = ({ if (!form || Object.keys(formik.values).length === 0) return null; return ( - - - - {form.tier_name} - {form.addon_name ? ` - ${form.addon_name}` : ""} - - + <> + + {form.code} - {form.name} + {form.addon_name ? ` - ${form.addon_name}` : ""} + + + {form?.items.length} {T.translate("general.items")} + + + + - - - - - + {T.translate("general.cancel")} + + - setNotesItem(null)} - onSave={formik.handleSubmit} - /> - setSettingsItem(null)} - /> - - - + + setNotesItem(null)} + onSave={formik.handleSubmit} + /> + setSettingsItem(null)} + /> + + ); }; -const mapStateToProps = ({ sponsorFormState, showAccessState }) => ({ - ...sponsorFormState, - showMetadata: showAccessState.showMetadata, - showTimeZone: showAccessState.showAccess.summit.time_zone_id +const mapStateToProps = ({ currentSummitState, sponsorSettingsState }) => ({ + showMetadata: sponsorSettingsState.settings, + showTimeZone: currentSummitState.currentSummit.time_zone_id }); -export default connect(mapStateToProps, { - saveCartForm -})(EditForm); +export default connect(mapStateToProps, {})(EditForm); diff --git a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js similarity index 51% rename from src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js rename to src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js index dc42b8cf0..54dc6b6bd 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/edit-cart-form.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/new-cart-form.js @@ -11,32 +11,44 @@ * limitations under the License. * */ -import React from "react"; -import {connect} from "react-redux"; +import React, { useEffect } from "react"; +import { connect } from "react-redux"; import { - updateCartForm, - getCartForm + addCartForm, + getSponsorForm } from "../../../../../actions/sponsor-cart-actions"; -import EditForm from "./edit-form"; +import EditForm from "./index"; -const EditCartForm = ({ - form, - getCartForm, - updateCartForm +const NewCartForm = ({ + formId, + addOn, + sponsorForm, + onCancel, + getSponsorForm, + addCartForm }) => { + useEffect(() => { + getSponsorForm(formId); + }, []); - const getForm = () => getCartForm(form.id); + const saveForm = (values) => addCartForm(formId, addOn?.addon_id, values); - const saveForm = (values) => updateCartForm(form_id, values); + if (!sponsorForm) return null; return ( - + ); }; -const mapStateToProps = () => ({}); +const mapStateToProps = ({ sponsorPageCartListState }) => ({ + sponsorForm: sponsorPageCartListState.sponsorForm +}); export default connect(mapStateToProps, { - getCartForm, - updateCartForm -})(EditCartForm); + getSponsorForm, + addCartForm +})(NewCartForm); 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 index 5b85249ea..312477e70 100644 --- 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 @@ -101,8 +101,8 @@ const SelectFormDialog = ({ sortable: true }, { - columnKey: "items", - header: T.translate("edit_sponsor.cart_tab.items"), + columnKey: "item_count", + header: T.translate("general.items"), sortable: false } ]; @@ -124,7 +124,7 @@ const SelectFormDialog = ({ { +const SponsorCartTab = ({ sponsor, summitId }) => { const [openAddFormDialog, setOpenAddFormDialog] = useState(false); - const [formEdit, setFormEdit] = useState(null); + const [formEdit, setFormEdit] = useState({formId: 19, addon: null}); - const handleAddForm = (form, addOnId) => { - setFormEdit({ form, addOnId }); + const handleAddForm = (form, addOn) => { + setFormEdit({ formId: form.id, addon: addOn }); + setOpenAddFormDialog(false); }; return ( - {formEdit && } + {formEdit && ( + setFormEdit(null)} + /> + )} {!formEdit && ( ({ - id: formData.id, - code: formData.code, - name: formData.name, - items: `${formData.items.length} items` +const mapForm = (formData) => ({ + ...formData, + item_count: `${formData.items.length} items` }); const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { @@ -111,7 +110,8 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { const { term, order, orderDir } = payload; return { ...state, - availableForms: { ...state.availableForms, term, order, orderDir } + availableForms: { ...state.availableForms, term, order, orderDir }, + sponsorForm: null }; } case RECEIVE_CART_AVAILABLE_FORMS: { @@ -124,8 +124,8 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { const forms = currentPage === 1 - ? data.map(mapForms) - : [...state.availableForms.forms, ...data.map(mapForms)]; + ? data.map(mapForm) + : [...state.availableForms.forms, ...data.map(mapForm)]; const availableForms = { ...state.availableForms, @@ -137,6 +137,10 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { return { ...state, availableForms }; } + 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..9812b12b9 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 = { + PERCENTAGE: "Percentage", + RATE: "Rate" +}; \ No newline at end of file From 969fcab56673fdcf7bdf779ad785919a8129a136 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 25 Feb 2026 18:06:42 -0300 Subject: [PATCH 3/3] feat: custom rate and discount row --- src/actions/sponsor-cart-actions.js | 168 ++++++++++-------- src/components/mui/FormItemTable/index.js | 120 ++++++++++--- src/i18n/en.json | 10 +- .../sponsor-cart-tab/components/cart-view.js | 10 +- .../components/edit-form/index.js | 52 +++--- .../components/edit-form/new-cart-form.js | 9 +- .../components/select-form-dialog/index.js | 31 ++-- src/pages/sponsors/sponsor-cart-tab/index.js | 16 +- .../sponsor-page-cart-list-reducer.js | 16 +- src/utils/constants.js | 4 +- 10 files changed, 278 insertions(+), 158 deletions(-) diff --git a/src/actions/sponsor-cart-actions.js b/src/actions/sponsor-cart-actions.js index 4a52ff8f3..282defb5f 100644 --- a/src/actions/sponsor-cart-actions.js +++ b/src/actions/sponsor-cart-actions.js @@ -21,7 +21,7 @@ import { 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"; @@ -38,6 +38,7 @@ 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"; @@ -174,55 +175,54 @@ export const unlockSponsorCartForm = (formId) => async (dispatch, getState) => { }); }; - export const getSponsorFormsForCart = ( term = "", currentPage = DEFAULT_CURRENT_PAGE, order = "id", - orderDir = DEFAULT_ORDER_DIR, + 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()); - }); + 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(); @@ -236,7 +236,7 @@ export const getSponsorForm = (formId) => async (dispatch, getState) => { }; return getRequest( - null, + createAction(REQUEST_CART_SPONSOR_FORM), createAction(RECEIVE_CART_SPONSOR_FORM), `${window.PURCHASES_API_URL}/api/v2/summits/${currentSummit.id}/show-forms/${formId}`, authErrorHandler @@ -245,39 +245,59 @@ export const getSponsorForm = (formId) => async (dispatch, getState) => { }); }; -export const addCartForm = (formId, addOnId, items) => async (dispatch, getState) => { - const { currentSummitState } = getState(); - const accessToken = await getAccessTokenSafely(); - const { currentSummit } = currentSummitState; +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 + ); - const params = { - access_token: accessToken - }; + return { + ...normalizedItem, + ...(hasQtyFields ? {} : { quantity }), + ...(custom_rate > 0 ? { custom_rate: amountToCents(custom_rate) } : {}), + meta_fields: metaFields + }; + }); - dispatch(startLoading()); +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 normalizedEntity = { - form_id: formId, - add_on_id: addOnId, - items: items.map((item) => ({ - quantity: item.quantity, - add_on_item_id: item.id - })) - } + const params = { + access_token: accessToken + }; - return postRequest( - null, - createAction(FORM_CART_SAVED), - `${window.API_BASE_URL}/api/v2/summits/${currentSummit.id}/sponsors`, - normalizedEntity, - snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_list.sponsor_added") + 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") + }) + ); }) - ); - }); -}; \ No newline at end of file + .finally(() => dispatch(stopLoading())); + }; diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index 0c8571c4b..df6f2bd15 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from "react"; import { IconButton, + InputAdornment, MenuItem, Paper, Table, @@ -15,7 +16,11 @@ 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 } from "openstack-uicore-foundation/lib/utils/money"; +import { + currencyAmountFromCents, + amountFromCents, + amountToCents +} from "openstack-uicore-foundation/lib/utils/money"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import { FORM_DISCOUNT_OPTIONS, @@ -37,7 +42,7 @@ const FormItemTable = ({ const valuesStr = JSON.stringify(values); const extraColumns = data[0]?.meta_fields?.filter((mf) => mf.class_field === "Form") || []; - const fixedColumns = 9; + const fixedColumns = 10; const totalColumns = extraColumns.length + fixedColumns; const currentApplicableRate = useMemo(() => { @@ -88,16 +93,25 @@ const FormItemTable = ({ values[`i-${row.form_item_id}-c-global-f-quantity`] || calculateQuantity(row); if (currentApplicableRate === "expired") return 0; - return qty * row.rates[currentApplicableRate]; + 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( - () => data.reduce((acc, row) => acc + calculateTotal(row), 0), - [data, valuesStr] - ); + 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); @@ -112,32 +126,55 @@ const FormItemTable = ({
- {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.early_bird_rate")} - {T.translate("edit_sponsor.cart_tab.edit_form.standard_rate")} - {T.translate("edit_sponsor.cart_tab.edit_form.onsite_rate")} + + {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")} + + {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")} + + {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")} + {T.translate( + "edit_sponsor.cart_tab.edit_form.additional_info" + )} )}
+ + $ + ) + } + }} + /> + - + {hasItemFields(row) && ( {currencyAmountFromCents(calculateTotal(row))} - + handleEdit(row)}> @@ -217,12 +273,8 @@ const FormItemTable = ({ key={`${i}-discountcell`} /> ))} - - + + {Object.values(FORM_DISCOUNT_OPTIONS).map((p) => ( {p} @@ -231,16 +283,26 @@ const FormItemTable = ({ - + + {values.discount_type === FORM_DISCOUNT_OPTIONS.RATE + ? "%" + : "$"} + + ), + ...(values.discount_type === FORM_DISCOUNT_OPTIONS.RATE + ? { max: 100 } + : {}) } }} /> diff --git a/src/i18n/en.json b/src/i18n/en.json index c2c7e80ed..aa5eef32a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -97,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", @@ -2483,6 +2487,7 @@ "edit_form": { "code": "Code", "description": "Description", + "custom_rate": "Custom Rate", "early_bird_rate": "Early Bird Rate", "standard_rate": "Standard Rate", "onsite_rate": "Onsite Rate", @@ -2492,7 +2497,8 @@ "notes_placeholder": "Enter your notes here...", "additional_info": "Additional Info", "discount": "Discount", - "total_on_caps": "TOTAL" + "total_on_caps": "TOTAL", + "no_forms_found": "No forms found." } }, "purchase_tab": { diff --git a/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js index 27d424c94..a443edbaa 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/cart-view.js @@ -30,7 +30,7 @@ const CartView = ({ lockSponsorCartForm, unlockSponsorCartForm, onEdit, - onAddForm, + onAddForm }) => { useEffect(() => { getSponsorCart(); @@ -74,12 +74,8 @@ const CartView = ({ 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: "addon_name", + header: T.translate("edit_sponsor.cart_tab.add_ons") }, { columnKey: "manage_items", 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 index 9dcc74a43..5717c9bad 100644 --- a/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js +++ b/src/pages/sponsors/sponsor-cart-tab/components/edit-form/index.js @@ -137,19 +137,21 @@ const buildInitialValues = (form, timeZone) => { // 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; - // discount + // custom rate + acc[`i-${item.form_item_id}-c-global-f-custom_rate`] = + item.custom_rate || 0; + return acc; }, {}); - initialValues.discount = form.discount || 0; - initialValues.discount_type = form.discount_type || FORM_DISCOUNT_OPTIONS.PERCENTAGE; + initialValues.discount_amount = form.discount_amount || 0; + initialValues.discount_type = + form.discount_type || FORM_DISCOUNT_OPTIONS.AMOUNT; return initialValues; -} - -const buildValidationSchema = (form) => { - const items = form.items || []; +}; +const buildValidationSchema = (items) => { const schema = items.reduce((acc, item) => { item.meta_fields .filter((f) => f.class_field === "Form") @@ -177,14 +179,21 @@ const buildValidationSchema = (form) => { 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(); + schema.discount = yup.number(T.translate("validation.number")); + schema.discount_type = yup + .string(T.translate("validation.string")) + .nullable(); return schema; -} +}; const EditForm = ({ form, @@ -193,9 +202,6 @@ const EditForm = ({ onSaveForm, onCancel }) => { - console.log("METADATA: ", showMetadata); - console.log("FORM : ", form); - const [notesItem, setNotesItem] = useState(null); const [settingsItem, setSettingsItem] = useState(null); const hasRateExpired = useMemo(() => { @@ -216,14 +222,17 @@ const EditForm = ({ }; const handleSave = (values) => { + const { discount_amount, discount_type, ...itemValues } = values; // re-format form values to match the API format - const items = Object.entries(values).reduce((res, [key, val]) => { + 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"].includes(itemTypeId); + const isItemProp = !["quantity", "notes", "custom_rate"].includes( + itemTypeId + ); let current_value = val; let resItem = res.find((i) => i.form_item_id === formItemId); @@ -236,14 +245,15 @@ const EditForm = ({ .find((i) => i.form_item_id === formItemId) ?.meta_fields.find((mf) => mf.type_id === parseInt(itemTypeId)); - if (metaField.type === "DateTime") { - current_value = moment(val).unix(); - } else if (metaField.type === "Time") { - current_value = moment(val).format("HH:mm"); + 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 }); @@ -255,7 +265,7 @@ const EditForm = ({ return res; }, []); - saveCartForm(formId, addOnId, items); + onSaveForm({ discount_amount, discount_type, items }); }; const formik = useFormik( @@ -277,7 +287,7 @@ const EditForm = ({ return ( <> - + {form.code} - {form.name} {form.addon_name ? ` - ${form.addon_name}` : ""} 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 index 54dc6b6bd..3394c9291 100644 --- 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 @@ -24,6 +24,7 @@ const NewCartForm = ({ addOn, sponsorForm, onCancel, + onSaveCallback, getSponsorForm, addCartForm }) => { @@ -31,14 +32,18 @@ const NewCartForm = ({ getSponsorForm(formId); }, []); - const saveForm = (values) => addCartForm(formId, addOn?.addon_id, values); + const saveForm = (values) => { + addCartForm(formId, addOn?.addon_id, values).then(() => { + onSaveCallback(); + }); + }; if (!sponsorForm) return null; return ( ); 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 index 312477e70..ce5b3c88e 100644 --- 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 @@ -36,8 +36,10 @@ const SelectFormDialog = ({ const { forms, currentPage, term, order, orderDir, total } = availableForms; useEffect(() => { - getSponsorFormsForCart(); - }, []); + if (open) { + getSponsorFormsForCart(); + } + }, [open]); const handleSort = (key, dir) => { getSponsorFormsForCart(term, 1, key, dir); @@ -45,17 +47,13 @@ const SelectFormDialog = ({ const handleLoadMore = () => { if (total > forms.length) { - getSponsorFormsForCart( - term, - currentPage + 1, - order, - orderDir - ); + getSponsorFormsForCart(term, currentPage + 1, order, orderDir); } }; const handleClose = () => { setSelectedRows([]); + setSelectedAddon(null); onClose(); }; @@ -70,6 +68,10 @@ const SelectFormDialog = ({ const handleOnSave = () => { const form = forms.find((f) => f.id === selectedRows[0]); onSave(form, selectedAddon); + + // reset dialog + setSelectedRows([]); + setSelectedAddon(null); }; const columns = [ @@ -136,13 +138,14 @@ const SelectFormDialog = ({ {selectedRows.length} items selected - + - + {forms.length === 0 && ( + + {T.translate("edit_sponsor.cart_tab.edit_form.no_forms_found")} + + )} {forms.length > 0 && ( ({ - availableForms: sponsorPageCartListState.availableForms, + availableForms: sponsorPageCartListState.availableForms }); export default connect(mapStateToProps, { diff --git a/src/pages/sponsors/sponsor-cart-tab/index.js b/src/pages/sponsors/sponsor-cart-tab/index.js index 0b70c3c95..2eede3152 100644 --- a/src/pages/sponsors/sponsor-cart-tab/index.js +++ b/src/pages/sponsors/sponsor-cart-tab/index.js @@ -19,20 +19,28 @@ import NewCartForm from "./components/edit-form/new-cart-form"; const SponsorCartTab = ({ sponsor, summitId }) => { const [openAddFormDialog, setOpenAddFormDialog] = useState(false); - const [formEdit, setFormEdit] = useState({formId: 19, addon: null}); + const [formEdit, setFormEdit] = useState(null); - const handleAddForm = (form, addOn) => { + const handleFormSelected = (form, addOn) => { setFormEdit({ formId: form.id, addon: addOn }); setOpenAddFormDialog(false); }; + const handleOnFormAdded = () => { + setFormEdit(null); + }; + return ( {formEdit && ( setFormEdit(null)} + onSaveCallback={handleOnFormAdded} /> )} {!formEdit && ( @@ -45,7 +53,7 @@ const SponsorCartTab = ({ sponsor, summitId }) => { open={!!openAddFormDialog} summitId={summitId} sponsor={sponsor} - onSave={handleAddForm} + onSave={handleFormSelected} onClose={() => setOpenAddFormDialog(false)} /> diff --git a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js index 00b50f3c8..ab16f5b65 100644 --- a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js +++ b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js @@ -15,9 +15,11 @@ 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 { - RECEIVE_CART_AVAILABLE_FORMS, RECEIVE_CART_SPONSOR_FORM, + 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 @@ -36,7 +38,7 @@ const DEFAULT_STATE = { order: "id", orderDir: 1 }, - sponsorForm: null, + sponsorForm: null }; const mapForm = (formData) => ({ @@ -66,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); @@ -137,6 +144,9 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { return { ...state, availableForms }; } + case REQUEST_CART_SPONSOR_FORM: { + return { ...state, sponsorForm: null }; + } case RECEIVE_CART_SPONSOR_FORM: { const sponsorForm = payload.response; return { ...state, sponsorForm }; diff --git a/src/utils/constants.js b/src/utils/constants.js index 9812b12b9..59123f8da 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -266,6 +266,6 @@ export const SPONSOR_USER_ASSIGNMENT_TYPE = { }; export const FORM_DISCOUNT_OPTIONS = { - PERCENTAGE: "Percentage", + AMOUNT: "Amount", RATE: "Rate" -}; \ No newline at end of file +};