feat(ui): add loading spinners to async buttons in dialogs and forms
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-02-11 23:27:31 +08:00
parent cdd25236e4
commit 550254b844
7 changed files with 32 additions and 17 deletions

View File

@@ -6,7 +6,7 @@ import { Dialog } from '../ui/dialog';
import { EventJoinDialogView } from './event-join.dialog.view';
export function EventJoinDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
const { mutateAsync } = useJoinEvent();
const { mutateAsync, isPending } = useJoinEvent();
const join = useCallback(() => {
mutateAsync({ body: { event_id: event.eventId } }).then(() => {
toast('加入活动成功');
@@ -19,7 +19,7 @@ export function EventJoinDialogContainer({ event, children }: { event: EventInfo
return (
<Dialog>
{children}
<EventJoinDialogView event={event} onJoinEvent={join} />
<EventJoinDialogView event={event} onJoinEvent={join} isPending={isPending} />
</Dialog>
);
}

View File

@@ -1,8 +1,9 @@
import type { EventInfo } from './types';
import { Loader2 } from 'lucide-react';
import { Button } from '../ui/button';
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function EventJoinDialogView({ event, onJoinEvent }: { event: EventInfo; onJoinEvent: () => void }) {
export function EventJoinDialogView({ event, onJoinEvent, isPending }: { event: EventInfo; onJoinEvent: () => void; isPending: boolean }) {
return (
<DialogContent>
<DialogHeader>
@@ -18,7 +19,7 @@ export function EventJoinDialogView({ event, onJoinEvent }: { event: EventInfo;
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button onClick={onJoinEvent}></Button>
<Button onClick={onJoinEvent} disabled={isPending}>{isPending ? <Loader2 className="animate-spin" /> : '加入'}</Button>
</DialogFooter>
</DialogContent>
);

View File

@@ -1,5 +1,6 @@
import type { KycSubmission } from './kyc.types';
import { useForm } from '@tanstack/react-form';
import { Loader2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import z from 'zod';
@@ -80,7 +81,7 @@ function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
<form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? <Loader2 className="animate-spin" /> : '开始认证'}</Button>
)}
/>
</DialogFooter>

View File

@@ -2,6 +2,7 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import NixOSLogo from '@/assets/nixos.svg?react';
@@ -41,6 +42,7 @@ export function LoginForm({
});
};
const isLoading = isPending || token === null;
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<form ref={formRef} onSubmit={handleSubmit}>
@@ -63,7 +65,8 @@ export function LoginForm({
/>
</Field>
<Field>
<Button type="submit" disabled={token === null || isPending}>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
</Button>
</Field>

View File

@@ -1,5 +1,6 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useForm } from '@tanstack/react-form';
import { Loader2 } from 'lucide-react';
import {
useEffect,
useState,
@@ -164,9 +165,11 @@ export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUs
<Button variant="outline"></Button>
</DialogClose>
<form.Subscribe
selector={state => [state.canSubmit]}
children={([canSubmit]) => (
<Button type="submit" disabled={!canSubmit}></Button>
selector={state => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit}>
{isSubmitting ? <Loader2 className="animate-spin" /> : '保存'}
</Button>
)}
/>
</DialogFooter>

View File

@@ -6,7 +6,7 @@ import {
isEmpty,
isNil,
} from 'lodash-es';
import { Mail, Pencil } from 'lucide-react';
import { Loader2, Mail, Pencil } from 'lucide-react';
import { useMemo, useState } from 'react';
import Markdown from 'react-markdown';
import { toast } from 'sonner';
@@ -18,6 +18,7 @@ import { EditProfileDialogContainer } from './edit-profile.dialog.container';
export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
const [enableBioEdit, setEnableBioEdit] = useState(false);
const [isSubmittingBio, setIsSubmittingBio] = useState(false);
const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, {
@@ -70,6 +71,7 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
else {
if (!isNil(bio)) {
try {
setIsSubmittingBio(true);
await onSaveBio(bio);
setEnableBioEdit(false);
}
@@ -77,13 +79,17 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
console.error(error);
toast.error('个人简介更新失败');
}
finally {
setIsSubmittingBio(false);
}
}
}
}}
size="icon-sm"
variant={enableBioEdit ? 'default' : 'outline'}
disabled={isSubmittingBio}
>
<Pencil />
{isSubmittingBio ? <Loader2 className="animate-spin" /> : <Pencil />}
</Button>
</section>
</div>

View File

@@ -22,5 +22,6 @@ export const Confirm: Story = {
args: {
event: exampleEvent,
onJoinEvent: () => { },
isPending: false,
},
};