From 7486dbef342ff2a65255cd0eb383c1e19b27ad41 Mon Sep 17 00:00:00 2001 From: Tale Agent Date: Tue, 23 Jun 2026 13:29:58 +0000 Subject: [PATCH 1/2] fix(platform): unify form validation timing across all forms (#1943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forms validated on every keystroke (mode: 'onChange'), so a field showed an error on its first character before the user finished typing. Others used no mode (RHF's 'onSubmit' default), so timing was inconsistent app-wide. - Add a shared useForm wrapper (app/components/ui/forms/use-form.ts) that defaults mode to 'onTouched': validate after the first blur, then re-validate on change. Callers can still override mode explicitly. - Repoint every direct react-hook-form useForm call and useFormEditor to the wrapper and drop the ad-hoc mode: 'onChange' overrides. - Add an oxlint no-restricted-imports guard so future forms can't import useForm straight from react-hook-form and regress the timing. - Update component tests that asserted on-keystroke errors to blur first, and add a wrapper unit test covering the no-error-on-first-keystroke behaviour. Docs/i18n: N/A — no user-facing strings, message keys, or error wording changed; only validation timing. --- services/platform/.oxlintrc.json | 20 +++++- .../components/ui/editor/use-form-editor.ts | 4 +- .../app/components/ui/forms/input.test.tsx | 2 +- .../app/components/ui/forms/use-form.test.tsx | 61 +++++++++++++++++++ .../app/components/ui/forms/use-form.ts | 36 +++++++++++ .../components/agent-create-dialog.test.tsx | 9 ++- .../agents/components/agent-create-dialog.tsx | 3 +- .../components/automation-create-dialog.tsx | 3 +- .../components/automation-rename-dialog.tsx | 2 +- .../components/step-create-dialog.tsx | 2 +- .../components/schedule-create-dialog.tsx | 3 +- .../components/customer-edit-dialog.tsx | 2 +- .../components/customers-import-dialog.tsx | 3 +- .../components/create-folder-dialog.tsx | 2 +- .../components/knowledge-entry-add-dialog.tsx | 2 +- .../knowledge-entry-edit-dialog.tsx | 2 +- .../onboarding/steps/account-step.tsx | 3 +- .../components/product-create-dialog.tsx | 3 +- .../components/product-edit-dialog.tsx | 2 +- .../components/products-import-dialog.tsx | 3 +- .../components/project-create-dialog.tsx | 2 +- .../components/project-rename-dialog.tsx | 2 +- .../account/components/account-form.tsx | 4 +- .../components/api-key-create-dialog.tsx | 3 +- .../components/enterprise-sso-form.test.tsx | 6 +- .../data-subject-requests/cancel-dialog.tsx | 3 +- .../extend-deadline-dialog.tsx | 3 +- .../file-request-dialog.tsx | 3 +- .../legal-hold/close-matter-dialog.tsx | 3 +- .../legal-hold/place-hold-dialog.tsx | 3 +- .../legal-hold/reject-release-dialog.tsx | 3 +- .../legal-hold/request-release-dialog.tsx | 3 +- .../legal-hold/upsert-matter-dialog.tsx | 3 +- .../components/member-add-dialog.tsx | 3 +- .../components/member-edit-dialog.tsx | 4 +- .../components/provider-add-panel.tsx | 4 +- .../components/team-create-dialog.test.tsx | 22 +++++-- .../teams/components/team-create-dialog.tsx | 3 +- .../teams/components/team-edit-dialog.tsx | 3 +- .../vendors/components/vendor-edit-dialog.tsx | 2 +- .../components/vendors-import-dialog.tsx | 3 +- .../components/website-add-dialog.tsx | 2 +- .../components/website-edit-dialog.tsx | 2 +- services/platform/app/routes/_auth/log-in.tsx | 3 +- .../$id/automations/$amId/configuration.tsx | 6 +- .../app/routes/forced-change-password.$id.tsx | 3 +- 46 files changed, 192 insertions(+), 76 deletions(-) create mode 100644 services/platform/app/components/ui/forms/use-form.test.tsx create mode 100644 services/platform/app/components/ui/forms/use-form.ts diff --git a/services/platform/.oxlintrc.json b/services/platform/.oxlintrc.json index 7888802cd5..900827d300 100644 --- a/services/platform/.oxlintrc.json +++ b/services/platform/.oxlintrc.json @@ -3,9 +3,27 @@ "extends": ["../../.oxlintrc.json"], "rules": { "typescript/no-unsafe-type-assertion": "error", - "typescript/no-unnecessary-type-assertion": "error" + "typescript/no-unnecessary-type-assertion": "error", + "eslint/no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-hook-form", + "importNames": ["useForm"], + "message": "Import { useForm } from '@/app/components/ui/forms/use-form' so validation timing (mode: 'onTouched') stays consistent app-wide (#1943)." + } + ] + } + ] }, "overrides": [ + { + "files": ["app/components/ui/forms/use-form.ts"], + "rules": { + "eslint/no-restricted-imports": "off" + } + }, { "files": ["convex/**/*.ts"], "rules": { diff --git a/services/platform/app/components/ui/editor/use-form-editor.ts b/services/platform/app/components/ui/editor/use-form-editor.ts index 12fbc413a2..b5a94ff696 100644 --- a/services/platform/app/components/ui/editor/use-form-editor.ts +++ b/services/platform/app/components/ui/editor/use-form-editor.ts @@ -3,7 +3,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - useForm, type DefaultValues, type FieldValues, type Path, @@ -11,6 +10,7 @@ import { type UseFormReturn, } from 'react-hook-form'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { structuralEqual } from '@/lib/utils/structural-equal'; import type { EditorController } from './types'; @@ -71,7 +71,7 @@ export function useFormEditor({ ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- zodResolver returns Resolver; widen (zodResolver(schema) as unknown as Resolver) : undefined, - mode: 'onChange', + // `mode` defaults to `'onTouched'` via the shared `useForm` wrapper (#1943). }); const [hasRemoteUpdate, setHasRemoteUpdate] = useState(false); diff --git a/services/platform/app/components/ui/forms/input.test.tsx b/services/platform/app/components/ui/forms/input.test.tsx index 229ef9af5b..62a1e8517a 100644 --- a/services/platform/app/components/ui/forms/input.test.tsx +++ b/services/platform/app/components/ui/forms/input.test.tsx @@ -1,10 +1,10 @@ -import { useForm } from 'react-hook-form'; import { describe, it, expect, vi } from 'vitest'; import { checkAccessibility, expectFocusable } from '@/tests/utils/a11y'; import { render, screen, waitFor } from '@/tests/utils/render'; import { Input } from './input'; +import { useForm } from './use-form'; describe('Input', () => { describe('rendering', () => { diff --git a/services/platform/app/components/ui/forms/use-form.test.tsx b/services/platform/app/components/ui/forms/use-form.test.tsx new file mode 100644 index 0000000000..60d3523de0 --- /dev/null +++ b/services/platform/app/components/ui/forms/use-form.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { zodResolver } from '@hookform/resolvers/zod'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { useForm } from './use-form'; + +interface Form { + name: string; +} + +const schema = z.object({ name: z.string().min(1) }); + +describe('useForm (shared wrapper)', () => { + it("defaults the validation mode to 'onTouched' (#1943)", () => { + const { result } = renderHook(() => useForm
()); + // `_options.mode` is RHF's record of the resolved mode; the wrapper sets it. + // oxlint-disable-next-line typescript/no-explicit-any -- reading RHF internals for the assertion + expect((result.current.control as any)._options.mode).toBe('onTouched'); + }); + + it('lets a caller override the default mode explicitly', () => { + const { result } = renderHook(() => useForm({ mode: 'onChange' })); + // oxlint-disable-next-line typescript/no-explicit-any -- reading RHF internals for the assertion + expect((result.current.control as any)._options.mode).toBe('onChange'); + }); + + it('does not flag a field as errored on the first keystroke before blur', async () => { + const { result } = renderHook(() => + useForm({ + defaultValues: { name: '' }, + resolver: zodResolver(schema), + }), + ); + + // Register the field, then change it without ever blurring — exactly the + // "typing the first character" path from #1943. + act(() => { + result.current.register('name'); + }); + await act(async () => { + result.current.setValue('name', 'a'); + }); + expect(result.current.formState.errors.name).toBeUndefined(); + + // Clearing the value and blurring (touching) it surfaces the error. + await act(async () => { + result.current.setValue('name', ''); + }); + act(() => { + result.current.register('name').onBlur({ + target: { name: 'name', value: '' }, + type: 'blur', + }); + }); + await waitFor(() => + expect(result.current.formState.errors.name).toBeDefined(), + ); + }); +}); diff --git a/services/platform/app/components/ui/forms/use-form.ts b/services/platform/app/components/ui/forms/use-form.ts new file mode 100644 index 0000000000..7cfc9b137c --- /dev/null +++ b/services/platform/app/components/ui/forms/use-form.ts @@ -0,0 +1,36 @@ +'use client'; + +import { + useForm as useReactHookForm, + type FieldValues, + type UseFormProps, + type UseFormReturn, +} from 'react-hook-form'; + +/** + * App-wide `useForm` wrapper. Defaults the validation `mode` to `'onTouched'` + * so a field is only validated after its first blur (and re-validated on every + * change thereafter), instead of erroring on the very first keystroke. + * + * Every form in the platform app — settings, dialogs, onboarding, chat — must + * import `useForm` from here rather than from `react-hook-form` directly, so + * validation timing stays identical across the app and can't silently regress + * to `react-hook-form`'s `'onSubmit'` default or an ad-hoc `'onChange'` + * (see #1943). The `no-restricted-imports` lint rule enforces this. + * + * A caller can still override `mode` explicitly when a form genuinely needs + * different timing — passing `mode` simply wins over the default below. + */ +export function useForm< + TFieldValues extends FieldValues = FieldValues, + // oxlint-disable-next-line typescript/no-explicit-any -- mirrors react-hook-form's own `useForm` signature + TContext = any, + TTransformedValues = TFieldValues, +>( + props?: UseFormProps, +): UseFormReturn { + return useReactHookForm({ + mode: 'onTouched', + ...props, + }); +} diff --git a/services/platform/app/features/agents/components/agent-create-dialog.test.tsx b/services/platform/app/features/agents/components/agent-create-dialog.test.tsx index 8592e9ccb7..823fc55ebd 100644 --- a/services/platform/app/features/agents/components/agent-create-dialog.test.tsx +++ b/services/platform/app/features/agents/components/agent-create-dialog.test.tsx @@ -185,8 +185,10 @@ describe('CreateAgentDialog', () => { // Migrated from tests/e2e/specs/validation.spec.ts — // "rejects an invalid slug and an empty name; cancels without creating". - // Pure client-side RHF + zod (mode: 'onChange'); the seeded mock provider - // supplies a model so the slug/name are the only things gating Continue. + // Pure client-side RHF + zod (shared `mode: 'onTouched'` default, #1943); the + // seeded mock provider supplies a model so the slug/name are the only things + // gating Continue. Each `user.type` of the next field blurs the previous one, + // marking it touched so its error renders. describe('slug + required validation gating (migrated from e2e)', () => { it('rejects an invalid slug and an empty name; cancels without creating', async () => { const onOpenChange = vi.fn(); @@ -207,7 +209,8 @@ describe('CreateAgentDialog', () => { }); // (a) Invalid slug + valid display name → Continue stays DISABLED and the - // pattern error renders (mode: 'onChange'). + // pattern error renders once the slug field is blurred (typing into the + // display name moves focus, marking the slug touched under `onTouched`). await user.type(slugField, 'Bad Slug!'); await user.type(displayNameField, 'Valid Display Name'); await waitFor(() => diff --git a/services/platform/app/features/agents/components/agent-create-dialog.tsx b/services/platform/app/features/agents/components/agent-create-dialog.tsx index 298fc9ee23..54d19bd863 100644 --- a/services/platform/app/features/agents/components/agent-create-dialog.tsx +++ b/services/platform/app/features/agents/components/agent-create-dialog.tsx @@ -7,13 +7,13 @@ import { Text } from '@tale/ui/text'; import { Link, useNavigate } from '@tanstack/react-router'; import { ConvexError } from 'convex/values'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod/v4'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { ModelSelector } from '@/app/components/ui/forms/model-selector'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { ModelInfoPopover } from '@/app/features/chat/components/model-info-popover'; import { useListProviders, @@ -215,7 +215,6 @@ export function CreateAgentDialog({ resolver: zodResolver(formSchema), // Validate on change so the Continue button can gate on validity // (required fields filled) instead of only after a submit attempt. - mode: 'onChange', defaultValues: { name: '', displayName: '', diff --git a/services/platform/app/features/automations/components/automation-create-dialog.tsx b/services/platform/app/features/automations/components/automation-create-dialog.tsx index f3866dbed7..324b6dc350 100644 --- a/services/platform/app/features/automations/components/automation-create-dialog.tsx +++ b/services/platform/app/features/automations/components/automation-create-dialog.tsx @@ -6,12 +6,12 @@ import { Stack } from '@tale/ui/layout'; import { Tabs } from '@tale/ui/tabs'; import { useNavigate } from '@tanstack/react-router'; import { useMemo, useState, useCallback } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { Dialog } from '@/app/components/ui/dialog/dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { convexErrorCode } from '@/lib/utils/convex-error'; @@ -98,7 +98,6 @@ function BlankTabContent({ resolver: zodResolver(formSchema), // Validate on change so Continue stays disabled until the required // name is filled, instead of only failing after a submit attempt. - mode: 'onChange', }); const onSubmit = useCallback( diff --git a/services/platform/app/features/automations/components/automation-rename-dialog.tsx b/services/platform/app/features/automations/components/automation-rename-dialog.tsx index 454fd7b320..b8a814b40f 100644 --- a/services/platform/app/features/automations/components/automation-rename-dialog.tsx +++ b/services/platform/app/features/automations/components/automation-rename-dialog.tsx @@ -2,11 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { convexErrorCode } from '@/lib/utils/convex-error'; diff --git a/services/platform/app/features/automations/components/step-create-dialog.tsx b/services/platform/app/features/automations/components/step-create-dialog.tsx index 10a1747e4e..3d58db4904 100644 --- a/services/platform/app/features/automations/components/step-create-dialog.tsx +++ b/services/platform/app/features/automations/components/step-create-dialog.tsx @@ -2,7 +2,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; @@ -10,6 +9,7 @@ import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { JsonInput } from '@/app/components/ui/forms/json-input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { narrowStringUnion } from '@/lib/utils/type-utils'; diff --git a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx index 6a94ec9ab6..68fc54e274 100644 --- a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx @@ -8,13 +8,13 @@ import { Text } from '@tale/ui/text'; import { CronExpressionParser } from 'cron-parser'; import { Sparkles } from 'lucide-react'; import { useMemo, useEffect, useCallback, useState } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { JsonInput } from '@/app/components/ui/forms/json-input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import { toId } from '@/convex/lib/type_cast_helpers'; import { useT } from '@/lib/i18n/client'; @@ -125,7 +125,6 @@ export function ScheduleCreateDialog({ resolver: zodResolver(schema), // Validate on change so Create stays disabled until the cron // expression is present and valid. - mode: 'onChange', defaultValues: { cronExpression: schedule?.cronExpression ?? '', }, diff --git a/services/platform/app/features/customers/components/customer-edit-dialog.tsx b/services/platform/app/features/customers/components/customer-edit-dialog.tsx index 53802391e6..d4fc2e5724 100644 --- a/services/platform/app/features/customers/components/customer-edit-dialog.tsx +++ b/services/platform/app/features/customers/components/customer-edit-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { Doc } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/customers/components/customers-import-dialog.tsx b/services/platform/app/features/customers/components/customers-import-dialog.tsx index c558b33a17..785f249754 100644 --- a/services/platform/app/features/customers/components/customers-import-dialog.tsx +++ b/services/platform/app/features/customers/components/customers-import-dialog.tsx @@ -2,10 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo, useCallback } from 'react'; -import { useForm, FormProvider } from 'react-hook-form'; +import { FormProvider } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { CONTACT_REQUIRED_COLUMNS, customerMappers, diff --git a/services/platform/app/features/documents/components/create-folder-dialog.tsx b/services/platform/app/features/documents/components/create-folder-dialog.tsx index d1f621f20d..df6f840ff4 100644 --- a/services/platform/app/features/documents/components/create-folder-dialog.tsx +++ b/services/platform/app/features/documents/components/create-folder-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useState, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; import { useToast } from '@/app/hooks/use-toast'; import { toId } from '@/convex/lib/type_cast_helpers'; diff --git a/services/platform/app/features/knowledge-entries/components/knowledge-entry-add-dialog.tsx b/services/platform/app/features/knowledge-entries/components/knowledge-entry-add-dialog.tsx index e6f1f2b95d..e27e6059b5 100644 --- a/services/platform/app/features/knowledge-entries/components/knowledge-entry-add-dialog.tsx +++ b/services/platform/app/features/knowledge-entries/components/knowledge-entry-add-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { CONTENT_MAX_LENGTH, diff --git a/services/platform/app/features/knowledge-entries/components/knowledge-entry-edit-dialog.tsx b/services/platform/app/features/knowledge-entries/components/knowledge-entry-edit-dialog.tsx index e577082227..5ad83b3b27 100644 --- a/services/platform/app/features/knowledge-entries/components/knowledge-entry-edit-dialog.tsx +++ b/services/platform/app/features/knowledge-entries/components/knowledge-entry-edit-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { CONTENT_MAX_LENGTH, diff --git a/services/platform/app/features/organization/components/onboarding/steps/account-step.tsx b/services/platform/app/features/organization/components/onboarding/steps/account-step.tsx index 40a06c93ef..ad01b1634d 100644 --- a/services/platform/app/features/organization/components/onboarding/steps/account-step.tsx +++ b/services/platform/app/features/organization/components/onboarding/steps/account-step.tsx @@ -5,12 +5,12 @@ import { Button } from '@tale/ui/button'; import { Stack } from '@tale/ui/layout'; import { Separator } from '@tale/ui/separator'; import { useCallback, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { MicrosoftIcon } from '@/app/components/icons/microsoft-icon'; import { ValidationCheckList } from '@/app/components/ui/feedback/validation-check-item'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { WizardStep } from '@/app/components/ui/wizard/wizard'; import { useIsSsoConfigured } from '@/app/features/auth/hooks/queries'; import { usePasswordValidation } from '@/app/hooks/use-password-validation'; @@ -62,7 +62,6 @@ export function AccountStep() { const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { email: '', password: '' }, }); diff --git a/services/platform/app/features/products/components/product-create-dialog.tsx b/services/platform/app/features/products/components/product-create-dialog.tsx index 21b10a035a..e0f1caf77d 100644 --- a/services/platform/app/features/products/components/product-create-dialog.tsx +++ b/services/platform/app/features/products/components/product-create-dialog.tsx @@ -4,13 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Grid, Row } from '@tale/ui/layout'; import { Text } from '@tale/ui/text'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { Dialog } from '@/app/components/ui/dialog/dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { type WizardStepMeta } from '@/app/components/ui/wizard/use-wizard'; import { Wizard, WizardStep } from '@/app/components/ui/wizard/wizard'; import { WizardFooter } from '@/app/components/ui/wizard/wizard-footer'; @@ -109,7 +109,6 @@ export function ProductCreateDialog({ formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), - mode: 'onChange', defaultValues: { name: '', description: '', diff --git a/services/platform/app/features/products/components/product-edit-dialog.tsx b/services/platform/app/features/products/components/product-edit-dialog.tsx index 8f136f58be..566483ce31 100644 --- a/services/platform/app/features/products/components/product-edit-dialog.tsx +++ b/services/platform/app/features/products/components/product-edit-dialog.tsx @@ -3,13 +3,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Grid } from '@tale/ui/layout'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/products/components/products-import-dialog.tsx b/services/platform/app/features/products/components/products-import-dialog.tsx index 3d4dea1689..24345cb3e3 100644 --- a/services/platform/app/features/products/components/products-import-dialog.tsx +++ b/services/platform/app/features/products/components/products-import-dialog.tsx @@ -2,10 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useCallback } from 'react'; -import { useForm, FormProvider } from 'react-hook-form'; +import { FormProvider } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useFileImport, productMappers, diff --git a/services/platform/app/features/projects/components/project-create-dialog.tsx b/services/platform/app/features/projects/components/project-create-dialog.tsx index a1f5ecf309..867bf00683 100644 --- a/services/platform/app/features/projects/components/project-create-dialog.tsx +++ b/services/platform/app/features/projects/components/project-create-dialog.tsx @@ -4,12 +4,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from '@tanstack/react-router'; import { ConvexError } from 'convex/values'; import { useEffect, useMemo, useRef } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod/v4'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/projects/components/project-rename-dialog.tsx b/services/platform/app/features/projects/components/project-rename-dialog.tsx index 283f90eaee..35c2fe5834 100644 --- a/services/platform/app/features/projects/components/project-rename-dialog.tsx +++ b/services/platform/app/features/projects/components/project-rename-dialog.tsx @@ -3,11 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ConvexError } from 'convex/values'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod/v4'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/settings/account/components/account-form.tsx b/services/platform/app/features/settings/account/components/account-form.tsx index 628b297b56..0c301a4872 100644 --- a/services/platform/app/features/settings/account/components/account-form.tsx +++ b/services/platform/app/features/settings/account/components/account-form.tsx @@ -6,7 +6,6 @@ import { Row } from '@tale/ui/layout'; import { SkeletonText } from '@tale/ui/skeleton'; import { Skeletonize, useSkeleton } from '@tale/ui/skeleton-context'; import { useCallback, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; @@ -18,6 +17,7 @@ import { ValidationCheckList } from '@/app/components/ui/feedback/validation-che import { Form } from '@/app/components/ui/forms/form'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useHasCredentialAccount } from '@/app/features/auth/hooks/queries'; import { SettingsPage } from '@/app/features/settings/components/settings-page'; import { SettingsRow } from '@/app/features/settings/components/settings-row'; @@ -299,7 +299,6 @@ function ChangePasswordDialog({ open, onOpenChange }: PasswordDialogProps) { watch, } = useForm({ resolver: zodResolver(changePasswordSchema), - mode: 'onChange', defaultValues: { currentPassword: '', newPassword: '', @@ -445,7 +444,6 @@ function SetPasswordDialog({ open, onOpenChange }: PasswordDialogProps) { watch, } = useForm({ resolver: zodResolver(setPasswordSchema), - mode: 'onChange', defaultValues: { newPassword: '', confirmPassword: '', diff --git a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx index 08d98d34ae..2780f8e1eb 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx @@ -5,13 +5,13 @@ import { Button } from '@tale/ui/button'; import { Text } from '@tale/ui/text'; import { Copy, Check } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; @@ -79,7 +79,6 @@ export function ApiKeyCreateDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { name: '', expiresIn: '2592000', diff --git a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.test.tsx b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.test.tsx index 4c23c43e58..07ceddadb6 100644 --- a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.test.tsx +++ b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.test.tsx @@ -230,11 +230,15 @@ describe('EnterpriseSsoForm validation + save', () => { const saveButton = await screen.findByRole('button', { name: /^save$/i }); + // `isValid` gates the Save button regardless of validation timing, so it + // disables as soon as the field is empty. await waitFor(() => { expect(saveButton).toBeDisabled(); }); - // The inline required error surfaces on the display-name field. + // Blur the field so the inline error surfaces (shared `mode: 'onTouched'` + // default, #1943 — the error does not render on the first keystroke). + await user.tab(); await waitFor(() => { const errors = screen.getAllByText(/this field is required/i); expect(errors.length).toBeGreaterThan(0); diff --git a/services/platform/app/features/settings/governance/data-subject-requests/cancel-dialog.tsx b/services/platform/app/features/settings/governance/data-subject-requests/cancel-dialog.tsx index a49517f66b..90f4db0591 100644 --- a/services/platform/app/features/settings/governance/data-subject-requests/cancel-dialog.tsx +++ b/services/platform/app/features/settings/governance/data-subject-requests/cancel-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; @@ -48,7 +48,6 @@ export function CancelDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { cancellationReason: '' }, }); const { handleSubmit, register, formState, reset } = form; diff --git a/services/platform/app/features/settings/governance/data-subject-requests/extend-deadline-dialog.tsx b/services/platform/app/features/settings/governance/data-subject-requests/extend-deadline-dialog.tsx index bcc503661f..a57ba14992 100644 --- a/services/platform/app/features/settings/governance/data-subject-requests/extend-deadline-dialog.tsx +++ b/services/platform/app/features/settings/governance/data-subject-requests/extend-deadline-dialog.tsx @@ -2,13 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; @@ -60,7 +60,6 @@ export function ExtendDeadlineDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { extraDays: 30, extensionReason: '', diff --git a/services/platform/app/features/settings/governance/data-subject-requests/file-request-dialog.tsx b/services/platform/app/features/settings/governance/data-subject-requests/file-request-dialog.tsx index 57871269a2..1b91d272a6 100644 --- a/services/platform/app/features/settings/governance/data-subject-requests/file-request-dialog.tsx +++ b/services/platform/app/features/settings/governance/data-subject-requests/file-request-dialog.tsx @@ -3,7 +3,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from '@tanstack/react-router'; import { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; @@ -12,6 +11,7 @@ import { Input } from '@/app/components/ui/forms/input'; import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; import { Select } from '@/app/components/ui/forms/select'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { @@ -98,7 +98,6 @@ export function FileRequestDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { targetUserId: '', reasonCode: 'no_longer_necessary', diff --git a/services/platform/app/features/settings/governance/legal-hold/close-matter-dialog.tsx b/services/platform/app/features/settings/governance/legal-hold/close-matter-dialog.tsx index 1be818b2c7..ea8f65b7b1 100644 --- a/services/platform/app/features/settings/governance/legal-hold/close-matter-dialog.tsx +++ b/services/platform/app/features/settings/governance/legal-hold/close-matter-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; @@ -45,7 +45,6 @@ export function CloseMatterDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { reason: '' }, }); const { register, handleSubmit, formState, reset } = form; diff --git a/services/platform/app/features/settings/governance/legal-hold/place-hold-dialog.tsx b/services/platform/app/features/settings/governance/legal-hold/place-hold-dialog.tsx index c1d6f7f2a7..4ac463e661 100644 --- a/services/platform/app/features/settings/governance/legal-hold/place-hold-dialog.tsx +++ b/services/platform/app/features/settings/governance/legal-hold/place-hold-dialog.tsx @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Row } from '@tale/ui/layout'; import { AlertTriangle } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; @@ -13,6 +12,7 @@ import { Input } from '@/app/components/ui/forms/input'; import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; import { Select } from '@/app/components/ui/forms/select'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; @@ -113,7 +113,6 @@ export function PlaceHoldDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { targetType: 'userMembership', targetId: '', diff --git a/services/platform/app/features/settings/governance/legal-hold/reject-release-dialog.tsx b/services/platform/app/features/settings/governance/legal-hold/reject-release-dialog.tsx index f6f51744e0..e402f6e4be 100644 --- a/services/platform/app/features/settings/governance/legal-hold/reject-release-dialog.tsx +++ b/services/platform/app/features/settings/governance/legal-hold/reject-release-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useOrganizationId } from '@/app/hooks/use-organization-id'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; @@ -50,7 +50,6 @@ export function RejectReleaseDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { reason: '' }, }); const { register, handleSubmit, formState, reset } = form; diff --git a/services/platform/app/features/settings/governance/legal-hold/request-release-dialog.tsx b/services/platform/app/features/settings/governance/legal-hold/request-release-dialog.tsx index 47cd9f4c04..2ceaa9f481 100644 --- a/services/platform/app/features/settings/governance/legal-hold/request-release-dialog.tsx +++ b/services/platform/app/features/settings/governance/legal-hold/request-release-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useOrganizationId } from '@/app/hooks/use-organization-id'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; @@ -50,7 +50,6 @@ export function RequestReleaseDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { reason: '' }, }); const { register, handleSubmit, formState, reset } = form; diff --git a/services/platform/app/features/settings/governance/legal-hold/upsert-matter-dialog.tsx b/services/platform/app/features/settings/governance/legal-hold/upsert-matter-dialog.tsx index f2d02dcd47..bc53c2f15d 100644 --- a/services/platform/app/features/settings/governance/legal-hold/upsert-matter-dialog.tsx +++ b/services/platform/app/features/settings/governance/legal-hold/upsert-matter-dialog.tsx @@ -2,13 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import type { Id } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; @@ -60,7 +60,6 @@ export function UpsertMatterDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { name: matter?.name ?? '', caseNumber: matter?.caseNumber ?? '', diff --git a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx index 1811c1372a..a7f24f7f67 100644 --- a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx @@ -5,7 +5,6 @@ import { Button } from '@tale/ui/button'; import { Stack } from '@tale/ui/layout'; import { ConvexError } from 'convex/values'; import { useState, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { CopyableField } from '@/app/components/ui/data-display/copyable-field'; @@ -15,6 +14,7 @@ import { ValidationCheckList } from '@/app/components/ui/feedback/validation-che import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { usePasswordPolicy } from '@/app/features/settings/governance/hooks/queries'; import { usePasswordValidation } from '@/app/hooks/use-password-validation'; import { useToast } from '@/app/hooks/use-toast'; @@ -89,7 +89,6 @@ export function AddMemberDialog({ useCreateMember(); const form = useForm({ resolver: zodResolver(addMemberSchema), - mode: 'onChange', defaultValues: { email: '', password: '', diff --git a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx index e42a959bcb..3ba233b9be 100644 --- a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx @@ -6,7 +6,7 @@ import { Button } from '@tale/ui/button'; import { HStack, Stack } from '@tale/ui/layout'; import { Text } from '@tale/ui/text'; import { useMemo, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; import * as z from 'zod'; import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; @@ -16,6 +16,7 @@ import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { usePasswordPolicy } from '@/app/features/settings/governance/hooks/queries'; import { usePasswordValidation } from '@/app/hooks/use-password-validation'; import { toast } from '@/app/hooks/use-toast'; @@ -100,7 +101,6 @@ export function EditMemberDialog({ const form = useForm({ resolver: zodResolver(editMemberSchema), - mode: 'onChange', defaultValues: { displayName: member?.displayName, role: isMemberRole(member?.role) ? member.role : undefined, diff --git a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx index fdad9d6ca4..aeff59fb7d 100644 --- a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx +++ b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx @@ -9,7 +9,7 @@ import { Text } from '@tale/ui/text'; import { useNavigate } from '@tanstack/react-router'; import { Loader2, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; +import { useFieldArray } from 'react-hook-form'; import { z } from 'zod/v4'; import { CollapsibleGuide } from '@/app/components/ui/data-display/collapsible-guide'; @@ -18,6 +18,7 @@ import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { Input } from '@/app/components/ui/forms/input'; import { SearchInput } from '@/app/components/ui/forms/search-input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { Sheet } from '@/app/components/ui/overlays/sheet'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; @@ -170,7 +171,6 @@ export function ProviderAddPanel({ getValues, } = useForm({ resolver: zodResolver(formSchema), - mode: 'onChange', defaultValues: { name: '', displayName: '', diff --git a/services/platform/app/features/settings/teams/components/team-create-dialog.test.tsx b/services/platform/app/features/settings/teams/components/team-create-dialog.test.tsx index 58fdbf0de3..bcc594ccbe 100644 --- a/services/platform/app/features/settings/teams/components/team-create-dialog.test.tsx +++ b/services/platform/app/features/settings/teams/components/team-create-dialog.test.tsx @@ -57,10 +57,13 @@ describe('TeamCreateDialog', () => { // Migrated from the `validation` E2E "create team dialog: disables submit until // a non-empty name is entered; cancels without creating". The gating is pure - // client UI: the name schema is `z.string().trim().min(1)` with RHF - // `mode: 'onChange'`, and the FormDialog submit button is disabled while - // `!isValid`. No backend call, router redirect, or persistence round-trip is - // involved in the assertion, so it belongs at the component tier. + // client UI: the name schema is `z.string().trim().min(1)` and the FormDialog + // submit button is disabled while `!isValid`. Validation timing follows the + // shared `useForm` wrapper's `mode: 'onTouched'` default (#1943): the field + // error renders only after the first blur, not on the first keystroke — while + // `isValid` (and therefore the submit gating) stays accurate throughout. No + // backend call, router redirect, or persistence round-trip is involved, so it + // belongs at the component tier. describe('name validation gating', () => { it('disables submit until a non-empty name is entered; cancels without creating', async () => { const onOpenChange = vi.fn(); @@ -82,8 +85,17 @@ describe('TeamCreateDialog', () => { expect(nameField).toHaveValue(''); expect(submit).toBeDisabled(); - // Whitespace-only trims to empty: still invalid → required error, disabled. + // First keystroke does NOT surface a validation error (the #1943 fix: + // `onTouched` waits for the first blur). Submit stays disabled because the + // whitespace-only value still trims to empty and gates `isValid`. await user.type(nameField, ' '); + expect( + screen.queryByText('Team name is required'), + ).not.toBeInTheDocument(); + expect(submit).toBeDisabled(); + + // Blurring the still-invalid field surfaces the required error. + await user.tab(); expect( await screen.findByText('Team name is required'), ).toBeInTheDocument(); diff --git a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx index 1ab37ceb5f..13d6f5f2e9 100644 --- a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx @@ -2,11 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback, useState, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import { authClient } from '@/lib/auth-client'; import { useT } from '@/lib/i18n/client'; @@ -50,7 +50,6 @@ export function TeamCreateDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { name: '', }, diff --git a/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx b/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx index d4f02cc3b2..8536690d47 100644 --- a/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx @@ -2,11 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { useToast } from '@/app/hooks/use-toast'; import { authClient } from '@/lib/auth-client'; import { useT } from '@/lib/i18n/client'; @@ -71,7 +71,6 @@ export function TeamEditDialog({ const form = useForm({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { name: team.name, }, diff --git a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx index 7e73beb659..3793c77741 100644 --- a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx @@ -2,11 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { Doc } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx index 5991d537b6..2bba9e0a8a 100644 --- a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx @@ -2,10 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo, useCallback } from 'react'; -import { useForm, FormProvider } from 'react-hook-form'; +import { FormProvider } from 'react-hook-form'; import { z } from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { CONTACT_REQUIRED_COLUMNS, useFileImport, diff --git a/services/platform/app/features/websites/components/website-add-dialog.tsx b/services/platform/app/features/websites/components/website-add-dialog.tsx index 5b03ec4c8b..24977d982d 100644 --- a/services/platform/app/features/websites/components/website-add-dialog.tsx +++ b/services/platform/app/features/websites/components/website-add-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/features/websites/components/website-edit-dialog.tsx b/services/platform/app/features/websites/components/website-edit-dialog.tsx index d4ff568f69..0b799e7893 100644 --- a/services/platform/app/features/websites/components/website-edit-dialog.tsx +++ b/services/platform/app/features/websites/components/website-edit-dialog.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { toast } from '@/app/hooks/use-toast'; import { Doc } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; diff --git a/services/platform/app/routes/_auth/log-in.tsx b/services/platform/app/routes/_auth/log-in.tsx index f30bf8b80f..fb24b52461 100644 --- a/services/platform/app/routes/_auth/log-in.tsx +++ b/services/platform/app/routes/_auth/log-in.tsx @@ -8,7 +8,6 @@ import { } from '@tanstack/react-router'; import { AlertCircle } from 'lucide-react'; import { useState, useEffect, useMemo, useCallback } from 'react'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { MicrosoftIcon } from '@/app/components/icons/microsoft-icon'; @@ -16,6 +15,7 @@ import { Form } from '@/app/components/ui/forms/form'; import { FormSection } from '@/app/components/ui/forms/form-section'; import { Input } from '@/app/components/ui/forms/input'; import { Label } from '@/app/components/ui/forms/label'; +import { useForm } from '@/app/components/ui/forms/use-form'; import { AuthFormLayout } from '@/app/features/auth/components/auth-form-layout'; import { useHasAnyUsers, @@ -120,7 +120,6 @@ export function LogInPage() { const form = useForm({ resolver: zodResolver(logInSchema), - mode: 'onChange', defaultValues: { email: '', password: '', diff --git a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx index 2eaf2bc67f..01d2c197d1 100644 --- a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx @@ -258,9 +258,9 @@ function ConfigurationPage() { {/* Controlled via RHF `Controller`: the field registers itself, - so dirty tracking is automatic and validation runs on change - (mode: 'onChange') — no `setValue(..., { shouldDirty, - shouldValidate })` to forget. */} + so dirty tracking is automatic and validation runs on the + shared `mode: 'onTouched'` default — no `setValue(..., { + shouldDirty, shouldValidate })` to forget. */} ({ resolver: zodResolver(schema), - mode: 'onChange', defaultValues: { newPassword: '', confirmPassword: '' }, }); From 67abd5b63c65bbfb2817881a77c1adef8a3af054 Mon Sep 17 00:00:00 2001 From: Tale Agent Date: Tue, 23 Jun 2026 13:37:53 +0000 Subject: [PATCH 2/2] fix(platform): drop floating promise in useForm wrapper test (#1943) --- .../app/components/ui/forms/use-form.test.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/services/platform/app/components/ui/forms/use-form.test.tsx b/services/platform/app/components/ui/forms/use-form.test.tsx index 60d3523de0..43375a5585 100644 --- a/services/platform/app/components/ui/forms/use-form.test.tsx +++ b/services/platform/app/components/ui/forms/use-form.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { zodResolver } from '@hookform/resolvers/zod'; -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { z } from 'zod'; @@ -35,7 +35,9 @@ describe('useForm (shared wrapper)', () => { ); // Register the field, then change it without ever blurring — exactly the - // "typing the first character" path from #1943. + // "typing the first character" path from #1943. Under `onTouched` no error + // is set until the field is blurred. (The blur -> error path is covered by + // the form integration tests, e.g. team-create-dialog / enterprise-sso.) act(() => { result.current.register('name'); }); @@ -44,18 +46,10 @@ describe('useForm (shared wrapper)', () => { }); expect(result.current.formState.errors.name).toBeUndefined(); - // Clearing the value and blurring (touching) it surfaces the error. + // Even reverting to the invalid empty value (still untouched) stays clean. await act(async () => { result.current.setValue('name', ''); }); - act(() => { - result.current.register('name').onBlur({ - target: { name: 'name', value: '' }, - type: 'blur', - }); - }); - await waitFor(() => - expect(result.current.formState.errors.name).toBeDefined(), - ); + expect(result.current.formState.errors.name).toBeUndefined(); }); });