diff --git a/src/app/base/components/FormikFormContent/FormikFormContent.test.tsx b/src/app/base/components/FormikFormContent/FormikFormContent.test.tsx index e4b9a97d16..00efb5cbcb 100644 --- a/src/app/base/components/FormikFormContent/FormikFormContent.test.tsx +++ b/src/app/base/components/FormikFormContent/FormikFormContent.test.tsx @@ -2,6 +2,7 @@ import { Field, Formik } from "formik"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; import configureStore from "redux-mock-store"; +import type { Mock } from "vitest"; import * as Yup from "yup"; import FormikFormContent from "./FormikFormContent"; @@ -31,7 +32,12 @@ vi.mock("react-router-dom", async () => { describe("FormikFormContent", () => { let state: RootState; + let scrollIntoViewSpy: Mock; + beforeEach(() => { + scrollIntoViewSpy = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = scrollIntoViewSpy; + state = factory.rootState({ config: factory.configState({ items: [ @@ -101,6 +107,18 @@ describe("FormikFormContent", () => { expect(screen.getByText("Uh oh!")).toBeInTheDocument(); }); + it("scrolls non-field errors into view when present", () => { + renderWithBrowserRouter( + + Content + , + { state } + ); + + expect(screen.getByText("Uh oh!")).toBeInTheDocument(); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + }); + it("can display non-field errors from the __all__ key", () => { renderWithBrowserRouter( diff --git a/src/app/base/components/FormikFormContent/FormikFormContent.tsx b/src/app/base/components/FormikFormContent/FormikFormContent.tsx index a74d483df0..5c6b6ff6c9 100644 --- a/src/app/base/components/FormikFormContent/FormikFormContent.tsx +++ b/src/app/base/components/FormikFormContent/FormikFormContent.tsx @@ -1,5 +1,5 @@ import type { ReactNode, AriaAttributes } from "react"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useId, useRef } from "react"; import { ContentSection } from "@canonical/maas-react-components"; import { Form, Notification } from "@canonical/react-components"; @@ -110,6 +110,7 @@ const FormikFormContent = ({ allowUnchanged, }); const [hasSaved, resetHasSaved] = useCycled(saved); + const notificationId = useId(); const hasErrors = !!errors; // Run onValuesChanged function whenever formik values change. @@ -168,10 +169,18 @@ const FormikFormContent = ({ } }, [navigate, savedRedirect, saved]); + useEffect(() => { + if (nonFieldError) { + document + .getElementById(notificationId) + ?.scrollIntoView({ behavior: "smooth" }); + } + }, [nonFieldError, notificationId]); + const renderFormContent = () => ( <> {!!nonFieldError && ( - + {nonFieldError} )} diff --git a/src/setupTests.ts b/src/setupTests.ts index 1cf1e32c8f..785ee13a88 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -8,21 +8,26 @@ const fetchMocker = createFetchMock(vi); fetchMocker.enableMocks(); const originalObserver = window.ResizeObserver; +const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; beforeAll(() => { // disable act warnings global.IS_REACT_ACT_ENVIRONMENT = false; }); -// mock ResizeObserver for MainToolbar beforeEach(() => { + // mock ResizeObserver for MainToolbar window.ResizeObserver = vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), })); + + // mock scrollIntoView for FormikFormContent + window.HTMLElement.prototype.scrollIntoView = vi.fn(); }); afterEach(() => { window.ResizeObserver = originalObserver; + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; });