Skip to content

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>
        &#123;&#123; t('listing.unsavedTitle') }}
      </template>
      <template #body>
        <p>&#123;&#123; t('listing.unsavedMessage') }}</p>
      </template>
      <template #footer>
        <div class="flex justify-end gap-3">
          <UButton variant="outline" @click="leaveModalOpen = false">
            &#123;&#123; t('listing.unsavedCancel') }}
          </UButton>
          <UButton color="error" variant="soft" @click="discardAndLeave">
            &#123;&#123; t('listing.unsavedDiscard') }}
          </UButton>
          <UButton color="primary" :loading="saving" @click="saveAndLeave">
            &#123;&#123; 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

  1. Open /cabinet/products/new — form loads with defaults
  2. Without changing anything, navigate away → no modal (form is clean)
  3. Type something in title → navigate away → modal appears
  4. Click "Остаться" → modal closes, stay on page
  5. Click "Не сохранять" → navigates away, no save
  6. Make changes → click "Сохранить черновик" on form → navigate away → no modal (snapshot updated)
  7. Make MORE changes after saving → navigate away → modal appears
  8. Click "Сохранить черновик" in modal → saves, then navigates
  9. Try closing browser tab with changes → native dialog appears
  10. 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"