mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(frontend): consolidate draft-migration errors into a single toast + modal (#9612)
* Draft migration error modal * nits
This commit is contained in:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user