feat(#38): Add periodic local storage backup for application form

This commit is contained in:
2026-02-21 09:07:36 +01:00
parent 008accbf8a
commit 1dc36ddb45
6 changed files with 274 additions and 2 deletions

View File

@@ -0,0 +1,97 @@
import type { ApplicationFormDto } from '~~/.api-client'
import { useLogger } from '~/composables/useLogger'
interface LocalStorageBackup {
formId: string
sectionIndex: number
timestamp: number
formData: ApplicationFormDto
}
const STORAGE_KEY_PREFIX = 'lch-form-backup-'
export function useLocalStorageBackup(formId: string) {
const storageKey = `${STORAGE_KEY_PREFIX}${formId}`
const logger = useLogger().withTag('localStorageBackup')
const hasBackup = ref(false)
const backupTimestamp = ref<Date | null>(null)
const backupSectionIndex = ref<number | null>(null)
function checkForBackup(): boolean {
if (import.meta.server) return false
try {
const storedApplicationForm = localStorage.getItem(storageKey)
if (storedApplicationForm) {
const backup = JSON.parse(storedApplicationForm) as LocalStorageBackup
hasBackup.value = true
backupTimestamp.value = new Date(backup.timestamp)
backupSectionIndex.value = backup.sectionIndex
return true
}
} catch {
clearBackup()
}
return false
}
function saveBackup(formData: ApplicationFormDto, currentSectionIndex: number): void {
if (import.meta.server) return
try {
const backup: LocalStorageBackup = {
formId,
sectionIndex: currentSectionIndex,
timestamp: Date.now(),
formData
}
localStorage.setItem(storageKey, JSON.stringify(backup))
hasBackup.value = true
backupTimestamp.value = new Date(backup.timestamp)
backupSectionIndex.value = currentSectionIndex
} catch (e) {
// localStorage might be full or unavailable
logger.error('Error saving backup', e)
}
}
function loadBackup(): ApplicationFormDto | null {
if (import.meta.server) return null
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const backup = JSON.parse(stored) as LocalStorageBackup
return backup.formData
}
} catch {
clearBackup()
}
return null
}
function clearBackup(): void {
if (import.meta.server) return
localStorage.removeItem(storageKey)
hasBackup.value = false
backupTimestamp.value = null
backupSectionIndex.value = null
}
// Call synchronously - import.meta.server guard handles SSR safety.
// Do NOT use onMounted here: this composable is called after `await` in async setup(),
// which means Vue's active component instance is gone and lifecycle hooks cannot be registered.
checkForBackup()
return {
hasBackup: readonly(hasBackup),
backupTimestamp: readonly(backupTimestamp),
backupSectionIndex: readonly(backupSectionIndex),
saveBackup,
loadBackup,
clearBackup,
checkForBackup
}
}

View File

@@ -43,9 +43,45 @@
</FormStepperWithNavigation>
</template>
</UDashboardPanel>
<UModal
:open="showRecoveryModal"
:title="$t('applicationForms.recovery.title')"
@update:open="showRecoveryModal = $event"
>
<template #body>
<p class="text-sm text-(--ui-text-muted)">
{{
$t('applicationForms.recovery.message', {
timestamp: backupTimestamp?.toLocaleString()
})
}}
</p>
<p
v-if="backupSectionIndex !== null && backupSectionIndex !== sectionIndex"
class="text-sm text-(--ui-text-muted) mt-2"
>
{{
$t('applicationForms.recovery.sectionNote', {
section: (backupSectionIndex + 1).toString()
})
}}
</p>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleDiscardBackup">
{{ $t('applicationForms.recovery.discard') }}
</UButton>
<UButton color="primary" @click="handleRestoreBackup">
{{ $t('applicationForms.recovery.restore') }}
</UButton>
</template>
</UModal>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import {
ComplianceStatus,
type ApplicationFormDto,
@@ -54,6 +90,7 @@ import {
} from '~~/.api-client'
import type { FormElementId } from '~~/types/formElement'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
import { useUserStore } from '~~/stores/useUserStore'
import { useCommentStore } from '~~/stores/useCommentStore'
@@ -91,6 +128,13 @@ const sectionIndex = computed(() => {
return !isNaN(index) ? index : 0
})
// LocalStorage backup for auto-save
const { hasBackup, backupTimestamp, backupSectionIndex, saveBackup, loadBackup, clearBackup } = useLocalStorageBackup(
applicationFormId!
)
const showRecoveryModal = ref(false)
const isReadOnly = computed(() => {
return applicationForm.value?.createdBy?.keycloakId !== user.value?.keycloakId
})
@@ -102,6 +146,11 @@ onMounted(async () => {
await nextTick()
originalFormJson.value = JSON.stringify(applicationForm.value)
window.addEventListener('beforeunload', handleBeforeUnload)
// Show recovery modal if backup exists
if (hasBackup.value) {
showRecoveryModal.value = true
}
})
onUnmounted(() => {
@@ -165,12 +214,50 @@ watch(sectionIndex, async () => {
originalFormJson.value = JSON.stringify(applicationForm.value)
})
// Auto-save to localStorage with 5 second debounce
watchDebounced(
() => applicationForm.value,
(form) => {
if (form && hasUnsavedChanges.value && !isReadOnly.value) {
saveBackup(form, sectionIndex.value)
}
},
{ debounce: 5000, deep: true }
)
function handleRestoreBackup() {
const backupData = loadBackup()
if (backupData && applicationForm.value) {
updateApplicationForm(backupData)
originalFormJson.value = JSON.stringify(backupData)
showRecoveryModal.value = false
clearBackup()
// Navigate to the section user was editing
if (backupSectionIndex.value !== null && backupSectionIndex.value !== sectionIndex.value) {
navigateTo(`/application-forms/${applicationFormId}/${backupSectionIndex.value}`)
}
toast.add({
title: $t('common.success'),
description: $t('applicationForms.recovery.restore'),
color: 'success'
})
}
}
function handleDiscardBackup() {
clearBackup()
showRecoveryModal.value = false
}
async function onSave() {
if (applicationForm.value?.id) {
const updated = await updateForm(applicationForm.value.id, applicationForm.value)
if (updated) {
updateApplicationForm(updated)
originalFormJson.value = JSON.stringify(updated)
clearBackup()
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
}
}

View File

@@ -36,9 +36,36 @@
</div>
</template>
</UDashboardPanel>
<!-- Recovery Modal - outside UDashboardPanel to avoid SSR Teleport hydration conflicts -->
<UModal
:open="showRecoveryModal"
:title="$t('applicationForms.recovery.title')"
@update:open="showRecoveryModal = $event"
>
<template #body>
<p class="text-sm text-(--ui-text-muted)">
{{
$t('applicationForms.recovery.message', {
timestamp: backupTimestamp?.toLocaleString()
})
}}
</p>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleDiscardBackup">
{{ $t('applicationForms.recovery.discard') }}
</UButton>
<UButton color="primary" @click="handleRestoreBackup">
{{ $t('applicationForms.recovery.restore') }}
</UButton>
</template>
</UModal>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import {
ComplianceStatus,
type FormElementDto,
@@ -46,6 +73,7 @@ import {
type PagedApplicationFormDto
} from '~~/.api-client'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
import type { FormElementId } from '~~/types/formElement'
import { useUserStore } from '~~/stores/useUserStore'
@@ -77,6 +105,11 @@ const applicationFormTemplate = computed(
() => data?.value?.content[0] ?? undefined
)
// LocalStorage backup for auto-save (using 'create' as key for new forms)
const { hasBackup, backupTimestamp, saveBackup, loadBackup, clearBackup } = useLocalStorageBackup('create')
const showRecoveryModal = ref(false)
const validationMap = ref<Map<FormElementId, ComplianceStatus> | undefined>()
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
@@ -86,6 +119,11 @@ const originalFormJson = ref<string>('')
onMounted(() => {
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
window.addEventListener('beforeunload', handleBeforeUnload)
// Show recovery modal if backup exists
if (hasBackup.value) {
showRecoveryModal.value = true
}
})
onUnmounted(() => {
@@ -133,11 +171,45 @@ watch(
{ deep: true }
)
// Auto-save to localStorage with 5 second debounce
watchDebounced(
() => applicationFormTemplate.value,
(form) => {
if (form && hasUnsavedChanges.value) {
saveBackup(form, 0)
}
},
{ debounce: 5000, deep: true }
)
function handleRestoreBackup() {
const backupData = loadBackup()
if (backupData && data.value?.content[0]) {
// Restore the backed up form data to the template
Object.assign(data.value.content[0], backupData)
originalFormJson.value = JSON.stringify(backupData)
showRecoveryModal.value = false
clearBackup()
toast.add({
title: $t('common.success'),
description: $t('applicationForms.recovery.restore'),
color: 'success'
})
}
}
function handleDiscardBackup() {
clearBackup()
showRecoveryModal.value = false
}
async function onSave() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm?.id) {
// Reset to prevent unsaved changes warning when navigating
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
clearBackup()
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
await navigateTo(`/application-forms/${applicationForm.id}/0`)
}
@@ -149,6 +221,7 @@ async function onSubmit() {
await submitApplicationForm(applicationForm.id)
// Reset to prevent unsaved changes warning when navigating
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
clearBackup()
await navigateTo('/')
toast.add({ title: $t('common.success'), description: $t('applicationForms.submitted'), color: 'success' })
}

View File

@@ -2,6 +2,7 @@ import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/no-multiple-template-root': 'off',
'vue/html-self-closing': [
'error',
{

View File

@@ -69,7 +69,14 @@
"versions": "Versionen",
"preview": "Vorschau"
},
"unsavedWarning": "Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?"
"unsavedWarning": "Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?",
"recovery": {
"title": "Nicht gespeicherte Änderungen gefunden",
"message": "Es wurde eine lokale Sicherung von {timestamp} gefunden. Möchten Sie diese wiederherstellen?",
"sectionNote": "Sie haben Abschnitt {section} bearbeitet.",
"restore": "Wiederherstellen",
"discard": "Verwerfen"
}
},
"templates": {
"title": "Vorlagen",

View File

@@ -69,7 +69,14 @@
"versions": "Versions",
"preview": "Preview"
},
"unsavedWarning": "You have unsaved changes. Do you really want to leave this page?"
"unsavedWarning": "You have unsaved changes. Do you really want to leave this page?",
"recovery": {
"title": "Unsaved changes found",
"message": "A local backup from {timestamp} was found. Would you like to restore it?",
"sectionNote": "You were editing section {section}.",
"restore": "Restore",
"discard": "Discard"
}
},
"templates": {
"title": "Templates",