From bc0d5bf241df3633921bd9d43d171e91034fbfcf Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:12:08 +0200 Subject: [PATCH] feat(frontend): consolidate draft-migration errors into a single toast + modal (#9612) * Draft migration error modal * nits --- .../DraftMigrationErrorModal.svelte | 124 ++++++++++++++++++ frontend/src/lib/userDraftDbMigration.ts | 30 +++-- .../lib/userDraftMigrationErrors.svelte.ts | 75 +++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 4 +- 4 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/components/DraftMigrationErrorModal.svelte create mode 100644 frontend/src/lib/userDraftMigrationErrors.svelte.ts diff --git a/frontend/src/lib/components/DraftMigrationErrorModal.svelte b/frontend/src/lib/components/DraftMigrationErrorModal.svelte new file mode 100644 index 0000000000..ccbc5eb501 --- /dev/null +++ b/frontend/src/lib/components/DraftMigrationErrorModal.svelte @@ -0,0 +1,124 @@ + + + +
+

+ Drafts are now user-scoped and synced to the database. + {#if draftMigrationErrors.list.length} + These local storage drafts could not be + migrated to the server — view their contents before deciding, then delete the ones you no + longer need. + {/if} + Learn more. +

+ + {#if draftMigrationErrors.list.length === 0} +

All issues resolved.

+ {:else} +
+ +
+ + {/if} + +
+ +
+
+
+ + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonView?.value ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts index 533961c859..e6bca477a7 100644 --- a/frontend/src/lib/userDraftDbMigration.ts +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -13,6 +13,10 @@ import { DraftService } from './gen' import type { UserDraftItemKind } from './gen' import { sendUserToast } from './toast' +import { + openDraftMigrationErrorModal, + reportDraftMigrationError +} from './userDraftMigrationErrors.svelte' import { getUsernameForNamespace } from './userNamespace' import { randomUUID } from './utils/uuid' @@ -176,8 +180,11 @@ export async function migrateUserDraftsToDb(): Promise { } if (toMigrate.length === 0) return - // Legacy drafts detected — tell the user the one-off upload is running. - sendUserToast('Migrating local storage drafts ...', 'info') + // Legacy drafts detected — tell the user the one-off upload is running, with + // an escape hatch to the modal where any failures show up as they happen. + sendUserToast('Migrating local storage drafts ...', 'info', [ + { label: 'See more', callback: openDraftMigrationErrorModal } + ]) for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) { try { @@ -203,18 +210,13 @@ export async function migrateUserDraftsToDb(): Promise { // surface it so the user isn't silently stuck, with an escape // hatch to drop the un-migratable draft. console.error('UserDraft LS→DB migration: failed for', key, e) - sendUserToast(`Could not migrate draft ${path} in workspace ${parsed.workspace}`, 'error', [ - { - label: 'Delete draft', - callback: () => { - try { - localStorage.removeItem(key) - } catch { - // ignore - } - } - } - ]) + reportDraftMigrationError({ + key, + workspace: parsed.workspace, + itemKind: parsed.itemKind, + path, + value + }) } } } diff --git a/frontend/src/lib/userDraftMigrationErrors.svelte.ts b/frontend/src/lib/userDraftMigrationErrors.svelte.ts new file mode 100644 index 0000000000..4059c4f990 --- /dev/null +++ b/frontend/src/lib/userDraftMigrationErrors.svelte.ts @@ -0,0 +1,75 @@ +/** + * Reactive registry of drafts that `migrateUserDraftsToDb` could not push to + * the server. The migration runs on every layout mount, so a persistently + * un-migratable draft would re-fail (and re-report) each time — keying by the + * LS key dedupes those repeats. A SINGLE toast fires on the empty→non-empty + * transition (never per-failure, never when there's nothing wrong); its action + * opens `DraftMigrationErrorModal`, which reads `list` live so failures that + * surface while the modal is already open just appear in place. + */ +import { SvelteMap } from 'svelte/reactivity' +import type { UserDraftItemKind } from '$lib/gen' +import { sendUserToast } from './toast' + +export type DraftMigrationError = { + /** The source `userdraft/...` localStorage key — identity and delete target. */ + key: string + workspace: string + itemKind: UserDraftItemKind + path: string + /** The draft payload, surfaced verbatim by the modal's "View JSON". */ + value: unknown +} + +const errors = new SvelteMap() +let modalOpen = $state(false) + +export const draftMigrationErrors = { + get list(): DraftMigrationError[] { + return [...errors.values()] + }, + get modalOpen(): boolean { + return modalOpen + }, + set modalOpen(open: boolean) { + modalOpen = open + } +} + +/** Open the modal listing the failed migrations. */ +export function openDraftMigrationErrorModal(): void { + modalOpen = true +} + +/** + * Record a failed draft migration. Idempotent per `key`; the toast only fires + * on the first failure of a batch (empty→non-empty) and is suppressed when the + * modal is already open, since the user is already resolving issues there. + */ +export function reportDraftMigrationError(error: DraftMigrationError): void { + if (errors.has(error.key)) return + const wasEmpty = errors.size === 0 + errors.set(error.key, error) + if (wasEmpty && !modalOpen) { + sendUserToast('Some local storage drafts could not be migrated', 'error', [ + { label: 'Resolve issues', callback: openDraftMigrationErrorModal } + ]) + } +} + +/** Drop the un-migratable draft from localStorage and clear its error entry. */ +export function deleteDraftMigrationError(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // Best-effort; the entry leaves the list regardless. + } + errors.delete(key) +} + +/** Drop every un-migratable draft at once. */ +export function deleteAllDraftMigrationErrors(): void { + for (const key of [...errors.keys()]) { + deleteDraftMigrationError(key) + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 86712720d8..fa9bf2122d 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -60,6 +60,7 @@ import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration' import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' + import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -441,7 +442,7 @@ // on success. The order matters — the second step only sees what // the first one normalized. $effect(() => { - if ($workspaceStore) { + if ($workspaceStore && $userStore) { untrack(() => { migrateLegacyUserDrafts($workspaceStore!) void migrateUserDraftsToDb() @@ -501,6 +502,7 @@ + {#if page.status == 404} {:else if $userStore}