diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 6bb72584b5..b6e23a9a77 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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} @@ -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 { + let listed: Awaited> + 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 ) diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts new file mode 100644 index 0000000000..a5bbd7cda7 --- /dev/null +++ b/frontend/src/lib/userDraft.test.ts @@ -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 }) + }) +}) diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index b6d1858b6f..1cb73f617f 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -38,10 +38,43 @@ let loadAppToken = 0 async function loadApp(): Promise { 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 = { diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index b58b50b408..d058545b37 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -126,10 +126,59 @@ let loadAppToken = 0 async function loadApp(): Promise { const tok = ++loadAppToken - const app_w_draft = await AppService.getAppByPathWithDraft({ - path: page.params.path ?? '', - workspace: $workspaceStore! - }) + let app_w_draft: Awaited> + 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 = { diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index f5fb459453..e2753d2b75 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -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) diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 2ebad0c63e..d6f5543310 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -1,5 +1,5 @@