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>
|
</FormStepperWithNavigation>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardPanel>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
ComplianceStatus,
|
ComplianceStatus,
|
||||||
type ApplicationFormDto,
|
type ApplicationFormDto,
|
||||||
@@ -54,6 +90,7 @@ import {
|
|||||||
} from '~~/.api-client'
|
} from '~~/.api-client'
|
||||||
import type { FormElementId } from '~~/types/formElement'
|
import type { FormElementId } from '~~/types/formElement'
|
||||||
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
||||||
|
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
|
||||||
import { useUserStore } from '~~/stores/useUserStore'
|
import { useUserStore } from '~~/stores/useUserStore'
|
||||||
import { useCommentStore } from '~~/stores/useCommentStore'
|
import { useCommentStore } from '~~/stores/useCommentStore'
|
||||||
|
|
||||||
@@ -91,6 +128,13 @@ const sectionIndex = computed(() => {
|
|||||||
return !isNaN(index) ? index : 0
|
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(() => {
|
const isReadOnly = computed(() => {
|
||||||
return applicationForm.value?.createdBy?.keycloakId !== user.value?.keycloakId
|
return applicationForm.value?.createdBy?.keycloakId !== user.value?.keycloakId
|
||||||
})
|
})
|
||||||
@@ -102,6 +146,11 @@ onMounted(async () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
originalFormJson.value = JSON.stringify(applicationForm.value)
|
originalFormJson.value = JSON.stringify(applicationForm.value)
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
|
||||||
|
// Show recovery modal if backup exists
|
||||||
|
if (hasBackup.value) {
|
||||||
|
showRecoveryModal.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -165,12 +214,50 @@ watch(sectionIndex, async () => {
|
|||||||
originalFormJson.value = JSON.stringify(applicationForm.value)
|
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() {
|
async function onSave() {
|
||||||
if (applicationForm.value?.id) {
|
if (applicationForm.value?.id) {
|
||||||
const updated = await updateForm(applicationForm.value.id, applicationForm.value)
|
const updated = await updateForm(applicationForm.value.id, applicationForm.value)
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updateApplicationForm(updated)
|
updateApplicationForm(updated)
|
||||||
originalFormJson.value = JSON.stringify(updated)
|
originalFormJson.value = JSON.stringify(updated)
|
||||||
|
clearBackup()
|
||||||
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
|
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardPanel>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
ComplianceStatus,
|
ComplianceStatus,
|
||||||
type FormElementDto,
|
type FormElementDto,
|
||||||
@@ -46,6 +73,7 @@ import {
|
|||||||
type PagedApplicationFormDto
|
type PagedApplicationFormDto
|
||||||
} from '~~/.api-client'
|
} from '~~/.api-client'
|
||||||
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
||||||
|
import { useLocalStorageBackup } from '~/composables/useLocalStorageBackup'
|
||||||
import type { FormElementId } from '~~/types/formElement'
|
import type { FormElementId } from '~~/types/formElement'
|
||||||
import { useUserStore } from '~~/stores/useUserStore'
|
import { useUserStore } from '~~/stores/useUserStore'
|
||||||
|
|
||||||
@@ -77,6 +105,11 @@ const applicationFormTemplate = computed(
|
|||||||
() => data?.value?.content[0] ?? undefined
|
() => 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 validationMap = ref<Map<FormElementId, ComplianceStatus> | undefined>()
|
||||||
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
|
const validationStatus = ref<ComplianceStatus>(ComplianceStatus.NonCritical)
|
||||||
|
|
||||||
@@ -86,6 +119,11 @@ const originalFormJson = ref<string>('')
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
|
||||||
|
// Show recovery modal if backup exists
|
||||||
|
if (hasBackup.value) {
|
||||||
|
showRecoveryModal.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -133,11 +171,45 @@ watch(
|
|||||||
{ deep: true }
|
{ 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() {
|
async function onSave() {
|
||||||
const applicationForm = await prepareAndCreateApplicationForm()
|
const applicationForm = await prepareAndCreateApplicationForm()
|
||||||
if (applicationForm?.id) {
|
if (applicationForm?.id) {
|
||||||
// Reset to prevent unsaved changes warning when navigating
|
// Reset to prevent unsaved changes warning when navigating
|
||||||
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
||||||
|
clearBackup()
|
||||||
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
|
toast.add({ title: $t('common.success'), description: $t('applicationForms.saved'), color: 'success' })
|
||||||
await navigateTo(`/application-forms/${applicationForm.id}/0`)
|
await navigateTo(`/application-forms/${applicationForm.id}/0`)
|
||||||
}
|
}
|
||||||
@@ -149,6 +221,7 @@ async function onSubmit() {
|
|||||||
await submitApplicationForm(applicationForm.id)
|
await submitApplicationForm(applicationForm.id)
|
||||||
// Reset to prevent unsaved changes warning when navigating
|
// Reset to prevent unsaved changes warning when navigating
|
||||||
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
originalFormJson.value = JSON.stringify(applicationFormTemplate.value)
|
||||||
|
clearBackup()
|
||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
toast.add({ title: $t('common.success'), description: $t('applicationForms.submitted'), color: 'success' })
|
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({
|
export default withNuxt({
|
||||||
rules: {
|
rules: {
|
||||||
|
'vue/no-multiple-template-root': 'off',
|
||||||
'vue/html-self-closing': [
|
'vue/html-self-closing': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,7 +69,14 @@
|
|||||||
"versions": "Versionen",
|
"versions": "Versionen",
|
||||||
"preview": "Vorschau"
|
"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": {
|
"templates": {
|
||||||
"title": "Vorlagen",
|
"title": "Vorlagen",
|
||||||
|
|||||||
@@ -69,7 +69,14 @@
|
|||||||
"versions": "Versions",
|
"versions": "Versions",
|
||||||
"preview": "Preview"
|
"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": {
|
"templates": {
|
||||||
"title": "Templates",
|
"title": "Templates",
|
||||||
|
|||||||
Reference in New Issue
Block a user