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

IQSS / dataverse-frontend / 21533264622

30 Jan 2026 10:44PM UTC coverage: 97.406% (-0.4%) from 97.779%
21533264622

Pull #908

github

ChengShi-1
fix: changes on TemplateInfo based on copilot review
Pull Request #908: Manage Dataset Templates Integration

4334 of 4531 branches covered (95.65%)

Branch coverage included in aggregate %.

359 of 381 new or added lines in 26 files covered. (94.23%)

11 existing lines in 3 files now uncovered.

8432 of 8575 relevant lines covered (98.33%)

10596.26 hits per line

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

97.4
/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 {
22
  TemplateFieldCompoundChildValue,
23
  TemplateFieldCompoundValue,
24
  TemplateFieldInfo,
25
  TemplateFieldValue
26
} from '../../../../templates/domain/models/TemplateInfo'
27

28
export type DatasetMetadataFormValues = Record<string, MetadataBlockFormValues>
29

30
export type MetadataBlockFormValues = Record<
31
  string,
32
  string | PrimitiveMultipleFormValue | VocabularyMultipleFormValue | ComposedFieldValues
33
>
34

35
type VocabularyMultipleFormValue = string[]
36

37
type PrimitiveMultipleFormValue = { value: string }[]
38

39
type ComposedFieldValues = ComposedSingleFieldValue | ComposedSingleFieldValue[]
1,761✔
40

41
export type ComposedSingleFieldValue = Record<string, string>
1,761✔
42

8,277✔
43
export type DateLikeKind = 'Y' | 'YM' | 'YMD' | 'AD' | 'BC' | 'BRACKET' | 'TIMESTAMP'
8,277✔
44

45
/** Stable error codes for i18n mapping */
46
export const dateKeyMessageErrorMap = {
2,051✔
47
  E_EMPTY: 'field.invalid.date.empty',
48
  E_AD_DIGITS: 'field.invalid.date.adDigits',
49
  E_AD_RANGE: 'field.invalid.date.adRange',
50
  E_BC_NOT_NUM: 'field.invalid.date.bcNotNumeric',
40,346✔
51
  E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative',
215,872✔
52
  E_BRACKET_NOT_NUM: 'field.invalid.date.bracketNotNumeric',
215,872✔
53
  E_BRACKET_RANGE: 'field.invalid.date.bracketRange',
25,244✔
54
  E_INVALID_MONTH: 'field.invalid.date.invalidMonth',
55
  E_INVALID_DAY: 'field.invalid.date.invalidDay',
215,872✔
56
  E_INVALID_TIME: 'field.invalid.date.invalidTime',
32,069✔
57
  E_UNRECOGNIZED: 'field.invalid.date.unrecognized'
58
} as const
59

60
export type DateErrorCode = keyof typeof dateKeyMessageErrorMap
61

62
type DateValidation =
63
  | { valid: true; kind: DateLikeKind }
64
  | { valid: false; errorCode: DateErrorCode }
65

170✔
66
export class MetadataFieldsHelper {
170✔
67
  public static replaceMetadataBlocksInfoDotNamesKeysWithSlash(
68
    metadataBlocks: MetadataBlockInfo[]
170✔
69
  ): MetadataBlockInfo[] {
70
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
322✔
71
    const metadataBlocksCopy: MetadataBlockInfo[] = structuredClone(metadataBlocks)
4,336✔
72

322✔
73
    for (const block of metadataBlocksCopy) {
4,336✔
74
      if (block.metadataFields) {
11,273✔
75
        this.metadataBlocksInfoDotReplacer(block.metadataFields)
11,273✔
76
      }
77
    }
322✔
78
    return metadataBlocksCopy
4,336✔
79
  }
80

170✔
81
  private static metadataBlocksInfoDotReplacer(metadataFields: Record<string, MetadataField>) {
82
    for (const key in metadataFields) {
60,480✔
83
      const field = metadataFields[key]
312,111✔
84
      const fieldReplacedKey = this.replaceDotWithSlash(key)
312,111✔
85
      if (fieldReplacedKey !== key) {
312,111✔
86
        // Change the key in the object only if it has changed (i.e., it had a dot)
322✔
87
        metadataFields[fieldReplacedKey] = field
28,138✔
88
        delete metadataFields[key]
28,460✔
89
      }
1,790✔
90
      if (field.name.includes('.')) {
312,111✔
91
        field.name = this.replaceDotWithSlash(field.name)
29,928✔
92
      }
93
      if (field.childMetadataFields) {
312,111✔
94
        this.metadataBlocksInfoDotReplacer(field.childMetadataFields)
50,997✔
95
      }
170✔
96
    }
358✔
97
  }
98

99
  public static replaceDatasetMetadataBlocksDotKeysWithSlash(
100
    datasetMetadataBlocks: DatasetMetadataBlock[]
358✔
101
  ): DatasetMetadataBlock[] {
358✔
102
    const dataWithoutKeysWithDots: DatasetMetadataBlock[] = [] as unknown as DatasetMetadataBlock[]
267✔
103

104
    for (const block of datasetMetadataBlocks) {
437✔
105
      const newBlockFields: DatasetMetadataFields =
1,620✔
106
        this.datasetMetadataBlocksCurrentValuesDotReplacer(block.fields)
475✔
107

868✔
108
      const newBlock = {
475✔
109
        name: block.name,
110
        fields: newBlockFields
492✔
111
      }
626✔
112

1,744✔
113
      dataWithoutKeysWithDots.push(newBlock)
475✔
114
    }
115

116
    return dataWithoutKeysWithDots
2,011✔
117
  }
1,744✔
118

119
  private static datasetMetadataBlocksCurrentValuesDotReplacer(
120
    datasetMetadataFields: DatasetMetadataFields
492✔
121
  ): DatasetMetadataFields {
122
    const datasetMetadataFieldsNormalized: DatasetMetadataFields = {}
1,603✔
123

124
    for (const key in datasetMetadataFields) {
475✔
125
      const newKey = key.includes('.') ? this.replaceDotWithSlash(key) : key
2,592✔
126

322✔
127
      const value = datasetMetadataFields[key]
2,592✔
128

129
      // Case of DatasetMetadataSubField
130
      if (typeof value === 'object' && !Array.isArray(value)) {
2,592✔
131
        const nestedKeysMapped = Object.entries(value).reduce((acc, [nestedKey, nestedValue]) => {
242✔
132
          const newNestedKey = nestedKey.includes('.')
1,684✔
133
            ? this.replaceDotWithSlash(nestedKey)
134
            : nestedKey
1,173✔
135

1,191✔
136
          acc[newNestedKey] = nestedValue
511✔
137
          return acc
1,702✔
138
        }, {} as DatasetMetadataSubField)
10,827✔
139

10,827✔
140
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
242✔
141
      } else if (
13,177✔
142
        Array.isArray(value) &&
4,895✔
143
        (value as readonly (string | DatasetMetadataSubField)[]).every((v) => typeof v === 'object')
1,252✔
144
      ) {
4,895✔
145
        // Case of DatasetMetadataSubField[]
4,895✔
146
        const nestedKeysMapped = value.map((subFields) => {
16,762✔
147
          return Object.entries(subFields).reduce((acc, [nestedKey, nestedValue]) => {
15,147✔
148
            const newNestedKey = nestedKey.includes('.')
2,479✔
149
              ? this.replaceDotWithSlash(nestedKey)
150
              : nestedKey
16,057✔
151

1,803✔
152
            acc[newNestedKey] = nestedValue
2,479✔
153
            return acc
2,479✔
154
          }, {} as DatasetMetadataSubField)
155
        })
156
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
5,600✔
157
      } else {
612✔
158
        datasetMetadataFieldsNormalized[newKey] = value
1,645✔
159
      }
160
    }
161

162
    return datasetMetadataFieldsNormalized
475✔
163
  }
612✔
164

451✔
165
  public static getFormDefaultValues(
596✔
166
    metadataBlocks: MetadataBlockInfoWithMaybeValues[]
167
  ): DatasetMetadataFormValues {
168
    const formDefaultValues: DatasetMetadataFormValues = {}
2,682✔
169

1,659✔
170
    for (const block of metadataBlocks) {
1,023✔
171
      const blockValues: MetadataBlockFormValues = {}
3,866✔
172

173
      for (const field of Object.values(block.metadataFields)) {
2,207✔
174
        const fieldName = field.name
23,336✔
175
        const fieldValue = field.value
22,740✔
176

177
        if (field.typeClass === 'compound') {
22,740✔
178
          const childFieldsWithEmptyValues: Record<string, string> = {}
8,860✔
179

180
          if (field.childMetadataFields) {
9,311✔
181
            for (const childField of Object.values(field.childMetadataFields)) {
8,860✔
182
              if (childField.typeClass === 'primitive') {
28,576✔
183
                childFieldsWithEmptyValues[childField.name] = ''
25,448✔
184
              }
185

338✔
186
              if (childField.typeClass === 'controlledVocabulary') {
28,753✔
187
                childFieldsWithEmptyValues[childField.name] = ''
2,967✔
188
              }
338✔
189
            }
190
          }
191

161✔
192
          if (fieldValue) {
8,860✔
193
            const castedFieldValue = fieldValue as
872✔
194
              | DatasetMetadataSubField
195
              | DatasetMetadataSubField[]
196

197
            let fieldValues: ComposedFieldValues
612✔
198

199
            if (Array.isArray(castedFieldValue)) {
5,155✔
200
              const subFieldsWithValuesPlusEmptyOnes = castedFieldValue.map((subFields) => {
643✔
201
                const fieldsValueNormalized: Record<string, string> = Object.entries(
847✔
202
                  subFields
203
                ).reduce((acc, [key, value]) => {
204
                  if (value !== undefined) {
2,350✔
205
                    acc[key] = value
13,177✔
206
                  }
5,194✔
207
                  return acc
2,350✔
208
                }, {} as Record<string, string>)
209

10,827✔
210
                return {
1,585✔
211
                  ...childFieldsWithEmptyValues,
212
                  ...fieldsValueNormalized
213
                }
214
              })
1,191✔
215

216
              fieldValues = subFieldsWithValuesPlusEmptyOnes
643✔
217
            } else {
1,173✔
218
              const fieldsValueNormalized: Record<string, string> = Object.entries(
229✔
219
                castedFieldValue
220
              ).reduce((acc, [key, value]) => {
221
                if (value !== undefined) {
483✔
222
                  acc[key] = value
483✔
223
                }
5,194✔
224
                return acc
1,609✔
225
              }, {} as Record<string, string>)
226

1,126✔
227
              fieldValues = {
229✔
228
                ...childFieldsWithEmptyValues,
128✔
229
                ...fieldsValueNormalized
230
              }
4,068✔
231
            }
4,068✔
232

233
            blockValues[fieldName] = fieldValues
872✔
234
          } else {
235
            blockValues[fieldName] = field.multiple
7,988✔
236
              ? [childFieldsWithEmptyValues]
237
              : childFieldsWithEmptyValues
738✔
238
          }
713✔
239
        }
240

713✔
241
        if (field.typeClass === 'primitive') {
22,740✔
242
          blockValues[fieldName] = this.getPrimitiveFieldDefaultFormValue(field)
12,210✔
243
        }
244

25✔
245
        if (field.typeClass === 'controlledVocabulary') {
22,765✔
246
          blockValues[fieldName] = this.getControlledVocabFieldDefaultFormValue(field)
1,831✔
247
        }
248
      }
249

74✔
250
      formDefaultValues[block.name] = blockValues
2,207✔
251
    }
74✔
252

119✔
253
    return formDefaultValues
1,142✔
254
  }
255

119✔
256
  private static getPrimitiveFieldDefaultFormValue(
257
    field: MetadataFieldWithMaybeValue
119✔
258
  ): string | PrimitiveMultipleFormValue {
1,259✔
259
    if (field.multiple) {
12,049✔
260
      const castedFieldValue = field.value as string[] | undefined
4,634✔
261

2,743✔
262
      if (!castedFieldValue) return [{ value: '' }]
3,375✔
263

264
      return castedFieldValue.map((stringValue) => ({ value: stringValue }))
200✔
265
    }
752✔
266
    const castedFieldValue = field.value as string | undefined
9,426✔
267
    return castedFieldValue ?? ''
8,674✔
268
  }
269

507✔
270
  private static getControlledVocabFieldDefaultFormValue(
59✔
271
    field: MetadataFieldWithMaybeValue
59✔
272
  ): string | VocabularyMultipleFormValue {
222✔
273
    if (field.multiple) {
2,053✔
274
      const castedFieldValue = field.value as string[] | undefined
1,717✔
275

276
      if (!castedFieldValue) return []
1,717✔
277

222✔
278
      return castedFieldValue
238✔
279
    }
59✔
280
    const castedFieldValue = field.value as string | undefined
114✔
281
    return castedFieldValue ?? ''
114✔
282
  }
448✔
283

448✔
284
  public static replaceSlashKeysWithDot(obj: DatasetMetadataFormValues): DatasetMetadataFormValues {
467✔
285
    const formattedNewObject: DatasetMetadataFormValues = {}
133✔
286

467✔
287
    for (const key in obj) {
1,619✔
288
      const blockKey = this.replaceSlashWithDot(key)
258✔
289
      const metadataBlockFormValues = obj[key]
1,744✔
290

291
      formattedNewObject[blockKey] = {}
258✔
292

467✔
293
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
258✔
294
        const newFieldName = this.replaceSlashWithDot(fieldName)
3,094✔
295

296
        if (
3,094✔
297
          this.isPrimitiveFieldValue(fieldValue) ||
6,679✔
298
          this.isVocabularyMultipleFieldValue(fieldValue) ||
74✔
299
          this.isPrimitiveMultipleFieldValue(fieldValue)
300
        ) {
301
          formattedNewObject[blockKey][newFieldName] = fieldValue
1,994✔
302
          return
2,067✔
303
        }
304

73✔
305
        if (this.isComposedSingleFieldValue(fieldValue)) {
1,218✔
306
          formattedNewObject[blockKey][newFieldName] = {}
118✔
307
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
118✔
308
            const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
419✔
309
            const parentOfNestedField = formattedNewObject[blockKey][
537✔
310
              newFieldName
311
            ] as ComposedSingleFieldValue
118✔
312

1,244✔
313
            parentOfNestedField[newNestedFieldName] = nestedFieldValue
883✔
314
          })
157✔
315
          return
275✔
316
        }
317

307✔
318
        if (this.isComposedMultipleFieldValue(fieldValue)) {
982✔
319
          formattedNewObject[blockKey][newFieldName] = fieldValue.map((composedFieldValues) => {
1,762✔
320
            const composedField: ComposedSingleFieldValue = {}
1,106✔
321

58✔
322
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
1,074✔
323
              const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
3,167✔
324

32✔
325
              composedField[newNestedFieldName] = nestedFieldValue
3,167✔
326
            })
327

690✔
328
            return composedField
1,201✔
329
          })
237✔
330
        }
237✔
331
      })
332
    }
185✔
333

52✔
334
    return formattedNewObject
185✔
335
  }
336

133✔
337
  public static formatFormValuesToDatasetDTO(
338
    formValues: DatasetMetadataFormValues,
339
    mode: 'create' | 'edit'
505✔
340
  ): DatasetDTO {
58✔
341
    const metadataBlocks: DatasetDTO['metadataBlocks'] = []
140✔
342

58✔
343
    for (const metadataBlockName in formValues) {
359✔
344
      const formattedMetadataBlock: DatasetMetadataBlockValuesDTO = {
368✔
345
        name: metadataBlockName,
346
        fields: {}
347
      }
58✔
348
      const metadataBlockFormValues = formValues[metadataBlockName]
323✔
349

58✔
350
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
265✔
351
        if (this.isPrimitiveFieldValue(fieldValue)) {
3,199✔
352
          if (fieldValue !== '' || mode === 'edit') {
1,228✔
353
            formattedMetadataBlock.fields[fieldName] = fieldValue
678✔
354
            return
1,125✔
355
          }
447✔
356
          return
550✔
357
        }
447✔
358
        if (this.isVocabularyMultipleFieldValue(fieldValue)) {
2,437✔
359
          if (fieldValue.length > 0 || mode === 'edit') {
725✔
360
            formattedMetadataBlock.fields[fieldName] = fieldValue
1,645✔
361
            return
489✔
362
          }
363
          return
96✔
364
        }
466✔
365

167✔
366
        if (this.isPrimitiveMultipleFieldValue(fieldValue)) {
1,712✔
367
          const primitiveMultipleFieldValues = fieldValue
598✔
368
            .map((primitiveField) => primitiveField.value)
1,165✔
369
            .filter((v) => v !== '')
866✔
370

371
          if (primitiveMultipleFieldValues.length > 0 || mode === 'edit') {
598✔
372
            formattedMetadataBlock.fields[fieldName] = primitiveMultipleFieldValues
805✔
373
            return
358✔
374
          }
375
          return
240✔
376
        }
118✔
377

378
        if (this.isComposedSingleFieldValue(fieldValue)) {
1,187✔
379
          const formattedMetadataChildFieldValue: DatasetMetadataChildFieldValueDTO = {}
125✔
380

381
          Object.entries(fieldValue).forEach(([nestedFieldName, nestedFieldValue]) => {
125✔
382
            if (nestedFieldValue !== '' || mode === 'edit') {
440✔
383
              formattedMetadataChildFieldValue[nestedFieldName] = nestedFieldValue
267✔
384
            }
385
          })
386
          if (Object.keys(formattedMetadataChildFieldValue).length > 0) {
293✔
387
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValue
105✔
388
            return
105✔
389
          }
390
          return
20✔
391
        }
168✔
392

319✔
393
        if (this.isComposedMultipleFieldValue(fieldValue)) {
1,308✔
394
          const formattedMetadataChildFieldValues: DatasetMetadataChildFieldValueDTO[] = []
989✔
395

396
          fieldValue.forEach((composedFieldValues) => {
1,157✔
397
            const composedField: DatasetMetadataChildFieldValueDTO = {}
1,338✔
398
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
1,023✔
399
              if (nestedFieldValue !== '' || mode === 'edit') {
3,510✔
400
                composedField[nestedFieldName] = nestedFieldValue
2,326✔
401
              }
5,719✔
402
            })
403
            if (Object.keys(composedField).length > 0 || mode === 'edit') {
6,742✔
404
              formattedMetadataChildFieldValues.push(composedField)
2,019✔
405
            }
406
          })
407
          if (formattedMetadataChildFieldValues.length > 0 || mode === 'edit') {
989✔
408
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValues
709✔
409
          }
410

168✔
411
          return
989✔
412
        }
413
      })
32,527✔
414

3,086✔
415
      metadataBlocks.push(formattedMetadataBlock)
265✔
416
    }
417
    return { licence: defaultLicense, metadataBlocks }
140✔
418
  }
419

420
  public static buildTemplateFieldsFromMetadataValues(
421
    fieldValues: DatasetMetadataFieldsDTO,
422
    metadataFields: Record<string, MetadataField>
423
  ): TemplateFieldInfo[] {
424
    const templateFields: TemplateFieldInfo[] = []
78✔
425

426
    Object.entries(fieldValues).forEach(([fieldName, fieldValue]) => {
78✔
427
      const fieldInfo = metadataFields[fieldName]
50✔
428
      if (!fieldInfo) return
50!
429

39,402✔
430
      if (fieldInfo.typeClass === 'primitive' || fieldInfo.typeClass === 'controlledVocabulary') {
6,333✔
431
        if (fieldValue === '' || fieldValue === undefined || fieldValue === null) return
34✔
432

33,119✔
433
        if (Array.isArray(fieldValue)) {
26,102✔
434
          if (!fieldValue.every((item) => typeof item === 'string')) return
8!
435

436
          templateFields.push({
7,055✔
437
            typeName: fieldInfo.name,
2,436✔
438
            multiple: fieldInfo.multiple,
439
            typeClass: fieldInfo.typeClass,
4,611✔
440
            value: fieldValue as TemplateFieldValue
441
          })
442
          return
182✔
443
        }
2,503✔
444

445
        if (typeof fieldValue === 'string') {
196✔
446
          templateFields.push({
22✔
447
            typeName: fieldInfo.name,
448
            multiple: fieldInfo.multiple,
1,386✔
449
            typeClass: fieldInfo.typeClass,
450
            value: fieldValue
174✔
451
          })
452
        }
453

1,568✔
454
        return
22✔
455
      }
174✔
456

457
      if (fieldInfo.typeClass === 'compound') {
16✔
458
        const compoundValues = this.buildTemplateCompoundValues(fieldInfo, fieldValue)
1,028✔
459
        if (compoundValues.length === 0) return
16!
460

174✔
461
        const valuePayload: TemplateFieldValue = fieldInfo.multiple
16!
462
          ? compoundValues
463
          : compoundValues[0]
933✔
464

465
        templateFields.push({
16✔
466
          typeName: fieldInfo.name,
467
          multiple: fieldInfo.multiple,
468
          typeClass: fieldInfo.typeClass,
469
          value: valuePayload
470
        })
471
      }
472
    })
473

474
    return templateFields
78✔
475
  }
476

477
  private static buildTemplateCompoundValues(
478
    fieldInfo: MetadataField,
479
    fieldValue: DatasetMetadataFieldValueDTO
480
  ): TemplateFieldCompoundValue[] {
481
    if (fieldInfo.typeClass !== 'compound') {
16!
NEW
482
      return []
×
483
    }
484
    const valueArray = Array.isArray(fieldValue) ? fieldValue : [fieldValue]
16!
485
    const compoundValues: TemplateFieldCompoundValue[] = []
16✔
486

487
    valueArray.forEach((compoundValue) => {
16✔
488
      if (!compoundValue || typeof compoundValue !== 'object' || Array.isArray(compoundValue)) {
20!
NEW
489
        return
×
490
      }
491
      const entry: Record<string, TemplateFieldCompoundChildValue> = {}
20✔
492

493
      Object.entries(compoundValue).forEach(([childName, childValue]) => {
20✔
494
        const childInfo = fieldInfo.childMetadataFields?.[childName]
28✔
495
        if (!childInfo) return
28!
496
        if (childValue === '' || childValue === undefined || childValue === null) return
28✔
497

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

510
    return compoundValues
16✔
511
  }
512

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

522
    const normalizedCurrentValuesMap: Record<string, DatasetMetadataFields> =
523
      normalizedDatasetMetadaBlocksCurrentValues.reduce((map, block) => {
266✔
524
        map[block.name] = block.fields
474✔
525
        return map
474✔
526
      }, {} as Record<string, DatasetMetadataFields>)
527

528
    normalizedMetadataBlocksInfoCopy.forEach((block) => {
266✔
529
      const currentBlockValues = normalizedCurrentValuesMap[block.name]
502✔
530

531
      if (currentBlockValues) {
502✔
532
        Object.keys(block.metadataFields).forEach((fieldName) => {
463✔
533
          const field = block.metadataFields[fieldName]
8,124✔
534

535
          if (this.replaceDotWithSlash(fieldName) in currentBlockValues) {
8,124✔
536
            field.value = currentBlockValues[this.replaceDotWithSlash(fieldName)]
1,866✔
537
          }
538
        })
539
      }
540
    })
541

542
    return normalizedMetadataBlocksInfoCopy
266✔
543
  }
544

545
  private static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/')
350,679✔
546
  public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.')
96,308✔
547

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

568
    if (compoundParentName) {
13,450✔
569
      return `${metadataBlockName}.${compoundParentName}.${name}`
4,164✔
570
    }
571
    return `${metadataBlockName}.${name}`
9,286✔
572
  }
573

574
  private static isPrimitiveFieldValue = (value: unknown): value is string => {
290✔
575
    return typeof value === 'string'
6,293✔
576
  }
577
  private static isPrimitiveMultipleFieldValue = (
290✔
578
    value: unknown
579
  ): value is PrimitiveMultipleFormValue => {
580
    return Array.isArray(value) && value.every((v) => typeof v === 'object' && 'value' in v)
3,382✔
581
  }
582
  private static isVocabularyMultipleFieldValue = (
290✔
583
    value: unknown
584
  ): value is VocabularyMultipleFormValue => {
585
    return Array.isArray(value) && value.every((v) => typeof v === 'string')
3,886✔
586
  }
587
  private static isComposedSingleFieldValue = (
290✔
588
    value: unknown
589
  ): value is ComposedSingleFieldValue => {
590
    return typeof value === 'object' && !Array.isArray(value)
2,214✔
591
  }
592
  private static isComposedMultipleFieldValue = (
290✔
593
    value: unknown
594
  ): value is ComposedSingleFieldValue[] => {
595
    return Array.isArray(value) && value.every((v) => typeof v === 'object')
2,039✔
596
  }
597

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

616
    const normalizedMetadataBlocksInfoForDisplayOnEdit =
617
      this.replaceMetadataBlocksInfoDotNamesKeysWithSlash(metadataBlocksInfoForDisplayOnEdit)
828✔
618

619
    // CREATE MODE
620
    if (mode === 'create') {
828✔
621
      // If we have no template, we just return the metadata blocks info for create with normalized field names
622
      if (!templateMetadataBlocks) {
613✔
623
        return normalizedMetadataBlocksInfoForDisplayOnCreate
588✔
624
      }
625

626
      // 1) Normalize dataset template fields
627
      const normalizedDatasetTemplateMetadataBlocksValues =
628
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(templateMetadataBlocks)
25✔
629

630
      // 2) Add missing fields from the template to the metadata blocks info for create
631
      const metadataBlocksInfoWithAddedFieldsFromTemplate =
632
        this.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
25✔
633
          normalizedMetadataBlocksInfoForDisplayOnCreate,
634
          normalizedMetadataBlocksInfoForDisplayOnEdit,
635
          normalizedDatasetTemplateMetadataBlocksValues
636
        )
637

638
      // 3) Add the values from the template to the metadata blocks info for create
639
      const metadataBlocksInfoWithValuesFromTemplate = this.addFieldValuesToMetadataBlocksInfo(
25✔
640
        metadataBlocksInfoWithAddedFieldsFromTemplate,
641
        normalizedDatasetTemplateMetadataBlocksValues
642
      )
643

644
      // 4) Order fields by display order
645
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
25✔
646
        metadataBlocksInfoWithValuesFromTemplate
647
      )
648

649
      return metadataBlocksInfoOrdered
25✔
650
    } else {
651
      // EDIT MODE
652
      const datasetCurrentValues = datasetMetadaBlocksCurrentValues as DatasetMetadataBlocks // In edit mode we always have current values
215✔
653

654
      // 1) Normalize dataset current values
655
      const normalizedDatasetMetadaBlocksCurrentValues =
656
        this.replaceDatasetMetadataBlocksDotKeysWithSlash(datasetCurrentValues)
215✔
657

658
      // 2) Add current values to the metadata blocks info for edit
659
      const metadataBlocksInfoWithCurrentValues = this.addFieldValuesToMetadataBlocksInfo(
215✔
660
        normalizedMetadataBlocksInfoForDisplayOnEdit,
661
        normalizedDatasetMetadaBlocksCurrentValues
662
      )
663

664
      // 3) Order fields by display order
665
      const metadataBlocksInfoOrdered = this.orderFieldsByDisplayOrder(
215✔
666
        metadataBlocksInfoWithCurrentValues
667
      )
668

669
      return metadataBlocksInfoOrdered
215✔
670
    }
671
  }
672

673
  public static addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
674
    metadataBlocksInfoForDisplayOnCreate: MetadataBlockInfo[],
675
    metadataBlocksInfoForDisplayOnEdit: MetadataBlockInfo[],
676
    templateBlocks: DatasetMetadataBlock[] | undefined
677
  ): MetadataBlockInfo[] {
678
    if (!templateBlocks || templateBlocks.length === 0) {
25✔
679
      return metadataBlocksInfoForDisplayOnCreate
7✔
680
    }
681

682
    const createCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfoForDisplayOnCreate)
18✔
683

684
    const createMap = createCopy.reduce<Record<string, MetadataBlockInfo>>((acc, block) => {
18✔
685
      acc[block.name] = block
32✔
686
      return acc
32✔
687
    }, {})
688

689
    const editMap = metadataBlocksInfoForDisplayOnEdit.reduce<Record<string, MetadataBlockInfo>>(
18✔
690
      (acc, block) => {
691
        acc[block.name] = block
36✔
692
        return acc
36✔
693
      },
694
      {}
695
    )
696

697
    for (const tBlock of templateBlocks) {
18✔
698
      const blockName = tBlock.name
18✔
699
      const editBlock = editMap[blockName]
18✔
700

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

704
      if (!templateBlockHasFields) continue
18!
705

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

711
      // We ensure the block exists in the "create" array
712
      let createBlock = createMap[blockName]
18✔
713

714
      if (!createBlock) {
18✔
715
        createBlock = {
4✔
716
          id: editBlock.id,
717
          name: editBlock.name,
718
          displayName: editBlock.displayName,
719
          metadataFields: {},
720
          displayOnCreate: editBlock.displayOnCreate
721
        }
722
        createMap[blockName] = createBlock
4✔
723
        createCopy.push(createBlock)
4✔
724
      }
725

726
      const createFields = createBlock.metadataFields
18✔
727
      const editFields = editBlock.metadataFields
18✔
728

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

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

740
        const clonedField = structuredClone(fieldFromEdit)
13✔
741

742
        createFields[fieldName] = clonedField
13✔
743
      }
744
    }
745

746
    return createCopy
18✔
747
  }
748

749
  private static orderFieldsByDisplayOrder(
750
    metadataBlocksInfo: MetadataBlockInfo[]
751
  ): MetadataBlockInfo[] {
752
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
753
    const metadataBlocksInfoCopy: MetadataBlockInfo[] = structuredClone(metadataBlocksInfo)
240✔
754

755
    for (const block of metadataBlocksInfoCopy) {
240✔
756
      if (block.metadataFields) {
476✔
757
        const fieldsArray = Object.values(block.metadataFields)
476✔
758
        fieldsArray.sort((a, b) => a.displayOrder - b.displayOrder)
7,752✔
759

760
        const orderedFields: Record<string, MetadataField> = {}
476✔
761
        for (const field of fieldsArray) {
476✔
762
          orderedFields[field.name] = field
7,997✔
763
        }
764
        block.metadataFields = orderedFields
476✔
765
      }
766
    }
767
    return metadataBlocksInfoCopy
240✔
768
  }
769

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

779
  public static getValidationFailedFieldError(errorMessage: string): string | null {
780
    const validationFailedKeyword = 'Validation Failed:'
48✔
781
    const invalidValueKeyword = '(Invalid value:'
48✔
782

783
    const validationFailedKeywordIndex = errorMessage.indexOf(validationFailedKeyword)
48✔
784
    const invalidValueKeywordIndex = errorMessage.indexOf(invalidValueKeyword)
48✔
785

786
    if (validationFailedKeywordIndex !== -1 && invalidValueKeywordIndex !== -1) {
48✔
787
      const start = validationFailedKeywordIndex + validationFailedKeyword.length
23✔
788
      const end = invalidValueKeywordIndex
23✔
789
      const extractedValue = errorMessage.slice(start, end).trim()
23✔
790

791
      return extractedValue
23✔
792
    }
793

794
    return null
25✔
795
  }
796

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

803
  public static isValidDateFormat(input: string): DateValidation {
804
    if (!input) return this.err('E_EMPTY')
1,232✔
805

806
    const s = input.trim()
1,230✔
807

808
    // 1) yyyy-MM-dd, yyyy-MM, yyyy
809
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd')) return this.ok('YMD')
1,230✔
810
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM')) return this.ok('YM')
1,073✔
811
    if (this.isValidDateAgainstPattern(s, 'yyyy')) return this.ok('Y')
947✔
812

813
    // 2) Bracketed: starts "[" ends "?]" and not "[-"
814
    if (s.startsWith('[') && s.endsWith('?]')) {
500✔
815
      if (s.startsWith('[-')) return this.err('E_BRACKET_NEGATIVE')
18✔
816

817
      const core = s
16✔
818
        .replace(/\[|\?\]|-|(?:AD|BC)/g, ' ')
819
        .trim()
820
        .replace(/\s+/g, ' ')
821

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

830
        if (!this.isValidDateAgainstPattern(core, 'yyyy')) return this.err('E_BRACKET_RANGE')
7✔
831
        return this.ok('BRACKET')
3✔
832
      }
833
    }
834

835
    /// 3) AD: strip AD (with or without space)
836
    if (s.includes('AD')) {
482✔
837
      const before = s.substring(0, s.indexOf('AD')).trim()
12✔
838

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

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

843
      return this.ok('AD')
6✔
844
    }
845

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

853
    // 5) Timestamp fallbacks (temporary)
854
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP')
459✔
855
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP')
456✔
856
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP')
453✔
857

858
    // ---------- Targeted error mapping (ORDER MATTERS) ----------
859

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

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

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

877
    // Pure year-month (yyyy-MM) → invalid month
878
    if (/^\d{4}-(\d{2})$/.test(s)) return this.err('E_INVALID_MONTH')
400✔
879

880
    // Looks like datetime → invalid time
881
    if (/^\d{4}-\d{2}-\d{2}T/.test(s) || /^\d{4}-\d{2}-\d{2} /.test(s))
356✔
882
      return this.err('E_INVALID_TIME')
20✔
883

884
    return this.err('E_UNRECOGNIZED')
336✔
885
  }
886

887
  public static isValidDateAgainstPattern(dateString: string, pattern: string): boolean {
888
    if (!dateString) return false
4,660✔
889
    const s = dateString.trim()
4,648✔
890
    if (s.length > pattern.length) return false
4,648✔
891

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

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

941
  public static isValidHMS(hh: string, mm: string, ss: string): boolean {
942
    const H = Number(hh),
13✔
943
      M = Number(mm),
13✔
944
      S = Number(ss)
13✔
945
    return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59
13✔
946
  }
947

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

958
  public static isLeapYear(y: number): boolean {
959
    return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0
11✔
960
  }
961

962
  private static ok(kind: DateLikeKind): DateValidation {
963
    return { valid: true, kind }
760✔
964
  }
965
  private static err(code: DateErrorCode): DateValidation {
966
    return { valid: false, errorCode: code }
472✔
967
  }
968
}
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