mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
feat(frontend): load and list never-deployed drafts from the draft table
With draft_only stub rows gone, never-deployed items live only in the draft table. Make them reachable again: - Editor pages (scripts/flows/apps/raw-apps): when getXByPathWithDraft 404s, fall back to DraftService.getDraft and seed the editor from the draft value (no deployed version → savedX has no hash, so deploy creates). - Home ItemsList: fetch DraftService.listDrafts and surface drafts that have no deployed counterpart as draft-only rows linking to the editor. - Add a userDraft / UserDraftDbService unit test (in-memory store, debounced persistence, kind→typ mapping, delete-on-undefined, empty-path skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
71db1caa23
commit
3e1250cbe3
@@ -36,7 +36,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
app: ListableApp & { has_draft?: boolean; canWrite: boolean }
|
||||
app: ListableApp & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -79,7 +79,9 @@
|
||||
{/if}
|
||||
|
||||
<Row
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
|
||||
href={app.draft_only
|
||||
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
|
||||
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
|
||||
kind="app"
|
||||
{marked}
|
||||
path={app.path}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
flow: Flow & { has_draft?: boolean; canWrite: boolean }
|
||||
flow: Flow & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -103,7 +103,9 @@
|
||||
<Row
|
||||
aiId={`flow-row-${flow.path}`}
|
||||
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
|
||||
href={`${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
href={flow.draft_only
|
||||
? `${base}/flows/edit/${flow.path}`
|
||||
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{marked}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
script: Script & { canWrite: boolean; use_codebase: boolean }
|
||||
script: Script & { canWrite: boolean; use_codebase: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -121,7 +121,7 @@
|
||||
<Row
|
||||
aiId={`script-run-button-${script.path}`}
|
||||
aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`}
|
||||
href={script.auto_kind === 'lib' && script.kind !== 'preprocessor'
|
||||
href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor')
|
||||
? `${base}/scripts/edit/${script.path}`
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
type Script,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
type ListableRawApp
|
||||
type ListableRawApp,
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
@@ -63,6 +64,9 @@
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
hash?: string
|
||||
/** Set on synthetic items that exist only in the draft table (never
|
||||
* deployed). Their rows link to the editor instead of a get/ page. */
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
type TableScript = TableItem<Script, 'script'>
|
||||
@@ -74,6 +78,9 @@
|
||||
let flows: TableFlow[] | undefined = $state()
|
||||
let apps: TableApp[] | undefined = $state()
|
||||
let raw_apps: TableRawApp[] | undefined = $state()
|
||||
/** Never-deployed items that live only in the draft table, keyed for merge
|
||||
* into `combinedItems`. */
|
||||
let draftOnlyItems: (TableScript | TableFlow | TableApp)[] = $state([])
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
|
||||
@@ -139,6 +146,76 @@
|
||||
loading = false
|
||||
}
|
||||
|
||||
/** Surface never-deployed items (rows that exist only in the draft table, no
|
||||
* deployed script/flow/app) so they're discoverable in the home list.
|
||||
* Deduped against the deployed items already loaded for the same kind. */
|
||||
async function loadDraftOnlyItems(): Promise<void> {
|
||||
let listed: Awaited<ReturnType<typeof DraftService.listDrafts>>
|
||||
try {
|
||||
listed = await DraftService.listDrafts({ workspace: $workspaceStore! })
|
||||
} catch (e) {
|
||||
console.error('Failed to load drafts', e)
|
||||
return
|
||||
}
|
||||
// Paths of deployed items per kind, to skip drafts that already have a
|
||||
// deployed counterpart (those are surfaced via the deployed item's
|
||||
// has_draft badge instead).
|
||||
const deployedScriptPaths = new Set((scripts ?? []).map((x) => x.path))
|
||||
const deployedFlowPaths = new Set((flows ?? []).map((x) => x.path))
|
||||
const deployedAppPaths = new Set([...(apps ?? []), ...(raw_apps ?? [])].map((x) => x.path))
|
||||
|
||||
const result: (TableScript | TableFlow | TableApp)[] = []
|
||||
for (const draft of listed) {
|
||||
const value = (draft.value ?? {}) as any
|
||||
const path = draft.path ?? value.path
|
||||
if (!path) continue
|
||||
const summary = value.summary ?? ''
|
||||
const time = draft.created_at ? new Date(draft.created_at).getTime() : 0
|
||||
const canWriteItem =
|
||||
canWrite(path, value.extra_perms ?? {}, $userStore) && !$userStore?.operator
|
||||
if (draft.typ === 'script') {
|
||||
if (deployedScriptPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
canWrite: canWriteItem,
|
||||
type: 'script',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableScript)
|
||||
} else if (draft.typ === 'flow') {
|
||||
if (deployedFlowPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
edited_at: draft.created_at,
|
||||
canWrite: canWriteItem,
|
||||
type: 'flow',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableFlow)
|
||||
} else if (draft.typ === 'app') {
|
||||
if (deployedAppPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
edited_at: draft.created_at,
|
||||
canWrite: canWriteItem,
|
||||
type: 'app',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableApp)
|
||||
}
|
||||
}
|
||||
draftOnlyItems = result
|
||||
}
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp,
|
||||
filterUserFolders: boolean,
|
||||
@@ -253,15 +330,19 @@
|
||||
if ($userStore && $workspaceStore) {
|
||||
;[archived, includeWithoutMain]
|
||||
untrack(() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
draftOnlyItems = []
|
||||
const loads = [loadScripts(includeWithoutMain), loadFlows()]
|
||||
if (!archived) {
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
loads.push(loadApps(), loadRawApps())
|
||||
} else {
|
||||
apps = []
|
||||
raw_apps = []
|
||||
}
|
||||
// Load draft-only (never-deployed) items after the deployed lists
|
||||
// resolve so we can dedupe against them. Archived view skips them.
|
||||
if (!archived) {
|
||||
Promise.all(loads).then(() => loadDraftOnlyItems())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -289,7 +370,10 @@
|
||||
...x,
|
||||
type: 'raw_app' as 'raw_app',
|
||||
time: new Date(x.edited_at).getTime()
|
||||
}))
|
||||
})),
|
||||
// Never-deployed items (draft table only); already carry `type` and
|
||||
// `time` and link to the editor via `draft_only`.
|
||||
...draftOnlyItems.map((x) => ({ ...x, time: x.time ?? 0 }))
|
||||
].sort((a, b) =>
|
||||
a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1
|
||||
)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
|
||||
// DraftService is hit (debounced) by UserDraftDbService; stub it so tests never
|
||||
// touch the network and we can assert the calls.
|
||||
const createDraft = vi.fn(async () => 'ok')
|
||||
const deleteDraft = vi.fn(async () => 'ok')
|
||||
vi.mock('./gen', () => ({
|
||||
DraftService: {
|
||||
createDraft: (...args: any[]) => createDraft(...args),
|
||||
deleteDraft: (...args: any[]) => deleteDraft(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
UserDraft,
|
||||
normalizeForCompare,
|
||||
localDraftDiffers,
|
||||
__resetUserDraftForTesting
|
||||
} from './userDraft.svelte'
|
||||
import { UserDraftDbService } from './userDraftDbService'
|
||||
|
||||
const WS = 'test-ws'
|
||||
const opts = { workspace: WS }
|
||||
|
||||
beforeEach(() => {
|
||||
__resetUserDraftForTesting()
|
||||
createDraft.mockClear()
|
||||
deleteDraft.mockClear()
|
||||
})
|
||||
|
||||
describe('normalizeForCompare', () => {
|
||||
it('drops undefined-valued keys via JSON round-trip', () => {
|
||||
expect(normalizeForCompare({ a: 1, b: undefined })).toEqual({ a: 1 })
|
||||
})
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(normalizeForCompare(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('localDraftDiffers', () => {
|
||||
it('is false when there is no local draft', () => {
|
||||
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
|
||||
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
|
||||
})
|
||||
it('treats {a: undefined} and {} as equal (normalized)', () => {
|
||||
expect(localDraftDiffers({ a: undefined } as any, {} as any)).toBe(false)
|
||||
})
|
||||
it('is true when the values meaningfully differ', () => {
|
||||
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft in-memory store (no live handle)', () => {
|
||||
it('save then get returns the value without a mounted handle', () => {
|
||||
UserDraft.save('resource', 'f/r/db', { host: 'x' }, opts)
|
||||
expect(UserDraft.get('resource', 'f/r/db', opts)).toEqual({ host: 'x' })
|
||||
expect(UserDraft.has('resource', 'f/r/db', opts)).toBe(true)
|
||||
})
|
||||
|
||||
it('remove clears the value', () => {
|
||||
UserDraft.save('resource', 'f/r/db', { host: 'x' }, opts)
|
||||
UserDraft.remove('resource', 'f/r/db', opts)
|
||||
expect(UserDraft.get('resource', 'f/r/db', opts)).toBeUndefined()
|
||||
expect(UserDraft.has('resource', 'f/r/db', opts)).toBe(false)
|
||||
})
|
||||
|
||||
it('discard resets the value to the fallback', () => {
|
||||
UserDraft.save('resource', 'f/r/db', { host: 'edited' }, opts)
|
||||
UserDraft.discard('resource', 'f/r/db', { host: 'deployed' }, opts)
|
||||
expect(UserDraft.get('resource', 'f/r/db', opts)).toEqual({ host: 'deployed' })
|
||||
})
|
||||
|
||||
it('list reflects in-memory drafts', () => {
|
||||
UserDraft.save('resource', 'f/r/a', { v: 1 }, opts)
|
||||
UserDraft.save('variable', 'f/v/b', { v: 2 }, opts)
|
||||
const list = UserDraft.list(opts)
|
||||
expect(list.map((d) => d.path).sort()).toEqual(['f/r/a', 'f/v/b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraftDbService (debounced persistence)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('persists DB-backed kinds via createDraft after debounce', async () => {
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/x', content: { a: 1 } })
|
||||
expect(createDraft).not.toHaveBeenCalled() // debounced
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(createDraft).toHaveBeenCalledTimes(1)
|
||||
expect(createDraft.mock.calls[0][0]).toMatchObject({
|
||||
workspace: WS,
|
||||
requestBody: { path: 'f/s/x', typ: 'script', value: { a: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('maps raw_app to the app draft typ', async () => {
|
||||
UserDraftDbService.save({
|
||||
workspace: WS,
|
||||
itemKind: 'raw_app',
|
||||
path: 'f/a/x',
|
||||
content: { a: 1 }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(createDraft.mock.calls[0][0].requestBody.typ).toBe('app')
|
||||
})
|
||||
|
||||
it('deletes the draft when content is undefined', async () => {
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'flow', path: 'f/f/x', content: undefined })
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(deleteDraft).toHaveBeenCalledTimes(1)
|
||||
expect(deleteDraft.mock.calls[0][0]).toMatchObject({
|
||||
workspace: WS,
|
||||
kind: 'flow',
|
||||
path: 'f/f/x'
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op for kinds without a DB draft', async () => {
|
||||
UserDraftDbService.save({
|
||||
workspace: WS,
|
||||
itemKind: 'resource',
|
||||
path: 'f/r/x',
|
||||
content: { a: 1 }
|
||||
})
|
||||
UserDraftDbService.save({
|
||||
workspace: WS,
|
||||
itemKind: 'trigger_http',
|
||||
path: 'f/t/x',
|
||||
content: { a: 1 }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(createDraft).not.toHaveBeenCalled()
|
||||
expect(deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips brand-new items at the empty path', async () => {
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: '', content: { a: 1 } })
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(createDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces a burst of edits into the latest write', async () => {
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 1 } })
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 2 } })
|
||||
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 3 } })
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
expect(createDraft).toHaveBeenCalledTimes(1)
|
||||
expect(createDraft.mock.calls[0][0].requestBody.value).toEqual({ v: 3 })
|
||||
})
|
||||
})
|
||||
@@ -38,10 +38,43 @@
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
let app_w_draft: AppWithLastVersionWDraft
|
||||
try {
|
||||
app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed app at this path: it may be a never-deployed item that
|
||||
// lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadAppToken) return
|
||||
if (!draft?.value) throw e
|
||||
// The app draft value has shape { value, path, summary, policy, custom_path }.
|
||||
const dv = draft.value as {
|
||||
value: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
custom_path?: string
|
||||
}
|
||||
// No deployed version → savedApp mirrors the draft, so the builder
|
||||
// treats a deploy as "create".
|
||||
savedApp = {
|
||||
summary: dv.summary,
|
||||
value: dv.value as App,
|
||||
path: dv.path,
|
||||
policy: dv.policy,
|
||||
draft: dv,
|
||||
custom_path: dv.custom_path
|
||||
}
|
||||
app = { ...dv } as AppWithLastVersion & { value: any }
|
||||
return
|
||||
}
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft))
|
||||
savedApp = {
|
||||
|
||||
@@ -126,10 +126,59 @@
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
let app_w_draft: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
try {
|
||||
app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed app at this path: it may be a never-deployed item that
|
||||
// lives only in the draft table. Raw apps use draft typ 'app'.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadAppToken) return
|
||||
if (!draft?.value) throw e
|
||||
// The raw-app draft value shape is { value: { files, runnables, data },
|
||||
// summary, policy, custom_path, path } OR the older { files, runnables, ... }.
|
||||
// Mirror how the deployed path derives backendSource/backendBundle, but
|
||||
// source it from the fetched draft value instead.
|
||||
const backendSource: any = draft.value
|
||||
const value = backendSource.value ?? backendSource
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: value?.files ?? {},
|
||||
runnables: value?.runnables ?? {},
|
||||
data:
|
||||
value?.data ??
|
||||
(value?.datatables ? { ...DEFAULT_DATA, tables: value.datatables } : { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? '',
|
||||
policy: backendSource.policy,
|
||||
custom_path: backendSource.custom_path
|
||||
}
|
||||
// No deployed version → savedApp mirrors the draft, so the builder
|
||||
// treats a deploy as "create".
|
||||
savedApp = {
|
||||
summary: backendSource.summary,
|
||||
value: value as any,
|
||||
path: backendSource.path ?? page.params.path ?? '',
|
||||
policy: backendSource.policy,
|
||||
draft: backendSource,
|
||||
custom_path: backendSource.custom_path
|
||||
}
|
||||
// Seed the handle (no DB write-back) before populating the form so the
|
||||
// persist effect's first write matches and is skipped.
|
||||
draftHandle.setInitial(backendBundle)
|
||||
extractRawApp({
|
||||
value,
|
||||
summary: backendSource.summary,
|
||||
policy: backendSource.policy,
|
||||
path: backendSource.path ?? page.params.path ?? ''
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft))
|
||||
savedApp = {
|
||||
|
||||
@@ -117,45 +117,73 @@
|
||||
seeding = true
|
||||
let draftTriggersToApply: Trigger[] | undefined = undefined
|
||||
let applyPrimarySchedule = false
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
|
||||
let flow: Flow
|
||||
try {
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. Seeding (not assigning) avoids writing the
|
||||
// freshly-loaded value straight back to the DB.
|
||||
const flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
|
||||
flowHandle.setInitial(flow)
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. Seeding (not assigning) avoids writing the
|
||||
// freshly-loaded value straight back to the DB.
|
||||
flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
|
||||
flowHandle.setInitial(flow)
|
||||
|
||||
if (flowWithDraft.draft != undefined) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
if (flowWithDraft.draft != undefined) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
applyPrimarySchedule = true
|
||||
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
|
||||
}
|
||||
} catch (e) {
|
||||
// No deployed flow at this path (both getFlowLatestVersion and
|
||||
// getFlowByPathWithDraft 404): it may be a never-deployed item that
|
||||
// lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadFlowToken) return
|
||||
if (!draft?.value) throw e
|
||||
// No deployed version → savedFlow mirrors the draft (no version), so
|
||||
// the builder treats a deploy as "create".
|
||||
savedFlow = { ...(draft.value as Flow), draft: draft.value as Flow } as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
version = undefined
|
||||
flow = draft.value as Flow
|
||||
flowHandle.setInitial(flow)
|
||||
savedPrimarySchedule = (draft.value as any)?.['primary_schedule']
|
||||
applyPrimarySchedule = true
|
||||
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
|
||||
draftTriggersToApply = (draft.value as any)?.['draft_triggers']
|
||||
}
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type NewScript, type NewScriptWithDraft } from '$lib/gen'
|
||||
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
|
||||
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
@@ -118,10 +118,33 @@
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
scriptHandle.setInitial({ ...scriptByHash, parent_hash: hash, lock: undefined })
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
let scriptWithDraft: NewScriptWithDraft
|
||||
try {
|
||||
scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed script at this path: it may be a never-deployed item
|
||||
// that lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadScriptToken) return
|
||||
if (!draft?.value) throw e
|
||||
const draftVal = { ...(draft.value as EditableScript) }
|
||||
// No deployed version → savedScript mirrors the draft (no hash), so
|
||||
// the builder treats a deploy as "create".
|
||||
savedScript = { ...draftVal, draft: draftVal } as NewScriptWithDraft
|
||||
scriptHandle.setInitial(draftVal)
|
||||
if (scriptHandle.draft) applyBaseline(scriptHandle.draft)
|
||||
urlScriptSeed = undefined
|
||||
fullyLoaded = true
|
||||
renderEditor = true
|
||||
return
|
||||
}
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user