feat(frontend): add FormSubmitButton and apply it to three forms

New shared component src/components/ui/form-submit-button.tsx. It is
the react-hook-form-flavoured analogue of React 19's useFormStatus:
the button subscribes to the nearest FormProvider via useFormContext
and reads formState.isSubmitting / isValid / isSubmitted itself, so
the surrounding form no longer has to thread loading flags down.
Shows a Loader2 spinner while submitting and disables itself once
the form is dirty-and-invalid (opt out with requireValid={false}).

Applied to the three forms where the submit is a plain
<Button type="submit"> living inside a <Form {...form}> wrapper:

- login-form.tsx — also dropped the manual isSubmitting toggles
  inside the form handler (RHF already tracks that through
  form.handleSubmit). Kept the OAuth-flow useState because the
  provider login does not go through form.handleSubmit; combined
  the two states on the OAuth buttons so they also disable while
  the form is submitting.
- password-change-form.tsx — dropped the manual isSubmitting state
  entirely; the only submit goes through form.handleSubmit. Cancel
  and Skip buttons stay as plain Buttons.
- resources-mkdir-dialog.tsx — replaced the submit button; kept the
  isCreating flag because Cancel and the Input still need to react
  to the mutation in flight.

Five other forms intentionally kept their current submit:
- knowledge-form.tsx uses our HeaderButton (responsive icon/label)
  with its own Spinner, not a plain Button.
- flow-form.tsx and templates/template.tsx use InputGroupButton
  (icon-only, sits inside an InputGroupAddon).
- settings-prompt.tsx and settings-provider.tsx attach their submit
  to the form via the HTML form="…" attribute, so the button is
  outside the FormProvider tree — useFormContext would throw.
  These also drive disabled state from multiple mutation flags
  (create/update/delete/validate) that aren't reducible to a single
  isSubmitting.

Verified: pnpm run build, lint (0/44 baseline unchanged), 475/475 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-19 09:39:23 +07:00
co-authored by Claude Opus 4.7
parent 338d39c10c
commit e15839bab1
4 changed files with 58 additions and 32 deletions
@@ -0,0 +1,45 @@
import { Loader2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Button } from '@/components/ui/button';
interface FormSubmitButtonProps extends React.ComponentProps<typeof Button> {
/**
* When true (the default), the button is disabled as soon as the form has
* been submitted once with invalid values — i.e. mirrors the "click first,
* see errors, then re-disable" UX our forms already use. Pass `false` for
* flows that must accept clicks even with invalid state (e.g. multi-step
* forms where submit also runs validation manually).
*/
requireValid?: boolean;
}
/**
* Submit button that reads the pending/validity state of the nearest
* react-hook-form context. Drops in next to a <Form {...form}> wrapper without
* any prop drilling — the button shows a spinner while form.handleSubmit(...)
* is awaiting and disables itself once the form is dirty-but-invalid.
*
* This is the RHF-flavoured analogue of React 19's useFormStatus — same idea
* (child component subscribes to the form's status), different source
* (useFormContext vs the form-action runtime) because our forms run through
* react-hook-form's handleSubmit, not native <form action>.
*/
function FormSubmitButton({ children, requireValid = true, ...props }: FormSubmitButtonProps) {
const { formState } = useFormContext();
const { isSubmitted, isSubmitting, isValid } = formState;
const isDisabled = isSubmitting || (requireValid && isSubmitted && !isValid);
return (
<Button
disabled={isDisabled}
type="submit"
{...props}
>
{isSubmitting && <Loader2 className="size-4 animate-spin" />}
{children}
</Button>
);
}
export { FormSubmitButton };
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
@@ -11,6 +10,7 @@ import Github from '@/components/icons/github';
import Google from '@/components/icons/google';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { useUser } from '@/providers/user-provider';
@@ -76,7 +76,6 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) {
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
setError(null);
setIsSubmitting(true);
try {
const result = await login(values);
@@ -96,8 +95,6 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) {
navigate(returnUrl);
} catch {
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
@@ -188,7 +185,7 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) {
.filter((provider) => providers.includes(provider.id))
.map((provider) => (
<Button
disabled={isSubmitting}
disabled={isSubmitting || form.formState.isSubmitting}
key={provider.id}
onClick={() => handleProviderLogin(provider.id)}
type="button"
@@ -248,14 +245,9 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) {
)}
/>
<Button
className="w-full"
disabled={isSubmitting || (!form.formState.isValid && form.formState.isSubmitted)}
type="submit"
>
{isSubmitting && <Loader2 className="animate-spin" />}
<FormSubmitButton className="w-full">
<span>Sign in</span>
</Button>
</FormSubmitButton>
{error && <FormMessage>{error}</FormMessage>}
</div>
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import { Eye, EyeOff } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
@@ -7,6 +7,7 @@ import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { api, type ApiErrorResponse, type ApiHttpError } from '@/lib/axios';
@@ -64,7 +65,6 @@ export function PasswordChangeForm({
onSuccess,
showSkip = false,
}: PasswordChangeFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<null | string>(null);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
@@ -80,7 +80,6 @@ export function PasswordChangeForm({
});
const handleSubmit = async (values: PasswordChangeFormValues) => {
setIsSubmitting(true);
setError(null);
try {
@@ -133,8 +132,6 @@ export function PasswordChangeForm({
}
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
@@ -272,13 +269,9 @@ export function PasswordChangeForm({
Cancel
</Button>
)}
<Button
disabled={isSubmitting || (!form.formState.isValid && form.formState.isSubmitted)}
type="submit"
>
{isSubmitting && <Loader2 className="mr-2 size-4 animate-spin" />}
<FormSubmitButton>
<span>Update Password</span>
</Button>
</FormSubmitButton>
</div>
</form>
</Form>
@@ -1,11 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FolderPlus, Loader2 } from 'lucide-react';
import { FolderPlus } from 'lucide-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { resourcesMkdirFormSchema, type ResourcesMkdirFormValues, useResourcesMkdir } from './use-resources-mkdir';
@@ -68,8 +69,6 @@ function ResourcesMkdirDialogForm({ defaultParentPath, onClose }: ResourcesMkdir
}
});
const isSubmitDisabled = !form.formState.isValid || isCreating;
return (
<DialogContent>
<DialogHeader>
@@ -120,13 +119,10 @@ function ResourcesMkdirDialogForm({ defaultParentPath, onClose }: ResourcesMkdir
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
type="submit"
>
{isCreating ? <Loader2 className="animate-spin" /> : <FolderPlus />}
<FormSubmitButton>
<FolderPlus />
Create
</Button>
</FormSubmitButton>
</div>
</form>
</Form>