Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/form-invalid-scroll-to-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@object-ui/components": patch
---

fix(form): scroll and focus the first errored field on an invalid submit (#2793)

Submitting a form with a missing required field already toasts the offending field names (#2329), but in a long form the field itself stays off-screen, so the user still hunts for it. react-hook-form's native focus-on-error only reaches fields whose registered ref is a focusable native input — it silently no-ops for custom widgets (lookup / select / master-detail), which is exactly the reported case. The form renderer now disables RHF's unreliable `shouldFocusError` and, in its `onInvalid` handler, scrolls the first errored field (in visual/declared order) into view via its `data-field` wrapper and focuses a focusable control inside it — working for every field type.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* #2793: submitting a form with a missing required field must give visible
* feedback — a toast naming the fields (already shipped in #2329) AND scroll
* the first errored field into view + focus it. Before this, react-hook-form's
* native focus-on-error silently no-opped for custom widgets (lookup / select /
* master-detail), so on a long form the user clicked 创建 and saw nothing.
*/

import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { toast } from '../../../ui/sonner';

beforeAll(async () => {
await import('../../../renderers');
}, 30000);

let toastErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
// happy-dom doesn't implement scrollIntoView — install a no-op we can spy on.
if (!(Element.prototype as any).scrollIntoView) {
(Element.prototype as any).scrollIntoView = () => {};
}
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function renderForm(fields: any[], defaultValues?: Record<string, unknown>) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
fields,
...(defaultValues ? { defaultValues } : {}),
}}
/>,
);
}

const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));

describe('form renderer — invalid-submit feedback (#2793)', () => {
it('toasts and scrolls+focuses the first errored field on a missing required field', async () => {
const { container } = renderForm([
{ name: 'title', label: 'Title', type: 'input', required: true },
{ name: 'project', label: 'Project', type: 'input', required: true },
]);

const titleWrap = container.querySelector('[data-field="title"]') as HTMLElement;
const scrollSpy = vi.fn();
titleWrap.scrollIntoView = scrollSpy;

submit();

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
// Names the missing field(s).
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Title/);
// First errored field (visual order) scrolled into view + focused.
expect(scrollSpy).toHaveBeenCalled();
await waitFor(() =>
expect(titleWrap.contains(document.activeElement)).toBe(true),
);
});

it('scrolls the first errored field even when an earlier field is valid', async () => {
const { container } = renderForm(
[
{ name: 'title', label: 'Title', type: 'input', required: true },
{ name: 'project', label: 'Project', type: 'input', required: true },
],
{ title: 'Filled' },
);

const titleWrap = container.querySelector('[data-field="title"]') as HTMLElement;
const projectWrap = container.querySelector('[data-field="project"]') as HTMLElement;
const titleScroll = vi.fn();
const projectScroll = vi.fn();
titleWrap.scrollIntoView = titleScroll;
projectWrap.scrollIntoView = projectScroll;

submit();

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
// Only `project` is invalid → it, not the valid `title`, is scrolled to.
expect(projectScroll).toHaveBeenCalled();
expect(titleScroll).not.toHaveBeenCalled();
});

it('does not toast or scroll when the form is valid', async () => {
const { container } = renderForm(
[{ name: 'title', label: 'Title', type: 'input', required: true }],
{ title: 'Filled' },
);
const wrap = container.querySelector('[data-field="title"]') as HTMLElement;
const scrollSpy = vi.fn();
wrap.scrollIntoView = scrollSpy;

submit();

// Give any async validation a tick, then assert no error feedback fired.
await new Promise((r) => setTimeout(r, 50));
expect(toastErrorSpy).not.toHaveBeenCalled();
expect(scrollSpy).not.toHaveBeenCalled();
});
});
42 changes: 38 additions & 4 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,12 +313,21 @@ ComponentRegistry.register('form',
disabled = false,
} = schema;

// Initialize react-hook-form
// Initialize react-hook-form. `shouldFocusError: false` because RHF's
// native focus-on-error only works for fields whose registered ref is a
// focusable native input — it silently no-ops for custom widgets
// (lookup / select / master-detail), which is exactly the #2793 case. We
// own the scroll+focus explicitly in the onInvalid handler below so it
// works for every field type and follows visual order.
const form = useForm({
defaultValues,
mode: validationMode,
shouldFocusError: false,
});

// Scoped to this form so the error-scroll query never reaches a sibling form.
const formRef = React.useRef<HTMLFormElement>(null);

const [isSubmitting, setIsSubmitting] = React.useState(false);
const [submitError, setSubmitError] = React.useState<string | null>(null);

Expand Down Expand Up @@ -575,6 +584,30 @@ ComponentRegistry.register('form',
const MAX = 3;
const fieldsText = labels.slice(0, MAX).join('、') + (labels.length > MAX ? '…' : '');
toast.error(t('validation.formInvalid', { fields: fieldsText }));

// #2793: the toast alone still leaves the user hunting — in a long form
// the offending field is off-screen. Scroll the FIRST errored field into
// view (in declared/visual order, not RHF's error-key order) and focus a
// focusable control inside it. The field wrapper carries `data-field`
// (FormItem); works for custom widgets that RHF's own focus can't reach.
const errored = new Set(names);
const firstName =
(fields as FormFieldConfig[])
.map((f) => f?.name)
.find((n): n is string => Boolean(n) && errored.has(n as string)) ?? names[0];
const root: ParentNode = formRef.current ?? document;
const escaped =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(firstName)
: firstName.replace(/["\\]/g, '\\$&');
const target = root.querySelector<HTMLElement>(`[data-field="${escaped}"]`);
if (target) {
target.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
const focusable = target.querySelector<HTMLElement>(
'input:not([type="hidden"]), select, textarea, button, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]',
);
focusable?.focus?.({ preventScroll: true });
}
});

// Handle cancel
Expand Down Expand Up @@ -645,9 +678,10 @@ ComponentRegistry.register('form',

return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className={className}
<form
ref={formRef}
onSubmit={handleSubmit}
className={className}
{...formProps}
// Apply designer props
data-obj-id={dataObjId}
Expand Down
Loading