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

IQSS / dataverse-frontend / 20374017687

19 Dec 2025 03:07PM UTC coverage: 98.167% (+0.4%) from 97.808%
20374017687

Pull #901

github

ekraffmiller
Add Aria label to Modal.tsx
Pull Request #901: 881 notifications with paging

4497 of 4672 branches covered (96.25%)

Branch coverage included in aggregate %.

24 of 24 new or added lines in 7 files covered. (100.0%)

27 existing lines in 12 files now uncovered.

8568 of 8637 relevant lines covered (99.2%)

15059.25 hits per line

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

99.03
/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 = {
243✔
39
  E_EMPTY: 'field.invalid.date.empty',
3,522✔
40
  E_AD_DIGITS: 'field.invalid.date.adDigits',
41
  E_AD_RANGE: 'field.invalid.date.adRange',
3,522✔
42
  E_BC_NOT_NUM: 'field.invalid.date.bcNotNumeric',
16,554✔
43
  E_BRACKET_NEGATIVE: 'field.invalid.date.bracketNegative',
16,554✔
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',
3,522✔
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
80,692✔
51

431,744✔
52
export type DateErrorCode = keyof typeof dateKeyMessageErrorMap
431,744✔
53

50,488✔
54
type DateValidation =
55
  | { valid: true; kind: DateLikeKind }
431,744✔
56
  | { valid: false; errorCode: DateErrorCode }
64,138✔
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,174✔
64

65
    for (const block of metadataBlocksCopy) {
4,514✔
66
      if (block.metadataFields) {
11,139✔
67
        this.metadataBlocksInfoDotReplacer(block.metadataFields)
10,799✔
68
      }
340✔
69
    }
70
    return metadataBlocksCopy
4,818✔
71
  }
72

644✔
73
  private static metadataBlocksInfoDotReplacer(metadataFields: Record<string, MetadataField>) {
74
    for (const key in metadataFields) {
58,200✔
75
      const field = metadataFields[key]
299,647✔
76
      const fieldReplacedKey = this.replaceDotWithSlash(key)
299,647✔
77
      if (fieldReplacedKey !== key) {
300,291✔
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,871✔
81
      }
82
      if (field.name.includes('.')) {
299,647✔
83
        field.name = this.replaceDotWithSlash(field.name)
26,531✔
84
      }
85
      if (field.childMetadataFields) {
299,647✔
86
        this.metadataBlocksInfoDotReplacer(field.childMetadataFields)
48,045✔
87
      }
88
    }
644✔
89
  }
3,580✔
90

91
  public static replaceDatasetMetadataBlocksDotKeysWithSlash(
3,580✔
92
    datasetMetadataBlocks: DatasetMetadataBlock[]
93
  ): DatasetMetadataBlock[] {
94
    const dataWithoutKeysWithDots: DatasetMetadataBlock[] = [] as unknown as DatasetMetadataBlock[]
3,845✔
95

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

100
      const newBlock = {
1,189✔
101
        name: block.name,
716✔
102
        fields: newBlockFields
103
      }
104

340✔
105
      dataWithoutKeysWithDots.push(newBlock)
3,713✔
106
    }
4,708✔
107

1,736✔
108
    return dataWithoutKeysWithDots
265✔
109
  }
110

984✔
111
  private static datasetMetadataBlocksCurrentValuesDotReplacer(
1,252✔
112
    datasetMetadataFields: DatasetMetadataFields
3,488✔
113
  ): DatasetMetadataFields {
114
    const datasetMetadataFieldsNormalized: DatasetMetadataFields = {}
473✔
115

116
    for (const key in datasetMetadataFields) {
3,961✔
117
      const newKey = key.includes('.') ? this.replaceDotWithSlash(key) : key
6,050✔
118

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

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

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

132
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
2,586✔
133
      } else if (
2,322✔
134
        Array.isArray(value) &&
2,346✔
135
        (value as readonly (string | DatasetMetadataSubField)[]).every((v) => typeof v === 'object')
3,622✔
136
      ) {
137
        // Case of DatasetMetadataSubField[]
2,382✔
138
        const nestedKeysMapped = value.map((subFields) => {
22,355✔
139
          return Object.entries(subFields).reduce((acc, [nestedKey, nestedValue]) => {
22,545✔
140
            const newNestedKey = nestedKey.includes('.')
2,471✔
141
              ? this.replaceDotWithSlash(nestedKey)
21,654✔
142
              : nestedKey
9,790✔
143

144
            acc[newNestedKey] = nestedValue
12,261✔
145
            return acc
12,261✔
146
          }, {} as DatasetMetadataSubField)
32,114✔
147
        })
28,508✔
148
        datasetMetadataFieldsNormalized[newKey] = nestedKeysMapped
701✔
149
      } else {
150
        datasetMetadataFieldsNormalized[newKey] = value
33,735✔
151
      }
3,606✔
152
    }
153

154
    return datasetMetadataFieldsNormalized
473✔
155
  }
156

9,790✔
157
  public static getFormDefaultValues(
1,224✔
158
    metadataBlocks: MetadataBlockInfoWithMaybeValues[]
159
  ): DatasetMetadataFormValues {
160
    const formDefaultValues: DatasetMetadataFormValues = {}
862✔
161

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

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

3,318✔
169
        if (field.typeClass === 'compound') {
18,937✔
170
          const childFieldsWithEmptyValues: Record<string, string> = {}
7,092✔
171

3,318✔
172
          if (field.childMetadataFields) {
7,092✔
173
            for (const childField of Object.values(field.childMetadataFields)) {
7,092✔
174
              if (childField.typeClass === 'primitive') {
24,492✔
175
                childFieldsWithEmptyValues[childField.name] = ''
20,662✔
176
              }
177

178
              if (childField.typeClass === 'controlledVocabulary') {
23,300✔
179
                childFieldsWithEmptyValues[childField.name] = ''
2,638✔
180
              }
902✔
181
            }
182
          }
322✔
183

184
          if (fieldValue) {
7,092✔
185
            const castedFieldValue = fieldValue as
1,544✔
186
              | DatasetMetadataSubField
676✔
187
              | DatasetMetadataSubField[]
188

676✔
189
            let fieldValues: ComposedFieldValues
190

191
            if (Array.isArray(castedFieldValue)) {
1,190✔
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
3,566✔
198
                  }
199
                  return acc
10,908✔
200
                }, {} as Record<string, string>)
201

202
                return {
845✔
203
                  ...childFieldsWithEmptyValues,
204
                  ...fieldsValueNormalized
205
                }
21,654✔
206
              })
10,388✔
207

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

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

2,252✔
225
            blockValues[fieldName] = fieldValues
868✔
226
          } else {
2,252✔
227
            blockValues[fieldName] = field.multiple
6,224✔
228
              ? [childFieldsWithEmptyValues]
256✔
229
              : childFieldsWithEmptyValues
230
          }
8,136✔
231
        }
8,136✔
232

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

237
        if (field.typeClass === 'controlledVocabulary') {
17,095✔
238
          blockValues[fieldName] = this.getControlledVocabFieldDefaultFormValue(field)
2,501✔
239
        }
240
      }
1,426✔
241

242
      formDefaultValues[block.name] = blockValues
2,062✔
243
    }
244

50✔
245
    return formDefaultValues
912✔
246
  }
247

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

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

238✔
256
      return castedFieldValue.map((stringValue) => ({ value: stringValue }))
184✔
257
    }
238✔
258
    const castedFieldValue = field.value as string | undefined
8,386✔
259
    return castedFieldValue ?? ''
5,868✔
260
  }
2,518✔
261

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

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

1,014✔
270
      return castedFieldValue
354✔
271
    }
118✔
272
    const castedFieldValue = field.value as string | undefined
481✔
273
    return castedFieldValue ?? ''
481✔
274
  }
275

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

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

896✔
283
      formattedNewObject[blockKey] = {}
1,092✔
284

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

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

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

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

236✔
310
        if (this.isComposedMultipleFieldValue(fieldValue)) {
770✔
311
          formattedNewObject[blockKey][newFieldName] = fieldValue.map((composedFieldValues) => {
1,006✔
312
            const composedField: ComposedSingleFieldValue = {}
3,292✔
313

928✔
314
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
1,118✔
315
              const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
2,863✔
316

317
              composedField[newNestedFieldName] = nestedFieldValue
3,163✔
318
            })
319

1,560✔
320
            return composedField
984✔
321
          })
116✔
322
        }
116✔
323
      })
324
    }
64✔
325

326
    return formattedNewObject
121✔
327
  }
1,380✔
328

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

104✔
335
    for (const metadataBlockName in formValues) {
127✔
336
      const formattedMetadataBlock: DatasetMetadataBlockValuesDTO = {
468✔
337
        name: metadataBlockName,
338
        fields: {}
339
      }
1,010✔
340
      const metadataBlockFormValues = formValues[metadataBlockName]
318✔
341

342
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
318✔
343
        if (this.isPrimitiveFieldValue(fieldValue)) {
2,652✔
344
          if (fieldValue !== '' || mode === 'edit') {
1,033✔
345
            formattedMetadataBlock.fields[fieldName] = fieldValue
654✔
346
            return
654✔
347
          }
116✔
348
          return
289✔
349
        }
116✔
350
        if (this.isVocabularyMultipleFieldValue(fieldValue)) {
1,387✔
UNCOV
351
          if (fieldValue.length > 0 || mode === 'edit') {
163✔
352
            formattedMetadataBlock.fields[fieldName] = fieldValue
154✔
353
            return
154✔
354
          }
894✔
355
          return
903✔
356
        }
357

894✔
358
        if (this.isPrimitiveMultipleFieldValue(fieldValue)) {
2,156✔
359
          const primitiveMultipleFieldValues = fieldValue
1,278✔
360
            .map((primitiveField) => primitiveField.value)
3,418✔
361
            .filter((v) => v !== '')
1,106✔
362

363
          if (primitiveMultipleFieldValues.length > 0 || mode === 'edit') {
346✔
364
            formattedMetadataBlock.fields[fieldName] = primitiveMultipleFieldValues
1,278✔
365
            return
680✔
366
          }
367
          return
368
        }
894✔
369

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

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

385
        if (this.isComposedMultipleFieldValue(fieldValue)) {
776✔
386
          const formattedMetadataChildFieldValues: DatasetMetadataChildFieldValueDTO[] = []
1,112✔
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,909✔
392
                composedField[nestedFieldName] = nestedFieldValue
2,628✔
393
              }
638✔
394
            })
395
            if (Object.keys(composedField).length > 0 || mode === 'edit') {
810✔
396
              formattedMetadataChildFieldValues.push(composedField)
1,064✔
397
            }
630✔
398
          })
399
          if (formattedMetadataChildFieldValues.length > 0 || mode === 'edit') {
1,406✔
400
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValues
1,320✔
401
          }
11,438✔
402

403
          return
12,214✔
404
        }
2,552✔
405
      })
406

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

412
  public static addFieldValuesToMetadataBlocksInfo(
413
    normalizedMetadataBlocksInfo: MetadataBlockInfo[],
65,054✔
414
    normalizedDatasetMetadaBlocksCurrentValues: DatasetMetadataBlock[]
6,172✔
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

78,804✔
430
      if (currentBlockValues) {
13,066✔
431
        Object.keys(block.metadataFields).forEach((fieldName) => {
461✔
432
          const field = block.metadataFields[fieldName]
74,332✔
433

52,144✔
434
          if (this.replaceDotWithSlash(fieldName) in currentBlockValues) {
8,094✔
435
            field.value = currentBlockValues[this.replaceDotWithSlash(fieldName)]
1,836✔
436
          }
14,094✔
437
        })
4,872✔
438
      }
439
    })
9,222✔
440

441
    return normalizedMetadataBlocksInfoCopy
264✔
442
  }
348✔
443

5,006✔
444
  private static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/')
336,516✔
445
  public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.')
5,712✔
446

447
  /*
448
   * To define the field name that will be used to register the field in the form
2,772✔
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
348✔
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
   */
3,136✔
454
  public static defineFieldName(
455
    name: string,
348✔
456
    metadataBlockName: string,
457
    compoundParentName?: string,
458
    fieldsArrayIndex?: number
2,024✔
459
  ) {
460
    if (fieldsArrayIndex !== undefined && !compoundParentName) {
54,614✔
461
      return `${metadataBlockName}.${name}.${fieldsArrayIndex}.value`
6,805✔
462
    }
463
    if (fieldsArrayIndex !== undefined && compoundParentName) {
49,327✔
464
      return `${metadataBlockName}.${compoundParentName}.${fieldsArrayIndex}.${name}`
37,389✔
465
    }
466

467
    if (compoundParentName) {
10,072✔
468
      return `${metadataBlockName}.${compoundParentName}.${name}`
3,808✔
469
    }
470
    return `${metadataBlockName}.${name}`
6,264✔
471
  }
472

473
  private static isPrimitiveFieldValue = (value: unknown): value is string => {
243✔
474
    return typeof value === 'string'
4,338✔
475
  }
476
  private static isPrimitiveMultipleFieldValue = (
243✔
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 = (
243✔
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 = (
243✔
487
    value: unknown
488
  ): value is ComposedSingleFieldValue => {
489
    return typeof value === 'object' && !Array.isArray(value)
1,744✔
490
  }
491
  private static isComposedMultipleFieldValue = (
243✔
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)
827✔
514

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

518
    // CREATE MODE
519
    if (mode === 'create') {
827✔
520
      // If we have no template, we just return the metadata blocks info for create with normalized field names
521
      if (!templateMetadataBlocks) {
612✔
522
        return normalizedMetadataBlocksInfoForDisplayOnCreate
587✔
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,752✔
658

659
        const orderedFields: Record<string, MetadataField> = {}
476✔
660
        for (const field of fieldsArray) {
476✔
661
          orderedFields[field.name] = field
7,997✔
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,182✔
704

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

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

712
    // 2) Bracketed: starts "[" ends "?]" and not "[-"
713
    if (s.startsWith('[') && s.endsWith('?]')) {
475✔
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')) {
457✔
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')) {
445✔
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')
434✔
754
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP')
431✔
755
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP')
428✔
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')
425✔
761

762
    // Distinguish month vs day precisely
763
    const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
423✔
764
    if (ymd) {
423✔
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')
380✔
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))
341✔
781
      return this.err('E_INVALID_TIME')
20✔
782

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

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

791
    switch (pattern) {
3,667!
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{1,2})$/.exec(s)
787✔
799
        if (!m) return false
787✔
800
        const month = Number(m[2])
228✔
801
        return month >= 1 && month <= 12
228✔
802
      }
803
      case 'yyyy-MM-dd': {
804
        const m = /^(\d{1,4})-(\d{1,2})-(\d{1,2})$/.exec(s)
1,170✔
805
        if (!m) return false
1,170✔
806
        const year = Number(m[1])
262✔
807
        const month = Number(m[2])
262✔
808
        const day = Number(m[3])
262✔
809

810
        if (!(month >= 1 && month <= 12)) return false
262✔
811
        const dim = this.daysInMonth(year, month)
182✔
812
        return day >= 1 && day <= dim
182✔
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)
423✔
816
        if (!m) return false
423✔
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)
429✔
823
        if (!m) return false
429✔
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)
420✔
830
        if (!m) return false
420✔
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:
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) {
186!
850
      case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31
148✔
851
      case 4: case 6: case 9: case 11: return 30
27✔
852
      case 2: return this.isLeapYear(y) ? 29 : 28
11✔
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 }
735✔
863
  }
864
  private static err(code: DateErrorCode): DateValidation {
865
    return { valid: false, errorCode: code }
447✔
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