diff --git a/package.json b/package.json
index 5245ac019..eea9f3df2 100644
--- a/package.json
+++ b/package.json
@@ -93,7 +93,7 @@
"moment-duration-format": "^2.3.2",
"moment-timezone": "^0.5.33",
"mui-color-input": "^9.0.0",
- "openstack-uicore-foundation": "5.0.56",
+ "openstack-uicore-foundation": "5.0.60-beta.0",
"p-limit": "^6.1.0",
"path-browserify": "^1.0.1",
"postcss-loader": "^6.2.1",
diff --git a/src/components/forms/__tests__/selection-plan-form.test.js b/src/components/forms/__tests__/selection-plan-form.test.js
index 35bce9f58..305384928 100644
--- a/src/components/forms/__tests__/selection-plan-form.test.js
+++ b/src/components/forms/__tests__/selection-plan-form.test.js
@@ -61,6 +61,8 @@ jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({
queryEventTypes: jest.fn()
}));
+jest.mock("../../../hooks/useScrollToError", () => jest.fn());
+
jest.mock("../../mui/formik-inputs/mui-formik-datetimepicker", () => ({
__esModule: true,
default: ({ name }) =>
diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js
index 416062d9e..4ba555944 100644
--- a/src/components/forms/selection-plan-form.js
+++ b/src/components/forms/selection-plan-form.js
@@ -20,7 +20,7 @@ import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/met
import Box from "@mui/material/Box";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
-import { scrollToError } from "../../utils/methods";
+import useScrollToError from "../../hooks/useScrollToError";
import MainTab from "./selection-plan-form/main-tab";
import TrackGroupsTab from "./selection-plan-form/track-groups-tab";
import EventTypesTab from "./selection-plan-form/event-types-tab";
@@ -111,12 +111,13 @@ const SelectionPlanForm = (props) => {
});
useEffect(() => {
- scrollToError(propsErrors);
formik.setErrors(
propsErrors && Object.keys(propsErrors).length > 0 ? propsErrors : {}
);
}, [propsErrors]);
+ useScrollToError(formik, true, setActiveTab);
+
// Sync sub-resource arrays from Redux without resetting user-editable main tab fields
useEffect(() => {
formik.setValues((current) => ({
diff --git a/src/components/forms/selection-plan-form/cfp-settings-tab.js b/src/components/forms/selection-plan-form/cfp-settings-tab.js
index d8a5e6675..d06f28a34 100644
--- a/src/components/forms/selection-plan-form/cfp-settings-tab.js
+++ b/src/components/forms/selection-plan-form/cfp-settings-tab.js
@@ -95,6 +95,7 @@ const CfpSettingsTab = ({ hidden, currentSummit }) => {
{
{
{
{
{
{
control={
@@ -65,6 +67,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
@@ -77,6 +80,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
@@ -89,6 +93,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
@@ -124,6 +129,7 @@ const MainTab = ({ hidden, currentSummit }) => {
{
diff --git a/src/components/inputs/email-template-input.js b/src/components/inputs/email-template-input.js
index 87f4871bd..d0be2e9eb 100644
--- a/src/components/inputs/email-template-input.js
+++ b/src/components/inputs/email-template-input.js
@@ -9,7 +9,7 @@
* 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 AsyncSelect from "react-select/lib/Async";
@@ -67,7 +67,7 @@ export default class EmailTemplateInput extends React.Component {
}
render() {
- const { error, value, onChange, id, multi, plainValue, ...rest } =
+ const { error, value, onChange, id, name, multi, plainValue, ...rest } =
this.props;
const has_error = this.props.hasOwnProperty("error") && error !== "";
@@ -77,12 +77,12 @@ export default class EmailTemplateInput extends React.Component {
if (value) {
theValue = plainValue
- ? { value: value, label: value }
+ ? { value, label: value }
: { value: value.id.toString(), label: value.identifier };
}
return (
-
+
(
{
+ Object.defineProperty(window.HTMLElement.prototype, "offsetParent", {
+ configurable: true,
+ get() {
+ let node = this;
+ while (node) {
+ if (node.hidden) return null;
+ node = node.parentElement;
+ }
+ return document.body;
+ }
+ });
+});
+
+beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView.mockClear();
+});
+
+const flushDoubleRaf = () =>
+ act(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
+ })
+ );
+
+const TabbedHarness = ({ onActiveTabChange }) => {
+ const [activeTab, setActiveTab] = useState("b");
+ const formik = useFormik({
+ initialValues: { name: "" },
+ validate: (values) => (values.name ? {} : { name: "required" }),
+ onSubmit: () => {}
+ });
+
+ useScrollToError(formik, true, (value) => {
+ setActiveTab(value);
+ onActiveTabChange?.(value);
+ });
+
+ return (
+
+ );
+};
+
+const VisibleHarness = () => {
+ const formik = useFormik({
+ initialValues: { name: "" },
+ validate: (values) => (values.name ? {} : { name: "required" }),
+ onSubmit: () => {}
+ });
+
+ useScrollToError(formik, true, jest.fn());
+
+ return (
+
+ );
+};
+
+const MixedVisibilityHarness = ({ onActiveTabChange }) => {
+ const [activeTab, setActiveTab] = useState("b");
+ const formik = useFormik({
+ initialValues: { hiddenField: "", visibleField: "" },
+ validate: (values) => {
+ const errors = {};
+ if (!values.hiddenField) errors.hiddenField = "required";
+ if (!values.visibleField) errors.visibleField = "required";
+ return errors;
+ },
+ onSubmit: () => {}
+ });
+
+ useScrollToError(formik, true, (value) => {
+ setActiveTab(value);
+ onActiveTabChange?.(value);
+ });
+
+ return (
+
+ );
+};
+
+const ExternalErrorHarness = ({ serverErrors, onActiveTabChange }) => {
+ const [activeTab, setActiveTab] = useState("b");
+ const formik = useFormik({
+ initialValues: { name: "ok" },
+ validate: () => ({}),
+ onSubmit: () => Promise.resolve()
+ });
+
+ // Mirrors how consumers sync server-side errors into Formik independently
+ // of the submit lifecycle (e.g. event-type-dialog.js syncing Redux errors).
+ React.useEffect(() => {
+ if (serverErrors) formik.setErrors(serverErrors);
+ }, [serverErrors]);
+
+ useScrollToError(formik, true, (value) => {
+ setActiveTab(value);
+ onActiveTabChange?.(value);
+ });
+
+ return (
+
+ );
+};
+
+const UntaggedHarness = () => {
+ const formik = useFormik({
+ initialValues: { name: "" },
+ validate: (values) => (values.name ? {} : { name: "required" }),
+ onSubmit: () => {}
+ });
+
+ useScrollToError(formik, true);
+
+ return (
+
+ );
+};
+
+describe("useScrollToError (tab-aware)", () => {
+ it("switches to the owning tab and scrolls when the errored field is hidden", async () => {
+ const onActiveTabChange = jest.fn();
+ render();
+
+ await act(async () => {
+ screen.getByText("save").click();
+ });
+ await flushDoubleRaf();
+
+ expect(onActiveTabChange).toHaveBeenCalledWith("a");
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+
+ it("does not switch tabs when the errored field is already visible", async () => {
+ render();
+
+ await act(async () => {
+ screen.getByText("save").click();
+ });
+
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+
+ it("scrolls to the visible field without activating the hidden field's tab when errors span both", async () => {
+ const onActiveTabChange = jest.fn();
+ render();
+
+ await act(async () => {
+ screen.getByText("save").click();
+ });
+
+ const visibleInput = document.querySelector("[name='visibleField']");
+
+ expect(onActiveTabChange).not.toHaveBeenCalled();
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ expect(window.HTMLElement.prototype.scrollIntoView.mock.instances[0]).toBe(
+ visibleInput
+ );
+ });
+
+ it("scrolls to errors injected after submission settles, even into an inactive panel", async () => {
+ const onActiveTabChange = jest.fn();
+ const { rerender } = render(
+
+ );
+
+ await act(async () => {
+ screen.getByText("save").click();
+ });
+ expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+
+ await act(async () => {
+ rerender(
+
+ );
+ });
+ await flushDoubleRaf();
+
+ expect(onActiveTabChange).toHaveBeenCalledWith("a");
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+
+ it("behaves as before when setActiveTab is not passed", async () => {
+ render();
+
+ await act(async () => {
+ screen.getByText("save").click();
+ });
+
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/useScrollToError.js b/src/hooks/useScrollToError.js
index 7579e47a8..f25f67634 100644
--- a/src/hooks/useScrollToError.js
+++ b/src/hooks/useScrollToError.js
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
// Smooth scroll for window
function smoothScrollTo(targetScrollTop, duration) {
@@ -27,41 +27,102 @@ function smoothScrollTo(targetScrollTop, duration) {
requestAnimationFrame(animationStep);
}
-const useScrollToError = (formik, relative = false) => {
+// Waits two animation frames so a DOM mutation applied just before this call
+// (e.g. React removing a tabpanel's `hidden` attribute after setActiveTab)
+// has been through layout before we measure/scroll against it.
+function afterNextLayout(callback) {
+ requestAnimationFrame(() => requestAnimationFrame(callback));
+}
+
+const useScrollToError = (formik, relative = false, setActiveTab) => {
const { errors, isValid, isSubmitting } = formik;
const errorArray = Object.keys(errors);
const errorCount = errorArray.length;
- useEffect(() => {
- if (isValid || errorCount === 0) return;
-
- const elementsSorted = errorArray
- .reduce((result, error) => {
- const element = document.querySelector(`[name='${error}']`);
- if (!element) return result;
+ // Prior state, so we can tell "errors just appeared" from "still correcting the same ones".
+ const prevIsSubmittingRef = useRef(isSubmitting);
+ const prevHadErrorsRef = useRef(errorCount > 0);
- const rect = element.getBoundingClientRect();
- const absoluteTop = rect.top + window.pageYOffset;
+ useEffect(() => {
+ const prevIsSubmitting = prevIsSubmittingRef.current;
+ const prevHadErrors = prevHadErrorsRef.current;
+ prevIsSubmittingRef.current = isSubmitting;
+ prevHadErrorsRef.current = errorCount > 0;
- result.push({ element, top: absoluteTop });
- return result;
- }, [])
- .sort((a, b) => a.top - b.top);
+ if (isValid || errorCount === 0) return;
- if (elementsSorted.length === 0) return;
+ const submitJustSettled = prevIsSubmitting && !isSubmitting;
+ const errorsJustAppeared = !isSubmitting && !prevHadErrors;
+ if (!submitJustSettled && !errorsJustAppeared) return;
+
+ const scrollToFirstVisible = () => {
+ const elementsSorted = errorArray
+ .reduce((result, error) => {
+ const element = document.querySelector(`[name='${error}']`);
+ if (!element || element.offsetParent === null) return result;
+
+ const rect = element.getBoundingClientRect();
+ const absoluteTop = rect.top + window.pageYOffset;
+
+ result.push({ element, top: absoluteTop });
+ return result;
+ }, [])
+ .sort((a, b) => a.top - b.top);
+
+ if (elementsSorted.length === 0) return;
+
+ const target = elementsSorted[0];
+
+ const offset = 100; // adjust as needed
+ const duration = 500; // 500ms scroll duration
+ const scrollToY = target.top - offset;
+
+ if (relative) {
+ target?.element.scrollIntoView({
+ behavior: "smooth",
+ block: "center"
+ });
+ } else {
+ smoothScrollTo(scrollToY, duration);
+ }
+ };
+
+ if (typeof setActiveTab !== "function") {
+ scrollToFirstVisible(); // unchanged path for every other call site
+ return;
+ }
- const target = elementsSorted[0];
+ // Tab-aware path: panels in a tabbed form are typically mounted-but-
+ // hidden, so `[name=...]` selectors still match fields on an inactive
+ // tab, but measuring/scrolling a `display:none` element is meaningless.
+ // `offsetParent` is `null` for any element hidden via `display:none`,
+ // including via an ancestor's `hidden` attribute.
+ const matches = errorArray
+ .map((error) => document.querySelector(`[name='${error}']`))
+ .filter(Boolean);
+
+ const allMatchesHidden =
+ matches.length > 0 && matches.every((el) => el.offsetParent === null);
+
+ if (!allMatchesHidden) {
+ scrollToFirstVisible();
+ return;
+ }
- const offset = 100; // adjust as needed
- const duration = 500; // 500ms scroll duration
- const scrollToY = target.top - offset;
+ // Every matched field is hidden: jump to the tab that owns the first
+ // errored field, then defer the scroll until it's actually visible.
+ // Tab panels are identified by the `id="tabpanel-"` convention.
+ const panelId = matches[0].closest("[role=\"tabpanel\"]")?.id;
+ const tabValue = panelId?.match(/^tabpanel-(.+)$/)?.[1];
- if (relative) {
- target?.element.scrollIntoView({ behavior: "smooth", block: "center" });
- } else {
- smoothScrollTo(scrollToY, duration);
+ if (!tabValue) {
+ scrollToFirstVisible(); // not inside a tagged tab panel, fall back
+ return;
}
- }, [isSubmitting]);
+
+ setActiveTab(tabValue);
+ afterNextLayout(scrollToFirstVisible);
+ }, [isSubmitting, errorCount]);
};
export default useScrollToError;
diff --git a/src/pages/events/components/__tests__/event-type-dialog.test.js b/src/pages/events/components/__tests__/event-type-dialog.test.js
index 9edb6eb01..c6a956023 100644
--- a/src/pages/events/components/__tests__/event-type-dialog.test.js
+++ b/src/pages/events/components/__tests__/event-type-dialog.test.js
@@ -8,6 +8,7 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import EventTypeDialog from "../event-type-dialog";
+import useScrollToError from "../../../../hooks/useScrollToError";
jest.mock("i18n-react/dist/i18n-react", () => ({
translate: jest.fn((key) => key)
@@ -149,6 +150,25 @@ describe("EventTypeDialog", () => {
expect(screen.getByTestId("textfield-name")).toBeInTheDocument();
});
+ it("wires setActiveTab into useScrollToError for tab-aware error scrolling", () => {
+ renderDialog();
+
+ expect(useScrollToError).toHaveBeenCalledWith(
+ expect.anything(),
+ true,
+ expect.any(Function)
+ );
+ });
+
+ it("tags both tabpanels with their owning tab value", () => {
+ renderDialog();
+
+ expect(document.getElementById("tabpanel-main")).toBeInTheDocument();
+ expect(
+ document.getElementById("tabpanel-schedule_settings")
+ ).toBeInTheDocument();
+ });
+
it("disables the class_name select once the entity has an id", () => {
renderDialog({ ...BASE_ENTITY, id: 5, class_name: "EVENT_TYPE" });
diff --git a/src/pages/events/components/event-type-dialog.js b/src/pages/events/components/event-type-dialog.js
index 8850809db..f2f15352c 100644
--- a/src/pages/events/components/event-type-dialog.js
+++ b/src/pages/events/components/event-type-dialog.js
@@ -158,7 +158,7 @@ const EventTypeDialog = ({
const { values, setFieldValue } = formik;
- useScrollToError(formik, true);
+ useScrollToError(formik, true, setActiveTab);
useEffect(() => {
const errorFields = Object.keys(errors || {});
@@ -248,7 +248,11 @@ const EventTypeDialog = ({
-
+
@@ -600,7 +604,11 @@ const EventTypeDialog = ({
)}
-