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

CSCfi / metadata-submitter-frontend / 17324617246

29 Aug 2025 01:06PM UTC coverage: 58.026% (+0.09%) from 57.939%
17324617246

push

github

Hang Le
Input loss warning (merge commit)

Merge branch 'feature/warn-data-loss' into 'main'
* Accommodate the lack of default values in unsaved form check

* Restore form reset on navigation for hook-form

* Refactor unsaved input checks into a hook

* Add blur on form array element removal to force force input check

* Reset unsaved status on submission exit and new object submission

* Remove resetting of form on click to navigate away

* Add datafolder link confirmation dialog

* Track form state to alert of form data loss

Closes #1089 and #1068
See merge request https://gitlab.ci.csc.fi/sds-dev/sd-submit/metadata-submitter-frontend/-/merge_requests/1156

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

594 of 809 branches covered (73.42%)

Branch coverage included in aggregate %.

165 of 251 new or added lines in 12 files covered. (65.74%)

9 existing lines in 5 files now uncovered.

5331 of 9402 relevant lines covered (56.7%)

5.32 hits per line

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

69.78
/src/components/SubmissionWizard/WizardComponents/WizardStep.tsx
1
import React, { useState } from "react"
1✔
2

3
import ChevronRightIcon from "@mui/icons-material/ChevronRight"
1✔
4
import Button from "@mui/material/Button"
1✔
5
import Collapse from "@mui/material/Collapse"
1✔
6
import Grid from "@mui/material/Grid"
1✔
7
import Link from "@mui/material/Link"
1✔
8
import List from "@mui/material/List"
1✔
9
import ListItem from "@mui/material/ListItem"
1✔
10
import { styled } from "@mui/material/styles"
1✔
11
import { useTranslation } from "react-i18next"
1✔
12
import { useNavigate } from "react-router"
1✔
13
import { TransitionGroup } from "react-transition-group"
1✔
14

15
import editObjectHook from "../WizardHooks/WizardEditObjectHook"
1✔
16

17
import WizardAlert from "./WizardAlert"
1✔
18
import WizardObjectStatusBadge from "./WizardObjectStatusBadge"
1✔
19

20
import { ObjectTypes } from "constants/wizardObject"
1✔
21
import { setFocus } from "features/focusSlice"
1✔
22
import { resetUnsavedForm } from "features/unsavedFormSlice"
1✔
23
import { resetCurrentObject, setCurrentObject } from "features/wizardCurrentObjectSlice"
1✔
24
import { setObjectType, resetObjectType } from "features/wizardObjectTypeSlice"
1✔
25
import { updateStep } from "features/wizardStepObjectSlice"
1✔
26
import { useAppDispatch, useAppSelector } from "hooks"
1✔
27
import type { DoiFormDetails, HandlerRef, StepObject } from "types"
28
import { hasDoiInfo, pathWithLocale } from "utils"
1✔
29

30
const ActionButton = (props: {
1✔
31
  step: number
32
  parent: string
33
  buttonText?: string
34
  disabled: boolean
35
  ref?: HandlerRef
36
}) => {
33✔
37
  const { step, parent, buttonText, disabled, ref } = props
33✔
38

39
  const navigate = useNavigate()
33✔
40
  const submission = useAppSelector(state => state.submission)
33✔
41
  const unsavedForm = useAppSelector(state => state.unsavedForm)
33✔
42
  const dispatch = useAppDispatch()
33✔
43
  const [alert, setAlert] = useState(false)
33✔
44

45
  const pathname = pathWithLocale(
33✔
46
    submission.submissionId ? `submission/${submission.submissionId}` : `submission`
33✔
47
  )
33✔
48

49
  const handleClick = () => {
33✔
NEW
50
    if (unsavedForm) {
×
NEW
51
      setAlert(true)
×
NEW
52
    } else {
×
53
      // no data to lose, navigate
NEW
54
      handleNavigation()
×
NEW
55
    }
×
UNCOV
56
  }
×
57

58
  const handleNavigation = () => {
33✔
NEW
59
    dispatch(resetUnsavedForm())
×
60
    dispatch(resetObjectType())
×
61
    dispatch(resetCurrentObject())
×
62
    dispatch(updateStep({ step: step, objectType: parent }))
×
63
    dispatch(setFocus())
×
64

65
    const stepParam = `step=${step}`
×
66
    switch (parent) {
×
67
      case "submissionDetails": {
×
68
        navigate({ pathname: pathname, search: stepParam })
×
69
        break
×
70
      }
×
71
      case "publish": {
×
72
        navigate({ pathname: pathname, search: stepParam })
×
73
        break
×
74
      }
×
75
      default: {
×
76
        navigate({ pathname: pathname, search: stepParam })
×
77
        dispatch(setObjectType(parent))
×
78
        // resets only hook-form
79
        if (ref?.current) ref.current?.dispatchEvent(new Event("reset", { bubbles: true }))
×
80
      }
×
81
    }
×
82
  }
×
83

84
  const handleAlert = (navigate: boolean) => {
33✔
85
    setAlert(false)
×
86
    if (navigate) {
×
87
      handleNavigation()
×
88
    }
×
89
  }
×
90

91
  return (
33✔
92
    <React.Fragment>
33✔
93
      <Button
33✔
94
        role="button"
33✔
95
        disabled={disabled}
33✔
96
        variant="contained"
33✔
97
        onClick={() => handleClick()}
33✔
98
        sx={theme => ({ marginTop: theme.spacing(2.4) })}
33✔
99
        data-testid={`${buttonText} ${parent}`}
33✔
100
      >
101
        {buttonText}
33✔
102
      </Button>
33✔
103
      {alert && <WizardAlert onAlert={handleAlert} parentLocation="submission" alertType="exit" />}
33!
104
    </React.Fragment>
33✔
105
  )
106
}
33✔
107

108
/*
109
 * Render items belonging to step.
110
 * Step can host for example submission details and its objects.
111
 */
112
const StepItems = (props: {
1✔
113
  step: number
114
  objects: StepObject[]
115
  submissionId: string
116
  doiInfo?: (Record<string, unknown> & DoiFormDetails) | undefined
117
  objectType: string
118
}) => {
42✔
119
  const { step, objects, submissionId, doiInfo, objectType } = props
42✔
120
  const dispatch = useAppDispatch()
42✔
121
  const unsavedForm = useAppSelector(state => state.unsavedForm)
42✔
122
  const navigate = useNavigate()
42✔
123
  const [alert, setAlert] = useState(false)
42✔
124
  const [clickedItem, setClickedItem] = useState({})
42✔
125
  const { t } = useTranslation()
42✔
126

127
  const handleClick = (item: StepObject) => {
42✔
128
    setClickedItem(item)
×
NEW
129
    if (unsavedForm) {
×
NEW
130
      setAlert(true)
×
NEW
131
    } else {
×
NEW
132
      handleItemEdit(item)
×
NEW
133
    }
×
UNCOV
134
  }
×
135

136
  const handleItemEdit = formObject => {
42✔
137
    dispatch(updateStep({ step: step, objectType: objectType }))
×
138

139
    switch (step) {
×
140
      case 1: {
×
141
        dispatch(resetObjectType())
×
142
        navigate({
×
143
          pathname: pathWithLocale(`submission/${submissionId}`),
×
144
          search: "step=1",
×
145
        })
×
146
        break
×
147
      }
×
148
      case 5: {
×
149
        dispatch(resetCurrentObject())
×
150
        dispatch(setCurrentObject(doiInfo))
×
151
        navigate({
×
152
          pathname: pathWithLocale(`submission/${submissionId}`),
×
153
          search: "step=5",
×
154
        })
×
155
        dispatch(setObjectType(objectType))
×
156
        break
×
157
      }
×
158
      default: {
×
159
        if (objectType === ObjectTypes.dacPolicies) {
×
160
          dispatch(resetCurrentObject())
×
161
          navigate({ pathname: pathWithLocale(`submission/${submissionId}`), search: "step=2" })
×
162
          dispatch(setObjectType(objectType))
×
163
        } else if (objectType === ObjectTypes.file) {
×
164
          // Handle linked folder click
165
          navigate({
×
166
            pathname: pathWithLocale(`submission/${submissionId}`),
×
167
            search: `step=${step}`,
×
168
          })
×
169
        } else {
×
170
          editObjectHook(objectType, formObject, step, submissionId, dispatch, navigate)
×
171
        }
×
172
        break
×
173
      }
×
174
    }
×
175
  }
×
176

177
  const handleAlert = (navigate: boolean) => {
42✔
178
    setAlert(false)
×
179
    if (navigate) {
×
180
      handleItemEdit(clickedItem)
×
181
    }
×
182
  }
×
183

184
  const ObjectItem = styled("div")(({ theme }) => ({
42✔
185
    paddingTop: theme.spacing(2.5),
7✔
186
  }))
42✔
187

188
  return (
42✔
189
    <React.Fragment>
42✔
190
      <TransitionGroup component={null}>
42✔
191
        {objects.map(item => {
42✔
192
          return (
9✔
193
            <Collapse component={"li"} key={item.id}>
9✔
194
              <ObjectItem>
9✔
195
                <Grid container justifyContent="space-between">
9✔
196
                  <Grid display="flex" alignItems="center" size={{ xs: 6 }}>
9✔
197
                    <Link
9✔
198
                      tabIndex={0}
9✔
199
                      onClick={() => handleClick(item)}
9✔
200
                      data-testid={`${objectType}-list-item`}
9✔
201
                      aria-label={t("ariaLabels.editObject")}
9✔
202
                      sx={theme => ({
9✔
203
                        fontWeight: "300",
7✔
204
                        textDecoration: "underline",
7✔
205
                        wordBreak: "break-all",
7✔
206
                        cursor: "pointer",
7✔
207
                        color: theme.palette.primary.main,
7✔
208
                      })}
7✔
209
                    >
210
                      {item.displayTitle}
9✔
211
                    </Link>
9✔
212
                  </Grid>
9✔
213
                  <Grid>
9✔
214
                    <WizardObjectStatusBadge />
9✔
215
                  </Grid>
9✔
216
                </Grid>
9✔
217
              </ObjectItem>
9✔
218
            </Collapse>
9✔
219
          )
220
        })}
42✔
221
      </TransitionGroup>
42✔
222
      {alert && <WizardAlert onAlert={handleAlert} parentLocation="submission" alertType="exit" />}
42!
223
    </React.Fragment>
42✔
224
  )
225
}
42✔
226

227
const ObjectWrapper = styled("div")(({ theme }) => {
1✔
228
  const treeBorder = `1px solid ${theme.palette.primary.main}`
33✔
229
  return {
33✔
230
    padding: theme.spacing(2.4),
33✔
231
    width: "100%",
33✔
232
    "&.activeObject": {
33✔
233
      backgroundColor: theme.palette.primary.mediumLight,
33✔
234
    },
33✔
235
    "& .stepItemHeader": {
33✔
236
      display: "flex",
33✔
237
      fontWeight: "bold",
33✔
238
    },
33✔
239
    "& .tree": {
33✔
240
      listStyle: "none",
33✔
241
      marginTop: theme.spacing(0.5),
33✔
242
      padding: 0,
33✔
243
      "& ul": {
33✔
244
        marginLeft: theme.spacing(1),
33✔
245
      },
33✔
246
      "& li": {
33✔
247
        position: "relative",
33✔
248
        marginLeft: theme.spacing(1),
33✔
249
        paddingLeft: theme.spacing(3),
33✔
250
        borderLeft: treeBorder,
33✔
251
      },
33✔
252
      "& li:last-of-type": {
33✔
253
        borderLeft: "none !important",
33✔
254
      },
33✔
255
      "& li:before": {
33✔
256
        position: "absolute",
33✔
257
        left: 0,
33✔
258
        width: theme.spacing(2),
33✔
259
        height: theme.spacing(4.25),
33✔
260
        verticalAlign: "top",
33✔
261
        borderBottom: treeBorder,
33✔
262
        content: "''",
33✔
263
      },
33✔
264
      "& li:last-of-type:before": {
33✔
265
        borderLeft: treeBorder,
33✔
266
      },
33✔
267
    },
33✔
268
  }
33✔
269
})
33✔
270

271
type WizardStepProps = {
272
  step: number
273
  schemas: {
274
    objectType: string
275
    name: string
276
    required?: boolean
277
    allowMultipleObjects?: boolean
278
    objects: StepObject[]
279
  }[]
280
  ref?: HandlerRef
281
}
282

283
/*
284
 * Render a single step inside WizardStepper in the Accordion
285
 */
286
const WizardStep = (props: WizardStepProps) => {
1✔
287
  const { step, schemas, ref } = props
24✔
288

289
  const submission = useAppSelector(state => state.submission)
24✔
290
  const currentStepObject = useAppSelector(state => state.stepObject)
24✔
291
  const { t } = useTranslation()
24✔
292

293
  return (
24✔
294
    <React.Fragment>
24✔
295
      {schemas.map((item, index) => {
24✔
296
        const { objectType, name, objects, allowMultipleObjects } = item
42✔
297
        const isActive = currentStepObject.stepObjectType === objectType
42✔
298

299
        // Check if we should show linked folder instead of objects
300
        const shouldShowLinkedFolder = objectType === ObjectTypes.file && submission.linkedFolder
42!
301

302
        const buttonText =
42✔
303
          step === 1
42✔
304
            ? t("edit")
15✔
305
            : objectType === ObjectTypes.file || objectType === "Summary"
27✔
306
              ? t("view")
9✔
307
              : objectType === t("summaryPage.publish") || hasDoiInfo(submission.doiInfo)
18✔
308
                ? t("edit")
9✔
309
                : t("add")
9✔
310

311
        return (
42✔
312
          <List key={objectType} disablePadding data-testid={`${objectType}-details`}>
42✔
313
            <ListItem divider={index !== schemas?.length - 1} disableGutters disablePadding>
42✔
314
              <ObjectWrapper className={isActive ? "activeObject" : ""}>
42✔
315
                <div className="stepItemHeader">
42✔
316
                  {isActive && <ChevronRightIcon fontSize="large" />}
42✔
317
                  {name}
42✔
318
                </div>
42✔
319

320
                {(objects || shouldShowLinkedFolder) && (
42!
321
                  <ul className="tree" data-testid={`${objectType}-objects-list`}>
42✔
322
                    {objects && (
42✔
323
                      <StepItems
42✔
324
                        step={step}
42✔
325
                        objects={objects}
42✔
326
                        submissionId={submission.submissionId}
42✔
327
                        doiInfo={submission.doiInfo}
42✔
328
                        objectType={objectType}
42✔
329
                      />
330
                    )}
331
                    {shouldShowLinkedFolder && (
42!
332
                      <StepItems
×
333
                        step={step}
×
334
                        objects={[
×
335
                          {
×
336
                            id: `linked-folder-${submission.submissionId}`,
×
337
                            displayTitle: submission.linkedFolder ?? "",
×
338
                          } as StepObject,
×
339
                        ]}
340
                        submissionId={submission.submissionId}
×
341
                        doiInfo={submission.doiInfo}
×
342
                        objectType={objectType}
×
343
                      />
344
                    )}
345
                  </ul>
42✔
346
                )}
347

348
                <ActionButton
42✔
349
                  step={step}
42✔
350
                  parent={step === 1 ? "submissionDetails" : objectType}
42✔
351
                  buttonText={buttonText}
42✔
352
                  disabled={!!objects?.length && !allowMultipleObjects}
42✔
353
                  ref={ref}
42✔
354
                />
355
              </ObjectWrapper>
42✔
356
            </ListItem>
42✔
357
          </List>
42✔
358
        )
359
      })}
24✔
360
    </React.Fragment>
24✔
361
  )
362
}
24✔
363

364
export default WizardStep
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