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

IQSS / dataverse-frontend / 21420156355

28 Jan 2026 12:42AM UTC coverage: 98.063% (+0.3%) from 97.779%
21420156355

Pull #908

github

ChengShi-1
fix: unit test error
Pull Request #908: Manage Dataset Templates Integration

1578 of 1634 branches covered (96.57%)

Branch coverage included in aggregate %.

55 of 55 new or added lines in 13 files covered. (100.0%)

23 existing lines in 13 files now uncovered.

3789 of 3839 relevant lines covered (98.7%)

10902.77 hits per line

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

99.62
/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
  DatasetMetadataFieldValueDTO,
11
  DatasetMetadataChildFieldValueDTO,
12
  DatasetMetadataFieldsDTO
13
} from '../../../../dataset/domain/useCases/DTOs/DatasetDTO'
14
import {
15
  DatasetMetadataBlock,
16
  DatasetMetadataBlocks,
17
  DatasetMetadataFields,
18
  DatasetMetadataSubField,
19
  defaultLicense
20
} from '../../../../dataset/domain/models/Dataset'
21
import { TemplateFieldInfo } from '../../../../templates/domain/models/TemplateInfo'
22

23
export type DatasetMetadataFormValues = Record<string, MetadataBlockFormValues>
24

25
export type MetadataBlockFormValues = Record<
26
  string,
27
  string | PrimitiveMultipleFormValue | VocabularyMultipleFormValue | ComposedFieldValues
28
>
29

30
type VocabularyMultipleFormValue = string[]
31

32
type PrimitiveMultipleFormValue = { value: string }[]
33

34
type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[]
35

36
export type ComposedSingleFieldValue = Record<string, string>
37

38
export type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP'
39

1,761✔
40
type TemplateFieldValuePayload =
41
  | string
1,761✔
42
  | string[]
8,277✔
43
  | TemplateFieldCompoundValue
8,277✔
44
  | TemplateFieldCompoundValue[]
45

46
type TemplateFieldCompoundValue = Record<string, TemplateFieldCompoundChildValue>
1,761✔
47

48
type TemplateFieldCompoundChildValue = {
49
  value: string | string[]
50
  typeName: string
40,346✔
51
  multiple: boolean
215,872✔
52
  typeClass: string
215,872✔
53
}
25,244✔
54

55
/** Stable error codes for i18n mapping */
215,872✔
56
export const dateKeyMessageErrorMap = {
32,069✔
57
  E_EMPTY: 'field.invalid.date.empty',
58
  E_AD_DIGITS: 'field.invalid.date.adDigits',
59
  E_AD_RANGE: 'field.invalid.date.adRange',
60
  E_BC_NOT_NUM: 'field.invalid.date.bcNotNumeric',
61
  E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative',
62
  E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric',
63
  E_BRACKET_RANGE: 'field.invalid.date.bracketRange',
64
  E_INVALID_MONTH: 'field.invalid.date.invalidMonth',
65
  E_INVALID_DAY: 'field.invalid.date.invalidDay',
170✔
66
  E_INVALID_TIME: 'field.invalid.date.invalidTime',
170✔
67
  E_UNRECOGNIZED: 'field.invalid.date.unrecognized'
68
} as const
170✔
69

70
export type DateErrorCode = keyof typeof dateKeyMessageErrorMap
322✔
71

72
type DateValidation =
322✔
73
  | { valid: true; kind: DateLikeKind }
74
  | { valid: false; errorCode: DateErrorCode }
75

76
export class MetadataFieldsHelper {
77
  public static replaceMetadataBlocksInfoDotNamesKeysWithSlash(
322✔
78
    metadataBlocks: MetadataBlockInfo[]
79
  ): MetadataBlockInfo[] {
80
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
170✔
81
    const metadataBlocksCopy: MetadataBlockInfo[] = structuredClone(metadataBlocks)
82

83
    for (const block of metadataBlocksCopy) {
84
      if (block.metadataFields) {
85
        this.metadataBlocksInfoDotReplacer(block.metadataFields)
86
      }
322✔
87
    }
88
    return metadataBlocksCopy
322✔
89
  }
1,790✔
90

91
  private static metadataBlocksInfoDotReplacer(metadataFields: Record<string, MetadataField>) {
1,790✔
92
    for (const key in metadataFields) {
93
      const field = metadataFields[key]
94
      const fieldReplacedKey = this.replaceDotWithSlash(key)
1,790✔
95
      if (fieldReplacedKey !== key) {
170✔
96
        // Change the key in the object only if it has changed (i.e., it had a dot)
358✔
97
        metadataFields[fieldReplacedKey] = field
98
        delete metadataFields[key]
99
      }
100
      if (field.name.includes('.')) {
358✔
101
        field.name = this.replaceDotWithSlash(field.name)
358✔
102
      }
103
      if (field.childMetadataFields) {
104
        this.metadataBlocksInfoDotReplacer(field.childMetadataFields)
170✔
105
      }
1,620✔
106
    }
2,354✔
107
  }
868✔
108

109
  public static replaceDatasetMetadataBlocksDotKeysWithSlash(
110
    datasetMetadataBlocks: DatasetMetadataBlock[]
492✔
111
  ): DatasetMetadataBlock[] {
626✔
112
    const dataWithoutKeysWithDots: DatasetMetadataBlock[] = [] as unknown as DatasetMetadataBlock[]
1,744✔
113

114
    for (const block of datasetMetadataBlocks) {
115
      const newBlockFields: DatasetMetadataFields =
116
        this.datasetMetadataBlocksCurrentValuesDotReplacer(block.fields)
1,744✔
117

1,744✔
118
      const newBlock = {
119
        name: block.name,
120
        fields: newBlockFields
492✔
121
      }
122

1,128✔
123
      dataWithoutKeysWithDots.push(newBlock)
124
    }
125

126
    return dataWithoutKeysWithDots
322✔
127
  }
128

129
  private static datasetMetadataBlocksCurrentValuesDotReplacer(
130
    datasetMetadataFields: DatasetMetadataFields
131
  ): DatasetMetadataFields {
132
    const datasetMetadataFieldsNormalized: DatasetMetadataFields = {}
1,173✔
133

134
    for (const key in datasetMetadataFields) {
1,173✔
135
      const newKey = key.includes('.') ? this.replaceDotWithSlash(key) : key
1,191✔
136

137
      const value = datasetMetadataFields[key]
1,191✔
138

10,827✔
139
      // Case of DatasetMetadataSubField
10,827✔
140
      if (typeof value === 'object' && !Array.isArray(value)) {
141
        const nestedKeysMapped = Object.entries(value).reduce((acc, [nestedKey, nestedValue]) => {
10,827✔
142
          const newNestedKey = nestedKey.includes('.')
4,895✔
143
            ? this.replaceDotWithSlash(nestedKey)
144
            : nestedKey
4,895✔
145

4,895✔
146
          acc[newNestedKey] = nestedValue
16,057✔
147
          return acc
14,254✔
148
        }, {} as DatasetMetadataSubField)
149

150
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
16,057✔
151
      } else if (
1,803✔
152
        Array.isArray(value) &&
153
        (value as readonly (string | DatasetMetadataSubField)[]).every((v) => typeof v === 'object')
154
      ) {
155
        // Case of DatasetMetadataSubField[]
156
        const nestedKeysMapped = value.map((subFields) => {
4,895✔
157
          return Object.entries(subFields).reduce((acc, [nestedKey, nestedValue]) => {
612✔
158
            const newNestedKey = nestedKey.includes('.')
159
              ? this.replaceDotWithSlash(nestedKey)
160
              : nestedKey
161

162
            acc[newNestedKey] = nestedValue
163
            return acc
612✔
164
          }, {} as DatasetMetadataSubField)
451✔
165
        })
596✔
166
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
167
      } else {
168
        datasetMetadataFieldsNormalized[newKey] = value
1,659✔
169
      }
1,659✔
170
    }
171

1,659✔
172
    return datasetMetadataFieldsNormalized
173
  }
174

596✔
175
  public static getFormDefaultValues(
176
    metadataBlocks: MetadataBlockInfoWithMaybeValues[]
177
  ): DatasetMetadataFormValues {
178
    const formDefaultValues: DatasetMetadataFormValues = {}
179

180
    for (const block of metadataBlocks) {
451✔
181
      const blockValues: MetadataBlockFormValues = {}
182

161✔
183
      for (const field of Object.values(block.metadataFields)) {
184
        const fieldName = field.name
185
        const fieldValue = field.value
338✔
186

338✔
187
        if (field.typeClass === 'compound') {
188
          const childFieldsWithEmptyValues: Record<string, string> = {}
338✔
189

190
          if (field.childMetadataFields) {
191
            for (const childField of Object.values(field.childMetadataFields)) {
161✔
192
              if (childField.typeClass === 'primitive') {
193
                childFieldsWithEmptyValues[childField.name] = ''
194
              }
195

196
              if (childField.typeClass === 'controlledVocabulary') {
197
                childFieldsWithEmptyValues[childField.name] = ''
612✔
198
              }
199
            }
4,283✔
200
          }
201

202
          if (fieldValue) {
203
            const castedFieldValue = fieldValue as
204
              | DatasetMetadataSubField
205
              | DatasetMetadataSubField[]
10,827✔
206

5,194✔
207
            let fieldValues: ComposedFieldValues
208

209
            if (Array.isArray(castedFieldValue)) {
10,827✔
210
              const subFieldsWithValuesPlusEmptyOnes = castedFieldValue.map((subFields) => {
738✔
211
                const fieldsValueNormalized: Record<string, string> = Object.entries(
212
                  subFields
213
                ).reduce((acc, [key, value]) => {
214
                  if (value !== undefined) {
1,191✔
215
                    acc[key] = value
216
                  }
217
                  return acc
1,173✔
218
                }, {} as Record<string, string>)
219

220
                return {
221
                  ...childFieldsWithEmptyValues,
222
                  ...fieldsValueNormalized
223
                }
5,194✔
224
              })
1,126✔
225

226
              fieldValues = subFieldsWithValuesPlusEmptyOnes
1,126✔
227
            } else {
228
              const fieldsValueNormalized: Record<string, string> = Object.entries(
128✔
229
                castedFieldValue
230
              ).reduce((acc, [key, value]) => {
4,068✔
231
                if (value !== undefined) {
4,068✔
232
                  acc[key] = value
233
                }
234
                return acc
235
              }, {} as Record<string, string>)
236

237
              fieldValues = {
738✔
238
                ...childFieldsWithEmptyValues,
713✔
239
                ...fieldsValueNormalized
240
              }
713✔
241
            }
242

161✔
243
            blockValues[fieldName] = fieldValues
244
          } else {
25✔
245
            blockValues[fieldName] = field.multiple
25✔
246
              ? [childFieldsWithEmptyValues]
247
              : childFieldsWithEmptyValues
248
          }
249
        }
74✔
250

251
        if (field.typeClass === 'primitive') {
74✔
252
          blockValues[fieldName] = this.getPrimitiveFieldDefaultFormValue(field)
119✔
253
        }
119✔
254

255
        if (field.typeClass === 'controlledVocabulary') {
119✔
256
          blockValues[fieldName] = this.getControlledVocabFieldDefaultFormValue(field)
257
        }
119✔
258
      }
1,259✔
259

260
      formDefaultValues[block.name] = blockValues
1,259✔
261
    }
2,743✔
262

263
    return formDefaultValues
264
  }
265

752✔
266
  private static getPrimitiveFieldDefaultFormValue(
752✔
267
    field: MetadataFieldWithMaybeValue
268
  ): string | PrimitiveMultipleFormValue {
269
    if (field.multiple) {
507✔
270
      const castedFieldValue = field.value as string[] | undefined
59✔
271

59✔
272
      if (!castedFieldValue) return [{ value: '' }]
222✔
273

222✔
274
      return castedFieldValue.map((stringValue) => ({ value: stringValue }))
275
    }
276
    const castedFieldValue = field.value as string | undefined
277
    return castedFieldValue ?? ''
222✔
278
  }
279

59✔
280
  private static getControlledVocabFieldDefaultFormValue(
281
    field: MetadataFieldWithMaybeValue
282
  ): string | VocabularyMultipleFormValue {
448✔
283
    if (field.multiple) {
448✔
284
      const castedFieldValue = field.value as string[] | undefined
467✔
285

286
      if (!castedFieldValue) return []
467✔
287

1,486✔
288
      return castedFieldValue
289
    }
1,486✔
290
    const castedFieldValue = field.value as string | undefined
291
    return castedFieldValue ?? ''
292
  }
467✔
293

294
  public static replaceSlashKeysWithDot(obj: DatasetMetadataFormValues): DatasetMetadataFormValues {
295
    const formattedNewObject: DatasetMetadataFormValues = {}
296

297
    for (const key in obj) {
298
      const blockKey = this.replaceSlashWithDot(key)
74✔
299
      const metadataBlockFormValues = obj[key]
300

301
      formattedNewObject[blockKey] = {}
302

73✔
303
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
304
        const newFieldName = this.replaceSlashWithDot(fieldName)
73✔
305

118✔
306
        if (
307
          this.isPrimitiveFieldValue(fieldValue) ||
308
          this.isVocabularyMultipleFieldValue(fieldValue) ||
309
          this.isPrimitiveMultipleFieldValue(fieldValue)
118✔
310
        ) {
311
          formattedNewObject[blockKey][newFieldName] = fieldValue
118✔
312
          return
1,244✔
313
        }
464✔
314

157✔
315
        if (this.isComposedSingleFieldValue(fieldValue)) {
157✔
316
          formattedNewObject[blockKey][newFieldName] = {}
317
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
307✔
318
            const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
319
            const parentOfNestedField = formattedNewObject[blockKey][
780✔
320
              newFieldName
90✔
321
            ] as ComposedSingleFieldValue
58✔
322

58✔
323
            parentOfNestedField[newNestedFieldName] = nestedFieldValue
324
          })
32✔
325
          return
326
        }
327

690✔
328
        if (this.isComposedMultipleFieldValue(fieldValue)) {
185✔
329
          formattedNewObject[blockKey][newFieldName] = fieldValue.map((composedFieldValues) => {
237✔
330
            const composedField: ComposedSingleFieldValue = {}
237✔
331

332
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
185✔
333
              const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
52✔
334

52✔
335
              composedField[newNestedFieldName] = nestedFieldValue
336
            })
133✔
337

338
            return composedField
339
          })
505✔
340
        }
58✔
341
      })
342
    }
58✔
343

219✔
344
    return formattedNewObject
103✔
345
  }
346

347
  public static formatFormValuesToDatasetDTO(
58✔
348
    formValues: DatasetMetadataFormValues,
58✔
349
    mode: 'create' | 'edit'
58✔
350
  ): DatasetDTO {
UNCOV
351
    const metadataBlocks: DatasetDTO['metadataBlocks'] = []
×
352

353
    for (const metadataBlockName in formValues) {
354
      const formattedMetadataBlock: DatasetMetadataBlockValuesDTO = {
447✔
355
        name: metadataBlockName,
447✔
356
        fields: {}
357
      }
447✔
358
      const metadataBlockFormValues = formValues[metadataBlockName]
466✔
359

466✔
360
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
1,482✔
361
        if (this.isPrimitiveFieldValue(fieldValue)) {
326✔
362
          if (fieldValue !== '' || mode === 'edit') {
363
            formattedMetadataBlock.fields[fieldName] = fieldValue
364
            return
466✔
365
          }
167✔
366
          return
367
        }
368
        if (this.isVocabularyMultipleFieldValue(fieldValue)) {
447✔
369
          if (fieldValue.length > 0 || mode === 'edit') {
148✔
370
            formattedMetadataBlock.fields[fieldName] = fieldValue
371
            return
372
          }
447✔
373
          return
374
        }
375

376
        if (this.isPrimitiveMultipleFieldValue(fieldValue)) {
118✔
377
          const primitiveMultipleFieldValues = fieldValue
378
            .map((primitiveField) => primitiveField.value)
73✔
379
            .filter((v) => v !== '')
380

381
          if (primitiveMultipleFieldValues.length > 0 || mode === 'edit') {
382
            formattedMetadataBlock.fields[fieldName] = primitiveMultipleFieldValues
383
            return
384
          }
385
          return
386
        }
168✔
387

388
        if (this.isComposedSingleFieldValue(fieldValue)) {
389
          const formattedMetadataChildFieldValue: DatasetMetadataChildFieldValueDTO = {}
390

391
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
168✔
392
            if (nestedFieldValue !== '' || mode === 'edit') {
319✔
393
              formattedMetadataChildFieldValue[nestedFieldName] = nestedFieldValue
319✔
394
            }
395
          })
396
          if (Object.keys(formattedMetadataChildFieldValue).length > 0) {
168✔
397
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValue
315✔
398
            return
399
          }
315✔
400
          return
313✔
401
        }
5,719✔
402

403
        if (this.isComposedMultipleFieldValue(fieldValue)) {
5,719✔
404
          const formattedMetadataChildFieldValues: DatasetMetadataChildFieldValueDTO[] = []
1,276✔
405

406
          fieldValue.forEach((composedFieldValues) => {
407
            const composedField: DatasetMetadataChildFieldValueDTO = {}
408
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
409
              if (nestedFieldValue !== '' || mode === 'edit') {
410
                composedField[nestedFieldName] = nestedFieldValue
168✔
411
              }
412
            })
413
            if (Object.keys(composedField).length > 0 || mode === 'edit') {
32,527✔
414
              formattedMetadataChildFieldValues.push(composedField)
3,086✔
415
            }
416
          })
417
          if (formattedMetadataChildFieldValues.length > 0 || mode === 'edit') {
418
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValues
419
          }
420

421
          return
422
        }
423
      })
424

425
      metadataBlocks.push(formattedMetadataBlock)
426
    }
427
    return { licence: defaultLicense, metadataBlocks }
428
  }
429

39,402✔
430
  public static buildTemplateFieldsFromMetadataValues(
6,283✔
431
    fieldValues: DatasetMetadataFieldsDTO,
432
    metadataFields: Record<string, MetadataField>
33,119✔
433
  ): TemplateFieldInfo[] {
26,072✔
434
    const templateFields: TemplateFieldInfo[] = []
435

436
    Object.entries(fieldValues).forEach(([fieldName, fieldValue]) => {
7,047✔
437
      const fieldInfo = metadataFields[fieldName]
2,436✔
438
      if (!fieldInfo) return
439

4,611✔
440
      if (fieldInfo.typeClass === 'primitive' || fieldInfo.typeClass === 'controlledVocabulary') {
441
        if (fieldValue === '' || fieldValue === undefined || fieldValue === null) return
442

174✔
443
        const valuePayload =
2,503✔
444
          fieldInfo.multiple && Array.isArray(fieldValue) ? fieldValue : (fieldValue as string)
445

174✔
446
        templateFields.push({
447
          typeName: fieldInfo.name,
448
          multiple: fieldInfo.multiple,
1,386✔
449
          typeClass: fieldInfo.typeClass,
450
          value: valuePayload as unknown as TemplateFieldInfo['value']
174✔
451
        })
452

453
        return
1,568✔
454
      }
455

174✔
456
      if (fieldInfo.typeClass === 'compound') {
457
        const compoundValues = this.buildTemplateCompoundValues(fieldInfo, fieldValue)
458
        if (compoundValues.length === 0) return
1,012✔
459

460
        const valuePayload = fieldInfo.multiple ? compoundValues : compoundValues[0]
174✔
461

462
        templateFields.push({
463
          typeName: fieldInfo.name,
933✔
464
          multiple: fieldInfo.multiple,
465
          typeClass: fieldInfo.typeClass,
466
          value: valuePayload as unknown as TemplateFieldInfo['value']
467
        })
468
      }
469
    })
470

471
    return templateFields
472
  }
473

474
  private static buildTemplateCompoundValues(
475
    fieldInfo: MetadataField,
476
    fieldValue: DatasetMetadataFieldValueDTO
477
  ): TemplateFieldValuePayload[] {
478
    if (fieldInfo.typeClass !== 'compound') {
479
      return []
480
    }
481
    const valueArray = Array.isArray(fieldValue) ? fieldValue : [fieldValue]
482
    const compoundValues: TemplateFieldValuePayload[] = []
483

484
    valueArray.forEach((compoundValue) => {
485
      if (!compoundValue || typeof compoundValue !== 'object' || Array.isArray(compoundValue)) {
486
        return
487
      }
488
      const entry: Record<string, TemplateFieldCompoundChildValue> = {}
489

490
      Object.entries(compoundValue).forEach(([childName, childValue]) => {
491
        const childInfo = fieldInfo.childMetadataFields?.[childName]
492
        if (!childInfo) return
493
        if (childValue === '' || childValue === undefined || childValue === null) return
494

495
        entry[childInfo.name] = {
496
          value: childValue as string | string[],
497
          typeName: childInfo.name,
498
          multiple: childInfo.multiple,
499
          typeClass: childInfo.typeClass
500
        }
501
      })
502
      if (Object.keys(entry).length > 0) {
503
        compoundValues.push(entry)
504
      }
505
    })
506

507
    return compoundValues
508
  }
509

510
  public static addFieldValuesToMetadataBlocksInfo(
511
    normalizedMetadataBlocksInfo: MetadataBlockInfo[],
512
    normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlock[]
513
  ): MetadataBlockInfoWithMaybeValues[] {
514
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
515
    const normalizedMetadataBlocksInfoCopy: MetadataBlockInfoWithMaybeValues[] = structuredClone(
516
      normalizedMetadataBlocksInfo
517
    )
518

519
    const normalizedCurrentValuesMap: Record<string, DatasetMetadataFields> =
520
      normalizedDatasetMetadaBlocksCurrentValues.reduce((map, block) => {
521
        map[block.name] = block.fields
522
        return map
523
      }, {} as Record<string, DatasetMetadataFields>)
524

525
    normalizedMetadataBlocksInfoCopy.forEach((block) => {
526
      const currentBlockValues = normalizedCurrentValuesMap[block.name]
527

528
      if (currentBlockValues) {
529
        Object.keys(block.metadataFields).forEach((fieldName) => {
530
          const field = block.metadataFields[fieldName]
531

532
          if (this.replaceDotWithSlash(fieldName) in currentBlockValues) {
533
            field.value = currentBlockValues[this.replaceDotWithSlash(fieldName)]
534
          }
535
        })
536
      }
537
    })
538

539
    return normalizedMetadataBlocksInfoCopy
540
  }
541

542
  private static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/')
543
  public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.')
544

545
  /*
546
   * To define the field name that will be used to register the field in the form
547
   * Most basic could be: metadataBlockName.name eg: citation.title
548
   * If the field is part of a compound field, the name will be: metadataBlockName.compoundParentName.name eg: citation.author.authorName
549
   * If the field is part of an array of fields, the name will be: metadataBlockName.fieldsArrayIndex.name.value eg: citation.alternativeTitle.0.value
550
   * 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
551
   */
552
  public static defineFieldName(
553
    name: string,
554
    metadataBlockName: string,
555
    compoundParentName?: string,
556
    fieldsArrayIndex?: number
557
  ) {
558
    if (fieldsArrayIndex !== undefined && !compoundParentName) {
559
      return `${metadataBlockName}.${name}.${fieldsArrayIndex}.value`
560
    }
561
    if (fieldsArrayIndex !== undefined && compoundParentName) {
562
      return `${metadataBlockName}.${compoundParentName}.${fieldsArrayIndex}.${name}`
563
    }
564

565
    if (compoundParentName) {
566
      return `${metadataBlockName}.${compoundParentName}.${name}`
567
    }
568
    return `${metadataBlockName}.${name}`
569
  }
570

571
  private static isPrimitiveFieldValue = (value: unknown): value is string => {
572
    return typeof value === 'string'
573
  }
574
  private static isPrimitiveMultipleFieldValue = (
575
    value: unknown
576
  ): value is PrimitiveMultipleFormValue => {
577
    return Array.isArray(value) && value.every((v) => typeof v === 'object' && 'value' in v)
578
  }
579
  private static isVocabularyMultipleFieldValue = (
580
    value: unknown
581
  ): value is VocabularyMultipleFormValue => {
582
    return Array.isArray(value) && value.every((v) => typeof v === 'string')
583
  }
584
  private static isComposedSingleFieldValue = (
585
    value: unknown
586
  ): value is ComposedSingleFieldValue => {
587
    return typeof value === 'object' && !Array.isArray(value)
588
  }
589
  private static isComposedMultipleFieldValue = (
590
    value: unknown
591
  ): value is ComposedSingleFieldValue[] => {
592
    return Array.isArray(value) && value.every((v) => typeof v === 'object')
593
  }
594

595
  /**
596
   * To define the metadata blocks info that will be used to render the form.
597
   * In create mode, if a template is provided, it adds the fields and values from the template to the metadata blocks info.
598
   * In edit mode, it adds the current dataset values to the metadata blocks info.
599
   * Normalizes field names by replacing dots with slashes to avoid issues with react-hook-form. (e.g. coverage.Spectral.MinimumWavelength -> coverage/Spectral/MinimumWavelength)
600
   * Finally, it orders the fields by display order.
601
   */
602
  public static defineMetadataBlockInfo(
603
    mode: 'create' | 'edit',
604
    metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[],
605
    metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[],
606
    datasetMetadaBlocksCurrentValues: DatasetMetadataBlocks | undefined,
607
    templateMetadataBlocks: DatasetMetadataBlock[] | undefined
608
  ): MetadataBlockInfo[] {
609
    // Replace field names with dots to slashes, to avoid issues with the form library react-hook-form
610
    const normalizedMetadataBlocksInfoForDisplayOnCreate =
611
      this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnCreate)
612

613
    const normalizedMetadataBlocksInfoForDisplayOnEdit =
614
      this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnEdit)
615

616
    // CREATE MODE
617
    if (mode === 'create') {
618
      // If we have no template, we just return the metadata blocks info for create with normalized field names
619
      if (!templateMetadataBlocks) {
620
        return normalizedMetadataBlocksInfoForDisplayOnCreate
621
      }
622

623
      // 1) Normalize dataset template fields
624
      const normalizedDatasetTemplateMetadataBlocksValues =
625
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(templateMetadataBlocks)
626

627
      // 2) Add missing fields from the template to the metadata blocks info for create
628
      const metadataBlocksInfoWithAddedFieldsFromTemplate =
629
        this.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
630
          normalizedMetadataBlocksInfoForDisplayOnCreate,
631
          normalizedMetadataBlocksInfoForDisplayOnEdit,
632
          normalizedDatasetTemplateMetadataBlocksValues
633
        )
634

635
      // 3) Add the values from the template to the metadata blocks info for create
636
      const metadataBlocksInfoWithValuesFromTemplate = this.addFieldValuesToMetadataBlocksInfo(
637
        metadataBlocksInfoWithAddedFieldsFromTemplate,
638
        normalizedDatasetTemplateMetadataBlocksValues
639
      )
640

641
      // 4) Order fields by display order
642
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
643
        metadataBlocksInfoWithValuesFromTemplate
644
      )
645

646
      return metadataBlocksInfoOrdered
647
    } else {
648
      // EDIT MODE
649
      const datasetCurrentValues = datasetMetadaBlocksCurrentValues as DatasetMetadataBlocks // In edit mode we always have current values
650

651
      // 1) Normalize dataset current values
652
      const normalizedDatasetMetadaBlocksCurrentValues =
653
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(datasetCurrentValues)
654

655
      // 2) Add current values to the metadata blocks info for edit
656
      const metadataBlocksInfoWithCurrentValues = this.addFieldValuesToMetadataBlocksInfo(
657
        normalizedMetadataBlocksInfoForDisplayOnEdit,
658
        normalizedDatasetMetadaBlocksCurrentValues
659
      )
660

661
      // 3) Order fields by display order
662
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
663
        metadataBlocksInfoWithCurrentValues
664
      )
665

666
      return metadataBlocksInfoOrdered
667
    }
668
  }
669

670
  public static addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
671
    metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[],
672
    metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[],
673
    templateBlocks: DatasetMetadataBlock[] | undefined
674
  ): MetadataBlockInfo[] {
675
    if (!templateBlocks || templateBlocks.length === 0) {
676
      return metadataBlocksInfoForDisplayOnCreate
677
    }
678

679
    const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate)
680

681
    const createMap = createCopy.reduce<Record<string, MetadataBlockInfo>>((acc, block) => {
682
      acc[block.name] = block
683
      return acc
684
    }, {})
685

686
    const editMap = metadataBlocksInfoForDisplayOnEdit.reduce<Record<string, MetadataBlockInfo>>(
687
      (acc, block) => {
688
        acc[block.name] = block
689
        return acc
690
      },
691
      {}
692
    )
693

694
    for (const tBlock of templateBlocks) {
695
      const blockName = tBlock.name
696
      const editBlock = editMap[blockName]
697

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

701
      if (!templateBlockHasFields) continue
702

703
      if (!editBlock) {
704
        // We don't know how this block looks in "edit", we can't copy its shape. So we skip it.
705
        continue
706
      }
707

708
      // We ensure the block exists in the "create" array
709
      let createBlock = createMap[blockName]
710

711
      if (!createBlock) {
712
        createBlock = {
713
          id: editBlock.id,
714
          name: editBlock.name,
715
          displayName: editBlock.displayName,
716
          metadataFields: {},
717
          displayOnCreate: editBlock.displayOnCreate
718
        }
719
        createMap[blockName] = createBlock
720
        createCopy.push(createBlock)
721
      }
722

723
      const createFields = createBlock.metadataFields
724
      const editFields = editBlock.metadataFields
725

726
      // For each field that the template brings with value, if it doesn't exist in "create", we copy it from "edit"
727
      const templateBlockFields = tBlock.fields ?? {}
728
      for (const fieldName of Object.keys(templateBlockFields)) {
729
        if (createFields[fieldName]) continue
730

731
        const fieldFromEdit = editFields[fieldName]
732
        if (!fieldFromEdit) {
733
          // The field doesn't exist in "edit" either: there's no way to know its shape; we skip it
734
          continue
735
        }
736

737
        const clonedField = structuredClone(fieldFromEdit)
738

739
        createFields[fieldName] = clonedField
740
      }
741
    }
742

743
    return createCopy
744
  }
745

746
  private static orderFieldsByDisplayOrder(
747
    metadataBlocksInfo: MetadataBlockInfo[]
748
  ): MetadataBlockInfo[] {
749
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
750
    const metadataBlocksInfoCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfo)
751

752
    for (const block of metadataBlocksInfoCopy) {
753
      if (block.metadataFields) {
754
        const fieldsArray = Object.values(block.metadataFields)
755
        fieldsArray.sort((a, b) => a.displayOrder - b.displayOrder)
756

757
        const orderedFields: Record<string, MetadataField> = {}
758
        for (const field of fieldsArray) {
759
          orderedFields[field.name] = field
760
        }
761
        block.metadataFields = orderedFields
762
      }
763
    }
764
    return metadataBlocksInfoCopy
765
  }
766

767
  /**
768
   * Extracts the invalid value from the error message, removing 'Validation Failed:' and everything after '(Invalid value:'
769
   * @param errorMessage
770
   * @returns the invalid value or null if it can't be extracted
771
   * @example
772
   * 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")
773
   * // returns "Point of Contact E-mail test@test.c is not a valid email address."
774
   */
775

776
  public static getValidationFailedFieldError(errorMessage: string): string | null {
777
    const validationFailedKeyword = 'Validation Failed:'
778
    const invalidValueKeyword = '(Invalid value:'
779

780
    const validationFailedKeywordIndex = errorMessage.indexOf(validationFailedKeyword)
781
    const invalidValueKeywordIndex = errorMessage.indexOf(invalidValueKeyword)
782

783
    if (validationFailedKeywordIndex !== -1 && invalidValueKeywordIndex !== -1) {
784
      const start = validationFailedKeywordIndex + validationFailedKeyword.length
785
      const end = invalidValueKeywordIndex
786
      const extractedValue = errorMessage.slice(start, end).trim()
787

788
      return extractedValue
789
    }
790

791
    return null
792
  }
793

794
  /**
795
   * This is for validating date type fields in the edit/create dataset form.
796
   * It replicates as much as possible the validation in the Dataverse backend
797
   * https://github.com/IQSS/dataverse/blob/42a2904a83fa3ed990c13813b9bc2bec166bfd4b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldValueValidator.java#L99
798
   */
799

800
  public static isValidDateFormat(input: string): DateValidation {
801
    if (!input) return this.err('E_EMPTY')
802

803
    const s = input.trim()
804

805
    // 1) yyyy-MM-dd, yyyy-MM, yyyy
806
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return this.ok('YMD')
807
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM')) return this.ok('YM')
808
    if (this.isValidDateAgainstPattern(s, 'yyyy')) return this.ok('Y')
809

810
    // 2) Bracketed: starts "[" ends "?]" and not "[-"
811
    if (s.startsWith('[') && s.endsWith('?]')) {
812
      if (s.startsWith('[-')) return this.err('E_BRACKET_NEGATIVE')
813

814
      const core = s
815
        .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ')
816
        .trim()
817
        .replace(/\s+/g, ' ')
818

819
      if (s.includes('BC')) {
820
        // BC: must be purely numeric
821
        if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM')
822
        return this.ok('BRACKET')
823
      } else {
824
        // AD/unspecified: numeric, 1–4 digits, 0..9999
825
        if (!/^\d+$/.test(core)) return this.err('E_BRACKET_NOT_NUM')
826

827
        if (!this.isValidDateAgainstPattern(core, 'yyyy')) return this.err('E_BRACKET_RANGE')
828
        return this.ok('BRACKET')
829
      }
830
    }
831

832
    /// 3) AD: strip AD (with or without space)
833
    if (s.includes('AD')) {
834
      const before = s.substring(0, s.indexOf('AD')).trim()
835

836
      if (!/^\d+$/.test(before)) return this.err('E_AD_DIGITS')
837

838
      if (!this.isValidDateAgainstPattern(before, 'yyyy')) return this.err('E_AD_RANGE')
839

840
      return this.ok('AD')
841
    }
842

843
    // 4) BC: strip BC, numeric only (no explicit max in JSF)
844
    if (s.includes('BC')) {
845
      const before = s.substring(0, s.indexOf('BC')).trim()
846
      if (!/^\d+$/.test(before)) return this.err('E_BC_NOT_NUM')
847
      return this.ok('BC')
848
    }
849

850
    // 5) Timestamp fallbacks (temporary)
851
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP')
852
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP')
853
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP')
854

855
    // ---------- Targeted error mapping (ORDER MATTERS) ----------
856

857
    // Numeric but longer than 4 → AD range-style error
858
    if (/^\d+$/.test(s) && s.length > 4) return this.err('E_AD_RANGE')
859

860
    // Distinguish month vs day precisely
861
    const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
862
    if (ymd) {
863
      const [, yStr, mStr, dStr] = ymd
864
      const year = Number(yStr)
865
      const month = Number(mStr)
866
      const day = Number(dStr)
867

868
      if (month < 1 || month > 12) return this.err('E_INVALID_MONTH')
869
      const dim = this.daysInMonth(year, month)
870
      if (day < 1 || day > dim) return this.err('E_INVALID_DAY')
871
      // If we get here, something else was invalid earlier, fall through.
872
    }
873

874
    // Pure year-month (yyyy-MM) → invalid month
875
    if (/^\d{4}-(\d{2})$/.test(s)) return this.err('E_INVALID_MONTH')
876

877
    // Looks like datetime → invalid time
878
    if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s))
879
      return this.err('E_INVALID_TIME')
880

881
    return this.err('E_UNRECOGNIZED')
882
  }
883

884
  public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean {
885
    if (!dateString) return false
886
    const s = dateString.trim()
887
    if (s.length > pattern.length) return false
888

889
    switch (pattern) {
890
      case 'yyyy': {
891
        if (!/^\d{1,4}$/.test(s)) return false
892
        const year = Number(s)
893
        return year >= 0 && year <= 9999 // AD cap
894
      }
895
      case 'yyyy-MM': {
896
        const m = /^(\d{1,4})-(\d{1,2})$/.exec(s)
897
        if (!m) return false
898
        const month = Number(m[2])
899
        return month >= 1 && month <= 12
900
      }
901
      case 'yyyy-MM-dd': {
902
        const m = /^(\d{1,4})-(\d{1,2})-(\d{1,2})$/.exec(s)
903
        if (!m) return false
904
        const year = Number(m[1])
905
        const month = Number(m[2])
906
        const day = Number(m[3])
907

908
        if (!(month >= 1 && month <= 12)) return false
909
        const dim = this.daysInMonth(year, month)
910
        return day >= 1 && day <= dim
911
      }
912
      case "yyyy-MM-dd'T'HH:mm:ss": {
913
        const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(s)
914
        if (!m) return false
915
        const [, y, mo, d, hh, mm, ss] = m
916
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
917
        return this.isValidHMS(hh, mm, ss)
918
      }
919
      case "yyyy-MM-dd'T'HH:mm:ss.SSS": {
920
        const m = /^(\d{1,4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s)
921
        if (!m) return false
922
        const [, y, mo, d, hh, mm, ss, ms] = m
923
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
924
        return this.isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms)
925
      }
926
      case 'yyyy-MM-dd HH:mm:ss': {
927
        const m = /^(\d{1,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s)
928
        if (!m) return false
929
        const [, y, mo, d, hh, mm, ss] = m
930
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
931
        return this.isValidHMS(hh, mm, ss)
932
      }
933
      default:
934
        return false
935
    }
936
  }
937

938
  public static isValidHMS(hh: string, mm: string, ss: string): boolean {
939
    const H = Number(hh),
940
      M = Number(mm),
941
      S = Number(ss)
942
    return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59
943
  }
944

945
  // prettier-ignore
946
  public static daysInMonth(y: number, m: number): number {
947
    switch (m) {
948
      case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31
949
      case 4: case 6: case 9: case 11: return 30
950
      case 2: return this.isLeapYear(y) ? 29 : 28
951
      default: return 0
952
    }
953
  }
954

955
  public static isLeapYear(y: number): boolean {
956
    return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0
957
  }
958

959
  private static ok(kind: DateLikeKind): DateValidation {
960
    return { valid: true, kind }
961
  }
962
  private static err(code: DateErrorCode): DateValidation {
963
    return { valid: false, errorCode: code }
964
  }
965
}
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