Files
windmill/frontend/src/lib/components/usePageDraftSync.svelte.ts
T
Diego ImbertandClaude Opus 4.8 25a891041d wire DB-backed autosave into the whitelabel flow SDK (#9637)
* fix(frontend): wire DB-backed autosave into the whitelabel flow SDK

FlowWrapper (the @windmill-labs/components flow editor entry) was never
updated after DB-backed user drafts moved autosave wiring to the page
layer, so the SDK editor had no autosave and never rendered the
AutosaveIndicator. Back the bound store with a per-user UserDraft handle
(workspace-guarded so it no-ops before a workspace exists) and pass
liveEditorDraftStoragePath so the indicator and Ctrl/Cmd+S flush engage.

Also set $workspaceStore on the /test_dev/sdk_flow harness page, which
lives outside the (logged) layout and so had an empty workspace store
(mirrors the sibling sdk_resource page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): shared test_dev header to log in + set the SDK token

Add a common TestDevHeader (rendered by a test_dev/+layout) that logs in
(email/password → bearer token), lets a token be pasted/set manually,
picks the workspace, loads the user, and persists the session across
reloads — mirroring the React SDK's initializeClients. test_dev routes
live outside the (logged) layout, so this is the single place that wires
OpenAPI.TOKEN + workspaceStore + userStore for the SDK demo pages.

Drop the now-redundant per-page workspace/user wiring from sdk_flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): reuse usePageDraftSync in the flow SDK instead of a parallel copy

FlowWrapper hand-rolled UserDraft.useMany + a manual seed effect, duplicating
the core of usePageDraftSync but dropping recordRemoteSync/seedBaseline/discardIf
— a divergence that would drift. The only reason it couldn't reuse the helper
was that useReactive passes workspace straight into useMany, whose reconcile
called resolveWorkspace() (which throws) before the detached-handle check.

Make reconcile resolve the workspace without throwing and treat an absent
workspace like an empty path — handing out a detached, local-only handle that
re-keys into a real entry once the workspace resolves. FlowWrapper then reuses
usePageDraftSync directly, keeping one code path for the page and SDK editors.

Seed via the spec's defaultValue (threaded through usePageDraftSync ->
useReactive -> useMany, captured once on first acquire and swallowed by the
syncer's seed guard) rather than a manual first-write effect, dropping the
fragile skipNextWrite assumption and the seededPath bookkeeping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): wire DB-backed autosave into the whitelabel script SDK

ScriptWrapper had the same gap FlowWrapper did: ScriptBuilder delegates its
draft handle to the page (it only stop/restart-syncs and flushes by
userDraftPath), so the SDK's plain `bind:script` never reached a UserDraft
handle — no autosave, no indicator. Back it with usePageDraftSync<script>
(bind:script={draftSync.draft}, userDraftPath), seeded from the consumer's
script via defaultValue. Same one-code-path reuse as the flow SDK.

AppWrapper needs no change: AppEditor already self-acquires its handle
(UserDraft.use('app', ...)), so apps autosave already — and now also tolerate
mounting before login via the reconcile change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): gate SDK editors on a resolved workspace

Before a workspace exists the draft handle is detached (local-only); editing
into it and then having the workspace resolve re-keys to a fresh real entry
seeded from the original value, silently dropping those edits. Gate the flow,
script, and app SDK editors on `$workspaceStore` so no editing happens until
the real draft key exists. Embedders set the workspace before rendering (React
SDK initializeClients); the test_dev header sets it on mount.

AppEditor additionally acquires its handle at init from a non-reactive
workspace, so gating AppWrapper also ensures it mounts with the workspace
already set rather than permanently detached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): add sdk_app test_dev page for the app editor SDK

Exercises AppWrapper the same way sdk_flow/sdk_script exercise their editors,
under the shared TestDevHeader. Confirms the app editor's self-managed autosave
+ AutosaveIndicator work via the SDK wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:42 +00:00

121 lines
4.5 KiB
TypeScript

import { untrack } from 'svelte'
import { UserDraft, type UserDraftHandle, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
/**
* Page-level draft orchestration for the full-page editors (scripts / flows /
* apps / apps_raw) — the page analogue of `useTriggerDraftSync`. Owns the
* per-path autosave handle (re-keyed on navigation), the live-editor-draft
* registry entry (home-page "edit draft" links), `recordRemoteSync` (a method
* so pages can't forget it and silently downgrade conflict detection to
* overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`.
* The entity-specific backend load and new-draft template stay in the page.
*/
export interface PageDraftSyncOptions<V = unknown> {
itemKind: UserDraftItemKind
/** Reactive draft storage path. `''` (e.g. viewing a historical hash)
* releases the handle and skips registry/sync work. */
path: () => string
/** Reactive workspace (`$workspaceStore`). */
workspace: () => string | undefined
/** Reactive effective path for the live-editor-draft registry (the
* draft's own `path`, used by home-page deep links). Omit to skip
* registry registration entirely (e.g. read-only hash views). */
effectivePath?: () => string | undefined
/** Predicate: is the value about to autosave back at the deployed
* baseline? When true the syncer POSTs a delete instead of a
* baseline-equal draft, so editing back to deployed clears the draft
* instead of leaving a no-op behind. MUST read the deployed baseline
* reactively (it's captured once per re-keyed acquire) and use
* `draftValuesEqual` so it can't disagree with the "unsaved changes"
* banner. Return false for draft-only items (no deployed baseline). */
discardIf?: (val: V) => boolean
/** Seeds the cell on first acquire without POSTing (the syncer's seed
* guard swallows it). Use when the value is already in hand at mount (an
* embedder providing the item) instead of assigning `draft` after load.
* Pass a STABLE reference (read it under `untrack`). */
defaultValue?: V
}
export interface PageDraftSync<V> {
/** The live autosave handle. `draft` is the editor's bind target. */
readonly handle: UserDraftHandle<V>
get draft(): V | undefined
set draft(value: V | undefined)
/** Load `value` as the deployed/template baseline WITHOUT POSTing it
* (see `UserDraft.seed`). */
seedBaseline(value: V): void
/** After a backend load, record the server's `draft_saved_at` so the
* next autosave attaches a matching `last_sync` and the server can
* reject stale writes. `undefined` clears it (no draft existed). */
recordRemoteSync(draftSavedAt: string | undefined): void
/** Drop the draft (server row + local cell) — restore-to-deployed and
* post-deploy cleanup. */
remove(): void
}
export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): PageDraftSync<V> {
// One handle, re-keyed on (workspace, path); `''` path releases it.
// `canBeDisabled` because these editors carry the "Enable auto-save" toggle.
const handle = UserDraft.useReactive<V>(() => ({
itemKind: opts.itemKind,
path: opts.path(),
workspace: opts.workspace(),
canBeDisabled: true,
defaultValue: opts.defaultValue,
discardIf: opts.discardIf
}))
// Live-editor-draft registry: lets the home-page "edit draft" link resolve
// this open editor. Re-registers on path change; cleared on teardown.
$effect(() => {
if (!opts.effectivePath) return
const ws = opts.workspace()
const p = opts.path()
const eff = opts.effectivePath()
if (!ws || !p) return
UserDraft.setLiveEditorDraft({
workspace: ws,
itemKind: opts.itemKind,
storagePath: p,
effectivePath: eff || p
})
return () => UserDraft.clearLiveEditorDraft(opts.itemKind, { workspace: ws, storagePath: p })
})
return {
get handle() {
return handle
},
get draft() {
return handle.draft
},
set draft(value: V | undefined) {
handle.draft = value
},
seedBaseline(value: V) {
const ws = opts.workspace()
const p = opts.path()
if (!ws || !p) return
UserDraft.seed(opts.itemKind, p, value, { workspace: ws })
},
recordRemoteSync(draftSavedAt: string | undefined) {
const ws = opts.workspace()
const p = opts.path()
if (!ws || !p) return
untrack(() =>
UserDraftDbSyncer.recordRemoteSync(
{ workspace: ws, itemKind: opts.itemKind, path: p },
draftSavedAt
)
)
},
remove() {
const ws = opts.workspace()
const p = opts.path()
if (!ws || !p) return
UserDraft.remove(opts.itemKind, p, { workspace: ws })
}
}
}