feat(#38): Add periodic local storage backup for application form
This commit is contained in:
97
legalconsenthub/app/composables/useLocalStorageBackup.ts
Normal file
97
legalconsenthub/app/composables/useLocalStorageBackup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user