diff --git a/frontend/src/components/ui/form-submit-button.tsx b/frontend/src/components/ui/form-submit-button.tsx new file mode 100644 index 00000000..c13ab5ce --- /dev/null +++ b/frontend/src/components/ui/form-submit-button.tsx @@ -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 { + /** + * 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
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 . + */ +function FormSubmitButton({ children, requireValid = true, ...props }: FormSubmitButtonProps) { + const { formState } = useFormContext(); + const { isSubmitted, isSubmitting, isValid } = formState; + const isDisabled = isSubmitting || (requireValid && isSubmitted && !isValid); + + return ( + + ); +} + +export { FormSubmitButton }; diff --git a/frontend/src/features/authentication/login-form.tsx b/frontend/src/features/authentication/login-form.tsx index 98e6ac96..7bbb2ea2 100644 --- a/frontend/src/features/authentication/login-form.tsx +++ b/frontend/src/features/authentication/login-form.tsx @@ -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) => { 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) => ( + {error && {error}} diff --git a/frontend/src/features/authentication/password-change-form.tsx b/frontend/src/features/authentication/password-change-form.tsx index fe12f3cb..30392a4f 100644 --- a/frontend/src/features/authentication/password-change-form.tsx +++ b/frontend/src/features/authentication/password-change-form.tsx @@ -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); 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 )} - + diff --git a/frontend/src/features/resources/resources-mkdir-dialog.tsx b/frontend/src/features/resources/resources-mkdir-dialog.tsx index 71c82ff0..64ece8d9 100644 --- a/frontend/src/features/resources/resources-mkdir-dialog.tsx +++ b/frontend/src/features/resources/resources-mkdir-dialog.tsx @@ -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 ( @@ -120,13 +119,10 @@ function ResourcesMkdirDialogForm({ defaultParentPath, onClose }: ResourcesMkdir > Cancel - +