Skip to content

Commit

Permalink
feat(apps/manage-frontend): auto-save of user inputs in element edit …
Browse files Browse the repository at this point in the history
…modal (#4474)
  • Loading branch information
sjschlapbach authored Jan 23, 2025
1 parent 49da9f6 commit 367e5c2
Show file tree
Hide file tree
Showing 12 changed files with 540 additions and 32 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/cypress-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ jobs:

- name: Upload service logs
if: always() # This ensures logs are uploaded even if the previous step fails
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: service-logs
path: service.log
Expand Down
29 changes: 27 additions & 2 deletions apps/frontend-manage/src/components/questions/Question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { twMerge } from 'tailwind-merge'
import ElementEditModal, {
ElementEditMode,
} from './manipulation/ElementEditModal'
import RecoveryPrompt from './manipulation/RecoveryPrompt'
import QuestionTags from './QuestionTags'

const StatusColors: Record<ElementStatus, string> = {
Expand Down Expand Up @@ -81,6 +82,7 @@ function Question({
const [isModificationModalOpen, setIsModificationModalOpen] = useState(false)
const [isDuplicationModalOpen, setIsDuplicationModalOpen] = useState(false)
const [isDeletionModalOpen, setIsDeletionModalOpen] = useState(false)
const [showRecoveryPrompt, setShowRecoveryPrompt] = useState(false)
const [deleteQuestion, { loading: deleting }] = useMutation(
DeleteQuestionDocument
)
Expand Down Expand Up @@ -177,14 +179,37 @@ function Question({
className={{
root: 'space-x-2 bg-white text-sm md:w-36 md:text-base',
}}
onClick={(): void => setIsModificationModalOpen(true)}
onClick={() => {
const value = localStorage.getItem(`autosave-element-${id}`)

if (value) {
setShowRecoveryPrompt(true)
} else {
setIsModificationModalOpen(true)
}
}}
data={{ cy: `edit-question-${title}` }}
>
<Button.Icon>
<FontAwesomeIcon icon={faPencil} />
</Button.Icon>
<Button.Label>{t('shared.generic.edit')}</Button.Label>
</Button>
{showRecoveryPrompt && (
<RecoveryPrompt
open={showRecoveryPrompt}
onRecovery={() => {
setShowRecoveryPrompt(false)
setIsModificationModalOpen(true)
}}
onDiscard={() => {
localStorage.removeItem(`autosave-element-${id}`)
setShowRecoveryPrompt(false)
setIsModificationModalOpen(true)
}}
editMode={false}
/>
)}
{isModificationModalOpen && (
<ElementEditModal
handleSetIsOpen={setIsModificationModalOpen}
Expand All @@ -198,7 +223,7 @@ function Question({
className={{
root: 'space-x-2 bg-white text-sm md:w-36 md:text-base',
}}
onClick={(): void => setIsDuplicationModalOpen(true)}
onClick={() => setIsDuplicationModalOpen(true)}
data={{ cy: `duplicate-question-${title}` }}
>
<Button.Icon>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react'
import { ElementFormTypes } from './types'

function AutoSaveMonitor({
values,
initialValuesString,
setAutoSavedElement,
}: {
values: ElementFormTypes
initialValuesString: string
setAutoSavedElement: Dispatch<SetStateAction<ElementFormTypes>>
}) {
// create a call-back function that will save the editor's content every 2 seconds
// (if not actively typing -> do not disturb other state updates)
const savingTimeout = useRef<NodeJS.Timeout | null>(null)
const autoSaveContent = useCallback(
({ values }: { values: ElementFormTypes }) => {
if (savingTimeout.current) {
clearTimeout(savingTimeout.current as NodeJS.Timeout)
}

savingTimeout.current = setTimeout(async () => {
// only update the stored content if it has changed
if (JSON.stringify(values) !== initialValuesString) {
setAutoSavedElement(values)
}
}, 2000)
},
[setAutoSavedElement, initialValuesString]
)

useEffect(() => {
autoSaveContent({ values })

return () => {
if (savingTimeout.current) {
clearTimeout(savingTimeout.current as NodeJS.Timeout)
}
}
}, [values])

return null
}

export default AutoSaveMonitor
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ function ElementContentInput({
tooltip={t('manage.questionForms.questionTooltip')}
/>
<ContentInput
autoFocus
error={meta.error}
touched={meta.touched}
content={field.value || '<br>'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
ManipulateSelectionQuestionDocument,
UpdateElementInstancesDocument,
} from '@klicker-uzh/graphql/dist/ops'
import { useLocalStorage } from '@uidotdev/usehooks'
import { Button, Modal } from '@uzh-bf/design-system'
import { Form, Formik } from 'formik'
import { useTranslations } from 'next-intl'
import React, { useState } from 'react'
import React, { useMemo, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import AutoSaveMonitor from './AutoSaveMonitor'
import ElementContentInput from './ElementContentInput'
import ElementExplanationField from './ElementExplanationField'
import ElementFailureToast from './ElementFailureToast'
Expand All @@ -42,6 +44,7 @@ import NumericalOptions from './options/NumericalOptions'
import OptionsLabel from './options/OptionsLabel'
import SampleSolutionSetting from './options/SampleSolutionSetting'
import SelectionOptions from './options/SelectionOptions'
import { ElementFormTypes } from './types'
import useElementFormInitialValues from './useElementFormInitialValues'
import useValidationSchema from './useValidationSchema'

Expand Down Expand Up @@ -81,6 +84,14 @@ function ElementEditModal({
numberOfAnswerOptions: answerCollectionEntries.length,
})

const [autoSavedElement, setAutoSavedElement] =
useLocalStorage<ElementFormTypes>(
typeof elementId === 'undefined' || isDuplication
? 'autosave-element-creation'
: `autosave-element-${elementId}`,
undefined
)

const { loading: loadingQuestion, data: dataQuestion } = useQuery(
GetSingleQuestionDocument,
{
Expand Down Expand Up @@ -115,15 +126,25 @@ function ElementEditModal({
isDuplication,
})

if (!initialValues || Object.keys(initialValues).length === 0) {
// only update the form values on initial rendering in creation or edit mode (not in duplication mode)
// (otherwise, saving the question will directly trigger another save)
const formikInitialValues = useMemo(() => {
if (!initialValues) {
return undefined
}
return isDuplication ? initialValues : (autoSavedElement ?? initialValues)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, isDuplication, initialValues])

if (!formikInitialValues || Object.keys(formikInitialValues).length === 0) {
return <div />
}

return (
<Formik
validateOnMount
enableReinitialize
initialValues={initialValues}
initialValues={formikInitialValues}
validationSchema={questionManipulationSchema}
onSubmit={async (values, { setSubmitting }) => {
setSubmitting(true)
Expand Down Expand Up @@ -284,6 +305,16 @@ function ElementEditModal({
}
}

// remove local storage entry
if (autoSavedElement) {
localStorage.removeItem(
typeof elementId === 'undefined' || isDuplication
? 'autosave-element-creation'
: `autosave-element-${elementId}`
)
}

// close modal, set success toast
setSubmitting(false)
triggerSuccessToast()
handleSetIsOpen(false)
Expand Down Expand Up @@ -334,12 +365,17 @@ function ElementEditModal({
<Button
className={{ root: 'border-uzh-grey-80 mt-2' }}
onClick={() => handleSetIsOpen(false)}
data={{ cy: 'close-question-modal' }}
data={{ cy: 'close-element-modal' }}
>
<Button.Label>{t('shared.generic.close')}</Button.Label>
</Button>
}
>
<AutoSaveMonitor
values={values}
initialValuesString={JSON.stringify(formikInitialValues)}
setAutoSavedElement={setAutoSavedElement}
/>
<ElementTypeMonitor
elementType={values.type ?? ElementType.Sc}
setElementDataTypename={setElementDataTypename}
Expand Down Expand Up @@ -398,7 +434,6 @@ function ElementEditModal({
<ElementFormErrors errors={errors} />
)}
</div>

<StudentElementPreview
values={values}
elementDataTypename={elementDataTypename}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { faArrowsRotate, faBan } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Button, Modal, UserNotification } from '@uzh-bf/design-system'
import { useTranslations } from 'next-intl'

function RecoveryPrompt({
open,
onRecovery,
onDiscard,
editMode,
}: {
open: boolean
onRecovery: () => void
onDiscard: () => void
editMode: boolean
}) {
const t = useTranslations()

return (
<Modal
hideCloseButton
escapeDisabled
open={open}
onClose={() => null}
title={t('manage.questionForms.recoverData')}
className={{ content: 'gap-1' }}
>
<UserNotification
type="warning"
message={
editMode
? t('manage.questionForms.temporaryStorageEditing')
: t('manage.questionForms.temporaryStorageCreation')
}
className={{ root: 'text-base' }}
/>
<div className="mt-2 flex flex-row justify-between">
<Button
onClick={onDiscard}
className={{
root: 'border-2 border-red-600 hover:border-red-600 hover:text-red-600',
}}
data={{ cy: 'discard-recovered-element-data' }}
>
<FontAwesomeIcon icon={faBan} />
<div>{t('manage.questionForms.discard')}</div>
</Button>
<Button
onClick={onRecovery}
className={{
root: 'border-primary-80 hover:border-primary-80 border-2',
}}
data={{ cy: 'load-recovered-element-data' }}
>
<FontAwesomeIcon icon={faArrowsRotate} />
<div>{t('manage.questionForms.loadData')}</div>
</Button>
</div>
</Modal>
)
}

export default RecoveryPrompt
33 changes: 28 additions & 5 deletions apps/frontend-manage/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Suspense, useEffect, useMemo, useState } from 'react'
import { isEmpty, pickBy } from 'remeda'
import { buildIndex, processItems } from 'src/lib/utils/filters'
import ElementSuccessToast from '~/components/questions/manipulation/ElementSuccessToast'
import RecoveryPrompt from '~/components/questions/manipulation/RecoveryPrompt'
import SuspendedCreationButtons from '../components/activities/creation/SuspendedCreationButtons'
import ElementCreation, {
WizardMode,
Expand All @@ -52,13 +53,14 @@ function Index() {
)

const [searchInput, setSearchInput] = useState('')
const [sortBy, setSortBy] = useState('')
const [successToast, setSuccessToast] = useState(false)
const [showRecoveryPrompt, setShowRecoveryPrompt] = useState(false)
const [creationMode, setCreationMode] = useState<undefined | WizardMode>(
undefined
)
const [isQuestionCreationModalOpen, setIsQuestionCreationModalOpen] =
useState(false)
const [sortBy, setSortBy] = useState('')
const [successToast, setSuccessToast] = useState(false)

const [selectedQuestions, setSelectedQuestions] = useState<
Record<number, Element | undefined>
Expand Down Expand Up @@ -390,9 +392,17 @@ function Index() {
)}
</div>
<Button
onClick={() =>
setIsQuestionCreationModalOpen(!isQuestionCreationModalOpen)
}
onClick={() => {
const value = localStorage.getItem(
'autosave-element-creation'
)

if (value) {
setShowRecoveryPrompt(true)
} else {
setIsQuestionCreationModalOpen(true)
}
}}
className={{
root: 'bg-primary-80 h-10 font-bold text-white',
}}
Expand Down Expand Up @@ -446,6 +456,19 @@ function Index() {
mode={ElementEditMode.CREATE}
/>
)}
<RecoveryPrompt
open={showRecoveryPrompt}
onRecovery={() => {
setShowRecoveryPrompt(false)
setIsQuestionCreationModalOpen(true)
}}
onDiscard={() => {
localStorage.removeItem('autosave-element-creation')
setShowRecoveryPrompt(false)
setIsQuestionCreationModalOpen(true)
}}
editMode={false}
/>
<Suspense fallback={<div />}>
<SuspendedFirstLoginModal />
</Suspense>
Expand Down
2 changes: 1 addition & 1 deletion cypress/cypress/e2e/B-feature-access-workflow.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('Tests the availability of certain functionalities to catalyst users on
`[data-cy="select-question-type-${messages.shared.SC.typeLabel}"]`
).click()
}
cy.get('[data-cy="close-question-modal"]').click()
cy.get('[data-cy="close-element-modal"]').click()
}

it('Test login for catalyst users and non-catalyst users', function () {
Expand Down
Loading

0 comments on commit 367e5c2

Please sign in to comment.