diff --git a/frontend/src/lib/components/ImportProjectStep.svelte b/frontend/src/lib/components/ImportProjectStep.svelte index 33a0532d8f..01b5b2931a 100644 --- a/frontend/src/lib/components/ImportProjectStep.svelte +++ b/frontend/src/lib/components/ImportProjectStep.svelte @@ -5,17 +5,18 @@ import Toggle from '$lib/components/Toggle.svelte' import FolderPicker from '$lib/components/FolderPicker.svelte' import { WorkspaceService } from '$lib/gen' - import ProjectContentBadges from '$lib/components/ProjectContentBadges.svelte' + import { contentSummary } from '$lib/components/ProjectContentBadges.svelte' import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle' import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte' import Portal from '$lib/components/Portal.svelte' - import { ImportExecution } from '$lib/importWizard/execution.svelte' - import { beforeNavigate } from '$app/navigation' + import { ImportExecution, plannedTasks } from '$lib/importWizard/execution.svelte' + import SetupChecklist, { type SetupStep } from '$lib/components/wizards/SetupChecklist.svelte' + import { beforeNavigate, goto } from '$app/navigation' import { FOLDER_NAME_RE, planProblem, type ImportPlan } from '$lib/importWizard/plan' import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte' - import { ArrowLeft, Check, Download, Loader2, X } from 'lucide-svelte' + import { ArrowLeft, Download, Loader2 } from 'lucide-svelte' // The last step: it shows the plan, and it is the only step that can act on it. // All the acting lives in ImportExecution — this file decides what the user sees @@ -63,12 +64,6 @@ const folderValid = $derived(FOLDER_NAME_RE.test(folder.trim())) const problem = $derived(planProblem({ ...plan, folder: folder.trim() })) - const destinationLabel = $derived( - plan.destination?.kind === 'new' - ? `${plan.destination.id} (new)` - : (plan.destination?.workspaceId ?? 'nowhere yet') - ) - // --- the run --------------------------------------------------------------- let reviewDrawer = $state() let reviewList = $state< @@ -143,6 +138,44 @@ const execution = $derived(run?.key === planKey ? run.execution : undefined) $effect(() => onExecution?.(execution)) + // What the import will bring, named on the row that brings it. Triggers and + // migrations only become known once the export is fetched, so the phrase grows + // mid-run rather than starting complete. + const importSummary = $derived( + project ? contentSummary({ ...project.counts, ...(execution?.extraCounts ?? {}) }) : '' + ) + + // The same rows before and during the run: the step states what it is about to do, + // and the run fills those rows in rather than replacing a paragraph with a list. + const tasks = $derived(execution?.tasks ?? plannedTasks(plan)) + + // `SetupStep` carries no detail field, so what the row reports goes in the title + // beside the label — the same sentence the run used to write after an em dash. The + // import row says what it is importing; every other row keeps whatever the run + // reported. The breakdown supersedes the run's own "N items" here, being the same + // total said in a more useful way. + const checklist = $derived( + tasks.map((task) => { + const detail = (task.key === 'import' && importSummary) || task.detail + return { + title: detail ? `${task.label} — ${detail}` : task.label, + status: task.status, + // Only under the row that wrote them. A failed item carries its error as the + // description, which the checklist opens by itself. + substeps: + task.key === 'import' + ? execution?.itemResults.map((r) => ({ + title: r.path, + status: r.ok ? ('done' as const) : ('failed' as const), + description: r.error + })) + : undefined + } + }) + ) + /** A run that has been attempted — what makes the button read Retry rather than Import. */ + const attempted = $derived(!!execution) + function start() { const current = execution ?? @@ -158,12 +191,65 @@ void current.run() } + const leaveModal = createAsyncConfirmationModal() + /** The question is on screen; a second attempt must not stack another one. */ + let askingToLeave = false + /** The navigation the question approved, which has to get past this guard. */ + let leaveApproved = false + // The browser's own back/forward, which the stepper's guard cannot see. Leaving - // mid-run unmounts the migration review the executor may be awaiting. + // mid-run unmounts the migration review the executor may be awaiting, so it is + // worth stopping for — but silently refusing reads as a broken back button, so + // cancel, ask, and re-navigate if the answer is yes. beforeNavigate((nav) => { - if (execution?.running) nav.cancel() + if (leaveApproved) return + // Nothing in flight has anything to lose. + if (!execution?.running) return + if (askingToLeave) { + nav.cancel() + return + } + // Leaving the app entirely cannot be resumed from here — the browser owns that + // prompt — so there is nothing to ask and nowhere to navigate back to. + const to = nav.to?.url + if (!to) { + nav.cancel() + return + } + nav.cancel() + void confirmLeave(to) }) + async function confirmLeave(to: URL): Promise { + askingToLeave = true + // `finally`, because this flag is what blocks a second attempt: an `ask` that threw + // would otherwise leave the step permanently unleavable, since every path above + // returns early on it. + try { + const landed = execution?.itemResults.length ?? 0 + const confirmed = await leaveModal.ask({ + title: 'Leave while the import is running?', + confirmationText: 'Leave', + type: 'danger', + // A run that has already written items leaves them behind, so promising + // otherwise would be a lie exactly when it matters most. + children: + (landed === 0 + ? 'Nothing has been imported into the workspace yet.' + : `${landed} item${landed === 1 ? '' : 's'} already imported into the workspace will stay there.`) + + '

The import stops where it is. Coming back to this link picks it up ' + + 'again without redoing what finished.' + }) + if (!confirmed) return + // Deliberately not re-read against `running`: the answer was about leaving, and a + // run that finished in the meantime only makes leaving safer. + leaveApproved = true + await goto(to) + } finally { + askingToLeave = false + } + } + // If this step is torn down while the review drawer is open, resolve the promise // the executor is waiting on rather than leaving it pending forever. $effect(() => () => reviewResolve?.(false)) @@ -189,28 +275,23 @@
-
-

- {project?.name ?? plan.slug} → {destinationLabel} -

- {#if project} -

{project.summary}

- {/if} -
- - {#if project} - - - {/if} - {#if existingWorkspace}
- Folder + + + Folder inside {existingWorkspace} + + +

+ Everything the project ships is imported into this folder. +

Letters, digits, dashes and underscores only.

- {:else} -

- Items import under f/{folder.trim() || plan.slug}/. -

{/if}
{/if} - {#if execution} - -
    - {#each execution.tasks as task (task.key)} -
  • -
    - {#if task.status === 'running'} - - {:else if task.status === 'done'} - - {:else if task.status === 'failed'} - - {:else} - - {/if} - - {task.label} - - {#if task.detail} - — {task.detail} - {/if} -
    + +

    What this will do

    - {#if task.key === 'import' && execution.results.length} - -
      - {#each execution.results as r} -
    • - {r.ok ? '✓' : '✗'} - {r.path} - {#if !r.ok}— {r.error}{/if} -
    • - {/each} -
    - {/if} -
  • - {/each} -
- {/if} + + {#if execution?.error} @@ -283,7 +326,16 @@ {/if} - + + Resources are imported as empty stubs — set their values after import; a resource whose path already exists is reported as failed (existing values are never overwritten). Trigger kinds are recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation @@ -338,8 +390,10 @@ > {#if execution?.running} Importing… - {:else if execution} + {:else if attempted} Retry + {:else if plan.destination?.kind === 'new'} + Create workspace and import {:else} Import {/if} @@ -351,16 +405,27 @@ + closeMigrationReview(false)}> closeMigrationReview(false)}>
+ + + {reviewList.length === 1 ? 'This data table is' : 'These data tables are'} already set up{existingWorkspace + ? ` in ${existingWorkspace}` + : ''} and may already hold data. These migrations were written to create the project's tables, + so running them here can alter or drop what is in them. Read the SQL before you run it, and skip + anything you are unsure of. +

- This project ships migrations that recreate the data tables it uses. Review and edit the - SQL, then choose which to run. A migration runs against the data table of the same name in - the destination workspace; if that data table has migrations enabled it is recorded, - otherwise it runs once as a preview job. + Review and edit the SQL, then choose which to run. A migration runs against the data table + of the same name in the destination workspace; if that data table has migrations enabled it + is recorded, otherwise it runs once as a preview job.

{#each reviewList as m (m.datatable_name)}
diff --git a/frontend/src/lib/components/wizards/SetupChecklist.svelte b/frontend/src/lib/components/wizards/SetupChecklist.svelte index 9da6d3a5ad..95f278b0c1 100644 --- a/frontend/src/lib/components/wizards/SetupChecklist.svelte +++ b/frontend/src/lib/components/wizards/SetupChecklist.svelte @@ -31,9 +31,12 @@ type Props = { steps: SetupStep[] class?: string + /** Applied to each step's substep block, for a caller whose substeps are a long + * list rather than a handful of checks and need their own scroll. */ + substepsClass?: string } - let { steps, class: className = '' }: Props = $props() + let { steps, class: className = '', substepsClass = '' }: Props = $props() /** * Only the steps the user has actually toggled. A failed step opens itself, so recording @@ -108,8 +111,8 @@
{#if step.substeps?.length} -
- +
+
{/if}