diff --git a/src/app/(modules)/package/components/PackageDialog.tsx b/src/app/(modules)/package/components/PackageDialog.tsx index eba7b77..85c2840 100644 --- a/src/app/(modules)/package/components/PackageDialog.tsx +++ b/src/app/(modules)/package/components/PackageDialog.tsx @@ -74,7 +74,11 @@ export function PackageDialog({ -
+
@@ -120,7 +124,7 @@ export function PackageDialog({ @@ -131,21 +135,22 @@ export function PackageDialog({

Chainage range

- Optional start and end kilometre values. + start and end kilometre values.

@@ -153,14 +158,15 @@ export function PackageDialog({ @@ -179,7 +185,9 @@ export function PackageDialog({ Cancel diff --git a/src/app/(modules)/package/hooks/usePackageForm.ts b/src/app/(modules)/package/hooks/usePackageForm.ts index 37ecdcc..ad35945 100644 --- a/src/app/(modules)/package/hooks/usePackageForm.ts +++ b/src/app/(modules)/package/hooks/usePackageForm.ts @@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { + optionalIntegerNumber, + validateChainageRange, +} from '@/lib/validation/numberField'; import { packageService } from '@/services/api'; import type { Package, PackageCreate, PackageUpdate } from '@/types'; import { packageKeys } from '../queries/packageKeys'; -const optionalNumber = z.string().refine( - (value) => { - if (!value.trim()) { - return true; - } - return Number.isFinite(Number(value)); - }, - { message: 'Enter a valid number' }, -); - -const packageFormSchema = z.object({ - id: z.string().optional(), - project_id: z.string().trim().min(1, 'Project is required'), - name: z.string().trim().min(1, 'Package name is required'), - region: z.string().optional(), - chainage_start_km: optionalNumber, - chainage_end_km: optionalNumber, -}); +const packageFormSchema = z + .object({ + id: z.string().optional(), + project_id: z.string().trim().min(1, 'Project is required'), + name: z.string().trim().min(1, 'Package name is required'), + region: z.string().optional(), + chainage_start_km: optionalIntegerNumber, + chainage_end_km: optionalIntegerNumber, + }) + .superRefine(validateChainageRange); export type PackageFormValues = z.infer; diff --git a/src/app/(modules)/project/components/ProjectDialog.tsx b/src/app/(modules)/project/components/ProjectDialog.tsx index eaa48b7..ae183b0 100644 --- a/src/app/(modules)/project/components/ProjectDialog.tsx +++ b/src/app/(modules)/project/components/ProjectDialog.tsx @@ -66,7 +66,11 @@ export function ProjectDialog({ - +
@@ -94,14 +98,14 @@ export function ProjectDialog({ @@ -127,7 +131,9 @@ export function ProjectDialog({ id="project-start-lat" type="number" step="any" - placeholder="0.0000" + min="-90" + max="90" + placeholder="Enter start latitude" aria-invalid={!!startLatErrorMessage} {...register('start_lat')} /> @@ -141,7 +147,9 @@ export function ProjectDialog({ id="project-start-lng" type="number" step="any" - placeholder="0.0000" + min="-180" + max="180" + placeholder="Enter start longitude" aria-invalid={!!startLngErrorMessage} {...register('start_lng')} /> @@ -158,7 +166,9 @@ export function ProjectDialog({ id="project-end-lat" type="number" step="any" - placeholder="0.0000" + min="-90" + max="90" + placeholder="Enter end latitude" aria-invalid={!!endLatErrorMessage} {...register('end_lat')} /> @@ -172,7 +182,9 @@ export function ProjectDialog({ id="project-end-lng" type="number" step="any" - placeholder="0.0000" + min="-180" + max="180" + placeholder="Enter end longitude" aria-invalid={!!endLngErrorMessage} {...register('end_lng')} /> @@ -192,7 +204,9 @@ export function ProjectDialog({ Cancel diff --git a/src/app/(modules)/project/hooks/useProjectForm.ts b/src/app/(modules)/project/hooks/useProjectForm.ts index d6ae48d..c420a1e 100644 --- a/src/app/(modules)/project/hooks/useProjectForm.ts +++ b/src/app/(modules)/project/hooks/useProjectForm.ts @@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { + optionalLatitude, + optionalLongitude, +} from '@/lib/validation/numberField'; import { projectService } from '@/services/api'; import type { Project, ProjectCreate, ProjectUpdate } from '@/types'; 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({ id: z.string().optional(), name: z.string().trim().min(1, 'Project name is required'), state: z.string().optional(), corridor_name: z.string().optional(), - start_lat: optionalCoordinate, - start_lng: optionalCoordinate, - end_lat: optionalCoordinate, - end_lng: optionalCoordinate, + start_lat: optionalLatitude, + start_lng: optionalLongitude, + end_lat: optionalLatitude, + end_lng: optionalLongitude, }); export type ProjectFormValues = z.infer; diff --git a/src/app/(modules)/segment/components/SegmentDialog.tsx b/src/app/(modules)/segment/components/SegmentDialog.tsx index d3159ee..babe360 100644 --- a/src/app/(modules)/segment/components/SegmentDialog.tsx +++ b/src/app/(modules)/segment/components/SegmentDialog.tsx @@ -182,16 +182,16 @@ export function SegmentDialog({
@@ -199,16 +199,16 @@ export function SegmentDialog({ @@ -312,7 +312,9 @@ export function SegmentDialog({ Cancel diff --git a/src/app/(modules)/segment/hooks/useSegmentForm.ts b/src/app/(modules)/segment/hooks/useSegmentForm.ts index ce4895d..c27fd08 100644 --- a/src/app/(modules)/segment/hooks/useSegmentForm.ts +++ b/src/app/(modules)/segment/hooks/useSegmentForm.ts @@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { + requiredIntegerNumber, + requiredLatitude, + requiredLongitude, + validateChainageRange, +} from '@/lib/validation/numberField'; import { chainageService } from '@/services/api'; import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types'; import { segmentKeys } from '../queries/segmentKeys'; -const requiredNumber = (message: string) => - z - .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(), - project_id: z.string().trim().min(1, 'Project is required'), - package_id: z.string().trim().min(1, 'Package is required'), - segment_name: z.string().trim().min(1, 'Segment name is required'), - chainage_start_km: requiredNumber('Start km is required'), - chainage_end_km: requiredNumber('End km is required'), - start_lat: latitude, - start_lng: longitude, - end_lat: latitude, - end_lng: longitude, - direction: z.enum(['UP', 'DOWN']), -}); +const segmentFormSchema = z + .object({ + id: z.string().optional(), + project_id: z.string().trim().min(1, 'Project is required'), + package_id: z.string().trim().min(1, 'Package is required'), + segment_name: z.string().trim().min(1, 'Segment name is required'), + chainage_start_km: requiredIntegerNumber('Start km is required'), + chainage_end_km: requiredIntegerNumber('End km is required'), + start_lat: requiredLatitude, + start_lng: requiredLongitude, + end_lat: requiredLatitude, + end_lng: requiredLongitude, + direction: z.enum(['UP', 'DOWN']), + }) + .superRefine(validateChainageRange); export type SegmentFormValues = z.infer; diff --git a/src/lib/validation/numberField.ts b/src/lib/validation/numberField.ts new file mode 100644 index 0000000..317e717 --- /dev/null +++ b/src/lib/validation/numberField.ts @@ -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'], + }); +}