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

CSCfi / metadata-submitter-frontend / 17543807456

08 Sep 2025 07:55AM UTC coverage: 58.373% (-0.004%) from 58.377%
17543807456

push

github

Hang Le
Fix removed form array items flagged as required (merge commit)

Merge branch 'bugfix/touched-field-required' into 'main'
* Fix removed form array item flagged as required in form validation

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

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

596 of 808 branches covered (73.76%)

Branch coverage included in aggregate %.

0 of 2 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

5358 of 9392 relevant lines covered (57.05%)

5.63 hits per line

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

66.05
/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 { 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))
5✔
86
  return traverseFormValuesForCleanUp(cleanedData)
5✔
87
}
5✔
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 => {
12✔
94
    const property = data[key] as Record<string, unknown> | string | null
22✔
95

96
    if (typeof property === "object" && !Array.isArray(property)) {
22✔
97
      if (property !== null) {
7✔
98
        data[key] = traverseFormValuesForCleanUp(property)
7✔
99
        if (Object.keys(property).length === 0) delete data[key]
7✔
100
      }
7✔
101
    }
7✔
102
    if (property === "") {
22✔
103
      delete data[key]
10✔
104
    }
10✔
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) {
12✔
109
      data[key] = Number(data[key])
1✔
110
    }
1✔
111
  })
12✔
112
  return data
12✔
113
}
12✔
114

115
/*
116
 * Build react-hook-form fields based on given schema
117
 */
118
const buildFields = (schema: FormObject) => {
1✔
119
  try {
26✔
120
    return traverseFields(schema, [])
26✔
121
  } catch (error) {
26!
122
    console.error(error)
×
123
  }
×
124
}
26✔
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()
236✔
131
  return children({ ...(methods as ConnectFormMethods) })
236✔
132
}
236✔
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) {
62✔
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 {
62✔
162
    return ""
61✔
163
  }
61✔
164
}
62✔
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,
160✔
171
  path: string[],
160✔
172
  requiredProperties?: string[],
160✔
173
  requireFirst?: boolean,
160✔
174
  nestedField?: NestedField
160✔
175
) => {
160✔
176
  const name = pathToName(path)
160✔
177
  const [lastPathItem] = path.slice(-1)
160✔
178
  const label = object.title ?? lastPathItem
160!
179
  const required = !!requiredProperties?.includes(lastPathItem) || requireFirst || false
160✔
180
  const description = object.description
160✔
181
  const autoCompleteIdentifiers = ["organisation", "name of the place of affiliation"]
160✔
182

183
  if (object.oneOf)
160✔
184
    return (
160✔
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) {
154✔
191
    case "object": {
160✔
192
      return (
68✔
193
        <FormSection
68✔
194
          key={name}
68✔
195
          name={name}
68✔
196
          label={label}
68✔
197
          level={path.length}
68✔
198
          description={description}
68✔
199
          isTitleShown
68✔
200
        >
201
          {Object.keys(object.properties).map(propertyKey => {
68✔
202
            const property = object.properties[propertyKey] as FormObject
132✔
203
            const required = object?.else?.required ?? object.required
132!
204
            let requireFirstItem = false
132✔
205

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

595
  const name = pathToName(path)
9✔
596

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

768
  // Default Value of input
769
  const defaultValue = getDefaultValue(name, nestedField)
62✔
770
  // useWatch to watch any changes in form's fields
771
  const watchValues = useWatch()
62✔
772

773
  // Case: DOI form - Affilation identifier to be prefilled and hidden
774
  const prefilledHiddenFields = ["affiliationIdentifier"]
62✔
775
  const isPrefilledHiddenField = isDOIForm && prefilledHiddenFields.includes(lastPathItem)
62!
776

777
  /*
778
   * Handle DOI form values
779
   */
780
  const { setValue, getValues } = useFormContext()
62✔
781
  const watchAutocompleteFieldName = isPrefilledHiddenField ? getPathName(path, "name") : null
62!
782
  const prefilledValue = watchAutocompleteFieldName
62!
783
    ? get(watchValues, watchAutocompleteFieldName)
×
784
    : null
62✔
785

786
  // Check value of current name path
787
  const val = getValues(name)
62✔
788

789
  React.useEffect(() => {
62✔
790
    if (!isPrefilledHiddenField) return
28!
791
    if (prefilledValue && !val) {
28!
792
      // Set value for prefilled field if autocompleteField exists
793
      setValue(name, autocompleteField)
×
794
    } else if (prefilledValue === undefined && val) {
×
795
      // Remove values if autocompleteField is deleted
796
      setValue(name, "")
×
797
    }
×
798
  }, [autocompleteField, prefilledValue])
62✔
799

800
  // Remove values for Affiliations' <location of affiliation identifier> field if autocompleteField is deleted
801
  React.useEffect(() => {
62✔
802
    if (
28✔
803
      prefilledValue === undefined &&
28!
804
      val &&
×
805
      lastPathItem === prefilledHiddenFields[0] &&
×
806
      isDOIForm
×
807
    )
808
      setValue(name, "")
28!
809
  }, [prefilledValue])
62✔
810

811
  return (
62✔
812
    <ConnectForm>
62✔
813
      {({ control }: ConnectFormMethods) => {
62✔
814
        const multiLineRowIdentifiers = ["abstract", "description", "policy text"]
62✔
815

816
        return (
62✔
817
          <Controller
62✔
818
            render={({ field, fieldState: { error } }) => {
62✔
819
              const inputValue =
63✔
820
                (watchAutocompleteFieldName && typeof val !== "object" && val) ||
63!
821
                (typeof field.value !== "object" && field.value) ||
63✔
822
                ""
60✔
823

824
              const handleChange = (e: { target: { value: string | number } }) => {
63✔
825
                const { value } = e.target
2✔
826
                const parsedValue =
2✔
827
                  type === "string" && typeof value === "number" ? value.toString() : value
2!
828
                field.onChange(parsedValue) // Helps with Cypress change detection
2✔
829
                setValue(name, parsedValue) // Enables update of nested fields, eg. DAC contact
2✔
830
              }
2✔
831

832
              return (
63✔
833
                <div style={{ marginBottom: "1rem" }}>
63✔
834
                  <BaselineDiv style={isPrefilledHiddenField ? { display: "none" } : {}}>
63!
835
                    <TextField
63✔
836
                      {...field}
63✔
837
                      slotProps={{ htmlInput: { "data-testid": name } }}
63✔
838
                      label={label}
63✔
839
                      id={name}
63✔
840
                      role="textbox"
63✔
841
                      error={!!error}
63✔
842
                      helperText={error?.message}
63✔
843
                      required={required}
63✔
844
                      type={type}
63✔
845
                      multiline={multiLineRowIdentifiers.some(value =>
63✔
846
                        label.toLowerCase().includes(value)
178✔
847
                      )}
63✔
848
                      rows={5}
63✔
849
                      value={inputValue}
63✔
850
                      onChange={handleChange}
63✔
851
                      disabled={defaultValue !== "" && name.includes("formats")}
63✔
852
                    />
853
                    {description && (
63✔
854
                      <FieldTooltip
33✔
855
                        title={<DisplayDescription description={description} />}
33✔
856
                        placement="right"
33✔
857
                        arrow
33✔
858
                        describeChild
33✔
859
                      >
860
                        <TooltipIcon />
33✔
861
                      </FieldTooltip>
33✔
862
                    )}
863
                  </BaselineDiv>
63✔
864
                </div>
63✔
865
              )
866
            }}
63✔
867
            name={name}
62✔
868
            control={control}
62✔
869
            defaultValue={defaultValue}
62✔
870
            rules={{ required: required }}
62✔
871
          />
872
        )
873
      }}
62✔
874
    </ConnectForm>
62✔
875
  )
876
}
62✔
877

878
/*
879
 * FormSelectField is rendered for selection from options where it's possible to choose many options
880
 */
881

882
const FormSelectField = ({
1✔
883
  name,
6✔
884
  label,
6✔
885
  required,
6✔
886
  options,
6✔
887
  description,
6✔
888
}: FormSelectFieldProps & { description: string }) => (
6✔
889
  <ConnectForm>
6✔
890
    {({ control }: ConnectFormMethods) => {
6✔
891
      return (
6✔
892
        <Controller
6✔
893
          name={name}
6✔
894
          control={control}
6✔
895
          render={({ field, fieldState: { error } }) => {
6✔
896
            return (
7✔
897
              <BaselineDiv>
7✔
898
                <TextField
7✔
899
                  {...field}
7✔
900
                  label={label}
7✔
901
                  id={name}
7✔
902
                  value={field.value || ""}
7✔
903
                  error={!!error}
7✔
904
                  helperText={error?.message}
7!
905
                  required={required}
7✔
906
                  select
7✔
907
                  SelectProps={{ native: true }}
7✔
908
                  onChange={e => {
7✔
909
                    let val = e.target.value
×
910
                    // Case: linkingAccessionIds which include "AccessionId + Form's title", we need to return only accessionId as value
911
                    if (val?.includes("Title")) {
×
912
                      const hyphenIndex = val.indexOf("-")
×
913
                      val = val.slice(0, hyphenIndex - 1)
×
914
                    }
×
915
                    return field.onChange(val)
×
916
                  }}
×
917
                  inputProps={{ "data-testid": name }}
7✔
918
                  sx={{ mb: "1rem" }}
7✔
919
                >
920
                  <option aria-label="None" value="" disabled />
7✔
921
                  {options.map(option => (
7✔
922
                    <option key={`${name}-${option}`} value={option} data-testid={`${name}-option`}>
21✔
923
                      {option}
21✔
924
                    </option>
21✔
925
                  ))}
7✔
926
                </TextField>
7✔
927
                {description && (
7!
928
                  <FieldTooltip
×
929
                    title={<DisplayDescription description={description} />}
×
930
                    placement="right"
×
931
                    arrow
×
932
                    describeChild
×
933
                  >
934
                    <TooltipIcon />
×
935
                  </FieldTooltip>
×
936
                )}
937
              </BaselineDiv>
7✔
938
            )
939
          }}
7✔
940
        />
941
      )
942
    }}
6✔
943
  </ConnectForm>
6✔
944
)
945

946
/*
947
 * FormDatePicker used for selecting date or date rage in DOI form
948
 */
949

950
const StyledDatePicker = styled(DatePicker)(({ theme }) => ({
1✔
951
  "& .MuiIconButton-root": {
×
952
    color: theme.palette.primary.main,
×
953
    "&:hover": {
×
954
      backgroundColor: theme.palette.action.hover,
×
955
    },
×
956
  },
×
957
}))
1✔
958

959
const FormDatePicker = ({
1✔
960
  name,
×
961
  required,
×
962
  description,
×
963
}: FormFieldBaseProps & { description: string }) => {
×
964
  const { t } = useTranslation()
×
965
  const { getValues } = useFormContext()
×
966
  const defaultValue = getValues(name) || ""
×
967

968
  const getStartEndDates = (date: string) => {
×
969
    const [startInput, endInput] = date?.split("/")
×
970
    if (startInput && endInput) return [moment(startInput), moment(endInput)]
×
971
    else if (startInput) return [moment(startInput), null]
×
972
    else if (endInput) return [null, moment(endInput)]
×
973
    else return [null, null]
×
974
  }
×
975

976
  const [startDate, setStartDate] = React.useState<moment.Moment | null>(
×
977
    getStartEndDates(defaultValue)[0]
×
978
  )
×
979
  const [endDate, setEndDate] = React.useState<moment.Moment | null>(
×
980
    getStartEndDates(defaultValue)[1]
×
981
  )
×
982
  const [startError, setStartError] = React.useState<string | null>(null)
×
983
  const [endError, setEndError] = React.useState<string | null>(null)
×
984
  const clearForm = useAppSelector(state => state.clearForm)
×
985

986
  React.useEffect(() => {
×
987
    if (clearForm) {
×
988
      setStartDate(null)
×
989
      setEndDate(null)
×
990
    }
×
991
  }, [clearForm])
×
992

993
  const format = "YYYY-MM-DD"
×
994
  const formatDate = (date: moment.Moment) => moment(date).format(format)
×
995

996
  const makeValidDateString = (start: moment.Moment | null, end: moment.Moment | null) => {
×
997
    let dateStr = ""
×
998
    if (start && start?.isValid()) {
×
999
      dateStr = formatDate(start)
×
1000
      if (end && end?.isValid() && formatDate(end) !== dateStr) {
×
1001
        dateStr += `/${formatDate(end)}`
×
1002
      }
×
1003
    }
×
1004
    return dateStr
×
1005
  }
×
1006

1007
  return (
×
1008
    <ConnectForm>
×
1009
      {({ setValue }: ConnectFormMethods) => {
×
1010
        const handleChangeStartDate = (newValue: moment.Moment | null) => {
×
1011
          setStartDate(newValue)
×
1012
          setValue(name, makeValidDateString(newValue, endDate), { shouldDirty: true })
×
1013
        }
×
1014

1015
        const handleChangeEndDate = (newValue: moment.Moment | null) => {
×
1016
          setEndDate(newValue)
×
1017
          setValue(name, makeValidDateString(startDate, newValue), { shouldDirty: true })
×
1018
        }
×
1019

1020
        return (
×
1021
          <LocalizationProvider dateAdapter={AdapterMoment}>
×
1022
            <Box
×
1023
              sx={{
×
1024
                width: "95%",
×
1025
                display: "flex",
×
1026
                flexDirection: "row",
×
1027
                alignItems: "baseline",
×
1028
                marginBottom: "1rem",
×
1029
              }}
×
1030
            >
1031
              <StyledDatePicker
×
1032
                label={t("datacite.startDate")}
×
1033
                value={startDate}
×
1034
                format={format}
×
1035
                onChange={handleChangeStartDate}
×
1036
                onError={setStartError}
×
1037
                shouldDisableDate={day => !!endDate && moment(day).isAfter(endDate)}
×
1038
                slotProps={{
×
1039
                  field: { clearable: true },
×
1040
                  textField: {
×
1041
                    required,
×
1042
                    error: !!startError,
×
1043
                    helperText: startError
×
1044
                      ? startError === "shouldDisableDate"
×
1045
                        ? t("datacite.error.range")
×
1046
                        : t("datacite.error.date")
×
1047
                      : "",
×
1048
                  },
×
1049
                  nextIconButton: { color: "primary" },
×
1050
                  previousIconButton: { color: "primary" },
×
1051
                  switchViewIcon: { color: "primary" },
×
1052
                }}
×
1053
              />
1054
              <span>–</span>
×
1055
              <StyledDatePicker
×
1056
                label={t("datacite.endDate")}
×
1057
                value={endDate}
×
1058
                format={format}
×
1059
                onChange={handleChangeEndDate}
×
1060
                onError={setEndError}
×
1061
                shouldDisableDate={day => !!startDate && moment(day).isBefore(startDate)}
×
1062
                slotProps={{
×
1063
                  field: { clearable: true },
×
1064
                  textField: {
×
1065
                    error: !!endError,
×
1066
                    helperText: endError
×
1067
                      ? endError === "shouldDisableDate"
×
1068
                        ? t("datacite.error.range")
×
1069
                        : t("datacite.error.date")
×
1070
                      : "",
×
1071
                  },
×
1072
                  nextIconButton: { color: "primary" },
×
1073
                  previousIconButton: { color: "primary" },
×
1074
                  switchViewIcon: { color: "primary" },
×
1075
                }}
×
1076
              />
1077
              {description && (
×
1078
                <FieldTooltip
×
1079
                  title={<DisplayDescription description={description} />}
×
1080
                  placement="right"
×
1081
                  arrow
×
1082
                  describeChild
×
1083
                >
1084
                  <TooltipIcon />
×
1085
                </FieldTooltip>
×
1086
              )}
1087
            </Box>
×
1088
          </LocalizationProvider>
×
1089
        )
1090
      }}
×
1091
    </ConnectForm>
×
1092
  )
1093
}
×
1094

1095
/*
1096
 * FormAutocompleteField uses ROR API to fetch organisations
1097
 */
1098
type RORItem = {
1099
  name: string
1100
  id: string
1101
}
1102
/*
1103
  More details of ROR data structure is here: https://ror.readme.io/docs/ror-data-structure
1104
*/
1105
type ROROrganization = Record<string, unknown> & {
1106
  id: string
1107
  names: { lang: string | null; types: string[]; value: string }[]
1108
}
1109

1110
const StyledAutocomplete = styled(Autocomplete)(() => ({
1✔
1111
  flex: "auto",
10✔
1112
  alignSelf: "flex-start",
10✔
1113
  "& + svg": {
10✔
1114
    marginTop: 1,
10✔
1115
  },
10✔
1116
})) as typeof Autocomplete
1✔
1117

1118
const FormAutocompleteField = ({
1✔
1119
  name,
10✔
1120
  label,
10✔
1121
  required,
10✔
1122
  description,
10✔
1123
}: FormFieldBaseProps & { description: string }) => {
10✔
1124
  const dispatch = useAppDispatch()
10✔
1125
  const { getValues } = useFormContext()
10✔
1126

1127
  const defaultValue = getValues(name) || ""
10✔
1128
  const [open, setOpen] = React.useState(false)
10✔
1129
  const [options, setOptions] = React.useState([])
10✔
1130
  const [inputValue, setInputValue] = React.useState("")
10✔
1131
  const [loading, setLoading] = React.useState(false)
10✔
1132

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

1139
    if (response) setLoading(false)
1✔
1140

1141
    if (response?.ok) {
1✔
1142
      const mappedOrganisations = response.data.items.reduce(
1✔
1143
        (orgArr: RORItem[], org: ROROrganization) => {
1✔
1144
          const orgName = org.names.filter(name => name.types.includes("ror_display"))[0]?.value
1✔
1145
          if (orgName) {
1✔
1146
            orgArr.push({
1✔
1147
              name: orgName,
1✔
1148
              id: org.id,
1✔
1149
            })
1✔
1150
          }
1✔
1151
          return orgArr
1✔
1152
        },
1✔
1153
        []
1✔
1154
      )
1✔
1155

1156
      setOptions(mappedOrganisations)
1✔
1157
    }
1✔
1158
  }
1✔
1159

1160
  const debouncedSearch = debounce((newInput: string) => {
10✔
1161
    if (newInput.length > 0) fetchOrganisations(newInput)
1✔
1162
  }, 150)
10✔
1163

1164
  React.useEffect(() => {
10✔
1165
    let active = true
5✔
1166

1167
    if (inputValue === "") {
5✔
1168
      setOptions([])
3✔
1169
      return undefined
3✔
1170
    }
3✔
1171

1172
    if (active && open) {
5✔
1173
      setLoading(true)
1✔
1174
      debouncedSearch(inputValue)
1✔
1175
    }
1✔
1176

1177
    return () => {
2✔
1178
      active = false
2✔
1179
      setLoading(false)
2✔
1180
    }
2✔
1181
  }, [inputValue])
10✔
1182

1183
  return (
10✔
1184
    <ConnectForm>
10✔
1185
      {({ errors, control }: ConnectFormMethods) => {
10✔
1186
        const error = get(errors, name)
10✔
1187

1188
        return (
10✔
1189
          <Controller
10✔
1190
            name={name}
10✔
1191
            control={control}
10✔
1192
            defaultValue={defaultValue}
10✔
1193
            render={({ field }) => {
10✔
1194
              return (
10✔
1195
                <StyledAutocomplete
10✔
1196
                  freeSolo
10✔
1197
                  open={open}
10✔
1198
                  onOpen={() => {
10✔
1199
                    setOpen(true)
1✔
1200
                  }}
1✔
1201
                  onClose={() => {
10✔
1202
                    setOpen(false)
1✔
1203
                  }}
1✔
1204
                  options={options}
10✔
1205
                  getOptionKey={option => option.id ?? ""}
10!
1206
                  getOptionLabel={option => option.name || ""}
10✔
1207
                  disableClearable={inputValue.length === 0}
10✔
1208
                  value={field.value}
10✔
1209
                  inputValue={inputValue || defaultValue}
10✔
1210
                  onChange={(_event, option: RORItem) => {
10✔
1211
                    field.onChange(option?.name)
1✔
1212
                    option?.id
1✔
1213
                      ? dispatch(setAutocompleteField(option.id))
1!
1214
                      : dispatch(setAutocompleteField(null))
×
1215
                  }}
1✔
1216
                  onInputChange={(_event, newInputValue: string) => setInputValue(newInputValue)}
10✔
1217
                  renderInput={params => (
10✔
1218
                    <BaselineDiv>
10✔
1219
                      <TextField
10✔
1220
                        {...params}
10✔
1221
                        label={label}
10✔
1222
                        id={name}
10✔
1223
                        name={name}
10✔
1224
                        variant="outlined"
10✔
1225
                        error={!!error}
10✔
1226
                        required={required}
10✔
1227
                        InputProps={{
10✔
1228
                          ...params.InputProps,
10✔
1229
                          endAdornment: (
10✔
1230
                            <React.Fragment>
10✔
1231
                              {loading ? <CircularProgress color="inherit" size={20} /> : null}
10✔
1232
                              {params.InputProps.endAdornment}
10✔
1233
                            </React.Fragment>
10✔
1234
                          ),
1235
                        }}
10✔
1236
                        inputProps={{ ...params.inputProps, "data-testid": `${name}-inputField` }}
10✔
1237
                        sx={[
10✔
1238
                          {
10✔
1239
                            "&.MuiAutocomplete-endAdornment": {
10✔
1240
                              top: 0,
10✔
1241
                            },
10✔
1242
                          },
10✔
1243
                        ]}
10✔
1244
                      />
1245
                      {description && (
10!
1246
                        <FieldTooltip
×
1247
                          title={
×
1248
                            <DisplayDescription description={description}>
×
1249
                              <>
×
1250
                                <br />
×
1251
                                {"Organisations provided by "}
×
1252
                                <a href="https://ror.org/" target="_blank" rel="noreferrer">
×
1253
                                  {"ror.org"}
×
1254
                                  <LaunchIcon sx={{ fontSize: "1rem", mb: -1 }} />
×
1255
                                </a>
×
1256
                              </>
×
1257
                            </DisplayDescription>
×
1258
                          }
1259
                          placement="right"
×
1260
                          arrow
×
1261
                          describeChild
×
1262
                        >
1263
                          <TooltipIcon />
×
1264
                        </FieldTooltip>
×
1265
                      )}
1266
                    </BaselineDiv>
10✔
1267
                  )}
10✔
1268
                />
1269
              )
1270
            }}
10✔
1271
          />
1272
        )
1273
      }}
10✔
1274
    </ConnectForm>
10✔
1275
  )
1276
}
10✔
1277

1278
const ValidationTagField = styled(TextField)(({ theme }) => ({
1✔
1279
  "& .MuiOutlinedInput-root.MuiInputBase-root": { flexWrap: "wrap" },
×
1280
  "& input": { flex: 1, minWidth: "2rem" },
×
1281
  "& label": { color: theme.palette.primary.main },
×
1282
  "& .MuiOutlinedInput-notchedOutline, div:hover .MuiOutlinedInput-notchedOutline":
×
1283
    highlightStyle(theme),
×
1284
}))
1✔
1285

1286
const FormTagField = ({
1✔
1287
  name,
×
1288
  label,
×
1289
  required,
×
1290
  description,
×
1291
}: FormFieldBaseProps & { description: string }) => {
×
1292
  const savedValues = useWatch({ name })
×
1293
  const [inputValue, setInputValue] = React.useState("")
×
1294
  const [tags, setTags] = React.useState<Array<string>>([])
×
1295

1296
  React.useEffect(() => {
×
1297
    // update tags when value becomes available or changes
1298
    const updatedTags = savedValues ? savedValues.split(",") : []
×
1299
    setTags(updatedTags)
×
1300
  }, [savedValues])
×
1301

1302
  const handleInputChange = e => {
×
1303
    setInputValue(e.target.value)
×
1304
  }
×
1305

1306
  const clearForm = useAppSelector(state => state.clearForm)
×
1307

1308
  React.useEffect(() => {
×
1309
    if (clearForm) {
×
1310
      setTags([])
×
1311
      setInputValue("")
×
1312
    }
×
1313
  }, [clearForm])
×
1314

1315
  const inputRef = React.useRef<HTMLInputElement | null>(null)
×
1316

1317
  return (
×
1318
    <ConnectForm>
×
1319
      {({ control }: ConnectFormMethods) => {
×
1320
        return (
×
1321
          <Controller
×
1322
            name={name}
×
1323
            control={control}
×
1324
            defaultValue={""}
×
1325
            render={({ field }) => {
×
1326
              const handleKeywordAsTag = (keyword: string) => {
×
1327
                // newTags with unique values
1328
                const newTags = !tags.includes(keyword) ? [...tags, keyword] : tags
×
1329
                setInputValue("")
×
1330
                // Convert tags to string for hidden registered input's values
1331
                field.onChange(newTags.join(","))
×
1332
              }
×
1333

1334
              const handleKeyDown = e => {
×
1335
                const { key } = e
×
1336
                const trimmedInput = inputValue.trim()
×
1337
                // Convert to tags if users press "," OR "Enter"
1338
                if ((key === "," || key === "Enter") && trimmedInput.length > 0) {
×
1339
                  e.preventDefault()
×
1340
                  handleKeywordAsTag(trimmedInput)
×
1341
                }
×
1342
              }
×
1343

1344
              // Convert to tags when user clicks outside of input field
1345
              const handleOnBlur = () => {
×
1346
                const trimmedInput = inputValue.trim()
×
1347
                if (trimmedInput.length > 0) {
×
1348
                  handleKeywordAsTag(trimmedInput)
×
1349
                }
×
1350
              }
×
1351

1352
              const handleTagDelete = item => () => {
×
1353
                const newTags = tags.filter(tag => tag !== item)
×
1354
                field.onChange(newTags.join(","))
×
1355
                // manual focus to trigger blur event
1356
                inputRef.current?.focus()
×
1357
              }
×
1358

1359
              return (
×
1360
                <BaselineDiv>
×
1361
                  <input
×
1362
                    {...field}
×
1363
                    required={required}
×
1364
                    style={{ width: 0, opacity: 0, transform: "translate(8rem, 2rem)" }}
×
1365
                    ref={inputRef}
×
1366
                  />
1367
                  <ValidationTagField
×
1368
                    slotProps={{
×
1369
                      htmlInput: { "data-testid": name },
×
1370
                      input: {
×
1371
                        startAdornment:
×
1372
                          tags.length > 0
×
1373
                            ? tags.map(item => (
×
1374
                                <Chip
×
1375
                                  key={item}
×
1376
                                  tabIndex={-1}
×
1377
                                  label={item}
×
1378
                                  onDelete={handleTagDelete(item)}
×
1379
                                  color="primary"
×
1380
                                  deleteIcon={<ClearIcon fontSize="small" />}
×
1381
                                  data-testid={item}
×
1382
                                  sx={{ fontSize: "1.4rem", m: "0.5rem" }}
×
1383
                                />
1384
                              ))
×
1385
                            : null,
×
1386
                      },
×
1387
                    }}
×
1388
                    label={label}
×
1389
                    id={name}
×
1390
                    value={inputValue}
×
1391
                    onChange={handleInputChange}
×
1392
                    onKeyDown={handleKeyDown}
×
1393
                    onBlur={handleOnBlur}
×
1394
                  />
1395

1396
                  {description && (
×
1397
                    <FieldTooltip
×
1398
                      title={<DisplayDescription description={description} />}
×
1399
                      placement="right"
×
1400
                      arrow
×
1401
                      describeChild
×
1402
                    >
1403
                      <TooltipIcon />
×
1404
                    </FieldTooltip>
×
1405
                  )}
1406
                </BaselineDiv>
×
1407
              )
1408
            }}
×
1409
          />
1410
        )
1411
      }}
×
1412
    </ConnectForm>
×
1413
  )
1414
}
×
1415

1416
/*
1417
 * Highlight required Checkbox
1418
 */
1419
const ValidationFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
1✔
1420
  label: {
6✔
1421
    "& span": { color: theme.palette.primary.main },
6✔
1422
  },
6✔
1423
}))
1✔
1424

1425
const FormBooleanField = ({
1✔
1426
  name,
6✔
1427
  label,
6✔
1428
  required,
6✔
1429
  description,
6✔
1430
}: FormFieldBaseProps & { description: string }) => {
6✔
1431
  return (
6✔
1432
    <ConnectForm>
6✔
1433
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1434
        const error = get(errors, name)
6✔
1435

1436
        const { ref, ...rest } = register(name)
6✔
1437
        // DAC form: "values" of MainContact checkbox
1438
        const values = getValues(name)
6✔
1439
        return (
6✔
1440
          <Box display="inline" px={1}>
6✔
1441
            <FormControl error={!!error} required={required}>
6✔
1442
              <FormGroup>
6✔
1443
                <BaselineDiv>
6✔
1444
                  <ValidationFormControlLabel
6✔
1445
                    control={
6✔
1446
                      <Checkbox
6✔
1447
                        id={name}
6✔
1448
                        {...rest}
6✔
1449
                        name={name}
6✔
1450
                        required={required}
6✔
1451
                        inputRef={ref}
6✔
1452
                        color="primary"
6✔
1453
                        checked={values || false}
6✔
1454
                        inputProps={
6✔
1455
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
6✔
1456
                        }
6✔
1457
                      />
1458
                    }
1459
                    label={
6✔
1460
                      <label>
6✔
1461
                        {label}
6✔
1462
                        <span>{required ? ` * ` : ""}</span>
6!
1463
                      </label>
6✔
1464
                    }
6✔
1465
                  />
1466
                  {description && (
6!
1467
                    <FieldTooltip
×
1468
                      title={<DisplayDescription description={description} />}
×
1469
                      placement="right"
×
1470
                      arrow
×
1471
                      describeChild
×
1472
                    >
1473
                      <TooltipIcon />
×
1474
                    </FieldTooltip>
×
1475
                  )}
1476
                </BaselineDiv>
6✔
1477

1478
                <FormHelperText>{error?.message}</FormHelperText>
6!
1479
              </FormGroup>
6✔
1480
            </FormControl>
6✔
1481
          </Box>
6✔
1482
        )
1483
      }}
6✔
1484
    </ConnectForm>
6✔
1485
  )
1486
}
6✔
1487

1488
const FormCheckBoxArray = ({
1✔
1489
  name,
6✔
1490
  label,
6✔
1491
  required,
6✔
1492
  options,
6✔
1493
  description,
6✔
1494
}: FormSelectFieldProps & { description: string }) => (
6✔
1495
  <Box px={1}>
6✔
1496
    <p>{label}</p>
6✔
1497
    <ConnectForm>
6✔
1498
      {({ register, errors, getValues }: ConnectFormMethods) => {
6✔
1499
        const values = getValues()[name]
6✔
1500

1501
        const error = get(errors, name)
6✔
1502

1503
        const { ref, ...rest } = register(name)
6✔
1504

1505
        return (
6✔
1506
          <FormControl error={!!error} required={required}>
6✔
1507
            <FormGroup aria-labelledby={name}>
6✔
1508
              {options.map(option => (
6✔
1509
                <React.Fragment key={option}>
12✔
1510
                  <FormControlLabel
12✔
1511
                    key={option}
12✔
1512
                    control={
12✔
1513
                      <Checkbox
12✔
1514
                        {...rest}
12✔
1515
                        inputRef={ref}
12✔
1516
                        name={name}
12✔
1517
                        value={option}
12✔
1518
                        checked={values && values?.includes(option) ? true : false}
12!
1519
                        color="primary"
12✔
1520
                        defaultValue=""
12✔
1521
                        inputProps={
12✔
1522
                          { "data-testid": name } as React.InputHTMLAttributes<HTMLInputElement>
12✔
1523
                        }
12✔
1524
                      />
1525
                    }
1526
                    label={option}
12✔
1527
                  />
1528
                  {description && (
12!
1529
                    <FieldTooltip
×
1530
                      title={<DisplayDescription description={description} />}
×
1531
                      placement="right"
×
1532
                      arrow
×
1533
                      describeChild
×
1534
                    >
1535
                      <TooltipIcon />
×
1536
                    </FieldTooltip>
×
1537
                  )}
1538
                </React.Fragment>
12✔
1539
              ))}
6✔
1540
              <FormHelperText>{error?.message}</FormHelperText>
6!
1541
            </FormGroup>
6✔
1542
          </FormControl>
6✔
1543
        )
1544
      }}
6✔
1545
    </ConnectForm>
6✔
1546
  </Box>
6✔
1547
)
1548

1549
type FormArrayProps = {
1550
  object: FormObject
1551
  path: Array<string>
1552
  required: boolean
1553
}
1554

1555
const FormArrayTitle = styled(Paper, { shouldForwardProp: prop => prop !== "level" })<{
1✔
1556
  level: number
1557
}>(({ theme, level }) => ({
1✔
1558
  display: "flex",
8✔
1559
  flexDirection: "column",
8✔
1560
  justifyContent: "start",
8✔
1561
  alignItems: "start",
8✔
1562
  backgroundColor: level < 2 ? theme.palette.primary.light : theme.palette.common.white,
8!
1563
  height: "100%",
8✔
1564
  marginLeft: level < 2 ? "5rem" : 0,
8!
1565
  marginRight: "3rem",
8✔
1566
  padding: level === 1 ? "2rem" : 0,
8!
1567
}))
1✔
1568

1569
const FormArrayChildrenTitle = styled(Paper)(() => ({
1✔
1570
  width: "70%",
1✔
1571
  display: "inline-block",
1✔
1572
  marginBottom: "1rem",
1✔
1573
  paddingLeft: "1rem",
1✔
1574
  paddingTop: "1rem",
1✔
1575
}))
1✔
1576

1577
/*
1578
 * FormArray is rendered for arrays of objects. User is given option to choose how many objects to add to array.
1579
 */
1580
const FormArray = ({
1✔
1581
  object,
8✔
1582
  path,
8✔
1583
  required,
8✔
1584
  description,
8✔
1585
}: FormArrayProps & { description: string }) => {
8✔
1586
  const name = pathToName(path)
8✔
1587
  const [lastPathItem] = path.slice(-1)
8✔
1588
  const level = path.length
8✔
1589
  const label = object.title ?? lastPathItem
8!
1590

1591
  // Get currentObject and the values of current field
1592
  const currentObject = useAppSelector(state => state.currentObject) || {}
8!
1593
  const fileTypes = useAppSelector(state => state.fileTypes)
8✔
1594

1595
  const fieldValues = get(currentObject, name)
8✔
1596

1597
  const items = traverseValues(object.items) as FormObject
8✔
1598

1599
  const { control } = useForm()
8✔
1600

1601
  const {
8✔
1602
    unregister,
8✔
1603
    getValues,
8✔
1604
    setValue,
8✔
1605
    formState: { isSubmitted },
8✔
1606
    clearErrors,
8✔
1607
  } = useFormContext()
8✔
1608

1609
  const { fields, append, remove } = useFieldArray({ control, name })
8✔
1610

1611
  const [formFields, setFormFields] = React.useState<Record<"id", string>[] | null>(null)
8✔
1612
  const { t } = useTranslation()
8✔
1613

1614
  // Append the correct values to the equivalent fields when editing form
1615
  // This applies for the case: "fields" does not get the correct data (empty array) although there are values in the fields
1616
  // E.g. Study > StudyLinks or Experiment > Expected Base Call Table
1617
  // Append only once when form is populated
1618
  React.useEffect(() => {
8✔
1619
    if (
4✔
1620
      fieldValues?.length > 0 &&
4!
1621
      fields?.length === 0 &&
×
1622
      typeof fieldValues === "object" &&
×
1623
      !formFields
×
1624
    ) {
4!
1625
      const fieldsArray: Record<string, unknown>[] = []
×
1626
      for (let i = 0; i < fieldValues.length; i += 1) {
×
1627
        fieldsArray.push({ fieldValues: fieldValues[i] })
×
1628
      }
×
1629
      append(fieldsArray)
×
1630
    }
×
1631
    // Create initial fields when editing object
1632
    setFormFields(fields)
4✔
1633
  }, [fields])
8✔
1634

1635
  // Get unique fileTypes from submitted fileTypes
1636
  const uniqueFileTypes = uniq(
8✔
1637
    flatten(fileTypes?.map((obj: { fileTypes: string[] }) => obj.fileTypes))
8✔
1638
  )
8✔
1639

1640
  React.useEffect(() => {
8✔
1641
    // Append fileType to formats' field
1642
    if (name === "formats") {
3!
1643
      for (let i = 0; i < uniqueFileTypes.length; i += 1) {
×
1644
        append({ formats: uniqueFileTypes[i] })
×
1645
      }
×
1646
    }
×
1647
  }, [uniqueFileTypes.length])
8✔
1648

1649
  // Clear required field array error and append
1650
  const handleAppend = () => {
8✔
1651
    clearErrors([name])
1✔
1652
    append({})
1✔
1653
  }
1✔
1654

1655
  const handleRemove = (index: number) => {
8✔
1656
    // Unregister field if removing last item: empty array isn't flagged as missing or invalid
1657
    if (index === 0 && getValues(name)?.length <= 1) {
×
NEW
1658
      setFormFields([])
×
NEW
1659
      remove(index)
×
1660
      unregister(name)
×
1661
    } else {
×
1662
      // Set the correct values according to the name path when removing a field
1663
      const values = getValues(name)
×
1664
      const filteredValues = values?.filter((_val: unknown, ind: number) => ind !== index)
×
1665
      setValue(name, filteredValues)
×
1666
      setFormFields(filteredValues)
×
1667
      remove(index)
×
1668
    }
×
1669
    if (document.activeElement instanceof HTMLElement) {
×
1670
      // force input check onBlur
1671
      document.activeElement.blur()
×
1672
    }
×
1673
  }
×
1674

1675
  return (
8✔
1676
    <Grid
8✔
1677
      container
8✔
1678
      key={`${name}-array`}
8✔
1679
      aria-labelledby={name}
8✔
1680
      data-testid={name}
8✔
1681
      direction={level < 2 ? "row" : "column"}
8!
1682
      sx={{ mb: level === 1 ? "3rem" : 0 }}
8!
1683
    >
1684
      <Grid size={{ xs: 12, md: 4 }}>
8✔
1685
        {
1686
          <FormArrayTitle square={true} elevation={0} level={level}>
8✔
1687
            <Typography
8✔
1688
              key={`${name}-header`}
8✔
1689
              variant={"subtitle1"}
8✔
1690
              data-testid={name}
8✔
1691
              role="heading"
8✔
1692
              color="secondary"
8✔
1693
            >
1694
              {label}
8✔
1695
              {required ? "*" : null}
8!
1696
              {required && formFields?.length === 0 && isSubmitted && (
8!
1697
                <span>
×
1698
                  <FormControl error>
×
1699
                    <FormHelperText>{t("errors.form.empty")}</FormHelperText>
×
1700
                  </FormControl>
×
1701
                </span>
×
1702
              )}
1703
              {description && (
8!
1704
                <FieldTooltip
×
1705
                  title={<DisplayDescription description={description} />}
×
1706
                  placement="top"
×
1707
                  arrow
×
1708
                  describeChild
×
1709
                >
1710
                  <TooltipIcon />
×
1711
                </FieldTooltip>
×
1712
              )}
1713
            </Typography>
8✔
1714
          </FormArrayTitle>
8✔
1715
        }
1716
      </Grid>
8✔
1717

1718
      <Grid size={{ xs: 12, md: 8 }}>
8✔
1719
        {formFields?.map((field, index) => {
8✔
1720
          const pathWithoutLastItem = path.slice(0, -1)
1✔
1721
          const lastPathItemWithIndex = `${lastPathItem}.${index}`
1✔
1722

1723
          if (items.oneOf) {
1✔
1724
            const pathForThisIndex = [...pathWithoutLastItem, lastPathItemWithIndex]
1✔
1725

1726
            return (
1✔
1727
              <Box
1✔
1728
                key={field.id || index}
1✔
1729
                data-testid={`${name}[${index}]`}
1✔
1730
                display="flex"
1✔
1731
                alignItems="center"
1✔
1732
              >
1733
                <FormArrayChildrenTitle elevation={2} square>
1✔
1734
                  <FormOneOfField
1✔
1735
                    key={field.id}
1✔
1736
                    nestedField={field as NestedField}
1✔
1737
                    path={pathForThisIndex}
1✔
1738
                    object={items}
1✔
1739
                  />
1740
                </FormArrayChildrenTitle>
1✔
1741
                <IconButton onClick={() => handleRemove(index)}>
1✔
1742
                  <RemoveIcon />
1✔
1743
                </IconButton>
1!
1744
              </Box>
1✔
1745
            )
1746
          }
1!
1747

1748
          const properties = object.items.properties
×
1749
          let requiredProperties =
×
1750
            index === 0 && object.contains?.allOf
×
1751
              ? object.contains?.allOf?.flatMap((item: FormObject) => item.required) // Case: DAC - Main Contact needs at least 1
×
1752
              : object.items?.required
×
1753

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

1757
          return (
×
1758
            <Box key={field.id || index} aria-labelledby={name} display="flex" alignItems="center">
×
1759
              <FormArrayChildrenTitle elevation={2} square>
×
1760
                {
1761
                  items
×
1762
                    ? Object.keys(items).map(item => {
×
1763
                        const pathForThisIndex = [
×
1764
                          ...pathWithoutLastItem,
×
1765
                          lastPathItemWithIndex,
×
1766
                          item,
×
1767
                        ]
1768
                        const requiredField = requiredProperties
×
1769
                          ? requiredProperties.filter((prop: string) => prop === item)
×
1770
                          : []
×
1771
                        return traverseFields(
×
1772
                          properties[item] as FormObject,
×
1773
                          pathForThisIndex,
×
1774
                          requiredField,
×
1775
                          false,
×
1776
                          field as NestedField
×
1777
                        )
×
1778
                      })
×
1779
                    : traverseFields(
×
1780
                        object.items,
×
1781
                        [...pathWithoutLastItem, lastPathItemWithIndex],
×
1782
                        [],
×
1783
                        false,
×
1784
                        field as NestedField
×
1785
                      ) // special case for doiSchema's "sizes" and "formats"
×
1786
                }
1787
              </FormArrayChildrenTitle>
1✔
1788
              <IconButton onClick={() => handleRemove(index)} size="large">
1✔
1789
                <RemoveIcon />
1✔
1790
              </IconButton>
1!
1791
            </Box>
1✔
1792
          )
1793
        })}
8✔
1794

1795
        <Button
8✔
1796
          variant="contained"
8✔
1797
          color="primary"
8✔
1798
          size="small"
8✔
1799
          startIcon={<AddIcon />}
8✔
1800
          onClick={() => handleAppend()}
8✔
1801
          sx={{ mb: "1rem" }}
8✔
1802
        >
1803
          {t("formActions.addItem")}
8✔
1804
        </Button>
8✔
1805
      </Grid>
8✔
1806
    </Grid>
8✔
1807
  )
1808
}
8✔
1809

1810
export default {
1✔
1811
  buildFields,
1✔
1812
  cleanUpFormValues,
1✔
1813
}
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