From 32a78be6dcaffec3177737e0c2fa470a59ec3837 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 2 Jun 2026 12:29:47 +0200 Subject: [PATCH] feat: redirect /add pages to /edit/draft_uuid with new_draft flag --- backend/windmill-api-flows/src/flows.rs | 47 +- backend/windmill-api-scripts/src/scripts.rs | 55 +- backend/windmill-api/openapi.yaml | 8 + backend/windmill-api/src/apps.rs | 69 +- backend/windmill-common/src/user_drafts.rs | 48 ++ .../components/apps/editor/AppEditor.svelte | 4 +- .../apps/editor/AppEditorTutorial.svelte | 2 +- .../components/flows/CreateActionsApp.svelte | 8 +- .../components/flows/CreateActionsFlow.svelte | 12 +- .../scripts/CreateActionsScript.svelte | 2 +- frontend/src/lib/tutorials/config.ts | 8 +- .../(root)/(logged)/apps/add/+page.svelte | 147 +--- .../(logged)/apps/edit/[...path]/+page.svelte | 42 +- .../(root)/(logged)/apps_raw/add/+page.svelte | 693 +----------------- .../apps_raw/edit/[...path]/+page.svelte | 32 +- .../(root)/(logged)/flows/add/+page.svelte | 213 +----- .../flows/edit/[...path]/+page.svelte | 39 +- .../(logged)/flows/get/[...path]/+page.svelte | 2 +- .../(root)/(logged)/scripts/add/+page.svelte | 303 +------- .../scripts/edit/[...path]/+page.svelte | 22 + .../scripts/get/[...hash]/+page.svelte | 2 +- .../svix/create-webhook/+page@(root).svelte | 2 +- 22 files changed, 350 insertions(+), 1410 deletions(-) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index fb5eb4a880..13d13555e6 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -20,7 +20,9 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, DB, @@ -1464,17 +1466,38 @@ async fn get_flow_by_path( tx.commit().await?; - let flow = not_found_if_none(flow_o, "Flow", path)?; - let overlay = maybe_overlay_draft( - &db, - &w_id, - &authed.email, - UserDraftItemKind::Flow, - path, - query.draft.get_draft, - flow, - ) - .await?; + // Editors that have only ever drafted (never deployed) a flow at this + // path will land here with no deployed row. When `get_draft` is set, + // fall back to the draft table so /flows/edit/draft_ works the + // same way as a deployed-flow reload. + let overlay = match flow_o { + Some(flow) => { + maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Flow, + path, + query.draft.get_draft, + flow, + ) + .await? + } + None if query.draft.get_draft => { + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Flow, path) + .await? + .ok_or_else(|| { + windmill_common::error::Error::NotFound(format!( + "Flow not found at path {path}" + )) + })? + } + None => { + return Err(windmill_common::error::Error::NotFound(format!( + "Flow not found at path {path}" + ))); + } + }; Ok(Json(overlay)) } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index e05d2cb0ff..c1f55cb2a3 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -12,7 +12,9 @@ use windmill_api_auth::{ check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; use windmill_common::{ - user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -1690,22 +1692,41 @@ async fn get_script_by_path( }; tx.commit().await?; - let script = windmill_common::scripts::prefetch_cached_script_with_starred( - not_found_if_none(script_o, "Script", path)?, - &db, - ) - .await?; - - let overlay = maybe_overlay_draft( - &db, - &w_id, - &authed.email, - UserDraftItemKind::Script, - path, - query.draft.get_draft, - script, - ) - .await?; + // Editors that have only ever drafted (never deployed) a script at this + // path will land here with no deployed row. When `get_draft` is set, fall + // back to the draft table so /scripts/edit/draft_ works the same + // way as a deployed-script reload. + let overlay = match script_o { + Some(script_o) => { + let script = + windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db) + .await?; + maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.draft.get_draft, + script, + ) + .await? + } + None if query.draft.get_draft => { + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Script, path) + .await? + .ok_or_else(|| { + windmill_common::error::Error::NotFound(format!( + "Script not found at path {path}" + )) + })? + } + None => { + return Err(windmill_common::error::Error::NotFound(format!( + "Script not found at path {path}" + ))) + } + }; Ok(Json(overlay)) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1a3c9e3e8f..1f73dd9fc7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10482,6 +10482,14 @@ paths: schema: type: boolean - $ref: "#/components/parameters/GetDraft" + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: "200": description: app details diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index b30daaa409..bc217279c7 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -58,7 +58,9 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, - user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery}, + user_drafts::{ + fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -552,6 +554,13 @@ struct GetAppQuery { starred: WithStarredInfoQuery, #[serde(flatten)] draft: WithDraftQuery, + /// When no deployed app exists at this path and `get_draft` is set, + /// `raw_app` picks which draft kind to look up (`raw_app` or `app`). + /// Ignored when a deployed row exists — the row's own `raw_app` + /// column wins. Frontend sets this from the route the editor is on + /// (`/apps_raw/...` → true, `/apps/...` → false). + #[serde(default)] + raw_app: Option, } async fn get_app( @@ -600,24 +609,48 @@ async fn get_app( }; tx.commit().await?; - let app = not_found_if_none(app_o, "App", path)?; - // The same `app` table backs both regular apps and raw apps; the - // `raw_app` flag on the row picks which draft kind to look up. - let kind = if app.app.raw_app { - UserDraftItemKind::RawApp - } else { - UserDraftItemKind::App + // Editors that have only ever drafted (never deployed) an app at this + // path will land here with no deployed row. When `get_draft` is set, + // fall back to the draft table so /apps/edit/draft_ and + // /apps_raw/edit/draft_ work the same way as a deployed reload. + // For draft-only there's no `raw_app` row column to consult — the + // caller's `raw_app` query param picks the draft kind. + let overlay = match app_o { + Some(app) => { + let kind = if app.app.raw_app { + UserDraftItemKind::RawApp + } else { + UserDraftItemKind::App + }; + maybe_overlay_draft( + &db, + &w_id, + &authed.email, + kind, + path, + query.draft.get_draft, + app, + ) + .await? + } + None if query.draft.get_draft => { + let kind = if query.raw_app.unwrap_or(false) { + UserDraftItemKind::RawApp + } else { + UserDraftItemKind::App + }; + fetch_draft_only(&db, &w_id, &authed.email, kind, path) + .await? + .ok_or_else(|| { + windmill_common::error::Error::NotFound(format!("App not found at path {path}")) + })? + } + None => { + return Err(windmill_common::error::Error::NotFound(format!( + "App not found at path {path}" + ))); + } }; - let overlay = maybe_overlay_draft( - &db, - &w_id, - &authed.email, - kind, - path, - query.draft.get_draft, - app, - ) - .await?; Ok(Json(overlay)) } diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 84969d426f..6e519a7816 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -160,3 +160,51 @@ fn deep_merge(target: &mut serde_json::Value, source: serde_json::Value) { (t, s) => *t = s, } } + +/// Fetch the authed user's draft as a standalone payload, used by +/// "get by path" routes when no deployed row exists at the path but a +/// draft might. Returns the draft JSON wrapped as `WithDraftOverlay` +/// with `is_draft = true`, so the response shape matches the overlay +/// path the handler uses when a deployed row IS present. +/// +/// Callers must already have established that no deployed row exists. +/// Returns `Ok(None)` when there's also no draft — caller should 404. +/// +/// The draft JSON is expected to be a JSON object (every editor writes +/// drafts as object-shaped editable state, so `serde(flatten)` works on +/// the inner value). A non-object draft would render with no fields +/// flattened — defensive but degraded. +pub async fn fetch_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND email = $2 + AND path = $3 + AND typ = $4"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let inner: serde_json::Value = serde_json::from_str(row.value.0.get())?; + Ok(Some(WithDraftOverlay { + inner, + is_draft: true, + draft_saved_at: Some(row.created_at), + })) +} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6d35b4367..03917e84ae 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -98,8 +98,8 @@ // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry - // (`UserDraft.remove`) when it wants to force a fresh start — - // `?nodraft=true`, template/hub loads, etc. + // (`UserDraft.remove`) when it wants to force a fresh start + // (template/hub loads, etc.). const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the diff --git a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte index 3431e906ad..c268058717 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte @@ -26,7 +26,7 @@ targetTutorial = undefined }} on:confirmed={async () => { - window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank') + window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank') }} >
diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 5cc0df1502..fba509f8a9 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -26,10 +26,10 @@ // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), // so the in-memory importStore would be lost. Use sessionStorage instead. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) - await goto('/apps_raw/add?nodraft=true') + await goto('/apps_raw/add') } else { $importStore = parsed - await goto('/apps/add?nodraft=true') + await goto('/apps/add') } drawer?.closeDrawer?.() } @@ -40,12 +40,12 @@ function selectLowCode() { appTypeModalOpen = false - goto(`${base}/apps/add?nodraft=true`) + goto(`${base}/apps/add`) } function selectFullCode() { appTypeModalOpen = false - goto(`${base}/apps_raw/add?nodraft=true`) + goto(`${base}/apps_raw/add`) } diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index dd975fcc72..1539504ecc 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -33,7 +33,7 @@ async function importRaw() { $importFlowStore = importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '') - await goto('/flows/add?nodraft=true') + await goto('/flows/add') drawer?.closeDrawer?.() } @@ -41,13 +41,13 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true&nodraft=true`) + await goto(`${base}/scripts/add?import=true`) wacDrawer?.closeDrawer?.() } function handleFlowClick() { if (skipModal) { - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } else { flowModalOpen = true } @@ -55,17 +55,17 @@ function selectFlowEditor() { flowModalOpen = false - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } function selectWacPython() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=python`) + goto(`${base}/scripts/add?wac=python`) } function selectWacTypescript() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + goto(`${base}/scripts/add?wac=typescript`) } function toggleSkipModal() { diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 5abd8235f2..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -14,7 +14,7 @@ unifiedSize="lg" variant="accent" startIcon={{ icon: Plus }} - href="{base}/scripts/add?nodraft=true" + href="{base}/scripts/add" endIcon={{ icon: Code2 }} > Script diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts index 455c4c50e3..082dc0473d 100644 --- a/frontend/src/lib/tutorials/config.ts +++ b/frontend/src/lib/tutorials/config.ts @@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Build a flow', description: 'Learn how to build workflows in Windmill with our interactive tutorial.', onClick: () => { - window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true` + window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial` }, index: 2, active: true, @@ -81,7 +81,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Fix a broken flow', description: 'Learn how to monitor and debug your script and flow executions.', onClick: () => { - window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true` + window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow` }, index: 3, active: true, @@ -131,7 +131,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Background runnables', description: 'Learn how to create and use background runnables in your apps.', onClick: () => { - window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true` + window.location.href = `${base}/apps/add?tutorial=backgroundrunnables` }, index: 4, active: true, @@ -145,7 +145,7 @@ export const TUTORIALS_CONFIG: Record = { title: 'Connection', description: 'Learn how to connect component inputs to outputs in your apps.', onClick: () => { - window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true` + window.location.href = `${base}/apps/add?tutorial=connection` }, index: 5, active: true, diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index 170ca3573c..b879ff42db 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -1,142 +1,15 @@ - -{#if value} -
- {#key value} - { - goto(`/apps/edit/${path}`) - }} - {summary} - app={value} - path={''} - {policy} - fromHub={hubId != null} - newApp={true} - replaceStateFn={(path) => replaceState(path, page.state)} - gotoFn={(path, opt) => goto(path, opt)} - /> - {/key} -
-{/if} diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index be959c7148..e165ba22c8 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -11,6 +11,7 @@ import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte' import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' + import { emptyApp } from '$lib/components/apps/editor/appUtils' import { untrack } from 'svelte' import { page } from '$app/state' import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' @@ -71,23 +72,42 @@ staleModalOpen = false } - // `?nodraft=true` is the callers' way of saying "skip the local autosave - // on this load." Wipe the UserDraft entry and strip the flag from the - // URL synchronously, before any descendant reads it. A plain reload - // (no nodraft) restores normally. - if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') { - UserDraft.remove('app', path) - const url = new URL(window.location.href) - url.searchParams.delete('nodraft') - window.history.replaceState(window.history.state, '', url.toString()) - } - /** Increments per `loadApp` call. Stale loads (e.g. when picker * navigation races a draft-discard reload) bail at the next checkpoint * after their captured token no longer matches. */ let loadAppToken = 0 async function loadApp(): Promise { const tok = ++loadAppToken + // `?new_draft=true` (set by `/apps/add`'s redirect) means we landed + // on a fresh `draft_{uuid}` path that's never been saved. Skip the + // backend fetch (it would 404), seed an empty app, strip the flag. + if (page.url.searchParams.get('new_draft') === 'true') { + const url = new URL(window.location.href) + url.searchParams.delete('new_draft') + window.history.replaceState(window.history.state, '', url.toString()) + const emptyValue = emptyApp() + app = { + summary: '', + value: emptyValue as any, + path: page.params.path ?? '', + policy: {} as any, + custom_path: undefined, + versions: [] as any, + id: 0 as any, + extra_perms: {}, + created_at: new Date().toISOString(), + created_by: '', + raw_app: false + } as unknown as AppWithLastVersion & { value: any } + savedApp = { + summary: '', + value: emptyValue as any, + path: page.params.path ?? '', + policy: {} as any + } + currentRevs = {} + return + } const backendApp = await AppService.getAppByPath({ path: page.params.path ?? '', workspace: $workspaceStore!, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 716000215f..7b0ab2eddd 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -1,688 +1,15 @@ - -{#if templatePicker} - -
- -
-

Summary

- -
- - -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- - -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- - {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable this - feature. - {/if} -
- {:else} -
- -
- Default settings for new tables -
-
-
- - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists - {/if} -
-
-
-
- - -
- -
- - -
- dataTableDrawer?.openDrawer()} - onRemove={(index) => { - preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) - }} - /> -
-
- {/if} -
- - -
-

- - Start with AI - (optional) -

- - {#if !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. -
- {#if $userStore?.is_admin} - Configure AI in - workspace settings - - to enable this feature. - {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. - {/if} -
- {:else} -
- -

- Leave empty to start with a blank template, or describe your app to get AI assistance - right away. -

-
- {/if} -
- - -
- - {#if isAiEnabled} - - {/if} -
- - -{/if} -{#key reloadCounter} - { - goto(`/apps_raw/edit/${event.detail}`) - }} - bind:files - bind:runnables - bind:data - {policy} - path={''} - liveEditorDraftStoragePath="" - bind:summary - newApp - /> -{/key} - - { - preWhitelistedTables = [...preWhitelistedTables, ref] - }} -/> diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index e30a8c28bd..50979b7741 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -56,17 +56,6 @@ let redraw = $state(0) let path = page.params.path ?? '' - // `?nodraft=true` is the callers' way of saying "skip the local autosave - // on this load." Wipe the UserDraft entry and strip the flag from the - // URL synchronously, before the handle is created. A plain reload (no - // nodraft) restores normally. - if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') { - UserDraft.remove('raw_app', path) - const url = new URL(window.location.href) - url.searchParams.delete('nodraft') - window.history.replaceState(window.history.state, '', url.toString()) - } - const draftHandle = UserDraft.use('raw_app', path) // Local-draft staleness modal: opened when the remote has moved on since @@ -175,10 +164,29 @@ let loadAppToken = 0 async function loadApp(): Promise { const tok = ++loadAppToken + // `?new_draft=true` (set by `/apps_raw/add`'s redirect) means we + // landed on a fresh `draft_{uuid}` path that's never been saved. + // Skip the backend fetch (it would 404), seed an empty raw app, + // strip the flag. `rawApp: true` on subsequent reloads tells the + // backend to look up the `raw_app` kind in the draft table. + if (page.url.searchParams.get('new_draft') === 'true') { + const url = new URL(window.location.href) + url.searchParams.delete('new_draft') + window.history.replaceState(window.history.state, '', url.toString()) + savedApp = { + summary: '', + value: { files: {}, runnables: {} }, + path: page.params.path ?? '', + policy: {}, + custom_path: undefined + } + return + } const backendApp = await AppService.getAppByPath({ path: page.params.path ?? '', workspace: $workspaceStore!, - getDraft: true + getDraft: true, + rawApp: true }) if (tok !== loadAppToken) return if (backendApp.is_draft) { diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 4cde50f5bf..50bd7876c3 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -1,208 +1,15 @@ - - - - { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onDetails={(e) => { - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - {initialPath} - {pathStoreInit} - liveEditorDraftStoragePath="" - bind:this={flowBuilder} - newFlow - {initialArgs} - {flowStore} - {flowStateStore} - {selectedId} - {loading} - {draftTriggersFromUrl} - {selectedTriggerIndexFromUrl} - noInitial -/> diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 4fbcad66c7..6eea51ffe6 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -52,17 +52,6 @@ // Derived so client-side nav (breadcrumb) re-keys the handle to the new path. let flowDraftPath = $derived(page.params.path ?? '') - // `?nodraft=true` is the callers' way of saying "skip the local autosave - // on this load." Wipe the UserDraft entry and strip the flag from the - // URL synchronously, before the handle is created — same pattern as - // /flows/add. A plain reload (no nodraft) restores normally. - if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') { - UserDraft.remove('flow', flowDraftPath) - const url = new URL(window.location.href) - url.searchParams.delete('nodraft') - window.history.replaceState(window.history.state, '', url.toString()) - } - // `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav; // `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref. const flowHandles = UserDraft.useMany(() => [{ itemKind: 'flow', path: flowDraftPath }]) @@ -167,6 +156,34 @@ // so flowBuilder is unmounted and direct calls would no-op. let draftTriggersToApply: Trigger[] | undefined = undefined let applyPrimarySchedule = false + // `?new_draft=true` (set by `/flows/add`'s redirect) means we + // landed on a fresh `draft_{uuid}` path that's never been saved. + // Skip both the latest-version and the get-by-path fetches (they + // would 404), seed an empty Flow, strip the single-use flag. + if (page.url.searchParams.get('new_draft') === 'true') { + const url = new URL(window.location.href) + url.searchParams.delete('new_draft') + window.history.replaceState(window.history.state, '', url.toString()) + const empty: Flow = { + path: page.params.path ?? '', + summary: '', + description: '', + value: { modules: [] }, + schema: {}, + extra_perms: {}, + edited_at: new Date().toISOString(), + edited_by: '' + } as unknown as Flow + savedFlow = structuredClone(empty) + flowHandle.setDraftAndMeta(empty, {}) + flow = empty + await initFlow(flow, flowStore, flowStateStore) + if (tok !== loadFlowToken) return + loading = false + selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata' + renderEditor = true + return + } // Currently there is no way to get version of flow with flow. // So we have to request it here const v = ( diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index e351226a7f..e65d1670b9 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -329,7 +329,7 @@ onClick: async () => { const app = createAppFromFlow(flow.path, flow.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, unifiedSize: 'md', variant: 'subtle', diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 8383f8b5e3..6a572fa0cf 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -1,290 +1,23 @@ - - - - -{#if scriptHandle.draft} - { - // "Deploy & Stay here" / lib: stay on the editor (just confirm). - if (e.stay) { - sendUserToast('Deployed') - return - } - goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - searchParams={page.url.searchParams} - bind:script={scriptHandle.draft} - {showMeta} - /> -{:else} - -{/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 8fdf2cff93..2dbb9e5b7e 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -197,6 +197,28 @@ async function loadScript(): Promise { const tok = ++loadScriptToken fullyLoaded = false + // `?new_draft=true` (set by `/scripts/add`'s redirect) means we + // landed on a fresh `draft_{uuid}` path that's never been saved + // anywhere. Skip the backend fetch (it would 404), seed an empty + // `NewScript`, and strip the single-use flag from the URL. + if (page.url.searchParams.get('new_draft') === 'true') { + const url = new URL(window.location.href) + url.searchParams.delete('new_draft') + window.history.replaceState(window.history.state, '', url.toString()) + const empty: EditableScript = { + path: page.params.path ?? '', + summary: '', + description: '', + content: '', + language: 'bun', + schema: {} + } as unknown as EditableScript + savedScript = structuredClone(empty) + scriptHandle.setDraftAndMeta(empty, {}) + fullyLoaded = true + renderEditor = true + return + } if (hash) { const scriptByHash = await ScriptService.getScriptByHash({ workspace: $workspaceStore!, diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 12ca517490..8cbb8abdac 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -422,7 +422,7 @@ onClick: async () => { const app = createAppFromScript(script.path, script.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, disabled: !showEditButtons, unifiedSize: 'md', diff --git a/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte index 2ad7ec0089..d4ac558cb8 100644 --- a/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte @@ -118,7 +118,7 @@ -