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) => (
-
@@ -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}}