Fix reference full-field validation

This commit is contained in:
karilint
2026-05-28 14:14:19 +03:00
parent 7b10b5b48f
commit a78fb2d599
9 changed files with 219 additions and 127 deletions
@@ -135,6 +135,7 @@ export type DetailContextType<T> = {
optionalRadioSelectionProps?: OptionalRadioSelectionProps
) => JSX.Element
validator: (editData: EditDataType<T>, field: keyof EditDataType<T>) => ValidationObject
validateFields?: (editData: EditDataType<T>) => ValidationObject[]
fieldsWithErrors: FieldsWithErrorsType
setFieldsWithErrors: SetFieldsWithErrorsType
isDirty: boolean
@@ -211,6 +212,9 @@ export const DetailContextProvider = <T extends object>({
markEditDataClean,
validator: (editData: unknown, fieldName: keyof EditDataType<T>) =>
contextState.validator(editData as EditDataType<T>, fieldName),
validateFields: contextState.validateFields
? (editData: unknown) => contextState.validateFields?.(editData as EditDataType<T>) ?? []
: undefined,
}}
>
{children}
@@ -108,6 +108,7 @@ export const DetailView = <T extends object>({
data,
onWrite,
validator,
validateFields,
isNew = false,
isUserPage = false,
isPersonPage = false,
@@ -124,6 +125,7 @@ export const DetailView = <T extends object>({
markEditDataClean?: (editData?: EditDataType<T>) => void
) => Promise<WriteResult>
validator: (editData: EditDataType<T>, field: keyof EditDataType<T>) => ValidationObject
validateFields?: (editData: EditDataType<T>) => ValidationObject[]
isNew?: boolean
isUserPage?: boolean
isPersonPage?: boolean
@@ -193,6 +195,7 @@ export const DetailView = <T extends object>({
radioSelection,
bigTextField,
validator,
validateFields,
fieldsWithErrors,
setFieldsWithErrors,
}
@@ -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<ReferenceDetailsType>,
field: keyof EditDataType<ReferenceDetailsType>
) => ValidationObject
validateReferenceFields: (editData: EditDataType<ReferenceDetailsType>) => 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<ReferenceDetailsType>
const errorObject = referenceValidator(editData, fieldKey)
if (errorObject.error) {
nextFieldsWithErrors[String(fieldKey)] = errorObject
}
for (const errorObject of validateReferenceFields(editData)) {
nextFieldsWithErrors[String(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<ReferenceDetailsType>, options?: TextFieldOptions) => (
<EditableTextField<ReferenceDetailsType> field={field} {...options} />
@@ -193,6 +191,7 @@ const NewReferenceDialog = ({
radioSelection,
bigTextField,
validator: referenceValidator,
validateFields: validateReferenceFields,
fieldsWithErrors,
setFieldsWithErrors,
}}
@@ -200,7 +199,7 @@ const NewReferenceDialog = ({
<NewReferenceDialogContent
onClose={onClose}
onCreated={onCreated}
referenceValidator={referenceValidator}
validateReferenceFields={validateReferenceFields}
referenceFieldDisplayLabelMap={referenceFieldDisplayLabelMap}
referenceTypes={referenceTypes}
/>
@@ -42,6 +42,7 @@ export const WriteButton = <T,>({
mode,
setMode,
validator,
validateFields,
fieldsWithErrors,
setFieldsWithErrors,
isDirty,
@@ -108,10 +109,27 @@ export const WriteButton = <T,>({
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.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) {
@@ -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}
/>
@@ -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<ReferenceDetailsType>()
const {
dropdown,
data,
editData,
mode,
textField,
bigTextField,
fieldsWithErrors,
validator,
validateFields,
setFieldsWithErrors,
} = useDetailContext<ReferenceDetailsType>()
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.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
})
}
}
}
+108 -95
View File
@@ -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
}
const createReferenceValidators = (
editData: EditDataType<ReferenceDetailsType>,
options?: ReferenceValidationOptions
): Validators<Partial<EditDataType<ReferenceDetailsType>>> => ({
title_primary: {
name: 'title_primary',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_primary', options)
},
},
title_secondary: {
name: 'title_secondary',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_secondary', options)
},
},
title_series: {
name: 'title_series',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_series', options)
},
},
gen_notes: {
name: 'gen_notes',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'gen_notes', options)
},
},
ref_type_id: {
name: 'ref_type_id',
required: true,
asNumber: true,
},
date_primary: {
name: 'date_primary',
required: true,
asNumber: yearCheck,
condition: (data: Partial<EditDataType<ReferenceDetailsType>>) => {
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)
},
},
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
},
},
date_secondary: {
name: 'date_secondary',
required: false,
asNumber: yearCheck,
},
ref_authors: {
name: 'ref_authors',
required: true,
minLength: 1,
miscArray: authorCheck,
condition: (data: Partial<EditDataType<ReferenceDetailsType>>) => {
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<EditDataType<ReferenceDetailsType>>) => {
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<EditDataType<ReferenceDetailsType>>) => {
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<ReferenceDetailsType>,
fieldName: keyof EditDataType<ReferenceDetailsType>,
options?: ReferenceValidationOptions
) => {
const validators: Validators<Partial<EditDataType<ReferenceDetailsType>>> = {
title_primary: {
name: 'title_primary',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_primary', options)
},
},
title_secondary: {
name: 'title_secondary',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_secondary', options)
},
},
title_series: {
name: 'title_series',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'title_series', options)
},
},
gen_notes: {
name: 'gen_notes',
useEditData: true,
miscCheck: (obj: object) => {
return orCheck(obj as EditDataType<ReferenceDetailsType>, 'gen_notes', options)
},
},
ref_type_id: {
name: 'ref_type_id',
required: true,
asNumber: true,
},
date_primary: {
name: 'date_primary',
required: true,
asNumber: yearCheck,
condition: (data: Partial<EditDataType<ReferenceDetailsType>>) => {
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)
},
},
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
},
},
date_secondary: {
name: 'date_secondary',
required: false,
asNumber: yearCheck,
},
ref_authors: {
name: 'ref_authors',
required: true,
minLength: 1,
miscArray: authorCheck,
condition: (data: Partial<EditDataType<ReferenceDetailsType>>) => {
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<EditDataType<ReferenceDetailsType>>) => {
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<EditDataType<ReferenceDetailsType>>) => {
const ids: number[] = [6, 7, 10, 11, 12, 13, 14]
return data.ref_type_id != null && ids.includes(data.ref_type_id)
},
},
}
const validators = createReferenceValidators(editData, options)
return validator<EditDataType<ReferenceDetailsType>>(validators, editData, fieldName)
}
export const validateReferenceFields = (
editData: EditDataType<ReferenceDetailsType>,
options?: ReferenceValidationOptions
) => validateFields<EditDataType<ReferenceDetailsType>>(createReferenceValidators(editData, options), editData)
export const createReferenceValidatorWithLabels =
(displayLabelMap?: ReferenceDisplayLabelMap) =>
(editData: EditDataType<ReferenceDetailsType>, fieldName: keyof EditDataType<ReferenceDetailsType>) =>
validateReference(editData, fieldName, { displayLabelMap })
export const createReferenceFieldsValidatorWithLabels =
(displayLabelMap?: ReferenceDisplayLabelMap) => (editData: EditDataType<ReferenceDetailsType>) =>
validateReferenceFields(editData, { displayLabelMap })
@@ -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', () => ({
@@ -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<ReferenceDetailsType>)
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'))