Appearance
Unsaved Changes Guard — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Prevent accidental loss of unsaved form data when navigating away from the product form page.
Architecture: Add dirty-tracking via JSON snapshot comparison in useProductForm, intercept in-app navigation with onBeforeRouteLeave, guard browser close with beforeunload, and show a 3-button UModal (Save / Discard / Cancel).
Tech Stack: Vue 3 Composition API, Vue Router navigation guards, beforeunload event, Nuxt UI UModal
Task 1: Add dirty tracking to useProductForm composable
Files:
- Modify:
app/features/product-form/composables/useProductForm.ts
Step 1: Add snapshot state and isDirty computed
After the canPublish computed (line 46), add:
ts
// Dirty tracking — snapshot comparison
const initialSnapshot = ref('')
function takeSnapshot() {
initialSnapshot.value = JSON.stringify(form)
}
const isDirty = computed(() => {
if (!initialSnapshot.value) return false
return JSON.stringify(form) !== initialSnapshot.value
})Step 2: Take snapshot after form initialization (create mode)
At the end of the useProductForm function, before the return, add:
ts
// Take initial snapshot for create mode (edit mode snapshot taken after loadProduct)
if (mode === 'create') {
takeSnapshot()
}Step 3: Take snapshot after loadProduct (edit mode)
In loadProduct(), after form.compatibility = p.compatibility (line 94), before return p, add:
ts
takeSnapshot()Step 4: Update snapshot after successful saveDraft
In saveDraft(), after draftSaved.value = true (line 156), add:
ts
takeSnapshot()Step 5: Export isDirty and takeSnapshot
Add to the return object:
ts
return {
// ... existing exports ...
isDirty,
takeSnapshot,
}Step 6: Run lint
Run: npm run lint Expected: clean
Step 7: Commit
bash
git add app/features/product-form/composables/useProductForm.ts
git commit -m "feat(product-form): add dirty tracking via JSON snapshot"Task 2: Add i18n keys for the unsaved changes modal
Files:
- Modify:
i18n/locales/ru.json
Step 1: Add modal text keys
In the "listing" section (after "oemPrimary": "(основной)" at line 201), add before the closing }:
json
"unsavedTitle": "Несохранённые изменения",
"unsavedMessage": "У вас есть несохранённые изменения. Сохранить черновик перед уходом?",
"unsavedSave": "Сохранить черновик",
"unsavedDiscard": "Не сохранять",
"unsavedCancel": "Остаться"Step 2: Commit
bash
git add i18n/locales/ru.json
git commit -m "feat(i18n): add unsaved changes modal text keys"Task 3: Add navigation guard and modal to ProductFormPage
Files:
- Modify:
app/features/product-form/ui/ProductFormPage.vue
Step 1: Destructure isDirty and takeSnapshot from composable
In the destructuring block (lines 12-30), add isDirty and takeSnapshot:
ts
const {
productId: _productId,
productIdRef,
form,
canPublish,
isDirty,
takeSnapshot,
saving,
publishing,
error,
fieldErrors,
draftSaved,
partCategories,
conditionCategories,
attributeCategories,
loadCategories,
loadProduct,
saveDraft,
publish,
ensureDraftForUpload,
} = useProductForm(props.mode, props.productId)Step 2: Add leave modal state and pending route
After the image upload line (const imageUpload = useImageUpload(productIdRef)), add:
ts
// Unsaved changes guard
const leaveModalOpen = ref(false)
const pendingRoute = ref<string | null>(null)
const skipGuard = ref(false)Step 3: Add onBeforeRouteLeave guard
After the onGeoChange function, before the "Publish disabled reason" section, add:
ts
// Navigation guard — prevent leaving with unsaved changes
onBeforeRouteLeave((to) => {
if (skipGuard.value || !isDirty.value) return true
pendingRoute.value = to.fullPath
leaveModalOpen.value = true
return false
})Step 4: Add beforeunload guard
After the onBeforeRouteLeave block, add:
ts
// Browser close/reload guard
function onBeforeUnload(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})Note: merge the new onMounted content into the existing onMounted block. The window.addEventListener line goes at the top of the existing onMounted(async () => { ... }). Add a separate onUnmounted call.
Final onMounted:
ts
onMounted(async () => {
window.addEventListener('beforeunload', onBeforeUnload)
await loadCategories()
if (props.mode === 'edit' && props.productId) {
const product = await loadProduct()
if (product) {
imageUpload.setServerImages(product.images ?? [])
const primaryCat = product.categories?.find(c => c.is_primary)
if (primaryCat) {
const cat = partCategories.value.find(pc => pc.id === primaryCat.category_id)
if (cat) {
if (cat.parent_id) {
selectedParentCategoryId.value = cat.parent_id
selectedChildCategoryId.value = cat.id
} else {
selectedParentCategoryId.value = cat.id
}
}
}
}
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})Step 5: Add modal action handlers
After onBeforeUnload, add:
ts
// Leave modal actions
async function saveAndLeave() {
leaveModalOpen.value = false
const id = await saveDraft()
if (id && pendingRoute.value) {
skipGuard.value = true
await router.push(pendingRoute.value)
}
}
function discardAndLeave() {
leaveModalOpen.value = false
if (pendingRoute.value) {
skipGuard.value = true
router.push(pendingRoute.value)
}
}Step 6: Update onPublish to skip guard
Modify the existing onPublish:
ts
async function onPublish() {
const success = await publish()
if (success) {
skipGuard.value = true
await router.push('/cabinet')
}
}Step 7: Update onSaveDraft to take snapshot on success
The saveDraft() in the composable already calls takeSnapshot() after success, so no change needed here. But update onSaveDraft to also reset draftSaved display on next interaction — no change needed, existing behavior is fine.
Step 8: Add UModal to template
Before the closing </div> of the root element (before line 407 </template>), add:
html
<!-- Unsaved changes modal -->
<UModal v-model:open="leaveModalOpen">
<template #header>
{{ t('listing.unsavedTitle') }}
</template>
<template #body>
<p>{{ t('listing.unsavedMessage') }}</p>
</template>
<template #footer>
<div class="flex justify-end gap-3">
<UButton variant="outline" @click="leaveModalOpen = false">
{{ t('listing.unsavedCancel') }}
</UButton>
<UButton color="error" variant="soft" @click="discardAndLeave">
{{ t('listing.unsavedDiscard') }}
</UButton>
<UButton color="primary" :loading="saving" @click="saveAndLeave">
{{ t('listing.unsavedSave') }}
</UButton>
</div>
</template>
</UModal>Step 9: Run lint
Run: npm run lint Expected: clean (fix with --fix if import order issues)
Step 10: Commit
bash
git add app/features/product-form/ui/ProductFormPage.vue
git commit -m "feat(product-form): add unsaved changes guard with modal"Task 4: Verify
Step 1: Run lint
Run: npm run lint Expected: clean
Step 2: Run typecheck
Run: npm run typecheck Expected: only pre-existing errors (auto-import issues in features/*/composables, chat module). No new errors in modified files.
Step 3: Run tests
Run: npm run test:run Expected: only pre-existing failures (2). No new failures.
Step 4: Manual verification checklist
- Open
/cabinet/products/new— form loads with defaults - Without changing anything, navigate away → no modal (form is clean)
- Type something in title → navigate away → modal appears
- Click "Остаться" → modal closes, stay on page
- Click "Не сохранять" → navigates away, no save
- Make changes → click "Сохранить черновик" on form → navigate away → no modal (snapshot updated)
- Make MORE changes after saving → navigate away → modal appears
- Click "Сохранить черновик" in modal → saves, then navigates
- Try closing browser tab with changes → native dialog appears
- Publish flow → no modal on redirect to
/cabinet
Step 5: Final commit (if any fixes needed)
bash
git add -A
git commit -m "fix(product-form): address review feedback for unsaved changes guard"