fix: supply the three APIs the import wizard already calls

`AppConnectDrawer`, `ImportProjectStep` and `execution.svelte.ts` landed
calling into props and exports that were never committed alongside them,
so the branch did not type-check. Each half is here now:

- `AppConnectInner.fillPath` — connect into a resource that already
  exists instead of refusing the path. The import creates every resource
  as an empty stub, so without it the connect flow can only ever say
  "already exists, delete it or pick another path". Opt-in: unset, the
  flow still refuses to write over anything, which is what `ResourcePicker`
  and the resources page rely on.
- `ProjectContentBadges.contentSummary` — the badge counts as one line of
  text, for the import step's task row. Shares `kinds()` with the badges
  so a project cannot be counted two ways.
- `installProject.onMigrationsStart` — fires before the reviewed
  migrations run, which is the only signal that phase has begun; the
  import step draws them as their own checklist row off the back of it.

Also fixes the wizard wedging itself shut: `requestClose` set `dismissing`
and cleared it after awaiting the confirmation, so an `ask` that threw left
the flag set — and the backdrop, Escape and the close button all return
early on it, leaving a reload as the only way out. Now `finally`, plus a
reset on open, since a promise that never settles never reaches `finally`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-21 12:55:19 +02:00
co-authored by Claude Opus 5
parent a566b5ee17
commit 9c1ef71270
4 changed files with 101 additions and 40 deletions
@@ -52,6 +52,15 @@
manual?: boolean
express?: boolean
workspace?: string
/**
* Fill an existing resource instead of creating one. The path is fixed to it and the
* "already exists" guard becomes an update, so a caller holding a resource that is
* already there — the import wizard's empty stubs — can connect into it rather than
* making the user delete it first and retype the path.
*
* Opt-in: without it this flow still refuses to write over anything.
*/
fillPath?: string
}
let {
@@ -61,7 +70,8 @@
disabled = $bindable(false),
manual = $bindable(true),
express = false,
workspace = undefined
workspace = undefined,
fillPath = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
@@ -553,8 +563,9 @@
valueToken = data.res
responseExtra = data.extra ?? {}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
// `fillPath` decides the path as surely as express does, so neither stops here.
if (fillPath || express) {
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
}
@@ -689,8 +700,8 @@
grant_type: 'client_credentials' // Mark this token as client_credentials
}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
if (fillPath || express) {
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
} catch (error) {
@@ -749,7 +760,10 @@
path
})
if (exists) {
// Filling one names its path up front; anything else reaching an occupied path got
// there by the user typing it, which is the case worth refusing.
const filling = exists && !!fillPath && path === fillPath
if (exists && !filling) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
@@ -895,17 +909,27 @@
}
}
await ResourceService.createResource({
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
if (filling) {
// The stub the import made carries no description, so this is the one chance to
// give it one; its resource_type and path are already what we want.
await ResourceService.updateResource({
workspace: effectiveWorkspace,
path,
value: resourceValue,
description,
labels,
ws_specific: wsSpecific
}
})
requestBody: { value: resourceValue, description }
})
} else {
await ResourceService.createResource({
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
value: resourceValue,
description,
labels,
ws_specific: wsSpecific
}
})
}
dispatch('refresh', path)
dispatch('close')
sendUserToast(
@@ -8,6 +8,30 @@
triggers?: number
migrations?: number
}
/** The kinds, in the order a project is read. Shared by the badges and the sentence
* below so the same project can never be counted two ways. */
function kinds(counts: ProjectContentCounts) {
return [
{ label: 'app', count: counts.apps },
{ label: 'flow', count: counts.flows },
{ label: 'script', count: counts.scripts },
{ label: 'resource', count: counts.resources },
{ label: 'trigger', count: counts.triggers ?? 0 },
{ label: 'data table migration', count: counts.migrations ?? 0 }
].filter((c) => c.count > 0)
}
/**
* The same counts as one line of text, for callers with a row to sit on rather than
* a space for chips — the import step names them beside the task that imports them.
* Empty when a project has nothing in it, so a caller can drop the whole phrase.
*/
export function contentSummary(counts: ProjectContentCounts): string {
return kinds(counts)
.map((c) => `${c.count} ${c.label}${c.count === 1 ? '' : 's'}`)
.join(', ')
}
</script>
<script lang="ts">
@@ -28,16 +52,15 @@
//
// Zero counts are dropped rather than shown: a project with no apps should read
// as "no apps", not as a "0 apps" chip the eye has to discount.
const shown = $derived(
[
{ label: 'app', count: counts.apps, icon: LayoutDashboard },
{ label: 'flow', count: counts.flows, icon: BarsStaggered },
{ label: 'script', count: counts.scripts, icon: Code2 },
{ label: 'resource', count: counts.resources, icon: Database },
{ label: 'trigger', count: counts.triggers ?? 0, icon: Zap },
{ label: 'data table migration', count: counts.migrations ?? 0, icon: Table2 }
].filter((c) => c.count > 0)
)
const ICONS: Record<string, any> = {
app: LayoutDashboard,
flow: BarsStaggered,
script: Code2,
resource: Database,
trigger: Zap,
'data table migration': Table2
}
const shown = $derived(kinds(counts).map((c) => ({ ...c, icon: ICONS[c.label] })))
</script>
<div class="flex flex-wrap items-center gap-1.5">
@@ -399,6 +399,9 @@
function reset(from: WizardResume | undefined) {
resumedPath = from?.resourcePath
// A pending confirmation that never settled leaves `dismissing` true, and `finally`
// cannot clear what never resolves — so a fresh open always starts dismissable.
dismissing = false
wiz = newWizardState({
name: from?.name || defaultTableName(),
projectName: from?.projectName || defaultProjectName(),
@@ -771,19 +774,25 @@
return
}
dismissing = true
const confirmed = await confirmationModal.ask({
title: 'Leave without adding a data table?',
// A run that failed and was sent back to be edited leaves whatever it got through
// behind it, so promising otherwise would be a lie exactly when it matters most.
children: leftBehind
? 'The setup that already ran left what it created behind, and what you have filled in here will be lost.'
: 'Nothing has been created yet, and what you have filled in here will be lost.',
confirmationText: 'Discard'
})
dismissing = false
// Re-read rather than trust the entry check: a run can start while the dialog is up, and
// answering Discard would otherwise tear the modal down in the middle of it.
if (confirmed && !preventClose) close()
// `finally`, because the flag is what blocks a second attempt: an `ask` that throws
// would otherwise leave the dialog permanently undismissable — the backdrop, Escape
// and the close button all return early here, so the only way out would be a reload.
try {
const confirmed = await confirmationModal.ask({
title: 'Leave without adding a data table?',
// A run that failed and was sent back to be edited leaves whatever it got through
// behind it, so promising otherwise would be a lie exactly when it matters most.
children: leftBehind
? 'The setup that already ran left what it created behind, and what you have filled in here will be lost.'
: 'Nothing has been created yet, and what you have filled in here will be lost.',
confirmationText: 'Discard'
})
// Re-read rather than trust the entry check: a run can start while the dialog is up, and
// answering Discard would otherwise tear the modal down in the middle of it.
if (confirmed && !preventClose) close()
} finally {
dismissing = false
}
}
function close() {
@@ -263,10 +263,14 @@ export async function installProject(args: {
exportData: ProjectExport
folder: string
migrations: ProjectMigration[]
/** Called once, before the reviewed migrations are applied, when there are any. Lets a
* caller show them as their own step rather than folding them into the item import. */
onMigrationsStart?: () => void
hasEeLicense: boolean
onResult: (r: InstallResult) => void
}): Promise<void> {
const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args
const { workspace, exportData, folder, migrations, hasEeLicense, onResult, onMigrationsStart } =
args
const record = (path: string, p: Promise<unknown>): Promise<void> =>
p.then(
@@ -393,6 +397,7 @@ export async function installProject(args: {
}
// Apply the reviewed data table migrations after items exist.
if (migrations.length) onMigrationsStart?.()
for (const m of migrations) {
await record(
`data table: ${m.datatable_name}`,