Skip to content

Commit 4693e2b

Browse files
authored
[JN-505] Save survey drafts to local storage (#495)
1 parent 92c7f72 commit 4693e2b

File tree

9 files changed

+314
-22
lines changed

9 files changed

+314
-22
lines changed

populate/lombok.config

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
# This file is generated by the 'io.freefair.lombok' Gradle plugin
22
config.stopBubbling = true
3-
lombok.extern.findbugs.addSuppressFBWarnings = true
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import DiscardLocalDraftModal from './DiscardLocalDraftModal'
5+
6+
describe('DiscardLocalDraftModal', () => {
7+
test('allows discarding drafts on exit', async () => {
8+
//Arrange
9+
const user = userEvent.setup()
10+
const FORM_DRAFT_KEY = 'surveyDraft_testForm_12'
11+
render(<DiscardLocalDraftModal
12+
formDraftKey={FORM_DRAFT_KEY}
13+
onExit={() => jest.fn()}
14+
onSaveDraft={() => jest.fn()}
15+
onDismiss={() => jest.fn()}
16+
/>)
17+
18+
//Act
19+
jest.spyOn(Storage.prototype, 'removeItem')
20+
const exitAndDiscardButton = screen.getByText('Exit & discard draft')
21+
await user.click(exitAndDiscardButton)
22+
23+
//Assert
24+
expect(localStorage.removeItem).toHaveBeenCalledWith(FORM_DRAFT_KEY)
25+
})
26+
27+
test('allows keeping drafts on exit', async () => {
28+
//Arrange
29+
const user = userEvent.setup()
30+
const FORM_DRAFT_KEY = 'surveyDraft_testForm_12'
31+
render(<DiscardLocalDraftModal
32+
formDraftKey={FORM_DRAFT_KEY}
33+
onExit={() => jest.fn()}
34+
onSaveDraft={() => jest.fn()}
35+
onDismiss={() => jest.fn()}
36+
/>)
37+
38+
//Act
39+
jest.spyOn(Storage.prototype, 'removeItem')
40+
const exitAndDiscardButton = screen.getByText('Exit & save draft')
41+
await user.click(exitAndDiscardButton)
42+
43+
//Assert
44+
expect(localStorage.removeItem).not.toHaveBeenCalled()
45+
})
46+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Modal, ModalFooter } from 'react-bootstrap'
2+
import { Button } from 'components/forms/Button'
3+
import React from 'react'
4+
import { deleteDraft } from '../utils/formDraftUtils'
5+
6+
/**
7+
* Modal presenting the user with the option to discard a local draft. Shown on Cancel if there is a local draft.
8+
*/
9+
const DiscardLocalDraftModal = ({ formDraftKey, onExit, onSaveDraft, onDismiss }: {
10+
formDraftKey: string
11+
onExit: () => void
12+
onSaveDraft: () => void
13+
onDismiss: () => void
14+
}) => {
15+
return <Modal show={true} onHide={onDismiss}>
16+
<Modal.Header>
17+
<Modal.Title>Unsaved Changes</Modal.Title>
18+
</Modal.Header>
19+
<Modal.Body>
20+
<p>Are you sure you want to cancel? You have an unsaved survey draft. You can save the
21+
draft and return to edit it later, or discard the draft if it is no longer needed.</p>
22+
</Modal.Body>
23+
<ModalFooter>
24+
<Button
25+
variant="primary"
26+
onClick={() => {
27+
onSaveDraft()
28+
onDismiss()
29+
onExit()
30+
}}
31+
>
32+
Exit & save draft
33+
</Button>
34+
<Button
35+
variant="danger"
36+
onClick={() => {
37+
deleteDraft({ formDraftKey })
38+
onDismiss()
39+
onExit()
40+
}}
41+
>
42+
Exit & discard draft
43+
</Button>
44+
<Button
45+
variant="secondary"
46+
onClick={onDismiss}
47+
>
48+
Cancel
49+
</Button>
50+
</ModalFooter>
51+
</Modal>
52+
}
53+
54+
export default DiscardLocalDraftModal
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react'
2+
import Modal from 'react-bootstrap/Modal'
3+
import { Button } from 'components/forms/Button'
4+
5+
/**
6+
* Shown to the user if the form editor is opened and we detect a local draft.
7+
*/
8+
const LoadedLocalDraftModal = ({ onDismiss, lastUpdated }: {
9+
onDismiss: () => void
10+
lastUpdated: number | undefined
11+
}) => {
12+
return <Modal show={true} onHide={onDismiss}>
13+
<Modal.Header>
14+
<Modal.Title>Survey Draft Loaded</Modal.Title>
15+
</Modal.Header>
16+
<Modal.Body>
17+
<p>A previously unsaved survey draft was automatically loaded.
18+
Be sure to save the survey in order to publish your changes.</p>
19+
{lastUpdated && <span>This draft was last updated {new Date(lastUpdated).toLocaleString()}</span> }
20+
</Modal.Body>
21+
<Modal.Footer>
22+
<Button
23+
variant="primary"
24+
onClick={onDismiss}
25+
>
26+
Continue
27+
</Button>
28+
</Modal.Footer>
29+
</Modal>
30+
}
31+
32+
export default LoadedLocalDraftModal
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { VersionedForm } from '@juniper/ui-core'
2+
3+
export type FormDraft = {
4+
content: string
5+
date: number
6+
}
7+
8+
/** returns a form draft key for local storage */
9+
export function getFormDraftKey({ form }: { form: VersionedForm }) {
10+
return `formDraft_${form.stableId}_${form.version}`
11+
}
12+
13+
/** returns a form draft from local storage, if there is one */
14+
export function getDraft({ formDraftKey }: { formDraftKey: string }): FormDraft | undefined {
15+
const draft = localStorage.getItem(formDraftKey)
16+
if (!draft) {
17+
return undefined
18+
} else {
19+
const draftParsed: FormDraft = JSON.parse(draft)
20+
return draftParsed
21+
}
22+
}
23+
24+
/** saves a form draft to local storage with the current timestamp, if there is one */
25+
export function saveDraft({ formDraftKey, draft, setSavingDraft }: {
26+
formDraftKey: string,
27+
draft: FormDraft
28+
setSavingDraft: (saving: boolean) => void
29+
}) {
30+
setSavingDraft(true)
31+
localStorage.setItem(formDraftKey, JSON.stringify(draft))
32+
//Saving a draft happens so quickly that the "Saving draft..." message isn't even visible to the user.
33+
//Set a timeout to show it for 2 seconds so the user knows that their drafts are being saved.
34+
setTimeout(() => {
35+
setSavingDraft(false)
36+
}, 2000)
37+
}
38+
39+
/** deletes a form draft from local storage */
40+
export function deleteDraft({ formDraftKey }: { formDraftKey: string }) {
41+
localStorage.removeItem(formDraftKey)
42+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import SurveyEditorView from './SurveyEditorView'
4+
import { getFormDraftKey } from '../../forms/designer/utils/formDraftUtils'
5+
import { VersionedForm } from '@juniper/ui-core'
6+
7+
describe('SurveyEditorView', () => {
8+
const mockForm: VersionedForm = {
9+
id: 'testForm',
10+
version: 12,
11+
content: '{}',
12+
stableId: 'testStableId',
13+
name: '',
14+
createdAt: 0,
15+
lastUpdatedAt: 0
16+
}
17+
18+
test('shows the user a LoadedLocalDraftModal when a draft is loaded', async () => {
19+
//Arrange
20+
const FORM_DRAFT_KEY = getFormDraftKey({ form: mockForm })
21+
localStorage.setItem(FORM_DRAFT_KEY, JSON.stringify({}))
22+
23+
jest.spyOn(Storage.prototype, 'getItem')
24+
25+
render(<SurveyEditorView
26+
currentForm={mockForm}
27+
onCancel={jest.fn()}
28+
onSave={jest.fn()}
29+
/>)
30+
31+
//Assert
32+
const modalHeader = screen.getByText('Survey Draft Loaded')
33+
expect(modalHeader).toBeInTheDocument()
34+
})
35+
36+
test('checks local storage for a draft', async () => {
37+
//Arrange
38+
const FORM_DRAFT_KEY = getFormDraftKey({ form: mockForm })
39+
40+
jest.spyOn(Storage.prototype, 'getItem')
41+
42+
render(<SurveyEditorView
43+
currentForm={mockForm}
44+
onCancel={jest.fn()}
45+
onSave={jest.fn()}
46+
/>)
47+
48+
//Assert
49+
expect(localStorage.getItem).toHaveBeenCalledWith(FORM_DRAFT_KEY)
50+
expect(screen.queryByText('Survey Draft Loaded')).not.toBeInTheDocument()
51+
})
52+
})

ui-admin/src/study/surveys/SurveyEditorView.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { VersionedForm } from 'api/api'
44

55
import { Button } from 'components/forms/Button'
66
import { FormContentEditor } from 'forms/FormContentEditor'
7+
import LoadedLocalDraftModal from 'forms/designer/modals/LoadedLocalDraftModal'
8+
import DiscardLocalDraftModal from 'forms/designer/modals/DiscardLocalDraftModal'
9+
import { deleteDraft, FormDraft, getDraft, getFormDraftKey, saveDraft } from 'forms/designer/utils/formDraftUtils'
10+
import { useAutosaveEffect } from '@juniper/ui-core/build/autoSaveUtils'
711

812
type SurveyEditorViewProps = {
913
currentForm: VersionedForm
@@ -21,24 +25,62 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
2125
onSave
2226
} = props
2327

24-
const [editedContent, setEditedContent] = useState<string>()
28+
const FORM_DRAFT_KEY = getFormDraftKey({ form: currentForm })
29+
const FORM_DRAFT_SAVE_INTERVAL = 10000
30+
2531
const [isEditorValid, setIsEditorValid] = useState(true)
2632
const [saving, setSaving] = useState(false)
27-
const isSaveEnabled = !!editedContent && isEditorValid && !saving
33+
const [savingDraft, setSavingDraft] = useState(false)
34+
35+
//Let the user know if we loaded a draft from local storage when the component first renders. It's important
36+
//for them to know if the version of the survey they're seeing are from a draft or are actually published.
37+
const [showLoadedDraftModal, setShowLoadedDraftModal] = useState(!!getDraft({ formDraftKey: FORM_DRAFT_KEY }))
38+
const [showDiscardDraftModal, setShowDiscardDraftModal] = useState(false)
39+
40+
const [draft, setDraft] = useState<FormDraft | undefined>(
41+
!readOnly ? getDraft({ formDraftKey: FORM_DRAFT_KEY }) : undefined)
42+
43+
const isSaveEnabled = !!draft && isEditorValid && !saving
44+
45+
const saveDraftToLocalStorage = () => {
46+
setDraft(currentDraft => {
47+
if (currentDraft && currentDraft?.content !== getDraft({ formDraftKey: FORM_DRAFT_KEY })?.content) {
48+
saveDraft({
49+
formDraftKey: FORM_DRAFT_KEY,
50+
draft: currentDraft,
51+
setSavingDraft
52+
})
53+
}
54+
return currentDraft
55+
})
56+
}
57+
58+
useAutosaveEffect(saveDraftToLocalStorage, FORM_DRAFT_SAVE_INTERVAL)
2859

2960
const onClickSave = async () => {
3061
if (!isSaveEnabled) {
3162
return
3263
}
3364
setSaving(true)
3465
try {
35-
await onSave({ content: editedContent })
36-
setEditedContent(undefined)
66+
await onSave({ content: draft?.content })
67+
//Once we've persisted the form draft to the database, there's no need to keep it in local storage.
68+
//Future drafts will have different FORM_DRAFT_KEYs anyway, as they're based on the form version number.
69+
deleteDraft({ formDraftKey: FORM_DRAFT_KEY })
70+
setDraft(undefined)
3771
} finally {
3872
setSaving(false)
3973
}
4074
}
4175

76+
const onClickCancel = () => {
77+
if (draft) {
78+
setShowDiscardDraftModal(true)
79+
} else {
80+
onCancel()
81+
}
82+
}
83+
4284
return (
4385
<div className="SurveyView d-flex flex-column flex-grow-1 mx-1 mb-1">
4486
<div className="d-flex p-2 align-items-center">
@@ -47,12 +89,13 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
4789
<span className="detail me-2 ms-2">version {currentForm.version}</span>
4890
</h5>
4991
</div>
92+
{ savingDraft && <span className="detail me-2 ms-2">Saving draft...</span> }
5093
{!readOnly && (
5194
<Button
5295
disabled={!isSaveEnabled}
5396
className="me-md-2"
5497
tooltip={(() => {
55-
if (!editedContent) {
98+
if (!draft) {
5699
return 'Form is unchanged. Make changes to save.'
57100
}
58101
if (!isEditorValid) {
@@ -66,14 +109,31 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
66109
Save
67110
</Button>
68111
)}
69-
<button className="btn btn-secondary" type="button" onClick={onCancel}>Cancel</button>
112+
<button className="btn btn-secondary" type="button"
113+
onClick={onClickCancel}>Cancel</button>
114+
{ showLoadedDraftModal && draft &&
115+
<LoadedLocalDraftModal
116+
onDismiss={() => setShowLoadedDraftModal(false)}
117+
lastUpdated={draft?.date}/>}
118+
{ showDiscardDraftModal && draft &&
119+
<DiscardLocalDraftModal
120+
formDraftKey={FORM_DRAFT_KEY}
121+
onExit={() => onCancel()}
122+
onSaveDraft={() =>
123+
saveDraft({
124+
formDraftKey: FORM_DRAFT_KEY,
125+
draft,
126+
setSavingDraft
127+
})}
128+
onDismiss={() => setShowDiscardDraftModal(false)}
129+
/>}
70130
</div>
71131
<FormContentEditor
72-
initialContent={currentForm.content}
132+
initialContent={draft?.content || currentForm.content} //favor loading the draft, if we find one
73133
readOnly={readOnly}
74134
onChange={(isValid, newContent) => {
75135
if (isValid) {
76-
setEditedContent(JSON.stringify(newContent))
136+
setDraft({ content: JSON.stringify(newContent), date: Date.now() })
77137
}
78138
setIsEditorValid(isValid)
79139
}}

ui-core/src/autoSaveUtils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect } from 'react'
2+
3+
/** TODO JSdoc */
4+
export function useAutosaveEffect(saveFn: () => void, autoSaveInterval: number) {
5+
useEffect(() => {
6+
let timeoutHandle: number
7+
// run saveFn at the specified interval
8+
(function loop() {
9+
timeoutHandle = window.setTimeout(() => {
10+
saveFn()
11+
loop()
12+
}, autoSaveInterval)
13+
})()
14+
return () => {
15+
window.clearTimeout(timeoutHandle)
16+
}
17+
}, [saveFn])
18+
}

0 commit comments

Comments
 (0)