feat: Add button loading states

This commit is contained in:
gpt-engineer-app[bot]
2025-11-04 18:11:31 +00:00
parent 2deab69ebe
commit 6b5be8a70b
11 changed files with 98 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
@@ -35,6 +36,7 @@ interface DesignerFormProps {
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps): React.JSX.Element { export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps): React.JSX.Element {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const { const {
register, register,
@@ -75,6 +77,7 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
return; return;
} }
setIsSubmitting(true);
try { try {
const formData = { const formData = {
...data, ...data,
@@ -97,6 +100,8 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally {
setIsSubmitting(false);
} }
})} className="space-y-6"> })} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
@@ -274,12 +279,15 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting}
> >
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
loading={isSubmitting}
loadingText="Saving..."
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Save Designer Save Designer

View File

@@ -152,7 +152,7 @@ export function IntegrationTestRunner() {
{/* Controls */} {/* Controls */}
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={runTests} disabled={isRunning || selectedSuites.length === 0}> <Button onClick={runTests} loading={isRunning} loadingText="Running..." disabled={selectedSuites.length === 0}>
<Play className="w-4 h-4 mr-2" /> <Play className="w-4 h-4 mr-2" />
Run Selected Run Selected
</Button> </Button>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
@@ -37,6 +38,7 @@ interface ManufacturerFormProps {
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps): React.JSX.Element { export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps): React.JSX.Element {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const { const {
register, register,
@@ -79,6 +81,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
return; return;
} }
setIsSubmitting(true);
try { try {
const formData = { const formData = {
...data, ...data,
@@ -86,7 +89,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined, founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
}; };
onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Only show success toast and close if not editing through moderation queue
if (!initialData?.id) { if (!initialData?.id) {
@@ -101,6 +104,8 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally {
setIsSubmitting(false);
} }
})} className="space-y-6"> })} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
@@ -284,12 +289,15 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting}
> >
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
loading={isSubmitting}
loadingText="Saving..."
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Save Manufacturer Save Manufacturer

View File

@@ -142,8 +142,8 @@ export function NotificationDebugPanel() {
<CardTitle>Notification Health Dashboard</CardTitle> <CardTitle>Notification Health Dashboard</CardTitle>
<CardDescription>Monitor duplicate prevention and notification system health</CardDescription> <CardDescription>Monitor duplicate prevention and notification system health</CardDescription>
</div> </div>
<Button variant="outline" size="sm" onClick={loadData} disabled={isLoading}> <Button variant="outline" size="sm" onClick={loadData} loading={isLoading} loadingText="Loading...">
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
@@ -35,6 +36,7 @@ interface OperatorFormProps {
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps): React.JSX.Element { export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps): React.JSX.Element {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const { const {
register, register,
@@ -75,6 +77,7 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
return; return;
} }
setIsSubmitting(true);
try { try {
const formData = { const formData = {
...data, ...data,
@@ -82,7 +85,7 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined, founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
}; };
onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Only show success toast and close if not editing through moderation queue
if (!initialData?.id) { if (!initialData?.id) {
@@ -97,6 +100,8 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally {
setIsSubmitting(false);
} }
})} className="space-y-6"> })} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
@@ -274,12 +279,15 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting}
> >
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
loading={isSubmitting}
loadingText="Saving..."
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Save Operator Save Operator

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
@@ -35,6 +36,7 @@ interface PropertyOwnerFormProps {
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps): React.JSX.Element { export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps): React.JSX.Element {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const { const {
register, register,
@@ -75,6 +77,7 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
return; return;
} }
setIsSubmitting(true);
try { try {
const formData = { const formData = {
...data, ...data,
@@ -82,7 +85,7 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined, founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
}; };
onSubmit(formData); await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue // Only show success toast and close if not editing through moderation queue
if (!initialData?.id) { if (!initialData?.id) {
@@ -97,6 +100,8 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally {
setIsSubmitting(false);
} }
})} className="space-y-6"> })} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
@@ -274,12 +279,15 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting}
> >
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
loading={isSubmitting}
loadingText="Saving..."
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Save Property Owner Save Property Owner

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import type { RideModelTechnicalSpec } from '@/types/database'; import type { RideModelTechnicalSpec } from '@/types/database';
import { getErrorMessage } from '@/lib/errorHandler'; import { getErrorMessage } from '@/lib/errorHandler';
import { handleError } from '@/lib/errorHandler'; import { handleError } from '@/lib/errorHandler';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -71,6 +72,7 @@ export function RideModelForm({
initialData initialData
}: RideModelFormProps) { }: RideModelFormProps) {
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const [isSubmitting, setIsSubmitting] = useState(false);
const [technicalSpecs, setTechnicalSpecs] = useState<{ const [technicalSpecs, setTechnicalSpecs] = useState<{
spec_name: string; spec_name: string;
spec_value: string; spec_value: string;
@@ -101,14 +103,16 @@ export function RideModelForm({
}); });
const handleFormSubmit = (data: RideModelFormData) => { const handleFormSubmit = async (data: RideModelFormData) => {
setIsSubmitting(true);
try { try {
// Include relational technical specs with extended type // Include relational technical specs with extended type
onSubmit({ await onSubmit({
...data, ...data,
manufacturer_id: manufacturerId, manufacturer_id: manufacturerId,
_technical_specifications: technicalSpecs _technical_specifications: technicalSpecs
}); });
toast.success('Ride model submitted for review');
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, { handleError(error, {
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model' action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
@@ -116,6 +120,8 @@ export function RideModelForm({
// Re-throw so parent can handle modal closing // Re-throw so parent can handle modal closing
throw error; throw error;
} finally {
setIsSubmitting(false);
} }
}; };
@@ -294,12 +300,15 @@ export function RideModelForm({
type="button" type="button"
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting}
> >
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
loading={isSubmitting}
loadingText="Saving..."
> >
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Save Model Save Model

View File

@@ -777,9 +777,10 @@ export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivity
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} loading={isRefreshing}
loadingText="Refreshing..."
> >
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
{showFilters && ( {showFilters && (

View File

@@ -435,7 +435,12 @@ export function TestDataGenerator(): React.JSX.Element {
)} )}
<div className="flex gap-3"> <div className="flex gap-3">
<Button onClick={handleGenerate} disabled={loading || selectedEntityTypes.length === 0}> <Button
onClick={handleGenerate}
loading={loading}
loadingText="Generating..."
disabled={selectedEntityTypes.length === 0}
>
<Beaker className="w-4 h-4 mr-2" /> <Beaker className="w-4 h-4 mr-2" />
Generate Test Data Generate Test Data
</Button> </Button>

View File

@@ -148,9 +148,9 @@ export function VersionCleanupSettings() {
onChange={(e) => setRetentionDays(Number(e.target.value))} onChange={(e) => setRetentionDays(Number(e.target.value))}
className="w-32" className="w-32"
/> />
<Button onClick={handleSaveRetention} disabled={isSaving}> <Button onClick={handleSaveRetention} loading={isSaving} loadingText="Saving...">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'} Save
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Keep most recent 10 versions per item, delete older ones beyond this period Keep most recent 10 versions per item, delete older ones beyond this period
@@ -176,15 +176,12 @@ export function VersionCleanupSettings() {
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<Button <Button
onClick={handleManualCleanup} onClick={handleManualCleanup}
disabled={isLoading} loading={isLoading}
loadingText="Running Cleanup..."
variant="outline" variant="outline"
className="w-full" className="w-full"
> >
{isLoading ? ( <Trash2 className="h-4 w-4 mr-2" />
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Run Manual Cleanup Now Run Manual Cleanup Now
</Button> </Button>
<p className="text-xs text-muted-foreground mt-2 text-center"> <p className="text-xs text-muted-foreground mt-2 text-center">

View File

@@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { breadcrumb } from "@/lib/errorBreadcrumbs"; import { breadcrumb } from "@/lib/errorBreadcrumbs";
@@ -36,13 +37,33 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
trackingLabel?: string; // Optional label for breadcrumb tracking trackingLabel?: string; // Optional label for breadcrumb tracking
loading?: boolean; // Show loading state with spinner
loadingText?: string; // Optional text to display during loading
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, onClick, trackingLabel, ...props }, ref) => { ({
className,
variant,
size,
asChild = false,
onClick,
trackingLabel,
loading = false,
loadingText,
children,
disabled,
...props
}, ref) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// Prevent clicks while loading
if (loading) {
e.preventDefault();
return;
}
// Add breadcrumb for button click // Add breadcrumb for button click
if (trackingLabel) { if (trackingLabel) {
breadcrumb.userAction('clicked', trackingLabel); breadcrumb.userAction('clicked', trackingLabel);
@@ -57,8 +78,18 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
onClick={handleClick} onClick={handleClick}
{...props} disabled={disabled || loading}
/> {...props}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{loadingText || children}
</>
) : (
children
)}
</Comp>
); );
}, },
); );