From 489ed8c1daaf6106f5d0b4b60941296c85e4e58c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 18 Mar 2026 03:52:27 -0300 Subject: [PATCH 1/4] feat: new item price tier component, update reducers and actions, new utils, validations and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/form-template-item-actions.js | 10 +- src/actions/inventory-item-actions.js | 10 +- src/actions/sponsor-forms-actions.js | 16 +- .../mui/__tests__/item-price-tiers.test.js | 171 ++++++++++++++++++ .../mui/editable-table/mui-table-editable.js | 17 +- .../mui/formik-inputs/item-price-tiers.js | 89 +++++++++ src/i18n/en.json | 9 +- .../add-form-template-item-popup.js | 8 +- .../form-templates/sponsor-inventory-popup.js | 41 +---- .../page-template-popup/index.js | 2 +- ...nsor-form-add-item-from-inventory-popup.js | 8 +- .../components/sponsor-form-item-form.js | 41 +---- .../sponsor-form-item-list-page/index.js | 6 +- .../sponsor-form-item-from-inventory.js | 8 +- .../sponsor-forms-manage-items.js | 6 +- .../sponsor-form-items-list-reducer.test.js | 18 +- ...nsor-customized-form-items-list-reducer.js | 25 +-- .../sponsor-form-items-list-reducer.js | 20 +- .../form-template-item-reducer.js | 20 +- .../inventory-item-reducer.js | 20 +- src/utils/__tests__/rate-helpers.test.js | 66 +++++++ src/utils/__tests__/yup.test.js | 34 ++++ src/utils/rate-helpers.js | 31 ++++ src/utils/yup.js | 17 +- 24 files changed, 517 insertions(+), 176 deletions(-) create mode 100644 src/components/mui/__tests__/item-price-tiers.test.js create mode 100644 src/components/mui/formik-inputs/item-price-tiers.js create mode 100644 src/utils/__tests__/rate-helpers.test.js create mode 100644 src/utils/__tests__/yup.test.js create mode 100644 src/utils/rate-helpers.js diff --git a/src/actions/form-template-item-actions.js b/src/actions/form-template-item-actions.js index 419f96fa2..705e81930 100644 --- a/src/actions/form-template-item-actions.js +++ b/src/actions/form-template-item-actions.js @@ -22,7 +22,7 @@ import { startLoading, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; -import { amountToCents } from "openstack-uicore-foundation/lib/utils/money"; +import { rateToCents } from "../utils/rate-helpers"; import { snackbarErrorHandler, snackbarSuccessHandler } from "./base-actions"; import { getAccessTokenSafely } from "../utils/methods"; import { @@ -178,13 +178,11 @@ const normalizeEntity = (entity) => { normalizedEntity.images = normalizedEntity.images?.filter( (img) => img.file_path ); - normalizedEntity.early_bird_rate = amountToCents( + normalizedEntity.early_bird_rate = rateToCents( normalizedEntity.early_bird_rate ); - normalizedEntity.standard_rate = amountToCents( - normalizedEntity.standard_rate - ); - normalizedEntity.onsite_rate = amountToCents(normalizedEntity.onsite_rate); + normalizedEntity.standard_rate = rateToCents(normalizedEntity.standard_rate); + normalizedEntity.onsite_rate = rateToCents(normalizedEntity.onsite_rate); return normalizedEntity; }; diff --git a/src/actions/inventory-item-actions.js b/src/actions/inventory-item-actions.js index d0d2422a1..ced14ab19 100644 --- a/src/actions/inventory-item-actions.js +++ b/src/actions/inventory-item-actions.js @@ -25,7 +25,7 @@ import { authErrorHandler, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; -import { amountToCents } from "openstack-uicore-foundation/lib/utils/money"; +import { rateToCents } from "../utils/rate-helpers"; import history from "../history"; import { getAccessTokenSafely } from "../utils/methods"; import { @@ -200,13 +200,11 @@ const normalizeEntity = (entity) => { (img) => img.file_path ); - normalizedEntity.early_bird_rate = amountToCents( + normalizedEntity.early_bird_rate = rateToCents( normalizedEntity.early_bird_rate ); - normalizedEntity.standard_rate = amountToCents( - normalizedEntity.standard_rate - ); - normalizedEntity.onsite_rate = amountToCents(normalizedEntity.onsite_rate); + normalizedEntity.standard_rate = rateToCents(normalizedEntity.standard_rate); + normalizedEntity.onsite_rate = rateToCents(normalizedEntity.onsite_rate); return normalizedEntity; }; diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 92a2a0ee0..2b7c4224b 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -22,7 +22,6 @@ import { stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; -import { amountToCents } from "openstack-uicore-foundation/lib/utils/money"; import T from "i18n-react/dist/i18n-react"; import moment from "moment-timezone"; import { @@ -30,6 +29,7 @@ import { getAccessTokenSafely, normalizeSelectAllField } from "../utils/methods"; +import { rateToCents } from "../utils/rate-helpers"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -1371,17 +1371,9 @@ const normalizeManagedItem = (entity) => { const normalizeRates = (entity, normalizedEntity) => { const { early_bird_rate, standard_rate, onsite_rate } = entity; - if (early_bird_rate === "" || early_bird_rate === undefined) - delete normalizedEntity.early_bird_rate; - else normalizedEntity.early_bird_rate = amountToCents(early_bird_rate); - - if (standard_rate === "" || standard_rate === undefined) - delete normalizedEntity.standard_rate; - else normalizedEntity.standard_rate = amountToCents(standard_rate); - - if (onsite_rate === "" || onsite_rate === undefined) - delete normalizedEntity.onsite_rate; - else normalizedEntity.onsite_rate = amountToCents(onsite_rate); + normalizedEntity.early_bird_rate = rateToCents(early_bird_rate); + normalizedEntity.standard_rate = rateToCents(standard_rate); + normalizedEntity.onsite_rate = rateToCents(onsite_rate); }; export const deleteSponsorFormManagedItem = diff --git a/src/components/mui/__tests__/item-price-tiers.test.js b/src/components/mui/__tests__/item-price-tiers.test.js new file mode 100644 index 000000000..537d10116 --- /dev/null +++ b/src/components/mui/__tests__/item-price-tiers.test.js @@ -0,0 +1,171 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Formik, Form } from "formik"; +import "@testing-library/jest-dom"; +import ItemPriceTiers from "../formik-inputs/item-price-tiers"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const ALL_ENABLED = { + early_bird_rate: 0, + standard_rate: 500, + onsite_rate: 1000 +}; + +const ALL_DISABLED = { + early_bird_rate: null, + standard_rate: null, + onsite_rate: null +}; + +const MIXED = { + early_bird_rate: 0, + standard_rate: null, + onsite_rate: 1000 +}; + +const renderComponent = (initialValues, props = {}, onSubmit = jest.fn()) => + render( + +
+ + + +
+ ); + +describe("ItemPriceTiers", () => { + describe("all enabled", () => { + it("should render all 3 checkboxes as checked", () => { + renderComponent(ALL_ENABLED); + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach((cb) => expect(cb).toBeChecked()); + }); + + it("should show no disabled N/A text fields", () => { + renderComponent(ALL_ENABLED); + const naFields = screen + .queryAllByDisplayValue("price_tiers.not_available") + .filter((el) => el.disabled); + expect(naFields).toHaveLength(0); + }); + }); + + describe("all disabled", () => { + it("should render all 3 checkboxes as unchecked", () => { + renderComponent(ALL_DISABLED); + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach((cb) => expect(cb).not.toBeChecked()); + }); + + it("should show 3 disabled N/A text fields", () => { + renderComponent(ALL_DISABLED); + const naFields = screen.getAllByDisplayValue("price_tiers.not_available"); + expect(naFields).toHaveLength(3); + naFields.forEach((el) => expect(el).toBeDisabled()); + }); + }); + + describe("mixed state", () => { + it("should render the correct number of checked and unchecked checkboxes", () => { + renderComponent(MIXED); + const checkboxes = screen.getAllByRole("checkbox"); + const checked = checkboxes.filter((cb) => cb.checked); + const unchecked = checkboxes.filter((cb) => !cb.checked); + expect(checked).toHaveLength(2); + expect(unchecked).toHaveLength(1); + }); + + it("should show 1 disabled N/A text field", () => { + renderComponent(MIXED); + const naFields = screen.getAllByDisplayValue("price_tiers.not_available"); + expect(naFields).toHaveLength(1); + }); + }); + + describe("toggle on", () => { + it("should enable a tier when its checkbox is clicked from unchecked", async () => { + renderComponent(ALL_DISABLED); + const checkboxes = screen.getAllByRole("checkbox"); + + await act(async () => { + await userEvent.click(checkboxes[0]); + }); + + expect(checkboxes[0]).toBeChecked(); + const naFields = screen.queryAllByDisplayValue( + "price_tiers.not_available" + ); + expect(naFields).toHaveLength(2); + }); + + it("should set the field value to 0 when toggled on", async () => { + const onSubmit = jest.fn(); + renderComponent(ALL_DISABLED, {}, onSubmit); + const checkboxes = screen.getAllByRole("checkbox"); + + await act(async () => { + await userEvent.click(checkboxes[0]); + await userEvent.click(screen.getByText("submit")); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ early_bird_rate: 0 }), + expect.anything() + ); + }); + }); + + describe("toggle off", () => { + it("should disable a tier when its checkbox is clicked from checked", async () => { + renderComponent(ALL_ENABLED); + const checkboxes = screen.getAllByRole("checkbox"); + + await act(async () => { + await userEvent.click(checkboxes[0]); + }); + + expect(checkboxes[0]).not.toBeChecked(); + const naFields = screen.queryAllByDisplayValue( + "price_tiers.not_available" + ); + expect(naFields).toHaveLength(1); + }); + + it("should set the field value to null when toggled off", async () => { + const onSubmit = jest.fn(); + renderComponent(ALL_ENABLED, {}, onSubmit); + const checkboxes = screen.getAllByRole("checkbox"); + + await act(async () => { + await userEvent.click(checkboxes[0]); + await userEvent.click(screen.getByText("submit")); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ early_bird_rate: null }), + expect.anything() + ); + }); + }); + + describe("readOnly prop", () => { + it("should disable all checkboxes when readOnly is true", () => { + renderComponent(ALL_ENABLED, { readOnly: true }); + const checkboxes = screen.getAllByRole("checkbox"); + checkboxes.forEach((cb) => expect(cb).toBeDisabled()); + }); + + it("should not disable checkboxes when readOnly is false", () => { + renderComponent(ALL_ENABLED, { readOnly: false }); + const checkboxes = screen.getAllByRole("checkbox"); + checkboxes.forEach((cb) => expect(cb).not.toBeDisabled()); + }); + }); +}); diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js index 47800d68e..2e3e69237 100644 --- a/src/components/mui/editable-table/mui-table-editable.js +++ b/src/components/mui/editable-table/mui-table-editable.js @@ -193,12 +193,15 @@ const MuiTableEditable = ({ } }; + const isEditable = (col, row) => + typeof col.editable === "function" ? col.editable(row) : !!col.editable; + // Handler for starting edit mode on a cell - const handleCellClick = (rowId, columnKey) => { + const handleCellClick = (row, columnKey) => { // Check if the column is editable const column = columns.find((col) => col.columnKey === columnKey); - if (column && column.editable) { - setEditingCell({ rowId, columnKey }); + if (column && isEditable(column, row)) { + setEditingCell({ rowId: row.id, columnKey }); } }; @@ -267,13 +270,13 @@ const MuiTableEditable = ({ {columns.map((col) => ( handleCellClick(row.id, col.columnKey)} + onClick={() => handleCellClick(row, col.columnKey)} sx={getCellSx(row, { - cursor: col.editable ? "pointer" : "default", - padding: col.editable ? "8px 16px" : undefined // Ensure enough space for the edit icon + cursor: isEditable(col, row) ? "pointer" : "default", + padding: isEditable(col, row) ? "8px 16px" : undefined // Ensure enough space for the edit icon })} > - {col.editable ? ( + {isEditable(col, row) ? ( { + const { values, setFieldValue } = useFormikContext(); + + const [enabled, setEnabled] = useState({ + [RATE_FIELDS.EARLY_BIRD]: isRateEnabled(values[RATE_FIELDS.EARLY_BIRD]), + [RATE_FIELDS.STANDARD]: isRateEnabled(values[RATE_FIELDS.STANDARD]), + [RATE_FIELDS.ONSITE]: isRateEnabled(values[RATE_FIELDS.ONSITE]) + }); + + const handleToggle = (field, checked) => { + setEnabled((prev) => ({ ...prev, [field]: checked })); + setFieldValue(field, checked ? 0 : null); + }; + + return ( + + {TIERS.map(({ field, label }) => { + const isEnabled = enabled[field]; + return ( + + + {T.translate(label)} + handleToggle(field, ev.target.checked)} + size="small" + disabled={readOnly} + /> + } + label={T.translate( + isEnabled + ? "price_tiers.available" + : "price_tiers.not_available" + )} + /> + + + {isEnabled ? ( + + ) : ( + + )} + + + ); + })} + + ); +}; + +export default ItemPriceTiers; diff --git a/src/i18n/en.json b/src/i18n/en.json index 38bff22d6..3b3b159ac 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -91,7 +91,7 @@ "file_required": "File is required", "string": "Must be a string.", "number": "Must be a number.", - "number_positive": "Must be a positive number.", + "non_negative": "Must be a positive number.", "integer": "Must be a integer", "one_option_required": "Must have one option", "two_decimals": "Max 2 decimal places", @@ -107,6 +107,13 @@ "wrong_format": "Wrong format.", "add_on_required": "Select at least one add-on" }, + "price_tiers": { + "early_bird_rate": "Early Bird Rate", + "standard_rate": "Standard Rate", + "onsite_rate": "Onsite Rate", + "available": "Available", + "not_available": "N/A" + }, "landing": { "os_summit_admin": "Show Admin", "sign_out": "Sign out", diff --git a/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js b/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js index cd8c9b212..aac1d2c05 100644 --- a/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js +++ b/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; import T from "i18n-react/dist/i18n-react"; -import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { connect } from "react-redux"; import PropTypes from "prop-types"; import { @@ -31,6 +30,7 @@ import { setSelectedAll, unSelectInventoryItem } from "../../../actions/inventory-item-actions"; +import { formatRateFromCents } from "../../../utils/rate-helpers"; import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants"; const AddFormTemplateItemDialog = ({ @@ -129,7 +129,7 @@ const AddFormTemplateItemDialog = ({ "inventory_items_list_modal.early_bid_rate_column_label" ), sortable: false, - render: (row) => currencyAmountFromCents(row.early_bird_rate) + render: (row) => formatRateFromCents(row.early_bird_rate) }, { columnKey: "standard_rate", @@ -137,7 +137,7 @@ const AddFormTemplateItemDialog = ({ "inventory_items_list_modal.standard_rate_column_label" ), sortable: false, - render: (row) => currencyAmountFromCents(row.standard_rate) + render: (row) => formatRateFromCents(row.standard_rate) }, { columnKey: "onsite_rate", @@ -145,7 +145,7 @@ const AddFormTemplateItemDialog = ({ "inventory_items_list_modal.onsite_rate_column_label" ), sortable: false, - render: (row) => currencyAmountFromCents(row.onsite_rate) + render: (row) => formatRateFromCents(row.onsite_rate) }, { columnKey: "default_quantity", diff --git a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js index 1ea41a94c..ffe9c6489 100644 --- a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js +++ b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js @@ -25,17 +25,17 @@ import { MAX_INVENTORY_IMAGES_UPLOAD_QTY } from "../../../utils/constants"; import MuiFormikTextField from "../../../components/mui/formik-inputs/mui-formik-textfield"; -import MuiFormikPriceField from "../../../components/mui/formik-inputs/mui-formik-pricefield"; import useScrollToError from "../../../hooks/useScrollToError"; import FormikTextEditor from "../../../components/inputs/formik-text-editor"; import { - decimalValidation, + nullableDecimalValidation, requiredStringValidation, positiveNumberValidation, formMetafieldsValidation, requiredHTMLValidation } from "../../../utils/yup"; import AdditionalInputList from "../../../components/mui/formik-inputs/additional-input/additional-input-list"; +import ItemPriceTiers from "../../../components/mui/formik-inputs/item-price-tiers"; import MuiFormikQuantityField from "../../../components/mui/formik-inputs/mui-formik-quantity-field"; import { getMediaInputValue } from "../../../utils/methods"; @@ -60,9 +60,9 @@ const SponsorItemDialog = ({ name: requiredStringValidation(), description: requiredHTMLValidation(), images: yup.array(), - early_bird_rate: decimalValidation(), - standard_rate: decimalValidation(), - onsite_rate: decimalValidation(), + early_bird_rate: nullableDecimalValidation(), + standard_rate: nullableDecimalValidation(), + onsite_rate: nullableDecimalValidation(), default_quantity: positiveNumberValidation(), quantity_limit_per_sponsor: positiveNumberValidation(), quantity_limit_per_show: positiveNumberValidation(), @@ -174,36 +174,7 @@ const SponsorItemDialog = ({ - - - {T.translate("edit_inventory_item.early_bird_rate")} * - - - - - - {T.translate("edit_inventory_item.standard_rate")} * - - - - - - {T.translate("edit_inventory_item.onsite_rate")} * - - - + diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/index.js b/src/pages/sponsors-global/page-templates/page-template-popup/index.js index 6b75729f4..95e01f8d0 100644 --- a/src/pages/sponsors-global/page-templates/page-template-popup/index.js +++ b/src/pages/sponsors-global/page-templates/page-template-popup/index.js @@ -68,7 +68,7 @@ const PageTemplatePopup = ({ pageTemplate, onClose, onSave, sponsorships, summit is: PAGE_MODULES_MEDIA_TYPES.FILE, then: (schema) => schema - .min(BYTES_PER_MB, T.translate("validation.number_positive")) + .min(BYTES_PER_MB, T.translate("validation.non_negative")) .required(T.translate("validation.required")) .test( "mib-aligned", diff --git a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js index 42b116741..e06be44a7 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; -import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import T from "i18n-react/dist/i18n-react"; import { connect } from "react-redux"; import { @@ -20,6 +19,7 @@ import CloseIcon from "@mui/icons-material/Close"; import Tooltip from "@mui/material/Tooltip"; import ImageIcon from "@mui/icons-material/Image"; import Box from "@mui/material/Box"; +import { formatRateFromCents } from "../../../../utils/rate-helpers"; import SearchInput from "../../../../components/mui/search-input"; import MuiTable from "../../../../components/mui/table/mui-table"; import { addInventoryItems } from "../../../../actions/sponsor-forms-actions"; @@ -123,7 +123,7 @@ const SponsorFormAddItemFromInventoryPopup = ({ "sponsor_form_item_list.add_from_inventory.early_bird_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.early_bird_rate) + render: (row) => formatRateFromCents(row.early_bird_rate) }, { columnKey: "standard_rate", @@ -131,7 +131,7 @@ const SponsorFormAddItemFromInventoryPopup = ({ "sponsor_form_item_list.add_from_inventory.standard_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.standard_rate) + render: (row) => formatRateFromCents(row.standard_rate) }, { columnKey: "onsite_rate", @@ -139,7 +139,7 @@ const SponsorFormAddItemFromInventoryPopup = ({ "sponsor_form_item_list.add_from_inventory.onsite_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.onsite_rate) + render: (row) => formatRateFromCents(row.onsite_rate) }, { columnKey: "hasImage", diff --git a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js index 7176b4b96..067add164 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js @@ -15,7 +15,7 @@ import * as yup from "yup"; import { FormikProvider, useFormik } from "formik"; import { addIssAfterDateFieldValidator, - decimalValidation, + nullableDecimalValidation, formMetafieldsValidation, positiveNumberValidation, requiredHTMLValidation, @@ -25,7 +25,7 @@ import MuiFormikTextField from "../../../../components/mui/formik-inputs/mui-for import AdditionalInputList from "../../../../components/mui/formik-inputs/additional-input/additional-input-list"; import useScrollToError from "../../../../hooks/useScrollToError"; import MuiFormikUpload from "../../../../components/mui/formik-inputs/mui-formik-upload"; -import MuiFormikPriceField from "../../../../components/mui/formik-inputs/mui-formik-pricefield"; +import ItemPriceTiers from "../../../../components/mui/formik-inputs/item-price-tiers"; import FormikTextEditor from "../../../../components/inputs/formik-text-editor"; import MuiFormikQuantityField from "../../../../components/mui/formik-inputs/mui-formik-quantity-field"; @@ -40,9 +40,9 @@ const SponsorFormItemForm = ({ initialValues, onSubmit }) => { code: requiredStringValidation(), name: requiredStringValidation(), description: requiredHTMLValidation(), - early_bird_rate: decimalValidation(), - standard_rate: decimalValidation(), - onsite_rate: decimalValidation(), + early_bird_rate: nullableDecimalValidation(), + standard_rate: nullableDecimalValidation(), + onsite_rate: nullableDecimalValidation(), default_quantity: positiveNumberValidation().required( T.translate("validation.required") ), @@ -92,36 +92,7 @@ const SponsorFormItemForm = ({ initialValues, onSubmit }) => { options={{ zIndex: 9999999 }} /> - - - - - - - - - + row.early_bird_rate !== "N/A", validation: { schema: rateCellValidation() } @@ -146,7 +146,7 @@ const SponsorFormItemListPage = ({ columnKey: "standard_rate", header: T.translate("sponsor_form_item_list.standard_rate"), sortable: true, - editable: true, + editable: (row) => row.standard_rate !== "N/A", validation: { schema: rateCellValidation() } @@ -155,7 +155,7 @@ const SponsorFormItemListPage = ({ columnKey: "onsite_rate", header: T.translate("sponsor_form_item_list.onsite_rate"), sortable: true, - editable: true, + editable: (row) => row.onsite_rate !== "N/A", validation: { schema: rateCellValidation() } diff --git a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js index f8b8fd61c..b60707291 100644 --- a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js +++ b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js @@ -20,7 +20,7 @@ import { import CloseIcon from "@mui/icons-material/Close"; import ImageIcon from "@mui/icons-material/Image"; import SwapVertIcon from "@mui/icons-material/SwapVert"; -import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { formatRateFromCents } from "../../../../../utils/rate-helpers"; import SearchInput from "../../../../../components/mui/search-input"; import { DEFAULT_CURRENT_PAGE, @@ -116,7 +116,7 @@ const SponsorFormItemFromInventoryPopup = ({ "edit_sponsor.forms_tab.form_manage_items.early_bird_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.early_bird_rate) + render: (row) => formatRateFromCents(row.early_bird_rate) }, { columnKey: "standard_rate", @@ -124,7 +124,7 @@ const SponsorFormItemFromInventoryPopup = ({ "edit_sponsor.forms_tab.form_manage_items.standard_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.standard_rate) + render: (row) => formatRateFromCents(row.standard_rate) }, { columnKey: "onsite_rate", @@ -132,7 +132,7 @@ const SponsorFormItemFromInventoryPopup = ({ "edit_sponsor.forms_tab.form_manage_items.onsite_rate" ), sortable: false, - render: (row) => currencyAmountFromCents(row.onsite_rate) + render: (row) => formatRateFromCents(row.onsite_rate) }, { columnKey: "default_quantity", diff --git a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js index 7f2003787..e3f97ffce 100644 --- a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js +++ b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js @@ -212,7 +212,7 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.early_bird_rate" ), sortable: false, - editable: true, + editable: (row) => row.early_bird_rate !== "N/A", validation: { schema: rateCellValidation() } @@ -223,7 +223,7 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.standard_rate" ), sortable: false, - editable: true, + editable: (row) => row.standard_rate !== "N/A", validation: { schema: rateCellValidation() } @@ -234,7 +234,7 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.onsite_rate" ), sortable: false, - editable: true, + editable: (row) => row.onsite_rate !== "N/A", validation: { schema: rateCellValidation() } diff --git a/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js b/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js index 562a7f486..a33b5d430 100644 --- a/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js @@ -25,9 +25,9 @@ function createDefaultState() { code: "", name: "", description: "", - early_bird_rate: "", - standard_rate: "", - onsite_rate: "", + early_bird_rate: 0, + standard_rate: 0, + onsite_rate: 0, quantity_limit_per_show: "", quantity_limit_per_sponsor: "", default_quantity: "", @@ -92,9 +92,9 @@ describe("SponsorFormItemsListReducer", () => { id: "A", code: "A", name: "A", - early_bird_rate: "100", - standard_rate: "100", - onsite_rate: "100", + early_bird_rate: 100, + standard_rate: 100, + onsite_rate: 100, default_quantity: "100", is_archived: true, images: [] @@ -163,9 +163,9 @@ describe("SponsorFormItemsListReducer", () => { id: "A", code: "A", name: "A", - early_bird_rate: "100", - standard_rate: "100", - onsite_rate: "100", + early_bird_rate: 100, + standard_rate: 100, + onsite_rate: 100, default_quantity: "100", is_archived: true, images: [], diff --git a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js index 2bb0d7c57..3d47f6452 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -12,10 +12,7 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { - amountFromCents, - currencyAmountFromCents -} from "openstack-uicore-foundation/lib/utils/money"; +import { formatRateFromCents, rateFromCents } from "../../utils/rate-helpers"; import { RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEMS, REQUEST_SPONSOR_CUSTOMIZED_FORM_ITEMS, @@ -92,9 +89,9 @@ const sponsorCustomizedFormItemsListReducer = ( id: a.id, code: a.code, name: a.name, - early_bird_rate: currencyAmountFromCents(a.early_bird_rate), - standard_rate: currencyAmountFromCents(a.standard_rate), - onsite_rate: currencyAmountFromCents(a.onsite_rate), + early_bird_rate: formatRateFromCents(a.early_bird_rate), + standard_rate: formatRateFromCents(a.standard_rate), + onsite_rate: formatRateFromCents(a.onsite_rate), default_quantity: a.default_quantity, is_archived: a.is_archived, images: a.images @@ -113,9 +110,9 @@ const sponsorCustomizedFormItemsListReducer = ( const currentItem = { ...item, - early_bird_rate: amountFromCents(item.early_bird_rate), - standard_rate: amountFromCents(item.standard_rate), - onsite_rate: amountFromCents(item.onsite_rate), + early_bird_rate: rateFromCents(item.early_bird_rate), + standard_rate: rateFromCents(item.standard_rate), + onsite_rate: rateFromCents(item.onsite_rate), meta_fields: item.meta_fields.length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; @@ -152,11 +149,9 @@ const sponsorCustomizedFormItemsListReducer = ( id: updatedItem.id, code: updatedItem.code, name: updatedItem.name, - early_bird_rate: currencyAmountFromCents( - updatedItem.early_bird_rate - ), - standard_rate: currencyAmountFromCents(updatedItem.standard_rate), - onsite_rate: currencyAmountFromCents(updatedItem.onsite_rate), + early_bird_rate: formatRateFromCents(updatedItem.early_bird_rate), + standard_rate: formatRateFromCents(updatedItem.standard_rate), + onsite_rate: formatRateFromCents(updatedItem.onsite_rate), default_quantity: updatedItem.default_quantity, is_archived: updatedItem.is_archived, images: updatedItem.images diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index e15f1dcff..a9779cdfe 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -11,8 +11,8 @@ * limitations under the License. * */ -import { amountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { formatRateFromCents, rateFromCents } from "../../utils/rate-helpers"; import { RECEIVE_SPONSOR_FORM_ITEM, RECEIVE_SPONSOR_FORM_ITEMS, @@ -37,9 +37,9 @@ const DEFAULT_STATE = { code: "", name: "", description: "", - early_bird_rate: "", - standard_rate: "", - onsite_rate: "", + early_bird_rate: 0, + standard_rate: 0, + onsite_rate: 0, quantity_limit_per_show: "", quantity_limit_per_sponsor: "", default_quantity: "", @@ -79,9 +79,9 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { id: a.id, code: a.code, name: a.name, - early_bird_rate: `$${amountFromCents(a.early_bird_rate)}`, - standard_rate: `$${amountFromCents(a.standard_rate)}`, - onsite_rate: `$${amountFromCents(a.onsite_rate)}`, + early_bird_rate: formatRateFromCents(a.early_bird_rate), + standard_rate: formatRateFromCents(a.standard_rate), + onsite_rate: formatRateFromCents(a.onsite_rate), default_quantity: a.default_quantity, is_archived: a.is_archived, images: a.images @@ -100,9 +100,9 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { const currentItem = { ...item, - early_bird_rate: amountFromCents(item.early_bird_rate), - standard_rate: amountFromCents(item.standard_rate), - onsite_rate: amountFromCents(item.onsite_rate), + early_bird_rate: rateFromCents(item.early_bird_rate), + standard_rate: rateFromCents(item.standard_rate), + onsite_rate: rateFromCents(item.onsite_rate), meta_fields: item.meta_fields.length > 0 ? item.meta_fields : [] }; diff --git a/src/reducers/sponsors_inventory/form-template-item-reducer.js b/src/reducers/sponsors_inventory/form-template-item-reducer.js index 3c41fb11a..8b900dc94 100644 --- a/src/reducers/sponsors_inventory/form-template-item-reducer.js +++ b/src/reducers/sponsors_inventory/form-template-item-reducer.js @@ -12,7 +12,7 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { amountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { RATE_FIELDS, rateFromCents } from "../../utils/rate-helpers"; import { RECEIVE_FORM_TEMPLATE_ITEM, RESET_FORM_TEMPLATE_ITEM_FORM, @@ -62,16 +62,17 @@ const formTemplateItemReducer = (state = DEFAULT_STATE, action) => { } case RECEIVE_FORM_TEMPLATE_ITEM: { const entity = { ...payload.response }; + const rateFieldValues = Object.values(RATE_FIELDS); for (const key in entity) { - if (entity.hasOwnProperty(key)) { + if (entity.hasOwnProperty(key) && !rateFieldValues.includes(key)) { entity[key] = entity[key] == null ? "" : entity[key]; } } - entity.early_bird_rate = amountFromCents(entity.early_bird_rate); - entity.standard_rate = amountFromCents(entity.standard_rate); - entity.onsite_rate = amountFromCents(entity.onsite_rate); + entity.early_bird_rate = rateFromCents(entity.early_bird_rate); + entity.standard_rate = rateFromCents(entity.standard_rate); + entity.onsite_rate = rateFromCents(entity.onsite_rate); return { ...state, @@ -84,16 +85,17 @@ const formTemplateItemReducer = (state = DEFAULT_STATE, action) => { case FORM_TEMPLATE_ITEM_ADDED: case FORM_TEMPLATE_ITEM_UPDATED: { const entity = { ...payload.response }; + const rateFieldValues = Object.values(RATE_FIELDS); for (const key in entity) { - if (entity.hasOwnProperty(key)) { + if (entity.hasOwnProperty(key) && !rateFieldValues.includes(key)) { entity[key] = entity[key] == null ? "" : entity[key]; } } - entity.early_bird_rate = amountFromCents(entity.early_bird_rate); - entity.standard_rate = amountFromCents(entity.standard_rate); - entity.onsite_rate = amountFromCents(entity.onsite_rate); + entity.early_bird_rate = rateFromCents(entity.early_bird_rate); + entity.standard_rate = rateFromCents(entity.standard_rate); + entity.onsite_rate = rateFromCents(entity.onsite_rate); return { ...state, diff --git a/src/reducers/sponsors_inventory/inventory-item-reducer.js b/src/reducers/sponsors_inventory/inventory-item-reducer.js index d55840300..b70bf43b7 100644 --- a/src/reducers/sponsors_inventory/inventory-item-reducer.js +++ b/src/reducers/sponsors_inventory/inventory-item-reducer.js @@ -12,7 +12,7 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { amountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { RATE_FIELDS, rateFromCents } from "../../utils/rate-helpers"; import { RECEIVE_INVENTORY_ITEM, RESET_INVENTORY_ITEM_FORM, @@ -61,16 +61,17 @@ const inventoryItemReducer = (state = DEFAULT_STATE, action) => { } case RECEIVE_INVENTORY_ITEM: { const entity = { ...payload.response }; + const rateFieldValues = Object.values(RATE_FIELDS); for (const key in entity) { - if (entity.hasOwnProperty(key)) { + if (entity.hasOwnProperty(key) && !rateFieldValues.includes(key)) { entity[key] = entity[key] == null ? "" : entity[key]; } } - entity.early_bird_rate = amountFromCents(entity.early_bird_rate); - entity.standard_rate = amountFromCents(entity.standard_rate); - entity.onsite_rate = amountFromCents(entity.onsite_rate); + entity.early_bird_rate = rateFromCents(entity.early_bird_rate); + entity.standard_rate = rateFromCents(entity.standard_rate); + entity.onsite_rate = rateFromCents(entity.onsite_rate); return { ...state, @@ -83,16 +84,17 @@ const inventoryItemReducer = (state = DEFAULT_STATE, action) => { case INVENTORY_ITEM_ADDED: case INVENTORY_ITEM_UPDATED: { const entity = { ...payload.response }; + const rateFieldValues = Object.values(RATE_FIELDS); for (const key in entity) { - if (entity.hasOwnProperty(key)) { + if (entity.hasOwnProperty(key) && !rateFieldValues.includes(key)) { entity[key] = entity[key] == null ? "" : entity[key]; } } - entity.early_bird_rate = amountFromCents(entity.early_bird_rate); - entity.standard_rate = amountFromCents(entity.standard_rate); - entity.onsite_rate = amountFromCents(entity.onsite_rate); + entity.early_bird_rate = rateFromCents(entity.early_bird_rate); + entity.standard_rate = rateFromCents(entity.standard_rate); + entity.onsite_rate = rateFromCents(entity.onsite_rate); return { ...state, diff --git a/src/utils/__tests__/rate-helpers.test.js b/src/utils/__tests__/rate-helpers.test.js new file mode 100644 index 000000000..71e148970 --- /dev/null +++ b/src/utils/__tests__/rate-helpers.test.js @@ -0,0 +1,66 @@ +import { + isRateEnabled, + rateFromCents, + rateToCents, + formatRateFromCents +} from "../rate-helpers"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (key) => key +})); + +describe("isRateEnabled", () => { + it("should return false for null", () => { + expect(isRateEnabled(null)).toBe(false); + }); + + it("should return true for 0", () => { + expect(isRateEnabled(0)).toBe(true); + }); + + it("should returns true for a positive value", () => { + expect(isRateEnabled(10)).toBe(true); + }); +}); + +describe("rateFromCents", () => { + it("should return null for null", () => { + expect(rateFromCents(null)).toBeNull(); + }); + + it("should display 0 cents as 0.00", () => { + expect(rateFromCents(0)).toBe("0.00"); + }); + + it("should display cents with decimals correctly", () => { + expect(rateFromCents(1050)).toBe("10.50"); + }); +}); + +describe("rateToCents", () => { + it("should return null for null", () => { + expect(rateToCents(null)).toBeNull(); + }); + + it("should converts 0 to 0 cents", () => { + expect(rateToCents(0)).toBe(0); + }); + + it("should converts a decimal dollar value to cents", () => { + expect(rateToCents(12.3)).toBe(1230); + }); +}); + +describe("formatRateFromCents", () => { + it("should returns the N/A translation key for null", () => { + expect(formatRateFromCents(null)).toBe("price_tiers.not_available"); + }); + + it("should format 0 cents as $0.00", () => { + expect(formatRateFromCents(0)).toBe("$0.00"); + }); + + it("should format cents with decimals correctly", () => { + expect(formatRateFromCents(1234)).toBe("$12.34"); + }); +}); diff --git a/src/utils/__tests__/yup.test.js b/src/utils/__tests__/yup.test.js new file mode 100644 index 000000000..6eb93e908 --- /dev/null +++ b/src/utils/__tests__/yup.test.js @@ -0,0 +1,34 @@ +import { nullableDecimalValidation } from "../yup"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (key) => key +})); + +describe("nullableDecimalValidation", () => { + const schema = nullableDecimalValidation(); + + it("should pass for null", async () => { + await expect(schema.isValid(null)).resolves.toBe(true); + }); + + it("should pass for 0", async () => { + await expect(schema.isValid(0)).resolves.toBe(true); + }); + + it("should pass for a positive integer", async () => { + await expect(schema.isValid(10)).resolves.toBe(true); + }); + + it("should pass for numbers up to 2 decimal places", async () => { + await expect(schema.isValid(10.5)).resolves.toBe(true); + await expect(schema.isValid(10.55)).resolves.toBe(true); + }); + + it("should fail for a negative value", async () => { + await expect(schema.isValid(-1)).resolves.toBe(false); + }); + + it("should fail for 3 or more decimal places", async () => { + await expect(schema.isValid(1.234)).resolves.toBe(false); + }); +}); diff --git a/src/utils/rate-helpers.js b/src/utils/rate-helpers.js new file mode 100644 index 000000000..fc90413c2 --- /dev/null +++ b/src/utils/rate-helpers.js @@ -0,0 +1,31 @@ +import T from "i18n-react"; +import { + amountFromCents, + amountToCents, + currencyAmountFromCents +} from "openstack-uicore-foundation/lib/utils/money"; + +export const RATE_FIELDS = { + EARLY_BIRD: "early_bird_rate", + STANDARD: "standard_rate", + ONSITE: "onsite_rate" +}; + +export const isRateEnabled = (value) => + value !== null && value !== undefined && value !== ""; + +export const rateFromCents = (cents) => { + if (cents === null || cents === undefined) return null; + return amountFromCents(cents); +}; + +export const rateToCents = (value) => { + if (value === null || value === undefined) return null; + return amountToCents(value); +}; + +export const formatRateFromCents = (cents) => { + if (cents === null || cents === undefined) + return T.translate("price_tiers.not_available"); + return currencyAmountFromCents(cents); +}; diff --git a/src/utils/yup.js b/src/utils/yup.js index 2300c7792..fe55f6e33 100644 --- a/src/utils/yup.js +++ b/src/utils/yup.js @@ -47,7 +47,7 @@ export const decimalValidation = () => yup .number() .typeError(T.translate("validation.number")) - .positive(T.translate("validation.number_positive")) + .positive(T.translate("validation.non_negative")) .required(T.translate("validation.required")) .test("max-decimals", T.translate("validation.two_decimals"), (value) => { if (value === undefined || value === null) return true; @@ -79,7 +79,7 @@ export const rateCellValidation = () => return /^\$?-?\d+(\.\d+)?$/.test(originalValue); } }) - .positive(T.translate("validation.number_positive")) + .positive(T.translate("validation.non_negative")) .test("max-decimals", T.translate("validation.two_decimals"), (value) => { if (value === undefined || value === null) return true; return /^\d+(\.\d{1,2})?$/.test(value.toString()); @@ -103,7 +103,7 @@ export const requiredHTMLValidation = () => export const positiveNumberValidation = () => numberValidation() .integer(T.translate("validation.integer")) - .min(0, T.translate("validation.number_positive")); + .min(0, T.translate("validation.non_negative")); export const formMetafieldsValidation = () => yup.array().of( @@ -159,3 +159,14 @@ export const opensAtValidation = () => yup .date(T.translate("validation.date")) .required(T.translate("validation.required")); + +export const nullableDecimalValidation = () => + yup + .number() + .nullable() + .typeError(T.translate("validation.number")) + .min(0, T.translate("validation.non_negative")) + .test("max-decimals", T.translate("validation.two_decimals"), (value) => { + if (value === undefined || value === null) return true; + return /^\d+(\.\d{1,2})?$/.test(value.toString()); + }); From a05fae808ba69d639c8efabc72ffa4ab5e2f9d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 18 Mar 2026 08:41:38 -0300 Subject: [PATCH 2/4] fix: adjust normalizeRates function, adjust validations and test mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 19 +++++++++++++------ .../mui/__tests__/item-price-tiers.test.js | 2 +- .../sponsor-form-item-list-page/index.js | 9 ++++++--- .../sponsor-forms-manage-items.js | 9 ++++++--- src/utils/__tests__/yup.test.js | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 2b7c4224b..9bb70cf6e 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -29,7 +29,7 @@ import { getAccessTokenSafely, normalizeSelectAllField } from "../utils/methods"; -import { rateToCents } from "../utils/rate-helpers"; +import { rateToCents, RATE_FIELDS } from "../utils/rate-helpers"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -1369,11 +1369,18 @@ const normalizeManagedItem = (entity) => { }; const normalizeRates = (entity, normalizedEntity) => { - const { early_bird_rate, standard_rate, onsite_rate } = entity; - - normalizedEntity.early_bird_rate = rateToCents(early_bird_rate); - normalizedEntity.standard_rate = rateToCents(standard_rate); - normalizedEntity.onsite_rate = rateToCents(onsite_rate); + if (RATE_FIELDS.EARLY_BIRD in entity) + normalizedEntity[RATE_FIELDS.EARLY_BIRD] = rateToCents( + entity[RATE_FIELDS.EARLY_BIRD] + ); + if (RATE_FIELDS.STANDARD in entity) + normalizedEntity[RATE_FIELDS.STANDARD] = rateToCents( + entity[RATE_FIELDS.STANDARD] + ); + if (RATE_FIELDS.ONSITE in entity) + normalizedEntity[RATE_FIELDS.ONSITE] = rateToCents( + entity[RATE_FIELDS.ONSITE] + ); }; export const deleteSponsorFormManagedItem = diff --git a/src/components/mui/__tests__/item-price-tiers.test.js b/src/components/mui/__tests__/item-price-tiers.test.js index 537d10116..f3977a8fb 100644 --- a/src/components/mui/__tests__/item-price-tiers.test.js +++ b/src/components/mui/__tests__/item-price-tiers.test.js @@ -5,7 +5,7 @@ import { Formik, Form } from "formik"; import "@testing-library/jest-dom"; import ItemPriceTiers from "../formik-inputs/item-price-tiers"; -jest.mock("i18n-react/dist/i18n-react", () => ({ +jest.mock("i18n-react", () => ({ __esModule: true, default: { translate: (key) => key } })); diff --git a/src/pages/sponsors/sponsor-form-item-list-page/index.js b/src/pages/sponsors/sponsor-form-item-list-page/index.js index ff4fd8d05..532af58c3 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -137,7 +137,8 @@ const SponsorFormItemListPage = ({ columnKey: "early_bird_rate", header: T.translate("sponsor_form_item_list.early_bird_rate"), sortable: true, - editable: (row) => row.early_bird_rate !== "N/A", + editable: (row) => + row.early_bird_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } @@ -146,7 +147,8 @@ const SponsorFormItemListPage = ({ columnKey: "standard_rate", header: T.translate("sponsor_form_item_list.standard_rate"), sortable: true, - editable: (row) => row.standard_rate !== "N/A", + editable: (row) => + row.standard_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } @@ -155,7 +157,8 @@ const SponsorFormItemListPage = ({ columnKey: "onsite_rate", header: T.translate("sponsor_form_item_list.onsite_rate"), sortable: true, - editable: (row) => row.onsite_rate !== "N/A", + editable: (row) => + row.onsite_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } diff --git a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js index e3f97ffce..54ceb0de0 100644 --- a/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js +++ b/src/pages/sponsors/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js @@ -212,7 +212,8 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.early_bird_rate" ), sortable: false, - editable: (row) => row.early_bird_rate !== "N/A", + editable: (row) => + row.early_bird_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } @@ -223,7 +224,8 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.standard_rate" ), sortable: false, - editable: (row) => row.standard_rate !== "N/A", + editable: (row) => + row.standard_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } @@ -234,7 +236,8 @@ const SponsorFormsManageItems = ({ "edit_sponsor.forms_tab.form_manage_items.onsite_rate" ), sortable: false, - editable: (row) => row.onsite_rate !== "N/A", + editable: (row) => + row.onsite_rate !== T.translate("price_tiers.not_available"), validation: { schema: rateCellValidation() } diff --git a/src/utils/__tests__/yup.test.js b/src/utils/__tests__/yup.test.js index 6eb93e908..502ad64c3 100644 --- a/src/utils/__tests__/yup.test.js +++ b/src/utils/__tests__/yup.test.js @@ -1,6 +1,6 @@ import { nullableDecimalValidation } from "../yup"; -jest.mock("i18n-react/dist/i18n-react", () => ({ +jest.mock("i18n-react", () => ({ translate: (key) => key })); From 93ddd5df0e3ceee38b1303743548bc0b6c80af59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 18 Mar 2026 09:07:05 -0300 Subject: [PATCH 3/4] fix: update validations for empty string cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/utils/__tests__/rate-helpers.test.js | 4 ++++ src/utils/__tests__/yup.test.js | 4 ++++ src/utils/rate-helpers.js | 1 + src/utils/yup.js | 5 +++++ 4 files changed, 14 insertions(+) diff --git a/src/utils/__tests__/rate-helpers.test.js b/src/utils/__tests__/rate-helpers.test.js index 71e148970..413c34c31 100644 --- a/src/utils/__tests__/rate-helpers.test.js +++ b/src/utils/__tests__/rate-helpers.test.js @@ -42,6 +42,10 @@ describe("rateToCents", () => { expect(rateToCents(null)).toBeNull(); }); + it("should return 0 for empty string (cleared input field)", () => { + expect(rateToCents("")).toBe(0); + }); + it("should converts 0 to 0 cents", () => { expect(rateToCents(0)).toBe(0); }); diff --git a/src/utils/__tests__/yup.test.js b/src/utils/__tests__/yup.test.js index 502ad64c3..bdba5c50a 100644 --- a/src/utils/__tests__/yup.test.js +++ b/src/utils/__tests__/yup.test.js @@ -24,6 +24,10 @@ describe("nullableDecimalValidation", () => { await expect(schema.isValid(10.55)).resolves.toBe(true); }); + it("should treat empty string as 0 (cleared input field)", async () => { + await expect(schema.cast("")).toBe(0); + }); + it("should fail for a negative value", async () => { await expect(schema.isValid(-1)).resolves.toBe(false); }); diff --git a/src/utils/rate-helpers.js b/src/utils/rate-helpers.js index fc90413c2..c841e817f 100644 --- a/src/utils/rate-helpers.js +++ b/src/utils/rate-helpers.js @@ -21,6 +21,7 @@ export const rateFromCents = (cents) => { export const rateToCents = (value) => { if (value === null || value === undefined) return null; + if (value === "") return 0; return amountToCents(value); }; diff --git a/src/utils/yup.js b/src/utils/yup.js index fe55f6e33..6dfa63a05 100644 --- a/src/utils/yup.js +++ b/src/utils/yup.js @@ -164,6 +164,11 @@ export const nullableDecimalValidation = () => yup .number() .nullable() + .transform((value, originalValue) => { + if (typeof originalValue === "string" && originalValue.trim() === "") + return 0; + return value; + }) .typeError(T.translate("validation.number")) .min(0, T.translate("validation.non_negative")) .test("max-decimals", T.translate("validation.two_decimals"), (value) => { From 83b2f5ad8c1c78f3471a05719d4aa92cd1b87e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 25 Mar 2026 12:03:33 -0300 Subject: [PATCH 4/4] fix: adjust validations, messages and toggle value for rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/formik-inputs/item-price-tiers.js | 7 +++---- src/i18n/en.json | 2 +- src/utils/yup.js | 8 ++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/mui/formik-inputs/item-price-tiers.js b/src/components/mui/formik-inputs/item-price-tiers.js index 5e3810afe..5ce85c7ae 100644 --- a/src/components/mui/formik-inputs/item-price-tiers.js +++ b/src/components/mui/formik-inputs/item-price-tiers.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import T from "i18n-react"; import { useFormikContext } from "formik"; import { @@ -21,14 +21,13 @@ const TIERS = [ const ItemPriceTiers = ({ readOnly = false }) => { const { values, setFieldValue } = useFormikContext(); - const [enabled, setEnabled] = useState({ + const enabled = { [RATE_FIELDS.EARLY_BIRD]: isRateEnabled(values[RATE_FIELDS.EARLY_BIRD]), [RATE_FIELDS.STANDARD]: isRateEnabled(values[RATE_FIELDS.STANDARD]), [RATE_FIELDS.ONSITE]: isRateEnabled(values[RATE_FIELDS.ONSITE]) - }); + }; const handleToggle = (field, checked) => { - setEnabled((prev) => ({ ...prev, [field]: checked })); setFieldValue(field, checked ? 0 : null); }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 3b3b159ac..2f4e01bd2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -91,7 +91,7 @@ "file_required": "File is required", "string": "Must be a string.", "number": "Must be a number.", - "non_negative": "Must be a positive number.", + "non_negative": "Must be a non-negative number.", "integer": "Must be a integer", "one_option_required": "Must have one option", "two_decimals": "Max 2 decimal places", diff --git a/src/utils/yup.js b/src/utils/yup.js index 6dfa63a05..45291c068 100644 --- a/src/utils/yup.js +++ b/src/utils/yup.js @@ -47,7 +47,7 @@ export const decimalValidation = () => yup .number() .typeError(T.translate("validation.number")) - .positive(T.translate("validation.non_negative")) + .min(0, T.translate("validation.non_negative")) .required(T.translate("validation.required")) .test("max-decimals", T.translate("validation.two_decimals"), (value) => { if (value === undefined || value === null) return true; @@ -60,7 +60,7 @@ export const rateCellValidation = () => // allow $ at the start .transform((value, originalValue) => { if (typeof originalValue === "string") { - const cleaned = originalValue.replace(/^\$/, ""); + const cleaned = originalValue.replace(/^\$/, "").replace(",", "."); return cleaned === "" ? undefined : parseFloat(cleaned); } return value; @@ -76,10 +76,10 @@ export const rateCellValidation = () => originalValue === "" ) return true; - return /^\$?-?\d+(\.\d+)?$/.test(originalValue); + return /^\$?-?\d+([.,]\d+)?$/.test(originalValue); } }) - .positive(T.translate("validation.non_negative")) + .min(0, T.translate("validation.non_negative")) .test("max-decimals", T.translate("validation.two_decimals"), (value) => { if (value === undefined || value === null) return true; return /^\d+(\.\d{1,2})?$/.test(value.toString());