Fix: Add client-side validation

This commit is contained in:
gpt-engineer-app[bot]
2025-11-05 21:13:04 +00:00
parent caa6c788df
commit 34300a89c4
3 changed files with 89 additions and 7 deletions

View File

@@ -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')}
/> />
{errors.location && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{errors.location.message}
</p>
)}
{!errors.location && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Search for the park's location using OpenStreetMap. Location will be created when submission is approved. Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
</p> </p>
)}
</div> </div>
{/* Operator & Property Owner Selection */} {/* Operator & Property Owner Selection */}

View File

@@ -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);

View File

@@ -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
};
}