diff --git a/package.json b/package.json index 7929085c..82db3488 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.39", + "version": "5.0.38-beta.5", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/index.js b/src/components/index.js index ea54b8e6..f074b1c0 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -70,7 +70,7 @@ export {default as MuiShowConfirmDialog} from './mui/showConfirmDialog' export {default as MuiSponsorAddonSelect} from './mui/sponsor-addon-select' export {default as MuiSummitAddonSelect} from './mui/summit-addon-select' export {default as MuiSummitsDropdown} from './mui/summits-dropdown' -export {default as MuiFormItemTable, getCurrentApplicableRate, isItemAvailable, GlobalQuantityField, ItemTableField, UnderlyingAlertNote} from './mui/FormItemTable' +export {default as MuiFormItemTable, getCurrentApplicableRate, isItemAvailable, GlobalQuantityField, ItemTableField, UnderlyingAlertNote, ExpandedRowContent} from './mui/FormItemTable' export {default as MuiItemSettingsModal} from './mui/ItemSettingsModal' export {default as MuiNotesModal} from './mui/NotesModal' export {default as MuiSnackbarNotification} from './mui/SnackbarNotification' diff --git a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js index 19100866..7d601bfb 100644 --- a/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js +++ b/src/components/mui/FormItemTable/__tests__/FormItemTable.test.js @@ -25,7 +25,13 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ /* eslint-disable import/first */ import React from "react"; import PropTypes from "prop-types"; -import { cleanup, fireEvent, screen, render } from "@testing-library/react"; +import { + cleanup, + fireEvent, + screen, + render, + waitFor +} from "@testing-library/react"; import "@testing-library/jest-dom"; import { FormikProvider, useFormik } from "formik"; import FormItemTable from "../index"; @@ -165,6 +171,59 @@ const MOCK_ITEMS_WITH_MIXED_RATES = [ } ]; +// No Form- or Item-class Quantity fields, so the global qty field isn't +// driven and rows start collapsed — used for tests that exercise manual +// row-toggle behavior in isolation from the driven-quantity auto-expand. +const MOCK_ITEMS_NO_QUANTITY = [ + { + form_item_id: 7, + code: "NOQ-1", + name: "No Quantity Driver Item 1", + rates: { + early_bird: 15000, + standard: 18800, + onsite: 22400 + }, + meta_fields: [] + }, + { + form_item_id: 8, + code: "NOQ-2", + name: "No Quantity Driver Item 2", + rates: { + early_bird: 15000, + standard: 18800, + onsite: 22400 + }, + meta_fields: [] + } +]; + +// Has a required Item-class field but no driving Quantity field, so the row +// starts collapsed — used to test the auto-expand-on-validation-error effect +// without the driven-quantity default-open behavior masking the transition. +const MOCK_ITEMS_WITH_REQUIRED_FIELD = [ + { + form_item_id: 9, + code: "REQ-1", + name: "Item With Required Field", + rates: { + early_bird: 15000, + standard: 18800, + onsite: 22400 + }, + meta_fields: [ + { + type_id: 1, + class_field: "Item", + name: "Special Instructions", + type: "Text", + is_required: true + } + ] + } +]; + jest.mock("../../formik-inputs/mui-formik-textfield", () => { const { useField } = require("formik"); return { @@ -345,8 +404,7 @@ const FormItemTableWrapper = ({ currentApplicableRate, timeZone, initialValues, - onNotesClick, - onSettingsClick + validate }) => { const defaultValues = { discount_type: "AMOUNT", @@ -356,6 +414,7 @@ const FormItemTableWrapper = ({ const formik = useFormik({ initialValues: defaultValues, + validate, onSubmit: () => {} }); @@ -366,8 +425,8 @@ const FormItemTableWrapper = ({ currentApplicableRate={currentApplicableRate} timeZone={timeZone} values={formik.values} - onNotesClick={onNotesClick} - onSettingsClick={onSettingsClick} + touched={formik.touched} + errors={formik.errors} /> ); @@ -378,20 +437,17 @@ FormItemTableWrapper.propTypes = { currentApplicableRate: PropTypes.string, timeZone: PropTypes.string.isRequired, initialValues: PropTypes.shape({}), - onNotesClick: PropTypes.func.isRequired, - onSettingsClick: PropTypes.func.isRequired + validate: PropTypes.func }; FormItemTableWrapper.defaultProps = { currentApplicableRate: "early_bird", - initialValues: {} + initialValues: {}, + validate: undefined }; // ---- Tests ---- describe("FormItemTable Component", () => { - const mockOnNotesClick = jest.fn(); - const mockOnSettingsClick = jest.fn(); - beforeEach(() => { jest.clearAllMocks(); }); @@ -407,10 +463,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect( @@ -435,7 +488,10 @@ describe("FormItemTable Component", () => { screen.getByText("sponsor_edit_form.total") ).toBeInTheDocument(); expect( - screen.getByText("sponsor_edit_form.notes") + screen.getAllByText("sponsor_edit_form.notes").length + ).toBeGreaterThan(0); + expect( + screen.getByText("sponsor_edit_form.details") ).toBeInTheDocument(); }); @@ -444,10 +500,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getByText("Installation")).toBeInTheDocument(); @@ -461,15 +514,12 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); - expect(screen.getByText("Qty of People")).toBeInTheDocument(); - expect(screen.getByText("Hour x Person")).toBeInTheDocument(); - expect(screen.getByText("Arrival Time")).toBeInTheDocument(); + expect(screen.getAllByPlaceholderText("Qty of People").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("Hour x Person").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("Arrival Time").length).toBeGreaterThan(0); }); it("displays rate values in cents to dollar format", () => { @@ -477,10 +527,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); // early_bird: 15000 cents = $150.00 @@ -496,10 +543,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect( @@ -514,10 +558,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getAllByText("general.n_a").length).toBeGreaterThan(0); @@ -528,10 +569,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getByText("$100.00")).toBeInTheDocument(); @@ -544,10 +582,7 @@ describe("FormItemTable Component", () => { data={MOCK_ITEMS_WITH_NULL_RATES} currentApplicableRate="early_bird" initialValues={{ "i-5-c-global-f-quantity": 3 }} - timeZone="America/New_York" - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + timeZone="America/New_York" /> ); // Row total and grand total should both be $0.00 when rate is null @@ -561,8 +596,6 @@ describe("FormItemTable Component", () => { data={MOCK_ITEMS_WITH_NULL_RATES} currentApplicableRate="standard" timeZone="America/New_York" - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} /> ); }).not.toThrow(); @@ -570,57 +603,144 @@ describe("FormItemTable Component", () => { }); describe("ITEM Class Fields", () => { - it("shows warning icon for items with ITEM class fields", () => { - render( + it("renders an info icon in the details column for every item", () => { + const { container } = render( ); - expect( - screen.getByText("sponsor_edit_form.additional_info") - ).toBeInTheDocument(); + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" + ); + expect(infoIcons.length).toBeGreaterThan(0); + }); + + it("renders one details icon per item", () => { + const { container } = render( + + ); + + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" + ); + expect(infoIcons.length).toBe(MOCK_FORM_A.items.length); + }); + + it("toggles row expansion when details button is clicked", () => { + const { container } = render( + + ); + + const infoIcon = container.querySelector( + "[data-testid=\"InfoOutlinedIcon\"]" + ); + fireEvent.click(infoIcon.parentElement); + + const arrowUpIcons = container.querySelectorAll( + "[data-testid=\"KeyboardArrowUpIcon\"]" + ); + expect(arrowUpIcons.length).toBe(1); }); + }); - it("renders settings button only for items with ITEM class fields", () => { + describe("Details Icon Color & Auto-Expand on Validation", () => { + it("shows an error-colored details icon when a required field is empty", () => { + // Row 1 has an Item-class required field ("Special Instructions") + // that has no value in initialValues. const { container } = render( ); - const settingsButtons = container.querySelectorAll( - "[data-testid=\"SettingsIcon\"]" + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" ); - expect(settingsButtons.length).toBe(1); + expect(infoIcons[0]).toHaveClass("MuiSvgIcon-colorError"); }); - it("calls onSettingsClick when settings button is clicked", () => { + it("shows a warning-colored details icon by default when there is no required field and nothing touched", () => { + // Row 3 (Installation Manpower) has no meta_fields at all. const { container } = render( ); - const settingsButton = container.querySelector( - "[data-testid=\"SettingsIcon\"]" - ).parentElement; - fireEvent.click(settingsButton); + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" + ); + expect(infoIcons[2]).toHaveClass("MuiSvgIcon-colorWarning"); + }); + + it("shows a success-colored details icon once a row's fields have been touched with no incomplete requirements", () => { + // Row 4 (Dismantle Manpower) has no required fields, so touching any + // of its inputs (without introducing an error) should flip it to success. + render( + + ); + + fireEvent.blur(screen.getByTestId("pricefield-i-4-c-global-f-custom_rate")); + + const infoIcon = screen.getAllByTestId("InfoOutlinedIcon")[3]; + expect(infoIcon).toHaveClass("MuiSvgIcon-colorSuccess"); + }); + + it("auto-expands the row when a required field is touched and fails validation", async () => { + // Simulate a yup/formik validation error on the row's required Item + // field, then mark it touched via blur, mirroring real form behavior. + const validate = () => ({ "i-9-c-Item-f-1": "Required" }); + + const { container } = render( + + ); + + // Row starts collapsed (no driving Quantity field on this fixture). + expect( + container.querySelectorAll("[data-testid=\"KeyboardArrowUpIcon\"]") + .length + ).toBe(0); + + fireEvent.blur(screen.getByTestId("textfield-i-9-c-Item-f-1")); + + // Formik's validate pipeline resolves asynchronously (even for a sync + // validate fn), so the errors/touched-driven auto-expand effect lands + // a tick after the blur event. + await waitFor(() => { + const arrowUpIcons = container.querySelectorAll( + "[data-testid=\"KeyboardArrowUpIcon\"]" + ); + expect(arrowUpIcons.length).toBe(1); + }); - expect(mockOnSettingsClick).toHaveBeenCalledWith(MOCK_FORM_A.items[0]); - expect(mockOnSettingsClick).toHaveBeenCalledTimes(1); + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" + ); + expect(infoIcons[0]).toHaveClass("MuiSvgIcon-colorError"); }); }); @@ -630,10 +750,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); const qtyPeopleInput = screen.getByTestId("textfield-i-1-c-Form-f-1"); @@ -647,10 +764,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); const timeInput = screen.getByTestId("timepicker-i-1-c-Form-f-3"); @@ -663,10 +777,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect( @@ -702,10 +813,7 @@ describe("FormItemTable Component", () => { data={MOCK_FORM_A.items} currentApplicableRate="early_bird" timeZone="America/New_York" - initialValues={initialValues} - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + initialValues={initialValues} /> ); const qtyInput = screen.getByTestId("textfield-i-1-c-global-f-quantity"); @@ -741,10 +849,7 @@ describe("FormItemTable Component", () => { data={itemsWithoutQuantityFields} currentApplicableRate="early_bird" timeZone="America/New_York" - initialValues={{ "i-10-c-global-f-quantity": 0 }} - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + initialValues={{ "i-10-c-global-f-quantity": 0 }} /> ); expect( @@ -769,10 +874,7 @@ describe("FormItemTable Component", () => { data={MOCK_FORM_A.items} currentApplicableRate="standard" timeZone="America/New_York" - initialValues={initialValues} - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + initialValues={initialValues} /> ); const allText = screen.getAllByText(/\$/); @@ -796,10 +898,7 @@ describe("FormItemTable Component", () => { data={MOCK_FORM_A.items} currentApplicableRate="standard" timeZone="America/New_York" - initialValues={initialValues} - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + initialValues={initialValues} /> ); const allText = screen.getAllByText(/\$/); @@ -813,10 +912,7 @@ describe("FormItemTable Component", () => { data={MOCK_FORM_A.items} currentApplicableRate="early_bird" timeZone="America/New_York" - initialValues={{}} - onNotesClick={mockOnNotesClick} - onSettingsClick={mockOnSettingsClick} - /> + initialValues={{}} /> ); expect( @@ -831,10 +927,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getAllByText("$150.00").length).toBeGreaterThan(0); @@ -845,10 +938,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getAllByText("$188.00").length).toBeGreaterThan(0); @@ -859,10 +949,7 @@ describe("FormItemTable Component", () => { + timeZone="America/New_York" /> ); expect(screen.getAllByText("$224.00").length).toBeGreaterThan(0); @@ -870,61 +957,57 @@ describe("FormItemTable Component", () => { }); describe("Notes Functionality", () => { - it("renders edit/notes button for all items", () => { - const { container } = render( + it("renders inline notes field for each item", () => { + render( ); - const editButtons = container.querySelectorAll( - "[data-testid=\"EditIcon\"]" - ); - expect(editButtons.length).toBe(TWO_ITEMS); + const notesLabels = screen.getAllByText("sponsor_edit_form.notes"); + expect(notesLabels.length).toBeGreaterThan(0); }); - it("calls onNotesClick with correct item when notes button is clicked", () => { + it("clicking details button expands first item row", () => { const { container } = render( ); - const editButtons = container.querySelectorAll( - "[data-testid=\"EditIcon\"]" + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" ); - fireEvent.click(editButtons[0].parentElement); + fireEvent.click(infoIcons[0].parentElement); - expect(mockOnNotesClick).toHaveBeenCalledWith(MOCK_FORM_A.items[0]); - expect(mockOnNotesClick).toHaveBeenCalledTimes(1); + const arrowUpIcons = container.querySelectorAll( + "[data-testid=\"KeyboardArrowUpIcon\"]" + ); + expect(arrowUpIcons.length).toBe(1); }); - it("calls onNotesClick for second item independently", () => { + it("clicking details button expands second item row independently", () => { const { container } = render( ); - const editButtons = container.querySelectorAll( - "[data-testid=\"EditIcon\"]" + const infoIcons = container.querySelectorAll( + "[data-testid=\"InfoOutlinedIcon\"]" ); - fireEvent.click(editButtons[1].parentElement); + fireEvent.click(infoIcons[1].parentElement); - expect(mockOnNotesClick).toHaveBeenCalledWith(MOCK_FORM_A.items[1]); - expect(mockOnNotesClick).toHaveBeenCalledTimes(1); + const arrowUpIcons = container.querySelectorAll( + "[data-testid=\"KeyboardArrowUpIcon\"]" + ); + expect(arrowUpIcons.length).toBe(1); }); }); diff --git a/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js b/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js index d3b26d71..29d1953a 100644 --- a/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js +++ b/src/components/mui/FormItemTable/__tests__/GlobalQuantityField.test.js @@ -64,6 +64,20 @@ describe("GlobalQuantityField", () => { expect(input).not.toHaveAttribute("readonly"); }); + test("input is not readOnly when the row has an Item-class Quantity field but extraColumns has no Form-class Quantity field", () => { + // Item-class Quantity metafields are per-row data entry fields, unrelated + // to the row's global quantity — only a Form-class Quantity column + // (extraColumns) may drive/lock the global quantity field. + const rowWithItemLevelQuantity = { + ...row, + meta_fields: [{ type_id: 1, class_field: "Item", type: "Quantity" }] + }; + renderField({ row: rowWithItemLevelQuantity, extraColumns: [] }); + const input = screen.getByRole("spinbutton"); + expect(input).not.toHaveAttribute("readonly"); + expect(input).not.toBeDisabled(); + }); + test("clamps value to quantity_limit_per_sponsor when user types above it", async () => { const onSubmit = jest.fn(); renderField({}, onSubmit); diff --git a/src/components/mui/FormItemTable/__tests__/helpers.test.js b/src/components/mui/FormItemTable/__tests__/helpers.test.js index fc3ad820..3d80e9de 100644 --- a/src/components/mui/FormItemTable/__tests__/helpers.test.js +++ b/src/components/mui/FormItemTable/__tests__/helpers.test.js @@ -22,13 +22,15 @@ jest.mock("../../../../utils/methods", () => ({ })); jest.mock("../../../../utils/constants", () => ({ - MILLISECONDS_IN_SECOND: 1000 + MILLISECONDS_IN_SECOND: 1000, + SPONSOR_FORMS_METAFIELD_CLASS: { FORM: "Form", ITEM: "Item" } })); import { epochToMomentTimeZone } from "../../../../utils/methods"; import { getCurrentApplicableRate, - isItemAvailable + isItemAvailable, + hasDrivingQuantityField } from "../helpers"; describe("isItemAvailable", () => { @@ -53,6 +55,19 @@ describe("isItemAvailable", () => { }); }); +describe("hasDrivingQuantityField", () => { + test("returns false when extraColumns has no Quantity field", () => { + expect(hasDrivingQuantityField([])).toBe(false); + }); + + test("returns true when a Form-class Quantity field exists in extraColumns", () => { + const extraColumns = [ + { type_id: 1, class_field: "Form", type: "Quantity" } + ]; + expect(hasDrivingQuantityField(extraColumns)).toBe(true); + }); +}); + describe("getCurrentApplicableRate", () => { beforeEach(() => jest.clearAllMocks()); diff --git a/src/components/mui/FormItemTable/components/ExpandedRowContent.js b/src/components/mui/FormItemTable/components/ExpandedRowContent.js new file mode 100644 index 00000000..2ec44002 --- /dev/null +++ b/src/components/mui/FormItemTable/components/ExpandedRowContent.js @@ -0,0 +1,78 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { Box, Grid2, TextField } from "@mui/material"; +import { useField } from "formik"; +import T from "i18n-react/dist/i18n-react"; +import { SPONSOR_FORMS_METAFIELD_CLASS } from "../../../../utils/constants"; +import ItemTableField from "./ItemTableField"; + +const InlineNotesField = ({ rowId, disabled }) => { + const name = `i-${rowId}-c-global-f-notes`; + const [field] = useField(name); + return ( + + ); +}; + +const ExpandedRowContent = ({ row, extraColumns, timeZone, disabled }) => { + const itemFields = (row.meta_fields ?? []).filter( + (f) => f.class_field === SPONSOR_FORMS_METAFIELD_CLASS.ITEM + ); + + return ( + + + {extraColumns.map((exc) => ( + + + + ))} + {itemFields.map((f) => ( + + + + ))} + + + + + + ); +}; + +export default ExpandedRowContent; diff --git a/src/components/mui/FormItemTable/components/GlobalQuantityField.js b/src/components/mui/FormItemTable/components/GlobalQuantityField.js index 831969e8..140bc90e 100644 --- a/src/components/mui/FormItemTable/components/GlobalQuantityField.js +++ b/src/components/mui/FormItemTable/components/GlobalQuantityField.js @@ -14,6 +14,7 @@ import React, { useEffect } from "react"; import { useField } from "formik"; import MuiFormikTextField from "../../formik-inputs/mui-formik-textfield"; +import { hasDrivingQuantityField } from "../helpers"; const GlobalQuantityField = ({ row, @@ -26,12 +27,10 @@ const GlobalQuantityField = ({ 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; + const isReadOnly = hasDrivingQuantityField(extraColumns); useEffect(() => { helpers.setValue(value); - helpers.setTouched(true); }, [value]); const handleChange = (e) => { diff --git a/src/components/mui/FormItemTable/components/ItemTableField.js b/src/components/mui/FormItemTable/components/ItemTableField.js index 69cd5f40..00decbee 100644 --- a/src/components/mui/FormItemTable/components/ItemTableField.js +++ b/src/components/mui/FormItemTable/components/ItemTableField.js @@ -28,7 +28,8 @@ const ItemTableField = ({ disabled = false }) => { const name = `i-${rowId}-c-${field.class_field}-f-${field.type_id}`; - const commonProps = { name, label, disabled }; + const required = field.is_required ?? false; + const commonProps = { name, label, disabled, required, slotProps: { inputLabel: { shrink: true } }, margin: "none" }; switch (field.type) { case "CheckBox": @@ -38,7 +39,7 @@ const ItemTableField = ({ ({ value: v.id, label: v.value }))} + options={field.values.map((v) => ({ value: String(v.id), label: v.value }))} /> ); case "RadioButtonList": @@ -46,13 +47,30 @@ const ItemTableField = ({ ({ value: v.id, label: v.value }))} + options={field.values.map((v) => ({ value: String(v.id), label: v.value }))} /> ); case "DateTime": - return ; + return ( + + ); case "Time": - return ; + return ( + + ); case "Quantity": return ( 0 @@ -75,7 +94,7 @@ const ItemTableField = ({ ({ value: v.id, label: v.value }))} + options={field.values.map((v) => ({ value: String(v.id), label: v.value }))} /> ); case "Text": diff --git a/src/components/mui/FormItemTable/helpers.js b/src/components/mui/FormItemTable/helpers.js index 03315030..a2f7873c 100644 --- a/src/components/mui/FormItemTable/helpers.js +++ b/src/components/mui/FormItemTable/helpers.js @@ -41,3 +41,11 @@ export const getCurrentApplicableRate = (timeZone, rateDates) => { export const isItemAvailable = (item, currentApplicableRate) => item.rates?.[currentApplicableRate] != null; + +// The global quantity for a row is driven (and therefore read-only/computed) +// when a Form-class metafield of type Quantity exists for it (extraColumns, +// shared across all rows). Item-class metafields are per-row data entry +// fields unrelated to the row's global quantity, even if one happens to be +// of type Quantity, so they must not affect this. +export const hasDrivingQuantityField = (extraColumns) => + extraColumns.some((exc) => exc.type === "Quantity"); diff --git a/src/components/mui/FormItemTable/index.js b/src/components/mui/FormItemTable/index.js index a1d46e7f..3e8aba60 100644 --- a/src/components/mui/FormItemTable/index.js +++ b/src/components/mui/FormItemTable/index.js @@ -11,8 +11,9 @@ * limitations under the License. * */ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Collapse, IconButton, MenuItem, Paper, @@ -23,31 +24,125 @@ import { TableHead, TableRow } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import SettingsIcon from "@mui/icons-material/Settings"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "../../../utils/money"; -import { DISCOUNT_TYPES, ONE_HUNDRED } from "../../../utils/constants"; +import { + DISCOUNT_TYPES, + ONE_HUNDRED, + SPONSOR_FORMS_METAFIELD_CLASS +} from "../../../utils/constants"; import GlobalQuantityField from "./components/GlobalQuantityField"; -import ItemTableField from "./components/ItemTableField"; import MuiFormikSelect from "../formik-inputs/mui-formik-select"; import MuiFormikPriceField from "../formik-inputs/mui-formik-pricefield"; import MuiFormikDiscountField from "../formik-inputs/mui-formik-discountfield"; -import UnderlyingAlertNote from "./components/UnderlyingAlertNote"; +import ExpandedRowContent from "./components/ExpandedRowContent"; +import { hasDrivingQuantityField, isItemAvailable } from "./helpers"; const FormItemTable = ({ data, currentApplicableRate, timeZone, values, - onNotesClick, - onSettingsClick + touched, + errors }) => { const valuesStr = JSON.stringify(values); - const extraColumns = - data[0]?.meta_fields?.filter((mf) => mf.class_field === "Form") || []; - const fixedColumns = 10; - const totalColumns = extraColumns.length + fixedColumns; + + const extraColumns = useMemo( + () => + data[0]?.meta_fields?.filter( + (mf) => mf.class_field === SPONSOR_FORMS_METAFIELD_CLASS.FORM + ) || [], + [data] + ); + + // Rows whose global qty is driven by a Form-level Quantity field only + // expose that field inside the expanded panel, so default them open — + // otherwise the field that determines pricing is hidden behind a click. + const [openRows, setOpenRows] = useState(() => + data.reduce((acc, row) => { + if (hasDrivingQuantityField(extraColumns)) acc[row.form_item_id] = true; + return acc; + }, {}) + ); + + // toggle, code, name, custom_rate, early_bird, standard, onsite, qty, total, details + const totalColumns = 10; + + // Rows that had a visible error the last time this effect ran. Used to + // only auto-expand on a fresh error occurrence, not on every validation + // cycle a persisting error is still part of — otherwise a row the user + // just collapsed gets forced back open on the next keystroke anywhere + // else in the form (errors/touched get new references on most + // validateOnChange cycles even when this row's own error didn't change). + const previousErrorRowsRef = useRef(new Set()); + + useEffect(() => { + if (!errors || Object.keys(errors).length === 0) { + previousErrorRowsRef.current = new Set(); + return; + } + + const currentErrorRows = new Set(); + const updates = {}; + data.forEach((row) => { + const itemFields = (row.meta_fields ?? []).filter( + (f) => f.class_field === SPONSOR_FORMS_METAFIELD_CLASS.ITEM + ); + const expandedKeys = new Set([ + ...extraColumns.map( + (exc) => + `i-${row.form_item_id}-c-${exc.class_field}-f-${exc.type_id}` + ), + ...itemFields.map( + (f) => `i-${row.form_item_id}-c-${f.class_field}-f-${f.type_id}` + ), + `i-${row.form_item_id}-c-global-f-notes` + ]); + const hasVisibleError = Object.keys(errors).some( + (key) => expandedKeys.has(key) && Boolean(touched?.[key]) + ); + if (hasVisibleError) { + currentErrorRows.add(row.form_item_id); + if (!previousErrorRowsRef.current.has(row.form_item_id)) { + updates[row.form_item_id] = true; + } + } + }); + previousErrorRowsRef.current = currentErrorRows; + + if (Object.keys(updates).length > 0) { + setOpenRows((prev) => ({ ...prev, ...updates })); + } + }, [data, extraColumns, errors, touched]); + + const toggleRow = (rowId) => { + setOpenRows((prev) => ({ ...prev, [rowId]: !prev[rowId] })); + }; + + const getDetailsIconColor = (row) => { + const hasIncomplete = (row.meta_fields ?? []) + .filter((mf) => mf.is_required) + .some((mf) => { + const val = + values[ + `i-${row.form_item_id}-c-${mf.class_field}-f-${mf.type_id}` + ]; + if (mf.type === "CheckBoxList") return !Array.isArray(val) || val.length === 0; + if (mf.type === "CheckBox") return val !== true; + return val === undefined || val === null || val === ""; + }); + if (hasIncomplete) return "error"; + + const prefix = `i-${row.form_item_id}-`; + const isTouched = Object.keys(touched ?? {}).some( + (key) => key.startsWith(prefix) && touched[key] + ); + return isTouched ? "success" : "warning"; + }; const calculateQuantity = useCallback( (row) => { @@ -83,23 +178,6 @@ const FormItemTable = ({ return qty * rate; }; - const hasItemFields = (row) => - row.meta_fields.filter((mf) => mf.class_field === "Item").length > 0; - - const itemFieldsIncomplete = (row) => { - const requiredFields = row.meta_fields.filter( - (mf) => mf.class_field === "Item" && mf.is_required - ); - const hasMissingFields = requiredFields.some((mf) => { - const value = values[`i-${row.form_item_id}-c-Item-f-${mf.type_id}`]; - if (mf.type === "CheckBoxList") return !Array.isArray(value) || value.length === 0; - if (mf.type === "CheckBox") return value !== true; - return value === undefined || value === null || value === ""; - }); - - return requiredFields.length > 0 && hasMissingFields; - }; - const formatRate = (rate) => { if (rate == null) return T.translate("general.n_a"); return currencyAmountFromCents(rate); @@ -110,24 +188,17 @@ const FormItemTable = ({ const discount = values.discount_type === DISCOUNT_TYPES.AMOUNT ? values.discount_amount - : subtotal * (values.discount_amount / ONE_HUNDRED / ONE_HUNDRED); // bps to fraction + : subtotal * (values.discount_amount / ONE_HUNDRED / ONE_HUNDRED); return subtotal - Math.round(discount); }, [data, valuesStr, currentApplicableRate]); - const handleEdit = (row) => { - onNotesClick(row); - }; - - const handleEditItemFields = (row) => { - onSettingsClick(row); - }; - return ( + {T.translate("sponsor_edit_form.code")} @@ -146,121 +217,124 @@ const FormItemTable = ({ {T.translate("sponsor_edit_form.onsite_rate")} - {extraColumns.map((exc) => ( - {exc.name} - ))} {T.translate("sponsor_edit_form.qty")} - - {/* item level extra field */} {T.translate("sponsor_edit_form.total")} - - {T.translate("sponsor_edit_form.notes")} + + {T.translate("sponsor_edit_form.details")} - {data.map((row) => ( - - {row.code} - -
{row.name}
- -
- - - - - {formatRate(row.rates.early_bird)} - - - {formatRate(row.rates.standard)} - - - {formatRate(row.rates.onsite)} - - {extraColumns.map((exc) => ( - - - - ))} - - - - - {hasItemFields(row) && ( - handleEditItemFields(row)} + {data.map((row) => { + const disabled = !isItemAvailable(row, currentApplicableRate); + const isOpen = !!openRows[row.form_item_id]; + + return ( + + + + toggleRow(row.form_item_id)} + > + {isOpen ? ( + + ) : ( + + )} + + + {row.code} + {row.name} + + + + + {formatRate(row.rates.early_bird)} + + - - - )} - - - {currencyAmountFromCents(calculateRowTotal(row))} - - - handleEdit(row)} - > - - - -
- ))} + {formatRate(row.rates.standard)} + + + {formatRate(row.rates.onsite)} + + + + + + {currencyAmountFromCents(calculateRowTotal(row))} + + + toggleRow(row.form_item_id)} + > + + + + + + + + + + + + + ); + })} {T.translate("sponsor_edit_form.discount")} {/* eslint-disable-next-line */} - {new Array(totalColumns - 5).fill(0).map((_, i) => ( + {new Array(totalColumns - 4).fill(0).map((_, i) => ( ))} - + {Object.values(DISCOUNT_TYPES).map((p) => ( {p} @@ -268,16 +342,15 @@ const FormItemTable = ({ ))} - @@ -309,3 +382,4 @@ export { getCurrentApplicableRate, isItemAvailable } from "./helpers"; export { default as GlobalQuantityField } from "./components/GlobalQuantityField"; export { default as ItemTableField } from "./components/ItemTableField"; export { default as UnderlyingAlertNote } from "./components/UnderlyingAlertNote"; +export { default as ExpandedRowContent } from "./components/ExpandedRowContent"; diff --git a/src/components/mui/formik-inputs/mui-formik-checkbox.js b/src/components/mui/formik-inputs/mui-formik-checkbox.js index 667e493a..f5181538 100644 --- a/src/components/mui/formik-inputs/mui-formik-checkbox.js +++ b/src/components/mui/formik-inputs/mui-formik-checkbox.js @@ -21,13 +21,13 @@ import { } from "@mui/material"; import { useField } from "formik"; -const MuiFormikCheckbox = ({ name, label, ...props }) => { +const MuiFormikCheckbox = ({ name, label, margin = "normal", ...props }) => { const [field, meta] = useField({ name, type: "checkbox" }); return ( { const [field, meta, helpers] = useField(name); - const requiredLabel = `${label} *`; + const displayLabel = required ? `${label} *` : label; return ( @@ -34,15 +35,6 @@ const MuiFormikDatepicker = ({ value={field.value} onChange={helpers.setValue} slotProps={{ - textField: { - name, - label: required ? requiredLabel : label, - error: meta.touched && Boolean(meta.error), - helperText: meta.touched && meta.error, - fullWidth: true, - disabled, - size: "small" - }, day: { sx: { fontSize: "1.2rem", @@ -55,6 +47,17 @@ const MuiFormikDatepicker = ({ fontSize: "1rem" } } + }, + ...externalSlotProps, + textField: { + name, + label: displayLabel, + error: meta.touched && Boolean(meta.error), + helperText: meta.touched && meta.error, + fullWidth: true, + disabled, + size: "small", + ...(externalSlotProps?.textField || {}) } }} margin="normal" diff --git a/src/components/mui/formik-inputs/mui-formik-select-v2.js b/src/components/mui/formik-inputs/mui-formik-select-v2.js index 27b3d6a0..ddcd85a4 100644 --- a/src/components/mui/formik-inputs/mui-formik-select-v2.js +++ b/src/components/mui/formik-inputs/mui-formik-select-v2.js @@ -10,14 +10,14 @@ import { } from "@mui/material"; import { useField } from "formik"; -const MuiFormikSelectV2 = ({ name, label, placeholder, options, ...rest }) => { +const MuiFormikSelectV2 = ({ name, label, placeholder, options, required, ...rest }) => { const [field, meta] = useField(name); const finalPlaceholder = placeholder || T.translate("placeholders.select"); return ( - - {label && {label}} + + {label && {label}}