mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
feat(drafts): render friendly user-typed path on home list for all 4 kinds
Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows
prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}`
URL slot, with two source rules — one per how each editor wires the
Path widget:
- Scripts already work: `ScriptBuilder` binds the Path widget directly
to `script.path`, so the typed path round-trips through the draft
JSON's own `path` field. Backend extracts `v["path"]` when it differs
from `row.path`.
- Flows / apps / raw apps don't write the typed path into the
autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the
bare `App` / raw-app value has no `path` field at all). Introduce an
explicit `draft_path` field on the draft JSON, written by the editor
ONLY when the typed path differs from the deployed/seeded
`savedX.path`:
- FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`.
- AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`.
- RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the
bind chain (RawAppEditor → route); the route's draftHandle.draft
spread includes `draft_path` when set.
Backend extracts `v["draft_path"]` and `None` when unchanged or after
deploy (deploy clears the whole draft, so the field naturally
disappears post-deploy without bookkeeping).
Flow route's `new_draft` branch now stops sync around the Path widget
cascade, with a 700ms scheduled `restartSync` (mirrors the existing
scripts/apps/raw_apps stoppers) — the new draft_path mutation lands
inside that window so `/flows/add` no longer fires an autosave before
the user's first edit. openapi/sqlx regenerated.
This commit is contained in:
@@ -268,6 +268,17 @@ async fn list_flows(
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// The Flow editor's autosave never updates `flow.path` from
|
||||
// the Path widget — the widget binds `$pathStore` directly,
|
||||
// which is one-way `flow.path → $pathStore`. So the editor
|
||||
// writes a separate `draft_path` field into the draft JSON
|
||||
// when (and only when) the typed path differs from the
|
||||
// deployed one. `None` here = unchanged.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableFlow {
|
||||
workspace_id: w_id.clone(),
|
||||
path: row.path,
|
||||
@@ -290,6 +301,7 @@ async fn list_flows(
|
||||
deployment_msg: None,
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +391,14 @@ async fn list_scripts(
|
||||
.get("kind")
|
||||
.and_then(|x| serde_json::from_value(x.clone()).ok())
|
||||
.unwrap_or(ScriptKind::Script);
|
||||
// Scripts bind the Path widget directly to `script.path`, so
|
||||
// the user-typed path round-trips through the draft JSON's
|
||||
// own `path` field — no separate `draft_path` field needed.
|
||||
let draft_path = v
|
||||
.get("path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableScript {
|
||||
hash: ScriptHash(0),
|
||||
path: row.path,
|
||||
@@ -418,6 +426,7 @@ async fn list_scripts(
|
||||
kind,
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7799,6 +7799,15 @@ paths:
|
||||
script — either no deployed row exists at this
|
||||
path (draft-only) or the user saved a per-user
|
||||
draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Surfaced for draft-only rows so
|
||||
the home list can render the meaningful name
|
||||
instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted
|
||||
when unchanged.
|
||||
|
||||
/w/{workspace}/scripts/list_paths:
|
||||
get:
|
||||
@@ -9566,6 +9575,17 @@ paths:
|
||||
flow — either no deployed row exists at this
|
||||
path (draft-only) or the user saved a per-user
|
||||
draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Sourced from the draft JSON's
|
||||
`draft_path` field (the editor only writes it
|
||||
when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful
|
||||
name instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted when
|
||||
unchanged.
|
||||
|
||||
/w/{workspace}/flows/history/p/{path}:
|
||||
get:
|
||||
@@ -27083,6 +27103,15 @@ components:
|
||||
True when the authed user has a draft for this app — either no
|
||||
deployed row exists at this path (draft-only) or the user has
|
||||
saved a per-user draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not yet deployed.
|
||||
Sourced from the draft JSON's `draft_path` field (the editor
|
||||
only writes it when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful name instead of
|
||||
the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted
|
||||
when unchanged.
|
||||
required:
|
||||
- id
|
||||
- workspace_id
|
||||
|
||||
@@ -167,6 +167,15 @@ pub struct ListableApp {
|
||||
/// saved a per-user draft on top of the deployed row.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed —
|
||||
/// sourced from the draft JSON's `draft_path` field, which the app
|
||||
/// editor only writes when the typed path differs from the deployed
|
||||
/// one. Lets the home list render the meaningful name instead of
|
||||
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
|
||||
/// unchanged.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
@@ -481,6 +490,16 @@ async fn list_apps(
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// App / raw-app drafts are the bare editor value (`App` or
|
||||
// `{files, runnables, …}`) — neither shape carries a `path`
|
||||
// field. The editor writes a separate `draft_path` field
|
||||
// into the draft JSON when (and only when) the typed path
|
||||
// differs from the deployed one. `None` here = unchanged.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableApp {
|
||||
id: 0,
|
||||
workspace_id: w_id.clone(),
|
||||
@@ -500,6 +519,7 @@ async fn list_apps(
|
||||
raw_app: row.typ == "raw_app",
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,15 @@ pub struct ListableFlow {
|
||||
/// saved a per-user draft on top of the deployed row.
|
||||
#[serde(default)]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed —
|
||||
/// sourced from the draft JSON's `draft_path` field, which the flow
|
||||
/// editor only writes when the typed path differs from the deployed
|
||||
/// one. Lets the home list render the meaningful name instead of
|
||||
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
|
||||
/// unchanged (which is most rows).
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
@@ -465,6 +465,17 @@ pub struct ListableScript {
|
||||
/// on top of it. Distinguishes user-mode-only state from team state.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed.
|
||||
/// Surfaced for draft-only rows so the home list can render the
|
||||
/// meaningful name instead of the autogenerated
|
||||
/// `u/{user}/draft_{uuid}` URL path. Sourced from the draft JSON
|
||||
/// (scripts: `value.path` — the editor binds the Path widget
|
||||
/// directly to `script.path`; flows/apps/raw apps: an explicit
|
||||
/// `value.draft_path` field the editor only writes when the typed
|
||||
/// path differs from the deployed one). `None` when unchanged.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
fn is_false(x: &bool) -> bool {
|
||||
|
||||
@@ -895,6 +895,29 @@
|
||||
if (p) untrack(() => ($pathStore = p))
|
||||
})
|
||||
|
||||
// Persist the user-typed path into the draft JSON as `draft_path`
|
||||
// when it differs from the deployed/seeded `flow.path`. The Path
|
||||
// widget binds `$pathStore` one-way to the popover input — without
|
||||
// this, the friendly auto-name on `/flows/add` and any in-place
|
||||
// rename never reach the autosaved Flow, so the home-list draft row
|
||||
// kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop
|
||||
// the field once it matches the baseline again so it doesn't
|
||||
// linger after a revert; deploy clears the whole draft, so the
|
||||
// field naturally disappears post-deploy too.
|
||||
$effect(() => {
|
||||
const typed = $pathStore
|
||||
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
|
||||
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
|
||||
if (!flow) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
flow.draft_path = typed
|
||||
} else if (flow.draft_path !== undefined) {
|
||||
delete (flow as any).draft_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
$effect.pre(() => {
|
||||
selectedId && untrack(() => select(selectedId))
|
||||
})
|
||||
|
||||
@@ -164,6 +164,29 @@
|
||||
darkMode
|
||||
} = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
// Persist the user-typed path into the bare App draft as
|
||||
// `draft_path` when it differs from the deployed/seeded baseline.
|
||||
// The Path widget binds `newEditedPath` (popover-local), and the
|
||||
// App's own shape has no `path` field — without this, the friendly
|
||||
// auto-name on `/apps/add` and any in-place rename never reach the
|
||||
// autosaved value, so the home-list draft row kept showing the
|
||||
// autogenerated `u/{user}/draft_{uuid}` slot. Drop it once it
|
||||
// matches the baseline again; deploy clears the whole draft, so
|
||||
// the field naturally disappears post-deploy.
|
||||
$effect(() => {
|
||||
const typed = newEditedPath
|
||||
const baseline = savedApp?.path ?? ''
|
||||
const a = $app as (typeof $app & { draft_path?: string }) | undefined
|
||||
if (!a) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
a.draft_path = typed
|
||||
} else if (a.draft_path !== undefined) {
|
||||
delete (a as any).draft_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { history, jobsDrawerOpen, refreshComponents } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
|
||||
kind="app"
|
||||
{marked}
|
||||
path={app.path}
|
||||
path={(app as any).draft_path ?? app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
flow: Flow & {
|
||||
draft_only?: boolean
|
||||
is_draft?: boolean
|
||||
draft_path?: string
|
||||
canWrite: boolean
|
||||
}
|
||||
marked: string | undefined
|
||||
@@ -127,7 +128,7 @@
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{marked}
|
||||
path={flow.path}
|
||||
path={flow.draft_path ?? flow.path}
|
||||
summary={flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
canWrite: boolean
|
||||
use_codebase: boolean
|
||||
is_draft?: boolean
|
||||
draft_path?: string
|
||||
}
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
@@ -144,7 +145,7 @@
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
{marked}
|
||||
path={script.path}
|
||||
path={script.draft_path ?? script.path}
|
||||
summary={script.summary}
|
||||
{errorHandlerMuted}
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
* still toggle the mode after mount; this prop only seeds the
|
||||
* initial state. */
|
||||
defaultSplitWithPreview?: boolean
|
||||
/** Set by `RawAppEditorHeader` whenever the user-typed path
|
||||
* (`newEditedPath`) differs from the deployed/seeded `savedApp.path`.
|
||||
* The route reads it back to inject `draft_path` into the autosaved
|
||||
* raw-app value so the home-page row can render the friendly name
|
||||
* instead of the URL's autogenerated `draft_{uuid}` slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -104,7 +110,8 @@
|
||||
defaultSidebarCollapsed = false,
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed',
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
defaultSplitWithPreview = true
|
||||
defaultSplitWithPreview = true,
|
||||
pendingDraftPath = $bindable(undefined)
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -1357,6 +1364,7 @@
|
||||
bind:jobsById
|
||||
bind:savedApp
|
||||
bind:summary
|
||||
bind:pendingDraftPath
|
||||
on:restore
|
||||
on:savedNewAppPath
|
||||
{policy}
|
||||
@@ -1637,8 +1645,9 @@
|
||||
title="Build failed"
|
||||
class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']"
|
||||
>
|
||||
<pre
|
||||
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
|
||||
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
|
||||
>{buildError}</pre
|
||||
>
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -127,6 +127,12 @@
|
||||
liveEditorDraftStoragePath?: string
|
||||
// Fired after a successful deploy; lets the session preview reload.
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
/** Surfaces the user-typed path (`newEditedPath`) up to the route
|
||||
* when (and only when) it differs from the deployed/seeded
|
||||
* `savedApp.path`. The route writes it into the autosaved raw-app
|
||||
* draft as `draft_path` so the home-page row can render the
|
||||
* friendly name instead of the URL's autogenerated draft slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -153,9 +159,18 @@
|
||||
onToggleSidebar = undefined,
|
||||
onNavigate = undefined,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onDeploy = undefined
|
||||
onDeploy = undefined,
|
||||
pendingDraftPath = $bindable(undefined)
|
||||
}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
const typed = newEditedPath
|
||||
const baseline = savedApp?.path ?? ''
|
||||
untrack(() => {
|
||||
pendingDraftPath = typed && typed !== baseline ? typed : undefined
|
||||
})
|
||||
})
|
||||
|
||||
let newEditedPath = $state(
|
||||
untrack(() =>
|
||||
newApp
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
/** User-typed path the home list renders when set — written by
|
||||
* the editor when (and only when) the typed path differs from
|
||||
* the deployed/seeded `savedApp.path`. */
|
||||
draft_path?: string
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined = $state(undefined)
|
||||
@@ -53,6 +57,11 @@
|
||||
// let lastVersion = 0
|
||||
let policy: any = $state({})
|
||||
let summary = $state('')
|
||||
/** User-typed path surfaced from `RawAppEditorHeader` whenever it
|
||||
* differs from `savedApp.path`. Mirrored into the autosaved raw-app
|
||||
* draft below as `draft_path` so the home list can show the friendly
|
||||
* name instead of the URL's `draft_{uuid}` slot. */
|
||||
let pendingDraftPath = $state<string | undefined>(undefined)
|
||||
|
||||
let savedApp:
|
||||
| {
|
||||
@@ -137,14 +146,20 @@
|
||||
readFieldsRecursively(data)
|
||||
readFieldsRecursively(policy)
|
||||
void summary
|
||||
void pendingDraftPath
|
||||
draftHandle.draft = {
|
||||
files: currentFiles,
|
||||
runnables,
|
||||
data,
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path
|
||||
}
|
||||
custom_path: savedApp?.custom_path,
|
||||
// Only persist when set — `RawAppEditorHeader` clears it back
|
||||
// to undefined once the typed path matches the baseline again,
|
||||
// and deploy clears the whole draft, so the field disappears
|
||||
// from the saved JSON in both cases.
|
||||
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
|
||||
} as RawAppDraft
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
@@ -525,6 +540,7 @@
|
||||
bind:runnables
|
||||
bind:data
|
||||
bind:summary
|
||||
bind:pendingDraftPath
|
||||
{newPath}
|
||||
path={page.params.path ?? ''}
|
||||
liveEditorDraftStoragePath={path}
|
||||
|
||||
@@ -189,6 +189,22 @@
|
||||
// path — FlowBuilder's Deploy button must `createFlow` at
|
||||
// the user-typed path, not `updateFlow` at the URL.
|
||||
isNewFlow = true
|
||||
// Suspend autosave around the bootstrap cascade: the Path
|
||||
// widget's `$workspaceStore && $userStore`-gated `initPath →
|
||||
// reset → onMetaChange → bind:path` chain seeds a friendly
|
||||
// auto-name into `$pathStore`, which FlowBuilder's
|
||||
// `draft_path` effect mirrors into `flow.draft_path` — that
|
||||
// programmatic write would otherwise fire as the user's
|
||||
// first save before they've touched anything. Resumed once
|
||||
// the cascade has settled (mirrors the other editors;
|
||||
// 700ms covers cold reload where `$userStore` loads over
|
||||
// the network).
|
||||
if ($workspaceStore) {
|
||||
const ws = $workspaceStore
|
||||
const restartPath = flowDraftPath
|
||||
UserDraft.stopSync('flow', restartPath, { workspace: ws })
|
||||
setTimeout(() => UserDraft.restartSync('flow', restartPath, { workspace: ws }), 700)
|
||||
}
|
||||
// Clear `initialPath` so the Path widget's `initPath` falls
|
||||
// into the empty-path branch and calls `reset()` to seed a
|
||||
// friendly auto-name (mirrors the script editor — without
|
||||
|
||||
Reference in New Issue
Block a user