mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Enhance FormFieldWrapper with blur validation and toasts
Adds validation on blur mode to FormFieldWrapper, introduces animated validation states, and implements standardized form submission toasts via a new formToasts helper; updates ParkForm and RideForm to use the new toast system and to propagate error state with scroll-to-error support.
This commit is contained in:
@@ -17,6 +17,7 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-
|
|||||||
import { SlugField } from '@/components/ui/slug-field';
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react';
|
import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react';
|
||||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -294,7 +295,16 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
await onSubmit(submissionData);
|
await onSubmit(submissionData);
|
||||||
|
|
||||||
// Parent component handles success feedback
|
// Show success toast
|
||||||
|
if (isModerator()) {
|
||||||
|
formToasts.success.moderatorApproval('Park', data.name);
|
||||||
|
} else if (isEditing) {
|
||||||
|
formToasts.success.update('Park', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Park', data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent component handles modal closing/navigation
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
@@ -308,6 +318,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(errorMessage);
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react';
|
import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react';
|
||||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||||
@@ -360,14 +361,14 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
// Pass clean data to parent with extended fields
|
// Pass clean data to parent with extended fields
|
||||||
await onSubmit(metricData);
|
await onSubmit(metricData);
|
||||||
|
|
||||||
toast({
|
// Show success toast
|
||||||
title: isEditing ? "Ride Updated" : "Submission Sent",
|
if (isModerator()) {
|
||||||
description: isEditing
|
formToasts.success.moderatorApproval('Ride', data.name);
|
||||||
? "The ride information has been updated successfully."
|
} else if (isEditing) {
|
||||||
: tempNewManufacturer
|
formToasts.success.update('Ride', data.name);
|
||||||
? "Ride, manufacturer, and model submitted for review"
|
} else {
|
||||||
: "Ride submitted for review"
|
formToasts.success.create('Ride', data.name);
|
||||||
});
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: isEditing ? 'Update Ride' : 'Create Ride',
|
action: isEditing ? 'Update Ride' : 'Create Ride',
|
||||||
@@ -378,6 +379,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ interface FormFieldWrapperProps {
|
|||||||
|
|
||||||
/** Hide automatic hint */
|
/** Hide automatic hint */
|
||||||
hideHint?: boolean;
|
hideHint?: boolean;
|
||||||
|
|
||||||
|
/** When to show validation feedback */
|
||||||
|
validationMode?: 'realtime' | 'onBlur';
|
||||||
|
|
||||||
|
/** Callback when field is blurred (for onBlur mode) */
|
||||||
|
onBlur?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,7 +198,10 @@ export function FormFieldWrapper({
|
|||||||
textareaProps,
|
textareaProps,
|
||||||
className,
|
className,
|
||||||
hideHint = false,
|
hideHint = false,
|
||||||
|
validationMode = 'realtime',
|
||||||
|
onBlur,
|
||||||
}: FormFieldWrapperProps) {
|
}: FormFieldWrapperProps) {
|
||||||
|
const [hasBlurred, setHasBlurred] = React.useState(false);
|
||||||
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
||||||
const autoHint = getAutoHint(fieldType);
|
const autoHint = getAutoHint(fieldType);
|
||||||
const displayHint = hint || autoHint;
|
const displayHint = hint || autoHint;
|
||||||
@@ -203,12 +212,27 @@ export function FormFieldWrapper({
|
|||||||
const charCount = typeof value === 'string' ? value.length : 0;
|
const charCount = typeof value === 'string' ? value.length : 0;
|
||||||
|
|
||||||
// Determine validation state
|
// Determine validation state
|
||||||
|
const shouldShowValidation = validationMode === 'realtime' || (validationMode === 'onBlur' && hasBlurred);
|
||||||
const hasValue = value !== undefined && value !== null && value !== '';
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
const isValid = !error && hasValue;
|
const isValid = shouldShowValidation && !error && hasValue;
|
||||||
const hasError = !!error;
|
const hasError = shouldShowValidation && !!error;
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setHasBlurred(true);
|
||||||
|
if (validationMode === 'onBlur' && onBlur) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
// Call original onBlur if provided
|
||||||
|
if ('value' in e.target && textareaProps?.onBlur) {
|
||||||
|
textareaProps.onBlur(e as React.FocusEvent<HTMLTextAreaElement>);
|
||||||
|
} else if (inputProps?.onBlur) {
|
||||||
|
inputProps.onBlur(e as React.FocusEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("space-y-2", className)} data-error={hasError ? "true" : undefined}>
|
||||||
{/* Label with optional terminology tooltip */}
|
{/* Label with optional terminology tooltip */}
|
||||||
<Label htmlFor={id} className="flex items-center gap-2">
|
<Label htmlFor={id} className="flex items-center gap-2">
|
||||||
{termKey ? (
|
{termKey ? (
|
||||||
@@ -232,11 +256,13 @@ export function FormFieldWrapper({
|
|||||||
<Textarea
|
<Textarea
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pr-10",
|
"pr-10 transition-all duration-300 ease-in-out",
|
||||||
error && "border-destructive",
|
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
isValid && "border-green-500/50"
|
error && "border-destructive focus:ring-destructive/20",
|
||||||
|
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||||
)}
|
)}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
onBlur={handleBlur}
|
||||||
{...textareaProps}
|
{...textareaProps}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -244,23 +270,25 @@ export function FormFieldWrapper({
|
|||||||
id={id}
|
id={id}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pr-10",
|
"pr-10 transition-all duration-300 ease-in-out",
|
||||||
error && "border-destructive",
|
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
isValid && "border-green-500/50"
|
error && "border-destructive focus:ring-destructive/20",
|
||||||
|
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||||
)}
|
)}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
onBlur={handleBlur}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Validation icon */}
|
{/* Validation icon with animation */}
|
||||||
{(isValid || hasError) && (
|
{(isValid || hasError) && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
{isValid && (
|
{isValid && (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500 animate-fade-in" />
|
||||||
)}
|
)}
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
<AlertCircle className="h-4 w-4 text-destructive animate-fade-in" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -268,7 +296,7 @@ export function FormFieldWrapper({
|
|||||||
|
|
||||||
{/* Hint text (if not hidden and exists) */}
|
{/* Hint text (if not hidden and exists) */}
|
||||||
{!hideHint && displayHint && !error && (
|
{!hideHint && displayHint && !error && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground animate-slide-in-down">
|
||||||
{displayHint}
|
{displayHint}
|
||||||
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
||||||
</p>
|
</p>
|
||||||
@@ -281,9 +309,9 @@ export function FormFieldWrapper({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message with animation */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive animate-slide-in-down">
|
||||||
{error}
|
{error}
|
||||||
{showCharCount && ` (${charCount}/${maxLength})`}
|
{showCharCount && ` (${charCount}/${maxLength})`}
|
||||||
</p>
|
</p>
|
||||||
@@ -299,6 +327,7 @@ export const formFieldPresets = {
|
|||||||
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
fieldType: 'url' as FormFieldType,
|
fieldType: 'url' as FormFieldType,
|
||||||
label: 'Website URL',
|
label: 'Website URL',
|
||||||
|
validationMode: 'onBlur',
|
||||||
...props,
|
...props,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
65
src/lib/formToasts.ts
Normal file
65
src/lib/formToasts.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized toast notifications for form submissions
|
||||||
|
* Provides consistent success/error feedback across all forms
|
||||||
|
*/
|
||||||
|
export const formToasts = {
|
||||||
|
success: {
|
||||||
|
create: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Submission Created',
|
||||||
|
description: entityName
|
||||||
|
? `${entityName} has been submitted for review.`
|
||||||
|
: `${entityType} has been submitted for review.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Update Submitted',
|
||||||
|
description: entityName
|
||||||
|
? `Changes to ${entityName} have been submitted for review.`
|
||||||
|
: `${entityType} update has been submitted for review.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
moderatorApproval: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Published Successfully',
|
||||||
|
description: entityName
|
||||||
|
? `${entityName} is now live on the site.`
|
||||||
|
: `${entityType} is now live on the site.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
error: {
|
||||||
|
validation: (fieldCount: number) => {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Failed',
|
||||||
|
description: `Please fix ${fieldCount} error${fieldCount > 1 ? 's' : ''} before submitting.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
network: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Connection Error',
|
||||||
|
description: 'Unable to submit. Please check your connection and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generic: (error: string) => {
|
||||||
|
toast({
|
||||||
|
title: 'Submission Failed',
|
||||||
|
description: error,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -104,11 +104,26 @@ export default {
|
|||||||
"0%": { transform: "translateX(-100%)" },
|
"0%": { transform: "translateX(-100%)" },
|
||||||
"100%": { transform: "translateX(100%)" },
|
"100%": { transform: "translateX(100%)" },
|
||||||
},
|
},
|
||||||
|
"fade-in": {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(-4px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" }
|
||||||
|
},
|
||||||
|
"fade-out": {
|
||||||
|
"0%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
"100%": { opacity: "0", transform: "translateY(-4px)" }
|
||||||
|
},
|
||||||
|
"slide-in-down": {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(-8px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
shimmer: "shimmer 2s infinite",
|
shimmer: "shimmer 2s infinite",
|
||||||
|
"fade-in": "fade-in 0.2s ease-out",
|
||||||
|
"fade-out": "fade-out 0.2s ease-out",
|
||||||
|
"slide-in-down": "slide-in-down 0.3s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user