From 367e5c25415bd6b6ca969d40d1ec1607bec87b8d Mon Sep 17 00:00:00 2001 From: Julius Schlapbach <80708107+sjschlapbach@users.noreply.github.com> Date: Thu, 23 Jan 2025 18:48:33 +0100 Subject: [PATCH] feat(apps/manage-frontend): auto-save of user inputs in element edit modal (#4474) --- .github/workflows/cypress-testing.yml | 2 +- .../src/components/questions/Question.tsx | 29 +- .../questions/manipulation/AutoSaveMonitor.ts | 45 +++ .../manipulation/ElementContentInput.tsx | 1 - .../manipulation/ElementEditModal.tsx | 45 ++- .../questions/manipulation/RecoveryPrompt.tsx | 63 ++++ apps/frontend-manage/src/pages/index.tsx | 33 +- .../e2e/B-feature-access-workflow.cy.ts | 2 +- .../cypress/e2e/D-questions-workflow.cy.ts | 324 +++++++++++++++++- cypress/cypress/fixtures/D-questions.json | 14 + packages/i18n/messages/de.ts | 7 + packages/i18n/messages/en.ts | 7 + 12 files changed, 540 insertions(+), 32 deletions(-) create mode 100644 apps/frontend-manage/src/components/questions/manipulation/AutoSaveMonitor.ts create mode 100644 apps/frontend-manage/src/components/questions/manipulation/RecoveryPrompt.tsx diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index 8b475aaa5f..518af9013c 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -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 diff --git a/apps/frontend-manage/src/components/questions/Question.tsx b/apps/frontend-manage/src/components/questions/Question.tsx index 49680ed50e..e0631643dd 100644 --- a/apps/frontend-manage/src/components/questions/Question.tsx +++ b/apps/frontend-manage/src/components/questions/Question.tsx @@ -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 = { @@ -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 ) @@ -177,7 +179,15 @@ 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}` }} > @@ -185,6 +195,21 @@ function Question({ {t('shared.generic.edit')} + {showRecoveryPrompt && ( + { + setShowRecoveryPrompt(false) + setIsModificationModalOpen(true) + }} + onDiscard={() => { + localStorage.removeItem(`autosave-element-${id}`) + setShowRecoveryPrompt(false) + setIsModificationModalOpen(true) + }} + editMode={false} + /> + )} {isModificationModalOpen && ( setIsDuplicationModalOpen(true)} + onClick={() => setIsDuplicationModalOpen(true)} data={{ cy: `duplicate-question-${title}` }} > diff --git a/apps/frontend-manage/src/components/questions/manipulation/AutoSaveMonitor.ts b/apps/frontend-manage/src/components/questions/manipulation/AutoSaveMonitor.ts new file mode 100644 index 0000000000..0edf4bfe73 --- /dev/null +++ b/apps/frontend-manage/src/components/questions/manipulation/AutoSaveMonitor.ts @@ -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> +}) { + // 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(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 diff --git a/apps/frontend-manage/src/components/questions/manipulation/ElementContentInput.tsx b/apps/frontend-manage/src/components/questions/manipulation/ElementContentInput.tsx index 6fdd9d20f8..3e45a29865 100644 --- a/apps/frontend-manage/src/components/questions/manipulation/ElementContentInput.tsx +++ b/apps/frontend-manage/src/components/questions/manipulation/ElementContentInput.tsx @@ -41,7 +41,6 @@ function ElementContentInput({ tooltip={t('manage.questionForms.questionTooltip')} /> '} diff --git a/apps/frontend-manage/src/components/questions/manipulation/ElementEditModal.tsx b/apps/frontend-manage/src/components/questions/manipulation/ElementEditModal.tsx index faf343a275..a6a2457f03 100644 --- a/apps/frontend-manage/src/components/questions/manipulation/ElementEditModal.tsx +++ b/apps/frontend-manage/src/components/questions/manipulation/ElementEditModal.tsx @@ -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' @@ -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' @@ -81,6 +84,14 @@ function ElementEditModal({ numberOfAnswerOptions: answerCollectionEntries.length, }) + const [autoSavedElement, setAutoSavedElement] = + useLocalStorage( + typeof elementId === 'undefined' || isDuplication + ? 'autosave-element-creation' + : `autosave-element-${elementId}`, + undefined + ) + const { loading: loadingQuestion, data: dataQuestion } = useQuery( GetSingleQuestionDocument, { @@ -115,7 +126,17 @@ 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
} @@ -123,7 +144,7 @@ function ElementEditModal({ { setSubmitting(true) @@ -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) @@ -334,12 +365,17 @@ function ElementEditModal({ } > + )}
- void + onDiscard: () => void + editMode: boolean +}) { + const t = useTranslations() + + return ( + null} + title={t('manage.questionForms.recoverData')} + className={{ content: 'gap-1' }} + > + +
+ + +
+
+ ) +} + +export default RecoveryPrompt diff --git a/apps/frontend-manage/src/pages/index.tsx b/apps/frontend-manage/src/pages/index.tsx index 4a361c6b2b..6aaa8310e6 100644 --- a/apps/frontend-manage/src/pages/index.tsx +++ b/apps/frontend-manage/src/pages/index.tsx @@ -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, @@ -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 ) const [isQuestionCreationModalOpen, setIsQuestionCreationModalOpen] = useState(false) - const [sortBy, setSortBy] = useState('') - const [successToast, setSuccessToast] = useState(false) const [selectedQuestions, setSelectedQuestions] = useState< Record @@ -390,9 +392,17 @@ function Index() { )}