Skip to content

Commit

Permalink
[JN-505] Save survey drafts to local storage (#495)
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthewBemis authored Aug 16, 2023
1 parent 92c7f72 commit 4693e2b
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 22 deletions.
1 change: 0 additions & 1 deletion populate/lombok.config
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
# This file is generated by the 'io.freefair.lombok' Gradle plugin
config.stopBubbling = true
lombok.extern.findbugs.addSuppressFBWarnings = true
46 changes: 46 additions & 0 deletions ui-admin/src/forms/designer/modals/DiscardLocalDraftModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DiscardLocalDraftModal from './DiscardLocalDraftModal'

describe('DiscardLocalDraftModal', () => {
test('allows discarding drafts on exit', async () => {
//Arrange
const user = userEvent.setup()
const FORM_DRAFT_KEY = 'surveyDraft_testForm_12'
render(<DiscardLocalDraftModal
formDraftKey={FORM_DRAFT_KEY}
onExit={() => jest.fn()}
onSaveDraft={() => jest.fn()}
onDismiss={() => jest.fn()}
/>)

//Act
jest.spyOn(Storage.prototype, 'removeItem')
const exitAndDiscardButton = screen.getByText('Exit & discard draft')
await user.click(exitAndDiscardButton)

//Assert
expect(localStorage.removeItem).toHaveBeenCalledWith(FORM_DRAFT_KEY)
})

test('allows keeping drafts on exit', async () => {
//Arrange
const user = userEvent.setup()
const FORM_DRAFT_KEY = 'surveyDraft_testForm_12'
render(<DiscardLocalDraftModal
formDraftKey={FORM_DRAFT_KEY}
onExit={() => jest.fn()}
onSaveDraft={() => jest.fn()}
onDismiss={() => jest.fn()}
/>)

//Act
jest.spyOn(Storage.prototype, 'removeItem')
const exitAndDiscardButton = screen.getByText('Exit & save draft')
await user.click(exitAndDiscardButton)

//Assert
expect(localStorage.removeItem).not.toHaveBeenCalled()
})
})
54 changes: 54 additions & 0 deletions ui-admin/src/forms/designer/modals/DiscardLocalDraftModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Modal, ModalFooter } from 'react-bootstrap'
import { Button } from 'components/forms/Button'
import React from 'react'
import { deleteDraft } from '../utils/formDraftUtils'

/**
* Modal presenting the user with the option to discard a local draft. Shown on Cancel if there is a local draft.
*/
const DiscardLocalDraftModal = ({ formDraftKey, onExit, onSaveDraft, onDismiss }: {
formDraftKey: string
onExit: () => void
onSaveDraft: () => void
onDismiss: () => void
}) => {
return <Modal show={true} onHide={onDismiss}>
<Modal.Header>
<Modal.Title>Unsaved Changes</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to cancel? You have an unsaved survey draft. You can save the
draft and return to edit it later, or discard the draft if it is no longer needed.</p>
</Modal.Body>
<ModalFooter>
<Button
variant="primary"
onClick={() => {
onSaveDraft()
onDismiss()
onExit()
}}
>
Exit & save draft
</Button>
<Button
variant="danger"
onClick={() => {
deleteDraft({ formDraftKey })
onDismiss()
onExit()
}}
>
Exit & discard draft
</Button>
<Button
variant="secondary"
onClick={onDismiss}
>
Cancel
</Button>
</ModalFooter>
</Modal>
}

export default DiscardLocalDraftModal
32 changes: 32 additions & 0 deletions ui-admin/src/forms/designer/modals/LoadedLocalDraftModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react'
import Modal from 'react-bootstrap/Modal'
import { Button } from 'components/forms/Button'

/**
* Shown to the user if the form editor is opened and we detect a local draft.
*/
const LoadedLocalDraftModal = ({ onDismiss, lastUpdated }: {
onDismiss: () => void
lastUpdated: number | undefined
}) => {
return <Modal show={true} onHide={onDismiss}>
<Modal.Header>
<Modal.Title>Survey Draft Loaded</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>A previously unsaved survey draft was automatically loaded.
Be sure to save the survey in order to publish your changes.</p>
{lastUpdated && <span>This draft was last updated {new Date(lastUpdated).toLocaleString()}</span> }
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
onClick={onDismiss}
>
Continue
</Button>
</Modal.Footer>
</Modal>
}

export default LoadedLocalDraftModal
42 changes: 42 additions & 0 deletions ui-admin/src/forms/designer/utils/formDraftUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { VersionedForm } from '@juniper/ui-core'

export type FormDraft = {
content: string
date: number
}

/** returns a form draft key for local storage */
export function getFormDraftKey({ form }: { form: VersionedForm }) {
return `formDraft_${form.stableId}_${form.version}`
}

/** returns a form draft from local storage, if there is one */
export function getDraft({ formDraftKey }: { formDraftKey: string }): FormDraft | undefined {
const draft = localStorage.getItem(formDraftKey)
if (!draft) {
return undefined
} else {
const draftParsed: FormDraft = JSON.parse(draft)
return draftParsed
}
}

/** saves a form draft to local storage with the current timestamp, if there is one */
export function saveDraft({ formDraftKey, draft, setSavingDraft }: {
formDraftKey: string,
draft: FormDraft
setSavingDraft: (saving: boolean) => void
}) {
setSavingDraft(true)
localStorage.setItem(formDraftKey, JSON.stringify(draft))
//Saving a draft happens so quickly that the "Saving draft..." message isn't even visible to the user.
//Set a timeout to show it for 2 seconds so the user knows that their drafts are being saved.
setTimeout(() => {
setSavingDraft(false)
}, 2000)
}

/** deletes a form draft from local storage */
export function deleteDraft({ formDraftKey }: { formDraftKey: string }) {
localStorage.removeItem(formDraftKey)
}
52 changes: 52 additions & 0 deletions ui-admin/src/study/surveys/SurveyEditorView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import SurveyEditorView from './SurveyEditorView'
import { getFormDraftKey } from '../../forms/designer/utils/formDraftUtils'
import { VersionedForm } from '@juniper/ui-core'

describe('SurveyEditorView', () => {
const mockForm: VersionedForm = {
id: 'testForm',
version: 12,
content: '{}',
stableId: 'testStableId',
name: '',
createdAt: 0,
lastUpdatedAt: 0
}

test('shows the user a LoadedLocalDraftModal when a draft is loaded', async () => {
//Arrange
const FORM_DRAFT_KEY = getFormDraftKey({ form: mockForm })
localStorage.setItem(FORM_DRAFT_KEY, JSON.stringify({}))

jest.spyOn(Storage.prototype, 'getItem')

render(<SurveyEditorView
currentForm={mockForm}
onCancel={jest.fn()}
onSave={jest.fn()}
/>)

//Assert
const modalHeader = screen.getByText('Survey Draft Loaded')
expect(modalHeader).toBeInTheDocument()
})

test('checks local storage for a draft', async () => {
//Arrange
const FORM_DRAFT_KEY = getFormDraftKey({ form: mockForm })

jest.spyOn(Storage.prototype, 'getItem')

render(<SurveyEditorView
currentForm={mockForm}
onCancel={jest.fn()}
onSave={jest.fn()}
/>)

//Assert
expect(localStorage.getItem).toHaveBeenCalledWith(FORM_DRAFT_KEY)
expect(screen.queryByText('Survey Draft Loaded')).not.toBeInTheDocument()
})
})
76 changes: 68 additions & 8 deletions ui-admin/src/study/surveys/SurveyEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { VersionedForm } from 'api/api'

import { Button } from 'components/forms/Button'
import { FormContentEditor } from 'forms/FormContentEditor'
import LoadedLocalDraftModal from 'forms/designer/modals/LoadedLocalDraftModal'
import DiscardLocalDraftModal from 'forms/designer/modals/DiscardLocalDraftModal'
import { deleteDraft, FormDraft, getDraft, getFormDraftKey, saveDraft } from 'forms/designer/utils/formDraftUtils'
import { useAutosaveEffect } from '@juniper/ui-core/build/autoSaveUtils'

type SurveyEditorViewProps = {
currentForm: VersionedForm
Expand All @@ -21,24 +25,62 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
onSave
} = props

const [editedContent, setEditedContent] = useState<string>()
const FORM_DRAFT_KEY = getFormDraftKey({ form: currentForm })
const FORM_DRAFT_SAVE_INTERVAL = 10000

const [isEditorValid, setIsEditorValid] = useState(true)
const [saving, setSaving] = useState(false)
const isSaveEnabled = !!editedContent && isEditorValid && !saving
const [savingDraft, setSavingDraft] = useState(false)

//Let the user know if we loaded a draft from local storage when the component first renders. It's important
//for them to know if the version of the survey they're seeing are from a draft or are actually published.
const [showLoadedDraftModal, setShowLoadedDraftModal] = useState(!!getDraft({ formDraftKey: FORM_DRAFT_KEY }))
const [showDiscardDraftModal, setShowDiscardDraftModal] = useState(false)

const [draft, setDraft] = useState<FormDraft | undefined>(
!readOnly ? getDraft({ formDraftKey: FORM_DRAFT_KEY }) : undefined)

const isSaveEnabled = !!draft && isEditorValid && !saving

const saveDraftToLocalStorage = () => {
setDraft(currentDraft => {
if (currentDraft && currentDraft?.content !== getDraft({ formDraftKey: FORM_DRAFT_KEY })?.content) {
saveDraft({
formDraftKey: FORM_DRAFT_KEY,
draft: currentDraft,
setSavingDraft
})
}
return currentDraft
})
}

useAutosaveEffect(saveDraftToLocalStorage, FORM_DRAFT_SAVE_INTERVAL)

const onClickSave = async () => {
if (!isSaveEnabled) {
return
}
setSaving(true)
try {
await onSave({ content: editedContent })
setEditedContent(undefined)
await onSave({ content: draft?.content })
//Once we've persisted the form draft to the database, there's no need to keep it in local storage.
//Future drafts will have different FORM_DRAFT_KEYs anyway, as they're based on the form version number.
deleteDraft({ formDraftKey: FORM_DRAFT_KEY })
setDraft(undefined)
} finally {
setSaving(false)
}
}

const onClickCancel = () => {
if (draft) {
setShowDiscardDraftModal(true)
} else {
onCancel()
}
}

return (
<div className="SurveyView d-flex flex-column flex-grow-1 mx-1 mb-1">
<div className="d-flex p-2 align-items-center">
Expand All @@ -47,12 +89,13 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
<span className="detail me-2 ms-2">version {currentForm.version}</span>
</h5>
</div>
{ savingDraft && <span className="detail me-2 ms-2">Saving draft...</span> }
{!readOnly && (
<Button
disabled={!isSaveEnabled}
className="me-md-2"
tooltip={(() => {
if (!editedContent) {
if (!draft) {
return 'Form is unchanged. Make changes to save.'
}
if (!isEditorValid) {
Expand All @@ -66,14 +109,31 @@ const SurveyEditorView = (props: SurveyEditorViewProps) => {
Save
</Button>
)}
<button className="btn btn-secondary" type="button" onClick={onCancel}>Cancel</button>
<button className="btn btn-secondary" type="button"
onClick={onClickCancel}>Cancel</button>
{ showLoadedDraftModal && draft &&
<LoadedLocalDraftModal
onDismiss={() => setShowLoadedDraftModal(false)}
lastUpdated={draft?.date}/>}
{ showDiscardDraftModal && draft &&
<DiscardLocalDraftModal
formDraftKey={FORM_DRAFT_KEY}
onExit={() => onCancel()}
onSaveDraft={() =>
saveDraft({
formDraftKey: FORM_DRAFT_KEY,
draft,
setSavingDraft
})}
onDismiss={() => setShowDiscardDraftModal(false)}
/>}
</div>
<FormContentEditor
initialContent={currentForm.content}
initialContent={draft?.content || currentForm.content} //favor loading the draft, if we find one
readOnly={readOnly}
onChange={(isValid, newContent) => {
if (isValid) {
setEditedContent(JSON.stringify(newContent))
setDraft({ content: JSON.stringify(newContent), date: Date.now() })
}
setIsEditorValid(isValid)
}}
Expand Down
18 changes: 18 additions & 0 deletions ui-core/src/autoSaveUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect } from 'react'

/** TODO JSdoc */
export function useAutosaveEffect(saveFn: () => void, autoSaveInterval: number) {
useEffect(() => {
let timeoutHandle: number
// run saveFn at the specified interval
(function loop() {
timeoutHandle = window.setTimeout(() => {
saveFn()
loop()
}, autoSaveInterval)
})()
return () => {
window.clearTimeout(timeoutHandle)
}
}, [saveFn])
}
Loading

0 comments on commit 4693e2b

Please sign in to comment.