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

CSCfi / metadata-submitter-frontend / 15630162011

13 Jun 2025 08:31AM UTC coverage: 57.804% (+0.5%) from 57.3%
15630162011

push

github

Hang Le
Update date selection in DOI form (merge commit)

Merge branch 'feature/update-to-x-date-pickers' into 'main'
* Update MUI lab to x-date-pickers lib. Update date selection component in DOI form

Closes #1021
See merge request https://gitlab.ci.csc.fi/sds-dev/sd-submit/metadata-submitter-frontend/-/merge_requests/1121

Approved-by: Hang Le <lhang@csc.fi>
Co-authored-by: Monika Radaviciute <mradavic@csc.fi>
Merged by Hang Le <lhang@csc.fi>

659 of 939 branches covered (70.18%)

Branch coverage included in aggregate %.

6 of 114 new or added lines in 1 file covered. (5.26%)

4 existing lines in 1 file now uncovered.

6178 of 10889 relevant lines covered (56.74%)

5.1 hits per line

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

65.83
/src/components/SubmissionWizard/WizardForms/WizardJSONSchemaParser.tsx
1
import * as React from "react"
1✔
2

3
import AddIcon from "@mui/icons-material/Add"
1✔
4
import ClearIcon from "@mui/icons-material/Clear"
1✔
5
import HelpOutlineIcon from "@mui/icons-material/HelpOutline"
1✔
6
import LaunchIcon from "@mui/icons-material/Launch"
1✔
7
import RemoveIcon from "@mui/icons-material/Remove"
1✔
8
import { FormControl } from "@mui/material"
1✔
9
import Autocomplete from "@mui/material/Autocomplete"
1✔
10
import Box from "@mui/material/Box"
1✔
11
import Button from "@mui/material/Button"
1✔
12
import Checkbox from "@mui/material/Checkbox"
1✔
13
import Chip from "@mui/material/Chip"
1✔
14
import CircularProgress from "@mui/material/CircularProgress"
1✔
15
import FormControlLabel from "@mui/material/FormControlLabel"
1✔
16
import FormGroup from "@mui/material/FormGroup"
1✔
17
import FormHelperText from "@mui/material/FormHelperText"
1✔
18
import Grid from "@mui/material/Grid"
1✔
19
import IconButton from "@mui/material/IconButton"
1✔
20
import Paper from "@mui/material/Paper"
1✔
21
import { styled } from "@mui/material/styles"
1✔
22
import { TypographyVariant } from "@mui/material/styles/createTypography"
23
import TextField from "@mui/material/TextField"
1✔
24
import Tooltip, { TooltipProps } from "@mui/material/Tooltip"
1✔
25
import Typography from "@mui/material/Typography"
1✔
26
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"
1✔
27
import { DatePicker } from "@mui/x-date-pickers/DatePicker"
1✔
28
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"
1✔
29
import { get, flatten, uniq, debounce } from "lodash"
1✔
30
import moment from "moment"
1✔
31
import { useFieldArray, useFormContext, useForm, Controller, useWatch } from "react-hook-form"
1✔
32
import { useTranslation } from "react-i18next"
1✔
33

34
import { DisplayObjectTypes, ObjectTypes } from "constants/wizardObject"
1✔
35
import { setAutocompleteField } from "features/autocompleteSlice"
1✔
36
import { useAppSelector, useAppDispatch } from "hooks"
1✔
37
import rorAPIService from "services/rorAPI"
1✔
38
import { ConnectFormChildren, ConnectFormMethods, FormObject, NestedField } from "types"
39
import { pathToName, traverseValues, getPathName } from "utils/JSONSchemaUtils"
1✔
40

41
/*
42
 * Highlight style for required fields
43
 */
44
const highlightStyle = theme => {
1✔
45
  return {
×
46
    borderColor: theme.palette.primary.main,
×
47
    borderWidth: 2,
×
48
  }
×
49
}
×
50

51
const BaselineDiv = styled("div")({
1✔
52
  display: "flex",
1✔
53
  alignItems: "baseline",
1✔
54
})
1✔
55

56
const FieldTooltip = styled(({ className, ...props }: TooltipProps) => (
1✔
57
  <Tooltip {...props} classes={{ popper: className }} />
44✔
58
))(({ theme }) => ({
1✔
59
  "& .MuiTooltip-tooltip": {
44✔
60
    padding: "2rem",
44✔
61
    backgroundColor: theme.palette.common.white,
44✔
62
    color: theme.palette.secondary.main,
44✔
63
    fontSize: "1.4rem",
44✔
64
    boxShadow: theme.shadows[1],
44✔
65
    border: `0.1rem solid ${theme.palette.primary.main}`,
44✔
66
    maxWidth: "25rem",
44✔
67
  },
44✔
68
  "& .MuiTooltip-arrow": {
44✔
69
    "&:before": {
44✔
70
      border: `0.1rem solid ${theme.palette.primary.main}`,
44✔
71
    },
44✔
72
    color: theme.palette.common.white,
44✔
73
  },
44✔
74
}))
1✔
75

76
const TooltipIcon = styled(HelpOutlineIcon)(({ theme }) => ({
1✔
77
  color: theme.palette.primary.main,
48✔
78
  marginLeft: "1rem",
48✔
79
}))
1✔
80

81
/*
82
 * Clean up form values from empty strings and objects, translate numbers inside strings to numbers.
83
 */
84
const cleanUpFormValues = (data: unknown) => {
1✔
85
  const cleanedData = JSON.parse(JSON.stringify(data))
4✔
86
  return traverseFormValuesForCleanUp(cleanedData)
4✔
87
}
4✔
88

89
// Array is populated in traverseFields method.
90
const integerFields: Array<string> = []
1✔
91

92
const traverseFormValuesForCleanUp = (data: Record<string, unknown>) => {
1✔
93
  Object.keys(data).forEach(key => {
14✔
94
    const property = data[key] as Record<string, unknown> | string | null
63✔
95

96
    if (typeof property === "object" && !Array.isArray(property)) {
63✔
97
      if (property !== null) {
10✔
98
        data[key] = traverseFormValuesForCleanUp(property)
10✔
99
        if (Object.keys(property).length === 0) delete data[key]
10✔
100
      }
10✔
101
    }
10✔
102
    if (property === "") {
63✔
103
      delete data[key]
43✔
104
    }
43✔
105
    // Integer typed fields are considered as string like ID's which are numbers.
106
    // Therefore these fields need to be handled as string in the form and cast as number
107
    // for backend operations. Eg. "1234" is converted to 1234 so it passes backend validation
108
    else if (integerFields.indexOf(key) > -1) {
20✔
109
      data[key] = Number(data[key])
1✔
110
    }
1✔
111
  })
14✔
112
  return data
14✔
113
}
14✔
114

115
/*
116
 * Build react-hook-form fields based on given schema
117
 */
118
const buildFields = (schema: FormObject) => {
1✔
119
  try {
24✔
120
    return traverseFields(schema, [])
24✔
121
  } catch (error) {
24!
122
    console.error(error)
×
123
  }
×
124
}
24✔
125

126
/*
127
 * Allow children components inside ConnectForm to pull react-hook-form objects and methods from context
128
 */
129
const ConnectForm = ({ children }: ConnectFormChildren) => {
1✔
130
  const methods = useFormContext()
227✔
131
  return children({ ...(methods as ConnectFormMethods) })
227✔
132
}
227✔
133

134
/*
135
 * Get defaultValue for options in a form. Used when rendering a saved/submitted form
136
 */
137
const getDefaultValue = (name: string, nestedField?: Record<string, unknown>) => {
1✔
138
  if (nestedField) {
59✔
139
    let result
1✔
140
    const path = name.split(".")
1✔
141
    // E.g. Case of DOI form - Formats's fields
142
    if (path[0] === "formats") {
1!
143
      const k = path[0]
×
144
      if (k in nestedField) {
×
145
        result = nestedField[k]
×
146
      } else {
×
147
        return
×
148
      }
×
149
    } else {
1✔
150
      for (let i = 1, n = path.length; i < n; ++i) {
1✔
151
        const k = path[i]
1✔
152

153
        if (nestedField && k in nestedField) {
1!
154
          result = nestedField[k]
×
155
        } else {
1✔
156
          return
1✔
157
        }
1✔
158
      }
1!
159
    }
×
160
    return result
×
161
  } else {
59✔
162
    return ""
58✔
163
  }
58✔
164
}
59✔
165

166
/*
167
 * Traverse fields recursively, return correct fields for given object or log error, if object type is not supported.
168
 */
169
const traverseFields = (
1✔
170
  object: FormObject,
156✔
171
  path: string[],
156✔
172
  requiredProperties?: string[],
156✔
173
  requireFirst?: boolean,
156✔
174
  nestedField?: NestedField
156✔
175
) => {
156✔
176
  const name = pathToName(path)
156✔
177
  const [lastPathItem] = path.slice(-1)
156✔
178
  const label = object.title ?? lastPathItem
156!
179
  const required = !!requiredProperties?.includes(lastPathItem) || requireFirst || false
156✔
180
  const description = object.description
156✔
181
  const autoCompleteIdentifiers = ["organisation", "name of the place of affiliation"]
156✔
182

183
  if (object.oneOf)
156✔
184
    return (
156✔
185
      <FormSection key={name} name={name} label={label} level={path.length}>
6✔
186
        <FormOneOfField key={name} path={path} object={object} required={required} />
6✔
187
      </FormSection>
6✔
188
    )
189

190
  switch (object.type) {
150✔
191
    case "object": {
156✔
192
      const properties =
66✔
193
        label === DisplayObjectTypes.dataset && path.length === 0
66!
194
          ? { title: object.properties["title"], description: object.properties["description"] }
×
195
          : object.properties
66✔
196

197
      return (
66✔
198
        <FormSection
66✔
199
          key={name}
66✔
200
          name={name}
66✔
201
          label={label}
66✔
202
          level={path.length}
66✔
203
          description={description}
66✔
204
          isTitleShown
66✔
205
        >
206
          {Object.keys(properties).map(propertyKey => {
66✔
207
            const property = properties[propertyKey] as FormObject
130✔
208
            const required = object?.else?.required ?? object.required
130!
209
            let requireFirstItem = false
130✔
210

211
            if (
130✔
212
              path.length === 0 &&
130✔
213
              propertyKey === "title" &&
47!
214
              !object.title.includes("DAC - Data Access Committee")
×
215
            ) {
130!
216
              requireFirstItem = true
×
217
            }
×
218
            // Require first field of section if parent section is a required property
219
            if (
130✔
220
              requireFirst ||
130✔
221
              requiredProperties?.includes(name) ||
129✔
222
              requiredProperties?.includes(Object.keys(properties)[0])
107✔
223
            ) {
130✔
224
              const parentProperty = Object.values(properties)[0] as { title: string }
23✔
225
              requireFirstItem = parentProperty.title === property.title ? true : false
23✔
226
            }
23✔
227

228
            return traverseFields(
130✔
229
              property,
130✔
230
              [...path, propertyKey],
130✔
231
              required,
130✔
232
              requireFirstItem,
130✔
233
              nestedField
130✔
234
            )
130✔
235
          })}
66✔
236
        </FormSection>
66✔
237
      )
238
    }
66✔
239
    case "string": {
156✔
240
      return object["enum"] ? (
52✔
241
        <FormSection key={name} name={name} label={label} level={path.length}>
6✔
242
          <FormSelectField
6✔
243
            key={name}
6✔
244
            name={name}
6✔
245
            label={label}
6✔
246
            options={object.enum}
6✔
247
            required={required}
6✔
248
            description={description}
6✔
249
          />
250
        </FormSection>
6✔
251
      ) : object.title === "Date" ? (
46!
252
        <FormDatePicker
×
253
          key={name}
×
254
          name={name}
×
255
          label={label}
×
256
          required={required}
×
257
          description={description}
×
258
        />
259
      ) : autoCompleteIdentifiers.some(value => label.toLowerCase().includes(value)) ? (
46✔
260
        <FormAutocompleteField
5✔
261
          key={name}
5✔
262
          name={name}
5✔
263
          label={label}
5✔
264
          required={required}
5✔
265
          description={description}
5✔
266
        />
1✔
267
      ) : name.includes("keywords") ? (
41!
268
        <FormSection key={name} name={name} label={label} level={path.length}>
×
269
          <FormTagField
×
270
            key={name}
×
271
            name={name}
×
272
            label={label}
×
273
            required={required}
×
274
            description={description}
×
275
          />
276
        </FormSection>
×
277
      ) : (
278
        <FormSection key={name} name={name} label={label} level={path.length}>
41✔
279
          <FormTextField
41✔
280
            key={name}
41✔
281
            name={name}
41✔
282
            label={label}
41✔
283
            required={required}
41✔
284
            description={description}
41✔
285
            nestedField={nestedField}
41✔
286
          />
287
        </FormSection>
41✔
288
      )
289
    }
52✔
290
    case "integer": {
156✔
291
      // List fields with integer type in schema. List is used as helper when cleaning up fields for backend.
292
      const fieldName = name.split(".").pop()
8✔
293
      if (fieldName && integerFields.indexOf(fieldName) < 0) integerFields.push(fieldName)
8✔
294

295
      return (
8✔
296
        <FormSection key={name} name={name} label={label} level={path.length}>
8✔
297
          <FormTextField
8✔
298
            key={name}
8✔
299
            name={name}
8✔
300
            label={label}
8✔
301
            required={required}
8✔
302
            description={description}
8✔
303
          />
304
        </FormSection>
8✔
305
      )
306
    }
8✔
307
    case "number": {
156✔
308
      return (
6✔
309
        <FormTextField
6✔
310
          key={name}
6✔
311
          name={name}
6✔
312
          label={label}
6✔
313
          required={required}
6✔
314
          description={description}
6✔
315
          type="number"
6✔
316
        />
317
      )
318
    }
6✔
319
    case "boolean": {
156✔
320
      return (
6✔
321
        <FormBooleanField
6✔
322
          key={name}
6✔
323
          name={name}
6✔
324
          label={label}
6✔
325
          required={required}
6✔
326
          description={description}
6✔
327
        />
328
      )
329
    }
6✔
330
    case "array": {
156✔
331
      return object.items.enum ? (
12✔
332
        <FormSection key={name} name={name} label={label} level={path.length}>
6✔
333
          <FormCheckBoxArray
6✔
334
            key={name}
6✔
335
            name={name}
6✔
336
            label=""
6✔
337
            options={object.items.enum}
6✔
338
            required={required}
6✔
339
            description={description}
6✔
340
          />
341
        </FormSection>
6✔
342
      ) : (
343
        <FormArray
6✔
344
          key={name}
6✔
345
          object={object}
6✔
346
          path={path}
6✔
347
          required={required}
6✔
348
          description={description}
6✔
349
        />
350
      )
351
    }
12✔
352
    case "null": {
156!
353
      return null
×
354
    }
×
355
    default: {
156!
356
      console.error(`
×
357
      No field parsing support for type ${object.type} yet.
×
358

359
      Pretty printed version of object with unsupported type:
360
      ${JSON.stringify(object, null, 2)}
×
361
      `)
×
362
      return null
×
363
    }
×
364
  }
156✔
365
}
156✔
366

367
const DisplayDescription = ({
1✔
368
  description,
5✔
369
  children,
5✔
370
}: {
371
  description: string
372
  children?: React.ReactElement<unknown>
373
}) => {
5✔
374
  const { t } = useTranslation()
5✔
375
  const [isReadMore, setIsReadMore] = React.useState(description.length > 60)
5✔
376

377
  const toggleReadMore = () => {
5✔
378
    setIsReadMore(!isReadMore)
2✔
379
  }
2✔
380

381
  const ReadmoreText = styled("span")(({ theme }) => ({
5✔
382
    fontWeight: 700,
3✔
383
    textDecoration: "underline",
3✔
384
    display: "block",
3✔
385
    marginTop: "0.5rem",
3✔
386
    color: theme.palette.primary.main,
3✔
387
    "&:hover": { cursor: "pointer" },
3✔
388
  }))
5✔
389

390
  return (
5✔
391
    <p>
5✔
392
      {isReadMore ? `${description.slice(0, 60)}...` : description}
5✔
393
      {!isReadMore && children}
5✔
394
      {description?.length >= 60 && (
5✔
395
        <ReadmoreText onClick={toggleReadMore}>
4✔
396
          {isReadMore ? t("showMore") : t("showLess")}
4✔
397
        </ReadmoreText>
4✔
398
      )}
399
    </p>
5✔
400
  )
401
}
5✔
402

403
type FormSectionProps = {
404
  name: string
405
  label: string
406
  level: number
407
  isTitleShown?: boolean
408
  children?: React.ReactNode
409
}
410

411
const FormSectionTitle = styled(Paper, { shouldForwardProp: prop => prop !== "level" })<{
1✔
412
  level: number
413
}>(({ theme, level }) => ({
1✔
414
  display: "flex",
66✔
415
  flexDirection: "column",
66✔
416
  justifyContent: "start",
66✔
417
  alignItems: "start",
66✔
418
  backgroundColor: level === 1 ? theme.palette.primary.light : theme.palette.common.white,
66✔
419
  height: "100%",
66✔
420
  marginLeft: level <= 1 ? "5rem" : 0,
66!
421
  marginRight: "3rem",
66✔
422
  padding: level === 0 ? "4rem 0 3rem 0" : level === 1 ? "2rem" : 0,
66!
423
}))
1✔
424

425
/*
426
 * FormSection is rendered for properties with type object
427
 */
428
const FormSection = ({
1✔
429
  name,
133✔
430
  label,
133✔
431
  level,
133✔
432
  children,
133✔
433
  description,
133✔
434
  isTitleShown,
133✔
435
}: FormSectionProps & { description?: string }) => {
133✔
436
  const splittedPath = name.split(".") // Have a fully splitted path for names such as "studyLinks.0", "dacLinks.0"
133✔
437

438
  const heading = (
133✔
439
    <Grid size={{ xs: 12, md: level === 0 ? 12 : level === 1 ? 4 : 8 }}>
133✔
440
      {(level <= 1 || ((level === 3 || level === 2) && isTitleShown)) && label && (
133✔
441
        <FormSectionTitle square={true} elevation={0} level={level}>
66✔
442
          <Typography
66✔
443
            key={`${name}-header`}
66✔
444
            variant={level === 0 ? "h4" : ("subtitle1" as TypographyVariant)}
66✔
445
            role="heading"
66✔
446
            color="secondary"
66✔
447
          >
448
            {label} {name.includes("keywords") ? "*" : ""}
66!
449
            {description && level === 1 && (
66✔
450
              <FieldTooltip
11✔
451
                title={<DisplayDescription description={description} />}
11✔
452
                placement="top"
11✔
453
                arrow
11✔
454
                describeChild
11✔
455
              >
456
                <TooltipIcon />
11✔
457
              </FieldTooltip>
11✔
458
            )}
459
          </Typography>
66✔
460
        </FormSectionTitle>
66✔
461
      )}
462
    </Grid>
133✔
463
  )
464

465
  return (
133✔
466
    <ConnectForm>
133✔
467
      {({ errors }: ConnectFormMethods) => {
133✔
468
        const error = get(errors, name)
133✔
469
        return (
133✔
470
          <>
133✔
471
            <Grid
133✔
472
              container
133✔
473
              key={`${name}-section`}
133✔
474
              sx={{ mb: level <= 1 && splittedPath.length <= 1 ? "3rem" : 0 }}
133✔
475
            >
476
              {heading}
133✔
477
              <Grid size={{ xs: 12, md: level === 1 && label ? 8 : 12 }}>{children}</Grid>
133✔
478
            </Grid>
133✔
479
            <div>
133✔
480
              {error ? (
133!
481
                <FormControl error>
×
482
                  <FormHelperText>
×
483
                    {label} {error?.message}
×
484
                  </FormHelperText>
×
485
                </FormControl>
×
486
              ) : null}
133✔
487
            </div>
133✔
488
          </>
133✔
489
        )
490
      }}
133✔
491
    </ConnectForm>
133✔
492
  )
493
}
133✔
494

495
/*
496
 * FormOneOfField is rendered if property can be choosed from many possible.
497
 */
498

499
const FormOneOfField = ({
1✔
500
  path,
9✔
501
  object,
9✔
502
  nestedField,
9✔
503
  required,
9✔
504
}: {
505
  path: string[]
506
  object: FormObject
507
  nestedField?: NestedField
508
  required?: boolean
509
}) => {
9✔
510
  const options = object.oneOf
9✔
511
  const [lastPathItem] = path.slice(-1)
9✔
512
  const description = object.description
9✔
513
  // Get the fieldValue when rendering a saved/submitted form
514
  // For e.g. obj.required is ["label", "url"] and nestedField is {id: "sth1", label: "sth2", url: "sth3"}
515
  // Get object from state and set default values if child of oneOf field has values
516
  // Fetching current object values from state rather than via getValues() method gets values on first call. The getValues method results in empty object on first call
517
  const currentObject = useAppSelector(state => state.currentObject) || {}
9!
518
  const values = currentObject[path.toString()]
9!
519
    ? currentObject
×
520
    : currentObject[
9✔
521
        Object.keys(currentObject)
9✔
522
          .filter(item => path.includes(item))
9✔
523
          .toString()
9✔
524
      ] || {}
9✔
525

526
  let fieldValue: string | number | undefined
9✔
527

528
  const flattenObject = (obj: { [x: string]: never }, prefix = "") =>
9✔
529
    Object.keys(obj).reduce(
×
530
      (acc, k) => {
×
531
        const pre = prefix.length ? prefix + "." : ""
×
532
        if (typeof obj[k] === "object") Object.assign(acc, flattenObject(obj[k], pre + k))
×
533
        else acc[pre + k] = obj[k]
×
534
        return acc
×
535
      },
×
536
      {} as Record<string, string>
×
537
    )
×
538

539
  if (Object.keys(values).length > 0 && lastPathItem !== "prevStepIndex") {
9!
540
    for (const item of path) {
×
541
      if (values[item]) {
×
542
        const itemValues = values[item]
×
543
        const parentPath = Object.keys(itemValues) ? Object.keys(itemValues).toString() : ""
×
544
        // Match key from currentObject to option property.
545
        // Field key can be deeply nested and therefore we need to have multiple cases for finding correct value.
546
        if (isNaN(Number(parentPath[0]))) {
×
547
          fieldValue = (
×
548
            options.find(option => option.properties[parentPath])
×
549
              ? // Eg. Sample > Sample Names > Sample Data Type
550
                options.find(option => option.properties[parentPath])
×
551
              : // Eg. Run > Run Type > Reference Alignment
552
                options.find(
×
553
                  option =>
×
554
                    option.properties[
×
555
                      Object.keys(flattenObject(itemValues))[0].split(".").slice(-1)[0]
×
556
                    ]
557
                )
×
558
          )?.title as string
×
559
        } else {
×
560
          // Eg. Experiment > Expected Base Call Table > Processing > Single Processing
561
          if (typeof itemValues === "string") {
×
562
            fieldValue = options.find(option => option.type === "string")?.title
×
563
          }
×
564
          // Eg. Experiment > Expected Base Call Table > Processing > Complex Processing
565
          else {
×
566
            const fieldKey = Object.keys(values[item][0])[0]
×
567
            fieldValue = options?.find(option => option.items?.properties[fieldKey])?.title
×
568
          }
×
569
        }
×
570
      }
×
571
    }
×
572
  }
×
573

574
  // Eg. Study > Study Links
575
  if (nestedField) {
9✔
576
    for (const option of options) {
2✔
577
      option.required.every(
4✔
578
        (val: string) =>
4✔
579
          nestedField.fieldValues && Object.keys(nestedField.fieldValues).includes(val)
4!
580
      )
4!
581
        ? (fieldValue = option.title)
×
582
        : ""
4✔
583
    }
4✔
584
  }
2✔
585

586
  // Special handling for Expected Base Call Table > Processing > Complex Processing > Pipeline > Pipe Section > Prev Step Index
587
  // Can be extended to other fields if needed
588
  const itemValue = get(currentObject, pathToName(path))
9✔
589

590
  if (itemValue) {
9!
591
    switch (lastPathItem) {
×
592
      case "prevStepIndex":
×
593
        {
×
594
          fieldValue = "String value"
×
595
        }
×
596
        break
×
597
    }
×
598
  }
×
599

600
  const name = pathToName(path)
9✔
601

602
  const label = object.title ?? lastPathItem
9!
603

604
  type ChildObject = { properties: Record<string, unknown>; required: boolean }
605

606
  const getChildObjects = (obj?: ChildObject) => {
9✔
607
    if (obj) {
×
608
      let childProps
×
609
      for (const key in obj) {
×
610
        // Check if object has nested "properties"
611
        if (key === "properties") {
×
612
          childProps = obj.properties
×
613
          const childPropsValues = Object.values(childProps)[0]
×
614
          if (Object.hasOwnProperty.call(childPropsValues, "properties")) {
×
615
            getChildObjects(childPropsValues as ChildObject)
×
616
          }
×
617
        }
×
618
      }
×
619

620
      const firstProp = childProps ? Object.keys(childProps)[0] : ""
×
621
      return { obj, firstProp }
×
622
    }
×
623
    return {}
×
624
  }
×
625

626
  const [field, setField] = React.useState(fieldValue)
9✔
627
  const clearForm = useAppSelector(state => state.clearForm)
9✔
628

629
  return (
9✔
630
    <ConnectForm>
9✔
631
      {({ errors, unregister, setValue, getValues, reset }: ConnectFormMethods) => {
9✔
632
        if (clearForm) {
9!
633
          // Clear the field and "clearForm" is true
634
          setField("")
×
635
          unregister(name)
×
636
        }
×
637

638
        const error = get(errors, name)
9✔
639
        // Option change handling
640
        const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
9✔
641
          const val = event.target.value
2✔
642
          setField(val)
2✔
643

644
          // Get fieldValues of current path
645
          const currentFieldValues = getValues(name)
2✔
646
          // Unregister if selecting "Complex Processing", "Null value" in Experiment form
647
          if (val === "Complex Processing") unregister(name)
2!
648
          if (val === "Null value") setValue(name, null)
2!
649
          // Remove previous values of the same path
650
          if (
2✔
651
            val !== "Complex Processing" &&
2✔
652
            val !== "Null value" &&
2✔
653
            currentFieldValues !== undefined
2✔
654
          ) {
2!
655
            reset({ ...getValues(), [name]: "" })
×
656
          }
×
657
        }
2✔
658

659
        // Selected option
660
        const selectedOption =
9✔
661
          options?.filter((option: { title: string }) => option.title === field)[0]?.properties ||
9✔
662
          {}
7✔
663
        const selectedOptionValues = Object.values(selectedOption)
9✔
664

665
        let childObject
9✔
666
        let requiredProp: string
9✔
667

668
        // If selectedOption has many nested "properties"
669
        if (
9✔
670
          selectedOptionValues.length > 0 &&
9✔
671
          Object.hasOwnProperty.call(selectedOptionValues[0], "properties")
2✔
672
        ) {
9!
673
          const { obj, firstProp } = getChildObjects(
×
674
            Object.values(selectedOption)[0] as ChildObject
×
675
          )
×
676
          childObject = obj
×
677
          requiredProp = firstProp || ""
×
678
        }
×
679
        // Else if selectedOption has no nested "properties"
680
        else {
9✔
681
          childObject = options?.filter((option: { title: string }) => option.title === field)[0]
9✔
682
          requiredProp = childObject?.required?.toString() || Object.keys(selectedOption)[0]
9✔
683
        }
9✔
684

685
        let child
9✔
686
        if (field) {
9✔
687
          const fieldObject = options?.filter(
2✔
688
            (option: { title: string }) => option.title === field
2✔
689
          )[0]
2✔
690
          child = traverseFields(
2✔
691
            { ...fieldObject, title: "" },
2✔
692
            path,
2✔
693
            required && requiredProp ? requiredProp.split(",") : [],
2!
694
            childObject?.required ? false : true,
2✔
695
            nestedField
2✔
696
          )
2✔
697
        } else child = null
9✔
698

699
        return (
9✔
700
          <>
9✔
701
            <BaselineDiv>
9✔
702
              <TextField
9✔
703
                name={name}
9✔
704
                label={label}
9✔
705
                id={name}
9✔
706
                role="listbox"
9✔
707
                value={field || ""}
9✔
708
                select
9✔
709
                SelectProps={{ native: true }}
9✔
710
                onChange={event => {
9✔
711
                  handleChange(event)
2✔
712
                }}
2✔
713
                error={!!error}
9✔
714
                helperText={error?.message}
9!
715
                required={required}
9✔
716
                inputProps={{ "data-testid": name }}
9✔
717
                sx={{ mb: "1rem" }}
9✔
718
              >
719
                <option aria-label="None" value="" disabled />
9✔
720
                {options?.map((optionObject: { title: string }) => {
9✔
721
                  const option = optionObject.title
18✔
722
                  return (
18✔
723
                    <option key={`${name}-${option}`} value={option}>
18✔
724
                      {option}
18✔
725
                    </option>
18✔
726
                  )
727
                })}
9✔
728
              </TextField>
9✔
729
              {description && (
9!
730
                <FieldTooltip
×
731
                  title={<DisplayDescription description={description} />}
×
732
                  placement="right"
×
733
                  arrow
×
734
                  describeChild
×
735
                >
736
                  <TooltipIcon />
×
737
                </FieldTooltip>
×
738
              )}
739
            </BaselineDiv>
9✔
740
            {child}
9✔
741
          </>
9✔
742
        )
743
      }}
9✔
744
    </ConnectForm>
9✔
745
  )
746
}
9✔
747

748
type FormFieldBaseProps = {
749
  name: string
750
  label: string
751
  required: boolean
752
}
753

754
type FormSelectFieldProps = FormFieldBaseProps & { options: string[] }
755

756
/*
757
 * FormTextField is the most usual type, rendered for strings, integers and numbers.
758
 */
759
const FormTextField = ({
1✔
760
  name,
59✔
761
  label,
59✔
762
  required,
59✔
763
  description,
59✔
764
  type = "string",
59✔
765
  nestedField,
59✔
766
}: FormFieldBaseProps & { description: string; type?: string; nestedField?: NestedField }) => {
59✔
767
  const objectType = useAppSelector(state => state.objectType)
59✔
768
  const isDOIForm = objectType === ObjectTypes.datacite
59✔
769
  const autocompleteField = useAppSelector(state => state.autocompleteField)
59✔
770
  const path = name.split(".")
59✔
771
  const [lastPathItem] = path.slice(-1)
59✔
772

773
  // Default Value of input
774
  const defaultValue = getDefaultValue(name, nestedField)
59✔
775

776
  // Case: DOI form - Affilation fields to be prefilled
777
  const prefilledFields = ["affiliationIdentifier", "schemeUri", "affiliationIdentifierScheme"]
59✔
778
  let watchAutocompleteFieldName = ""
59✔
779
  let prefilledValue: null | undefined = null
59✔
780

781
  // Case: DOI form - Check if it's <creators>'s and <contributors>'s FullName field in DOI form
782
  const isFullNameField =
59✔
783
    (path[0] === "creators" || path[0] === "contributors") && path[2] === "name"
59!
784
  let fullNameValue = "" // Case: DOI form - Creators and Contributors' FullName
59✔
785

786
  let disabled = false // boolean if inputValue is disabled
59✔
787

788
  // useWatch to watch any changes in form's fields
789
  const watchValues = useWatch()
59✔
790

791
  if (isDOIForm) {
59!
792
    watchAutocompleteFieldName =
×
793
      name.includes("affiliation") && prefilledFields.includes(lastPathItem)
×
794
        ? getPathName(path, "name")
×
795
        : ""
×
796

797
    // check changes of value of autocompleteField from watchValues
798
    prefilledValue = watchAutocompleteFieldName
×
799
      ? get(watchValues, watchAutocompleteFieldName)
×
800
      : null
×
801

802
    // If it's <creators>'s and <contributors>'s FullName field, watch the values of GivenName and FamilyName
803
    if (isFullNameField) {
×
804
      const givenName = getPathName(path, "givenName")
×
805
      const givenNameValue = get(watchValues, givenName) || ""
×
806
      const familyName = getPathName(path, "familyName")
×
807
      const familyNameValue =
×
808
        get(watchValues, familyName)?.length > 0 ? get(watchValues, familyName).concat(",") : ""
×
809
      // Return value for FullName field
810
      fullNameValue = `${familyNameValue}${givenNameValue}`
×
811
    }
×
812

813
    // Conditions to disable input field: disable editing option if the field is rendered as prefilled
814
    disabled =
×
815
      (prefilledFields.includes(lastPathItem) && prefilledValue !== null) ||
×
816
      isFullNameField ||
×
817
      (defaultValue !== "" && name.includes("formats"))
×
818
  }
×
819

820
  /*
821
   * Handle DOI form values
822
   */
823
  const { setValue, getValues } = useFormContext()
59✔
824

825
  // Check value of current name path
826
  const val = getValues(name)
59✔
827

828
  // Set values for Affiliations' fields if autocompleteField exists
829
  React.useEffect(() => {
59✔
830
    if (prefilledValue && !val && isDOIForm) {
27!
831
      lastPathItem === prefilledFields[0] ? setValue(name, autocompleteField) : null
×
832
      lastPathItem === prefilledFields[1] ? setValue(name, "https://ror.org") : null
×
833
      lastPathItem === prefilledFields[2] ? setValue(name, "ROR") : null
×
834
    }
×
835
  }, [autocompleteField, prefilledValue])
59✔
836

837
  // Remove values for Affiliations' <location of affiliation identifier> field if autocompleteField is deleted
838
  React.useEffect(() => {
59✔
839
    if (prefilledValue === undefined && val && lastPathItem === prefilledFields[0] && isDOIForm)
27!
840
      setValue(name, "")
27!
841
  }, [prefilledValue])
59✔
842

843
  React.useEffect(() => {
59✔
844
    // Set value of <creators>'s and <contributors>'s FullName field with the fullNameValue from givenName and familyName
845
    if (isFullNameField && fullNameValue) setValue(name, fullNameValue)
27!
846
  }, [isFullNameField, fullNameValue])
59✔
847

848
  return (
59✔
849
    <ConnectForm>
59✔
850
      {({ control }: ConnectFormMethods) => {
59✔
851
        const multiLineRowIdentifiers = ["abstract", "description", "policy text"]
59✔
852

853
        return (
59✔
854
          <Controller
59✔
855
            render={({ field, fieldState: { error } }) => {
59✔
856
              const inputValue =
60✔
857
                (watchAutocompleteFieldName && typeof val !== "object" && val) ||
60!
858
                fullNameValue ||
60✔
859
                (typeof field.value !== "object" && field.value) ||
60✔
860
                ""
57✔
861

862
              const handleChange = (e: { target: { value: string | number } }) => {
60✔
863
                const { value } = e.target
2✔
864
                const parsedValue =
2✔
865
                  type === "string" && typeof value === "number" ? value.toString() : value
2!
866
                field.onChange(parsedValue) // Helps with Cypress change detection
2✔
867
                setValue(name, parsedValue) // Enables update of nested fields, eg. DAC contact
2✔
868
              }
2✔
869

870
              return (
60✔
871
                <BaselineDiv>
60✔
872
                  <TextField
60✔
873
                    {...field}
60✔
874
                    inputProps={{ "data-testid": name }}
60✔
875
                    label={label}
60✔
876
                    id={name}
60✔
877
                    role="textbox"
60✔
878
                    error={!!error}
60✔
879
                    helperText={error?.message}
60✔
880
                    required={required}
60✔
881
                    type={type}
60✔
882
                    multiline={multiLineRowIdentifiers.some(value =>
60✔
883
                      label.toLowerCase().includes(value)
169✔
884
                    )}
60✔
885
                    rows={5}
60✔
886
                    value={inputValue}
60✔
887
                    onChange={handleChange}
60✔
888
                    disabled={disabled}
60✔
889
                    sx={{ mb: "1rem" }}
60✔
890
                  />
891
                  {description && (
60✔
892
                    <FieldTooltip
33✔
893
                      title={<DisplayDescription description={description} />}
33✔
894
                      placement="right"
33✔
895
                      arrow
33✔
896
                      describeChild
33✔
897
                    >
898
                      <TooltipIcon />
33✔
899
                    </FieldTooltip>
33✔
900
                  )}
901
                </BaselineDiv>
60✔
902
              )
903
            }}
60✔
904
            name={name}
59✔
905
            control={control}
59✔
906
            defaultValue={defaultValue}
59✔
907
            rules={{ required: required }}
59✔
908
          />
909
        )
910
      }}
59✔
911
    </ConnectForm>
59✔
912
  )
913
}
59✔
914

915
/*
916
 * FormSelectField is rendered for selection from options where it's possible to choose many options
917
 */
918

919
const FormSelectField = ({
1✔
920
  name,
6✔
921
  label,
6✔
922
  required,
6✔
923
  options,
6✔
924
  description,
6✔
925
}: FormSelectFieldProps & { description: string }) => (
6✔
926
  <ConnectForm>
6✔
927
    {({ control }: ConnectFormMethods) => {
6✔
928
      return (
6✔
929
        <Controller
6✔
930
          name={name}
6✔
931
          control={control}
6✔
932
          render={({ field, fieldState: { error } }) => {
6✔
933
            return (
7✔
934
              <BaselineDiv>
7✔
935
                <TextField
7✔
936
                  {...field}
7✔
937
                  label={label}
7✔
938
                  id={name}
7✔
939
                  value={field.value || ""}
7✔
940
                  error={!!error}
7✔
941
                  helperText={error?.message}
7!
942
                  required={required}
7✔
943
                  select
7✔
944
                  SelectProps={{ native: true }}
7✔
945
                  onChange={e => {
7✔
946
                    let val = e.target.value
×
947
                    // Case: linkingAccessionIds which include "AccessionId + Form's title", we need to return only accessionId as value
948
                    if (val?.includes("Title")) {
×
949
                      const hyphenIndex = val.indexOf("-")
×
950
                      val = val.slice(0, hyphenIndex - 1)
×
951
                    }
×
952
                    return field.onChange(val)
×
953
                  }}
×
954
                  inputProps={{ "data-testid": name }}
7✔
955
                  sx={{ mb: "1rem" }}
7✔
956
                >
957
                  <option aria-label="None" value="" disabled />
7✔
958
                  {options.map(option => (
7✔
959
                    <option key={`${name}-${option}`} value={option} data-testid={`${name}-option`}>
21✔
960
                      {option}
21✔
961
                    </option>
21✔
962
                  ))}
7✔
963
                </TextField>
7✔
964
                {description && (
7!
965
                  <FieldTooltip
×
966
                    title={<DisplayDescription description={description} />}
×
967
                    placement="right"
×
968
                    arrow
×
969
                    describeChild
×
970
                  >
971
                    <TooltipIcon />
×
972
                  </FieldTooltip>
×
973
                )}
974
              </BaselineDiv>
7✔
975
            )
976
          }}
7✔
977
        />
978
      )
979
    }}
6✔
980
  </ConnectForm>
6✔
981
)
982

983
/*
984
 * FormDatePicker used for selecting date or date rage in DOI form
985
 */
986

987
const StyledDatePicker = styled(DatePicker)(({ theme }) => ({
1✔
NEW
988
  "& .MuiIconButton-root": {
×
NEW
989
    color: theme.palette.primary.main,
×
NEW
990
    "&:hover": {
×
NEW
991
      backgroundColor: theme.palette.action.hover,
×
NEW
992
    },
×
NEW
993
  },
×
994
}))
1✔
995

996
const FormDatePicker = ({
1✔
997
  name,
×
998
  required,
×
999
  description,
×
1000
}: FormFieldBaseProps & { description: string }) => {
×
NEW
1001
  const { t } = useTranslation()
×
NEW
1002
  const { getValues } = useFormContext()
×
NEW
1003
  const defaultValue = getValues(name) || ""
×
1004

NEW
1005
  const getStartEndDates = (date: string) => {
×
NEW
1006
    const [startInput, endInput] = date?.split("/")
×
NEW
1007
    if (startInput && endInput) return [moment(startInput), moment(endInput)]
×
NEW
1008
    else if (startInput) return [moment(startInput), null]
×
NEW
1009
    else if (endInput) return [null, moment(endInput)]
×
NEW
1010
    else return [null, null]
×
NEW
1011
  }
×
1012

NEW
1013
  const [startDate, setStartDate] = React.useState<moment.Moment | null>(
×
NEW
1014
    getStartEndDates(defaultValue)[0]
×
NEW
1015
  )
×
NEW
1016
  const [endDate, setEndDate] = React.useState<moment.Moment | null>(
×
NEW
1017
    getStartEndDates(defaultValue)[1]
×
NEW
1018
  )
×
NEW
1019
  const [startError, setStartError] = React.useState<string | null>(null)
×
NEW
1020
  const [endError, setEndError] = React.useState<string | null>(null)
×
NEW
1021
  const clearForm = useAppSelector(state => state.clearForm)
×
1022

NEW
1023
  React.useEffect(() => {
×
NEW
1024
    if (clearForm) {
×
NEW
1025
      setStartDate(null)
×
NEW
1026
      setEndDate(null)
×
NEW
1027
    }
×
NEW
1028
  }, [clearForm])
×
1029

NEW
1030
  const format = "YYYY-MM-DD"
×
NEW
1031
  const formatDate = (date: moment.Moment) => moment(date).format(format)
×
1032

NEW
1033
  const makeValidDateString = (start: moment.Moment | null, end: moment.Moment | null) => {
×
NEW
1034
    let dateStr = ""
×
NEW
1035
    if (start && start?.isValid()) {
×
NEW
1036
      dateStr = formatDate(start)
×
NEW
1037
      if (end && end?.isValid() && formatDate(end) !== dateStr) {
×
NEW
1038
        dateStr += `/${formatDate(end)}`
×
NEW
1039
      }
×
NEW
1040
    }
×
NEW
1041
    return dateStr
×
NEW
1042
  }
×
1043

NEW
1044
  return (
×
NEW
1045
    <ConnectForm>
×
NEW
1046
      {({ setValue }: ConnectFormMethods) => {
×
NEW
1047
        const handleChangeStartDate = (newValue: moment.Moment | null) => {
×
NEW
1048
          setStartDate(newValue)
×
NEW
1049
          setValue(name, makeValidDateString(newValue, endDate))
×
UNCOV
1050
        }
×
1051

NEW
1052
        const handleChangeEndDate = (newValue: moment.Moment | null) => {
×
NEW
1053
          setEndDate(newValue)
×
NEW
1054
          setValue(name, makeValidDateString(startDate, newValue))
×
UNCOV
1055
        }
×
1056

NEW
1057
        return (
×
NEW
1058
          <LocalizationProvider dateAdapter={AdapterMoment}>
×
1059
            <Box
×
1060
              sx={{
×
1061
                width: "95%",
×
1062
                display: "flex",
×
1063
                flexDirection: "row",
×
NEW
1064
                alignItems: "baseline",
×
NEW
1065
                marginBottom: "1rem",
×
UNCOV
1066
              }}
×
1067
            >
NEW
1068
              <StyledDatePicker
×
NEW
1069
                label={t("datacite.startDate")}
×
NEW
1070
                value={startDate}
×
NEW
1071
                format={format}
×
NEW
1072
                onChange={handleChangeStartDate}
×
NEW
1073
                onError={setStartError}
×
NEW
1074
                shouldDisableDate={day => !!endDate && moment(day).isAfter(endDate)}
×
NEW
1075
                slotProps={{
×
NEW
1076
                  field: { clearable: true },
×
NEW
1077
                  textField: {
×
NEW
1078
                    required,
×
NEW
1079
                    error: !!startError,
×
NEW
1080
                    helperText: startError
×
NEW
1081
                      ? startError === "shouldDisableDate"
×
NEW
1082
                        ? t("datacite.error.range")
×
NEW
1083
                        : t("datacite.error.date")
×
NEW
1084
                      : "",
×
NEW
1085
                  },
×
NEW
1086
                  nextIconButton: { color: "primary" },
×
NEW
1087
                  previousIconButton: { color: "primary" },
×
NEW
1088
                  switchViewIcon: { color: "primary" },
×
NEW
1089
                }}
×
1090
              />
NEW
1091
              <span>–</span>
×
NEW
1092
              <StyledDatePicker
×
NEW
1093
                label={t("datacite.endDate")}
×
NEW
1094
                value={endDate}
×
NEW
1095
                format={format}
×
NEW
1096
                onChange={handleChangeEndDate}
×
NEW
1097
                onError={setEndError}
×
NEW
1098
                shouldDisableDate={day => !!startDate && moment(day).isBefore(startDate)}
×
NEW
1099
                slotProps={{
×
NEW
1100
                  field: { clearable: true },
×
NEW
1101
                  textField: {
×
NEW
1102
                    error: !!endError,
×
NEW
1103
                    helperText: endError
×
NEW
1104
                      ? endError === "shouldDisableDate"
×
NEW
1105
                        ? t("datacite.error.range")
×
NEW
1106
                        : t("datacite.error.date")
×
NEW
1107
                      : "",
×
NEW
1108
                  },
×
NEW
1109
                  nextIconButton: { color: "primary" },
×
NEW
1110
                  previousIconButton: { color: "primary" },
×
NEW
1111
                  switchViewIcon: { color: "primary" },
×
NEW
1112
                }}
×
1113
              />
NEW
1114
              {description && (
×
NEW
1115
                <FieldTooltip
×
NEW
1116
                  title={<DisplayDescription description={description} />}
×
NEW
1117
                  placement="right"
×
NEW
1118
                  arrow
×
NEW
1119
                  describeChild
×
1120
                >
NEW
1121
                  <TooltipIcon />
×
NEW
1122
                </FieldTooltip>
×
1123
              )}
1124
            </Box>
×
1125
          </LocalizationProvider>
×
1126
        )
UNCOV
1127
      }}
×
1128
    </ConnectForm>
×
1129
  )
1130
}
×
1131

1132
/*
1133
 * FormAutocompleteField uses ROR API to fetch organisations
1134
 */
1135
type RORItem = {
1136
  name: string
1137
  id: string
1138
}
1139

1140
const StyledAutocomplete = styled(Autocomplete)(() => ({
1✔
1141
  flex: "auto",
8✔
1142
  alignSelf: "flex-start",
8✔
1143
  "& + svg": {
8✔
1144
    marginTop: 1,
8✔
1145
  },
8✔
1146
})) as typeof Autocomplete
1✔
1147

1148
const FormAutocompleteField = ({
1✔
1149
  name,
9✔
1150
  label,
9✔
1151
  required,
9✔
1152
  description,
9✔
1153
}: FormFieldBaseProps & { description: string }) => {
9✔
1154
  const dispatch = useAppDispatch()
9✔
1155

1156
  const { setValue, getValues } = useFormContext()
9✔
1157

1158
  const defaultValue = getValues(name) || ""
9✔
1159
  const [selection, setSelection] = React.useState<RORItem | null>(null)
9✔
1160
  const [open, setOpen] = React.useState(false)
9✔
1161
  const [options, setOptions] = React.useState([])
9✔
1162
  const [inputValue, setInputValue] = React.useState("")
9✔
1163
  const [loading, setLoading] = React.useState(false)
9✔
1164
  const clearForm = useAppSelector(state => state.clearForm)
9✔
1165

1166
  const fetchOrganisations = async (searchTerm: string) => {
9✔
1167
    // Check if searchTerm includes non-word char, for e.g. "(", ")", "-" because the api does not work with those chars
1168
    const isContainingNonWordChar = searchTerm.match(/\W/g)
1✔
1169
    const response =
1✔
1170
      isContainingNonWordChar === null ? await rorAPIService.getOrganisations(searchTerm) : null
1!
1171

1172
    if (response) setLoading(false)
1✔
1173

1174
    if (response?.ok) {
1✔
1175
      const mappedOrganisations = response.data.items.map((org: RORItem) => ({
1✔
1176
        name: org.name,
1✔
1177
        id: org.id,
1✔
1178
      }))
1✔
1179
      setOptions(mappedOrganisations)
1✔
1180
    }
1✔
1181
  }
1✔
1182

1183
  const debouncedSearch = debounce((newInput: string) => {
9✔
1184
    if (newInput.length > 0) fetchOrganisations(newInput)
1✔
1185
  }, 150)
9✔
1186

1187
  React.useEffect(() => {
9✔
1188
    let active = true
4✔
1189

1190
    if (inputValue === "") {
4✔
1191
      setOptions([])
2✔
1192
      return undefined
2✔
1193
    }
2✔
1194

1195
    if (active && open) {
4✔
1196
      setLoading(true)
1✔
1197
      debouncedSearch(inputValue)
1✔
1198
    }
1✔
1199

1200
    return () => {
2✔
1201
      active = false
2✔
1202
      setLoading(false)
2✔
1203
    }
2✔
1204
  }, [selection, inputValue])
9✔
1205

1206
  React.useEffect(() => {
9✔
1207
    if (clearForm) {
2!
1208
      setSelection(null)
×
1209
      setInputValue("")
×
1210
    }
×
1211
  }, [clearForm])
9✔
1212

1213
  return (
9✔
1214
    <ConnectForm>
9✔
1215
      {({ errors, control }: ConnectFormMethods) => {
9✔
1216
        const error = get(errors, name)
8✔
1217

1218
        const handleAutocompleteValueChange = (_event: unknown, option: RORItem) => {
8✔
1219
          setSelection(option)
1✔
1220
          setValue(name, option?.name)
1✔
1221
          option?.id ? dispatch(setAutocompleteField(option.id)) : null
1!
1222
        }
1✔
1223

1224
        const handleInputChange = (_event: unknown, newInputValue: string, reason: string) => {
8✔
1225
          setInputValue(newInputValue)
3✔
1226
          switch (reason) {
3✔
1227
            case "input":
3✔
1228
            case "clear":
3✔
1229
              setInputValue(newInputValue)
1✔
1230
              break
1✔
1231
            case "reset":
3✔
1232
              selection ? setInputValue(selection?.name) : null
1!
1233
              break
1✔
1234
            default:
3✔
1235
              break
1✔
1236
          }
3✔
1237
        }
3✔
1238

1239
        return (
8✔
1240
          <Controller
8✔
1241
            render={() => (
8✔
1242
              <StyledAutocomplete
8✔
1243
                freeSolo
8✔
1244
                open={open}
8✔
1245
                onOpen={() => {
8✔
1246
                  setOpen(true)
1✔
1247
                }}
1✔
1248
                onClose={() => {
8✔
1249
                  setOpen(false)
1✔
1250
                }}
1✔
1251
                options={options}
8✔
1252
                getOptionLabel={option => option.name || ""}
8✔
1253
                disableClearable={inputValue.length === 0}
8✔
1254
                renderInput={params => (
8✔
1255
                  <BaselineDiv>
8✔
1256
                    <TextField
8✔
1257
                      {...params}
8✔
1258
                      label={label}
8✔
1259
                      id={name}
8✔
1260
                      name={name}
8✔
1261
                      variant="outlined"
8✔
1262
                      error={!!error}
8✔
1263
                      required={required}
8✔
1264
                      InputProps={{
8✔
1265
                        ...params.InputProps,
8✔
1266
                        endAdornment: (
8✔
1267
                          <React.Fragment>
8✔
1268
                            {loading ? <CircularProgress color="inherit" size={20} /> : null}
8✔
1269
                            {params.InputProps.endAdornment}
8✔
1270
                          </React.Fragment>
8✔
1271
                        ),
1272
                      }}
8✔
1273
                      inputProps={{ ...params.inputProps, "data-testid": `${name}-inputField` }}
8✔
1274
                      sx={[
8✔
1275
                        {
8✔
1276
                          "&.MuiAutocomplete-endAdornment": {
8✔
1277
                            top: 0,
8✔
1278
                          },
8✔
1279
                        },
8✔
1280
                      ]}
8✔
1281
                    />
1282
                    {description && (
8!
1283
                      <FieldTooltip
×
1284
                        title={
×
1285
                          <DisplayDescription description={description}>
×
1286
                            <>
×
1287
                              <br />
×
1288
                              {"Organisations provided by "}
×
1289
                              <a href="https://ror.org/" target="_blank" rel="noreferrer">
×
1290
                                {"ror.org"}
×
1291
                                <LaunchIcon sx={{ fontSize: "1rem", mb: -1 }} />
×
1292
                              </a>
×
1293
                            </>
×
1294
                          </DisplayDescription>
×
1295
                        }
1296
                        placement="right"
×
1297
                        arrow
×
1298
                        describeChild
×
1299
                      >
1300
                        <TooltipIcon />
×
1301
                      </FieldTooltip>
×
1302
                    )}
1303
                  </BaselineDiv>
8✔
1304
                )}
1305
                onChange={handleAutocompleteValueChange}
8✔
1306
                onInputChange={handleInputChange}
8✔
1307
                value={defaultValue}
8✔
1308
                inputValue={inputValue || defaultValue}
8✔
1309
              />
1310
            )}
1311
            name={name}
8✔
1312
            control={control}
8✔
1313
          />
1314
        )
1315
      }}
8✔
1316
    </ConnectForm>
9✔
1317
  )
1318
}
9✔
1319

1320
const ValidationTagField = styled(TextField)(({ theme }) => ({
1✔
1321
  "& .MuiOutlinedInput-root.MuiInputBase-root": { flexWrap: "wrap" },
×
1322
  "& input": { flex: 1, minWidth: "2rem" },
×
1323
  "& label": { color: theme.palette.primary.main },
×
1324
  "& .MuiOutlinedInput-notchedOutline, div:hover .MuiOutlinedInput-notchedOutline":
×
1325
    highlightStyle(theme),
×
1326
}))
1✔
1327

1328
const FormTagField = ({
1✔
1329
  name,
×
1330
  label,
×
1331
  required,
×
1332
  description,
×
1333
}: FormFieldBaseProps & { description: string }) => {
×
1334
  // Check initialValues of the tag field and define the initialTags for rendering
1335
  const initialValues = useWatch({ name })
×
1336
  const initialTags: Array<string> = initialValues ? initialValues.split(",") : []
×
1337

1338
  const [inputValue, setInputValue] = React.useState("")
×
1339
  const [tags, setTags] = React.useState<Array<string>>(initialTags)
×
1340

1341
  const handleInputChange = e => {
×
1342
    setInputValue(e.target.value)
×
1343
  }
×
1344

1345
  const clearForm = useAppSelector(state => state.clearForm)
×
1346

1347
  React.useEffect(() => {
×
1348
    if (clearForm) {
×
1349
      setTags([])
×
1350
      setInputValue("")
×
1351
    }
×
1352
  }, [clearForm])
×
1353

1354
  return (
×
1355
    <ConnectForm>
×
1356
      {({ control }: ConnectFormMethods) => {
×
1357
        const defaultValue = initialValues || ""
×
1358
        return (
×
1359
          <Controller
×
1360
            name={name}
×
1361
            control={control}
×
1362
            defaultValue={defaultValue}
×
1363
            render={({ field }) => {
×
1364
              const handleKeywordAsTag = (keyword: string) => {
×
1365
                // newTags with unique values
1366
                const newTags = !tags.includes(keyword) ? [...tags, keyword] : tags
×
1367
                setTags(newTags)
×
1368
                setInputValue("")
×
1369
                // Convert tags to string for hidden registered input's values
1370
                field.onChange(newTags.join(","))
×
1371
              }
×
1372

1373
              const handleKeyDown = e => {
×
1374
                const { key } = e
×
1375
                const trimmedInput = inputValue.trim()
×
1376
                // Convert to tags if users press "," OR "Enter"
1377
                if ((key === "," || key === "Enter") && trimmedInput.length > 0) {
×
1378
                  e.preventDefault()
×
1379
                  handleKeywordAsTag(trimmedInput)
×
1380
                }
×
1381
              }
×
1382

1383
              // Convert to tags when user clicks outside of input field
1384
              const handleOnBlur = () => {
×
1385
                const trimmedInput = inputValue.trim()
×
1386
                if (trimmedInput.length > 0) {
×
1387
                  handleKeywordAsTag(trimmedInput)
×
1388
                }
×
1389
              }
×
1390

1391
              const handleTagDelete = item => () => {
×
1392
                const newTags = tags.filter(tag => tag !== item)
×
1393
                setTags(newTags)
×
1394
                field.onChange(newTags.join(","))
×
1395
              }
×
1396

1397
              return (
×
1398
                <BaselineDiv>
×
1399
                  <input
×
1400
                    {...field}
×
1401
                    required={required}
×
1402
                    style={{ width: 0, opacity: 0, transform: "translate(8rem, 2rem)" }}
×
1403
                  />
1404
                  <ValidationTagField
×
1405
                    InputProps={{
×
1406
                      startAdornment:
×
1407
                        tags.length > 0
×
1408
                          ? tags.map(item => (
×
1409
                              <Chip
×
1410
                                key={item}
×
1411
                                tabIndex={-1}
×
1412
                                label={item}
×
1413
                                onDelete={handleTagDelete(item)}
×
1414
                                color="primary"
×
1415
                                deleteIcon={<ClearIcon fontSize="small" />}
×
1416
                                data-testid={item}
×
1417
                                sx={{ fontSize: "1.4rem", m: "0.5rem" }}
×
1418
                              />
1419
                            ))
×
1420
                          : null,
×
1421
                    }}
×
1422
                    inputProps={{ "data-testid": name }}
×
1423
                    label={label}
×
1424
                    id={name}
×
1425
                    value={inputValue}
×
1426
                    onChange={handleInputChange}
×
1427
                    onKeyDown={handleKeyDown}
×
1428
                    onBlur={handleOnBlur}
×
1429
                  />
1430

1431
                  {description && (
×
1432
                    <FieldTooltip
×
1433
                      title={<DisplayDescription description={description} />}
×
1434
                      placement="right"
×
1435
                      arrow
×
1436
                      describeChild
×
1437
                    >
1438
                      <TooltipIcon />
×
1439
                    </FieldTooltip>
×
1440
                  )}
1441
                </BaselineDiv>
×
1442
              )
1443
            }}
×
1444
          />
1445
        )
1446
      }}
×
1447
    </ConnectForm>
×
1448
  )
1449
}
×
1450

1451
/*
1452
 * Highlight required Checkbox
1453
 */
1454
const ValidationFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
1✔
1455
  label: {
6✔
1456
    "& span": { color: theme.palette.primary.main },
6✔
1457
  },
6✔
1458
}))
1✔
1459

1460
const FormBooleanField = ({
1✔
1461
  name,
6✔
1462
  label,
6✔
1463
  required,
6✔
1464
  description,
6✔
1465
}: FormFieldBaseProps & { description: string }) => {
6✔
1466
  return (
6✔
1467
    <ConnectForm>
6✔
1468
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1469
        const error = get(errors, name)
6✔
1470

1471
        const { ref, ...rest } = register(name)
6✔
1472
        // DAC form: "values" of MainContact checkbox
1473
        const values = getValues(name)
6✔
1474
        return (
6✔
1475
          <Box display="inline" px={1}>
6✔
1476
            <FormControl error={!!error} required={required}>
6✔
1477
              <FormGroup>
6✔
1478
                <BaselineDiv>
6✔
1479
                  <ValidationFormControlLabel
6✔
1480
                    control={
6✔
1481
                      <Checkbox
6✔
1482
                        id={name}
6✔
1483
                        {...rest}
6✔
1484
                        name={name}
6✔
1485
                        required={required}
6✔
1486
                        inputRef={ref}
6✔
1487
                        color="primary"
6✔
1488
                        checked={values || false}
6✔
1489
                        inputProps={
6✔
1490
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
6✔
1491
                        }
6✔
1492
                      />
1493
                    }
1494
                    label={
6✔
1495
                      <label>
6✔
1496
                        {label}
6✔
1497
                        <span>{required ? ` * ` : ""}</span>
6!
1498
                      </label>
6✔
1499
                    }
6✔
1500
                  />
1501
                  {description && (
6!
1502
                    <FieldTooltip
×
1503
                      title={<DisplayDescription description={description} />}
×
1504
                      placement="right"
×
1505
                      arrow
×
1506
                      describeChild
×
1507
                    >
1508
                      <TooltipIcon />
×
1509
                    </FieldTooltip>
×
1510
                  )}
1511
                </BaselineDiv>
6✔
1512

1513
                <FormHelperText>{error?.message}</FormHelperText>
6!
1514
              </FormGroup>
6✔
1515
            </FormControl>
6✔
1516
          </Box>
6✔
1517
        )
1518
      }}
6✔
1519
    </ConnectForm>
6✔
1520
  )
1521
}
6✔
1522

1523
const FormCheckBoxArray = ({
1✔
1524
  name,
6✔
1525
  label,
6✔
1526
  required,
6✔
1527
  options,
6✔
1528
  description,
6✔
1529
}: FormSelectFieldProps & { description: string }) => (
6✔
1530
  <Box px={1}>
6✔
1531
    <p>{label} Check from following options</p>
6✔
1532
    <ConnectForm>
6✔
1533
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1534
        const values = getValues()[name]
6✔
1535

1536
        const error = get(errors, name)
6✔
1537

1538
        const { ref, ...rest } = register(name)
6✔
1539

1540
        return (
6✔
1541
          <FormControl error={!!error} required={required}>
6✔
1542
            <FormGroup aria-labelledby={name}>
6✔
1543
              {options.map(option => (
6✔
1544
                <React.Fragment key={option}>
12✔
1545
                  <FormControlLabel
12✔
1546
                    key={option}
12✔
1547
                    control={
12✔
1548
                      <Checkbox
12✔
1549
                        {...rest}
12✔
1550
                        inputRef={ref}
12✔
1551
                        name={name}
12✔
1552
                        value={option}
12✔
1553
                        checked={values && values?.includes(option) ? true : false}
12!
1554
                        color="primary"
12✔
1555
                        defaultValue=""
12✔
1556
                        inputProps={
12✔
1557
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
12✔
1558
                        }
12✔
1559
                      />
1560
                    }
1561
                    label={option}
12✔
1562
                  />
1563
                  {description && (
12!
1564
                    <FieldTooltip
×
1565
                      title={<DisplayDescription description={description} />}
×
1566
                      placement="right"
×
1567
                      arrow
×
1568
                      describeChild
×
1569
                    >
1570
                      <TooltipIcon />
×
1571
                    </FieldTooltip>
×
1572
                  )}
1573
                </React.Fragment>
12✔
1574
              ))}
6✔
1575
              <FormHelperText>{error?.message}</FormHelperText>
6!
1576
            </FormGroup>
6✔
1577
          </FormControl>
6✔
1578
        )
1579
      }}
6✔
1580
    </ConnectForm>
6✔
1581
  </Box>
6✔
1582
)
1583

1584
type FormArrayProps = {
1585
  object: FormObject
1586
  path: Array<string>
1587
  required: boolean
1588
}
1589

1590
const FormArrayTitle = styled(Paper, { shouldForwardProp: prop => prop !== "level" })<{
1✔
1591
  level: number
1592
}>(({ theme, level }) => ({
1✔
1593
  display: "flex",
8✔
1594
  flexDirection: "column",
8✔
1595
  justifyContent: "start",
8✔
1596
  alignItems: "start",
8✔
1597
  backgroundColor: level < 2 ? theme.palette.primary.light : theme.palette.common.white,
8!
1598
  height: "100%",
8✔
1599
  marginLeft: level < 2 ? "5rem" : 0,
8!
1600
  marginRight: "3rem",
8✔
1601
  padding: level === 1 ? "2rem" : 0,
8!
1602
}))
1✔
1603

1604
const FormArrayChildrenTitle = styled(Paper)(() => ({
1✔
1605
  width: "70%",
1✔
1606
  display: "inline-block",
1✔
1607
  marginBottom: "1rem",
1✔
1608
  paddingLeft: "1rem",
1✔
1609
  paddingTop: "1rem",
1✔
1610
}))
1✔
1611

1612
/*
1613
 * FormArray is rendered for arrays of objects. User is given option to choose how many objects to add to array.
1614
 */
1615
const FormArray = ({
1✔
1616
  object,
8✔
1617
  path,
8✔
1618
  required,
8✔
1619
  description,
8✔
1620
}: FormArrayProps & { description: string }) => {
8✔
1621
  const name = pathToName(path)
8✔
1622
  const [lastPathItem] = path.slice(-1)
8✔
1623
  const level = path.length
8✔
1624
  const label = object.title ?? lastPathItem
8!
1625

1626
  // Get currentObject and the values of current field
1627
  const currentObject = useAppSelector(state => state.currentObject) || {}
8!
1628
  const fileTypes = useAppSelector(state => state.fileTypes)
8✔
1629

1630
  const fieldValues = get(currentObject, name)
8✔
1631

1632
  const items = traverseValues(object.items) as FormObject
8✔
1633

1634
  const { control } = useForm()
8✔
1635

1636
  const {
8✔
1637
    register,
8✔
1638
    getValues,
8✔
1639
    setValue,
8✔
1640
    formState: { isSubmitted },
8✔
1641
    clearErrors,
8✔
1642
  } = useFormContext()
8✔
1643

1644
  const { fields, append, remove } = useFieldArray({ control, name })
8✔
1645

1646
  const [isValid, setValid] = React.useState(false)
8✔
1647
  const [formFields, setFormFields] = React.useState<Record<"id", string>[] | null>(null)
8✔
1648

1649
  // Append the correct values to the equivalent fields when editing form
1650
  // This applies for the case: "fields" does not get the correct data (empty array) although there are values in the fields
1651
  // E.g. Study > StudyLinks or Experiment > Expected Base Call Table
1652
  // Append only once when form is populated
1653
  React.useEffect(() => {
8✔
1654
    if (
4✔
1655
      fieldValues?.length > 0 &&
4!
1656
      fields?.length === 0 &&
×
1657
      typeof fieldValues === "object" &&
×
1658
      !formFields
×
1659
    ) {
4!
1660
      const fieldsArray: Record<string, unknown>[] = []
×
1661
      for (let i = 0; i < fieldValues.length; i += 1) {
×
1662
        fieldsArray.push({ fieldValues: fieldValues[i] })
×
1663
      }
×
1664
      append(fieldsArray)
×
1665
    }
×
1666
    // Create initial fields when editing object
1667
    setFormFields(fields)
4✔
1668
  }, [fields])
8✔
1669

1670
  // Get unique fileTypes from submitted fileTypes
1671
  const uniqueFileTypes = uniq(
8✔
1672
    flatten(fileTypes?.map((obj: { fileTypes: string[] }) => obj.fileTypes))
8✔
1673
  )
8✔
1674

1675
  React.useEffect(() => {
8✔
1676
    // Append fileType to formats' field
1677
    if (name === "formats") {
3!
1678
      for (let i = 0; i < uniqueFileTypes.length; i += 1) {
×
1679
        append({ formats: uniqueFileTypes[i] })
×
1680
      }
×
1681
    }
×
1682
  }, [uniqueFileTypes.length])
8✔
1683

1684
  // Clear required field array error and append
1685
  const handleAppend = () => {
8✔
1686
    setValid(true)
1✔
1687
    clearErrors([name])
1✔
1688
    append({})
1✔
1689
  }
1✔
1690

1691
  const handleRemove = (index: number) => {
8✔
1692
    // Re-register hidden input if all field arrays are removed
1693
    if (index === 0) setValid(false)
×
1694
    // Set the correct values according to the name path when removing a field
1695
    const values = getValues(name)
×
1696
    const filteredValues = values?.filter((_val: unknown, ind: number) => ind !== index)
×
1697
    setValue(name, filteredValues)
×
1698
    setFormFields(filteredValues)
×
1699
    remove(index)
×
1700
  }
×
1701

1702
  return (
8✔
1703
    <Grid
8✔
1704
      container
8✔
1705
      key={`${name}-array`}
8✔
1706
      aria-labelledby={name}
8✔
1707
      data-testid={name}
8✔
1708
      direction={level < 2 ? "row" : "column"}
8!
1709
      sx={{ mb: level === 1 ? "3rem" : 0 }}
8!
1710
    >
1711
      <Grid size={{ xs: 12, md: 4 }}>
8✔
1712
        {
1713
          <FormArrayTitle square={true} elevation={0} level={level}>
8✔
1714
            {required && !isValid && (
8!
1715
              <input hidden={true} value="form-array-required" {...register(name)} />
×
1716
            )}
1717
            <Typography
8✔
1718
              key={`${name}-header`}
8✔
1719
              variant={"subtitle1"}
8✔
1720
              data-testid={name}
8✔
1721
              role="heading"
8✔
1722
              color="secondary"
8✔
1723
            >
1724
              {label}
8✔
1725
              {required ? "*" : null}
8!
1726
              {required && !isValid && formFields?.length === 0 && isSubmitted && (
8!
1727
                <span>
×
1728
                  <FormControl error>
×
1729
                    <FormHelperText>must have at least 1 item</FormHelperText>
×
1730
                  </FormControl>
×
1731
                </span>
×
1732
              )}
1733
              {description && (
8!
1734
                <FieldTooltip
×
1735
                  title={<DisplayDescription description={description} />}
×
1736
                  placement="top"
×
1737
                  arrow
×
1738
                  describeChild
×
1739
                >
1740
                  <TooltipIcon />
×
1741
                </FieldTooltip>
×
1742
              )}
1743
            </Typography>
8✔
1744
          </FormArrayTitle>
8✔
1745
        }
1746
      </Grid>
8✔
1747

1748
      <Grid size={{ xs: 12, md: 8 }}>
8✔
1749
        {formFields?.map((field, index) => {
8✔
1750
          const pathWithoutLastItem = path.slice(0, -1)
1✔
1751
          const lastPathItemWithIndex = `${lastPathItem}.${index}`
1✔
1752

1753
          if (items.oneOf) {
1✔
1754
            const pathForThisIndex = [...pathWithoutLastItem, lastPathItemWithIndex]
1✔
1755

1756
            return (
1✔
1757
              <Box
1✔
1758
                key={field.id || index}
1✔
1759
                data-testid={`${name}[${index}]`}
1✔
1760
                display="flex"
1✔
1761
                alignItems="center"
1✔
1762
              >
1763
                <FormArrayChildrenTitle elevation={2} square>
1✔
1764
                  <FormOneOfField
1✔
1765
                    key={field.id}
1✔
1766
                    nestedField={field as NestedField}
1✔
1767
                    path={pathForThisIndex}
1✔
1768
                    object={items}
1✔
1769
                  />
1770
                </FormArrayChildrenTitle>
1✔
1771
                <IconButton onClick={() => handleRemove(index)}>
1✔
1772
                  <RemoveIcon />
1✔
1773
                </IconButton>
1!
1774
              </Box>
1✔
1775
            )
1776
          }
1!
1777

1778
          const properties = object.items.properties
×
1779
          let requiredProperties =
×
1780
            index === 0 && object.contains?.allOf
×
1781
              ? object.contains?.allOf?.flatMap((item: FormObject) => item.required) // Case: DAC - Main Contact needs at least 1
×
1782
              : object.items?.required
×
1783

1784
          // Force first array item as required field if array is required but none of the items are required
1785
          if (required && !requiredProperties) requiredProperties = [Object.keys(items)[0]]
1!
1786

1787
          return (
×
1788
            <Box key={field.id || index} aria-labelledby={name} display="flex" alignItems="center">
×
1789
              <FormArrayChildrenTitle elevation={2} square>
×
1790
                {
1791
                  items
×
1792
                    ? Object.keys(items).map(item => {
×
1793
                        const pathForThisIndex = [
×
1794
                          ...pathWithoutLastItem,
×
1795
                          lastPathItemWithIndex,
×
1796
                          item,
×
1797
                        ]
1798
                        const requiredField = requiredProperties
×
1799
                          ? requiredProperties.filter((prop: string) => prop === item)
×
1800
                          : []
×
1801
                        return traverseFields(
×
1802
                          properties[item] as FormObject,
×
1803
                          pathForThisIndex,
×
1804
                          requiredField,
×
1805
                          false,
×
1806
                          field as NestedField
×
1807
                        )
×
1808
                      })
×
1809
                    : traverseFields(
×
1810
                        object.items,
×
1811
                        [...pathWithoutLastItem, lastPathItemWithIndex],
×
1812
                        [],
×
1813
                        false,
×
1814
                        field as NestedField
×
1815
                      ) // special case for doiSchema's "sizes" and "formats"
×
1816
                }
1817
              </FormArrayChildrenTitle>
1✔
1818
              <IconButton onClick={() => handleRemove(index)} size="large">
1✔
1819
                <RemoveIcon />
1✔
1820
              </IconButton>
1!
1821
            </Box>
1✔
1822
          )
1823
        })}
8✔
1824

1825
        <Button
8✔
1826
          variant="contained"
8✔
1827
          color="primary"
8✔
1828
          size="small"
8✔
1829
          startIcon={<AddIcon />}
8✔
1830
          onClick={() => handleAppend()}
8✔
1831
          sx={{ mb: "1rem" }}
8✔
1832
        >
1833
          Add new item
1834
        </Button>
8✔
1835
      </Grid>
8✔
1836
    </Grid>
8✔
1837
  )
1838
}
8✔
1839

1840
export default {
1✔
1841
  buildFields,
1✔
1842
  cleanUpFormValues,
1✔
1843
}
1✔
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