Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/forms/__tests__/selection-plan-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <div data-testid={name} />
Expand Down
5 changes: 3 additions & 2 deletions src/components/forms/selection-plan-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const EmailTemplatesTab = ({ hidden }) => {
</label>
<EmailTemplateInput
id="presentation_creator_notification_email_template"
name="presentation_creator_notification_email_template"
value={values.presentation_creator_notification_email_template}
placeholder={T.translate(
"edit_selection_plan.placeholders.creator_notification_email_select_template"
Expand All @@ -55,6 +56,7 @@ const EmailTemplatesTab = ({ hidden }) => {
</label>
<EmailTemplateInput
id="presentation_moderator_notification_email_template"
name="presentation_moderator_notification_email_template"
value={values.presentation_moderator_notification_email_template}
placeholder={T.translate(
"edit_selection_plan.placeholders.moderator_notification_email_select_template"
Expand All @@ -73,6 +75,7 @@ const EmailTemplatesTab = ({ hidden }) => {
</label>
<EmailTemplateInput
id="presentation_speaker_notification_email_template"
name="presentation_speaker_notification_email_template"
value={values.presentation_speaker_notification_email_template}
placeholder={T.translate(
"edit_selection_plan.placeholders.speaker_notification_email_select_template"
Expand Down
6 changes: 6 additions & 0 deletions src/components/forms/selection-plan-form/main-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const MainTab = ({ hidden, currentSummit }) => {
<label> {T.translate("edit_selection_plan.name")} *</label>
<TextField
id="name"
name="name"
fullWidth
size="small"
error={!!hasErrors("name")}
Expand All @@ -53,6 +54,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
<Checkbox
id="is_enabled"
name="is_enabled"
checked={values.is_enabled}
onChange={handleChange}
/>
Expand All @@ -65,6 +67,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
<Checkbox
id="is_hidden"
name="is_hidden"
checked={values.is_hidden}
onChange={handleChange}
/>
Expand All @@ -77,6 +80,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
<Checkbox
id="allow_proposed_schedules"
name="allow_proposed_schedules"
checked={values.allow_proposed_schedules}
onChange={handleChange}
/>
Expand All @@ -89,6 +93,7 @@ const MainTab = ({ hidden, currentSummit }) => {
control={
<Checkbox
id="allow_new_presentations"
name="allow_new_presentations"
checked={values.allow_new_presentations}
onChange={handleChange}
/>
Expand Down Expand Up @@ -124,6 +129,7 @@ const MainTab = ({ hidden, currentSummit }) => {
<label>{T.translate("edit_selection_plan.max_submissions")}</label>
<TextField
id="max_submission_allowed_per_user"
name="max_submission_allowed_per_user"
type="number"
fullWidth
size="small"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const TrackChairSettingsTab = ({
control={
<Checkbox
id="allow_track_change_requests"
name="allow_track_change_requests"
checked={values.allow_track_change_requests}
onChange={handleChange}
/>
Expand Down
131 changes: 131 additions & 0 deletions src/hooks/__tests__/useScrollToError.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// @testing-library/react 12 (React 16) does not export renderHook; use a
// lightweight component wrapper instead.
import "@testing-library/jest-dom";
import React, { useState } from "react";
import { act, render, screen } from "@testing-library/react";
import { useFormik } from "formik";
import useScrollToError from "../useScrollToError";

window.HTMLElement.prototype.scrollIntoView = jest.fn();

// jsdom implements no layout, so offsetParent is always null regardless of
// CSS. Reflect the one hiding mechanism this hook cares about (the `hidden`
// attribute) so tests can simulate real display:none semantics.
beforeAll(() => {
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 (
<form onSubmit={formik.handleSubmit}>
<div role="tabpanel" id="tabpanel-a" hidden={activeTab !== "a"}>
<input name="name" onChange={formik.handleChange} />
</div>
<div role="tabpanel" id="tabpanel-b" hidden={activeTab !== "b"} />
<button type="submit">save</button>
</form>
);
};

const VisibleHarness = () => {
const formik = useFormik({
initialValues: { name: "" },
validate: (values) => (values.name ? {} : { name: "required" }),
onSubmit: () => {}
});

useScrollToError(formik, true, jest.fn());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that visible errors do not activate a tab.

VisibleHarness passes an unreachable jest.fn() to the hook. The test only asserts scrolling. It still passes if the hook incorrectly calls setActiveTab for a visible field.

Pass a mock through VisibleHarness and assert that it was not called.

Also applies to: 112-120

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/__tests__/useScrollToError.test.js` at line 71, Update
VisibleHarness and its test cases for useScrollToError to accept and pass a
setActiveTab mock, then assert it was not called when the field error is visible
while retaining the existing scroll assertion.


return (
<form onSubmit={formik.handleSubmit}>
<input name="name" onChange={formik.handleChange} />
<button type="submit">save</button>
</form>
);
};

const UntaggedHarness = () => {
const formik = useFormik({
initialValues: { name: "" },
validate: (values) => (values.name ? {} : { name: "required" }),
onSubmit: () => {}
});

useScrollToError(formik, true);

return (
<form onSubmit={formik.handleSubmit}>
<input name="name" onChange={formik.handleChange} />
<button type="submit">save</button>
</form>
);
};

describe("useScrollToError (tab-aware)", () => {
it("switches to the owning tab and scrolls when the errored field is hidden", async () => {
const onActiveTabChange = jest.fn();
render(<TabbedHarness onActiveTabChange={onActiveTabChange} />);

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(<VisibleHarness />);

await act(async () => {
screen.getByText("save").click();
});

expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});

it("behaves as before when setActiveTab is not passed", async () => {
render(<UntaggedHarness />);

await act(async () => {
screen.getByText("save").click();
});

expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});
});
94 changes: 71 additions & 23 deletions src/hooks/useScrollToError.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,40 +27,88 @@ 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;

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 scrollToFirstVisible = () => {
const elementsSorted = errorArray
.reduce((result, error) => {
const element = document.querySelector(`[name='${error}']`);
if (!element) 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);
Comment on lines +48 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exclude hidden fields before selecting the scroll target.

When errors exist in both visible and hidden panels, allMatchesHidden is false. scrollToFirstVisible then includes hidden fields. A hidden field has a zero layout rect and can sort before the visible field. The hook then calls scrollIntoView on the hidden field without activating its tab.

Proposed fix
           const element = document.querySelector(`[name='${error}']`);
-          if (!element) return result;
+          if (!element || element.offsetParent === null) return result;

Add a mixed-tab regression test with one hidden error and one visible error. The hook must scroll to the visible field.

Also applies to: 94-96

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScrollToError.js` around lines 48 - 57, Update the error-element
collection in useScrollToError so hidden fields are excluded before sorting and
selecting the scroll target, while retaining visible fields and their existing
document-position ordering. Add a mixed-tab regression test covering one hidden
and one visible error, asserting the hook scrolls to the visible field without
activating the hidden field’s tab.


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-<value>"` 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;
}

setActiveTab(tabValue);
afterNextLayout(scrollToFirstVisible);
}, [isSubmitting]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/hooks/useScrollToError.js --view expanded

rg -n -C 4 'formik\.setErrors|useScrollToError\(' \
  src/hooks/useScrollToError.js \
  src/components/forms/selection-plan-form.js \
  src/pages/events/components/event-type-dialog.js \
  src/hooks/__tests__/useScrollToError.test.js

Repository: fntechgit/summit-admin

Length of output: 4491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n src/hooks/useScrollToError.js
printf '\n--- selection-plan-form context ---\n'
sed -n '90,130p' src/components/forms/selection-plan-form.js
printf '\n--- event-type-dialog context ---\n'
sed -n '145,180p' src/pages/events/components/event-type-dialog.js
printf '\n--- hook tests ---\n'
cat -n src/hooks/__tests__/useScrollToError.test.js

Repository: fntechgit/summit-admin

Length of output: 12045


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Formik declarations and dependency versions ---'
rg -n -C 3 '"formik"|setErrors|SET_ERRORS|isSubmitting' \
  package.json package-lock.json yarn.lock pnpm-lock.yaml \
  src --glob '!src/hooks/useScrollToError.js' \
  --glob '!src/hooks/__tests__/useScrollToError.test.js' \
  2>/dev/null || true

printf '%s\n' '--- selection-plan-form error inputs ---'
rg -n -C 6 'propsErrors|onSave|errors' src/components/forms/selection-plan-form.js

printf '%s\n' '--- event-type-dialog error inputs ---'
rg -n -C 6 'const .*errors|errors[ =:]|onSubmit|dispatch|save' \
  src/pages/events/components/event-type-dialog.js

printf '%s\n' '--- test configuration and existing external-error patterns ---'
rg -n -C 4 'setErrors|external.*error|server.*error|render\(|flushDoubleRaf|isSubmitting' \
  src --glob '*test*' --glob '*spec*' \
  package.json jest.config.* babel.config.* 2>/dev/null || true

Repository: fntechgit/summit-admin

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- package metadata ---'
python3 - <<'PY'
import json
from pathlib import Path

p = Path("package.json")
if p.exists():
    data = json.loads(p.read_text())
    print("formik:", data.get("dependencies", {}).get("formik") or data.get("devDependencies", {}).get("formik"))
PY
for f in package-lock.json yarn.lock pnpm-lock.yaml; do
  if [ -f "$f" ]; then
    printf '%s\n' "--- $f ---"
    rg -n -m 3 'formik(@|:|")' "$f" || true
  fi
done

printf '%s\n' '--- focused error-flow context ---'
sed -n '1,90p' src/components/forms/selection-plan-form.js
sed -n '1,90p' src/pages/events/components/event-type-dialog.js
rg -n -C 8 'propsErrors|errors[),]|errors[ =:]|setErrors|isSubmitting' \
  src/components/forms/selection-plan-form.js \
  src/pages/events/components/event-type-dialog.js

printf '%s\n' '--- static dependency invariant ---'
python3 - <<'PY'
import re
from pathlib import Path

hook = Path("src/hooks/useScrollToError.js").read_text()
effect = re.search(r'useEffect\(\(\) => \{(?P<body>.*?)\n\s*\},\s*\[(?P<deps>[^\]]*)\]\);', hook, re.S)
if not effect:
    raise SystemExit("hook effect not found")
body = effect.group("body")
deps = [x.strip() for x in effect.group("deps").split(",") if x.strip()]
print("effect dependencies:", deps)
for name in ("errors", "isValid", "isSubmitting", "errorArray", "errorCount"):
    print(f"{name}: body={bool(re.search(r'\\b' + name + r'\\b', body))}")
print("external setErrors call sites:")
for path in (
    "src/components/forms/selection-plan-form.js",
    "src/pages/events/components/event-type-dialog.js",
):
    text = Path(path).read_text()
    print(path, len(re.findall(r'formik\\.setErrors\\s*\\(', text)))
PY

Repository: fntechgit/summit-admin

Length of output: 16651


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- locked Formik version ---'
sed -n '6450,6475p' yarn.lock

printf '%s\n' '--- dependency invariant ---'
python3 - <<'PY'
import re
from pathlib import Path

hook = Path("src/hooks/useScrollToError.js").read_text()
effect = re.search(
    r'useEffect\(\(\) => \{(?P<body>.*?)\n\s*\},\s*\[(?P<deps>[^\]]*)\]\);',
    hook,
    re.S,
)
if not effect:
    raise SystemExit("hook effect not found")

body = effect.group("body")
deps = [item.strip() for item in effect.group("deps").split(",") if item.strip()]
print("effect dependencies:", deps)
for name in ("errors", "isValid", "isSubmitting", "errorArray", "errorCount"):
    present = bool(re.search(r"\b" + re.escape(name) + r"\b", body))
    print(f"{name}: body={present}")
for path in (
    "src/components/forms/selection-plan-form.js",
    "src/pages/events/components/event-type-dialog.js",
):
    text = Path(path).read_text()
    effect_deps = re.findall(
        r"(?:propsErrors|errors)\s*\]\s*\);", text
    )
    calls = len(re.findall(r"formik\.setErrors\s*\(", text))
    print(f"{path}: setErrors_calls={calls}, matching_effect_dependency_tokens={effect_deps}")
PY

Repository: fntechgit/summit-admin

Length of output: 1521


🌐 Web query:

Formik 2.4.6 source setErrors implementation isSubmitting reducer SET_ERRORS

💡 Result:

In Formik 2.4.6, the setErrors method and the SET_ERRORS reducer action are implemented as follows: setErrors Implementation: The setErrors function is a memoized callback provided by the Formik component (via useEventCallback or React.useCallback) that dispatches an action to the internal state reducer [1][2][3]. const setErrors = React.useCallback((errors: FormikErrors) => { dispatch({ type: 'SET_ERRORS', payload: errors }); }, []); SET_ERRORS Reducer Action: The internal formikReducer handles the SET_ERRORS action by updating the errors object in the state, but only if the new errors object is not deeply equal to the current errors object [1][4][3]. This optimization prevents unnecessary re-renders [5]. case 'SET_ERRORS': if (isEqual(state.errors, msg.payload)) { return state; } return {...state, errors: msg.payload }; isSubmitting State: The isSubmitting state is independent of setErrors. It is managed via its own reducer action, SET_ISSUBMITTING [1][4][3]. Formik automatically sets isSubmitting to true when a submission is attempted and ensures it is set back to false once the submission handler (if it returns a promise) resolves or rejects [6][7]. If the submission handler is synchronous, you must manually call setSubmitting(false) to reset this state [6][7]. Top results: [1], [4], [7], [3]

Citations:


Re-run scrolling when Formik errors change.

Add errors (or another deliberate error trigger) to the effect dependencies. formik.setErrors updates errors without changing isSubmitting, so externally applied errors can remain hidden in inactive panels. Add a regression test for errors injected after submission settles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScrollToError.js` at line 112, Update the effect dependency
array in useScrollToError to include Formik errors or another deliberate trigger
that changes whenever errors are externally applied, while preserving the
existing isSubmitting behavior. Add a regression test covering errors injected
after submission settles and verify scrolling still reaches errors in inactive
panels.

};

Expand Down
20 changes: 20 additions & 0 deletions src/pages/events/components/__tests__/event-type-dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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" });

Expand Down
Loading
Loading