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

CSCfi / metadata-submitter-frontend / 20331691855

18 Dec 2025 09:07AM UTC coverage: 55.065% (-3.3%) from 58.346%
20331691855

push

github

Hang Le
Update dependency @vitest/coverage-v8 to v4 (merge commit)

Merge branch 'renovate/vitest-coverage-v8-4.x' into 'main'
* Update dependency @vitest/coverage-v8 to v4

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

Approved-by: Liisa Lado-Villar <145-lilado@users.noreply.gitlab.ci.csc.fi>
Co-authored-by: renovate-bot <group_183_bot_aa67d732ac40e4c253df6728543b928a@noreply.gitlab.ci.csc.fi>
Merged by Hang Le <lhang@csc.fi>

476 of 1055 branches covered (45.12%)

Branch coverage included in aggregate %.

1307 of 2183 relevant lines covered (59.87%)

8.04 hits per line

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

68.85
/src/components/SubmissionWizard/WizardForms/WizardFillObjectDetailsForm.tsx
1
/* XML upload is disabled for MVP */
2
/* Breaking change for JSON schema version draft-2020-12:
3
 * https://ajv.js.org/json-schema.html#draft-2020-12
4
 */
5
import React, { useEffect, useState, useRef, useTransition } from "react"
6

7
import { GlobalStyles } from "@mui/material"
8
import Button from "@mui/material/Button"
9
import CircularProgress from "@mui/material/CircularProgress"
10
import Container from "@mui/material/Container"
11
import LinearProgress from "@mui/material/LinearProgress"
12
import { Box, styled } from "@mui/system"
13
import { cloneDeep } from "lodash"
14
import { useForm, FormProvider, FieldValues, SubmitHandler, Resolver } from "react-hook-form"
15
import type { FieldErrors, UseFormReturn } from "react-hook-form"
16
import { useTranslation } from "react-i18next"
17

18
import WizardStepContentHeader from "../WizardComponents/WizardStepContentHeader"
19
import checkUnsavedInputHook from "../WizardHooks/WizardCheckUnsavedInputHook"
20
import submitObjectHook from "../WizardHooks/WizardSubmitObjectHook"
21

22
import { WizardAjvResolver } from "./WizardAjvResolver"
23
import JSONSchemaParser from "./WizardJSONSchemaParser"
24
//import WizardXMLUploadModal from "./WizardXMLUploadModal"
25

26
import { ResponseStatus } from "constants/responseStatus"
27
import { Namespaces } from "constants/translation"
28
import { SDObjectTypes } from "constants/wizardObject"
29
import { resetAutocompleteField } from "features/autocompleteSlice"
30
import { setClearForm } from "features/clearFormSlice"
31
import { updateStatus } from "features/statusMessageSlice"
32
import { resetUnsavedForm } from "features/unsavedFormSlice"
33
import { setCurrentObject } from "features/wizardCurrentObjectSlice"
34
import { addMetadataToSubmission } from "features/wizardSubmissionSlice"
35
//import { setXMLModalOpen, resetXMLModalOpen } from "features/wizardXMLModalSlice"
36
import { useAppSelector, useAppDispatch } from "hooks"
37
import type {
38
  MetadataFormDetails,
39
  SubmissionDetailsWithId,
40
  CurrentFormObject,
41
  FormObject,
42
} from "types"
43
import {
44
  dereferenceSchema,
45
  loadJSONSchema,
46
  validateJSONSchema,
47
  localizeSchema,
48
} from "utils/JSONSchemaUtils"
49

50
const Form = styled("form")(({ theme }) => ({
14✔
51
  ...theme.form,
52
}))
53

54
type CustomCardHeaderProps = {
55
  objectType: string
56
  onClickSubmit: () => void
57
  onClickSaveDOI: () => Promise<void>
58
  // onClickClearForm: () => void
59
  // onOpenXMLModal: () => void
60
  // onDeleteForm: () => void
61
  refForm: string
62
}
63

64
type FormContentProps = {
65
  methods: UseFormReturn
66
  formSchema: FormObject
67
  onSubmit: SubmitHandler<FieldValues>
68
  objectType: string
69
  submission: SubmissionDetailsWithId
70
  currentObject: CurrentFormObject
71
}
72

73
/*
74
 * Create header for form card with button to close the card
75
 */
76
const CustomCardHeader = (props: CustomCardHeaderProps) => {
8✔
77
  const {
78
    objectType,
79
    refForm,
80
    onClickSubmit,
81
    onClickSaveDOI,
82
    // onClickClearForm,
83
    // onOpenXMLModal,
84
    // onDeleteForm,
85
  } = props
14✔
86

87
  const focusTarget = useRef<HTMLButtonElement>(null)
14✔
88
  const shouldFocus = useAppSelector(state => state.focus)
24✔
89
  const { t } = useTranslation()
14✔
90

91
  useEffect(() => {
14✔
92
    if (shouldFocus && focusTarget.current) focusTarget.current.focus()
6!
93
  }, [shouldFocus])
94

95
  const SaveButton = (
96
    <Button
14✔
97
      ref={focusTarget}
98
      variant="contained"
99
      aria-label={t("ariaLabels.submitForm")}
100
      size="small"
101
      onClick={objectType === SDObjectTypes.publicMetadata ? onClickSaveDOI : onClickSubmit}
14!
102
      form={refForm}
103
      data-testid="form-datacite"
104
      type="submit"
105
    >
106
      {t("save")}
107
    </Button>
108
  )
109

110
  return <WizardStepContentHeader action={SaveButton} />
14✔
111
}
112

113
/*
114
 * Return react-hook-form based form which is rendered from schema and checked against resolver.
115
 */
116
const FormContent = ({
8✔
117
  methods,
118
  formSchema,
119
  onSubmit,
120
  objectType,
121
  submission,
122
  currentObject,
123
}: FormContentProps) => {
124
  const dispatch = useAppDispatch()
14✔
125
  const { t } = useTranslation()
14✔
126
  const clearForm = useAppSelector(state => state.clearForm)
24✔
127
  // const alert = useAppSelector(state => state.alert)
128
  const [currentObjectId, setCurrentObjectId] = useState<string | null>(currentObject?.accessionId)
14✔
129

130
  // const autoSaveTimer: { current: NodeJS.Timeout | null } = useRef(null)
131
  // let timer = 0
132

133
  // const { isSubmitSuccessful } = methods.formState
134

135
  // Set form default values
136
  useEffect(() => {
14✔
137
    try {
6✔
138
      const mutable = cloneDeep(currentObject)
6✔
139
      methods.reset(mutable)
6✔
140
    } catch (e) {
141
      console.error("Reset failed:", e)
×
142
    }
143
  }, [currentObject?.accessionId])
144

145
  useEffect(() => {
14✔
146
    //   if (isSubmitSuccessful) {
147
    dispatch(setClearForm(false))
6✔
148
  }, [clearForm])
149

150
  // useEffect(() => {
151
  //   checkDirty()
152
  // }, [methods.formState.isDirty])
153

154
  // const handleClearForm = () => {
155
  //   methods.reset({ undefined })
156
  //   dispatch(setClearForm(true))
157
  //   dispatch(
158
  //     setCurrentObject({ })
159
  //   )
160
  // }
161

162
  // Check if the form is empty
163
  // const isFormCleanedValuesEmpty = (cleanedValues: {
164
  //   [x: string]: unknown
165
  //   [x: number]: unknown
166
  //   accessionId?: string
167
  //   lastModified?: string
168
  //   objectType?: string
169
  //   status?: string
170
  //   title?: string
171
  //   submissionType?: string
172
  // }) => {
173
  //   return Object.keys(cleanedValues).filter(val => val !== "index").length === 0
174
  // }
175

176
  // const checkDirty = () => {
177
  //   const isFormTouched = () => {
178
  //     return Object.keys(methods.formState.dirtyFields).length > 0
179
  //   }
180
  // }
181

182
  const getCleanedValues = () =>
14✔
183
    JSONSchemaParser.cleanUpFormValues(methods.getValues()) as CurrentFormObject
2✔
184

185
  const handleChange = () => {
14✔
186
    clearForm ? dispatch(setClearForm(false)) : null
2!
187
    const clone = cloneDeep(currentObject)
2✔
188
    const values = getCleanedValues()
2✔
189

190
    if (clone && Object.keys(values).filter(val => val !== "index").length > 0) {
2!
191
      Object.keys(values).forEach(item => (clone[item] = values[item]))
2✔
192

193
      !currentObject.accessionId && currentObjectId
2!
194
        ? dispatch(
195
            setCurrentObject({
196
              ...clone,
197
              cleanedValues: values,
198
              objectId: currentObjectId,
199
            })
200
          )
201
        : dispatch(setCurrentObject({ ...clone, cleanedValues: values }))
202
    }
203
  }
204

205
  const handleMetadataSubmit = async (data: MetadataFormDetails) => {
14✔
206
    dispatch(addMetadataToSubmission(submission.submissionId, data))
×
207
      .then(() => {
208
        dispatch(resetAutocompleteField())
×
209
        // dispatch(resetCurrentObject())
210
        methods.reset(data, { keepValues: true })
×
211
        dispatch(resetUnsavedForm())
×
212
        dispatch(
×
213
          updateStatus({
214
            status: ResponseStatus.success,
215
            helperText: "snackbarMessages.success.doi.saved",
216
          })
217
        )
218
      })
219
      .catch(error =>
220
        dispatch(
×
221
          updateStatus({
222
            status: ResponseStatus.error,
223
            response: error,
224
            helperText: "snackbarMessages.error.helperText.submitDoi",
225
          })
226
        )
227
      )
228
  }
229

230
  const handleValidationErrors = (errors: FieldErrors) => {
14✔
231
    const missingRequired = Object.values(errors).some(err =>
×
232
      err?.message?.toString().includes("required")
×
233
    )
234
    const message = missingRequired
×
235
      ? t("snackbarMessages.info.requiredFields")
236
      : t("snackbarMessages.info.invalidFields")
237
    dispatch(
×
238
      updateStatus({
239
        status: ResponseStatus.info,
240
        helperText: message,
241
      })
242
    )
243
  }
244

245
  /*
246
   * Logic for auto-save feature.
247
   * This helps with getting current accession ID and form data without rendering on every timer increment.
248
   */
249

250
  // const startTimer = () => {
251
  //   autoSaveTimer.current = setInterval(() => {
252
  //     timer = timer + 1
253
  //     if (timer >= 60) {
254
  //     }
255
  //   }, 1000)
256
  // }
257

258
  // const resetTimer = () => {
259
  //   clearInterval(autoSaveTimer.current as NodeJS.Timeout)
260
  //   timer = 0
261
  // }
262

263
  // const keyHandler = () => {
264
  //   resetTimer()
265

266
  //   // Prevent auto save from DOI form
267
  //   if (![SDObjectTypes.datacite, FEGAObjectTypes.study].includes(objectType)) startTimer()
268
  // }
269

270
  // useEffect(() => {
271
  //   window.addEventListener("keydown", keyHandler)
272
  //   return () => {
273
  //     resetTimer()
274
  //     window.removeEventListener("keydown", keyHandler)
275
  //   }
276
  // }, [])
277

278
  // const emptyFormError = () => {
279
  //   dispatch(
280
  //     updateStatus({
281
  //       status: ResponseStatus.info,
282
  //       helperText: t("snackbarMessages.info.emptyForm"),
283
  //     })
284
  //   )
285
  // }
286

287
  /*const handleXMLModalOpen = () => {
288
    dispatch(setXMLModalOpen())
289
  }*/
290

291
  // const handleDeleteForm = async () => {
292
  //   if (currentObjectId) {
293
  //     try {
294
  //       *TODO: deleteObjectFromSubmission is removed, need to replace this func.
295
  //       await dispatch(
296
  //         deleteObjectFromSubmission(currentObject.status, currentObjectId, objectType)
297
  //       )
298
  //       handleReset()
299
  //       handleChange()
300
  //       dispatch(resetCurrentObject())
301

302
  //       if (objectType === FEGAObjectTypes.analysis || objectType === FEGAObjectTypes.run) {
303
  //         dispatch(deleteFileType(currentObjectId))
304
  //       }
305
  //     } catch (error) {
306
  //       dispatch(
307
  //         updateStatus({
308
  //           status: ResponseStatus.error,
309
  //           response: error,
310
  //           helperText: "snackbarMessages.error.helperText.deleteObject",
311
  //         })
312
  //       )
313
  //     }
314
  //   }
315
  // }
316

317
  const handleReset = () => {
14✔
318
    methods.reset({ undefined })
×
319
    setCurrentObjectId(null)
×
320
  }
321

322
  return (
14✔
323
    <FormProvider {...methods}>
324
      <CustomCardHeader
325
        objectType={objectType}
326
        refForm="hook-form"
327
        onClickSubmit={() => {}}
328
        onClickSaveDOI={methods.handleSubmit(
329
          async data => handleMetadataSubmit(data as MetadataFormDetails),
×
330
          handleValidationErrors
331
        )}
332
        // onClickClearForm={() => handleClearForm()}
333
        // onOpenXMLModal={() => handleXMLModalOpen()}
334
        // onDeleteForm={() => handleDeleteForm()}
335
      />
336
      <Form
337
        id="hook-form"
338
        onChange={() => handleChange()}
2✔
339
        onSubmit={methods.handleSubmit(onSubmit)}
340
        onReset={handleReset}
341
        onBlur={() =>
1✔
342
          checkUnsavedInputHook(
343
            methods.formState.dirtyFields,
344
            methods.formState.defaultValues,
345
            methods.getValues,
346
            dispatch
347
          )
348
        }
349
      >
350
        <Box>{JSONSchemaParser.buildFields(formSchema)}</Box>
351
      </Form>
352
    </FormProvider>
353
  )
354
}
355

356
/*
357
 * Container for json schema based form. Handles json schema loading, form rendering, form submitting and error/success alerts.
358
 */
359
const WizardFillObjectDetailsForm = () => {
8✔
360
  const dispatch = useAppDispatch()
34✔
361
  const { t } = useTranslation()
34✔
362

363
  const objectType = useAppSelector(state => state.objectType)
51✔
364
  const submission = useAppSelector(state => state.submission)
51✔
365
  const currentObject = useAppSelector(state => state.currentObject)
51✔
366
  const locale = useAppSelector(state => state.locale)
51✔
367

368
  //const openedXMLModal = useAppSelector(state => state.openedXMLModal)
369

370
  const [states, setStates] = useState({
34✔
371
    baseSchema: {}, // keep the orginal version of schema that won't be changed with translation
372
    formSchema: {},
373
    isLoading: true,
374
  })
375

376
  const [namespace, setNamespace] = useState("")
34✔
377

378
  const resolver = WizardAjvResolver(states.formSchema, locale) as Resolver<
34✔
379
    Record<string, unknown>,
380
    unknown,
381
    {}
382
  >
383
  const methods = useForm({ mode: "onBlur", resolver })
34✔
384

385
  const [submitting, startTransition] = useTransition()
34✔
386

387
  /*
388
   * Fetch json schema from either session storage or API, set schema and dereferenced version to component state.
389
   */
390
  useEffect(() => {
34✔
391
    const fetchSchema = async () => {
7✔
392
      let currentSchema
393

394
      if (objectType === SDObjectTypes.publicMetadata) {
7!
395
        const submissionSchema = await loadJSONSchema("submission")
7✔
396
        const metadataSchema = submissionSchema.properties.metadata
7✔
397
        currentSchema = metadataSchema
7✔
398
        setNamespace(Namespaces.submissionMetadata)
7✔
399
        if (submission.metadata) dispatch(setCurrentObject(submission.metadata))
7!
400
      } else {
401
        // Use case for other JSON schemas in the future.
402
        // set to correct i18n namespace if necessary.
403
        const schema = await loadJSONSchema(objectType)
×
404
        currentSchema = schema
×
405
      }
406
      validateJSONSchema(currentSchema)
7✔
407
      const dereferencedSchema: Promise<FormObject> = await dereferenceSchema(
7✔
408
        currentSchema as FormObject
409
      )
410

411
      setStates({
7✔
412
        ...states,
413
        baseSchema: { ...dereferencedSchema },
414
        formSchema: { ...dereferencedSchema },
415
        isLoading: false,
416
      })
417
    }
418

419
    // Only fetch schema for SD's publicMetadata for now
420
    if (objectType === SDObjectTypes.publicMetadata) fetchSchema()
7!
421
  }, [objectType])
422

423
  useEffect(() => {
34✔
424
    const localizedSchema = localizeSchema(objectType, namespace, states.baseSchema, t)
13✔
425
    setStates({ ...states, formSchema: { ...localizedSchema } })
13✔
426
  }, [locale, states.baseSchema])
427

428
  /*
429
   * Submit a new or existing form with cleaned values and check for response errors
430
   */
431
  const onSubmit = (data: Record<string, unknown>) => {
34✔
432
    startTransition(async () => {
×
433
      if (!Object.keys(data).length) return
×
434
      const response = await submitObjectHook(data, submission.submissionId, objectType, dispatch)
×
435
      if (response["ok"]) {
×
436
        methods.reset(data, { keepValues: true })
×
437
        dispatch(resetUnsavedForm())
×
438
      }
439
    })
440
  }
441

442
  if (states.isLoading) return <CircularProgress />
34✔
443

444
  return (
14✔
445
    <>
446
      <GlobalStyles styles={{ ".MuiContainer-root": { maxWidth: "100% !important" } }} />
447
      <Container sx={{ m: 0, p: 0, width: "100%", boxSizing: "border-box" }} maxWidth={false}>
448
        <FormContent
449
          formSchema={states.formSchema as FormObject}
450
          methods={methods}
451
          onSubmit={onSubmit as SubmitHandler<FieldValues>}
452
          objectType={objectType}
453
          submission={submission}
454
          currentObject={currentObject}
455
          key={currentObject?.accessionId || submission.submissionId}
28✔
456
        />
457
        {submitting && <LinearProgress />}
34!
458
        {/*<WizardXMLUploadModal
459
          open={openedXMLModal}
460
          handleClose={() => {
461
            dispatch(resetXMLModalOpen())
462
          }}
463
        />*/}
464
      </Container>
465
    </>
466
  )
467
}
468

469
export default WizardFillObjectDetailsForm
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