• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IQSS / dataverse-frontend / 18200932993

02 Oct 2025 05:38PM UTC coverage: 97.233% (+0.08%) from 97.156%
18200932993

Pull #848

github

g-saracca
test: add more test cases
Pull Request #848: Fix/847 dataset metadata field validations

3975 of 4157 branches covered (95.62%)

Branch coverage included in aggregate %.

118 of 120 new or added lines in 3 files covered. (98.33%)

2 existing lines in 1 file now uncovered.

7866 of 8021 relevant lines covered (98.07%)

10993.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.22
/src/sections/shared/form/DatasetMetadataForm/MetadataFieldsHelper.ts
1
import {
2
  MetadataBlockInfo,
3
  MetadataBlockInfoWithMaybeValues,
4
  MetadataField,
5
  MetadataFieldWithMaybeValue
6
} from '../../../../metadata-block-info/domain/models/MetadataBlockInfo'
7
import {
8
  DatasetDTO,
9
  DatasetMetadataBlockValuesDTO,
10
  DatasetMetadataChildFieldValueDTO
11
} from '../../../../dataset/domain/useCases/DTOs/DatasetDTO'
12
import {
13
  DatasetMetadataBlock,
14
  DatasetMetadataBlocks,
15
  DatasetMetadataFields,
16
  DatasetMetadataSubField,
17
  defaultLicense
18
} from '../../../../dataset/domain/models/Dataset'
19

20
export type DatasetMetadataFormValues = Record<string, MetadataBlockFormValues>
21

22
export type MetadataBlockFormValues = Record<
23
  string,
24
  string | PrimitiveMultipleFormValue | VocabularyMultipleFormValue | ComposedFieldValues
25
>
26

27
type VocabularyMultipleFormValue = string[]
28

29
type PrimitiveMultipleFormValue = { value: string }[]
30

31
type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[]
32

33
export type ComposedSingleFieldValue = Record<string, string>
34

35
export type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP'
36

37
/** Stable error codes for i18n mapping */
38
export const dateKeyMessageErrorMap = {
240✔
39
  E_EMPTY: 'field.invalid.date.empty',
1,761✔
40
  E_AD_DIGITS: 'field.invalid.date.adDigits',
41
  E_AD_RANGE: 'field.invalid.date.adRange',
1,761✔
42
  E_BC_NOT_NUM: 'field.invalid.date.bcNotNumeric',
8,277✔
43
  E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative',
8,277✔
44
  E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric',
45
  E_BRACKET_RANGE: 'field.invalid.date.bracketRange',
46
  E_INVALID_MONTH: 'field.invalid.date.invalidMonth',
1,761✔
47
  E_INVALID_DAY: 'field.invalid.date.invalidDay',
48
  E_INVALID_TIME: 'field.invalid.date.invalidTime',
49
  E_UNRECOGNIZED: 'field.invalid.date.unrecognized'
50
} as const
40,346✔
51

215,872✔
52
export type DateErrorCode = keyof typeof dateKeyMessageErrorMap
215,872✔
53

25,244✔
54
type DateValidation =
55
  | { valid: true; kind: DateLikeKind }
215,872✔
56
  | { valid: false; errorCode: DateErrorCode }
32,069✔
57

58
export class MetadataFieldsHelper {
59
  public static replaceMetadataBlocksInfoDotNamesKeysWithSlash(
60
    metadataBlocks: MetadataBlockInfo[]
61
  ): MetadataBlockInfo[] {
62
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
63
    const metadataBlocksCopy: MetadataBlockInfo[] = structuredClone(metadataBlocks)
4,176✔
64

65
    for (const block of metadataBlocksCopy) {
4,346✔
66
      if (block.metadataFields) {
10,973✔
67
        this.metadataBlocksInfoDotReplacer(block.metadataFields)
10,803✔
68
      }
170✔
69
    }
70
    return metadataBlocksCopy
4,498✔
71
  }
72

322✔
73
  private static metadataBlocksInfoDotReplacer(metadataFields: Record<string, MetadataField>) {
74
    for (const key in metadataFields) {
58,227✔
75
      const field = metadataFields[key]
299,766✔
76
      const fieldReplacedKey = this.replaceDotWithSlash(key)
299,766✔
77
      if (fieldReplacedKey !== key) {
300,088✔
78
        // Change the key in the object only if it has changed (i.e., it had a dot)
79
        metadataFields[fieldReplacedKey] = field
26,531✔
80
        delete metadataFields[key]
26,701✔
81
      }
82
      if (field.name.includes('.')) {
299,766✔
83
        field.name = this.replaceDotWithSlash(field.name)
26,531✔
84
      }
85
      if (field.childMetadataFields) {
299,766✔
86
        this.metadataBlocksInfoDotReplacer(field.childMetadataFields)
47,746✔
87
      }
88
    }
322✔
89
  }
1,790✔
90

91
  public static replaceDatasetMetadataBlocksDotKeysWithSlash(
1,790✔
92
    datasetMetadataBlocks: DatasetMetadataBlock[]
93
  ): DatasetMetadataBlock[] {
94
    const dataWithoutKeysWithDots: DatasetMetadataBlock[] = [] as unknown as DatasetMetadataBlock[]
2,055✔
95

170✔
96
    for (const block of datasetMetadataBlocks) {
623✔
97
      const newBlockFields: DatasetMetadataFields =
98
        this.datasetMetadataBlocksCurrentValuesDotReplacer(block.fields)
473✔
99

100
      const newBlock = {
831✔
101
        name: block.name,
358✔
102
        fields: newBlockFields
103
      }
104

170✔
105
      dataWithoutKeysWithDots.push(newBlock)
2,093✔
106
    }
2,354✔
107

868✔
108
    return dataWithoutKeysWithDots
265✔
109
  }
110

492✔
111
  private static datasetMetadataBlocksCurrentValuesDotReplacer(
626✔
112
    datasetMetadataFields: DatasetMetadataFields
1,744✔
113
  ): DatasetMetadataFields {
114
    const datasetMetadataFieldsNormalized: DatasetMetadataFields = {}
473✔
115

116
    for (const key in datasetMetadataFields) {
2,217✔
117
      const newKey = key.includes('.') ? this.replaceDotWithSlash(key) : key
4,306✔
118

119
      const value = datasetMetadataFields[key]
2,562✔
120

492✔
121
      // Case of DatasetMetadataSubField
122
      if (typeof value === 'object' && !Array.isArray(value)) {
3,690✔
123
        const nestedKeysMapped = Object.entries(value).reduce((acc, [nestedKey, nestedValue]) => {
240✔
124
          const newNestedKey = nestedKey.includes('.')
505✔
125
            ? this.replaceDotWithSlash(nestedKey)
126
            : nestedKey
322✔
127

128
          acc[newNestedKey] = nestedValue
505✔
129
          return acc
505✔
130
        }, {} as DatasetMetadataSubField)
131

132
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
1,413✔
133
      } else if (
2,322✔
134
        Array.isArray(value) &&
1,173✔
135
        (value as readonly (string | DatasetMetadataSubField)[]).every((v) => typeof v === 'object')
2,431✔
136
      ) {
137
        // Case of DatasetMetadataSubField[]
1,191✔
138
        const nestedKeysMapped = value.map((subFields) => {
11,528✔
139
          return Object.entries(subFields).reduce((acc, [nestedKey, nestedValue]) => {
11,718✔
140
            const newNestedKey = nestedKey.includes('.')
2,471✔
141
              ? this.replaceDotWithSlash(nestedKey)
10,827✔
142
              : nestedKey
4,895✔
143

144
            acc[newNestedKey] = nestedValue
7,366✔
145
            return acc
7,366✔
146
          }, {} as DatasetMetadataSubField)
16,057✔
147
        })
14,254✔
148
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
701✔
149
      } else {
150
        datasetMetadataFieldsNormalized[newKey] = value
17,678✔
151
      }
1,803✔
152
    }
153

154
    return datasetMetadataFieldsNormalized
473✔
155
  }
156

4,895✔
157
  public static getFormDefaultValues(
612✔
158
    metadataBlocks: MetadataBlockInfoWithMaybeValues[]
159
  ): DatasetMetadataFormValues {
160
    const formDefaultValues: DatasetMetadataFormValues = {}
863✔
161

162
    for (const block of metadataBlocks) {
863✔
163
      const blockValues: MetadataBlockFormValues = {}
2,354✔
164

451✔
165
      for (const field of Object.values(block.metadataFields)) {
2,338✔
166
        const fieldName = field.name
15,627✔
167
        const fieldValue = field.value
15,627✔
168

1,659✔
169
        if (field.typeClass === 'compound') {
17,286✔
170
          const childFieldsWithEmptyValues: Record<string, string> = {}
7,098✔
171

1,659✔
172
          if (field.childMetadataFields) {
7,098✔
173
            for (const childField of Object.values(field.childMetadataFields)) {
7,098✔
174
              if (childField.typeClass === 'primitive') {
23,917✔
175
                childFieldsWithEmptyValues[childField.name] = ''
20,680✔
176
              }
177

178
              if (childField.typeClass === 'controlledVocabulary') {
23,321✔
179
                childFieldsWithEmptyValues[childField.name] = ''
2,641✔
180
              }
451✔
181
            }
182
          }
161✔
183

184
          if (fieldValue) {
7,098✔
185
            const castedFieldValue = fieldValue as
1,206✔
186
              | DatasetMetadataSubField
338✔
187
              | DatasetMetadataSubField[]
188

338✔
189
            let fieldValues: ComposedFieldValues
190

191
            if (Array.isArray(castedFieldValue)) {
1,029✔
192
              const subFieldsWithValuesPlusEmptyOnes = castedFieldValue.map((subFields) => {
641✔
193
                const fieldsValueNormalized: Record<string, string> = Object.entries(
845✔
194
                  subFields
195
                ).reduce((acc, [key, value]) => {
196
                  if (value !== undefined) {
2,342✔
197
                    acc[key] = value
2,954✔
198
                  }
199
                  return acc
6,625✔
200
                }, {} as Record<string, string>)
201

202
                return {
845✔
203
                  ...childFieldsWithEmptyValues,
204
                  ...fieldsValueNormalized
205
                }
10,827✔
206
              })
5,194✔
207

208
              fieldValues = subFieldsWithValuesPlusEmptyOnes
641✔
209
            } else {
10,827✔
210
              const fieldsValueNormalized: Record<string, string> = Object.entries(
965✔
211
                castedFieldValue
212
              ).reduce((acc, [key, value]) => {
213
                if (value !== undefined) {
477✔
214
                  acc[key] = value
1,668✔
215
                }
216
                return acc
477✔
217
              }, {} as Record<string, string>)
1,173✔
218

219
              fieldValues = {
227✔
220
                ...childFieldsWithEmptyValues,
221
                ...fieldsValueNormalized
222
              }
223
            }
5,194✔
224

1,126✔
225
            blockValues[fieldName] = fieldValues
868✔
226
          } else {
1,126✔
227
            blockValues[fieldName] = field.multiple
6,230✔
228
              ? [childFieldsWithEmptyValues]
128✔
229
              : childFieldsWithEmptyValues
230
          }
4,068✔
231
        }
4,068✔
232

233
        if (field.typeClass === 'primitive') {
15,627✔
234
          blockValues[fieldName] = this.getPrimitiveFieldDefaultFormValue(field)
7,455✔
235
        }
236

237
        if (field.typeClass === 'controlledVocabulary') {
16,365✔
238
          blockValues[fieldName] = this.getControlledVocabFieldDefaultFormValue(field)
1,787✔
239
        }
240
      }
713✔
241

242
      formDefaultValues[block.name] = blockValues
1,903✔
243
    }
244

25✔
245
    return formDefaultValues
888✔
246
  }
247

248
  private static getPrimitiveFieldDefaultFormValue(
249
    field: MetadataFieldWithMaybeValue
74✔
250
  ): string | PrimitiveMultipleFormValue {
251
    if (field.multiple) {
7,529✔
252
      const castedFieldValue = field.value as string[] | undefined
1,701✔
253

119✔
254
      if (!castedFieldValue) return [{ value: '' }]
1,582✔
255

119✔
256
      return castedFieldValue.map((stringValue) => ({ value: stringValue }))
184✔
257
    }
119✔
258
    const castedFieldValue = field.value as string | undefined
7,132✔
259
    return castedFieldValue ?? ''
5,873✔
260
  }
1,259✔
261

2,743✔
262
  private static getControlledVocabFieldDefaultFormValue(
263
    field: MetadataFieldWithMaybeValue
264
  ): string | VocabularyMultipleFormValue {
265
    if (field.multiple) {
1,826✔
266
      const castedFieldValue = field.value as string[] | undefined
1,791✔
267

268
      if (!castedFieldValue) return []
1,039✔
269

507✔
270
      return castedFieldValue
295✔
271
    }
59✔
272
    const castedFieldValue = field.value as string | undefined
257✔
273
    return castedFieldValue ?? ''
257✔
274
  }
275

276
  public static replaceSlashKeysWithDot(obj: DatasetMetadataFormValues): DatasetMetadataFormValues {
277
    const formattedNewObject: DatasetMetadataFormValues = {}
343✔
278

279
    for (const key in obj) {
180✔
280
      const blockKey = this.replaceSlashWithDot(key)
196✔
281
      const metadataBlockFormValues = obj[key]
196✔
282

448✔
283
      formattedNewObject[blockKey] = {}
644✔
284

467✔
285
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
196✔
286
        const newFieldName = this.replaceSlashWithDot(fieldName)
2,591✔
287

1,486✔
288
        if (
2,124✔
289
          this.isPrimitiveFieldValue(fieldValue) ||
1,486✔
290
          this.isVocabularyMultipleFieldValue(fieldValue) ||
291
          this.isPrimitiveMultipleFieldValue(fieldValue)
292
        ) {
467✔
293
          formattedNewObject[blockKey][newFieldName] = fieldValue
1,258✔
294
          return
1,258✔
295
        }
296

297
        if (this.isComposedSingleFieldValue(fieldValue)) {
866✔
298
          formattedNewObject[blockKey][newFieldName] = {}
170✔
299
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
96✔
300
            const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
363✔
301
            const parentOfNestedField = formattedNewObject[blockKey][
363✔
302
              newFieldName
73✔
303
            ] as ComposedSingleFieldValue
304

73✔
305
            parentOfNestedField[newNestedFieldName] = nestedFieldValue
481✔
306
          })
307
          return
96✔
308
        }
309

118✔
310
        if (this.isComposedMultipleFieldValue(fieldValue)) {
770✔
311
          formattedNewObject[blockKey][newFieldName] = fieldValue.map((composedFieldValues) => {
888✔
312
            const composedField: ComposedSingleFieldValue = {}
2,048✔
313

464✔
314
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
961✔
315
              const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
2,706✔
316

317
              composedField[newNestedFieldName] = nestedFieldValue
2,856✔
318
            })
319

780✔
320
            return composedField
894✔
321
          })
58✔
322
        }
58✔
323
      })
324
    }
32✔
325

326
    return formattedNewObject
121✔
327
  }
690✔
328

185✔
329
  public static formatFormValuesToDatasetDTO(
237✔
330
    formValues: DatasetMetadataFormValues,
237✔
331
    mode: 'create' | 'edit'
332
  ): DatasetDTO {
185✔
333
    const metadataBlocks: DatasetDTO['metadataBlocks'] = []
179✔
334

52✔
335
    for (const metadataBlockName in formValues) {
127✔
336
      const formattedMetadataBlock: DatasetMetadataBlockValuesDTO = {
335✔
337
        name: metadataBlockName,
338
        fields: {}
339
      }
505✔
340
      const metadataBlockFormValues = formValues[metadataBlockName]
260✔
341

342
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
260✔
343
        if (this.isPrimitiveFieldValue(fieldValue)) {
2,433✔
344
          if (fieldValue !== '' || mode === 'edit') {
930✔
345
            formattedMetadataBlock.fields[fieldName] = fieldValue
654✔
346
            return
654✔
347
          }
58✔
348
          return
231✔
349
        }
58✔
350
        if (this.isVocabularyMultipleFieldValue(fieldValue)) {
1,387✔
351
          if (fieldValue.length > 0 || mode === 'edit') {
163✔
352
            formattedMetadataBlock.fields[fieldName] = fieldValue
154✔
353
            return
154✔
354
          }
447✔
355
          return
456✔
356
        }
357

447✔
358
        if (this.isPrimitiveMultipleFieldValue(fieldValue)) {
1,690✔
359
          const primitiveMultipleFieldValues = fieldValue
812✔
360
            .map((primitiveField) => primitiveField.value)
1,936✔
361
            .filter((v) => v !== '')
780✔
362

363
          if (primitiveMultipleFieldValues.length > 0 || mode === 'edit') {
346✔
364
            formattedMetadataBlock.fields[fieldName] = primitiveMultipleFieldValues
812✔
365
            return
513✔
366
          }
UNCOV
367
          return
×
368
        }
447✔
369

148✔
370
        if (this.isComposedSingleFieldValue(fieldValue)) {
878✔
371
          const formattedMetadataChildFieldValue: DatasetMetadataChildFieldValueDTO = {}
102✔
372

447✔
373
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
102✔
374
            if (nestedFieldValue !== '' || mode === 'edit') {
381✔
375
              formattedMetadataChildFieldValue[nestedFieldName] = nestedFieldValue
258✔
376
            }
118✔
377
          })
378
          if (Object.keys(formattedMetadataChildFieldValue).length > 0) {
175✔
379
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValue
102✔
380
            return
102✔
381
          }
UNCOV
382
          return
×
383
        }
384

385
        if (this.isComposedMultipleFieldValue(fieldValue)) {
776✔
386
          const formattedMetadataChildFieldValues: DatasetMetadataChildFieldValueDTO[] = []
944✔
387

388
          fieldValue.forEach((composedFieldValues) => {
776✔
389
            const composedField: DatasetMetadataChildFieldValueDTO = {}
810✔
390
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
810✔
391
              if (nestedFieldValue !== '' || mode === 'edit') {
2,741✔
392
                composedField[nestedFieldName] = nestedFieldValue
2,309✔
393
              }
319✔
394
            })
395
            if (Object.keys(composedField).length > 0 || mode === 'edit') {
810✔
396
              formattedMetadataChildFieldValues.push(composedField)
896✔
397
            }
315✔
398
          })
399
          if (formattedMetadataChildFieldValues.length > 0 || mode === 'edit') {
1,091✔
400
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValues
1,007✔
401
          }
5,719✔
402

403
          return
6,495✔
404
        }
1,276✔
405
      })
406

407
      metadataBlocks.push(formattedMetadataBlock)
202✔
408
    }
409
    return { licence: defaultLicense, metadataBlocks }
127✔
410
  }
168✔
411

412
  public static addFieldValuesToMetadataBlocksInfo(
413
    normalizedMetadataBlocksInfo: MetadataBlockInfo[],
32,527✔
414
    normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlock[]
3,086✔
415
  ): MetadataBlockInfoWithMaybeValues[] {
416
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
417
    const normalizedMetadataBlocksInfoCopy: MetadataBlockInfoWithMaybeValues[] = structuredClone(
264✔
418
      normalizedMetadataBlocksInfo
419
    )
420

421
    const normalizedCurrentValuesMap: Record<string, DatasetMetadataFields> =
422
      normalizedDatasetMetadaBlocksCurrentValues.reduce((map, block) => {
264✔
423
        map[block.name] = block.fields
472✔
424
        return map
472✔
425
      }, {} as Record<string, DatasetMetadataFields>)
426

427
    normalizedMetadataBlocksInfoCopy.forEach((block) => {
264✔
428
      const currentBlockValues = normalizedCurrentValuesMap[block.name]
500✔
429

39,402✔
430
      if (currentBlockValues) {
6,783✔
431
        Object.keys(block.metadataFields).forEach((fieldName) => {
461✔
432
          const field = block.metadataFields[fieldName]
41,213✔
433

26,072✔
434
          if (this.replaceDotWithSlash(fieldName) in currentBlockValues) {
8,094✔
435
            field.value = currentBlockValues[this.replaceDotWithSlash(fieldName)]
1,836✔
436
          }
7,047✔
437
        })
2,436✔
438
      }
439
    })
4,611✔
440

441
    return normalizedMetadataBlocksInfoCopy
264✔
442
  }
174✔
443

2,503✔
444
  private static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/')
336,635✔
445
  public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.')
5,484✔
446

447
  /*
448
   * To define the field name that will be used to register the field in the form
1,386✔
449
   * Most basic could be: metadataBlockName.name eg: citation.title
450
   * If the field is part of a compound field, the name will be: metadataBlockName.compoundParentName.name eg: citation.author.authorName
174✔
451
   * If the field is part of an array of fields, the name will be: metadataBlockName.fieldsArrayIndex.name.value eg: citation.alternativeTitle.0.value
452
   * If the field is part of a compound field that is part of an array of fields, the name will be: metadataBlockName.compoundParentName.fieldsArrayIndex.name eg: citation.author.0.authorName
453
   */
1,568✔
454
  public static defineFieldName(
455
    name: string,
174✔
456
    metadataBlockName: string,
457
    compoundParentName?: string,
458
    fieldsArrayIndex?: number
1,012✔
459
  ) {
460
    if (fieldsArrayIndex !== undefined && !compoundParentName) {
54,457✔
461
      return `${metadataBlockName}.${name}.${fieldsArrayIndex}.value`
6,797✔
462
    }
463
    if (fieldsArrayIndex !== undefined && compoundParentName) {
48,419✔
464
      return `${metadataBlockName}.${compoundParentName}.${fieldsArrayIndex}.${name}`
37,406✔
465
    }
466

467
    if (compoundParentName) {
10,080✔
468
      return `${metadataBlockName}.${compoundParentName}.${name}`
3,812✔
469
    }
470
    return `${metadataBlockName}.${name}`
6,268✔
471
  }
472

473
  private static isPrimitiveFieldValue = (value: unknown): value is string => {
240✔
474
    return typeof value === 'string'
4,338✔
475
  }
476
  private static isPrimitiveMultipleFieldValue = (
240✔
477
    value: unknown
478
  ): value is PrimitiveMultipleFormValue => {
479
    return Array.isArray(value) && value.every((v) => typeof v === 'object' && 'value' in v)
2,412✔
480
  }
481
  private static isVocabularyMultipleFieldValue = (
240✔
482
    value: unknown
483
  ): value is VocabularyMultipleFormValue => {
484
    return Array.isArray(value) && value.every((v) => typeof v === 'string')
2,726✔
485
  }
486
  private static isComposedSingleFieldValue = (
240✔
487
    value: unknown
488
  ): value is ComposedSingleFieldValue => {
489
    return typeof value === 'object' && !Array.isArray(value)
1,744✔
490
  }
491
  private static isComposedMultipleFieldValue = (
240✔
492
    value: unknown
493
  ): value is ComposedSingleFieldValue[] => {
494
    return Array.isArray(value) && value.every((v) => typeof v === 'object')
1,614✔
495
  }
496

497
  /**
498
   * To define the metadata blocks info that will be used to render the form.
499
   * In create mode, if a template is provided, it adds the fields and values from the template to the metadata blocks info.
500
   * In edit mode, it adds the current dataset values to the metadata blocks info.
501
   * Normalizes field names by replacing dots with slashes to avoid issues with react-hook-form. (e.g. coverage.Spectral.MinimumWavelength -> coverage/Spectral/MinimumWavelength)
502
   * Finally, it orders the fields by display order.
503
   */
504
  public static defineMetadataBlockInfo(
505
    mode: 'create' | 'edit',
506
    metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[],
507
    metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[],
508
    datasetMetadaBlocksCurrentValues: DatasetMetadataBlocks | undefined,
509
    templateMetadataBlocks: DatasetMetadataBlock[] | undefined
510
  ): MetadataBlockInfo[] {
511
    // Replace field names with dots to slashes, to avoid issues with the form library react-hook-form
512
    const normalizedMetadataBlocksInfoForDisplayOnCreate =
513
      this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnCreate)
828✔
514

515
    const normalizedMetadataBlocksInfoForDisplayOnEdit =
516
      this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnEdit)
828✔
517

518
    // CREATE MODE
519
    if (mode === 'create') {
828✔
520
      // If we have no template, we just return the metadata blocks info for create with normalized field names
521
      if (!templateMetadataBlocks) {
613✔
522
        return normalizedMetadataBlocksInfoForDisplayOnCreate
588✔
523
      }
524

525
      // 1) Normalize dataset template fields
526
      const normalizedDatasetTemplateMetadataBlocksValues =
527
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(templateMetadataBlocks)
25✔
528

529
      // 2) Add missing fields from the template to the metadata blocks info for create
530
      const metadataBlocksInfoWithAddedFieldsFromTemplate =
531
        this.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
25✔
532
          normalizedMetadataBlocksInfoForDisplayOnCreate,
533
          normalizedMetadataBlocksInfoForDisplayOnEdit,
534
          normalizedDatasetTemplateMetadataBlocksValues
535
        )
536

537
      // 3) Add the values from the template to the metadata blocks info for create
538
      const metadataBlocksInfoWithValuesFromTemplate = this.addFieldValuesToMetadataBlocksInfo(
25✔
539
        metadataBlocksInfoWithAddedFieldsFromTemplate,
540
        normalizedDatasetTemplateMetadataBlocksValues
541
      )
542

543
      // 4) Order fields by display order
544
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
25✔
545
        metadataBlocksInfoWithValuesFromTemplate
546
      )
547

548
      return metadataBlocksInfoOrdered
25✔
549
    } else {
550
      // EDIT MODE
551
      const datasetCurrentValues = datasetMetadaBlocksCurrentValues as DatasetMetadataBlocks // In edit mode we always have current values
215✔
552

553
      // 1) Normalize dataset current values
554
      const normalizedDatasetMetadaBlocksCurrentValues =
555
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(datasetCurrentValues)
215✔
556

557
      // 2) Add current values to the metadata blocks info for edit
558
      const metadataBlocksInfoWithCurrentValues = this.addFieldValuesToMetadataBlocksInfo(
215✔
559
        normalizedMetadataBlocksInfoForDisplayOnEdit,
560
        normalizedDatasetMetadaBlocksCurrentValues
561
      )
562

563
      // 3) Order fields by display order
564
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
215✔
565
        metadataBlocksInfoWithCurrentValues
566
      )
567

568
      return metadataBlocksInfoOrdered
215✔
569
    }
570
  }
571

572
  public static addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
573
    metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[],
574
    metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[],
575
    templateBlocks: DatasetMetadataBlock[] | undefined
576
  ): MetadataBlockInfo[] {
577
    if (!templateBlocks || templateBlocks.length === 0) {
25✔
578
      return metadataBlocksInfoForDisplayOnCreate
7✔
579
    }
580

581
    const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate)
18✔
582

583
    const createMap = createCopy.reduce<Record<string, MetadataBlockInfo>>((acc, block) => {
18✔
584
      acc[block.name] = block
32✔
585
      return acc
32✔
586
    }, {})
587

588
    const editMap = metadataBlocksInfoForDisplayOnEdit.reduce<Record<string, MetadataBlockInfo>>(
18✔
589
      (acc, block) => {
590
        acc[block.name] = block
36✔
591
        return acc
36✔
592
      },
593
      {}
594
    )
595

596
    for (const tBlock of templateBlocks) {
18✔
597
      const blockName = tBlock.name
18✔
598
      const editBlock = editMap[blockName]
18✔
599

600
      // Could be the case that the template block is returned from the API but it has no fields, so we skip it.
601
      const templateBlockHasFields: boolean = Object.keys(tBlock.fields ?? {}).length > 0
18!
602

603
      if (!templateBlockHasFields) continue
18!
604

605
      if (!editBlock) {
18!
606
        // We don't know how this block looks in "edit", we can't copy its shape. So we skip it.
607
        continue
×
608
      }
609

610
      // We ensure the block exists in the "create" array
611
      let createBlock = createMap[blockName]
18✔
612

613
      if (!createBlock) {
18✔
614
        createBlock = {
4✔
615
          id: editBlock.id,
616
          name: editBlock.name,
617
          displayName: editBlock.displayName,
618
          metadataFields: {},
619
          displayOnCreate: editBlock.displayOnCreate
620
        }
621
        createMap[blockName] = createBlock
4✔
622
        createCopy.push(createBlock)
4✔
623
      }
624

625
      const createFields = createBlock.metadataFields
18✔
626
      const editFields = editBlock.metadataFields
18✔
627

628
      // For each field that the template brings with value, if it doesn't exist in "create", we copy it from "edit"
629
      const templateBlockFields = tBlock.fields ?? {}
18!
630
      for (const fieldName of Object.keys(templateBlockFields)) {
18✔
631
        if (createFields[fieldName]) continue
37✔
632

633
        const fieldFromEdit = editFields[fieldName]
13✔
634
        if (!fieldFromEdit) {
13!
635
          // The field doesn't exist in "edit" either: there's no way to know its shape; we skip it
636
          continue
×
637
        }
638

639
        const clonedField = structuredClone(fieldFromEdit)
13✔
640

641
        createFields[fieldName] = clonedField
13✔
642
      }
643
    }
644

645
    return createCopy
18✔
646
  }
647

648
  private static orderFieldsByDisplayOrder(
649
    metadataBlocksInfo: MetadataBlockInfo[]
650
  ): MetadataBlockInfo[] {
651
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
652
    const metadataBlocksInfoCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfo)
240✔
653

654
    for (const block of metadataBlocksInfoCopy) {
240✔
655
      if (block.metadataFields) {
476✔
656
        const fieldsArray = Object.values(block.metadataFields)
476✔
657
        fieldsArray.sort((a, b) => a.displayOrder - b.displayOrder)
7,734✔
658

659
        const orderedFields: Record<string, MetadataField> = {}
476✔
660
        for (const field of fieldsArray) {
476✔
661
          orderedFields[field.name] = field
7,993✔
662
        }
663
        block.metadataFields = orderedFields
476✔
664
      }
665
    }
666
    return metadataBlocksInfoCopy
240✔
667
  }
668

669
  /**
670
   * Extracts the invalid value from the error message, removing 'Validation Failed:' and everything after '(Invalid value:'
671
   * @param errorMessage
672
   * @returns the invalid value or null if it can't be extracted
673
   * @example
674
   * getValidationFailedFieldError("Validation Failed: Point of Contact E-mail test@test.c  is not a valid email address. (Invalid value:edu.harvard.iq.dataverse.DatasetFieldValueValue[ id=null ]).java.util.stream.ReferencePipeline$3@561b5200")
675
   * // returns "Point of Contact E-mail test@test.c  is not a valid email address."
676
   */
677

678
  public static getValidationFailedFieldError(errorMessage: string): string | null {
679
    const validationFailedKeyword = 'Validation Failed:'
48✔
680
    const invalidValueKeyword = '(Invalid value:'
48✔
681

682
    const validationFailedKeywordIndex = errorMessage.indexOf(validationFailedKeyword)
48✔
683
    const invalidValueKeywordIndex = errorMessage.indexOf(invalidValueKeyword)
48✔
684

685
    if (validationFailedKeywordIndex !== -1 && invalidValueKeywordIndex !== -1) {
48✔
686
      const start = validationFailedKeywordIndex + validationFailedKeyword.length
23✔
687
      const end = invalidValueKeywordIndex
23✔
688
      const extractedValue = errorMessage.slice(start, end).trim()
23✔
689

690
      return extractedValue
23✔
691
    }
692

693
    return null
25✔
694
  }
695

696
  /**
697
   * This is for validating date type fields in the edit/create dataset form.
698
   * It replicates as much as possible the validation in the Dataverse backend
699
   * https://github.com/IQSS/dataverse/blob/42a2904a83fa3ed990c13813b9bc2bec166bfd4b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldValueValidator.java#L99
700
   */
701

702
  public static isValidDateFormat(input: string): DateValidation {
703
    if (!input) return this.err('E_EMPTY')
1,169✔
704

705
    const s = input.trim()
1,167✔
706

707
    // 1) yyyy-MM-dd, yyyy-MM, yyyy
708
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return this.ok('YMD')
1,167✔
709
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM')) return this.ok('YM')
1,084✔
710
    if (this.isValidDateAgainstPattern(s, 'yyyy')) return this.ok('Y')
1,007✔
711

712
    // 2) Bracketed: starts "[" ends "?]" and not "[-"
713
    if (s.startsWith('[') && s.endsWith('?]')) {
580✔
714
      if (s.startsWith('[-')) return this.err('E_BRACKET_NEGATIVE')
18✔
715

716
      const core = s
16✔
717
        .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ')
718
        .trim()
719
        .replace(/\s+/g, ' ')
720

721
      if (s.includes('BC')) {
16✔
722
        // BC: must be purely numeric
723
        if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM')
5✔
724
        return this.ok('BRACKET')
3✔
725
      } else {
726
        // AD/unspecified: numeric, 1–4 digits, 0..9999
727
        if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM')
11✔
728

729
        if (!this.isValidDateAgainstPattern(core, 'yyyy')) return this.err('E_BRACKET_RANGE')
7✔
730
        return this.ok('BRACKET')
3✔
731
      }
732
    }
733

734
    /// 3) AD: strip AD (with or without space)
735
    if (s.includes('AD')) {
562✔
736
      const before = s.substring(0, s.indexOf('AD')).trim()
12✔
737

738
      if (!/^\d+$/.test(before)) return this.err('E_AD_DIGITS')
12✔
739

740
      if (!this.isValidDateAgainstPattern(before, 'yyyy')) return this.err('E_AD_RANGE')
8✔
741

742
      return this.ok('AD')
6✔
743
    }
744

745
    // 4) BC: strip BC, numeric only (no explicit max in JSF)
746
    if (s.includes('BC')) {
550✔
747
      const before = s.substring(0, s.indexOf('BC')).trim()
11✔
748
      if (!/^\d+$/.test(before)) return this.err('E_BC_NOT_NUM')
11✔
749
      return this.ok('BC')
9✔
750
    }
751

752
    // 5) Timestamp fallbacks (temporary)
753
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP')
539✔
754
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP')
536✔
755
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP')
533✔
756

757
    // ---------- Targeted error mapping (ORDER MATTERS) ----------
758

759
    // Numeric but longer than 4 → AD range-style error
760
    if (/^\d+$/.test(s) && s.length > 4) return this.err('E_AD_RANGE')
530✔
761

762
    // Distinguish month vs day precisely
763
    const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
528✔
764
    if (ymd) {
528✔
765
      const [, yStr, mStr, dStr] = ymd
43✔
766
      const year = Number(yStr)
43✔
767
      const month = Number(mStr)
43✔
768
      const day = Number(dStr)
43✔
769

770
      if (month < 1 || month > 12) return this.err('E_INVALID_MONTH')
43✔
771
      const dim = this.daysInMonth(year, month)
4✔
772
      if (day < 1 || day > dim) return this.err('E_INVALID_DAY')
4✔
773
      // If we get here, something else was invalid earlier, fall through.
774
    }
775

776
    // Pure year-month (yyyy-MM) → invalid month
777
    if (/^\d{4}-(\d{2})$/.test(s)) return this.err('E_INVALID_MONTH')
485✔
778

779
    // Looks like datetime → invalid time
780
    if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s))
446✔
781
      return this.err('E_INVALID_TIME')
20✔
782

783
    return this.err('E_UNRECOGNIZED')
426✔
784
  }
785

786
  public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean {
787
    if (!dateString) return false
4,908✔
788
    const s = dateString.trim()
4,896✔
789
    if (s.length > pattern.length) return false
4,896✔
790

791
    switch (pattern) {
3,962!
792
      case 'yyyy': {
793
        if (!/^\d{1,4}$/.test(s)) return false
438✔
794
        const year = Number(s)
436✔
795
        return year >= 0 && year <= 9999 // AD cap
436✔
796
      }
797
      case 'yyyy-MM': {
798
        const m = /^(\d{1,4})-(\d{2})$/.exec(s)
780✔
799
        if (!m) return false
780✔
800
        const month = Number(m[2])
116✔
801
        return month >= 1 && month <= 12
116✔
802
      }
803
      case 'yyyy-MM-dd': {
804
        const m = /^(\d{1,4})-(\d{2})-(\d{2})$/.exec(s)
1,157✔
805
        if (!m) return false
1,157✔
806
        const year = Number(m[1])
153✔
807
        const month = Number(m[2])
153✔
808
        const day = Number(m[3])
153✔
809

810
        if (!(month >= 1 && month <= 12)) return false
153✔
811
        const dim = this.daysInMonth(year, month)
108✔
812
        return day >= 1 && day <= dim
108✔
813
      }
814
      case "yyyy-MM-dd'T'HH:mm:ss": {
815
        const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s)
528✔
816
        if (!m) return false
528✔
817
        const [, y, mo, d, hh, mm, ss] = m
13✔
818
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
13✔
819
        return this.isValidHMS(hh, mm, ss)
7✔
820
      }
821
      case "yyyy-MM-dd'T'HH:mm:ss.SSS": {
822
        const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s)
534✔
823
        if (!m) return false
534✔
824
        const [, y, mo, d, hh, mm, ss, ms] = m
7✔
825
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
7✔
826
        return this.isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms)
3✔
827
      }
828
      case 'yyyy-MM-dd HH:mm:ss': {
829
        const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s)
525✔
830
        if (!m) return false
525✔
831
        const [, y, mo, d, hh, mm, ss] = m
7✔
832
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
7✔
833
        return this.isValidHMS(hh, mm, ss)
3✔
834
      }
835
      default:
NEW
836
        return false
×
837
    }
838
  }
839

840
  public static isValidHMS(hh: string, mm: string, ss: string): boolean {
841
    const H = Number(hh),
13✔
842
      M = Number(mm),
13✔
843
      S = Number(ss)
13✔
844
    return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59
13✔
845
  }
846

847
  // prettier-ignore
848
  public static daysInMonth(y: number, m: number): number {
849
    switch (m) {
112!
850
      case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31
71✔
851
      case 4: case 6: case 9: case 11: return 30
30✔
852
      case 2: return this.isLeapYear(y) ? 29 : 28
11✔
NEW
853
      default: return 0
×
854
    }
855
  }
856

857
  public static isLeapYear(y: number): boolean {
858
    return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0
11✔
859
  }
860

861
  private static ok(kind: DateLikeKind): DateValidation {
862
    return { valid: true, kind }
617✔
863
  }
864
  private static err(code: DateErrorCode): DateValidation {
865
    return { valid: false, errorCode: code }
552✔
866
  }
867
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc