refactor(app): unify the header save action on AppHeaderAction with a loading prop

The four detail-page saves rendered two ways: knowledge as AppHeaderAction (a
primary CTA that collapses to an icon on mobile), the other three as a secondary
FormSubmitButton. FormSubmitButton buys nothing here — all three sit in the
header, outside the form's FormProvider, so its useFormContext read returns null
and they already submit via form= + a manual loading prop. Its real value is the
in-<Form> subscription, which its 8 dialog/auth consumers keep using untouched.

AppHeaderAction gains an optional loading (icon->spinner + disable); the three
header saves become AppHeaderAction like knowledge, so all four are now the same
primary CTA that collapses to an icon-only button (aria-label preserved) below
md. type="submit" is explicit because Button defaults type to "button". Verified
at 480px: label hidden, 32px icon button, aria-label "Create"; at 1440: primary
fill, label shown.

Researched the alternatives first: React 19 useFormStatus can't read a
form=-associated button (react.dev; facebook/react#27980) and targets native
form actions not RHF; and a Radix Slot asChild compose throws on FormSubmitButton's
two-child array and can't inject the spinner past child-wins prop merging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-17 02:50:53 +07:00
co-authored by Claude Opus 4.8
parent 672851ef32
commit 1adcbec0f9
5 changed files with 23 additions and 26 deletions
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button, type ButtonProps } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -10,6 +12,7 @@ interface AppHeaderActionProps extends Omit<ButtonProps, 'children'> {
endIcon?: ReactNode;
icon: ReactNode;
label: ReactNode;
loading?: boolean;
}
export function AppHeader({ children, className }: { children: ReactNode; className?: string }) {
@@ -28,9 +31,11 @@ export function AppHeader({ children, className }: { children: ReactNode; classN
export function AppHeaderAction({
'aria-label': ariaLabel,
className,
disabled,
endIcon,
icon,
label,
loading = false,
size = 'sm',
...props
}: AppHeaderActionProps) {
@@ -40,10 +45,11 @@ export function AppHeaderAction({
<Button
aria-label={accessibleLabel}
className={cn('w-8 px-0 md:w-auto md:px-3', className)}
disabled={disabled || loading}
size={size}
{...props}
>
{icon}
{loading ? <Loader2 className="size-4 animate-spin" /> : icon}
<span className="hidden md:inline">{label}</span>
{endIcon ? <span className="hidden md:inline-flex">{endIcon}</span> : null}
</Button>
@@ -16,7 +16,6 @@ import { AppHeaderAction } from '@/components/layouts/app/app-header';
import { type EditorViewMode } from '@/components/shared/markdown-editor';
import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes';
import { Form } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { AnonymizeTextDocument, KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
import { useAppForm } from '@/hooks/use-app-form';
import { useBreakpoint } from '@/hooks/use-breakpoint';
@@ -319,8 +318,9 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
const saveButton = (
<AppHeaderAction
disabled={!canSubmit}
icon={isSaving ? <Spinner variant="circle" /> : <Save aria-hidden="true" />}
icon={<Save aria-hidden="true" />}
label={isNew ? 'Create' : 'Save'}
loading={isSaving}
type="submit"
/>
);
@@ -71,7 +71,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { Form, FormControl, FormItem, FormMessage } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
@@ -675,15 +674,13 @@ function SettingsPrompt() {
type="button"
variant="outline"
/>
<FormSubmitButton
<AppHeaderAction
form={activeFormId}
icon={<Save className="size-4" />}
label="Save"
loading={isLoading}
size="sm"
variant="secondary"
>
Save
</FormSubmitButton>
type="submit"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -53,7 +53,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
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 { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -1881,15 +1880,13 @@ function SettingsProvider() {
type="button"
variant="outline"
/>
<FormSubmitButton
<AppHeaderAction
form="provider-form"
icon={<Save className="size-4" />}
label={isNew ? 'Create' : 'Save'}
loading={isLoading}
size="sm"
variant="secondary"
>
{isNew ? 'Create' : 'Save'}
</FormSubmitButton>
type="submit"
/>
{!isNew && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
+6 -9
View File
@@ -17,7 +17,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { z } from 'zod';
import { AppHeader, AppHeaderActions, AppHeaderContent } from '@/components/layouts/app/app-header';
import { AppHeader, AppHeaderAction, AppHeaderActions, AppHeaderContent } from '@/components/layouts/app/app-header';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import {
DetailNavigationButtons,
@@ -41,7 +41,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Spinner } from '@/components/ui/spinner';
@@ -476,16 +475,14 @@ function Template() {
/>
)}
{(isNew || !!templateData?.flowTemplate) && (
<FormSubmitButton
disabled={isSaving || (!isNew && !hasUnsavedChanges)}
<AppHeaderAction
disabled={!isNew && !hasUnsavedChanges}
form="template-form"
icon={<Save className="size-4" />}
label={isNew ? 'Create' : 'Save'}
loading={isSaving}
size="sm"
variant="secondary"
>
{isNew ? 'Create' : 'Save'}
</FormSubmitButton>
type="submit"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>