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

CSCfi / metadata-submitter-frontend / 19494873348

19 Nov 2025 08:31AM UTC coverage: 59.668% (+1.3%) from 58.339%
19494873348

push

github

Hang Le
Add JSON schemas to frontend, refactor the codes, add more localization configs (merge commit)

Merge branch 'feature/move-json-schemas-to-frontend' into 'main'
* Fix submissionDetails objectType not fetchable

* Add JSON schemas to frontend and refactor the codes

Closes #1092, #1097, #1108, #1109, #1111, #1113, and #1114
See merge request https://gitlab.ci.csc.fi/sds-dev/sd-submit/metadata-submitter-frontend/-/merge_requests/1183

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

639 of 831 branches covered (76.9%)

Branch coverage included in aggregate %.

400 of 578 new or added lines in 36 files covered. (69.2%)

62 existing lines in 12 files now uncovered.

5400 of 9290 relevant lines covered (58.13%)

4.49 hits per line

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

72.63
/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 { setAutocompleteField } from "features/autocompleteSlice"
1✔
35
import { useAppSelector, useAppDispatch } from "hooks"
1✔
36
import rorAPIService from "services/rorAPI"
1✔
37
import { ConnectFormChildren, ConnectFormMethods, FormObject, NestedField } from "types"
38
import { pathToName, traverseValues } from "utils/JSONSchemaUtils"
1✔
39

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

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

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

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

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

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

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

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

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

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

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

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

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

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

189
  switch (object.type) {
115✔
190
    case "object": {
121✔
191
      return (
44✔
192
        <FormSection
44✔
193
          key={name}
44✔
194
          name={name}
44✔
195
          label={label}
44✔
196
          level={path.length}
44✔
197
          description={description}
44✔
198
          isTitleShown
44✔
199
        >
200
          {Object.keys(object.properties).map(propertyKey => {
44✔
201
            const property = object.properties[propertyKey] as FormObject
95✔
202
            const required = object?.else?.required ?? object.required
95!
203
            let requireFirstItem = false
95✔
204

205
            if (
95✔
206
              path.length === 0 &&
95✔
207
              propertyKey === "title" &&
45!
208
              !object.title.includes("DAC - Data Access Committee")
×
209
            ) {
95!
210
              requireFirstItem = true
×
211
            }
×
212
            // Require first field of section if parent section is a required property
213
            if (
95✔
214
              requireFirst ||
95✔
215
              requiredProperties?.includes(name) ||
94✔
216
              requiredProperties?.includes(Object.keys(object.properties)[0])
94✔
217
            ) {
95✔
218
              const parentProperty = Object.values(object.properties)[0] as { title: string }
1✔
219
              requireFirstItem = parentProperty.title === property.title ? true : false
1!
220
            }
1✔
221

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

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

353
      Pretty printed version of object with unsupported type:
354
      ${JSON.stringify(object, null, 2)}
×
355
      `)
×
356
      return null
×
357
    }
×
358
  }
121✔
359
}
121✔
360

361
const DisplayDescription = ({
1✔
362
  description,
5✔
363
  children,
5✔
364
}: {
365
  description: string
366
  children?: React.ReactElement<unknown>
367
}) => {
5✔
368
  const { t } = useTranslation()
5✔
369
  const [isReadMore, setIsReadMore] = React.useState(description.length > 60)
5✔
370

371
  const toggleReadMore = () => {
5✔
372
    setIsReadMore(!isReadMore)
2✔
373
  }
2✔
374

375
  const ReadmoreText = styled("span")(({ theme }) => ({
5✔
376
    fontWeight: 700,
3✔
377
    textDecoration: "underline",
3✔
378
    display: "block",
3✔
379
    marginTop: "0.5rem",
3✔
380
    color: theme.palette.primary.main,
3✔
381
    "&:hover": { cursor: "pointer" },
3✔
382
  }))
5✔
383

384
  return (
5✔
385
    <p>
5✔
386
      {isReadMore ? `${description.slice(0, 60)}...` : description}
5✔
387
      {!isReadMore && children}
5✔
388
      {description?.length >= 60 && (
5✔
389
        <ReadmoreText onClick={toggleReadMore}>
4✔
390
          {isReadMore ? t("showMore") : t("showLess")}
4✔
391
        </ReadmoreText>
4✔
392
      )}
393
    </p>
5✔
394
  )
395
}
5✔
396

397
type FormSectionProps = {
398
  name: string
399
  label: string
400
  level: number
401
  isTitleShown?: boolean
402
  children?: React.ReactNode
403
}
404

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

419
/*
420
 * FormSection is rendered for properties with type object
421
 */
422
const FormSection = ({
1✔
423
  name,
89✔
424
  label,
89✔
425
  level,
89✔
426
  children,
89✔
427
  description,
89✔
428
  isTitleShown,
89✔
429
}: FormSectionProps & { description?: string }) => {
89✔
430
  const splittedPath = name.split(".") // Have a fully splitted path for names such as "studyLinks.0", "dacLinks.0"
89✔
431

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

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

489
/*
490
 * FormOneOfField is rendered if property can be choosed from many possible.
491
 */
492

493
const FormOneOfField = ({
1✔
494
  path,
9✔
495
  object,
9✔
496
  nestedField,
9✔
497
  required,
9✔
498
}: {
499
  path: string[]
500
  object: FormObject
501
  nestedField?: NestedField
502
  required?: boolean
503
}) => {
9✔
504
  const options = object.oneOf
9✔
505
  const [lastPathItem] = path.slice(-1)
9✔
506
  const description = object.description
9✔
507
  // Get the fieldValue when rendering a saved/submitted form
508
  // For e.g. obj.required is ["label", "url"] and nestedField is {id: "sth1", label: "sth2", url: "sth3"}
509
  // Get object from state and set default values if child of oneOf field has values
510
  // 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
511
  const currentObject = useAppSelector(state => state.currentObject) || {}
9!
512
  const values = currentObject[path.toString()]
9!
513
    ? currentObject
×
514
    : currentObject[
9✔
515
        Object.keys(currentObject)
9✔
516
          .filter(item => path.includes(item))
9✔
517
          .toString()
9✔
518
      ] || {}
9✔
519

520
  let fieldValue: string | number | undefined
9✔
521

522
  const flattenObject = (obj: { [x: string]: never }, prefix = "") =>
9✔
523
    Object.keys(obj).reduce(
×
524
      (acc, k) => {
×
525
        const pre = prefix.length ? prefix + "." : ""
×
526
        if (typeof obj[k] === "object") Object.assign(acc, flattenObject(obj[k], pre + k))
×
527
        else acc[pre + k] = obj[k]
×
528
        return acc
×
529
      },
×
530
      {} as Record<string, string>
×
531
    )
×
532

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

568
  // Eg. Study > Study Links
569
  if (nestedField) {
9✔
570
    for (const option of options) {
2✔
571
      option.required.every(
4✔
572
        (val: string) =>
4✔
573
          nestedField.fieldValues && Object.keys(nestedField.fieldValues).includes(val)
4!
574
      )
4!
575
        ? (fieldValue = option.title)
×
576
        : ""
4✔
577
    }
4✔
578
  }
2✔
579

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

584
  if (itemValue) {
9!
585
    switch (lastPathItem) {
×
586
      case "prevStepIndex":
×
587
        {
×
588
          fieldValue = "String value"
×
589
        }
×
590
        break
×
591
    }
×
592
  }
×
593

594
  const name = pathToName(path)
9✔
595

596
  const label = object.title ?? lastPathItem
9!
597

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

600
  const getChildObjects = (obj?: ChildObject) => {
9✔
601
    if (obj) {
×
602
      let childProps
×
603
      for (const key in obj) {
×
604
        // Check if object has nested "properties"
605
        if (key === "properties") {
×
606
          childProps = obj.properties
×
607
          const childPropsValues = Object.values(childProps)[0]
×
608
          if (Object.hasOwnProperty.call(childPropsValues, "properties")) {
×
609
            getChildObjects(childPropsValues as ChildObject)
×
610
          }
×
611
        }
×
612
      }
×
613

614
      const firstProp = childProps ? Object.keys(childProps)[0] : ""
×
615
      return { obj, firstProp }
×
616
    }
×
617
    return {}
×
618
  }
×
619

620
  const [field, setField] = React.useState(fieldValue)
9✔
621
  const clearForm = useAppSelector(state => state.clearForm)
9✔
622

623
  return (
9✔
624
    <ConnectForm>
9✔
625
      {({ errors, unregister, setValue, getValues, reset }: ConnectFormMethods) => {
9✔
626
        if (clearForm) {
9!
627
          // Clear the field and "clearForm" is true
628
          setField("")
×
629
          unregister(name)
×
630
        }
×
631

632
        const error = get(errors, name)
9✔
633
        // Option change handling
634
        const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
9✔
635
          const val = event.target.value
2✔
636
          setField(val)
2✔
637

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

653
        // Selected option
654
        const selectedOption =
9✔
655
          options?.filter((option: { title: string }) => option.title === field)[0]?.properties ||
9✔
656
          {}
7✔
657
        const selectedOptionValues = Object.values(selectedOption)
9✔
658

659
        let childObject
9✔
660
        let requiredProp: string
9✔
661

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

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

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

742
type FormFieldBaseProps = {
743
  name: string
744
  label: string
745
  required: boolean
746
}
747

748
type FormSelectFieldProps = FormFieldBaseProps & { options: string[] }
749

750
/*
751
 * FormTextField is the most usual type, rendered for strings, integers and numbers.
752
 */
753
const FormTextField = ({
1✔
754
  name,
24✔
755
  label,
24✔
756
  required,
24✔
757
  description,
24✔
758
  type = "string",
24✔
759
  nestedField,
24✔
760
}: FormFieldBaseProps & { description: string; type?: string; nestedField?: NestedField }) => {
24✔
761
  // Default Value of input
762
  const defaultValue = getDefaultValue(name, nestedField)
24✔
763
  const { setValue } = useFormContext()
24✔
764

765
  return (
24✔
766
    <ConnectForm>
24✔
767
      {({ control }: ConnectFormMethods) => {
24✔
768
        const multiLineRowIdentifiers = ["abstract", "description", "policy text"]
24✔
769

770
        return (
24✔
771
          <Controller
24✔
772
            render={({ field, fieldState: { error } }) => {
24✔
773
              const inputValue = (typeof field.value !== "object" && field.value) || ""
30✔
774

775
              const handleChange = (e: { target: { value: string | number } }) => {
30✔
776
                const { value } = e.target
1✔
777
                const parsedValue =
1✔
778
                  type === "string" && typeof value === "number" ? value.toString() : value
1!
779
                field.onChange(parsedValue) // Helps with Cypress change detection
1✔
780
                setValue(name, parsedValue) // Enables update of nested fields, eg. DAC contact
1✔
781
              }
1✔
782

783
              return (
30✔
784
                <div style={{ marginBottom: "1rem" }}>
30✔
785
                  <BaselineDiv>
30✔
786
                    <TextField
30✔
787
                      {...field}
30✔
788
                      slotProps={{ htmlInput: { "data-testid": name } }}
30✔
789
                      label={label}
30✔
790
                      id={name}
30✔
791
                      role="textbox"
30✔
792
                      error={!!error}
30✔
793
                      helperText={error?.message}
30✔
794
                      required={required}
30✔
795
                      type={type}
30✔
796
                      multiline={multiLineRowIdentifiers.some(value =>
30✔
797
                        label.toLowerCase().includes(value)
90✔
798
                      )}
30✔
799
                      rows={5}
30✔
800
                      value={inputValue}
30✔
801
                      onChange={handleChange}
30✔
802
                      disabled={defaultValue !== "" && name.includes("formats")}
30✔
803
                    />
804
                    {description && (
30!
UNCOV
805
                      <FieldTooltip
×
UNCOV
806
                        title={<DisplayDescription description={description} />}
×
UNCOV
807
                        placement="right"
×
UNCOV
808
                        arrow
×
UNCOV
809
                        describeChild
×
810
                      >
UNCOV
811
                        <TooltipIcon />
×
UNCOV
812
                      </FieldTooltip>
×
813
                    )}
814
                  </BaselineDiv>
30✔
815
                </div>
30✔
816
              )
817
            }}
30✔
818
            name={name}
24✔
819
            control={control}
24✔
820
            defaultValue={defaultValue}
24✔
821
            rules={{ required: required }}
24✔
822
          />
823
        )
824
      }}
24✔
825
    </ConnectForm>
24✔
826
  )
827
}
24✔
828

829
/*
830
 * FormSelectField is rendered for selection from options where it's possible to choose many options
831
 */
832

833
const FormSelectField = ({
1✔
834
  name,
6✔
835
  label,
6✔
836
  required,
6✔
837
  options,
6✔
838
  description,
6✔
839
}: FormSelectFieldProps & { description: string }) => (
6✔
840
  <ConnectForm>
6✔
841
    {({ control }: ConnectFormMethods) => {
6✔
842
      return (
6✔
843
        <Controller
6✔
844
          name={name}
6✔
845
          control={control}
6✔
846
          render={({ field, fieldState: { error } }) => {
6✔
847
            return (
7✔
848
              <BaselineDiv>
7✔
849
                <TextField
7✔
850
                  {...field}
7✔
851
                  label={label}
7✔
852
                  id={name}
7✔
853
                  value={field.value || ""}
7✔
854
                  error={!!error}
7✔
855
                  helperText={error?.message}
7!
856
                  required={required}
7✔
857
                  select
7✔
858
                  SelectProps={{ native: true }}
7✔
859
                  onChange={e => {
7✔
860
                    let val = e.target.value
×
861
                    // Case: linkingAccessionIds which include "AccessionId + Form's title", we need to return only accessionId as value
862
                    if (val?.includes("Title")) {
×
863
                      const hyphenIndex = val.indexOf("-")
×
864
                      val = val.slice(0, hyphenIndex - 1)
×
865
                    }
×
866
                    return field.onChange(val)
×
867
                  }}
×
868
                  inputProps={{ "data-testid": name }}
7✔
869
                  sx={{ mb: "1rem" }}
7✔
870
                >
871
                  <option aria-label="None" value="" disabled />
7✔
872
                  {options.map(option => (
7✔
873
                    <option key={`${name}-${option}`} value={option} data-testid={`${name}-option`}>
21✔
874
                      {option}
21✔
875
                    </option>
21✔
876
                  ))}
7✔
877
                </TextField>
7✔
878
                {description && (
7!
879
                  <FieldTooltip
×
880
                    title={<DisplayDescription description={description} />}
×
881
                    placement="right"
×
882
                    arrow
×
883
                    describeChild
×
884
                  >
885
                    <TooltipIcon />
×
886
                  </FieldTooltip>
×
887
                )}
888
              </BaselineDiv>
7✔
889
            )
890
          }}
7✔
891
        />
892
      )
893
    }}
6✔
894
  </ConnectForm>
6✔
895
)
896

897
/*
898
 * FormDatePicker used for selecting date or date rage in DOI form
899
 */
900

901
const StyledDatePicker = styled(DatePicker)(({ theme }) => ({
1✔
902
  "& .MuiIconButton-root": {
×
903
    color: theme.palette.primary.main,
×
904
    "&:hover": {
×
905
      backgroundColor: theme.palette.action.hover,
×
906
    },
×
907
  },
×
908
}))
1✔
909

910
const FormDatePicker = ({
1✔
911
  name,
×
912
  required,
×
913
  description,
×
914
}: FormFieldBaseProps & { description: string }) => {
×
915
  const { t } = useTranslation()
×
916
  const { getValues } = useFormContext()
×
917
  const defaultValue = getValues(name) || ""
×
918

919
  const getStartEndDates = (date: string) => {
×
920
    const [startInput, endInput] = date?.split("/")
×
921
    if (startInput && endInput) return [moment(startInput), moment(endInput)]
×
922
    else if (startInput) return [moment(startInput), null]
×
923
    else if (endInput) return [null, moment(endInput)]
×
924
    else return [null, null]
×
925
  }
×
926

927
  const [startDate, setStartDate] = React.useState<moment.Moment | null>(
×
928
    getStartEndDates(defaultValue)[0]
×
929
  )
×
930
  const [endDate, setEndDate] = React.useState<moment.Moment | null>(
×
931
    getStartEndDates(defaultValue)[1]
×
932
  )
×
933
  const [startError, setStartError] = React.useState<string | null>(null)
×
934
  const [endError, setEndError] = React.useState<string | null>(null)
×
935
  const clearForm = useAppSelector(state => state.clearForm)
×
936

937
  React.useEffect(() => {
×
938
    if (clearForm) {
×
939
      setStartDate(null)
×
940
      setEndDate(null)
×
941
    }
×
942
  }, [clearForm])
×
943

944
  const format = "YYYY-MM-DD"
×
945
  const formatDate = (date: moment.Moment) => moment(date).format(format)
×
946

947
  const makeValidDateString = (start: moment.Moment | null, end: moment.Moment | null) => {
×
948
    let dateStr = ""
×
949
    if (start && start?.isValid()) {
×
950
      dateStr = formatDate(start)
×
951
      if (end && end?.isValid() && formatDate(end) !== dateStr) {
×
952
        dateStr += `/${formatDate(end)}`
×
953
      }
×
954
    }
×
955
    return dateStr
×
956
  }
×
957

958
  return (
×
959
    <ConnectForm>
×
960
      {({ setValue }: ConnectFormMethods) => {
×
961
        const handleChangeStartDate = (newValue: moment.Moment | null) => {
×
962
          setStartDate(newValue)
×
963
          setValue(name, makeValidDateString(newValue, endDate), { shouldDirty: true })
×
964
        }
×
965

966
        const handleChangeEndDate = (newValue: moment.Moment | null) => {
×
967
          setEndDate(newValue)
×
968
          setValue(name, makeValidDateString(startDate, newValue), { shouldDirty: true })
×
969
        }
×
970

971
        return (
×
972
          <LocalizationProvider dateAdapter={AdapterMoment}>
×
973
            <Box
×
974
              sx={{
×
975
                width: "95%",
×
976
                display: "flex",
×
977
                flexDirection: "row",
×
978
                alignItems: "baseline",
×
979
                marginBottom: "1rem",
×
980
              }}
×
981
            >
982
              <StyledDatePicker
×
983
                label={t("datacite.startDate")}
×
984
                value={startDate}
×
985
                format={format}
×
986
                onChange={handleChangeStartDate}
×
987
                onError={setStartError}
×
988
                shouldDisableDate={day => !!endDate && moment(day).isAfter(endDate)}
×
989
                slotProps={{
×
990
                  field: { clearable: true },
×
991
                  textField: {
×
992
                    required,
×
993
                    error: !!startError,
×
994
                    helperText: startError
×
995
                      ? startError === "shouldDisableDate"
×
996
                        ? t("datacite.error.range")
×
997
                        : t("datacite.error.date")
×
998
                      : "",
×
999
                  },
×
1000
                  nextIconButton: { color: "primary" },
×
1001
                  previousIconButton: { color: "primary" },
×
1002
                  switchViewIcon: { color: "primary" },
×
1003
                }}
×
1004
              />
1005
              <span>–</span>
×
1006
              <StyledDatePicker
×
1007
                label={t("datacite.endDate")}
×
1008
                value={endDate}
×
1009
                format={format}
×
1010
                onChange={handleChangeEndDate}
×
1011
                onError={setEndError}
×
1012
                shouldDisableDate={day => !!startDate && moment(day).isBefore(startDate)}
×
1013
                slotProps={{
×
1014
                  field: { clearable: true },
×
1015
                  textField: {
×
1016
                    error: !!endError,
×
1017
                    helperText: endError
×
1018
                      ? endError === "shouldDisableDate"
×
1019
                        ? t("datacite.error.range")
×
1020
                        : t("datacite.error.date")
×
1021
                      : "",
×
1022
                  },
×
1023
                  nextIconButton: { color: "primary" },
×
1024
                  previousIconButton: { color: "primary" },
×
1025
                  switchViewIcon: { color: "primary" },
×
1026
                }}
×
1027
              />
1028
              {description && (
×
1029
                <FieldTooltip
×
1030
                  title={<DisplayDescription description={description} />}
×
1031
                  placement="right"
×
1032
                  arrow
×
1033
                  describeChild
×
1034
                >
1035
                  <TooltipIcon />
×
1036
                </FieldTooltip>
×
1037
              )}
1038
            </Box>
×
1039
          </LocalizationProvider>
×
1040
        )
1041
      }}
×
1042
    </ConnectForm>
×
1043
  )
1044
}
×
1045

1046
/*
1047
 * FormAutocompleteField uses ROR API to fetch organisations
1048
 */
1049
type RORItem = {
1050
  name: string
1051
  id: string
1052
}
1053
/*
1054
  More details of ROR data structure is here: https://ror.readme.io/docs/ror-data-structure
1055
*/
1056
type ROROrganization = Record<string, unknown> & {
1057
  id: string
1058
  names: { lang: string | null; types: string[]; value: string }[]
1059
}
1060

1061
const StyledAutocomplete = styled(Autocomplete)(() => ({
1✔
1062
  flex: "auto",
10✔
1063
  alignSelf: "flex-start",
10✔
1064
  "& + svg": {
10✔
1065
    marginTop: 1,
10✔
1066
  },
10✔
1067
})) as typeof Autocomplete
1✔
1068

1069
const FormAutocompleteField = ({
1✔
1070
  name,
10✔
1071
  label,
10✔
1072
  required,
10✔
1073
  description,
10✔
1074
}: FormFieldBaseProps & { description: string }) => {
10✔
1075
  const dispatch = useAppDispatch()
10✔
1076
  const { getValues } = useFormContext()
10✔
1077

1078
  const defaultValue = getValues(name) || ""
10✔
1079
  const [open, setOpen] = React.useState(false)
10✔
1080
  const [options, setOptions] = React.useState([])
10✔
1081
  const [inputValue, setInputValue] = React.useState("")
10✔
1082
  const [loading, setLoading] = React.useState(false)
10✔
1083

1084
  const fetchOrganisations = async (searchTerm: string) => {
10✔
1085
    // Check if searchTerm includes non-word char, for e.g. "(", ")", "-" because the api does not work with those chars
1086
    const isContainingNonWordChar = searchTerm.match(/\W/g)
1✔
1087
    const response =
1✔
1088
      isContainingNonWordChar === null ? await rorAPIService.getOrganisations(searchTerm) : null
1!
1089

1090
    if (response) setLoading(false)
1✔
1091

1092
    if (response?.ok) {
1✔
1093
      const mappedOrganisations = response.data.items.reduce(
1✔
1094
        (orgArr: RORItem[], org: ROROrganization) => {
1✔
1095
          const orgName = org.names.filter(name => name.types.includes("ror_display"))[0]?.value
1✔
1096
          if (orgName) {
1✔
1097
            orgArr.push({
1✔
1098
              name: orgName,
1✔
1099
              id: org.id,
1✔
1100
            })
1✔
1101
          }
1✔
1102
          return orgArr
1✔
1103
        },
1✔
1104
        []
1✔
1105
      )
1✔
1106

1107
      setOptions(mappedOrganisations)
1✔
1108
    }
1✔
1109
  }
1✔
1110

1111
  const debouncedSearch = debounce((newInput: string) => {
10✔
1112
    if (newInput.length > 0) fetchOrganisations(newInput)
1✔
1113
  }, 150)
10✔
1114

1115
  React.useEffect(() => {
10✔
1116
    let active = true
5✔
1117

1118
    if (inputValue === "") {
5✔
1119
      setOptions([])
3✔
1120
      return undefined
3✔
1121
    }
3✔
1122

1123
    if (active && open) {
5✔
1124
      setLoading(true)
1✔
1125
      debouncedSearch(inputValue)
1✔
1126
    }
1✔
1127

1128
    return () => {
2✔
1129
      active = false
2✔
1130
      setLoading(false)
2✔
1131
    }
2✔
1132
  }, [inputValue])
10✔
1133

1134
  return (
10✔
1135
    <ConnectForm>
10✔
1136
      {({ errors, control }: ConnectFormMethods) => {
10✔
1137
        const error = get(errors, name)
10✔
1138

1139
        return (
10✔
1140
          <Controller
10✔
1141
            name={name}
10✔
1142
            control={control}
10✔
1143
            defaultValue={defaultValue}
10✔
1144
            render={({ field }) => {
10✔
1145
              return (
10✔
1146
                <StyledAutocomplete
10✔
1147
                  freeSolo
10✔
1148
                  open={open}
10✔
1149
                  onOpen={() => {
10✔
1150
                    setOpen(true)
1✔
1151
                  }}
1✔
1152
                  onClose={() => {
10✔
1153
                    setOpen(false)
1✔
1154
                  }}
1✔
1155
                  options={options}
10✔
1156
                  getOptionKey={option => option.id ?? ""}
10!
1157
                  getOptionLabel={option => option.name || ""}
10✔
1158
                  disableClearable={inputValue.length === 0}
10✔
1159
                  value={field.value}
10✔
1160
                  inputValue={inputValue || defaultValue}
10✔
1161
                  onChange={(_event, option: RORItem) => {
10✔
1162
                    field.onChange(option?.name)
1✔
1163
                    option?.id
1✔
1164
                      ? dispatch(setAutocompleteField(option.id))
1!
1165
                      : dispatch(setAutocompleteField(null))
×
1166
                  }}
1✔
1167
                  onInputChange={(_event, newInputValue: string) => setInputValue(newInputValue)}
10✔
1168
                  renderInput={params => (
10✔
1169
                    <BaselineDiv>
10✔
1170
                      <TextField
10✔
1171
                        {...params}
10✔
1172
                        label={label}
10✔
1173
                        id={name}
10✔
1174
                        name={name}
10✔
1175
                        variant="outlined"
10✔
1176
                        error={!!error}
10✔
1177
                        required={required}
10✔
1178
                        InputProps={{
10✔
1179
                          ...params.InputProps,
10✔
1180
                          endAdornment: (
10✔
1181
                            <React.Fragment>
10✔
1182
                              {loading ? <CircularProgress color="inherit" size={20} /> : null}
10✔
1183
                              {params.InputProps.endAdornment}
10✔
1184
                            </React.Fragment>
10✔
1185
                          ),
1186
                        }}
10✔
1187
                        inputProps={{ ...params.inputProps, "data-testid": `${name}-inputField` }}
10✔
1188
                        sx={[
10✔
1189
                          {
10✔
1190
                            "&.MuiAutocomplete-endAdornment": {
10✔
1191
                              top: 0,
10✔
1192
                            },
10✔
1193
                          },
10✔
1194
                        ]}
10✔
1195
                      />
1196
                      {description && (
10!
1197
                        <FieldTooltip
×
1198
                          title={
×
1199
                            <DisplayDescription description={description}>
×
1200
                              <>
×
1201
                                <br />
×
1202
                                {"Organisations provided by "}
×
1203
                                <a href="https://ror.org/" target="_blank" rel="noreferrer">
×
1204
                                  {"ror.org"}
×
1205
                                  <LaunchIcon sx={{ fontSize: "1rem", mb: -1 }} />
×
1206
                                </a>
×
1207
                              </>
×
1208
                            </DisplayDescription>
×
1209
                          }
1210
                          placement="right"
×
1211
                          arrow
×
1212
                          describeChild
×
1213
                        >
1214
                          <TooltipIcon />
×
1215
                        </FieldTooltip>
×
1216
                      )}
1217
                    </BaselineDiv>
10✔
1218
                  )}
10✔
1219
                />
1220
              )
1221
            }}
10✔
1222
          />
1223
        )
1224
      }}
10✔
1225
    </ConnectForm>
10✔
1226
  )
1227
}
10✔
1228

1229
const ValidationTagField = styled(TextField)(({ theme }) => ({
1✔
1230
  "& .MuiOutlinedInput-root.MuiInputBase-root": { flexWrap: "wrap" },
15✔
1231
  "& input": { flex: 1, minWidth: "2rem" },
15✔
1232
  "& label": { color: theme.palette.primary.main },
15✔
1233
  "& .MuiOutlinedInput-notchedOutline, div:hover .MuiOutlinedInput-notchedOutline":
15✔
1234
    highlightStyle(theme),
15✔
1235
}))
1✔
1236

1237
const FormTagField = ({
1✔
1238
  name,
15✔
1239
  label,
15✔
1240
  required,
15✔
1241
  description,
15✔
1242
}: FormFieldBaseProps & { description: string }) => {
15✔
1243
  const savedValues = useWatch({ name })
15✔
1244
  const [inputValue, setInputValue] = React.useState("")
15✔
1245
  const [tags, setTags] = React.useState<Array<string>>([])
15✔
1246

1247
  React.useEffect(() => {
15✔
1248
    // update tags when value becomes available or changes
1249
    const updatedTags = savedValues ? savedValues.split(",") : []
9✔
1250
    setTags(updatedTags)
9✔
1251
  }, [savedValues])
15✔
1252

1253
  const handleInputChange = e => {
15✔
1254
    setInputValue(e.target.value)
1✔
1255
  }
1✔
1256

1257
  const clearForm = useAppSelector(state => state.clearForm)
15✔
1258

1259
  React.useEffect(() => {
15✔
1260
    if (clearForm) {
4!
1261
      setTags([])
×
1262
      setInputValue("")
×
1263
    }
×
1264
  }, [clearForm])
15✔
1265

1266
  const inputRef = React.useRef<HTMLInputElement | null>(null)
15✔
1267

1268
  return (
15✔
1269
    <ConnectForm>
15✔
1270
      {({ control }: ConnectFormMethods) => {
15✔
1271
        return (
15✔
1272
          <Controller
15✔
1273
            name={name}
15✔
1274
            control={control}
15✔
1275
            defaultValue={""}
15✔
1276
            render={({ field }) => {
15✔
1277
              const handleKeywordAsTag = (keyword: string) => {
15✔
1278
                // newTags with unique values
1279
                const newTags = !tags.includes(keyword) ? [...tags, keyword] : tags
1!
1280
                setInputValue("")
1✔
1281
                // Convert tags to string for hidden registered input's values
1282
                field.onChange(newTags.join(","))
1✔
1283
              }
1✔
1284

1285
              const handleKeyDown = e => {
15✔
1286
                const { key } = e
×
1287
                const trimmedInput = inputValue.trim()
×
1288
                // Convert to tags if users press "," OR "Enter"
1289
                if ((key === "," || key === "Enter") && trimmedInput.length > 0) {
×
1290
                  e.preventDefault()
×
1291
                  handleKeywordAsTag(trimmedInput)
×
1292
                }
×
1293
              }
×
1294

1295
              // Convert to tags when user clicks outside of input field
1296
              const handleOnBlur = () => {
15✔
1297
                const trimmedInput = inputValue.trim()
1✔
1298
                if (trimmedInput.length > 0) {
1✔
1299
                  handleKeywordAsTag(trimmedInput)
1✔
1300
                }
1✔
1301
              }
1✔
1302

1303
              const handleTagDelete = item => () => {
15✔
1304
                const newTags = tags.filter(tag => tag !== item)
×
1305
                field.onChange(newTags.join(","))
×
1306
                // manual focus to trigger blur event
1307
                inputRef.current?.focus()
×
1308
              }
×
1309

1310
              return (
15✔
1311
                <BaselineDiv>
15✔
1312
                  <input
15✔
1313
                    {...field}
15✔
1314
                    required={required}
15✔
1315
                    style={{ width: 0, opacity: 0, transform: "translate(8rem, 2rem)" }}
15✔
1316
                    ref={inputRef}
15✔
1317
                  />
1318
                  <ValidationTagField
15✔
1319
                    slotProps={{
15✔
1320
                      htmlInput: { "data-testid": name },
15✔
1321
                      input: {
15✔
1322
                        startAdornment:
15✔
1323
                          tags.length > 0
15✔
1324
                            ? tags.map(item => (
1✔
1325
                                <Chip
2✔
1326
                                  key={item}
2✔
1327
                                  tabIndex={-1}
2✔
1328
                                  label={item}
2✔
1329
                                  onDelete={handleTagDelete(item)}
2✔
1330
                                  color="primary"
2✔
1331
                                  deleteIcon={<ClearIcon fontSize="small" />}
2✔
1332
                                  data-testid={item}
2✔
1333
                                  sx={{ fontSize: "1.4rem", m: "0.5rem" }}
2✔
1334
                                />
1335
                              ))
1✔
1336
                            : null,
14✔
1337
                      },
15✔
1338
                    }}
15✔
1339
                    label={label}
15✔
1340
                    id={name}
15✔
1341
                    value={inputValue}
15✔
1342
                    onChange={handleInputChange}
15✔
1343
                    onKeyDown={handleKeyDown}
15✔
1344
                    onBlur={handleOnBlur}
15✔
1345
                  />
1346

1347
                  {description && (
15✔
1348
                    <FieldTooltip
15✔
1349
                      title={<DisplayDescription description={description} />}
15✔
1350
                      placement="right"
15✔
1351
                      arrow
15✔
1352
                      describeChild
15✔
1353
                    >
1354
                      <TooltipIcon />
15✔
1355
                    </FieldTooltip>
15✔
1356
                  )}
1357
                </BaselineDiv>
15✔
1358
              )
1359
            }}
15✔
1360
          />
1361
        )
1362
      }}
15✔
1363
    </ConnectForm>
15✔
1364
  )
1365
}
15✔
1366

1367
/*
1368
 * Highlight required Checkbox
1369
 */
1370
const ValidationFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
1✔
1371
  label: {
6✔
1372
    "& span": { color: theme.palette.primary.main },
6✔
1373
  },
6✔
1374
}))
1✔
1375

1376
const FormBooleanField = ({
1✔
1377
  name,
6✔
1378
  label,
6✔
1379
  required,
6✔
1380
  description,
6✔
1381
}: FormFieldBaseProps & { description: string }) => {
6✔
1382
  return (
6✔
1383
    <ConnectForm>
6✔
1384
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1385
        const error = get(errors, name)
6✔
1386

1387
        const { ref, ...rest } = register(name)
6✔
1388
        // DAC form: "values" of MainContact checkbox
1389
        const values = getValues(name)
6✔
1390
        return (
6✔
1391
          <Box display="inline" px={1}>
6✔
1392
            <FormControl error={!!error} required={required}>
6✔
1393
              <FormGroup>
6✔
1394
                <BaselineDiv>
6✔
1395
                  <ValidationFormControlLabel
6✔
1396
                    control={
6✔
1397
                      <Checkbox
6✔
1398
                        id={name}
6✔
1399
                        {...rest}
6✔
1400
                        name={name}
6✔
1401
                        required={required}
6✔
1402
                        inputRef={ref}
6✔
1403
                        color="primary"
6✔
1404
                        checked={values || false}
6✔
1405
                        inputProps={
6✔
1406
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
6✔
1407
                        }
6✔
1408
                      />
1409
                    }
1410
                    label={
6✔
1411
                      <label>
6✔
1412
                        {label}
6✔
1413
                        <span>{required ? ` * ` : ""}</span>
6!
1414
                      </label>
6✔
1415
                    }
6✔
1416
                  />
1417
                  {description && (
6!
1418
                    <FieldTooltip
×
1419
                      title={<DisplayDescription description={description} />}
×
1420
                      placement="right"
×
1421
                      arrow
×
1422
                      describeChild
×
1423
                    >
1424
                      <TooltipIcon />
×
1425
                    </FieldTooltip>
×
1426
                  )}
1427
                </BaselineDiv>
6✔
1428

1429
                <FormHelperText>{error?.message}</FormHelperText>
6!
1430
              </FormGroup>
6✔
1431
            </FormControl>
6✔
1432
          </Box>
6✔
1433
        )
1434
      }}
6✔
1435
    </ConnectForm>
6✔
1436
  )
1437
}
6✔
1438

1439
const FormCheckBoxArray = ({
1✔
1440
  name,
6✔
1441
  label,
6✔
1442
  required,
6✔
1443
  options,
6✔
1444
  description,
6✔
1445
}: FormSelectFieldProps & { description: string }) => (
6✔
1446
  <Box px={1}>
6✔
1447
    <p>{label}</p>
6✔
1448
    <ConnectForm>
6✔
1449
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1450
        const values = getValues()[name]
6✔
1451

1452
        const error = get(errors, name)
6✔
1453

1454
        const { ref, ...rest } = register(name)
6✔
1455

1456
        return (
6✔
1457
          <FormControl error={!!error} required={required}>
6✔
1458
            <FormGroup aria-labelledby={name}>
6✔
1459
              {options.map(option => (
6✔
1460
                <React.Fragment key={option}>
12✔
1461
                  <FormControlLabel
12✔
1462
                    key={option}
12✔
1463
                    control={
12✔
1464
                      <Checkbox
12✔
1465
                        {...rest}
12✔
1466
                        inputRef={ref}
12✔
1467
                        name={name}
12✔
1468
                        value={option}
12✔
1469
                        checked={values && values?.includes(option) ? true : false}
12!
1470
                        color="primary"
12✔
1471
                        defaultValue=""
12✔
1472
                        inputProps={
12✔
1473
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
12✔
1474
                        }
12✔
1475
                      />
1476
                    }
1477
                    label={option}
12✔
1478
                  />
1479
                  {description && (
12!
1480
                    <FieldTooltip
×
1481
                      title={<DisplayDescription description={description} />}
×
1482
                      placement="right"
×
1483
                      arrow
×
1484
                      describeChild
×
1485
                    >
1486
                      <TooltipIcon />
×
1487
                    </FieldTooltip>
×
1488
                  )}
1489
                </React.Fragment>
12✔
1490
              ))}
6✔
1491
              <FormHelperText>{error?.message}</FormHelperText>
6!
1492
            </FormGroup>
6✔
1493
          </FormControl>
6✔
1494
        )
1495
      }}
6✔
1496
    </ConnectForm>
6✔
1497
  </Box>
6✔
1498
)
1499

1500
type FormArrayProps = {
1501
  object: FormObject
1502
  path: Array<string>
1503
  required: boolean
1504
}
1505

1506
const FormArrayTitle = styled(Paper, { shouldForwardProp: prop => prop !== "level" })<{
1✔
1507
  level: number
1508
}>(({ theme, level }) => ({
1✔
1509
  display: "flex",
17✔
1510
  flexDirection: "column",
17✔
1511
  justifyContent: "start",
17✔
1512
  alignItems: "start",
17✔
1513
  backgroundColor: level < 2 ? theme.palette.primary.light : theme.palette.common.white,
17✔
1514
  height: "100%",
17✔
1515
  marginLeft: level < 2 ? "5rem" : 0,
17✔
1516
  marginRight: "3rem",
17✔
1517
  padding: level === 1 ? "2rem" : 0,
17✔
1518
}))
1✔
1519

1520
const FormArrayChildrenTitle = styled(Paper)(() => ({
1✔
1521
  width: "70%",
1✔
1522
  display: "inline-block",
1✔
1523
  marginBottom: "1rem",
1✔
1524
  paddingLeft: "1rem",
1✔
1525
  paddingTop: "1rem",
1✔
1526
}))
1✔
1527

1528
/*
1529
 * FormArray is rendered for arrays of objects. User is given option to choose how many objects to add to array.
1530
 */
1531
const FormArray = ({
1✔
1532
  object,
17✔
1533
  path,
17✔
1534
  required,
17✔
1535
  description,
17✔
1536
}: FormArrayProps & { description: string }) => {
17✔
1537
  const name = pathToName(path)
17✔
1538
  const [lastPathItem] = path.slice(-1)
17✔
1539
  const level = path.length
17✔
1540
  const label = object.title ?? lastPathItem
17!
1541

1542
  // Get currentObject and the values of current field
1543
  const currentObject = useAppSelector(state => state.currentObject) || {}
17!
1544
  const fileTypes = useAppSelector(state => state.fileTypes)
17✔
1545

1546
  const fieldValues = get(currentObject, name)
17✔
1547

1548
  const items = traverseValues(object.items) as FormObject
17✔
1549

1550
  const { control } = useForm()
17✔
1551

1552
  const {
17✔
1553
    unregister,
17✔
1554
    getValues,
17✔
1555
    setValue,
17✔
1556
    formState: { isSubmitted },
17✔
1557
    clearErrors,
17✔
1558
  } = useFormContext()
17✔
1559

1560
  const { fields, append, remove } = useFieldArray({ control, name })
17✔
1561

1562
  const [formFields, setFormFields] = React.useState<Record<"id", string>[] | null>(null)
17✔
1563
  const { t } = useTranslation()
17✔
1564

1565
  // Append the correct values to the equivalent fields when editing form
1566
  // This applies for the case: "fields" does not get the correct data (empty array) although there are values in the fields
1567
  // E.g. Study > StudyLinks or Experiment > Expected Base Call Table
1568
  // Append only once when form is populated
1569
  React.useEffect(() => {
17✔
1570
    if (
8✔
1571
      fieldValues?.length > 0 &&
8!
1572
      fields?.length === 0 &&
×
1573
      typeof fieldValues === "object" &&
×
1574
      !formFields
×
1575
    ) {
8!
1576
      const fieldsArray: Record<string, unknown>[] = []
×
1577
      for (let i = 0; i < fieldValues.length; i += 1) {
×
1578
        fieldsArray.push({ fieldValues: fieldValues[i] })
×
1579
      }
×
1580
      append(fieldsArray)
×
1581
    }
×
1582
    // Create initial fields when editing object
1583
    setFormFields(fields)
8✔
1584
  }, [fields])
17✔
1585

1586
  // Get unique fileTypes from submitted fileTypes
1587
  const uniqueFileTypes = uniq(
17✔
1588
    flatten(fileTypes?.map((obj: { fileTypes: string[] }) => obj.fileTypes))
17✔
1589
  )
17✔
1590

1591
  React.useEffect(() => {
17✔
1592
    // Append fileType to formats' field
1593
    if (name === "formats") {
7!
1594
      for (let i = 0; i < uniqueFileTypes.length; i += 1) {
×
1595
        append({ formats: uniqueFileTypes[i] })
×
1596
      }
×
1597
    }
×
1598
  }, [uniqueFileTypes.length])
17✔
1599

1600
  // Clear required field array error and append
1601
  const handleAppend = () => {
17✔
1602
    clearErrors([name])
1✔
1603
    append({})
1✔
1604
  }
1✔
1605

1606
  const handleRemove = (index: number) => {
17✔
1607
    // Unregister field if removing last item: empty array isn't flagged as missing or invalid
1608
    if (index === 0 && getValues(name)?.length <= 1) {
×
1609
      setFormFields([])
×
1610
      remove(index)
×
1611
      unregister(name)
×
1612
    } else {
×
1613
      // Set the correct values according to the name path when removing a field
1614
      const values = getValues(name)
×
1615
      const filteredValues = values?.filter((_val: unknown, ind: number) => ind !== index)
×
1616
      setValue(name, filteredValues)
×
1617
      setFormFields(filteredValues)
×
1618
      remove(index)
×
1619
    }
×
1620
    if (document.activeElement instanceof HTMLElement) {
×
1621
      // force input check onBlur
1622
      document.activeElement.blur()
×
1623
    }
×
1624
  }
×
1625

1626
  return (
17✔
1627
    <Grid
17✔
1628
      container
17✔
1629
      key={`${name}-array`}
17✔
1630
      aria-labelledby={name}
17✔
1631
      data-testid={name}
17✔
1632
      direction={level < 2 ? "row" : "column"}
17✔
1633
      sx={{ mb: level === 1 ? "3rem" : 0 }}
17✔
1634
    >
1635
      <Grid size={{ xs: 12, md: 4 }}>
17✔
1636
        {
1637
          <FormArrayTitle square={true} elevation={0} level={level}>
17✔
1638
            <Typography
17✔
1639
              key={`${name}-header`}
17✔
1640
              variant={"subtitle1"}
17✔
1641
              data-testid={name}
17✔
1642
              role="heading"
17✔
1643
              color="secondary"
17✔
1644
            >
1645
              {label}
17✔
1646
              {required ? "*" : null}
17!
1647
              {required && formFields?.length === 0 && isSubmitted && (
17!
1648
                <span>
×
1649
                  <FormControl error>
×
1650
                    <FormHelperText>{t("errors.form.empty")}</FormHelperText>
×
1651
                  </FormControl>
×
1652
                </span>
×
1653
              )}
1654
              {description && (
17✔
1655
                <FieldTooltip
9✔
1656
                  title={<DisplayDescription description={description} />}
9✔
1657
                  placement="top"
9✔
1658
                  arrow
9✔
1659
                  describeChild
9✔
1660
                >
1661
                  <TooltipIcon />
9✔
1662
                </FieldTooltip>
9✔
1663
              )}
1664
            </Typography>
17✔
1665
          </FormArrayTitle>
17✔
1666
        }
1667
      </Grid>
17✔
1668

1669
      <Grid size={{ xs: 12, md: 8 }}>
17✔
1670
        {formFields?.map((field, index) => {
17✔
1671
          const pathWithoutLastItem = path.slice(0, -1)
1✔
1672
          const lastPathItemWithIndex = `${lastPathItem}.${index}`
1✔
1673

1674
          if (items.oneOf) {
1✔
1675
            const pathForThisIndex = [...pathWithoutLastItem, lastPathItemWithIndex]
1✔
1676

1677
            return (
1✔
1678
              <Box
1✔
1679
                key={field.id || index}
1✔
1680
                data-testid={`${name}[${index}]`}
1✔
1681
                display="flex"
1✔
1682
                alignItems="center"
1✔
1683
              >
1684
                <FormArrayChildrenTitle elevation={2} square>
1✔
1685
                  <FormOneOfField
1✔
1686
                    key={field.id}
1✔
1687
                    nestedField={field as NestedField}
1✔
1688
                    path={pathForThisIndex}
1✔
1689
                    object={items}
1✔
1690
                  />
1691
                </FormArrayChildrenTitle>
1✔
1692
                <IconButton onClick={() => handleRemove(index)}>
1✔
1693
                  <RemoveIcon />
1✔
1694
                </IconButton>
1!
1695
              </Box>
1✔
1696
            )
1697
          }
1!
1698

1699
          const properties = object.items.properties
×
1700
          let requiredProperties =
×
1701
            index === 0 && object.contains?.allOf
×
1702
              ? object.contains?.allOf?.flatMap((item: FormObject) => item.required) // Case: DAC - Main Contact needs at least 1
×
1703
              : object.items?.required
×
1704

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

1708
          return (
×
1709
            <Box key={field.id || index} aria-labelledby={name} display="flex" alignItems="center">
×
1710
              <FormArrayChildrenTitle elevation={2} square>
×
1711
                {
1712
                  items
×
1713
                    ? Object.keys(items).map(item => {
×
1714
                        const pathForThisIndex = [
×
1715
                          ...pathWithoutLastItem,
×
1716
                          lastPathItemWithIndex,
×
1717
                          item,
×
1718
                        ]
1719
                        const requiredField = requiredProperties
×
1720
                          ? requiredProperties.filter((prop: string) => prop === item)
×
1721
                          : []
×
1722
                        return traverseFields(
×
1723
                          properties[item] as FormObject,
×
1724
                          pathForThisIndex,
×
1725
                          requiredField,
×
1726
                          false,
×
1727
                          field as NestedField
×
1728
                        )
×
1729
                      })
×
1730
                    : traverseFields(
×
1731
                        object.items,
×
1732
                        [...pathWithoutLastItem, lastPathItemWithIndex],
×
1733
                        [],
×
1734
                        false,
×
1735
                        field as NestedField
×
1736
                      ) // special case for doiSchema's "sizes" and "formats"
×
1737
                }
1738
              </FormArrayChildrenTitle>
1✔
1739
              <IconButton onClick={() => handleRemove(index)} size="large">
1✔
1740
                <RemoveIcon />
1✔
1741
              </IconButton>
1!
1742
            </Box>
1✔
1743
          )
1744
        })}
17✔
1745

1746
        <Button
17✔
1747
          variant="contained"
17✔
1748
          color="primary"
17✔
1749
          size="small"
17✔
1750
          startIcon={<AddIcon />}
17✔
1751
          onClick={() => handleAppend()}
17✔
1752
          sx={{ mb: "1rem" }}
17✔
1753
        >
1754
          {t("formActions.addItem")}
17✔
1755
        </Button>
17✔
1756
      </Grid>
17✔
1757
    </Grid>
17✔
1758
  )
1759
}
17✔
1760

1761
export default {
1✔
1762
  buildFields,
1✔
1763
  cleanUpFormValues,
1✔
1764
}
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