diff --git a/frontend/src/components/Locality/LocalityDetails.tsx b/frontend/src/components/Locality/LocalityDetails.tsx index 35808454..d0aca5c4 100755 --- a/frontend/src/components/Locality/LocalityDetails.tsx +++ b/frontend/src/components/Locality/LocalityDetails.tsx @@ -18,7 +18,7 @@ import { ProjectTab } from './Tabs/ProjectTab' import { SpeciesTab } from './Tabs/SpeciesTab' import { TaphonomyTab } from './Tabs/TaphonomyTab' 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 { emptyLocality } from '../DetailView/common/defaultValues' import { useNotify } from '@/hooks/notification' @@ -164,6 +164,7 @@ export const LocalityDetails = ({ isNew={isNew} onWrite={onWrite} validator={validateLocality} + validateFields={validateLocalityFields} deleteFunction={deleteFunction} hasStagingMode wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} diff --git a/frontend/src/components/Museum/MuseumDetails.tsx b/frontend/src/components/Museum/MuseumDetails.tsx index f011ef93..00ec5bd2 100644 --- a/frontend/src/components/Museum/MuseumDetails.tsx +++ b/frontend/src/components/Museum/MuseumDetails.tsx @@ -2,7 +2,7 @@ import { useEditMuseumMutation, useGetMuseumDetailsQuery } from '../../redux/mus import { DetailView, TabType } from '../DetailView/DetailView' import { MuseumInfoTab } from './Tabs/MuseumInfoTab' 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 { CircularProgress } from '@mui/material' import { Museum, EditDataType, ValidationErrors } from '@/shared/types' @@ -58,6 +58,7 @@ export const MuseumDetails = ({ onWrite={onWrite} isNew={isNew} validator={validateMuseum} + validateFields={validateMuseumFields} deleteFunction={undefined} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} /> diff --git a/frontend/src/components/Person/PersonDetails.tsx b/frontend/src/components/Person/PersonDetails.tsx index bae92b59..f420f102 100755 --- a/frontend/src/components/Person/PersonDetails.tsx +++ b/frontend/src/components/Person/PersonDetails.tsx @@ -11,7 +11,7 @@ import { PersonTab } from './Tabs/PersonTab' import { PersonRelationsTab } from './Tabs/PersonRelationsTab' import { useUser } from '@/hooks/user' 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 { useEffect } from 'react' import { emptyPerson } from '../DetailView/common/defaultValues' @@ -127,6 +127,7 @@ export const PersonDetails = () => { tabs={tabs} data={isNew ? emptyPerson : data!} validator={validatePerson} + validateFields={validatePersonFields} deleteFunction={deleteFunction} /> ) diff --git a/frontend/src/components/Region/RegionDetails.tsx b/frontend/src/components/Region/RegionDetails.tsx index 95d2bb5e..a548702d 100755 --- a/frontend/src/components/Region/RegionDetails.tsx +++ b/frontend/src/components/Region/RegionDetails.tsx @@ -5,7 +5,7 @@ import { DetailView, TabType } from '../DetailView/DetailView' import { CoordinatorTab } from './Tabs/CoordinatorTab' import { EditDataType, RegionDetails as RegionDetailsType, ValidationErrors } from '@/shared/types' import { useNotify } from '@/hooks/notification' -import { validateRegion } from '@/shared/validators/region' +import { validateRegion, validateRegionFields } from '@/shared/validators/region' import { useEffect } from 'react' import { emptyRegion } from '../DetailView/common/defaultValues' @@ -66,6 +66,7 @@ export const RegionDetails = () => { data={isNew ? emptyRegion : data!} onWrite={onWrite} validator={validateRegion} + validateFields={validateRegionFields} deleteFunction={deleteFunction} /> ) diff --git a/frontend/src/components/Species/SpeciesDetails.tsx b/frontend/src/components/Species/SpeciesDetails.tsx index 129f8f64..85fd15d1 100755 --- a/frontend/src/components/Species/SpeciesDetails.tsx +++ b/frontend/src/components/Species/SpeciesDetails.tsx @@ -12,7 +12,7 @@ import { TaxonomyTab } from './Tabs/TaxonomyTab' import { TeethTab } from './Tabs/TeethTab' import { UpdateTab } from '../DetailView/common/UpdateTab' 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 { useNotify } from '@/hooks/notification' import { useEffect, useState } from 'react' @@ -124,6 +124,7 @@ export const SpeciesDetails = ({ taxonomy={true} hasStagingMode validator={validateSpecies} + validateFields={validateSpeciesFields} deleteFunction={deleteFunction} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} /> diff --git a/frontend/src/components/TimeBound/TimeBoundDetails.tsx b/frontend/src/components/TimeBound/TimeBoundDetails.tsx index d18ca234..91a1eccb 100755 --- a/frontend/src/components/TimeBound/TimeBoundDetails.tsx +++ b/frontend/src/components/TimeBound/TimeBoundDetails.tsx @@ -13,7 +13,7 @@ import { UpdateTab } from '../DetailView/common/UpdateTab' import { DetailView, TabType } from '../DetailView/DetailView' import { BoundTab } from './Tabs/BoundTab' import { TimeUnitTab } from './Tabs/TimeUnitTab.tsx' -import { validateTimeBound } from '@/shared/validators/timeBound' +import { validateTimeBound, validateTimeBoundFields } from '@/shared/validators/timeBound' export const TimeBoundDetails = ({ wrapWithUnsavedChangesProvider = true, @@ -108,6 +108,7 @@ export const TimeBoundDetails = ({ isNew={isNew} onWrite={onWrite} validator={validateTimeBound} + validateFields={validateTimeBoundFields} deleteFunction={deleteFunction} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} /> diff --git a/frontend/src/components/TimeUnit/TimeUnitDetails.tsx b/frontend/src/components/TimeUnit/TimeUnitDetails.tsx index f739d85a..cc7a5990 100755 --- a/frontend/src/components/TimeUnit/TimeUnitDetails.tsx +++ b/frontend/src/components/TimeUnit/TimeUnitDetails.tsx @@ -13,7 +13,7 @@ import { DetailView, TabType } from '../DetailView/DetailView' import { LocalityTab } from './Tabs/LocalityTab' import { TimeUnitTab } from './Tabs/TimeUnitTab' 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 { useDeleteTimeUnit } from '@/hooks/useDeleteTimeUnit' import { getApiErrorMessage, isDuplicateNameError } from '@/utils/api' @@ -112,6 +112,7 @@ export const TimeUnitDetails = ({ data={isNew ? emptyTimeUnit : data!} onWrite={onWrite} validator={validateTimeUnit} + validateFields={validateTimeUnitFields} deleteFunction={deleteFunction} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} /> diff --git a/frontend/src/shared/validators/locality.ts b/frontend/src/shared/validators/locality.ts index 55f5ce00..a316ced1 100755 --- a/frontend/src/shared/validators/locality.ts +++ b/frontend/src/shared/validators/locality.ts @@ -1,5 +1,5 @@ import { EditDataType, LocalityDetailsType } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' import { validCountries } from './countryList' const pollenFields = ['pers_pollen_ap', 'pers_pollen_nap', 'pers_pollen_other'] as const @@ -58,10 +58,9 @@ const validatePollenRecordTotal = (editData: Partial, - fieldName: keyof EditDataType -) => { +const createLocalityValidators = ( + editData: EditDataType +): Validators>> => { const isAbsoluteDatingMethod = editData.date_meth === 'absolute' const isTimeUnitDatingMethod = editData.date_meth === 'time_unit' 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 validators: Validators>> = { + return { // const isNew = editData.lid === undefined date_meth: { name: 'Dating method', @@ -266,6 +265,18 @@ export const validateLocality = ( asString: (value: string) => validateFraction('Maximum fraction', value), }, } +} +export const validateLocality = ( + editData: EditDataType, + fieldName: keyof EditDataType +) => { + const validators = createLocalityValidators(editData) return validator>(validators, editData, fieldName) } + +export const validateLocalityFields = (editData: Partial>) => + validateFields>( + createLocalityValidators(editData as EditDataType), + editData + ) diff --git a/frontend/src/shared/validators/museum.ts b/frontend/src/shared/validators/museum.ts index 1cf9104b..c7ec4e12 100644 --- a/frontend/src/shared/validators/museum.ts +++ b/frontend/src/shared/validators/museum.ts @@ -1,5 +1,5 @@ import { EditDataType, Museum } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' /* museum: string; @@ -14,36 +14,39 @@ import { Validators, validator } from './validator' used_gene: boolean | null; */ -export const validateMuseum = (editData: EditDataType, fieldName: keyof EditDataType) => { - const validators: Validators>> = { - museum: { - name: 'Museum', - required: true, - asString: museumCode => { - if (museumCode.indexOf(' ') !== -1) return 'Museum code must not contain a space' - return null - }, +const museumValidators: Validators>> = { + museum: { + name: 'Museum', + required: true, + asString: museumCode => { + if (museumCode.indexOf(' ') !== -1) return 'Museum code must not contain a space' + return null }, - institution: { - name: 'Institution', - required: true, + }, + institution: { + 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>(validators, editData, fieldName) + }, } + +export const validateMuseum = (editData: EditDataType, fieldName: keyof EditDataType) => { + return validator>(museumValidators, editData, fieldName) +} + +export const validateMuseumFields = (editData: Partial>) => + validateFields>(museumValidators, editData) diff --git a/frontend/src/shared/validators/person.ts b/frontend/src/shared/validators/person.ts index 13d1a3d7..d57dd286 100755 --- a/frontend/src/shared/validators/person.ts +++ b/frontend/src/shared/validators/person.ts @@ -1,51 +1,54 @@ import { EditDataType, PersonDetailsType } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' + +const personValidators: Validators>> = { + 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 = ( editData: EditDataType, fieldName: keyof EditDataType ) => { - const validators: Validators>> = { - 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>(validators, editData, fieldName) + return validator>(personValidators, editData, fieldName) } + +export const validatePersonFields = (editData: Partial>) => + validateFields>(personValidators, editData) diff --git a/frontend/src/shared/validators/region.ts b/frontend/src/shared/validators/region.ts index 8b5333d7..3b387c3a 100755 --- a/frontend/src/shared/validators/region.ts +++ b/frontend/src/shared/validators/region.ts @@ -1,5 +1,5 @@ import { EditDataType, RegionCoordinator, RegionCountry, RegionDetails } from '../types' -import { Validators, validator, ValidationError } from './validator' +import { Validators, validateFields, validator, ValidationError } from './validator' const countryCheck = (countries: EditDataType) => { for (const country of countries) { @@ -21,29 +21,32 @@ const coordinatorCheck = (coordinators: EditDataType) => { } return null as ValidationError } -export const validateRegion = (editData: EditDataType, fieldName: keyof EditDataType) => { - const validators: Validators>> = { - reg_coord_id: { - name: 'reg_coord_id', - required: true, +const regionValidators: Validators>> = { + reg_coord_id: { + name: 'reg_coord_id', + 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', - required: true, - asString: value => { - if (value.trim().length === 0) return 'Region name must not be empty' - return null - }, - }, - now_reg_coord_country: { - name: 'Countries', - miscArray: countryCheck, - }, - now_reg_coord_people: { - name: 'Region Coordinators', - miscArray: coordinatorCheck, - }, - } - - return validator>(validators, editData, fieldName) + }, + now_reg_coord_country: { + name: 'Countries', + miscArray: countryCheck, + }, + now_reg_coord_people: { + name: 'Region Coordinators', + miscArray: coordinatorCheck, + }, } + +export const validateRegion = (editData: EditDataType, fieldName: keyof EditDataType) => { + return validator>(regionValidators, editData, fieldName) +} + +export const validateRegionFields = (editData: Partial>) => + validateFields>(regionValidators, editData) diff --git a/frontend/src/shared/validators/species.ts b/frontend/src/shared/validators/species.ts index abe59569..474199cb 100755 --- a/frontend/src/shared/validators/species.ts +++ b/frontend/src/shared/validators/species.ts @@ -1,194 +1,201 @@ import { taxonStatusOptions } from '../taxonStatusOptions' import { EditDataType, SpeciesDetailsType } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' const isEmptyUniqIdentifier = (unique_identifier: string) => { if (unique_identifier === '' || unique_identifier === '-') return true 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 +): Validators>> => ({ + 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 = ( editData: EditDataType, fieldName: keyof EditDataType ) => { - 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 validators: Validators>> = { - 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 - }, - }, - } - + const validators = createSpeciesValidators(editData) return validator>(validators, editData, fieldName) } + +export const validateSpeciesFields = (editData: Partial>) => + validateFields>( + createSpeciesValidators(editData as EditDataType), + editData + ) diff --git a/frontend/src/shared/validators/timeUnit.ts b/frontend/src/shared/validators/timeUnit.ts index 378cdd85..9a8d995e 100755 --- a/frontend/src/shared/validators/timeUnit.ts +++ b/frontend/src/shared/validators/timeUnit.ts @@ -1,64 +1,74 @@ import { EditDataType, TimeUnitDetailsType } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' + +const createTimeUnitValidators = ( + editData: EditDataType +): Validators>> => ({ + 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, fieldName: keyof TimeUnitDetailsType) => { - const validators: Validators>> = { - 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 - }, - }, - } + const validators = createTimeUnitValidators(editData) return validator>(validators, editData, fieldName) } + +export const validateTimeUnitFields = (editData: Partial>) => + validateFields>( + createTimeUnitValidators(editData as EditDataType), + editData + ) diff --git a/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx b/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx index 3a4d57e0..97e95e50 100644 --- a/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx +++ b/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx @@ -167,6 +167,7 @@ jest.mock('@/util/taxonomyUtilities', () => ({ jest.mock('@/shared/validators/species', () => ({ validateSpecies: jest.fn(() => ({ name: '', error: null })), + validateSpeciesFields: jest.fn(() => []), })) const mockUseDetailContext = useDetailContext as jest.MockedFunction<() => DetailContextType> diff --git a/frontend/src/tests/shared/validators/validator.test.ts b/frontend/src/tests/shared/validators/validator.test.ts index c61c174a..cb21db8c 100644 --- a/frontend/src/tests/shared/validators/validator.test.ts +++ b/frontend/src/tests/shared/validators/validator.test.ts @@ -1,6 +1,12 @@ 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 { validateTimeUnitFields } from '@/shared/validators/timeUnit' import { validateFields, type Validators } from '@/shared/validators/validator' 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' }, + ]) + ) + }) +}) diff --git a/frontend/tests/timeUnitForm.test.tsx b/frontend/tests/timeUnitForm.test.tsx index 93466a47..80d8be8c 100644 --- a/frontend/tests/timeUnitForm.test.tsx +++ b/frontend/tests/timeUnitForm.test.tsx @@ -9,12 +9,20 @@ import { PageContextProvider } from '@/components/Page' import type { TimeUnit, TimeUnitDetailsType } from '@/shared/types' import '@testing-library/jest-dom' +jest.setTimeout(15000) + jest.mock('lodash-es', () => ({ cloneDeep: (value: unknown) => value, })) jest.mock('@/shared/validators/timeUnit', () => ({ validateTimeUnit: () => ({ name: 'tu_display_name', error: '' }), + validateTimeUnitFields: () => [], +})) + +jest.mock('@/shared/validators/validator', () => ({ + ...jest.requireActual('@/shared/validators/validator'), + referenceValidator: () => null, })) jest.mock('@/util/config', () => ({ @@ -23,6 +31,10 @@ jest.mock('@/util/config', () => ({ ENV: 'test', })) +jest.mock('@/hooks/user', () => ({ + useUser: () => ({ role: 1, initials: 'TEST' }), +})) + const mockEditTimeUnitMutation = jest.fn() const mockGetAllTimeUnitsQuery = jest.fn() @@ -38,6 +50,17 @@ jest.mock('@/redux/timeUnitReducer', () => ({ 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', () => ({ SequenceSelect: () =>
, })) @@ -178,10 +201,13 @@ describe('TimeUnit creation duplicate name handling', () => { return { unwrap: unwrapMock } }) - renderTimeUnitCreation() + const { container } = renderTimeUnitCreation() 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') await user.click(rankSelect) await user.click(await screen.findByText('No value'))