mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Fix: Add client-side validation
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState, useEffect } 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';
|
||||||
import { entitySchemas } from '@/lib/entityValidationSchemas';
|
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
|
||||||
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
|
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -17,7 +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 { MapPin, Save, X, Plus } from 'lucide-react';
|
import { MapPin, Save, X, Plus, AlertCircle } 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';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
@@ -167,6 +167,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
|
trigger,
|
||||||
formState: { errors }
|
formState: { errors }
|
||||||
} = useForm<ParkFormData>({
|
} = useForm<ParkFormData>({
|
||||||
resolver: zodResolver(entitySchemas.park),
|
resolver: zodResolver(entitySchemas.park),
|
||||||
@@ -202,6 +203,20 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
const handleFormSubmit = async (data: ParkFormData) => {
|
const handleFormSubmit = async (data: ParkFormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Pre-submission validation for required fields
|
||||||
|
const { valid, errors: validationErrors } = validateRequiredFields('park', data);
|
||||||
|
if (!valid) {
|
||||||
|
validationErrors.forEach(error => {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Missing Required Fields',
|
||||||
|
description: error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Block new photo uploads on edits
|
// CRITICAL: Block new photo uploads on edits
|
||||||
if (isEditing && data.images?.uploaded) {
|
if (isEditing && data.images?.uploaded) {
|
||||||
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
|
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
|
||||||
@@ -405,16 +420,29 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Location</Label>
|
<Label className="flex items-center gap-1">
|
||||||
|
Location
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
<LocationSearch
|
<LocationSearch
|
||||||
onLocationSelect={(location) => {
|
onLocationSelect={(location) => {
|
||||||
setValue('location', location);
|
setValue('location', location);
|
||||||
|
// Manually trigger validation for the location field
|
||||||
|
trigger('location');
|
||||||
}}
|
}}
|
||||||
initialLocationId={watch('location_id')}
|
initialLocationId={watch('location_id')}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
{errors.location && (
|
||||||
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
|
<p className="text-sm text-destructive flex items-center gap-1">
|
||||||
</p>
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
{errors.location.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!errors.location && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator & Property Owner Selection */}
|
{/* Operator & Property Owner Selection */}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { validateSubmissionHandler } from '@/lib/entityFormValidation';
|
|||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import type { RideTechnicalSpec, RideCoasterStat, RideNameHistory } from '@/types/database';
|
import type { RideTechnicalSpec, RideCoasterStat, RideNameHistory } from '@/types/database';
|
||||||
import type { TempCompanyData, TempRideModelData, TempParkData } from '@/types/company';
|
import type { TempCompanyData, TempRideModelData, TempParkData } from '@/types/company';
|
||||||
import { entitySchemas } from '@/lib/entityValidationSchemas';
|
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -266,6 +266,20 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
const handleFormSubmit = async (data: RideFormData) => {
|
const handleFormSubmit = async (data: RideFormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Pre-submission validation for required fields
|
||||||
|
const { valid, errors: validationErrors } = validateRequiredFields('ride', data);
|
||||||
|
if (!valid) {
|
||||||
|
validationErrors.forEach(error => {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Missing Required Fields',
|
||||||
|
description: error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Block new photo uploads on edits
|
// CRITICAL: Block new photo uploads on edits
|
||||||
if (isEditing && data.images?.uploaded) {
|
if (isEditing && data.images?.uploaded) {
|
||||||
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
|
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export const parkValidationSchema = z.object({
|
|||||||
}, {
|
}, {
|
||||||
message: 'Closing date must be after opening date',
|
message: 'Closing date must be after opening date',
|
||||||
path: ['closing_date'],
|
path: ['closing_date'],
|
||||||
|
}).refine((data) => {
|
||||||
|
// Either location object OR location_id must be provided
|
||||||
|
return !!(data.location || data.location_id);
|
||||||
|
}, {
|
||||||
|
message: 'Location is required. Please search and select a location for the park.',
|
||||||
|
path: ['location']
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -280,6 +286,12 @@ export const rideValidationSchema = z.object({
|
|||||||
.max(1000, 'Submission notes must be less than 1000 characters')
|
.max(1000, 'Submission notes must be less than 1000 characters')
|
||||||
.nullish()
|
.nullish()
|
||||||
.transform(val => val ?? undefined),
|
.transform(val => val ?? undefined),
|
||||||
|
}).refine((data) => {
|
||||||
|
// park_id is required (either real UUID or temp- reference)
|
||||||
|
return !!(data.park_id && data.park_id.trim().length > 0);
|
||||||
|
}, {
|
||||||
|
message: 'Park is required. Please select or create a park for this ride.',
|
||||||
|
path: ['park_id']
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -774,3 +786,31 @@ export async function validateMultipleItems(
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required fields before submission
|
||||||
|
* Returns user-friendly error messages
|
||||||
|
*/
|
||||||
|
export function validateRequiredFields(
|
||||||
|
entityType: keyof typeof entitySchemas,
|
||||||
|
data: any
|
||||||
|
): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (entityType === 'park') {
|
||||||
|
if (!data.location && !data.location_id) {
|
||||||
|
errors.push('Location is required. Please search and select a location for the park.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType === 'ride') {
|
||||||
|
if (!data.park_id || data.park_id.trim().length === 0) {
|
||||||
|
errors.push('Park is required. Please select or create a park for this ride.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user