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

IQSS / dataverse-frontend / 21483969918

29 Jan 2026 03:23PM UTC coverage: 97.477% (-0.3%) from 97.779%
21483969918

Pull #908

github

ChengShi-1
fix: delete button
Pull Request #908: Manage Dataset Templates Integration

4336 of 4531 branches covered (95.7%)

Branch coverage included in aggregate %.

363 of 385 new or added lines in 26 files covered. (94.29%)

3 existing lines in 2 files now uncovered.

8416 of 8551 relevant lines covered (98.42%)

10660.59 hits per line

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

97.45
/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,359✔
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)
4,336✔
82

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

91
  private static metadataBlocksInfoDotReplacer(metadataFields: Record<string, MetadataField>) {
1,790✔
92
    for (const key in metadataFields) {
60,480✔
93
      const field = metadataFields[key]
312,111✔
94
      const fieldReplacedKey = this.replaceDotWithSlash(key)
313,901✔
95
      if (fieldReplacedKey !== key) {
312,281✔
96
        // Change the key in the object only if it has changed (i.e., it had a dot)
358✔
97
        metadataFields[fieldReplacedKey] = field
28,138✔
98
        delete metadataFields[key]
28,138✔
99
      }
100
      if (field.name.includes('.')) {
312,469✔
101
        field.name = this.replaceDotWithSlash(field.name)
28,496✔
102
      }
103
      if (field.childMetadataFields) {
312,111✔
104
        this.metadataBlocksInfoDotReplacer(field.childMetadataFields)
49,377✔
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[]
2,011✔
113

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

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

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

126
    return dataWithoutKeysWithDots
589✔
127
  }
128

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

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

137
      const value = datasetMetadataFields[key]
3,783✔
138

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

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

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

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

1,659✔
172
    return datasetMetadataFieldsNormalized
475✔
173
  }
174

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

180
    for (const block of metadataBlocks) {
1,474✔
181
      const blockValues: MetadataBlockFormValues = {}
2,207✔
182

161✔
183
      for (const field of Object.values(block.metadataFields)) {
2,207✔
184
        const fieldName = field.name
22,740✔
185
        const fieldValue = field.value
23,078✔
186

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

190
          if (field.childMetadataFields) {
8,860✔
191
            for (const childField of Object.values(field.childMetadataFields)) {
9,021✔
192
              if (childField.typeClass === 'primitive') {
28,415✔
193
                childFieldsWithEmptyValues[childField.name] = ''
25,448✔
194
              }
195

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

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

5,194✔
207
            let fieldValues: ComposedFieldValues
208

209
            if (Array.isArray(castedFieldValue)) {
11,699✔
210
              const subFieldsWithValuesPlusEmptyOnes = castedFieldValue.map((subFields) => {
1,381✔
211
                const fieldsValueNormalized: Record<string, string> = Object.entries(
847✔
212
                  subFields
213
                ).reduce((acc, [key, value]) => {
214
                  if (value !== undefined) {
3,541✔
215
                    acc[key] = value
2,350✔
216
                  }
217
                  return acc
3,523✔
218
                }, {} as Record<string, string>)
219

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

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

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

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

251
        if (field.typeClass === 'primitive') {
22,814✔
252
          blockValues[fieldName] = this.getPrimitiveFieldDefaultFormValue(field)
12,168✔
253
        }
119✔
254

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

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

263
    return formDefaultValues
1,023✔
264
  }
265

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

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

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

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

286
      if (!castedFieldValue) return []
2,184✔
287

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

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

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

301
      formattedNewObject[blockKey] = {}
258✔
302

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

118✔
306
        if (
3,094✔
307
          this.isPrimitiveFieldValue(fieldValue) ||
6,679✔
308
          this.isVocabularyMultipleFieldValue(fieldValue) ||
309
          this.isPrimitiveMultipleFieldValue(fieldValue)
118✔
310
        ) {
311
          formattedNewObject[blockKey][newFieldName] = fieldValue
2,112✔
312
          return
3,238✔
313
        }
464✔
314

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

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

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

332
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
1,201✔
333
              const newNestedFieldName = this.replaceSlashWithDot(nestedFieldName)
3,219✔
334

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

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

219✔
344
    return formattedNewObject
236✔
345
  }
346

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

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

466✔
360
      Object.entries(metadataBlockFormValues).forEach(([fieldName, fieldValue]) => {
1,747✔
361
        if (this.isPrimitiveFieldValue(fieldValue)) {
3,525✔
362
          if (fieldValue !== '' || mode === 'edit') {
1,228✔
363
            formattedMetadataBlock.fields[fieldName] = fieldValue
678✔
364
            return
1,144✔
365
          }
167✔
366
          return
550✔
367
        }
368
        if (this.isVocabularyMultipleFieldValue(fieldValue)) {
2,418✔
369
          if (fieldValue.length > 0 || mode === 'edit') {
407✔
370
            formattedMetadataBlock.fields[fieldName] = fieldValue
163✔
371
            return
163✔
372
          }
447✔
373
          return
96✔
374
        }
375

376
        if (this.isPrimitiveMultipleFieldValue(fieldValue)) {
1,830✔
377
          const primitiveMultipleFieldValues = fieldValue
598✔
378
            .map((primitiveField) => primitiveField.value)
791✔
379
            .filter((v) => v !== '')
718✔
380

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

388
        if (this.isComposedSingleFieldValue(fieldValue)) {
1,114✔
389
          const formattedMetadataChildFieldValue: DatasetMetadataChildFieldValueDTO = {}
125✔
390

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

403
        if (this.isComposedMultipleFieldValue(fieldValue)) {
6,708✔
404
          const formattedMetadataChildFieldValues: DatasetMetadataChildFieldValueDTO[] = []
2,265✔
405

406
          fieldValue.forEach((composedFieldValues) => {
989✔
407
            const composedField: DatasetMetadataChildFieldValueDTO = {}
1,023✔
408
            Object.entries(composedFieldValues).forEach(([nestedFieldName, nestedFieldValue]) => {
1,023✔
409
              if (nestedFieldValue !== '' || mode === 'edit') {
3,195✔
410
                composedField[nestedFieldName] = nestedFieldValue
2,181✔
411
              }
412
            })
413
            if (Object.keys(composedField).length > 0 || mode === 'edit') {
33,550✔
414
              formattedMetadataChildFieldValues.push(composedField)
3,829✔
415
            }
416
          })
417
          if (formattedMetadataChildFieldValues.length > 0 || mode === 'edit') {
989✔
418
            formattedMetadataBlock.fields[fieldName] = formattedMetadataChildFieldValues
709✔
419
          }
420

421
          return
989✔
422
        }
423
      })
424

425
      metadataBlocks.push(formattedMetadataBlock)
265✔
426
    }
427
    return { licence: defaultLicense, metadataBlocks }
140✔
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[] = []
78✔
435

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

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

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

174✔
446
        templateFields.push({
30✔
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,598✔
454
      }
455

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

460
        const valuePayload = fieldInfo.multiple ? compoundValues : compoundValues[0]
190!
461

462
        templateFields.push({
16✔
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
78✔
472
  }
473

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

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

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

495
        entry[childInfo.name] = {
24✔
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) {
20✔
503
        compoundValues.push(entry)
20✔
504
      }
505
    })
506

507
    return compoundValues
16✔
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(
266✔
516
      normalizedMetadataBlocksInfo
517
    )
518

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

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

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

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

539
    return normalizedMetadataBlocksInfoCopy
266✔
540
  }
541

542
  private static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/')
350,679✔
543
  public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.')
96,308✔
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) {
89,455✔
559
      return `${metadataBlockName}.${name}.${fieldsArrayIndex}.value`
23,606✔
560
    }
561
    if (fieldsArrayIndex !== undefined && compoundParentName) {
65,849✔
562
      return `${metadataBlockName}.${compoundParentName}.${fieldsArrayIndex}.${name}`
52,399✔
563
    }
564

565
    if (compoundParentName) {
13,450✔
566
      return `${metadataBlockName}.${compoundParentName}.${name}`
4,164✔
567
    }
568
    return `${metadataBlockName}.${name}`
9,286✔
569
  }
570

571
  private static isPrimitiveFieldValue = (value: unknown): value is string => {
290✔
572
    return typeof value === 'string'
6,293✔
573
  }
574
  private static isPrimitiveMultipleFieldValue = (
290✔
575
    value: unknown
576
  ): value is PrimitiveMultipleFormValue => {
577
    return Array.isArray(value) && value.every((v) => typeof v === 'object' && 'value' in v)
3,382✔
578
  }
579
  private static isVocabularyMultipleFieldValue = (
290✔
580
    value: unknown
581
  ): value is VocabularyMultipleFormValue => {
582
    return Array.isArray(value) && value.every((v) => typeof v === 'string')
3,886✔
583
  }
584
  private static isComposedSingleFieldValue = (
290✔
585
    value: unknown
586
  ): value is ComposedSingleFieldValue => {
587
    return typeof value === 'object' && !Array.isArray(value)
2,214✔
588
  }
589
  private static isComposedMultipleFieldValue = (
290✔
590
    value: unknown
591
  ): value is ComposedSingleFieldValue[] => {
592
    return Array.isArray(value) && value.every((v) => typeof v === 'object')
2,039✔
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)
828✔
612

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

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

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

627
      // 2) Add missing fields from the template to the metadata blocks info for create
628
      const metadataBlocksInfoWithAddedFieldsFromTemplate =
629
        this.addFieldsFromTemplateToMetadataBlocksInfoForDisplayOnCreate(
25✔
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(
25✔
637
        metadataBlocksInfoWithAddedFieldsFromTemplate,
638
        normalizedDatasetTemplateMetadataBlocksValues
639
      )
640

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

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

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

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

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

666
      return metadataBlocksInfoOrdered
215✔
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) {
25✔
676
      return metadataBlocksInfoForDisplayOnCreate
7✔
677
    }
678

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

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

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

694
    for (const tBlock of templateBlocks) {
18✔
695
      const blockName = tBlock.name
18✔
696
      const editBlock = editMap[blockName]
18✔
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
18!
700

701
      if (!templateBlockHasFields) continue
18!
702

703
      if (!editBlock) {
18!
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]
18✔
710

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

723
      const createFields = createBlock.metadataFields
18✔
724
      const editFields = editBlock.metadataFields
18✔
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 ?? {}
18!
728
      for (const fieldName of Object.keys(templateBlockFields)) {
18✔
729
        if (createFields[fieldName]) continue
37✔
730

731
        const fieldFromEdit = editFields[fieldName]
13✔
732
        if (!fieldFromEdit) {
13!
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)
13✔
738

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

743
    return createCopy
18✔
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)
240✔
751

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

757
        const orderedFields: Record<string, MetadataField> = {}
476✔
758
        for (const field of fieldsArray) {
476✔
759
          orderedFields[field.name] = field
7,997✔
760
        }
761
        block.metadataFields = orderedFields
476✔
762
      }
763
    }
764
    return metadataBlocksInfoCopy
240✔
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:'
48✔
778
    const invalidValueKeyword = '(Invalid value:'
48✔
779

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

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

788
      return extractedValue
23✔
789
    }
790

791
    return null
25✔
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')
1,232✔
802

803
    const s = input.trim()
1,230✔
804

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

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

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

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

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

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

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

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

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

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

850
    // 5) Timestamp fallbacks (temporary)
851
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss")) return this.ok('TIMESTAMP')
459✔
852
    if (this.isValidDateAgainstPattern(s, "yyyy-MM-dd'T'HH:mm:ss.SSS")) return this.ok('TIMESTAMP')
456✔
853
    if (this.isValidDateAgainstPattern(s, 'yyyy-MM-dd HH:mm:ss')) return this.ok('TIMESTAMP')
453✔
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')
450✔
859

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

868
      if (month < 1 || month > 12) return this.err('E_INVALID_MONTH')
48✔
869
      const dim = this.daysInMonth(year, month)
4✔
870
      if (day < 1 || day > dim) return this.err('E_INVALID_DAY')
4✔
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')
400✔
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))
356✔
879
      return this.err('E_INVALID_TIME')
20✔
880

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

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

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

908
        if (!(month >= 1 && month <= 12)) return false
272✔
909
        const dim = this.daysInMonth(year, month)
182✔
910
        return day >= 1 && day <= dim
182✔
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)
448✔
914
        if (!m) return false
448✔
915
        const [, y, mo, d, hh, mm, ss] = m
13✔
916
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
13✔
917
        return this.isValidHMS(hh, mm, ss)
7✔
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)
454✔
921
        if (!m) return false
454✔
922
        const [, y, mo, d, hh, mm, ss, ms] = m
7✔
923
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
7✔
924
        return this.isValidHMS(hh, mm, ss) && /^\d{3}$/.test(ms)
3✔
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)
445✔
928
        if (!m) return false
445✔
929
        const [, y, mo, d, hh, mm, ss] = m
7✔
930
        if (!this.isValidDateAgainstPattern(`${y}-${mo}-${d}`, 'yyyy-MM-dd')) return false
7✔
931
        return this.isValidHMS(hh, mm, ss)
3✔
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),
13✔
940
      M = Number(mm),
13✔
941
      S = Number(ss)
13✔
942
    return H >= 0 && H <= 23 && M >= 0 && M <= 59 && S >= 0 && S <= 59
13✔
943
  }
944

945
  // prettier-ignore
946
  public static daysInMonth(y: number, m: number): number {
947
    switch (m) {
186!
948
      case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31
148✔
949
      case 4: case 6: case 9: case 11: return 30
27✔
950
      case 2: return this.isLeapYear(y) ? 29 : 28
11✔
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
11✔
957
  }
958

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