diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c86d09e..cce49890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * [UIQM-665](https://issues.folio.org/browse/UIQM-665) Fix to generate array content in 008 after changing document type of MARC bib. * [UIQM-694](https://issues.folio.org/browse/UIQM-694) Separate error messages triggered by controlled subfields of different linked fields. * [UIQM-592](https://issues.folio.org/browse/UIQM-592) Fix to input polish special chars into fields. +* [UIQM-697](https://issues.folio.org/browse/UIQM-697) Field 008: Validate the length of subfields. Add backslashes if the length of a subfield of field 008 is shorter, if longer - cut off the extra characters. * [UIQM-699](https://issues.folio.org/browse/UIQM-699) ECS - send validation request with central tenant id for shared Bib and Authority records. ## [8.0.1] (https://github.com/folio-org/ui-quick-marc/tree/v8.0.1) (2024-04-18) diff --git a/src/QuickMarcEditor/QuickMarcCreateWrapper.test.js b/src/QuickMarcEditor/QuickMarcCreateWrapper.test.js index af9cee62..829eb1d1 100644 --- a/src/QuickMarcEditor/QuickMarcCreateWrapper.test.js +++ b/src/QuickMarcEditor/QuickMarcCreateWrapper.test.js @@ -28,6 +28,7 @@ import { holdingsLeader, } from '../../test/jest/fixtures/leaders'; import fixedFieldSpecBib from '../../test/mocks/fixedFieldSpecBib'; +import fixedFieldSpecAuth from '../../test/mocks/fixedFieldSpecAuth'; const runWithDelayedPromise = (fn, delay) => () => { return new Promise(resolve => setTimeout(() => resolve(fn()), delay)); @@ -232,6 +233,11 @@ const mockFormValues = jest.fn((marcType) => ({ updateInfo: { recordState: 'NEW' }, })); +const mockSpecs = { + [MARC_TYPES.BIB]: fixedFieldSpecBib, + [MARC_TYPES.AUTHORITY]: fixedFieldSpecAuth, +}; + jest.mock('@folio/stripes/final-form', () => () => (Component) => ({ onSubmit, marcType, @@ -311,7 +317,7 @@ const renderQuickMarcCreateWrapper = ({ mutator={mutator} action={QUICK_MARC_ACTIONS.CREATE} marcType={marcType} - fixedFieldSpec={fixedFieldSpecBib} + fixedFieldSpec={mockSpecs[marcType]} initialValues={mockFormValues(marcType)} locations={locations} /> diff --git a/src/QuickMarcEditor/QuickMarcDeriveWrapper.test.js b/src/QuickMarcEditor/QuickMarcDeriveWrapper.test.js index aa6f0d87..5502c24b 100644 --- a/src/QuickMarcEditor/QuickMarcDeriveWrapper.test.js +++ b/src/QuickMarcEditor/QuickMarcDeriveWrapper.test.js @@ -73,7 +73,7 @@ const mockFormValues = jest.fn(() => ({ Cont: ['b', '\\', '\\', '\\'], Ctry: 'miu', Date1: '2009', - Date2: '\\\\', + Date2: '\\\\\\\\', Desc: 'i', DtSt: 's', Entered: '130325', diff --git a/src/QuickMarcEditor/QuickMarcEditWrapper.test.js b/src/QuickMarcEditor/QuickMarcEditWrapper.test.js index 42a26fde..d30c770d 100644 --- a/src/QuickMarcEditor/QuickMarcEditWrapper.test.js +++ b/src/QuickMarcEditor/QuickMarcEditWrapper.test.js @@ -24,6 +24,7 @@ import { bibLeaderString, } from '../../test/jest/fixtures/leaders'; import fixedFieldSpecBib from '../../test/mocks/fixedFieldSpecBib'; +import fixedFieldSpecAuth from '../../test/mocks/fixedFieldSpecAuth'; jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), @@ -277,6 +278,11 @@ const mockFormValues = jest.fn((marcType) => ({ updateInfo: { recordState: 'NEW' }, })); +const mockSpecs = { + [MARC_TYPES.BIB]: fixedFieldSpecBib, + [MARC_TYPES.AUTHORITY]: fixedFieldSpecAuth, +}; + const mockActualizeLinks = jest.fn((formValuesToProcess) => Promise.resolve(formValuesToProcess)); const mockUpdateMarcRecord = jest.fn().mockResolvedValue(); const mockOnCheckCentralTenantPerm = jest.fn().mockReturnValue(false); @@ -383,7 +389,7 @@ const renderQuickMarcEditWrapper = ({ locations={locations} externalRecordPath="/some-record" refreshPageData={jest.fn().mockResolvedValue()} - fixedFieldSpec={fixedFieldSpecBib} + fixedFieldSpec={mockSpecs[marcType]} onCheckCentralTenantPerm={mockOnCheckCentralTenantPerm} {...renderProps} {...props} diff --git a/src/QuickMarcEditor/utils.js b/src/QuickMarcEditor/utils.js index 17203580..00916cc2 100644 --- a/src/QuickMarcEditor/utils.js +++ b/src/QuickMarcEditor/utils.js @@ -1228,3 +1228,9 @@ export const isDiacritic = (char) => { return char.normalize('NFD') !== char; }; + +export const getVisibleNonSelectable008Subfields = (fixedFieldType) => { + return fixedFieldType.items + .filter(field => !field.readOnly) + .filter(field => !field.isArray); +}; diff --git a/src/hooks/useValidation/rules.js b/src/hooks/useValidation/rules.js index fb6d189c..74933c94 100644 --- a/src/hooks/useValidation/rules.js +++ b/src/hooks/useValidation/rules.js @@ -26,6 +26,7 @@ import { validateContentExistence, validateFixedFieldPositions, validateLccnDuplication, + validateFixedFieldLength, } from './validators'; import { is010LinkedToBibRecord, @@ -52,6 +53,7 @@ const RULES = { SUBFIELD_VALUE_MATCH: validateSubfieldValueMatch, SUBFIELD_CHANGED: validateSubfieldChanged, FIXED_FIELD_POSITIONS: validateFixedFieldPositions, + FIXED_FIELD_LENGTH: validateFixedFieldLength, DUPLICATE_LCCN: validateLccnDuplication, }; @@ -84,6 +86,11 @@ const BASE_BIB_VALIDATORS = [ validator: RULES.FIXED_FIELD_POSITIONS, message: (name) => ({ id: 'ui-quick-marc.record.error.008.invalidValue', values: { name } }), }, + { + tag: '008', + validator: RULES.FIXED_FIELD_LENGTH, + message: (name, length) => ({ id: 'ui-quick-marc.record.error.008.invalidLength', values: { name, length } }), + }, { validator: RULES.$9IN_LINKABLE, message: () => ({ id: 'ui-quick-marc.record.error.$9' }), @@ -258,6 +265,11 @@ const BASE_AUTHORITY_VALIDATORS = [ validator: RULES.DUPLICATE_LCCN, message: () => ({ id: 'ui-quick-marc.record.error.010.lccnDuplicated' }), }, + { + tag: '008', + validator: RULES.FIXED_FIELD_LENGTH, + message: (name, length) => ({ id: 'ui-quick-marc.record.error.008.invalidLength', values: { name, length } }), + }, ]; const CREATE_AUTHORITY_VALIDATORS = [ diff --git a/src/hooks/useValidation/useValidation.js b/src/hooks/useValidation/useValidation.js index ba8673b3..1bb35460 100644 --- a/src/hooks/useValidation/useValidation.js +++ b/src/hooks/useValidation/useValidation.js @@ -2,6 +2,7 @@ import { useCallback, useContext, } from 'react'; +import { useIntl } from 'react-intl'; import flow from 'lodash/flow'; import { useOkapiKy } from '@folio/stripes/core'; @@ -13,6 +14,8 @@ import { useValidate, } from '../../queries'; import { + getLeaderPositions, + getVisibleNonSelectable008Subfields, isLeaderRow, joinErrors, } from '../../QuickMarcEditor/utils'; @@ -21,7 +24,11 @@ import { MISSING_FIELD_ID, SEVERITY, } from './constants'; -import { QUICK_MARC_ACTIONS } from '../../QuickMarcEditor/constants'; +import { + FIXED_FIELD_TAG, + QUICK_MARC_ACTIONS, +} from '../../QuickMarcEditor/constants'; +import { FixedFieldFactory } from '../../QuickMarcEditor/QuickMarcEditorRows/FixedField'; const BE_VALIDATION_MARC_TYPES = [MARC_TYPES.BIB, MARC_TYPES.AUTHORITY]; @@ -46,6 +53,7 @@ const useValidation = (context = {}, tenantId = null) => { const { validate: validateFetch } = useValidate({ tenantId }); const { duplicateLccnCheckingEnabled } = useLccnDuplicateConfig({ marcType: context.marcType }); const ky = useOkapiKy(); + const intl = useIntl(); const runFrontEndValidation = useCallback(async (marcRecords) => { const validationRules = validators[context.marcType][context.action]; @@ -56,11 +64,12 @@ const useValidation = (context = {}, tenantId = null) => { marcRecords, duplicateLccnCheckingEnabled, ky, + intl, }, rule))) .then(errorsList => errorsList.reduce((joinedErrors, ruleErrors) => joinErrors(joinedErrors, ruleErrors), {})); return formatFEValidation(errors); - }, [context, quickMarcContext, duplicateLccnCheckingEnabled, ky]); + }, [context, quickMarcContext, duplicateLccnCheckingEnabled, ky, intl]); const formatBEValidationResponse = (response, marcRecords) => { if (!response.issues) { @@ -103,7 +112,49 @@ const useValidation = (context = {}, tenantId = null) => { return issues; }, [context.action, context.marcType]); - const runBackEndValidation = useCallback(async (marcRecords) => { + // if the length of a subfield of field 008 is shorter, then add backslashes, + // if longer, then cut off the extra characters. + const fillIn008FieldBlanks = useCallback((marcRecords) => { + if (![MARC_TYPES.BIB, MARC_TYPES.AUTHORITY].includes(context.marcType)) { + return marcRecords; + } + + const { type, position7 } = getLeaderPositions(context.marcType, marcRecords); + const fixedFieldType = FixedFieldFactory.getFixedFieldType(context.fixedFieldSpec, type, position7); + + const fieldsMap = getVisibleNonSelectable008Subfields(fixedFieldType) + .reduce((acc, field) => ({ ...acc, [field.code]: field }), {}); + + return marcRecords.map(field => { + if (field.tag !== FIXED_FIELD_TAG) { + return field; + } + + // if the spec contains a subfield length of 4, then '123456' becomes '1234' and '12' becomes '12\\\\' + return { + ...field, + content: Object.keys(field.content).reduce((acc, code) => { + const value = field.content[code]; + + if (Array.isArray(value) || !fieldsMap[code]) { + acc[code] = value; + } else { + const length = fieldsMap[code].length; + + acc[code] = value.length === length + ? value + : value.substring(0, length).padEnd(length, '\\'); + } + + return acc; + }, {}), + }; + }); + }, [context.fixedFieldSpec, context.marcType]); + + const runBackEndValidation = useCallback(async (records) => { + const marcRecords = fillIn008FieldBlanks(records); + const body = { fields: marcRecords.filter(record => !isLeaderRow(record)), leader: marcRecords.find(isLeaderRow)?.content, @@ -116,7 +167,7 @@ const useValidation = (context = {}, tenantId = null) => { () => formatBEValidationResponse(response, marcRecords), removeError001MissingField, )(); - }, [context, validateFetch, removeError001MissingField]); + }, [context, validateFetch, removeError001MissingField, fillIn008FieldBlanks]); const isBackEndValidationMarcType = useCallback(marcType => BE_VALIDATION_MARC_TYPES.includes(marcType), []); diff --git a/src/hooks/useValidation/useValidation.test.js b/src/hooks/useValidation/useValidation.test.js index 1f490de7..48386b8d 100644 --- a/src/hooks/useValidation/useValidation.test.js +++ b/src/hooks/useValidation/useValidation.test.js @@ -8,6 +8,7 @@ import { QUICK_MARC_ACTIONS } from '../../QuickMarcEditor/constants'; import { MARC_TYPES } from '../../common/constants'; import { MISSING_FIELD_ID } from './constants'; import fixedFieldSpecBib from '../../../test/mocks/fixedFieldSpecBib'; +import fixedFieldSpecAuth from '../../../test/mocks/fixedFieldSpecAuth'; import { authorityLeader, authorityLeaderString, @@ -553,6 +554,103 @@ describe('useValidation', () => { ); }); }); + + describe('when the length of the subfields of field 008 does not correspond with the one from spec', () => { + it('should append backslashes if there are fewer characters and cut off extra ones', async () => { + const { result } = renderHook(() => useValidation(marcContext), { + wrapper: getWrapper(), + }); + + await result.current.validate([ + ...record.records, + { + id: 5, + tag: '008', + content: { + Date1: '199', + Ctry: 'a', + }, + }, + { + id: 6, + tag: '008', + content: { + Date2: '19999', + }, + }, + ]); + + expect(mockValidate).toHaveBeenCalledWith({ + body: expect.objectContaining({ + fields: expect.arrayContaining([{ + id: 5, + tag: '008', + content: { + Date1: '199\\', + Ctry: 'a\\\\', + }, + }, { + id: 6, + tag: '008', + content: { + Date2: '1999', + }, + }]), + }), + }); + }); + + it('should return error messages for each field 008', async () => { + const { result } = renderHook(() => useValidation(marcContext), { + wrapper: getWrapper(), + }); + + const validationErrors = await result.current.validate([ + ...record.records, + { + id: 5, + tag: '008', + content: { + Date1: '199', + Ctry: 'a', + }, + }, + { + id: 6, + tag: '008', + content: { + Date2: '19999', + }, + }, + ]); + + expect(validationErrors).toEqual(expect.objectContaining({ + 5: [{ + id: 'ui-quick-marc.record.error.008.invalidLength', + severity: 'error', + values: { + length: 4, + name: 'ui-quick-marc.record.fixedField.Date1', + }, + }, { + id: 'ui-quick-marc.record.error.008.invalidLength', + severity: 'error', + values: { + length: 3, + name: 'ui-quick-marc.record.fixedField.Ctry', + }, + }], + 6: [{ + id: 'ui-quick-marc.record.error.008.invalidLength', + severity: 'error', + values: { + length: 4, + name: 'ui-quick-marc.record.fixedField.Date2', + }, + }], + })); + }); + }); }); describe('when validating Holdings record', () => { @@ -845,6 +943,7 @@ describe('useValidation', () => { naturalId: null, linkableBibFields, linkingRules, + fixedFieldSpec: fixedFieldSpecAuth, }; const record = { @@ -904,6 +1003,42 @@ describe('useValidation', () => { [MISSING_FIELD_ID]: [{ message: 'error message', severity: 'error', tag: '245[0]' }], }); }); + + describe('when the length of the subfields of field 008 exceeds the limit', () => { + it('should return error messages', async () => { + const { result } = renderHook(() => useValidation(marcContext), { + wrapper: getWrapper(), + }); + + const validationErrors = await result.current.validate([ + ...record.records, + { + id: 4, + content: { + 'Geo Subd': 'test', + 'Lang': 'test', + }, + tag: '008', + }, + ]); + + expect(validationErrors[4]).toEqual([{ + id: 'ui-quick-marc.record.error.008.invalidLength', + severity: 'error', + values: { + length: 1, + name: 'ui-quick-marc.record.fixedField.Geo Subd', + }, + }, { + id: 'ui-quick-marc.record.error.008.invalidLength', + severity: 'error', + values: { + length: 1, + name: 'ui-quick-marc.record.fixedField.Lang', + }, + }]); + }); + }); }); describe('when action is CREATE', () => { @@ -940,6 +1075,7 @@ describe('useValidation', () => { naturalId: null, linkableBibFields, linkingRules, + fixedFieldSpec: fixedFieldSpecAuth, }; const record = { diff --git a/src/hooks/useValidation/validators.js b/src/hooks/useValidation/validators.js index 137e287c..2dbfd69f 100644 --- a/src/hooks/useValidation/validators.js +++ b/src/hooks/useValidation/validators.js @@ -9,6 +9,7 @@ import { checkIsEmptyContent, convertLeaderToString, getLeaderPositions, + getVisibleNonSelectable008Subfields, } from '../../QuickMarcEditor/utils'; import { LEADER_EDITABLE_BYTES, @@ -411,6 +412,33 @@ export const validateFixedFieldPositions = ({ marcRecords, fixedFieldSpec, marcT return undefined; }; +export const validateFixedFieldLength = ({ marcRecords, fixedFieldSpec, marcType, intl }, rule) => { + const { type, position7: subtype } = getLeaderPositions(marcType, marcRecords); + const fixedFieldType = FixedFieldFactory.getFixedFieldType(fixedFieldSpec, type, subtype); + const fields008 = marcRecords.filter(x => x.tag === FIXED_FIELD_TAG); + const nonSelectableSubfields = getVisibleNonSelectable008Subfields(fixedFieldType); + + const errors = fields008.reduce((acc, field) => { + nonSelectableSubfields.forEach(subfield => { + if (field.content[subfield.code] && field.content[subfield.code].length !== subfield.length) { + const subfieldName = intl.formatMessage({ id: `ui-quick-marc.record.fixedField.${subfield.code}` }); + + acc[field.id] = [ + ...(acc[field.id] || []), + rule.message(subfieldName, subfield.length), + ]; + } + }); + + return acc; + }, {}); + + if (!isEmpty(errors)) { + return errors; + } + + return undefined; +}; export const validateLccnDuplication = async ({ ky, marcRecords, diff --git a/translations/ui-quick-marc/en.json b/translations/ui-quick-marc/en.json index 76165909..b27cfa2b 100644 --- a/translations/ui-quick-marc/en.json +++ b/translations/ui-quick-marc/en.json @@ -732,6 +732,7 @@ "record.error.008.empty": "Record cannot be saved without 008 field", "record.error.008.multiple": "Record cannot be saved with more than one 008 field", "record.error.008.invalidValue": "Record cannot be saved. Field 008 contains an invalid value in \"{name}\" position.", + "record.error.008.invalidLength": "Invalid {name} field length, must be {length} characters.", "record.error.heading.empty": "Record cannot be saved without 1XX field.", "record.error.heading.multiple": "Record cannot be saved. Cannot have multiple 1XXs", "record.error.title.multiple": "Record cannot be saved with more than one field 245.",