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
20 changes: 19 additions & 1 deletion services/platform/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions services/platform/app/components/ui/editor/use-form-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useForm,
type DefaultValues,
type FieldValues,
type Path,
type Resolver,
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';
Expand Down Expand Up @@ -71,7 +71,7 @@ export function useFormEditor<T extends FieldValues>({
? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- zodResolver returns Resolver<unknown,…>; widen
(zodResolver(schema) as unknown as Resolver<T>)
: undefined,
mode: 'onChange',
// `mode` defaults to `'onTouched'` via the shared `useForm` wrapper (#1943).
});

const [hasRemoteUpdate, setHasRemoteUpdate] = useState(false);
Expand Down
2 changes: 1 addition & 1 deletion services/platform/app/components/ui/forms/input.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
55 changes: 55 additions & 0 deletions services/platform/app/components/ui/forms/use-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { zodResolver } from '@hookform/resolvers/zod';
import { act, renderHook } 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<Form>());
// `_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<Form>({ 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<Form>({
defaultValues: { name: '' },
resolver: zodResolver(schema),
}),
);

// Register the field, then change it without ever blurring — exactly the
// "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');
});
await act(async () => {
result.current.setValue('name', 'a');
});
expect(result.current.formState.errors.name).toBeUndefined();

// Even reverting to the invalid empty value (still untouched) stays clean.
await act(async () => {
result.current.setValue('name', '');
});
expect(result.current.formState.errors.name).toBeUndefined();
});
});
36 changes: 36 additions & 0 deletions services/platform/app/components/ui/forms/use-form.ts
Original file line number Diff line number Diff line change
@@ -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<TFieldValues, TContext, TTransformedValues>,
): UseFormReturn<TFieldValues, TContext, TTransformedValues> {
return useReactHookForm<TFieldValues, TContext, TTransformedValues>({
mode: 'onTouched',
...props,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

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';
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ?? '',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,7 +62,6 @@ export function AccountStep() {

const form = useForm<AccountFormData>({
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: { email: '', password: '' },
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,7 +109,6 @@ export function ProductCreateDialog({
formState: { errors },
} = useForm<ProductFormData>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
name: '',
description: '',
Expand Down
Loading