Broaden full-field validation coverage

This commit is contained in:
karilint
2026-05-28 15:23:38 +03:00
parent a78fb2d599
commit 94092424c3
16 changed files with 501 additions and 353 deletions
@@ -18,7 +18,7 @@ import { ProjectTab } from './Tabs/ProjectTab'
import { SpeciesTab } from './Tabs/SpeciesTab' import { SpeciesTab } from './Tabs/SpeciesTab'
import { TaphonomyTab } from './Tabs/TaphonomyTab' import { TaphonomyTab } from './Tabs/TaphonomyTab'
import { EditDataType, LocalityDetailsType, ValidationErrors } from '@/shared/types' import { EditDataType, LocalityDetailsType, ValidationErrors } from '@/shared/types'
import { validateLocality } from '@/shared/validators/locality' import { validateLocality, validateLocalityFields } from '@/shared/validators/locality'
import { UpdateTab } from '../DetailView/common/UpdateTab' import { UpdateTab } from '../DetailView/common/UpdateTab'
import { emptyLocality } from '../DetailView/common/defaultValues' import { emptyLocality } from '../DetailView/common/defaultValues'
import { useNotify } from '@/hooks/notification' import { useNotify } from '@/hooks/notification'
@@ -164,6 +164,7 @@ export const LocalityDetails = ({
isNew={isNew} isNew={isNew}
onWrite={onWrite} onWrite={onWrite}
validator={validateLocality} validator={validateLocality}
validateFields={validateLocalityFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
hasStagingMode hasStagingMode
wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider}
@@ -2,7 +2,7 @@ import { useEditMuseumMutation, useGetMuseumDetailsQuery } from '../../redux/mus
import { DetailView, TabType } from '../DetailView/DetailView' import { DetailView, TabType } from '../DetailView/DetailView'
import { MuseumInfoTab } from './Tabs/MuseumInfoTab' import { MuseumInfoTab } from './Tabs/MuseumInfoTab'
import { emptyMuseum } from '../DetailView/common/defaultValues' import { emptyMuseum } from '../DetailView/common/defaultValues'
import { validateMuseum } from '@/shared/validators/museum' import { validateMuseum, validateMuseumFields } from '@/shared/validators/museum'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { CircularProgress } from '@mui/material' import { CircularProgress } from '@mui/material'
import { Museum, EditDataType, ValidationErrors } from '@/shared/types' import { Museum, EditDataType, ValidationErrors } from '@/shared/types'
@@ -58,6 +58,7 @@ export const MuseumDetails = ({
onWrite={onWrite} onWrite={onWrite}
isNew={isNew} isNew={isNew}
validator={validateMuseum} validator={validateMuseum}
validateFields={validateMuseumFields}
deleteFunction={undefined} deleteFunction={undefined}
wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider}
/> />
@@ -11,7 +11,7 @@ import { PersonTab } from './Tabs/PersonTab'
import { PersonRelationsTab } from './Tabs/PersonRelationsTab' import { PersonRelationsTab } from './Tabs/PersonRelationsTab'
import { useUser } from '@/hooks/user' import { useUser } from '@/hooks/user'
import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/shared/types' import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/shared/types'
import { validatePerson } from '@/shared/validators/person' import { validatePerson, validatePersonFields } from '@/shared/validators/person'
import { useNotify } from '@/hooks/notification' import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react' import { useEffect } from 'react'
import { emptyPerson } from '../DetailView/common/defaultValues' import { emptyPerson } from '../DetailView/common/defaultValues'
@@ -127,6 +127,7 @@ export const PersonDetails = () => {
tabs={tabs} tabs={tabs}
data={isNew ? emptyPerson : data!} data={isNew ? emptyPerson : data!}
validator={validatePerson} validator={validatePerson}
validateFields={validatePersonFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
/> />
) )
@@ -5,7 +5,7 @@ import { DetailView, TabType } from '../DetailView/DetailView'
import { CoordinatorTab } from './Tabs/CoordinatorTab' import { CoordinatorTab } from './Tabs/CoordinatorTab'
import { EditDataType, RegionDetails as RegionDetailsType, ValidationErrors } from '@/shared/types' import { EditDataType, RegionDetails as RegionDetailsType, ValidationErrors } from '@/shared/types'
import { useNotify } from '@/hooks/notification' import { useNotify } from '@/hooks/notification'
import { validateRegion } from '@/shared/validators/region' import { validateRegion, validateRegionFields } from '@/shared/validators/region'
import { useEffect } from 'react' import { useEffect } from 'react'
import { emptyRegion } from '../DetailView/common/defaultValues' import { emptyRegion } from '../DetailView/common/defaultValues'
@@ -66,6 +66,7 @@ export const RegionDetails = () => {
data={isNew ? emptyRegion : data!} data={isNew ? emptyRegion : data!}
onWrite={onWrite} onWrite={onWrite}
validator={validateRegion} validator={validateRegion}
validateFields={validateRegionFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
/> />
) )
@@ -12,7 +12,7 @@ import { TaxonomyTab } from './Tabs/TaxonomyTab'
import { TeethTab } from './Tabs/TeethTab' import { TeethTab } from './Tabs/TeethTab'
import { UpdateTab } from '../DetailView/common/UpdateTab' import { UpdateTab } from '../DetailView/common/UpdateTab'
import { EditDataType, SpeciesDetailsType, ValidationErrors } from '@/shared/types' import { EditDataType, SpeciesDetailsType, ValidationErrors } from '@/shared/types'
import { validateSpecies } from '@/shared/validators/species' import { validateSpecies, validateSpeciesFields } from '@/shared/validators/species'
import { emptySpecies } from '../DetailView/common/defaultValues' import { emptySpecies } from '../DetailView/common/defaultValues'
import { useNotify } from '@/hooks/notification' import { useNotify } from '@/hooks/notification'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -124,6 +124,7 @@ export const SpeciesDetails = ({
taxonomy={true} taxonomy={true}
hasStagingMode hasStagingMode
validator={validateSpecies} validator={validateSpecies}
validateFields={validateSpeciesFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider}
/> />
@@ -13,7 +13,7 @@ import { UpdateTab } from '../DetailView/common/UpdateTab'
import { DetailView, TabType } from '../DetailView/DetailView' import { DetailView, TabType } from '../DetailView/DetailView'
import { BoundTab } from './Tabs/BoundTab' import { BoundTab } from './Tabs/BoundTab'
import { TimeUnitTab } from './Tabs/TimeUnitTab.tsx' import { TimeUnitTab } from './Tabs/TimeUnitTab.tsx'
import { validateTimeBound } from '@/shared/validators/timeBound' import { validateTimeBound, validateTimeBoundFields } from '@/shared/validators/timeBound'
export const TimeBoundDetails = ({ export const TimeBoundDetails = ({
wrapWithUnsavedChangesProvider = true, wrapWithUnsavedChangesProvider = true,
@@ -108,6 +108,7 @@ export const TimeBoundDetails = ({
isNew={isNew} isNew={isNew}
onWrite={onWrite} onWrite={onWrite}
validator={validateTimeBound} validator={validateTimeBound}
validateFields={validateTimeBoundFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider}
/> />
@@ -13,7 +13,7 @@ import { DetailView, TabType } from '../DetailView/DetailView'
import { LocalityTab } from './Tabs/LocalityTab' import { LocalityTab } from './Tabs/LocalityTab'
import { TimeUnitTab } from './Tabs/TimeUnitTab' import { TimeUnitTab } from './Tabs/TimeUnitTab'
import { TimeUnitUpdateTab } from './Tabs/TimeUnitUpdateTab' import { TimeUnitUpdateTab } from './Tabs/TimeUnitUpdateTab'
import { validateTimeUnit } from '@/shared/validators/timeUnit' import { validateTimeUnit, validateTimeUnitFields } from '@/shared/validators/timeUnit'
import { makeEditData } from '../DetailView/Context/DetailContext' import { makeEditData } from '../DetailView/Context/DetailContext'
import { useDeleteTimeUnit } from '@/hooks/useDeleteTimeUnit' import { useDeleteTimeUnit } from '@/hooks/useDeleteTimeUnit'
import { getApiErrorMessage, isDuplicateNameError } from '@/utils/api' import { getApiErrorMessage, isDuplicateNameError } from '@/utils/api'
@@ -112,6 +112,7 @@ export const TimeUnitDetails = ({
data={isNew ? emptyTimeUnit : data!} data={isNew ? emptyTimeUnit : data!}
onWrite={onWrite} onWrite={onWrite}
validator={validateTimeUnit} validator={validateTimeUnit}
validateFields={validateTimeUnitFields}
deleteFunction={deleteFunction} deleteFunction={deleteFunction}
wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider}
/> />
+17 -6
View File
@@ -1,5 +1,5 @@
import { EditDataType, LocalityDetailsType } from '../types' import { EditDataType, LocalityDetailsType } from '../types'
import { Validators, validator } from './validator' import { Validators, validateFields, validator } from './validator'
import { validCountries } from './countryList' import { validCountries } from './countryList'
const pollenFields = ['pers_pollen_ap', 'pers_pollen_nap', 'pers_pollen_other'] as const const pollenFields = ['pers_pollen_ap', 'pers_pollen_nap', 'pers_pollen_other'] as const
@@ -58,10 +58,9 @@ const validatePollenRecordTotal = (editData: Partial<EditDataType<LocalityDetail
return return
} }
export const validateLocality = ( const createLocalityValidators = (
editData: EditDataType<LocalityDetailsType>, editData: EditDataType<LocalityDetailsType>
fieldName: keyof EditDataType<LocalityDetailsType> ): Validators<Partial<EditDataType<LocalityDetailsType>>> => {
) => {
const isAbsoluteDatingMethod = editData.date_meth === 'absolute' const isAbsoluteDatingMethod = editData.date_meth === 'absolute'
const isTimeUnitDatingMethod = editData.date_meth === 'time_unit' const isTimeUnitDatingMethod = editData.date_meth === 'time_unit'
const isCompositeDatingMethod = editData.date_meth === 'composite' const isCompositeDatingMethod = editData.date_meth === 'composite'
@@ -80,7 +79,7 @@ export const validateLocality = (
const compositeDatingMethodRequiredText = 'One age row must follow the rules for Absolute, the other for Time Unit' const compositeDatingMethodRequiredText = 'One age row must follow the rules for Absolute, the other for Time Unit'
const validators: Validators<Partial<EditDataType<LocalityDetailsType>>> = { return {
// const isNew = editData.lid === undefined // const isNew = editData.lid === undefined
date_meth: { date_meth: {
name: 'Dating method', name: 'Dating method',
@@ -266,6 +265,18 @@ export const validateLocality = (
asString: (value: string) => validateFraction('Maximum fraction', value), asString: (value: string) => validateFraction('Maximum fraction', value),
}, },
} }
}
export const validateLocality = (
editData: EditDataType<LocalityDetailsType>,
fieldName: keyof EditDataType<LocalityDetailsType>
) => {
const validators = createLocalityValidators(editData)
return validator<EditDataType<LocalityDetailsType>>(validators, editData, fieldName) return validator<EditDataType<LocalityDetailsType>>(validators, editData, fieldName)
} }
export const validateLocalityFields = (editData: Partial<EditDataType<LocalityDetailsType>>) =>
validateFields<EditDataType<LocalityDetailsType>>(
createLocalityValidators(editData as EditDataType<LocalityDetailsType>),
editData
)
+34 -31
View File
@@ -1,5 +1,5 @@
import { EditDataType, Museum } from '../types' import { EditDataType, Museum } from '../types'
import { Validators, validator } from './validator' import { Validators, validateFields, validator } from './validator'
/* /*
museum: string; museum: string;
@@ -14,36 +14,39 @@ import { Validators, validator } from './validator'
used_gene: boolean | null; used_gene: boolean | null;
*/ */
export const validateMuseum = (editData: EditDataType<Museum>, fieldName: keyof EditDataType<Museum>) => { const museumValidators: Validators<Partial<EditDataType<Museum>>> = {
const validators: Validators<Partial<EditDataType<Museum>>> = { museum: {
museum: { name: 'Museum',
name: 'Museum', required: true,
required: true, asString: museumCode => {
asString: museumCode => { if (museumCode.indexOf(' ') !== -1) return 'Museum code must not contain a space'
if (museumCode.indexOf(' ') !== -1) return 'Museum code must not contain a space' return null
return null
},
}, },
institution: { },
name: 'Institution', institution: {
required: true, name: 'Institution',
required: true,
},
city: {
name: 'City',
required: true,
},
country: {
name: 'Country',
required: true,
},
state_code: {
name: 'State code',
asString: value => {
if (value.length > 5) return 'State code must contain a maximum of 5 characters'
return null
}, },
city: { },
name: 'City',
required: true,
},
country: {
name: 'Country',
required: true,
},
state_code: {
name: 'State code',
asString: value => {
if (value.length > 5) return 'State code must contain a maximum of 5 characters'
return null
},
},
}
return validator<EditDataType<Museum>>(validators, editData, fieldName)
} }
export const validateMuseum = (editData: EditDataType<Museum>, fieldName: keyof EditDataType<Museum>) => {
return validator<EditDataType<Museum>>(museumValidators, editData, fieldName)
}
export const validateMuseumFields = (editData: Partial<EditDataType<Museum>>) =>
validateFields<EditDataType<Museum>>(museumValidators, editData)
+47 -44
View File
@@ -1,51 +1,54 @@
import { EditDataType, PersonDetailsType } from '../types' import { EditDataType, PersonDetailsType } from '../types'
import { Validators, validator } from './validator' import { Validators, validateFields, validator } from './validator'
const personValidators: Validators<Partial<EditDataType<PersonDetailsType>>> = {
initials: {
name: 'initials',
required: true,
asString: true,
minLength: 2,
maxLength: 8,
},
first_name: {
name: 'First Name',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
surname: {
name: 'Surname',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
email: {
name: 'Email',
required: true,
asString: true,
minLength: 5,
maxLength: 30,
},
organization: {
name: 'Organization',
required: true,
asString: true,
minLength: 2,
maxLength: 30,
},
country: {
name: 'Country',
required: true,
},
}
export const validatePerson = ( export const validatePerson = (
editData: EditDataType<PersonDetailsType>, editData: EditDataType<PersonDetailsType>,
fieldName: keyof EditDataType<PersonDetailsType> fieldName: keyof EditDataType<PersonDetailsType>
) => { ) => {
const validators: Validators<Partial<EditDataType<PersonDetailsType>>> = { return validator<EditDataType<PersonDetailsType>>(personValidators, editData, fieldName)
initials: {
name: 'initials',
required: true,
asString: true,
minLength: 2,
maxLength: 8,
},
first_name: {
name: 'First Name',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
surname: {
name: 'Surname',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
email: {
name: 'Email',
required: true,
asString: true,
minLength: 5,
maxLength: 30,
},
organization: {
name: 'Organization',
required: true,
asString: true,
minLength: 2,
maxLength: 30,
},
country: {
name: 'Country',
required: true,
},
}
return validator<EditDataType<PersonDetailsType>>(validators, editData, fieldName)
} }
export const validatePersonFields = (editData: Partial<EditDataType<PersonDetailsType>>) =>
validateFields<EditDataType<PersonDetailsType>>(personValidators, editData)
+28 -25
View File
@@ -1,5 +1,5 @@
import { EditDataType, RegionCoordinator, RegionCountry, RegionDetails } from '../types' import { EditDataType, RegionCoordinator, RegionCountry, RegionDetails } from '../types'
import { Validators, validator, ValidationError } from './validator' import { Validators, validateFields, validator, ValidationError } from './validator'
const countryCheck = (countries: EditDataType<RegionCountry[]>) => { const countryCheck = (countries: EditDataType<RegionCountry[]>) => {
for (const country of countries) { for (const country of countries) {
@@ -21,29 +21,32 @@ const coordinatorCheck = (coordinators: EditDataType<RegionCoordinator[]>) => {
} }
return null as ValidationError return null as ValidationError
} }
export const validateRegion = (editData: EditDataType<RegionDetails>, fieldName: keyof EditDataType<RegionDetails>) => { const regionValidators: Validators<Partial<EditDataType<RegionDetails>>> = {
const validators: Validators<Partial<EditDataType<RegionDetails>>> = { reg_coord_id: {
reg_coord_id: { name: 'reg_coord_id',
name: 'reg_coord_id', required: true,
required: true, },
region: {
name: 'Region',
required: true,
asString: value => {
if (value.trim().length === 0) return 'Region name must not be empty'
return null
}, },
region: { },
name: 'Region', now_reg_coord_country: {
required: true, name: 'Countries',
asString: value => { miscArray: countryCheck,
if (value.trim().length === 0) return 'Region name must not be empty' },
return null now_reg_coord_people: {
}, name: 'Region Coordinators',
}, miscArray: coordinatorCheck,
now_reg_coord_country: { },
name: 'Countries',
miscArray: countryCheck,
},
now_reg_coord_people: {
name: 'Region Coordinators',
miscArray: coordinatorCheck,
},
}
return validator<EditDataType<RegionDetails>>(validators, editData, fieldName)
} }
export const validateRegion = (editData: EditDataType<RegionDetails>, fieldName: keyof EditDataType<RegionDetails>) => {
return validator<EditDataType<RegionDetails>>(regionValidators, editData, fieldName)
}
export const validateRegionFields = (editData: Partial<EditDataType<RegionDetails>>) =>
validateFields<EditDataType<RegionDetails>>(regionValidators, editData)
+187 -180
View File
@@ -1,194 +1,201 @@
import { taxonStatusOptions } from '../taxonStatusOptions' import { taxonStatusOptions } from '../taxonStatusOptions'
import { EditDataType, SpeciesDetailsType } from '../types' import { EditDataType, SpeciesDetailsType } from '../types'
import { Validators, validator } from './validator' import { Validators, validateFields, validator } from './validator'
const isEmptyUniqIdentifier = (unique_identifier: string) => { const isEmptyUniqIdentifier = (unique_identifier: string) => {
if (unique_identifier === '' || unique_identifier === '-') return true if (unique_identifier === '' || unique_identifier === '-') return true
return false return false
} }
const validatePositiveInteger = (name: string, value: number) => {
if (!Number.isInteger(value) || value < 1) return `${name} must be a positive integer.`
return
}
const validateNonNegativeInteger = (name: string, value: number) => {
if (!Number.isInteger(value) || value < 0) return `${name} must be a non-negative integer.`
return
}
const validateIntegerInRange = (name: string, value: number, min: number, max: number) => {
if (!Number.isInteger(value)) return `${name} must be a whole number.`
if (value < min || value > max) return `${name} must be between ${min} and ${max}.`
return
}
const createSpeciesValidators = (
editData: EditDataType<SpeciesDetailsType>
): Validators<Partial<EditDataType<SpeciesDetailsType>>> => ({
subclass_or_superorder_name: {
name: 'Subclass or Superorder',
asString: (subClassName: string) => {
if (subClassName.indexOf(' ') !== -1) return 'Subclass must not contain any spaces.'
if (subClassName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Subclass must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
order_name: {
name: 'Order',
required: true,
asString: (orderName: string) => {
if (orderName !== 'incertae sedis' && orderName.indexOf(' ') !== -1)
return 'Order must not contain any spaces, unless the value is "incertae sedis".'
if (orderName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Order must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
suborder_or_superfamily_name: {
name: 'Suborder or Superfamily',
asString: (subOrderName: string) => {
if (subOrderName.indexOf(' ') !== -1) return 'Suborder must not contain any spaces.'
if (subOrderName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Suborder must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
family_name: {
name: 'Family',
required: true,
asString: (familyName: string) => {
if (familyName !== 'incertae sedis' && familyName.indexOf(' ') !== -1)
return 'Family must not contain any spaces, unless the value is "incertae sedis".'
if (familyName !== 'indet.' && editData.order_name === 'indet.')
return 'when the Family is indet., Genus must also be indet.'
if (familyName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Family must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
subfamily_name: {
name: 'Subfamily or Tribe',
asString: (subFamilyName: string) => {
if (subFamilyName.indexOf(' ') !== -1) return 'Subfamily must not contain any spaces.'
if (subFamilyName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Subfamily must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
genus_name: {
name: 'Genus',
required: true,
asString: (genusName: string) => {
if (genusName.indexOf(' ') !== -1) return 'Genus must not contain any spaces.'
if (genusName !== 'indet.' && editData.family_name === 'indet.')
return 'when the Family is indet., Genus must also be indet.'
if (genusName !== 'gen.' && editData.family_name === 'fam.') return 'when the Family is fam., Genus must be gen.'
if (genusName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Genus must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
species_name: {
name: 'Species',
required: true,
asString: (speciesName: string) => {
if (speciesName.indexOf(' ') !== -1) return 'Species name must not contain any spaces.'
if (speciesName !== 'indet.' && editData.genus_name === 'indet.')
return 'when the Genus is indet., Species must also be indet.'
if (speciesName !== 'sp.' && editData.genus_name === 'gen.') return 'when the Genus is gen., Species must be sp.'
if (speciesName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Species must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
taxonomic_status: {
name: 'Taxonomic Status',
asString: (taxonomicStatus: string) => {
const allowedTaxonStatuses = taxonStatusOptions
.slice(1)
.filter((option): option is string => typeof option === 'string')
if (!allowedTaxonStatuses.includes(taxonomicStatus) && taxonomicStatus !== '')
return `Taxonomic Status must be one of the following (or left empty): ${allowedTaxonStatuses.join(', ')}.`
return
},
},
body_mass: {
name: 'Body Mass (g)',
asNumber: (value: number) => validatePositiveInteger('Body Mass (g)', value),
},
brain_mass: {
name: 'Brain Mass (g)',
asNumber: (value: number) => validatePositiveInteger('Brain Mass (g)', value),
},
mw_or_low: {
name: 'Cusp Relief Low (OR%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Relief Low (OR%)', value, 0, 100),
},
mw_or_high: {
name: 'Cusp Relief High (OR%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Relief High (OR%)', value, 0, 100),
},
mw_cs_sharp: {
name: 'Cusp Shape Sharp (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Sharp (CS%)', value, 0, 100),
},
mw_cs_round: {
name: 'Cusp Shape Rounded (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Rounded (CS%)', value, 0, 100),
},
mw_cs_blunt: {
name: 'Cusp Shape Blunt (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Blunt (CS%)', value, 0, 100),
},
mw_scale_min: {
name: 'Scale Minimum',
asNumber: (value: number) => {
const nonNegativeError = validateNonNegativeInteger('Scale Minimum', value)
if (nonNegativeError) return nonNegativeError
if (typeof editData.mw_scale_max === 'number' && value > editData.mw_scale_max)
return 'Scale Minimum cannot be greater than Scale Maximum.'
return
},
},
mw_scale_max: {
name: 'Scale Maximum',
asNumber: (value: number) => {
const nonNegativeError = validateNonNegativeInteger('Scale Maximum', value)
if (nonNegativeError) return nonNegativeError
if (typeof editData.mw_scale_min === 'number' && value < editData.mw_scale_min)
return 'Scale Maximum cannot be less than Scale Minimum.'
return
},
},
mw_value: {
name: 'Reported Value',
asNumber: (value: number) => {
if (value < 0) return 'Reported Value cannot be negative.'
if (typeof editData.mw_scale_min === 'number' && value < editData.mw_scale_min)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
if (typeof editData.mw_scale_max === 'number' && value > editData.mw_scale_max)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
return
},
},
unique_identifier: {
name: 'Unique Identifier',
required: true,
asString: (uniqueIdentifier: string) => {
if (editData.species_name === 'sp.' && isEmptyUniqIdentifier(uniqueIdentifier ?? ''))
return 'when the species is sp., Unique Identifier must have a value.'
return
},
},
})
export const validateSpecies = ( export const validateSpecies = (
editData: EditDataType<SpeciesDetailsType>, editData: EditDataType<SpeciesDetailsType>,
fieldName: keyof EditDataType<SpeciesDetailsType> fieldName: keyof EditDataType<SpeciesDetailsType>
) => { ) => {
const validatePositiveInteger = (name: string, value: number) => { const validators = createSpeciesValidators(editData)
if (!Number.isInteger(value) || value < 1) return `${name} must be a positive integer.`
return
}
const validateNonNegativeInteger = (name: string, value: number) => {
if (!Number.isInteger(value) || value < 0) return `${name} must be a non-negative integer.`
return
}
const validateIntegerInRange = (name: string, value: number, min: number, max: number) => {
if (!Number.isInteger(value)) return `${name} must be a whole number.`
if (value < min || value > max) return `${name} must be between ${min} and ${max}.`
return
}
const validators: Validators<Partial<EditDataType<SpeciesDetailsType>>> = {
subclass_or_superorder_name: {
name: 'Subclass or Superorder',
asString: (subClassName: string) => {
if (subClassName.indexOf(' ') !== -1) return 'Subclass must not contain any spaces.'
if (subClassName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Subclass must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
order_name: {
name: 'Order',
required: true,
asString: (orderName: string) => {
if (orderName !== 'incertae sedis' && orderName.indexOf(' ') !== -1)
return 'Order must not contain any spaces, unless the value is "incertae sedis".'
if (orderName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Order must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
suborder_or_superfamily_name: {
name: 'Suborder or Superfamily',
asString: (subOrderName: string) => {
if (subOrderName.indexOf(' ') !== -1) return 'Suborder must not contain any spaces.'
if (subOrderName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Suborder must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
family_name: {
name: 'Family',
required: true,
asString: (familyName: string) => {
if (familyName !== 'incertae sedis' && familyName.indexOf(' ') !== -1)
return 'Family must not contain any spaces, unless the value is "incertae sedis".'
if (familyName !== 'indet.' && editData.order_name === 'indet.')
return 'when the Family is indet., Genus must also be indet.'
if (familyName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Family must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
subfamily_name: {
name: 'Subfamily or Tribe',
asString: (subFamilyName: string) => {
if (subFamilyName.indexOf(' ') !== -1) return 'Subfamily must not contain any spaces.'
if (subFamilyName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Subfamily must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
genus_name: {
name: 'Genus',
required: true,
asString: (genusName: string) => {
if (genusName.indexOf(' ') !== -1) return 'Genus must not contain any spaces.'
if (genusName !== 'indet.' && editData.family_name === 'indet.')
return 'when the Family is indet., Genus must also be indet.'
if (genusName !== 'gen.' && editData.family_name === 'fam.')
return 'when the Family is fam., Genus must be gen.'
if (genusName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Genus must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
species_name: {
name: 'Species',
required: true,
asString: (speciesName: string) => {
if (speciesName.indexOf(' ') !== -1) return 'Species name must not contain any spaces.'
if (speciesName !== 'indet.' && editData.genus_name === 'indet.')
return 'when the Genus is indet., Species must also be indet.'
if (speciesName !== 'sp.' && editData.genus_name === 'gen.')
return 'when the Genus is gen., Species must be sp.'
if (speciesName.indexOf('/') !== -1 && editData.taxonomic_status !== 'informal species')
return 'Species must not contain a forward slash (/), unless Taxonomic Status is set to "informal species".'
return
},
},
taxonomic_status: {
name: 'Taxonomic Status',
asString: (taxonomicStatus: string) => {
const allowedTaxonStatuses = taxonStatusOptions
.slice(1)
.filter((option): option is string => typeof option === 'string')
if (!allowedTaxonStatuses.includes(taxonomicStatus) && taxonomicStatus !== '')
return `Taxonomic Status must be one of the following (or left empty): ${allowedTaxonStatuses.join(', ')}.`
return
},
},
body_mass: {
name: 'Body Mass (g)',
asNumber: (value: number) => validatePositiveInteger('Body Mass (g)', value),
},
brain_mass: {
name: 'Brain Mass (g)',
asNumber: (value: number) => validatePositiveInteger('Brain Mass (g)', value),
},
mw_or_low: {
name: 'Cusp Relief Low (OR%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Relief Low (OR%)', value, 0, 100),
},
mw_or_high: {
name: 'Cusp Relief High (OR%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Relief High (OR%)', value, 0, 100),
},
mw_cs_sharp: {
name: 'Cusp Shape Sharp (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Sharp (CS%)', value, 0, 100),
},
mw_cs_round: {
name: 'Cusp Shape Rounded (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Rounded (CS%)', value, 0, 100),
},
mw_cs_blunt: {
name: 'Cusp Shape Blunt (CS%)',
asNumber: (value: number) => validateIntegerInRange('Cusp Shape Blunt (CS%)', value, 0, 100),
},
mw_scale_min: {
name: 'Scale Minimum',
asNumber: (value: number) => {
const nonNegativeError = validateNonNegativeInteger('Scale Minimum', value)
if (nonNegativeError) return nonNegativeError
if (typeof editData.mw_scale_max === 'number' && value > editData.mw_scale_max)
return 'Scale Minimum cannot be greater than Scale Maximum.'
return
},
},
mw_scale_max: {
name: 'Scale Maximum',
asNumber: (value: number) => {
const nonNegativeError = validateNonNegativeInteger('Scale Maximum', value)
if (nonNegativeError) return nonNegativeError
if (typeof editData.mw_scale_min === 'number' && value < editData.mw_scale_min)
return 'Scale Maximum cannot be less than Scale Minimum.'
return
},
},
mw_value: {
name: 'Reported Value',
asNumber: (value: number) => {
if (value < 0) return 'Reported Value cannot be negative.'
if (typeof editData.mw_scale_min === 'number' && value < editData.mw_scale_min)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
if (typeof editData.mw_scale_max === 'number' && value > editData.mw_scale_max)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
return
},
},
unique_identifier: {
name: 'Unique Identifier',
required: true,
asString: (uniqueIdentifier: string) => {
if (editData.species_name === 'sp.' && isEmptyUniqIdentifier(uniqueIdentifier ?? ''))
return 'when the species is sp., Unique Identifier must have a value.'
return
},
},
}
return validator<EditDataType<SpeciesDetailsType>>(validators, editData, fieldName) return validator<EditDataType<SpeciesDetailsType>>(validators, editData, fieldName)
} }
export const validateSpeciesFields = (editData: Partial<EditDataType<SpeciesDetailsType>>) =>
validateFields<EditDataType<SpeciesDetailsType>>(
createSpeciesValidators(editData as EditDataType<SpeciesDetailsType>),
editData
)
+69 -59
View File
@@ -1,64 +1,74 @@
import { EditDataType, TimeUnitDetailsType } from '../types' import { EditDataType, TimeUnitDetailsType } from '../types'
import { Validators, validator } from './validator' import { Validators, validateFields, validator } from './validator'
const createTimeUnitValidators = (
editData: EditDataType<TimeUnitDetailsType>
): Validators<EditDataType<Partial<TimeUnitDetailsType>>> => ({
tu_display_name: {
name: 'Name',
required: true,
},
sequence: {
name: 'Sequence',
required: true,
},
up_bnd: {
name: 'New Upper Bound',
required: true,
asNumber: (num: number) => {
if (num === editData.low_bnd) return 'Upper and lower bounds cannot be the same'
return
},
},
low_bnd: {
name: 'New Lower Bound',
required: true,
asNumber: (num: number) => {
if (num === editData.up_bnd) return 'Upper and lower bounds cannot be the same'
return
},
},
up_bound: {
name: 'Upper Bound',
required: () =>
editData.up_bnd !== undefined && editData.up_bnd !== null
? `Upper bound with ID ${editData.up_bnd} does not exist`
: 'This field is required',
miscCheck: () => {
if (editData.low_bound && editData.low_bound.age! === editData.up_bound!.age!) {
return 'Upper bound age cannot be the same as lower bound age'
}
if (editData.low_bound && editData.low_bound.age! < editData.up_bound!.age!) {
return 'Upper bound age has to be lower than lower bound age'
}
return
},
},
low_bound: {
name: 'Lower Bound',
required: () =>
editData.low_bnd !== undefined && editData.low_bnd !== null
? `Lower bound with ID ${editData.low_bnd} does not exist`
: 'This field is required',
miscCheck: () => {
if (editData.up_bound && editData.up_bound.age! === editData.low_bound!.age!) {
return 'Lower bound age cannot be the same as upper bound age'
}
if (editData.up_bound && editData.up_bound.age! > editData.low_bound!.age!) {
return 'Lower bound age has to be higher than upper bound age'
}
return
},
},
})
export const validateTimeUnit = (editData: EditDataType<TimeUnitDetailsType>, fieldName: keyof TimeUnitDetailsType) => { export const validateTimeUnit = (editData: EditDataType<TimeUnitDetailsType>, fieldName: keyof TimeUnitDetailsType) => {
const validators: Validators<EditDataType<Partial<TimeUnitDetailsType>>> = { const validators = createTimeUnitValidators(editData)
tu_display_name: {
name: 'Name',
required: true,
},
sequence: {
name: 'Sequence',
required: true,
},
up_bnd: {
name: 'New Upper Bound',
required: true,
asNumber: (num: number) => {
if (num === editData.low_bnd) return 'Upper and lower bounds cannot be the same'
return
},
},
low_bnd: {
name: 'New Lower Bound',
required: true,
asNumber: (num: number) => {
if (num === editData.up_bnd) return 'Upper and lower bounds cannot be the same'
return
},
},
up_bound: {
name: 'Upper Bound',
required: () =>
editData.up_bnd !== undefined && editData.up_bnd !== null
? `Upper bound with ID ${editData.up_bnd} does not exist`
: 'This field is required',
miscCheck: () => {
if (editData.low_bound && editData.low_bound.age! === editData.up_bound!.age!) {
return 'Upper bound age cannot be the same as lower bound age'
}
if (editData.low_bound && editData.low_bound.age! < editData.up_bound!.age!) {
return 'Upper bound age has to be lower than lower bound age'
}
return
},
},
low_bound: {
name: 'Lower Bound',
required: () =>
editData.low_bnd !== undefined && editData.low_bnd !== null
? `Lower bound with ID ${editData.low_bnd} does not exist`
: 'This field is required',
miscCheck: () => {
if (editData.up_bound && editData.up_bound.age! === editData.low_bound!.age!) {
return 'Lower bound age cannot be the same as upper bound age'
}
if (editData.up_bound && editData.up_bound.age! > editData.low_bound!.age!) {
return 'Lower bound age has to be higher than upper bound age'
}
return
},
},
}
return validator<EditDataType<TimeUnitDetailsType>>(validators, editData, fieldName) return validator<EditDataType<TimeUnitDetailsType>>(validators, editData, fieldName)
} }
export const validateTimeUnitFields = (editData: Partial<EditDataType<TimeUnitDetailsType>>) =>
validateFields<EditDataType<TimeUnitDetailsType>>(
createTimeUnitValidators(editData as EditDataType<TimeUnitDetailsType>),
editData
)
@@ -167,6 +167,7 @@ jest.mock('@/util/taxonomyUtilities', () => ({
jest.mock('@/shared/validators/species', () => ({ jest.mock('@/shared/validators/species', () => ({
validateSpecies: jest.fn(() => ({ name: '', error: null })), validateSpecies: jest.fn(() => ({ name: '', error: null })),
validateSpeciesFields: jest.fn(() => []),
})) }))
const mockUseDetailContext = useDetailContext as jest.MockedFunction<() => DetailContextType<LocalityDetailsType>> const mockUseDetailContext = useDetailContext as jest.MockedFunction<() => DetailContextType<LocalityDetailsType>>
@@ -1,6 +1,12 @@
import { describe, expect, it } from '@jest/globals' import { describe, expect, it } from '@jest/globals'
import { validateLocalityFields } from '@/shared/validators/locality'
import { validateMuseumFields } from '@/shared/validators/museum'
import { validatePersonFields } from '@/shared/validators/person'
import { validateRegionFields } from '@/shared/validators/region'
import { validateSpeciesFields } from '@/shared/validators/species'
import { validateTimeBoundFields } from '@/shared/validators/timeBound' import { validateTimeBoundFields } from '@/shared/validators/timeBound'
import { validateTimeUnitFields } from '@/shared/validators/timeUnit'
import { validateFields, type Validators } from '@/shared/validators/validator' import { validateFields, type Validators } from '@/shared/validators/validator'
type TestData = { type TestData = {
@@ -43,3 +49,74 @@ describe('validateTimeBoundFields', () => {
]) ])
}) })
}) })
describe('entity full-field validators', () => {
it('reports missing Time Unit required fields', () => {
const errors = validateTimeUnitFields({})
expect(errors).toEqual(
expect.arrayContaining([
{ name: 'Name', error: 'This field is required' },
{ name: 'Sequence', error: 'This field is required' },
{ name: 'New Upper Bound', error: 'This field is required' },
{ name: 'New Lower Bound', error: 'This field is required' },
{ name: 'Upper Bound', error: 'This field is required' },
{ name: 'Lower Bound', error: 'This field is required' },
])
)
})
it('reports missing simple entity required fields', () => {
expect(validatePersonFields({})).toEqual(
expect.arrayContaining([
{ name: 'initials', error: 'This field is required' },
{ name: 'First Name', error: 'This field is required' },
{ name: 'Country', error: 'This field is required' },
])
)
expect(validateMuseumFields({})).toEqual(
expect.arrayContaining([
{ name: 'Museum', error: 'This field is required' },
{ name: 'Institution', error: 'This field is required' },
{ name: 'Country', error: 'This field is required' },
])
)
expect(validateRegionFields({})).toEqual(
expect.arrayContaining([
{ name: 'reg_coord_id', error: 'This field is required' },
{ name: 'Region', error: 'This field is required' },
])
)
})
it('reports missing Species taxonomy fields', () => {
const errors = validateSpeciesFields({})
expect(errors).toEqual(
expect.arrayContaining([
{ name: 'Order', error: 'This field is required' },
{ name: 'Family', error: 'This field is required' },
{ name: 'Genus', error: 'This field is required' },
{ name: 'Species', error: 'This field is required' },
{ name: 'Unique Identifier', error: 'This field is required' },
])
)
})
it('reports missing Locality required and conditional fields', () => {
const errors = validateLocalityFields({ date_meth: 'time_unit' })
expect(errors).toEqual(
expect.arrayContaining([
{ name: 'Age (max)', error: 'This field is required' },
{ name: 'Age (min)', error: 'This field is required' },
{ name: 'Basis for age (Time unit, min)', error: 'This field is required' },
{ name: 'Basis for age (Time unit, max)', error: 'This field is required' },
{ name: 'Locality name', error: 'This field is required' },
{ name: 'Country', error: 'This field is required' },
])
)
})
})
+27 -1
View File
@@ -9,12 +9,20 @@ import { PageContextProvider } from '@/components/Page'
import type { TimeUnit, TimeUnitDetailsType } from '@/shared/types' import type { TimeUnit, TimeUnitDetailsType } from '@/shared/types'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
jest.setTimeout(15000)
jest.mock('lodash-es', () => ({ jest.mock('lodash-es', () => ({
cloneDeep: (value: unknown) => value, cloneDeep: (value: unknown) => value,
})) }))
jest.mock('@/shared/validators/timeUnit', () => ({ jest.mock('@/shared/validators/timeUnit', () => ({
validateTimeUnit: () => ({ name: 'tu_display_name', error: '' }), validateTimeUnit: () => ({ name: 'tu_display_name', error: '' }),
validateTimeUnitFields: () => [],
}))
jest.mock('@/shared/validators/validator', () => ({
...jest.requireActual<typeof import('@/shared/validators/validator')>('@/shared/validators/validator'),
referenceValidator: () => null,
})) }))
jest.mock('@/util/config', () => ({ jest.mock('@/util/config', () => ({
@@ -23,6 +31,10 @@ jest.mock('@/util/config', () => ({
ENV: 'test', ENV: 'test',
})) }))
jest.mock('@/hooks/user', () => ({
useUser: () => ({ role: 1, initials: 'TEST' }),
}))
const mockEditTimeUnitMutation = jest.fn() const mockEditTimeUnitMutation = jest.fn()
const mockGetAllTimeUnitsQuery = jest.fn() const mockGetAllTimeUnitsQuery = jest.fn()
@@ -38,6 +50,17 @@ jest.mock('@/redux/timeUnitReducer', () => ({
useGetAllTimeUnitsQuery: (...args: unknown[]) => mockGetAllTimeUnitsQuery(...args), useGetAllTimeUnitsQuery: (...args: unknown[]) => mockGetAllTimeUnitsQuery(...args),
})) }))
jest.mock('@/redux/speciesReducer', () => ({
useGetAllSpeciesQuery: () => ({ data: undefined, isFetching: false, isLoading: false }),
useGetAllSynonymsQuery: () => ({ data: undefined, isFetching: false, isLoading: false }),
}))
jest.mock('@/redux/referenceReducer', () => ({
useGetAllReferencesQuery: () => ({ data: [], isError: false }),
useGetReferenceTypesQuery: () => ({ data: [] }),
useEditReferenceMutation: () => [jest.fn(), { isLoading: false }],
}))
jest.mock('@/components/Sequence/SequenceSelect', () => ({ jest.mock('@/components/Sequence/SequenceSelect', () => ({
SequenceSelect: () => <div data-testid="sequence-select" />, SequenceSelect: () => <div data-testid="sequence-select" />,
})) }))
@@ -178,10 +201,13 @@ describe('TimeUnit creation duplicate name handling', () => {
return { unwrap: unwrapMock } return { unwrap: unwrapMock }
}) })
renderTimeUnitCreation() const { container } = renderTimeUnitCreation()
const user = userEvent.setup() const user = userEvent.setup()
const nameInput = container.querySelector('input#tu_display_name-textfield') as HTMLInputElement
await user.type(nameInput, 'Rank Test Time Unit')
const rankSelect = screen.getByLabelText('Rank') const rankSelect = screen.getByLabelText('Rank')
await user.click(rankSelect) await user.click(rankSelect)
await user.click(await screen.findByText('No value')) await user.click(await screen.findByText('No value'))