feat(frontend): consolidate draft-migration errors into a single toast + modal (#9612)

* Draft migration error modal

* nits
This commit is contained in:
Diego Imbert
2026-06-16 14:12:08 +02:00
committed by GitHub
parent 5a2405743b
commit bc0d5bf241
4 changed files with 218 additions and 15 deletions
@@ -0,0 +1,124 @@
<script lang="ts">
/**
* Lists drafts that `migrateUserDraftsToDb` failed to push to the server.
* Opened from the single "Resolve issues" toast (or stays empty/closed when
* nothing failed). Reads the registry live, so failures that surface while
* the modal is already open appear in place. Mount once, app-wide.
*/
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Braces, Trash2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import {
draftMigrationErrors,
deleteDraftMigrationError,
deleteAllDraftMigrationErrors,
type DraftMigrationError
} from '$lib/userDraftMigrationErrors.svelte'
const DRAFT_DOCS_URL = 'https://www.windmill.dev/docs/core_concepts/draft_and_deploy'
let jsonView = $state<DraftMigrationError | undefined>(undefined)
let jsonOpen = $state(false)
function viewJson(error: DraftMigrationError) {
jsonView = error
jsonOpen = true
}
</script>
<Modal2
bind:isOpen={draftMigrationErrors.modalOpen}
title="Resolve draft migration issues"
fixedWidth="md"
fixedHeight="adaptive"
closeOnOutsideClick={!jsonOpen}
>
<div class="flex flex-col w-full gap-4">
<p class="text-sm text-secondary">
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}
<a href={DRAFT_DOCS_URL} target="_blank" rel="noopener noreferrer">Learn more</a>.
</p>
{#if draftMigrationErrors.list.length === 0}
<p class="text-sm text-tertiary italic">All issues resolved.</p>
{:else}
<div class="flex justify-end">
<Button
color="red"
variant="default"
size="xs"
startIcon={{ icon: Trash2 }}
on:click={() => deleteAllDraftMigrationErrors()}
>
Remove all
</Button>
</div>
<ul class="divide-y border-t border-b flex-1 overflow-y-auto max-h-72">
{#each draftMigrationErrors.list as error (error.key)}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-primary truncate">{error.path}</div>
<div class="text-xs text-tertiary truncate">
{error.itemKind} · {error.workspace}
</div>
</div>
<Button
variant="default"
size="xs"
startIcon={{ icon: Braces }}
on:click={() => viewJson(error)}
>
View JSON
</Button>
<Button
color="red"
variant="default"
size="xs"
startIcon={{ icon: Trash2 }}
on:click={() => deleteDraftMigrationError(error.key)}
>
Delete draft
</Button>
</li>
{/each}
</ul>
{/if}
<div class="flex justify-end">
<Button variant="default" size="sm" on:click={() => (draftMigrationErrors.modalOpen = false)}>
Close
</Button>
</div>
</div>
</Modal2>
<Modal2
bind:isOpen={jsonOpen}
title="Draft JSON {jsonView?.path ?? ''}"
fixedWidth="lg"
fixedHeight="lg"
>
{#snippet headerRight()}
<Button
variant="default"
size="xs"
on:click={() => {
navigator.clipboard?.writeText(JSON.stringify(jsonView?.value ?? {}, null, 2))
sendUserToast('Copied to clipboard')
}}
>
Copy
</Button>
{/snippet}
<div class="w-full overflow-auto">
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
>{JSON.stringify(jsonView?.value ?? {}, null, 2)}</pre
>
</div>
</Modal2>
+16 -14
View File
@@ -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<void> {
}
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<void> {
// 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
})
}
}
}
@@ -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<string, DraftMigrationError>()
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)
}
}
@@ -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 @@
<svelte:window bind:innerWidth />
<UserSettings bind:this={userSettings} showMcpMode={true} />
<DraftMigrationErrorModal />
{#if page.status == 404}
<CenteredModal title="Page not found, redirecting you to login" loading={true}></CenteredModal>
{:else if $userStore}