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}`,