feat: warn before leaving a form with unsaved changes
Drag-dropping page images then clicking a nav link silently discarded the whole form. Add a reusable guardUnsavedChanges helper (SvelteKit beforeNavigate confirm + native beforeunload) and wire it into the manga upload, manga edit, and chapter upload forms. The dirty check returns false while submitting so the post-save redirect isn't prompted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
frontend/e2e/unsaved-guard.spec.ts
Normal file
59
frontend/e2e/unsaved-guard.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Leaving a form with unsaved work (e.g. a half-filled upload) must prompt so a
|
||||
// misclicked nav link doesn't silently discard everything.
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
async function authed(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: { id: 'u1', username: 'uploader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/genres', (r) =>
|
||||
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('upload form prompts before navigating away with unsaved changes', async ({ page }) => {
|
||||
await authed(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/upload');
|
||||
|
||||
// Make the form dirty.
|
||||
await page.getByLabel(/Title/).fill('WIP Manga');
|
||||
|
||||
// Cancel the confirm → navigation is blocked, we stay put with our input.
|
||||
let dialogSeen = false;
|
||||
page.once('dialog', (d) => {
|
||||
dialogSeen = true;
|
||||
d.dismiss();
|
||||
});
|
||||
await page.getByRole('link', { name: 'Bookmarks' }).click();
|
||||
|
||||
await expect.poll(() => dialogSeen).toBe(true);
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
await expect(page.getByLabel(/Title/)).toHaveValue('WIP Manga');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.127.2",
|
||||
"version": "0.128.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
36
frontend/src/lib/unsaved-guard.ts
Normal file
36
frontend/src/lib/unsaved-guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/**
|
||||
* Warn before leaving a page with unsaved form state. Call once from a
|
||||
* component's script (during init). `isDirty` is read live on each navigation,
|
||||
* so pass a closure over the form's reactive state — and have it return `false`
|
||||
* once a save has succeeded (or is in flight) so the post-save redirect doesn't
|
||||
* prompt.
|
||||
*
|
||||
* Covers both in-app navigation (SvelteKit `beforeNavigate`, with a confirm) and
|
||||
* full-page unload / tab close / reload (the native `beforeunload` prompt).
|
||||
*/
|
||||
export function guardUnsavedChanges(isDirty: () => boolean): void {
|
||||
beforeNavigate((nav) => {
|
||||
// `leave` (tab close / reload) can't show a custom confirm here — the
|
||||
// beforeunload handler below covers it. Only guard in-app navigations.
|
||||
if (nav.type === 'leave') return;
|
||||
if (!isDirty()) return;
|
||||
if (!confirm('You have unsaved changes. Leave this page and discard them?')) {
|
||||
nav.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty()) {
|
||||
e.preventDefault();
|
||||
// Legacy browsers require returnValue to be set.
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError, fileUrl } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import {
|
||||
deleteMangaCover,
|
||||
updateManga,
|
||||
@@ -49,6 +50,25 @@
|
||||
mangaTitle.trim().length > 0 && !coverError && !submitting
|
||||
);
|
||||
|
||||
// Warn before leaving with unsaved edits. `submitting` stays true through
|
||||
// the post-save redirect, so that navigation isn't prompted.
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!submitting &&
|
||||
(mangaTitle !== data.manga.title ||
|
||||
mangaStatus !== data.manga.status ||
|
||||
mangaDescription !== (data.manga.description ?? '') ||
|
||||
JSON.stringify(mangaAuthors) !==
|
||||
JSON.stringify(data.manga.authors.map((a) => a.name)) ||
|
||||
JSON.stringify(mangaAltTitles) !== JSON.stringify([...data.manga.alt_titles]) ||
|
||||
JSON.stringify(mangaGenreIds) !==
|
||||
JSON.stringify(data.manga.genres.map((g) => g.id)) ||
|
||||
coverFile != null ||
|
||||
pendingCoverRemoval ||
|
||||
authorDraft.trim() !== '' ||
|
||||
altTitleDraft.trim() !== '')
|
||||
);
|
||||
|
||||
function addAuthor() {
|
||||
const name = authorDraft.trim();
|
||||
if (!name) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError, fileUrl } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import { createChapter } from '$lib/api/chapters';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import ChapterPagesEditor, {
|
||||
@@ -19,6 +20,16 @@
|
||||
let submitting = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Warn before leaving with staged pages / edits. `submitting` stays true
|
||||
// through the post-save redirect, so that navigation isn't prompted.
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!submitting &&
|
||||
(pages.length > 0 ||
|
||||
title.trim() !== '' ||
|
||||
(number != null && number !== data.defaultNumber))
|
||||
);
|
||||
|
||||
const allPagesValid = $derived(pages.every((p) => !p.error));
|
||||
const canSubmit = $derived(
|
||||
Boolean(session.user) &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { guardUnsavedChanges } from '$lib/unsaved-guard';
|
||||
import { createManga, type MangaStatus } from '$lib/api/mangas';
|
||||
import { createChapter } from '$lib/api/chapters';
|
||||
import { session } from '$lib/session.svelte';
|
||||
@@ -43,6 +44,23 @@
|
||||
let mangaError = $state<string | null>(null);
|
||||
let success = $state<string | null>(null);
|
||||
|
||||
// Warn before navigating away with an in-progress upload. Suppressed while
|
||||
// submitting and after a successful save (which redirects).
|
||||
guardUnsavedChanges(
|
||||
() =>
|
||||
!success &&
|
||||
!submitting &&
|
||||
(mangaTitle.trim() !== '' ||
|
||||
mangaDescription.trim() !== '' ||
|
||||
mangaAuthors.length > 0 ||
|
||||
mangaAltTitles.length > 0 ||
|
||||
mangaGenreIds.length > 0 ||
|
||||
coverFile != null ||
|
||||
stagedChapters.length > 0 ||
|
||||
authorDraft.trim() !== '' ||
|
||||
altTitleDraft.trim() !== '')
|
||||
);
|
||||
|
||||
const allChapterPagesValid = $derived(
|
||||
stagedChapters.every((c) => c.pages.every((p) => !p.error))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user