mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 03:26:37 +00:00
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:
co-authored by
Claude Opus 4.7
parent
338d39c10c
commit
e15839bab1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user