From 9c1ef7127044c63f6da34c7fd6d4199a3c95bda0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 21 Aug 2026 12:55:19 +0200 Subject: [PATCH] fix: supply the three APIs the import wizard already calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../src/lib/components/AppConnectInner.svelte | 56 +++++++++++++------ .../components/ProjectContentBadges.svelte | 43 ++++++++++---- .../AddDataTableWizard.svelte | 35 +++++++----- .../workspaceSettings/projectInstall.ts | 7 ++- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index d22cc8c96d..7d62a16423 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -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( diff --git a/frontend/src/lib/components/ProjectContentBadges.svelte b/frontend/src/lib/components/ProjectContentBadges.svelte index 032100422c..dfb8e0eb90 100644 --- a/frontend/src/lib/components/ProjectContentBadges.svelte +++ b/frontend/src/lib/components/ProjectContentBadges.svelte @@ -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(', ') + }
diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index 03d156f5c4..66546a7702 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -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() { diff --git a/frontend/src/lib/components/workspaceSettings/projectInstall.ts b/frontend/src/lib/components/workspaceSettings/projectInstall.ts index 30d62ff7a4..6cae3d5e0e 100644 --- a/frontend/src/lib/components/workspaceSettings/projectInstall.ts +++ b/frontend/src/lib/components/workspaceSettings/projectInstall.ts @@ -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 { - const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args + const { workspace, exportData, folder, migrations, hasEeLicense, onResult, onMigrationsStart } = + args const record = (path: string, p: Promise): Promise => 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}`,