diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0f34937ad..34c62c885 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -82,8 +82,9 @@ Cypress.Commands.add('pageForbidden', url => { }) Cypress.Commands.add('resetDatabase', () => { - cy.task('waitForDbHealthy') - cy.request(Cypress.env('databaseResetUrl')).its('status').should('eq', 200) + const dbWaitTimeoutMs = Number(Cypress.env('dbWaitTimeoutMs') ?? 30000) + cy.task('waitForDbHealthy', undefined, { timeout: dbWaitTimeoutMs }) + cy.request({ url: Cypress.env('databaseResetUrl'), timeout: dbWaitTimeoutMs }).its('status').should('eq', 200) }) // Optimized database reset that only resets once per test file @@ -118,9 +119,14 @@ Cypress.Commands.add('loginWithSession', (username) => { }) }, { validate: () => { - // Validate the session is still valid by checking for the username box - cy.visit('/') - cy.contains('.username-box', username, { timeout: 10000 }).should('be.visible') + cy.window().should(window => { + const storedUserState = window.localStorage.getItem('userState') + expect(storedUserState, 'stored user state').to.not.be.null + + const parsedUserState = JSON.parse(storedUserState) + expect(parsedUserState?.username, 'stored username').to.eq(username) + expect(parsedUserState?.token, 'stored login token').to.be.a('string').and.not.be.empty + }) }, }) }) diff --git a/frontend/src/components/DetailView/Context/DetailContext.tsx b/frontend/src/components/DetailView/Context/DetailContext.tsx index f606a1308..e3af0d77e 100755 --- a/frontend/src/components/DetailView/Context/DetailContext.tsx +++ b/frontend/src/components/DetailView/Context/DetailContext.tsx @@ -135,6 +135,7 @@ export type DetailContextType = { optionalRadioSelectionProps?: OptionalRadioSelectionProps ) => JSX.Element validator: (editData: EditDataType, field: keyof EditDataType) => ValidationObject + validateFields?: (editData: EditDataType) => ValidationObject[] fieldsWithErrors: FieldsWithErrorsType setFieldsWithErrors: SetFieldsWithErrorsType isDirty: boolean @@ -211,6 +212,9 @@ export const DetailContextProvider = ({ markEditDataClean, validator: (editData: unknown, fieldName: keyof EditDataType) => contextState.validator(editData as EditDataType, fieldName), + validateFields: contextState.validateFields + ? (editData: unknown) => contextState.validateFields?.(editData as EditDataType) ?? [] + : undefined, }} > {children} diff --git a/frontend/src/components/DetailView/DetailView.tsx b/frontend/src/components/DetailView/DetailView.tsx index fc1330336..c822e953f 100755 --- a/frontend/src/components/DetailView/DetailView.tsx +++ b/frontend/src/components/DetailView/DetailView.tsx @@ -108,6 +108,7 @@ export const DetailView = ({ data, onWrite, validator, + validateFields, isNew = false, isUserPage = false, isPersonPage = false, @@ -124,6 +125,7 @@ export const DetailView = ({ markEditDataClean?: (editData?: EditDataType) => void ) => Promise validator: (editData: EditDataType, field: keyof EditDataType) => ValidationObject + validateFields?: (editData: EditDataType) => ValidationObject[] isNew?: boolean isUserPage?: boolean isPersonPage?: boolean @@ -193,6 +195,7 @@ export const DetailView = ({ radioSelection, bigTextField, validator, + validateFields, fieldsWithErrors, setFieldsWithErrors, } diff --git a/frontend/src/components/DetailView/StagingView.tsx b/frontend/src/components/DetailView/StagingView.tsx index 6b27df358..edb1d8e76 100755 --- a/frontend/src/components/DetailView/StagingView.tsx +++ b/frontend/src/components/DetailView/StagingView.tsx @@ -22,6 +22,7 @@ import type { DropdownOption } from './common/editingComponents' import type { FieldsWithErrorsType, OptionalRadioSelectionProps, TextFieldOptions } from './DetailView' import type { ValidationObject } from '@/shared/validators/validator' import { + createReferenceFieldsValidatorWithLabels, createReferenceValidatorWithLabels, ReferenceDisplayLabelMap, ReferenceFieldDisplayNames, @@ -32,16 +33,13 @@ import { formatReferenceValidationErrorMessage } from '@/components/Reference/re const NewReferenceDialogContent = ({ onClose, onCreated, - referenceValidator, + validateReferenceFields, referenceFieldDisplayLabelMap, referenceTypes, }: { onClose: () => void onCreated: (reference: ReferenceDetailsType) => void - referenceValidator: ( - editData: EditDataType, - field: keyof EditDataType - ) => ValidationObject + validateReferenceFields: (editData: EditDataType) => ValidationObject[] referenceFieldDisplayLabelMap?: ReferenceDisplayLabelMap referenceTypes?: ReferenceType[] }) => { @@ -52,12 +50,8 @@ const NewReferenceDialogContent = ({ const validateAllFields = () => { const nextFieldsWithErrors: FieldsWithErrorsType = {} - for (const field in editData) { - const fieldKey = field as keyof EditDataType - const errorObject = referenceValidator(editData, fieldKey) - if (errorObject.error) { - nextFieldsWithErrors[String(fieldKey)] = errorObject - } + for (const errorObject of validateReferenceFields(editData)) { + nextFieldsWithErrors[String(errorObject.field ?? errorObject.name)] = errorObject } setFieldsWithErrors(() => nextFieldsWithErrors) @@ -136,6 +130,10 @@ const NewReferenceDialog = ({ () => createReferenceValidatorWithLabels(referenceFieldDisplayLabelMap), [referenceFieldDisplayLabelMap] ) + const validateReferenceFields = useMemo( + () => createReferenceFieldsValidatorWithLabels(referenceFieldDisplayLabelMap), + [referenceFieldDisplayLabelMap] + ) const textField = (field: keyof EditDataType, options?: TextFieldOptions) => ( field={field} {...options} /> @@ -193,6 +191,7 @@ const NewReferenceDialog = ({ radioSelection, bigTextField, validator: referenceValidator, + validateFields: validateReferenceFields, fieldsWithErrors, setFieldsWithErrors, }} @@ -200,7 +199,7 @@ const NewReferenceDialog = ({ diff --git a/frontend/src/components/DetailView/common/editingComponents.tsx b/frontend/src/components/DetailView/common/editingComponents.tsx index f3a816eaa..26c20f7a0 100755 --- a/frontend/src/components/DetailView/common/editingComponents.tsx +++ b/frontend/src/components/DetailView/common/editingComponents.tsx @@ -557,10 +557,8 @@ export const TimeBoundSelection = ({ }) => { const { editData, setEditData, validator, fieldsWithErrors, setFieldsWithErrors } = useDetailContext() - const errorObject = validator( - editData, - (targetField === 'up_bnd' ? 'up_bound' : 'low_bound') as keyof EditDataType - ) + const boundField = (targetField === 'up_bnd' ? 'up_bound' : 'low_bound') as keyof EditDataType + const errorObject = validator(editData, boundField) const [open, setOpen] = useState(false) const selectorFn = (selected: TimeBoundDetailsType) => { @@ -573,7 +571,7 @@ export const TimeBoundSelection = ({ } useEffect(() => { - checkFieldErrors(String(targetField), errorObject, fieldsWithErrors, setFieldsWithErrors) + checkFieldErrors(String(boundField), errorObject, fieldsWithErrors, setFieldsWithErrors) // eslint-disable-next-line react-hooks/exhaustive-deps }, [errorObject]) diff --git a/frontend/src/components/DetailView/components.tsx b/frontend/src/components/DetailView/components.tsx index 001219654..3ae32a7c1 100755 --- a/frontend/src/components/DetailView/components.tsx +++ b/frontend/src/components/DetailView/components.tsx @@ -42,6 +42,7 @@ export const WriteButton = ({ mode, setMode, validator, + validateFields, fieldsWithErrors, setFieldsWithErrors, isDirty, @@ -108,10 +109,27 @@ export const WriteButton = ({ useEffect(() => { if (mode.option === 'edit' || mode.option === 'new') { - for (const field in editData) { - const fieldAsString = String(field) - const errorObject = validator(editData, field) - checkFieldErrors(fieldAsString, errorObject, fieldsWithErrors, setFieldsWithErrors) + if (validateFields) { + const validationErrors = validateFields(editData) + setFieldsWithErrors(prevFieldsWithErrors => { + const nextFieldsWithErrors = { ...prevFieldsWithErrors } + + for (const field of Object.keys(nextFieldsWithErrors)) { + if (field !== 'mandatoryReference') delete nextFieldsWithErrors[field] + } + + for (const errorObject of validationErrors) { + nextFieldsWithErrors[String(errorObject.field ?? errorObject.name)] = errorObject + } + + return nextFieldsWithErrors + }) + } else { + for (const field in editData) { + const fieldAsString = String(field) + const errorObject = validator(editData, field) + checkFieldErrors(fieldAsString, errorObject, fieldsWithErrors, setFieldsWithErrors) + } } } if (mode.staging == true) { diff --git a/frontend/src/components/Locality/LocalityDetails.tsx b/frontend/src/components/Locality/LocalityDetails.tsx index 35808454d..d0aca5c49 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 f011ef931..00ec5bd2e 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 bae92b598..f420f1024 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/Reference/ReferenceDetails.tsx b/frontend/src/components/Reference/ReferenceDetails.tsx index ec1878bf7..d45966fe8 100755 --- a/frontend/src/components/Reference/ReferenceDetails.tsx +++ b/frontend/src/components/Reference/ReferenceDetails.tsx @@ -13,6 +13,7 @@ import { import { EditDataType, ReferenceDetailsType } from '@/shared/types' import { emptyReference } from '../DetailView/common/defaultValues' import { + createReferenceFieldsValidatorWithLabels, createReferenceValidatorWithLabels, ReferenceFieldDisplayNames, ReferenceDisplayLabelMap, @@ -82,6 +83,10 @@ export const ReferenceDetails = ({ () => createReferenceValidatorWithLabels(referenceFieldDisplayLabelMap), [referenceFieldDisplayLabelMap] ) + const validateReferenceFields = useMemo( + () => createReferenceFieldsValidatorWithLabels(referenceFieldDisplayLabelMap), + [referenceFieldDisplayLabelMap] + ) useEffect(() => { if (deleteSuccess) { @@ -138,6 +143,7 @@ export const ReferenceDetails = ({ data={isNew ? emptyReference : data!} onWrite={onWrite} validator={referenceValidator} + validateFields={validateReferenceFields} deleteFunction={deleteFunction} wrapWithUnsavedChangesProvider={wrapWithUnsavedChangesProvider} /> diff --git a/frontend/src/components/Reference/Tabs/ReferenceTab.tsx b/frontend/src/components/Reference/Tabs/ReferenceTab.tsx index 7f13dd232..2bd9e19b1 100755 --- a/frontend/src/components/Reference/Tabs/ReferenceTab.tsx +++ b/frontend/src/components/Reference/Tabs/ReferenceTab.tsx @@ -8,28 +8,55 @@ import { JournalTab } from './JournalTab' import { useEffect } from 'react' export const ReferenceTab = () => { - const { dropdown, data, editData, mode, textField, bigTextField, fieldsWithErrors, validator, setFieldsWithErrors } = - useDetailContext() + const { + dropdown, + data, + editData, + mode, + textField, + bigTextField, + fieldsWithErrors, + validator, + validateFields, + setFieldsWithErrors, + } = useDetailContext() const { data: referenceTypes } = useGetReferenceTypesQuery() useEffect(() => { if (mode.option === 'edit' || mode.option === 'new') { - for (const field in editData) { - const fieldAsKey = field as keyof typeof editData - const fieldAsString = String(fieldAsKey) - const errorObject = validator(editData, fieldAsKey) - if (errorObject.error) { - if (!(fieldAsString in fieldsWithErrors)) { + if (validateFields) { + const validationErrors = validateFields(editData) + setFieldsWithErrors(prevFieldsWithErrors => { + const nextFieldsWithErrors = { ...prevFieldsWithErrors } + + for (const field of Object.keys(nextFieldsWithErrors)) { + if (field !== 'mandatoryReference') delete nextFieldsWithErrors[field] + } + + for (const errorObject of validationErrors) { + nextFieldsWithErrors[String(errorObject.field ?? errorObject.name)] = errorObject + } + + return nextFieldsWithErrors + }) + } else { + for (const field in editData) { + const fieldAsKey = field as keyof typeof editData + const fieldAsString = String(fieldAsKey) + const errorObject = validator(editData, fieldAsKey) + if (errorObject.error) { + if (!(fieldAsString in fieldsWithErrors)) { + setFieldsWithErrors(prevFieldsWithErrors => { + return { ...prevFieldsWithErrors, [fieldAsString]: errorObject } + }) + } + } else if (!errorObject.error && fieldAsString in fieldsWithErrors) { setFieldsWithErrors(prevFieldsWithErrors => { - return { ...prevFieldsWithErrors, [fieldAsString]: errorObject } + const newFieldsWithErrors = { ...prevFieldsWithErrors } + delete newFieldsWithErrors[fieldAsString] + return newFieldsWithErrors }) } - } else if (!errorObject.error && fieldAsString in fieldsWithErrors) { - setFieldsWithErrors(prevFieldsWithErrors => { - const newFieldsWithErrors = { ...prevFieldsWithErrors } - delete newFieldsWithErrors[fieldAsString] - return newFieldsWithErrors - }) } } } diff --git a/frontend/src/components/Region/RegionDetails.tsx b/frontend/src/components/Region/RegionDetails.tsx index 95d2bb5ed..a548702d1 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 129f8f640..85fd15d1a 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 d18ca2345..91a1eccbe 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/Tabs/TimeUnitTab.tsx b/frontend/src/components/TimeUnit/Tabs/TimeUnitTab.tsx index 0fc715d1b..aeb26c970 100755 --- a/frontend/src/components/TimeUnit/Tabs/TimeUnitTab.tsx +++ b/frontend/src/components/TimeUnit/Tabs/TimeUnitTab.tsx @@ -132,7 +132,7 @@ export const TimeUnitTab = () => { )} <> - + {!mode.read && ( @@ -158,7 +158,7 @@ export const TimeUnitTab = () => { )} - + {!mode.read && ( diff --git a/frontend/src/components/TimeUnit/TimeUnitDetails.tsx b/frontend/src/components/TimeUnit/TimeUnitDetails.tsx index f739d85ac..cc7a5990f 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 55f5ce002..a316ced14 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 1cf9104b7..c7ec4e12b 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 - }, - }, - institution: { - name: 'Institution', - required: true, - }, - city: { - name: 'City', - required: true, - }, - country: { - name: 'Country', - required: true, +const museumValidators: Validators>> = { + museum: { + name: 'Museum', + required: true, + asString: museumCode => { + if (museumCode.indexOf(' ') !== -1) return 'Museum code must not contain a space' + return null }, - state_code: { - name: 'State code', - asString: value => { - if (value.length > 5) return 'State code must contain a maximum of 5 characters' - return null - }, + }, + 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 }, - } + }, +} - 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 13d1a3d7c..d57dd2865 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/reference.ts b/frontend/src/shared/validators/reference.ts index 15a8a26c1..ab0cc76b1 100755 --- a/frontend/src/shared/validators/reference.ts +++ b/frontend/src/shared/validators/reference.ts @@ -1,5 +1,5 @@ import { EditDataType, ReferenceDetailsType, ReferenceAuthorType, ReferenceJournalType } from '../types' -import { Validators, validator, ValidationError } from './validator' +import { Validators, validateFields, validator, ValidationError } from './validator' const authorCheck: (data: ReferenceAuthorType[]) => ValidationError = (data: ReferenceAuthorType[]) => { if (data.length === 0) { @@ -173,109 +173,122 @@ const orCheck = ( return null } -export const validateReference = ( +const createReferenceValidators = ( editData: EditDataType, - fieldName: keyof EditDataType, options?: ReferenceValidationOptions -) => { - const validators: Validators>> = { - title_primary: { - name: 'title_primary', - useEditData: true, - miscCheck: (obj: object) => { - return orCheck(obj as EditDataType, 'title_primary', options) - }, - }, - title_secondary: { - name: 'title_secondary', - useEditData: true, - miscCheck: (obj: object) => { - return orCheck(obj as EditDataType, 'title_secondary', options) - }, - }, - title_series: { - name: 'title_series', - useEditData: true, - miscCheck: (obj: object) => { - return orCheck(obj as EditDataType, 'title_series', options) - }, +): Validators>> => ({ + title_primary: { + name: 'title_primary', + useEditData: true, + miscCheck: (obj: object) => { + return orCheck(obj as EditDataType, 'title_primary', options) }, - gen_notes: { - name: 'gen_notes', - useEditData: true, - miscCheck: (obj: object) => { - return orCheck(obj as EditDataType, 'gen_notes', options) - }, + }, + title_secondary: { + name: 'title_secondary', + useEditData: true, + miscCheck: (obj: object) => { + return orCheck(obj as EditDataType, 'title_secondary', options) }, - ref_type_id: { - name: 'ref_type_id', - required: true, - asNumber: true, + }, + title_series: { + name: 'title_series', + useEditData: true, + miscCheck: (obj: object) => { + return orCheck(obj as EditDataType, 'title_series', options) }, - date_primary: { - name: 'date_primary', - required: true, - asNumber: yearCheck, - condition: (data: Partial>) => { - const ids: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] - return data.ref_type_id != null && ids.includes(data.ref_type_id) - }, + }, + gen_notes: { + name: 'gen_notes', + useEditData: true, + miscCheck: (obj: object) => { + return orCheck(obj as EditDataType, 'gen_notes', options) }, - start_page: { - name: 'start_page', - required: false, - asNumber: (num: number) => positiveIntegerCheck('start_page', num), + }, + ref_type_id: { + name: 'ref_type_id', + required: true, + asNumber: true, + }, + date_primary: { + name: 'date_primary', + required: true, + asNumber: yearCheck, + condition: (data: Partial>) => { + const ids: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + return data.ref_type_id != null && ids.includes(data.ref_type_id) }, - end_page: { - name: 'end_page', - required: false, - asNumber: (num: number) => { - const base = positiveIntegerCheck('end_page', num) - if (base) return base - if (typeof editData.start_page === 'number' && num < editData.start_page) { - return 'end_page must be greater than or equal to start_page' - } - return null - }, - }, - date_secondary: { - name: 'date_secondary', - required: false, - asNumber: yearCheck, + }, + start_page: { + name: 'start_page', + required: false, + asNumber: (num: number) => positiveIntegerCheck('start_page', num), + }, + end_page: { + name: 'end_page', + required: false, + asNumber: (num: number) => { + const base = positiveIntegerCheck('end_page', num) + if (base) return base + if (typeof editData.start_page === 'number' && num < editData.start_page) { + return 'end_page must be greater than or equal to start_page' + } + return null }, - ref_authors: { - name: 'ref_authors', - required: true, - minLength: 1, - miscArray: authorCheck, - condition: (data: Partial>) => { - const ids: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] - return data.ref_type_id != null && ids.includes(data.ref_type_id) - }, + }, + date_secondary: { + name: 'date_secondary', + required: false, + asNumber: yearCheck, + }, + ref_authors: { + name: 'ref_authors', + required: true, + minLength: 1, + miscArray: authorCheck, + condition: (data: Partial>) => { + const ids: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + return data.ref_type_id != null && ids.includes(data.ref_type_id) }, - ref_journal: { - name: 'ref_journal', - miscCheck: journalCheck, - condition: (data: Partial>) => { - const ids: number[] = [1, 5, 14] - return data.ref_type_id != null && ids.includes(data.ref_type_id) - }, + }, + ref_journal: { + name: 'ref_journal', + miscCheck: journalCheck, + condition: (data: Partial>) => { + const ids: number[] = [1, 5, 14] + return data.ref_type_id != null && ids.includes(data.ref_type_id) }, - exact_date: { - name: 'exact_date', - required: true, - asString: dateCheck, - condition: (data: Partial>) => { - const ids: number[] = [6, 7, 10, 11, 12, 13, 14] - return data.ref_type_id != null && ids.includes(data.ref_type_id) - }, + }, + exact_date: { + name: 'exact_date', + required: true, + asString: dateCheck, + condition: (data: Partial>) => { + const ids: number[] = [6, 7, 10, 11, 12, 13, 14] + return data.ref_type_id != null && ids.includes(data.ref_type_id) }, - } + }, +}) +export const validateReference = ( + editData: EditDataType, + fieldName: keyof EditDataType, + options?: ReferenceValidationOptions +) => { + const validators = createReferenceValidators(editData, options) return validator>(validators, editData, fieldName) } +export const validateReferenceFields = ( + editData: EditDataType, + options?: ReferenceValidationOptions +) => validateFields>(createReferenceValidators(editData, options), editData) + export const createReferenceValidatorWithLabels = (displayLabelMap?: ReferenceDisplayLabelMap) => (editData: EditDataType, fieldName: keyof EditDataType) => validateReference(editData, fieldName, { displayLabelMap }) + +export const createReferenceFieldsValidatorWithLabels = + (displayLabelMap?: ReferenceDisplayLabelMap) => (editData: EditDataType) => + validateReferenceFields(editData, { displayLabelMap }) diff --git a/frontend/src/shared/validators/region.ts b/frontend/src/shared/validators/region.ts index 8b5333d74..a467f00aa 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,33 @@ 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, - }, - 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, +const regionValidators: Validators>> = { + reg_coord_id: { + name: 'reg_coord_id', + required: true, + condition: editData => 'reg_coord_id' in editData, + }, + 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) +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 abe595695..474199cb1 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 } -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 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 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 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') +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), - }, + 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 - }, - }, - } + 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 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 378cdd85e..07c526419 100755 --- a/frontend/src/shared/validators/timeUnit.ts +++ b/frontend/src/shared/validators/timeUnit.ts @@ -1,64 +1,72 @@ import { EditDataType, TimeUnitDetailsType } from '../types' -import { Validators, validator } from './validator' +import { Validators, validateFields, validator } from './validator' -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 - }, +const createTimeUnitValidators = ( + editData: EditDataType +): Validators>> => ({ + tu_display_name: { + name: 'Name', + required: true, + }, + sequence: { + name: 'Sequence', + required: true, + }, + up_bnd: { + name: 'New Upper Bound', + 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 - }, + }, + low_bnd: { + name: 'New Lower Bound', + 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 - }, + }, + 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 - }, + }, + 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 = createTimeUnitValidators(editData) return validator>(validators, editData, fieldName) } + +export const validateTimeUnitFields = (editData: Partial>) => + validateFields>( + createTimeUnitValidators(editData as EditDataType), + editData + ) diff --git a/frontend/src/shared/validators/validator.ts b/frontend/src/shared/validators/validator.ts index 696ad9a80..5a99100b4 100755 --- a/frontend/src/shared/validators/validator.ts +++ b/frontend/src/shared/validators/validator.ts @@ -1,7 +1,7 @@ import { Editable, Reference } from '../types' export type ValidationError = string | null | undefined -export type ValidationObject = { name: string; error: ValidationError } +export type ValidationObject = { name: string; error: ValidationError; field?: string } export type Validator = { name: string @@ -18,6 +18,15 @@ export type Validator = { export type Validators = { [field in keyof T]: Validator } & { [key: string]: Validator } +const validationObject = (name: string, error: ValidationError, field: string): ValidationObject => { + const object: ValidationObject = { name, error } + Object.defineProperty(object, 'field', { + value: field, + enumerable: false, + }) + return object +} + const validate: (validator: Validator, value: unknown) => ValidationError = (validator: Validator, value: unknown) => { const { required, minLength, maxLength, asNumber, asString, miscCheck, miscArray } = validator @@ -80,11 +89,11 @@ export const validator = ( const fieldValidator = validators[fieldName] if (!fieldValidator || (fieldValidator.condition && !fieldValidator.condition(editData))) { - return { name: fieldName as string, error: null } + return validationObject(fieldName as string, null, fieldName as string) } const validationError = validate(fieldValidator, fieldValidator.useEditData ? editData : editData[fieldName]) - return { name: fieldValidator.name, error: validationError } + return validationObject(fieldValidator.name, validationError, fieldName as string) } export const validateFields = ( diff --git a/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx b/frontend/src/tests/components/LocalitySpeciesTaxonStatusDropdown.test.tsx index 3a4d57e06..97e95e50d 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/components/StagingView.test.tsx b/frontend/src/tests/components/StagingView.test.tsx index f5e991cc8..7e980adb7 100644 --- a/frontend/src/tests/components/StagingView.test.tsx +++ b/frontend/src/tests/components/StagingView.test.tsx @@ -75,6 +75,7 @@ jest.mock('@/components/Reference/Tabs/ReferenceTab', () => ({ jest.mock('@/shared/validators/reference', () => ({ createReferenceValidatorWithLabels: () => (_editData: unknown, field: string) => ({ name: field, error: null }), + createReferenceFieldsValidatorWithLabels: () => () => [], })) jest.mock('@/hooks/notification', () => ({ diff --git a/frontend/src/tests/shared/validators/reference.test.ts b/frontend/src/tests/shared/validators/reference.test.ts index fe1336bbf..29eb91ea8 100644 --- a/frontend/src/tests/shared/validators/reference.test.ts +++ b/frontend/src/tests/shared/validators/reference.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, jest } from '@jest/globals' -import { createReferenceValidatorWithLabels } from '@/shared/validators/reference' +import { + createReferenceFieldsValidatorWithLabels, + createReferenceValidatorWithLabels, +} from '@/shared/validators/reference' import type { EditDataType, ReferenceDetailsType } from '@/shared/types' const createReferenceDetails = ( @@ -86,6 +89,24 @@ describe('validateReference display labels', () => { }) }) +describe('validateReferenceFields', () => { + it('runs validators for configured fields missing from editData', () => { + const validateFields = createReferenceFieldsValidatorWithLabels() + + const errors = validateFields({ + ref_type_id: 1, + title_primary: 'Article title', + } as EditDataType) + + expect(errors).toEqual( + expect.arrayContaining([ + { name: 'date_primary', error: 'This field is required' }, + { name: 'ref_authors', error: 'This field is required' }, + ]) + ) + }) +}) + describe('validateReference year fields', () => { it('rejects a future primary year', () => { jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z')) diff --git a/frontend/src/tests/shared/validators/validator.test.ts b/frontend/src/tests/shared/validators/validator.test.ts index c61c174a0..8bf0f841f 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,80 @@ 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: 'Upper Bound', error: 'This field is required' }, + { name: 'Lower Bound', error: 'This field is required' }, + ]) + ) + }) + + it('does not require Time Unit helper bound ids when resolved bounds exist', () => { + const errors = validateTimeUnitFields({ + tu_display_name: 'Test time unit', + sequence: 'ALMA', + up_bound: { bid: 11, b_name: 'C2N-y', age: 4.37, b_comment: '' }, + low_bound: { bid: 14, b_name: 'C2N-o', age: 4.631, b_comment: '' }, + }) + + expect(errors).toEqual([]) + }) + + 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: '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 93466a471..80d8be8c3 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'))