mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
feat: redirect /add pages to /edit/draft_uuid with new_draft flag
This commit is contained in:
@@ -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_<uuid> 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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_<uuid> 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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool>,
|
||||
}
|
||||
|
||||
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_<uuid> and
|
||||
// /apps_raw/edit/draft_<uuid> 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Option<WithDraftOverlay>> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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<App>(stateApp)
|
||||
// Captured once on mount: the load-time revs are only used as the
|
||||
|
||||
@@ -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')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
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<TabId, TabConfig> = {
|
||||
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<TabId, TabConfig> = {
|
||||
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<TabId, TabConfig> = {
|
||||
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,
|
||||
|
||||
@@ -1,142 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
|
||||
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import { replaceState } from '$app/navigation'
|
||||
|
||||
// `/apps/add` is a thin redirect onto the canonical editor at
|
||||
// `/apps/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
|
||||
// design rationale — all editor logic lives in /apps/edit/[...path].
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
|
||||
import { emptyApp } from '$lib/components/apps/editor/appUtils'
|
||||
import { tick } from 'svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /apps/add (no nodraft) instead restores the previous
|
||||
// session via the child AppEditor's `UserDraft.use`.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('app', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
let appEditor: AppEditor | undefined = $state(undefined)
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
const templateId = page.url.searchParams.get('template_id')
|
||||
|
||||
const importRaw = $importStore
|
||||
if ($importStore) {
|
||||
$importStore = undefined
|
||||
}
|
||||
|
||||
let summary = $state('')
|
||||
let value: App = $state({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: [],
|
||||
theme: {
|
||||
type: 'path',
|
||||
path: DEFAULT_THEME
|
||||
}
|
||||
onMount(() => {
|
||||
const uuid = crypto.randomUUID()
|
||||
const params = new URLSearchParams(page.url.searchParams)
|
||||
params.set('new_draft', 'true')
|
||||
goto(`/apps/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
|
||||
})
|
||||
let policy: Policy = $state({
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
})
|
||||
|
||||
loadApp()
|
||||
|
||||
async function loadApp() {
|
||||
if (importRaw) {
|
||||
// Import/template/hub loads are an explicit "start fresh from this
|
||||
// content" — drop any previous empty-path autosave so it doesn't
|
||||
// shadow the imported value on AppEditor mount.
|
||||
UserDraft.remove('app', '')
|
||||
sendUserToast('Loaded from YAML/JSON')
|
||||
if ('value' in importRaw) {
|
||||
summary = importRaw.summary
|
||||
value = importRaw.value
|
||||
policy = importRaw.policy
|
||||
} else {
|
||||
value = importRaw
|
||||
}
|
||||
} else if (templatePath) {
|
||||
UserDraft.remove('app', '')
|
||||
const template = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
})
|
||||
value = template.value as any
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (templateId) {
|
||||
UserDraft.remove('app', '')
|
||||
const template = await AppService.getAppByVersion({
|
||||
workspace: $workspaceStore!,
|
||||
id: parseInt(templateId)
|
||||
})
|
||||
value = template.value as any
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (hubId) {
|
||||
UserDraft.remove('app', '')
|
||||
const hub = await AppService.getHubAppById({ id: Number(hubId) })
|
||||
value = {
|
||||
hiddenInlineScripts: [],
|
||||
unusedInlineScripts: [],
|
||||
fullscreen: false,
|
||||
...((hub.app.value ?? {}) as any)
|
||||
}
|
||||
summary = hub.app.summary
|
||||
sendUserToast('App loaded from Hub')
|
||||
goto('?', { replaceState: true })
|
||||
} else {
|
||||
value = emptyApp()
|
||||
}
|
||||
|
||||
// Trigger tutorial after everything is initialized
|
||||
const tutorialParam = page.url.searchParams.get('tutorial')
|
||||
if (tutorialParam) {
|
||||
// Wait for critical elements to be ready before triggering tutorial
|
||||
await tick()
|
||||
let attempts = 0
|
||||
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
attempts++
|
||||
}
|
||||
appEditor?.triggerTutorial()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
<div class="h-screen">
|
||||
{#key value}
|
||||
<AppEditor
|
||||
bind:this={appEditor}
|
||||
onSavedNewAppPath={(path) => {
|
||||
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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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<void> {
|
||||
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!,
|
||||
|
||||
@@ -1,688 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
// `/apps_raw/add` is a thin redirect onto the canonical editor at
|
||||
// `/apps_raw/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
|
||||
// design rationale — all editor logic lives in /apps_raw/edit/[...path].
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { page } from '$app/state'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
|
||||
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import {
|
||||
react18Template,
|
||||
react19Template,
|
||||
svelte5Template
|
||||
} from '$lib/components/raw_apps/templates'
|
||||
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
createDatatablesResource,
|
||||
createSchemasResource,
|
||||
toDatatableItems,
|
||||
toSchemaItems
|
||||
} from '$lib/components/raw_apps/datatableUtils.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Sparkles, Plus, List, Ban, ExternalLinkIcon } from 'lucide-svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import RawAppDataTableList from '$lib/components/raw_apps/RawAppDataTableList.svelte'
|
||||
import RawAppDataTableDrawer from '$lib/components/raw_apps/RawAppDataTableDrawer.svelte'
|
||||
import { type DataTableRef, formatDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
|
||||
// `nodraft` is captured into a local because we strip it from the URL
|
||||
// below — downstream readers like `templatePicker` must see the original
|
||||
// signal.
|
||||
const nodraft = page.url.searchParams.get('nodraft')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
const templateId = page.url.searchParams.get('template_id')
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
|
||||
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
|
||||
// signal "start fresh". Wipe the persisted empty-path autosave and strip
|
||||
// the flag from the URL synchronously so a reload doesn't wipe the
|
||||
// freshly-started draft. A plain reload of /apps_raw/add (no nodraft)
|
||||
// instead restores the previous session.
|
||||
if (nodraft && typeof window !== 'undefined') {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
|
||||
let importRaw = $importStore
|
||||
if ($importStore) {
|
||||
$importStore = undefined
|
||||
}
|
||||
if (!importRaw) {
|
||||
const sessionData = sessionStorage.getItem('rawAppImport')
|
||||
if (sessionData) {
|
||||
sessionStorage.removeItem('rawAppImport')
|
||||
importRaw = JSON.parse(sessionData)
|
||||
}
|
||||
}
|
||||
|
||||
const draftHandle = UserDraft.use<{
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, Runnable>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}>('raw_app', '')
|
||||
// Restore the persisted autosave so a plain reload of /apps_raw/add
|
||||
// resumes the last session. Captured once; the $effect below mirrors
|
||||
// later edits back. Import/template/hub flows in loadApp() wipe the
|
||||
// entry first (`UserDraft.remove`) for "start fresh" semantics.
|
||||
const restoredDraft = untrack(() => draftHandle.draft)
|
||||
|
||||
const defaultRunnables: Record<string, Runnable> = {
|
||||
a: {
|
||||
name: 'a',
|
||||
fields: {},
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
content:
|
||||
'// import * as wmill from "windmill-client"\n\nexport async function main(x: string) {\n return x\n}\n',
|
||||
language: 'bun',
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
x: {
|
||||
default: null,
|
||||
description: '',
|
||||
originalType: 'string',
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: ['x'],
|
||||
type: 'object'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let summary = $state(restoredDraft?.summary ?? '')
|
||||
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
|
||||
let policy: Policy = $state(
|
||||
restoredDraft?.policy ?? {
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
}
|
||||
)
|
||||
|
||||
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
|
||||
/** Data configuration including tables and creation policy */
|
||||
let data: RawAppData = $state(restoredDraft?.data ?? { ...DEFAULT_DATA })
|
||||
|
||||
// First mirror consumes the handle's first-write skip up-front (wipe
|
||||
// then restore) so the user's first real edit isn't the one dropped.
|
||||
let firstMirror = true
|
||||
$effect(() => {
|
||||
readFieldsRecursively(files)
|
||||
readFieldsRecursively(runnables)
|
||||
readFieldsRecursively(data)
|
||||
readFieldsRecursively(policy)
|
||||
void summary
|
||||
untrack(() => {
|
||||
if (firstMirror) {
|
||||
firstMirror = false
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
}
|
||||
draftHandle.draft = { files, runnables, data, summary, policy }
|
||||
})
|
||||
onMount(() => {
|
||||
const uuid = crypto.randomUUID()
|
||||
const params = new URLSearchParams(page.url.searchParams)
|
||||
params.set('new_draft', 'true')
|
||||
goto(`/apps_raw/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent + the
|
||||
// d == null guard keeps it from looping with the mirror above or
|
||||
// clobbering "start fresh" loads (which discard the in-memory draft).
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
if (d == null) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary, policy })) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
if (d.policy !== undefined) policy = d.policy
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
loadApp()
|
||||
|
||||
function extractValue(value: any) {
|
||||
files = value.files
|
||||
runnables = value.runnables
|
||||
// Support old formats and new format
|
||||
if (value.data) {
|
||||
const d = value.data
|
||||
// Handle old nested creation format
|
||||
if (d.creation) {
|
||||
data = {
|
||||
tables: d.tables ?? [],
|
||||
datatable: d.creation.datatable,
|
||||
schema: d.creation.schema
|
||||
}
|
||||
} else {
|
||||
data = d
|
||||
}
|
||||
} else if (value.dataTableRefs) {
|
||||
data = { ...DEFAULT_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
}
|
||||
async function loadApp() {
|
||||
if (importRaw) {
|
||||
// Import/template/hub loads are an explicit "start fresh from this
|
||||
// content" — drop the restored empty-path autosave so it doesn't
|
||||
// linger as the next plain reload's baseline.
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
sendUserToast('Loaded from YAML/JSON')
|
||||
if ('value' in importRaw) {
|
||||
summary = importRaw.summary
|
||||
extractValue(importRaw.value)
|
||||
|
||||
policy = importRaw.policy
|
||||
} else {
|
||||
extractValue(importRaw)
|
||||
}
|
||||
console.log('importRaw', importRaw)
|
||||
} else if (templatePath) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const template = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
})
|
||||
extractValue(template.value)
|
||||
console.log('App loaded from template')
|
||||
sendUserToast('App loaded from template path')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (templateId) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const template = await AppService.getAppByVersion({
|
||||
workspace: $workspaceStore!,
|
||||
id: parseInt(templateId)
|
||||
})
|
||||
extractValue(template.value)
|
||||
console.log('App loaded from template id')
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (hubId) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
|
||||
if (hub.app?.value) {
|
||||
extractValue(hub.app.value)
|
||||
}
|
||||
if (hub.app?.summary) {
|
||||
summary = hub.app.summary
|
||||
}
|
||||
console.log('App loaded from Hub')
|
||||
sendUserToast('App loaded from Hub')
|
||||
goto('?', { replaceState: true })
|
||||
}
|
||||
}
|
||||
|
||||
const templates = [
|
||||
{
|
||||
name: 'React 19',
|
||||
icon: 'tsx',
|
||||
files: undefined,
|
||||
selected: true
|
||||
},
|
||||
{
|
||||
name: 'React 18',
|
||||
icon: 'tsx',
|
||||
files: react18Template
|
||||
},
|
||||
{
|
||||
name: 'Svelte 5',
|
||||
icon: 'svelte',
|
||||
files: svelte5Template
|
||||
}
|
||||
]
|
||||
let templatePicker = $state(nodraft != null && !importRaw)
|
||||
let reloadCounter = $state(0)
|
||||
|
||||
// Modal state
|
||||
let selectedTemplateIndex = $state(0)
|
||||
let tableCreationEnabled = $state(true)
|
||||
let selectedDatatable = $state<string | undefined>(undefined)
|
||||
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
|
||||
let selectedSchema = $state<string | undefined>(undefined)
|
||||
let newSchemaName = $state('')
|
||||
let appSummary = $state('')
|
||||
let initialPrompt = $state('')
|
||||
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
|
||||
|
||||
// Pre-whitelisted tables for the app
|
||||
let preWhitelistedTables = $state<DataTableRef[]>([])
|
||||
|
||||
// Load available datatables and schemas using shared utilities
|
||||
const datatables = createDatatablesResource(() => $workspaceStore)
|
||||
const schemas = createSchemasResource(() => selectedDatatable)
|
||||
|
||||
// Derived value to force reactivity on datatables.current
|
||||
const availableDatatables = $derived(datatables.current)
|
||||
const availableSchemas = $derived(schemas.current)
|
||||
|
||||
// Auto-select datatable: prefer "main" if available, otherwise first one
|
||||
// Only runs once when datatables first load (selectedDatatable is undefined)
|
||||
let hasAutoSelected = false
|
||||
$effect(() => {
|
||||
if (availableDatatables?.length > 0 && !hasAutoSelected) {
|
||||
hasAutoSelected = true
|
||||
if (availableDatatables.includes('main')) {
|
||||
selectedDatatable = 'main'
|
||||
} else {
|
||||
selectedDatatable = availableDatatables[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Generate unique schema name (appX where X is first unused number)
|
||||
function generateUniqueSchemaName(existingSchemas: string[]): string {
|
||||
let num = 1
|
||||
while (existingSchemas.includes(`app${num}`)) {
|
||||
num++
|
||||
}
|
||||
return `app${num}`
|
||||
}
|
||||
|
||||
// Check if new schema name already exists
|
||||
const newSchemaAlreadyExists = $derived(
|
||||
schemaMode === 'new' &&
|
||||
newSchemaName.trim() !== '' &&
|
||||
(availableSchemas ?? []).includes(newSchemaName.trim())
|
||||
)
|
||||
|
||||
// Track if the user has manually edited the schema name
|
||||
let userEditedSchemaName = $state(false)
|
||||
|
||||
// Set default new schema name when schemas load or when switching to new mode
|
||||
// Also auto-fix if the current name exists and was auto-generated (not user-edited)
|
||||
$effect(() => {
|
||||
const schemas = availableSchemas ?? []
|
||||
if (schemaMode === 'new') {
|
||||
if (!newSchemaName) {
|
||||
// Initial load: set default name
|
||||
newSchemaName = generateUniqueSchemaName(schemas)
|
||||
userEditedSchemaName = false
|
||||
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
|
||||
// Auto-generated name now exists (schemas reloaded), regenerate
|
||||
newSchemaName = generateUniqueSchemaName(schemas)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reset schema when datatable changes
|
||||
let previousDatatable = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (previousDatatable !== undefined && selectedDatatable !== previousDatatable) {
|
||||
selectedSchema = undefined
|
||||
newSchemaName = ''
|
||||
userEditedSchemaName = false
|
||||
}
|
||||
previousDatatable = selectedDatatable
|
||||
})
|
||||
|
||||
// Update AI prompt when summary changes
|
||||
$effect(() => {
|
||||
if (appSummary.trim() && isAiEnabled) {
|
||||
initialPrompt = `Build ${appSummary.trim()}`
|
||||
}
|
||||
})
|
||||
|
||||
const datatableItems = $derived(toDatatableItems(availableDatatables))
|
||||
const schemaItems = $derived(toSchemaItems(availableSchemas))
|
||||
|
||||
// The effective schema to use (either selected existing, new schema name, or undefined for none)
|
||||
const effectiveSchema = $derived(
|
||||
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
|
||||
)
|
||||
|
||||
const hasNoDatatables = $derived(availableDatatables?.length === 0)
|
||||
const isAiEnabled = $derived($copilotInfo.enabled)
|
||||
|
||||
async function startApp(withPrompt: boolean) {
|
||||
const template = templates[selectedTemplateIndex]
|
||||
if (template.files) {
|
||||
files = template.files
|
||||
reloadCounter += 1
|
||||
}
|
||||
|
||||
// Set summary
|
||||
summary = appSummary.trim()
|
||||
|
||||
// Create new schema if needed
|
||||
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
|
||||
try {
|
||||
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
|
||||
const dbOps = dbSchemaOpsWithPreviewScripts({
|
||||
workspace: $workspaceStore,
|
||||
input: {
|
||||
type: 'database',
|
||||
resourceType: 'postgresql',
|
||||
resourcePath: `datatable://${selectedDatatable}`
|
||||
}
|
||||
})
|
||||
await dbOps.onCreateSchema({ schema: newSchemaName })
|
||||
} catch (e) {
|
||||
console.error('Failed to create schema:', e)
|
||||
sendUserToast(`Failed to create schema: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the data configuration including pre-whitelisted tables
|
||||
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
|
||||
if (tableCreationEnabled && selectedDatatable) {
|
||||
data = {
|
||||
tables: formattedTables,
|
||||
datatable: selectedDatatable,
|
||||
schema: effectiveSchema
|
||||
}
|
||||
} else {
|
||||
data = {
|
||||
tables: formattedTables,
|
||||
datatable: undefined,
|
||||
schema: undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Sync to aiChatManager
|
||||
aiChatManager.datatableCreationPolicy = {
|
||||
enabled: tableCreationEnabled && !!selectedDatatable,
|
||||
datatable: tableCreationEnabled ? selectedDatatable : undefined,
|
||||
schema: tableCreationEnabled ? effectiveSchema : undefined
|
||||
}
|
||||
|
||||
templatePicker = false
|
||||
|
||||
// Remove nodraft from URL
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('nodraft')) {
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
// If starting with a prompt, trigger AI after a short delay for the editor to initialize
|
||||
if (withPrompt && initialPrompt.trim() && isAiEnabled) {
|
||||
setTimeout(() => {
|
||||
aiChatManager.changeMode(AIMode.APP)
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.toggleOpen()
|
||||
}
|
||||
aiChatManager.instructions = initialPrompt.trim()
|
||||
aiChatManager.sendRequest()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if templatePicker}
|
||||
<Modal kind="X" open title="New App setup">
|
||||
<div class="flex flex-col gap-6 min-w-sm">
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
|
||||
<TextInput
|
||||
bind:value={appSummary}
|
||||
inputProps={{
|
||||
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Template Selection -->
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each templates as t, i}
|
||||
<button
|
||||
onclick={() => (selectedTemplateIndex = i)}
|
||||
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
|
||||
? 'bg-surface-accent-selected border border-accent'
|
||||
: ''} hover:bg-surface-hover border rounded-lg transition-all"
|
||||
>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<FileEditorIcon file={'.' + t.icon} size={32} />
|
||||
</div>
|
||||
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Configuration -->
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
|
||||
|
||||
{#if hasNoDatatables}
|
||||
<Alert type="warning" title="No datatables configured.">
|
||||
You can still create an app, but for data storage you won't be able to use data tables
|
||||
which are <b>highly recommended</b>.
|
||||
<br />
|
||||
|
||||
{#if $userStore?.is_admin}
|
||||
Configure datatables in
|
||||
<a
|
||||
href="/workspace_settings?tab=windmill_data_tables"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure datatables in workspace settings to enable this
|
||||
feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Default Datatable & Schema -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
|
||||
<div class="flex flex-col gap-4 rounded-md p-4 border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-emphasis font-semibold" for="datatable"
|
||||
>Datatable</label
|
||||
>
|
||||
<Select
|
||||
id="datatable"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-emphasis font-semibold">Schema</span>
|
||||
|
||||
<div class="flex flex-row gap-1 w-full items-center">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
|
||||
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
|
||||
<ToggleButton
|
||||
value="existing"
|
||||
label="Existing"
|
||||
icon={List}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if schemaMode === 'new'}
|
||||
<TextInput
|
||||
bind:value={newSchemaName}
|
||||
inputProps={{
|
||||
placeholder: 'Schema name',
|
||||
oninput: () => (userEditedSchemaName = true)
|
||||
}}
|
||||
class="flex-1"
|
||||
error={newSchemaAlreadyExists}
|
||||
size="sm"
|
||||
/>
|
||||
{:else if schemaMode === 'existing'}
|
||||
<div class="flex-1">
|
||||
<Select
|
||||
disablePortal
|
||||
items={schemaItems}
|
||||
bind:value={selectedSchema}
|
||||
placeholder="Schema"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if newSchemaAlreadyExists}
|
||||
<span class="text-xs text-red-500"
|
||||
>Schema "{newSchemaName}" already exists</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Creation Toggle -->
|
||||
<div class="flex items-center">
|
||||
<Toggle
|
||||
size="sm"
|
||||
bind:checked={tableCreationEnabled}
|
||||
options={{ right: 'Allow AI to create new tables' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pre-whitelisted Tables -->
|
||||
<div class="pt-6">
|
||||
<RawAppDataTableList
|
||||
dataTableRefs={preWhitelistedTables}
|
||||
defaultDatatable={selectedDatatable}
|
||||
defaultSchema={effectiveSchema}
|
||||
standalone
|
||||
hideDefaultSelector
|
||||
onAdd={() => dataTableDrawer?.openDrawer()}
|
||||
onRemove={(index) => {
|
||||
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI Prompt (Optional) -->
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
|
||||
<Sparkles size={16} class="text-ai" />
|
||||
Start with AI
|
||||
<span class="text-xs font-normal text-tertiary">(optional)</span>
|
||||
</h2>
|
||||
|
||||
{#if !isAiEnabled}
|
||||
<Alert type="info" title="AI is not configured for this workspace.">
|
||||
You can still create an app manually but using AI is highly recommended.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure AI in
|
||||
<a
|
||||
href="/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a>
|
||||
to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure AI in workspace settings to enable this feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
bind:value={initialPrompt}
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder:
|
||||
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
|
||||
}}
|
||||
/>
|
||||
<p class="text-xs text-tertiary">
|
||||
Leave empty to start with a blank template, or describe your app to get AI assistance
|
||||
right away.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
on:click={() => startApp(false)}
|
||||
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
|
||||
>
|
||||
Start without AI
|
||||
</Button>
|
||||
{#if isAiEnabled}
|
||||
<Button
|
||||
variant="accent"
|
||||
on:click={() => startApp(true)}
|
||||
disabled={!templates[selectedTemplateIndex] ||
|
||||
!initialPrompt.trim() ||
|
||||
newSchemaAlreadyExists}
|
||||
startIcon={{ icon: Sparkles }}
|
||||
btnClasses={AIBtnClasses('accent')}
|
||||
>
|
||||
Start with AI
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
{#key reloadCounter}
|
||||
<RawAppEditor
|
||||
on:savedNewAppPath={(event) => {
|
||||
goto(`/apps_raw/edit/${event.detail}`)
|
||||
}}
|
||||
bind:files
|
||||
bind:runnables
|
||||
bind:data
|
||||
{policy}
|
||||
path={''}
|
||||
liveEditorDraftStoragePath=""
|
||||
bind:summary
|
||||
newApp
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<RawAppDataTableDrawer
|
||||
bind:this={dataTableDrawer}
|
||||
offset={10000}
|
||||
existingRefs={preWhitelistedTables}
|
||||
onAdd={(ref) => {
|
||||
preWhitelistedTables = [...preWhitelistedTables, ref]
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<RawAppDraft>('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<void> {
|
||||
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) {
|
||||
|
||||
@@ -1,208 +1,15 @@
|
||||
<script lang="ts">
|
||||
// `/flows/add` is a thin redirect onto the canonical editor at
|
||||
// `/flows/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
|
||||
// design rationale — all editor logic lives in /flows/edit/[...path].
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { initialArgsStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { decodeState, emptySchema, type StateStore } from '$lib/utils'
|
||||
import { tick } from 'svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /flows/add (no nodraft) instead restores whatever the
|
||||
// user was last working on.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('flow', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
const templateId = page.url.searchParams.get('template_id')
|
||||
const isFork = page.url.searchParams.get('fork')
|
||||
|
||||
let forkState: any = undefined
|
||||
if (isFork) {
|
||||
const forkJson = localStorage.getItem('fork_flow')
|
||||
if (forkJson) {
|
||||
try {
|
||||
forkState = JSON.parse(forkJson)
|
||||
} catch {}
|
||||
localStorage.removeItem('fork_flow')
|
||||
} else if ((window.opener as any)?.__forkPreviewData) {
|
||||
forkState = (window.opener as any).__forkPreviewData
|
||||
delete (window.opener as any).__forkPreviewData
|
||||
}
|
||||
}
|
||||
|
||||
let selectedId: string = $state('settings-metadata')
|
||||
let loading = $state(false)
|
||||
|
||||
let initialPath: string | undefined = $state(undefined)
|
||||
let pathStoreInit: string | undefined = $state(undefined)
|
||||
|
||||
let initialArgs = $state({})
|
||||
if ($initialArgsStore) {
|
||||
initialArgs = $initialArgsStore
|
||||
$initialArgsStore = undefined
|
||||
}
|
||||
// initialArgs may also be set from decoded state below (e.g. fork preview)
|
||||
let flowBuilder: FlowBuilder | undefined = $state(undefined)
|
||||
|
||||
function emptyFlow(): Flow {
|
||||
return {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
path: '',
|
||||
edited_at: '',
|
||||
edited_by: '',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
}
|
||||
}
|
||||
|
||||
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
|
||||
|
||||
const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return flowHandle.draft ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
flowHandle.draft = v
|
||||
}
|
||||
}
|
||||
const flowStateStore = $state({ val: {} })
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
async function loadFlow() {
|
||||
loading = true
|
||||
// Start from the persisted autosave, not a fresh `emptyFlow()`. The
|
||||
// branches below override `flow` when the user explicitly asked for a
|
||||
// different starting point (import/fork/URL state/template/hub); a
|
||||
// plain reload of /flows/add (no query params) falls through with
|
||||
// the LS value intact so the user's last session is restored.
|
||||
let flow: Flow = flowHandle.draft ?? emptyFlow()
|
||||
|
||||
let state = forkState
|
||||
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
|
||||
|
||||
if (initialStateQuery) {
|
||||
state = decodeState(initialStateQuery)
|
||||
}
|
||||
if ($importFlowStore) {
|
||||
flow = $importFlowStore
|
||||
$importFlowStore = undefined
|
||||
sendUserToast('Flow loaded from YAML/JSON')
|
||||
} else if (!templatePath && !hubId && state) {
|
||||
flow = state.flow
|
||||
pathStoreInit = state.path
|
||||
if (state.initialArgs) {
|
||||
initialArgs = state.initialArgs
|
||||
}
|
||||
draftTriggersFromUrl = state.draft_triggers
|
||||
selectedTriggerIndexFromUrl = state.selected_trigger
|
||||
flowBuilder?.setDraftTriggers(draftTriggersFromUrl)
|
||||
flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl)
|
||||
state?.selectedId && (selectedId = state?.selectedId)
|
||||
} else {
|
||||
if (templatePath) {
|
||||
let template: Flow
|
||||
if (templateId) {
|
||||
template = await FlowService.getFlowVersion({
|
||||
workspace: $workspaceStore!,
|
||||
version: parseInt(templateId)
|
||||
})
|
||||
} else {
|
||||
template = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
})
|
||||
}
|
||||
// Template/hub flows are an explicit "start fresh from this
|
||||
// content" — drop any previous empty-path autosave and use
|
||||
// the freshly built flow as the baseline.
|
||||
flow = emptyFlow()
|
||||
Object.assign(flow, template)
|
||||
const oldPath = templatePath.split('/')
|
||||
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
|
||||
oldPath[oldPath.length - 1]
|
||||
}_fork`
|
||||
goto('?', { replaceState: true })
|
||||
selectedId = 'settings-metadata'
|
||||
} else if (hubId) {
|
||||
const hub = await FlowService.getHubFlowById({ id: Number(hubId) })
|
||||
delete hub['comments']
|
||||
initialPath = `u/${$userStore?.username
|
||||
.split('@')[0]
|
||||
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
|
||||
flow = emptyFlow()
|
||||
Object.assign(flow, hub.flow)
|
||||
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
|
||||
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
|
||||
hubId,
|
||||
flow.value.preprocessor_module.value.content
|
||||
)
|
||||
}
|
||||
goto('?', { replaceState: true })
|
||||
selectedId = 'constants'
|
||||
}
|
||||
}
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
flowBuilder?.loadFlowState()
|
||||
loading = false
|
||||
|
||||
// Trigger tutorial after everything is initialized
|
||||
const tutorialParam = page.url.searchParams.get('tutorial')
|
||||
if (tutorialParam) {
|
||||
// Wait for critical elements to be ready before triggering tutorial
|
||||
await tick()
|
||||
let attempts = 0
|
||||
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
attempts++
|
||||
}
|
||||
flowBuilder?.triggerTutorial()
|
||||
}
|
||||
}
|
||||
|
||||
loadFlow()
|
||||
onMount(() => {
|
||||
const uuid = crypto.randomUUID()
|
||||
const params = new URLSearchParams(page.url.searchParams)
|
||||
params.set('new_draft', 'true')
|
||||
goto(`/flows/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<FlowBuilder
|
||||
onDeploy={(e) => {
|
||||
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
|
||||
/>
|
||||
|
||||
@@ -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<Flow>(() => [{ 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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,290 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { type NewScript, ScriptService, type ScriptLang } from '$lib/gen'
|
||||
|
||||
import { page } from '$app/state'
|
||||
import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import type { Schema } from '$lib/common'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
encodeState,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
// `/scripts/add` is a thin redirect onto the canonical editor at
|
||||
// `/scripts/edit/draft_{uuid}?new_draft=true`. All editor logic — fetch,
|
||||
// seed handling, autosave wiring — lives in `/scripts/edit/[...path]`;
|
||||
// keeping it in one place means a single source of truth for the
|
||||
// editor's load lifecycle.
|
||||
//
|
||||
// `new_draft=true` tells the edit page "first mount, don't fetch the
|
||||
// non-existent item; seed empty." The flag is consumed and stripped
|
||||
// from the URL by the edit page on first render. Any other query
|
||||
// params (`template`, `hub`, `tutorial`, ...) ride along untouched so
|
||||
// existing entry-points keep working.
|
||||
import { goto } from '$lib/navigation'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
type Script = NewScript & {
|
||||
draft_triggers?: Trigger[]
|
||||
hash?: string
|
||||
extra_perms?: Record<string, any>
|
||||
}
|
||||
|
||||
// Default
|
||||
let schema: Schema = emptySchema()
|
||||
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
const hubPath = page.url.searchParams.get('hub')
|
||||
const showMeta = /true|1/i.test(page.url.searchParams.get('show_meta') ?? '0')
|
||||
const urlArgs = page.url.searchParams.get('initial_args')
|
||||
const collabLang = page.url.searchParams.get('lang') as ScriptLang | null
|
||||
const wacParam = page.url.searchParams.get('wac')
|
||||
const importParam = page.url.searchParams.get('import')
|
||||
|
||||
/** Some pages (run/[...run]'s "Fork" action, workspace_settings'
|
||||
* error/success-handler template buttons) base64-JSON-encode a NewScript
|
||||
* payload into the URL hash. That value is an explicit "open this script"
|
||||
* intent and wins over local autosave, templates, hubs, and YAML imports.
|
||||
*
|
||||
* We can't use `decodeState` from utils.ts directly — it fires its own
|
||||
* "Impossible to parse state" toast on failure, which would noise up the
|
||||
* UI when the hash isn't a script payload at all (e.g. a route anchor).
|
||||
*/
|
||||
function decodeUrlScript(): Partial<Script> | undefined {
|
||||
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
|
||||
if (!fragment) return undefined
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
|
||||
if (decoded && typeof decoded === 'object') return decoded as Partial<Script>
|
||||
} catch {
|
||||
// Hash isn't a valid encoded script — ignore.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const urlScript = decodeUrlScript()
|
||||
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /scripts/add (no nodraft) instead restores the
|
||||
// previous session.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('script', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
|
||||
if (get(initialArgsStore)) $initialArgsStore = undefined
|
||||
|
||||
const path = page.url.searchParams.get('path')
|
||||
|
||||
function defaultScript(): Script {
|
||||
return {
|
||||
hash: '',
|
||||
path: path ?? '',
|
||||
summary: '',
|
||||
content: '',
|
||||
description: '',
|
||||
schema: schema,
|
||||
is_template: false,
|
||||
extra_perms: {},
|
||||
language:
|
||||
(wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ??
|
||||
collabLang ??
|
||||
(($defaultScripts?.order?.filter(
|
||||
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x)
|
||||
)?.[0] ?? 'bun') as ScriptLang),
|
||||
kind: 'script'
|
||||
}
|
||||
}
|
||||
|
||||
// templatePath/hubPath/import/url-hash flows replace the value before
|
||||
// render, so defaultValue is left undefined for those to avoid flashing a
|
||||
// blank editor.
|
||||
const scriptHandle = UserDraft.use<Script>('script', '', {
|
||||
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
effectivePath: scriptHandle.draft?.path
|
||||
})
|
||||
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
|
||||
// sync with edits (encoded back into the hash, debounced). Asks the user
|
||||
// via modal when the URL payload would clobber an existing local autosave.
|
||||
let urlConflictModalOpen = $state(false)
|
||||
let pendingUrlPayload: Script | undefined = undefined
|
||||
|
||||
if (urlScript) {
|
||||
const seeded = { ...defaultScript(), ...urlScript } as Script
|
||||
const existing = scriptHandle.draft
|
||||
if (existing) {
|
||||
const localClean = orderedJsonStringify(cleanValueProperties(existing))
|
||||
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
|
||||
if (localClean !== seededClean) {
|
||||
pendingUrlPayload = seeded
|
||||
urlConflictModalOpen = true
|
||||
}
|
||||
} else {
|
||||
scriptHandle.draft = seeded
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
}
|
||||
|
||||
function onUrlConflictUseUrl() {
|
||||
if (pendingUrlPayload) {
|
||||
scriptHandle.draft = pendingUrlPayload
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
function onUrlConflictKeepLocal() {
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
|
||||
let _urlHashSyncTimeout: number | undefined
|
||||
$effect(() => {
|
||||
const draft = scriptHandle.draft
|
||||
if (!draft) return
|
||||
// Gate while the conflict modal is open so we don't overwrite the URL
|
||||
// payload before the user has decided.
|
||||
if (urlConflictModalOpen) return
|
||||
readFieldsRecursively(draft)
|
||||
if (typeof window === 'undefined') return
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
_urlHashSyncTimeout = setTimeout(() => {
|
||||
const snapshot = $state.snapshot(scriptHandle.draft)
|
||||
if (!snapshot) return
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = encodeState(snapshot)
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, 500)
|
||||
})
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
async function loadTemplate(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (templatePath) {
|
||||
try {
|
||||
const template = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
})
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
schema: template.schema,
|
||||
language: template.language,
|
||||
path: template.path + '_fork'
|
||||
}
|
||||
} catch (err) {
|
||||
scriptHandle.draft = defaultScript()
|
||||
console.error('Error loading template', err)
|
||||
sendUserToast('Error loading template: ' + err.message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHub(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (hubPath) {
|
||||
try {
|
||||
const { content, language, summary } = await ScriptService.getHubScriptByPath({
|
||||
path: hubPath
|
||||
})
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
description: `Fork of ${hubPath}`,
|
||||
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
|
||||
summary: summary ?? '',
|
||||
language: language as Script['language'],
|
||||
path: hubPath + '_fork'
|
||||
}
|
||||
} catch (err) {
|
||||
scriptHandle.draft = defaultScript()
|
||||
console.error('Error loading script from hub', err)
|
||||
sendUserToast('Error loading script from hub: ' + err.message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadHub()
|
||||
|
||||
let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined
|
||||
if (!urlScript && importParam && $importScriptStore) {
|
||||
const imported = $importScriptStore
|
||||
$importScriptStore = undefined
|
||||
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
...imported,
|
||||
path: path ?? '',
|
||||
hash: '',
|
||||
extra_perms: {}
|
||||
}
|
||||
if (isWac) {
|
||||
importedWacTemplate = imported.language === 'python3' ? 'wac_python' : 'wac_typescript'
|
||||
sendUserToast('WAC script loaded from YAML/JSON')
|
||||
} else {
|
||||
sendUserToast('Script loaded from YAML/JSON')
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
untrack(() => loadTemplate())
|
||||
}
|
||||
onMount(() => {
|
||||
const uuid = crypto.randomUUID()
|
||||
const params = new URLSearchParams(page.url.searchParams)
|
||||
params.set('new_draft', 'true')
|
||||
goto(`/scripts/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
|
||||
<LocalDraftStaleModal
|
||||
open={urlConflictModalOpen}
|
||||
cause="url"
|
||||
onLoadLatest={onUrlConflictUseUrl}
|
||||
onKeepDraft={onUrlConflictKeepLocal}
|
||||
/>
|
||||
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
{initialArgs}
|
||||
lockedLanguage={templatePath != null || hubPath != null}
|
||||
template={importedWacTemplate ??
|
||||
(wacParam === 'python'
|
||||
? 'wac_python'
|
||||
: wacParam === 'typescript'
|
||||
? 'wac_typescript'
|
||||
: 'script')}
|
||||
onDeploy={(e) => {
|
||||
// "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}
|
||||
<ScriptEditorSkeleton />
|
||||
{/if}
|
||||
|
||||
@@ -197,6 +197,28 @@
|
||||
async function loadScript(): Promise<void> {
|
||||
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!,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<Button size="xs" variant="default" target="_blank" href="/scripts/add"
|
||||
>Create new script</Button
|
||||
>
|
||||
<Button size="xs" variant="default" target="_blank" href="/flows/add?nodraft=true"
|
||||
<Button size="xs" variant="default" target="_blank" href="/flows/add"
|
||||
>Create new flow</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user