feat: add shared validation for chainage and coordinates

This commit is contained in:
2026-06-23 18:32:38 +05:30
parent 066374bebc
commit b282fdf56d
7 changed files with 219 additions and 95 deletions

View File

@@ -74,7 +74,11 @@ export function PackageDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4"> <div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -120,7 +124,7 @@ export function PackageDialog({
<FormField id="package-region" label="Region"> <FormField id="package-region" label="Region">
<Input <Input
id="package-region" id="package-region"
placeholder="North Zone" placeholder="Enter region"
{...register('region')} {...register('region')}
/> />
</FormField> </FormField>
@@ -131,21 +135,22 @@ export function PackageDialog({
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage range</h3> <h3 className="text-sm font-semibold">Chainage range</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Optional start and end kilometre values. start and end kilometre values.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="package-start" id="package-start"
label="Start (km)" label="Start Chainage"
error={startErrorMessage} error={startErrorMessage}
> >
<Input <Input
id="package-start" id="package-start"
type="number" type="number"
step="0.01" step="1"
placeholder="0.00" min="0"
placeholder="Enter start chainage"
aria-invalid={!!startErrorMessage} aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')} {...register('chainage_start_km')}
/> />
@@ -153,14 +158,15 @@ export function PackageDialog({
<FormField <FormField
id="package-end" id="package-end"
label="End (km)" label="End Chainage"
error={endErrorMessage} error={endErrorMessage}
> >
<Input <Input
id="package-end" id="package-end"
type="number" type="number"
step="0.01" step="1"
placeholder="0.00" min="0"
placeholder="Enter end chainage"
aria-invalid={!!endErrorMessage} aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')} {...register('chainage_end_km')}
/> />
@@ -179,7 +185,9 @@ export function PackageDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{packageId ? 'Update Package' : 'Create Package'} {packageId ? 'Update Package' : 'Create Package'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import {
optionalIntegerNumber,
validateChainageRange,
} from '@/lib/validation/numberField';
import { packageService } from '@/services/api'; import { packageService } from '@/services/api';
import type { Package, PackageCreate, PackageUpdate } from '@/types'; import type { Package, PackageCreate, PackageUpdate } from '@/types';
import { packageKeys } from '../queries/packageKeys'; import { packageKeys } from '../queries/packageKeys';
const optionalNumber = z.string().refine( const packageFormSchema = z
(value) => { .object({
if (!value.trim()) {
return true;
}
return Number.isFinite(Number(value));
},
{ message: 'Enter a valid number' },
);
const packageFormSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'), project_id: z.string().trim().min(1, 'Project is required'),
name: z.string().trim().min(1, 'Package name is required'), name: z.string().trim().min(1, 'Package name is required'),
region: z.string().optional(), region: z.string().optional(),
chainage_start_km: optionalNumber, chainage_start_km: optionalIntegerNumber,
chainage_end_km: optionalNumber, chainage_end_km: optionalIntegerNumber,
}); })
.superRefine(validateChainageRange);
export type PackageFormValues = z.infer<typeof packageFormSchema>; export type PackageFormValues = z.infer<typeof packageFormSchema>;

View File

@@ -66,7 +66,11 @@ export function ProjectDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4"> <div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -94,14 +98,14 @@ export function ProjectDialog({
<FormField id="project-state" label="State"> <FormField id="project-state" label="State">
<Input <Input
id="project-state" id="project-state"
placeholder="Maharashtra" placeholder="Enter state"
{...register('state')} {...register('state')}
/> />
</FormField> </FormField>
<FormField id="project-corridor" label="Corridor Name"> <FormField id="project-corridor" label="Corridor Name">
<Input <Input
id="project-corridor" id="project-corridor"
placeholder="Mumbai-Goa Highway" placeholder="Enter corridor name"
{...register('corridor_name')} {...register('corridor_name')}
/> />
</FormField> </FormField>
@@ -127,7 +131,9 @@ export function ProjectDialog({
id="project-start-lat" id="project-start-lat"
type="number" type="number"
step="any" step="any"
placeholder="0.0000" min="-90"
max="90"
placeholder="Enter start latitude"
aria-invalid={!!startLatErrorMessage} aria-invalid={!!startLatErrorMessage}
{...register('start_lat')} {...register('start_lat')}
/> />
@@ -141,7 +147,9 @@ export function ProjectDialog({
id="project-start-lng" id="project-start-lng"
type="number" type="number"
step="any" step="any"
placeholder="0.0000" min="-180"
max="180"
placeholder="Enter start longitude"
aria-invalid={!!startLngErrorMessage} aria-invalid={!!startLngErrorMessage}
{...register('start_lng')} {...register('start_lng')}
/> />
@@ -158,7 +166,9 @@ export function ProjectDialog({
id="project-end-lat" id="project-end-lat"
type="number" type="number"
step="any" step="any"
placeholder="0.0000" min="-90"
max="90"
placeholder="Enter end latitude"
aria-invalid={!!endLatErrorMessage} aria-invalid={!!endLatErrorMessage}
{...register('end_lat')} {...register('end_lat')}
/> />
@@ -172,7 +182,9 @@ export function ProjectDialog({
id="project-end-lng" id="project-end-lng"
type="number" type="number"
step="any" step="any"
placeholder="0.0000" min="-180"
max="180"
placeholder="Enter end longitude"
aria-invalid={!!endLngErrorMessage} aria-invalid={!!endLngErrorMessage}
{...register('end_lng')} {...register('end_lng')}
/> />
@@ -192,7 +204,9 @@ export function ProjectDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{projectId ? 'Update Project' : 'Create Project'} {projectId ? 'Update Project' : 'Create Project'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import {
optionalLatitude,
optionalLongitude,
} from '@/lib/validation/numberField';
import { projectService } from '@/services/api'; import { projectService } from '@/services/api';
import type { Project, ProjectCreate, ProjectUpdate } from '@/types'; import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
import { projectKeys } from '../queries/projectKeys'; import { projectKeys } from '../queries/projectKeys';
const optionalCoordinate = z.string().refine(
(value) => {
if (!value.trim()) {
return true;
}
return Number.isFinite(Number(value));
},
{ message: 'Enter a valid number' },
);
const projectFormSchema = z.object({ const projectFormSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().trim().min(1, 'Project name is required'), name: z.string().trim().min(1, 'Project name is required'),
state: z.string().optional(), state: z.string().optional(),
corridor_name: z.string().optional(), corridor_name: z.string().optional(),
start_lat: optionalCoordinate, start_lat: optionalLatitude,
start_lng: optionalCoordinate, start_lng: optionalLongitude,
end_lat: optionalCoordinate, end_lat: optionalLatitude,
end_lng: optionalCoordinate, end_lng: optionalLongitude,
}); });
export type ProjectFormValues = z.infer<typeof projectFormSchema>; export type ProjectFormValues = z.infer<typeof projectFormSchema>;

View File

@@ -182,16 +182,16 @@ export function SegmentDialog({
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="segment-start-km" id="segment-start-km"
label="Start (km)" label="Start Chainage"
required required
error={getError('chainage_start_km')} error={getError('chainage_start_km')}
> >
<Input <Input
id="segment-start-km" id="segment-start-km"
type="number" type="number"
step="any" step="1"
min="0" min="0"
placeholder="0.0" placeholder="Enter start chainage"
aria-invalid={!!getError('chainage_start_km')} aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')} {...register('chainage_start_km')}
/> />
@@ -199,16 +199,16 @@ export function SegmentDialog({
<FormField <FormField
id="segment-end-km" id="segment-end-km"
label="End (km)" label="End Chainage"
required required
error={getError('chainage_end_km')} error={getError('chainage_end_km')}
> >
<Input <Input
id="segment-end-km" id="segment-end-km"
type="number" type="number"
step="any" step="1"
min="0" min="0"
placeholder="1.0" placeholder="Enter end chainage"
aria-invalid={!!getError('chainage_end_km')} aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')} {...register('chainage_end_km')}
/> />
@@ -312,7 +312,9 @@ export function SegmentDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{segmentId ? 'Update Segment' : 'Create Segment'} {segmentId ? 'Update Segment' : 'Create Segment'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import {
requiredIntegerNumber,
requiredLatitude,
requiredLongitude,
validateChainageRange,
} from '@/lib/validation/numberField';
import { chainageService } from '@/services/api'; import { chainageService } from '@/services/api';
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types'; import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
import { segmentKeys } from '../queries/segmentKeys'; import { segmentKeys } from '../queries/segmentKeys';
const requiredNumber = (message: string) => const segmentFormSchema = z
z .object({
.string()
.trim()
.min(1, message)
.refine((value) => Number.isFinite(Number(value)), {
message: 'Enter a valid number',
});
const latitude = requiredNumber('Latitude is required').refine(
(value) => {
const numberValue = Number(value);
return numberValue >= -90 && numberValue <= 90;
},
{ message: 'Latitude must be between -90 and 90' },
);
const longitude = requiredNumber('Longitude is required').refine(
(value) => {
const numberValue = Number(value);
return numberValue >= -180 && numberValue <= 180;
},
{ message: 'Longitude must be between -180 and 180' },
);
const segmentFormSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'), project_id: z.string().trim().min(1, 'Project is required'),
package_id: z.string().trim().min(1, 'Package is required'), package_id: z.string().trim().min(1, 'Package is required'),
segment_name: z.string().trim().min(1, 'Segment name is required'), segment_name: z.string().trim().min(1, 'Segment name is required'),
chainage_start_km: requiredNumber('Start km is required'), chainage_start_km: requiredIntegerNumber('Start km is required'),
chainage_end_km: requiredNumber('End km is required'), chainage_end_km: requiredIntegerNumber('End km is required'),
start_lat: latitude, start_lat: requiredLatitude,
start_lng: longitude, start_lng: requiredLongitude,
end_lat: latitude, end_lat: requiredLatitude,
end_lng: longitude, end_lng: requiredLongitude,
direction: z.enum(['UP', 'DOWN']), direction: z.enum(['UP', 'DOWN']),
}); })
.superRefine(validateChainageRange);
export type SegmentFormValues = z.infer<typeof segmentFormSchema>; export type SegmentFormValues = z.infer<typeof segmentFormSchema>;

View File

@@ -0,0 +1,127 @@
import { z } from 'zod';
interface ChainageRangeValues {
chainage_start_km: string;
chainage_end_km: string;
}
function isNonNegativeWholeNumberText(value: string) {
return /^\d+$/.test(value.trim());
}
function isFiniteNumberText(value: string) {
return Number.isFinite(Number(value.trim()));
}
function coordinateSchema({
requiredMessage,
rangeMessage,
min,
max,
}: {
requiredMessage?: string;
rangeMessage: string;
min: number;
max: number;
}) {
const schema = requiredMessage
? z.string().trim().min(1, requiredMessage)
: z.string();
return schema
.refine(
(value) => {
const trimmedValue = value.trim();
return !trimmedValue || isFiniteNumberText(trimmedValue);
},
{ message: 'Enter a valid number' },
)
.refine(
(value) => {
const trimmedValue = value.trim();
if (!trimmedValue || !isFiniteNumberText(trimmedValue)) {
return true;
}
const numberValue = Number(trimmedValue);
return numberValue >= min && numberValue <= max;
},
{ message: rangeMessage },
);
}
export const optionalLatitude = coordinateSchema({
min: -90,
max: 90,
rangeMessage: 'Latitude must be between -90 and 90',
});
export const optionalLongitude = coordinateSchema({
min: -180,
max: 180,
rangeMessage: 'Longitude must be between -180 and 180',
});
export const requiredLatitude = coordinateSchema({
requiredMessage: 'Latitude is required',
min: -90,
max: 90,
rangeMessage: 'Latitude must be between -90 and 90',
});
export const requiredLongitude = coordinateSchema({
requiredMessage: 'Longitude is required',
min: -180,
max: 180,
rangeMessage: 'Longitude must be between -180 and 180',
});
export const optionalIntegerNumber = z.string().refine(
(value) => {
const trimmedValue = value.trim();
if (!trimmedValue) {
return true;
}
return isNonNegativeWholeNumberText(trimmedValue);
},
{ message: 'Enter a whole number 0 or above' },
);
export const requiredIntegerNumber = (message: string) =>
z.string().trim().min(1, message).refine(isNonNegativeWholeNumberText, {
message: 'Enter a whole number 0 or above',
});
export function validateChainageRange(
values: ChainageRangeValues,
ctx: z.RefinementCtx,
) {
const startValue = values.chainage_start_km.trim();
const endValue = values.chainage_end_km.trim();
if (!startValue || !endValue) {
return;
}
const start = Number(startValue);
const end = Number(endValue);
if (
!Number.isFinite(start) ||
!Number.isFinite(end) ||
!isNonNegativeWholeNumberText(startValue) ||
!isNonNegativeWholeNumberText(endValue) ||
start <= end
) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Start km cannot be greater than end km',
path: ['chainage_start_km'],
});
}