refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed

This commit is contained in:
Diego Imbert
2026-06-05 01:36:10 +02:00
parent 41001aa9fc
commit bb60641ff6
14 changed files with 297 additions and 459 deletions
@@ -5,7 +5,6 @@
import { createEventDispatcher, untrack } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
import ResourceForm from './ResourceForm.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
@@ -259,48 +258,24 @@
// draft (if any) sits in `.draft` as the editor's internal
// `ResourceState` shape — the editor reads it directly.
const savedDraftState = (r as any).draft as ResourceState | undefined
if (r.is_draft) {
notifyDraftLoaded({
workspace: ws,
itemKind: 'resource',
path: initialPath ?? '',
draftOnly: r.no_deployed,
onResetToDeployed: async () => {
const fresh = await ResourceService.getResource({
workspace: ws,
path: initialPath ?? ''
})
const deployed: ResourceState = {
path: fresh.path,
description: fresh.description ?? '',
args: (fresh.value ?? {}) as any,
labels: fresh.labels ?? undefined,
wsSpecific: fresh.ws_specific ?? false
}
fetchedResources[ws] = fresh
fetchedRev[ws] = fresh.edited_at
initialStates[ws] = structuredClone(deployed)
// Reset the live cell to the deployed state and drop the
// DB autosave. Setting the draft to `undefined` would
// blank the cell (and the editor) — `discard` reseeds it
// with the deployed baseline instead. The rev is re-recorded
// by the seeding effect on the user's next edit.
UserDraft.discard('resource', initialPath ?? '', deployed, { workspace: ws })
}
})
}
fetchedResources[ws] = r
fetchedRev[ws] = r.edited_at
// Translate the deployed wire shape into the editor's
// `ResourceState` shape. When a saved draft exists, use it
// directly — it's already a `ResourceState`.
const s: ResourceState = savedDraftState ?? {
// The deployed baseline, translated into the editor's
// `ResourceState` shape. Kept as the dirty-check reference
// so the "unsaved changes" banner compares draft-vs-deployed
// instead of loaded-vs-current — when a saved draft exists,
// the form opens with `draft != deployed` so the banner
// fires immediately, exactly as if the user had typed.
const deployedState: ResourceState = {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as any,
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
// What the editor opens with: the saved draft if present,
// otherwise the deployed.
const s: ResourceState = savedDraftState ?? deployedState
// Reconcile the local autosave with the backend before the
// handle is registered. If the backend moved on since the
// autosave was written (recorded rev != current rev) surface
@@ -330,7 +305,7 @@
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
initialStates[ws] = structuredClone(deployedState)
existedInitially[ws] = true
perWsUser[ws] = user
// Keep resource_type in sync for the base workspace (controls the schema)
@@ -80,6 +80,7 @@
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import LabelsInput from './LabelsInput.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
@@ -354,6 +355,7 @@
// new drafts). Resumed in the async `.finally` so language
// switches AFTER bootstrap (which also call `initContent`) sync
// normally.
console.log('Suspending autosave for script bootstrap', userDraftPath)
UserDraft.stopSync('script', userDraftPath)
if (template === 'wac_python') {
script.modules = {
@@ -1837,6 +1839,9 @@
onNavigate={(item) => onNavigate?.(item)}
/>
{/if}
{#if $workspaceStore}
<AutosaveIndicator workspace={$workspaceStore} itemKind="script" path={userDraftPath} />
{/if}
</div>
<!-- Separator -->
@@ -7,7 +7,6 @@
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { canWrite } from '$lib/utils'
import { Save } from 'lucide-svelte'
import VariableForm from './VariableForm.svelte'
@@ -172,44 +171,14 @@
// draft (if any) sits in `.draft` as the editor's internal
// `VariableState` shape — the editor reads it directly.
const savedDraftState = (v as any).draft as VariableState | undefined
if (v.is_draft) {
notifyDraftLoaded({
workspace: ws,
itemKind: 'variable',
path: p,
draftOnly: v.no_deployed,
onResetToDeployed: async () => {
const fresh = await VariableService.getVariable({
workspace: ws,
path: p,
decryptSecret: false
})
const deployed: VariableState = {
path: fresh.path,
variable: {
value: fresh.value ?? '',
is_secret: fresh.is_secret,
description: fresh.description ?? ''
},
labels: fresh.labels ?? undefined,
wsSpecific: fresh.ws_specific ?? false
}
fetchedRev[ws] = fresh.edited_at
initialStates[ws] = structuredClone(deployed)
// Reset the live cell to the deployed state and drop the
// DB autosave. Setting the draft to `undefined` would
// blank the cell (and the editor) — `discard` reseeds it
// with the deployed baseline instead. The rev is re-recorded
// by the seeding effect on the user's next edit.
UserDraft.discard('variable', editPath ?? '', deployed, { workspace: ws })
}
})
}
fetchedRev[ws] = v.edited_at
// Translate the deployed wire shape into the editor's
// `VariableState` shape. When a saved draft exists, use it
// directly — it's already a `VariableState`.
const s: VariableState = savedDraftState ?? {
// The deployed baseline, translated into the editor's
// `VariableState` shape. Kept as the dirty-check reference
// so the "unsaved changes" banner compares draft-vs-deployed
// instead of loaded-vs-current — when a saved draft exists,
// the form opens with `draft != deployed` so the banner
// fires immediately, exactly as if the user had typed.
const deployedState: VariableState = {
path: v.path,
variable: {
value: v.value ?? '',
@@ -219,6 +188,9 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
// What the editor opens with: the saved draft if present,
// otherwise the deployed.
const s: VariableState = savedDraftState ?? deployedState
// See ResourceEditor for the same pattern: a backend that
// moved on since the autosave was written → staleness modal;
// otherwise just a "showing your local autosave" toast with
@@ -237,7 +209,7 @@
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
initialStates[ws] = structuredClone(deployedState)
existedInitially[ws] = true
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
@@ -5,7 +5,6 @@
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
@@ -136,11 +135,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
const draftOverlay = await loadTrigger(defaultValues)
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
}
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
@@ -185,41 +185,28 @@
}
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
return undefined
}
try {
const s = await AzureTriggerService.getAzureTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
getDraft: true
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_azure',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load Azure trigger: ${error.body}`, true)
return undefined
}
}
@@ -15,7 +15,6 @@
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
@@ -132,12 +131,16 @@
edit = true
dirtyPath = false
dirtyLocalPart = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
// Form holds DEPLOYED here. Capture `originalConfig` as the
// deployed baseline so `hasChanged` (= current != originalConfig)
// fires whenever a draft exists, not only after the user edits.
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<EmailTrigger>)
if (!defaultConfig) {
// If the email trigger is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
}
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load email trigger: ${err}`, true)
@@ -207,40 +210,29 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
/**
* Apply the deployed config to the form, then return the saved-draft
* overlay (if any) so the caller can capture the deployed-only form
* state as `originalConfig` BEFORE applying the draft. See
* `NatsTriggerEditorInner` for the rationale.
*/
async function loadTrigger(
defaultConfig?: Partial<EmailTrigger>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Partial<EmailTrigger>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await EmailTriggerService.getEmailTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_email',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective: any = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await EmailTriggerService.getEmailTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
async function triggerScript(): Promise<void> {
@@ -5,7 +5,6 @@
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
@@ -138,11 +137,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
const draftOverlay = await loadTrigger(defaultValues)
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
}
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
@@ -189,42 +189,28 @@
}
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_gcp',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
}
return undefined
}
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
return undefined
}
}
@@ -23,7 +23,6 @@
generateRandomString,
sendUserToast
} from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import Section from '$lib/components/Section.svelte'
import { Loader2, Pipette, Plus } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
@@ -231,12 +230,13 @@
edit = true
dirtyPath = false
dirtyRoutePath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<HttpTrigger>)
if (!defaultConfig) {
// If the route is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
initialConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
}
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load route: ${err}`, true)
@@ -340,40 +340,24 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Partial<HttpTrigger>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Partial<HttpTrigger>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_http',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective: any = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
async function triggerScript(): Promise<void> {
@@ -9,7 +9,6 @@
import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2, RotateCcw } from 'lucide-svelte'
@@ -160,11 +159,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
@@ -246,39 +246,24 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_kafka',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
function getSaveCfg(): Record<string, any> {
@@ -8,7 +8,6 @@
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -156,11 +155,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
@@ -245,42 +245,28 @@
}
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
try {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await MqttTriggerService.getMqttTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_mqtt',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await MqttTriggerService.getMqttTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load mqtt trigger: ${error.body}`, true)
return undefined
}
}
@@ -8,7 +8,6 @@
import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -144,11 +143,18 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
// At this point the form holds the DEPLOYED config (or
// `defaultConfig` for new triggers). Capture `originalConfig`
// here so `hasChanged` (= `current != originalConfig`) compares
// against the deployed baseline; if a draft exists, applying
// the overlay below makes `current != originalConfig` fire
// the "unsaved changes" banner immediately.
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load nats trigger: ${err}`, true)
@@ -228,39 +234,32 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
/**
* Apply the deployed config to the form, then return the saved-draft
* overlay (if any) so the caller can capture the deployed-only form
* state as `originalConfig` BEFORE applying the draft. The
* "unsaved changes" banner compares `current` vs `originalConfig`,
* so capturing originalConfig from the deployed-only form makes the
* banner fire whenever a draft is present (instead of only after
* the user starts editing on top of the draft).
*/
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await NatsTriggerService.getNatsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_nats',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await NatsTriggerService.getNatsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
function getSaveCfg() {
@@ -15,7 +15,6 @@
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -251,11 +250,12 @@
relations = []
transaction_to_track = []
tab = 'basic'
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
@@ -368,52 +368,62 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
/**
* Apply the deployed config to the form (incl. publication fetch),
* then return the saved-draft overlay (already merged with the
* publication payload) so the caller can capture the deployed-only
* form state as `originalConfig` BEFORE applying the draft. See
* `NatsTriggerEditorInner.loadTrigger` for the broader rationale.
*
* Postgres-specific wrinkle: the publication payload is fetched from
* the resource — keyed by `postgres_resource_path` /
* `publication_name`, which the draft may have changed. Fetch once
* using the effective values so the overlay reflects the draft's
* publication, not the deployed one.
*/
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
if (defaultConfig?.publication) {
transaction_to_track = [...defaultConfig.publication.transaction_to_track]
relations = defaultConfig.publication.table_to_track ?? []
}
return
} else {
const s = await PostgresTriggerService.getPostgresTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_postgres',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed before the
// publication fetch — the draft might have changed
// `postgres_resource_path` / `publication_name`, which are the
// keys we look up the publication by.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
const publication_data = await PostgresTriggerService.getPostgresPublication({
path: effective.postgres_resource_path,
workspace: $workspaceStore!,
publication: effective.publication_name
})
loadTriggerConfig({ ...effective, publication: publication_data })
return undefined
}
const s = await PostgresTriggerService.getPostgresTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
// Fetch deployed publication and apply deployed config — this
// becomes the `originalConfig` baseline for the dirty check.
const deployedPublication = await PostgresTriggerService.getPostgresPublication({
path: deployedTrigger.postgres_resource_path,
workspace: $workspaceStore!,
publication: deployedTrigger.publication_name
})
loadTriggerConfig({ ...deployedTrigger, publication: deployedPublication })
if (!draftFromBackend) return undefined
// Draft may have changed the resource/publication keys; fetch
// the publication that matches the effective values so the
// overlay opens on the draft's view.
const effective = { ...deployedTrigger, ...draftFromBackend }
const effectivePublication =
effective.postgres_resource_path === deployedTrigger.postgres_resource_path &&
effective.publication_name === deployedTrigger.publication_name
? deployedPublication
: await PostgresTriggerService.getPostgresPublication({
path: effective.postgres_resource_path,
workspace: $workspaceStore!,
publication: effective.publication_name
})
return { ...effective, publication: effectivePublication }
}
function getCaptureConfig() {
@@ -25,7 +25,6 @@
} from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { base } from '$lib/base'
import Section from '$lib/components/Section.svelte'
import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte'
@@ -153,11 +152,15 @@
initialPath = ePath
itemKind = isFlow ? 'flow' : 'script'
path = defaultCfg?.path ?? ePath
await loadSchedule(defaultCfg)
const draftOverlay = await loadSchedule(defaultCfg)
edit = true
if (!defaultCfg) {
// Form holds DEPLOYED here. Capture `initialConfig` as the
// deployed baseline so the dirty check / unsaved banner
// fires whenever a saved draft exists.
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
}
if (draftOverlay) await loadScheduleCfg(draftOverlay)
await draftSync.maybeRestore()
} finally {
clearTimeout(loadingTimeout)
@@ -294,23 +297,10 @@
path: schedule_path,
getDraft
})
if (resp.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_schedule',
path: schedule_path,
draftOnly: resp.no_deployed,
onResetToDeployed: async () => {
await openNew(nis_flow, initial_script_path, defaultValues, schedule_path, {
getDraft: false
})
}
})
}
// The autosaved draft (when present) sits in `.draft` as the
// editor's saved Schedule shape. Layer it over the deployed
// at the field level so downstream `loadScheduleCfg` sees
// the editor's last-saved state.
// at the field level so the inline form assignments below
// see the editor's last-saved state.
const { draft: draftFromBackend, ...deployedSchedule } = resp as any
s = draftFromBackend
? ({ ...deployedSchedule, ...draftFromBackend } as Schedule)
@@ -477,41 +467,36 @@
}
}
/**
* Apply the deployed schedule config to the form, then return the
* saved-draft overlay (if any) so the caller can capture the
* deployed-only form state as `initialConfig` BEFORE applying the
* draft. The "unsaved changes" banner compares `current` vs
* `initialConfig` (via `useTriggerDraftSync.deployed`), so capturing
* from the deployed-only form makes the banner fire whenever a
* draft is present.
*/
async function loadSchedule(
defaultCfg?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
if (!defaultCfg) {
try {
const s = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_schedule',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadSchedule(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed so
// `loadScheduleCfg` sees the editor's last-saved fields.
const { draft: draftFromBackend, ...deployedSchedule } = s as any
const effectiveSchedule = draftFromBackend
? { ...deployedSchedule, ...draftFromBackend }
: deployedSchedule
await loadScheduleCfg(effectiveSchedule)
} catch (err) {
sendUserToast(`Could not load schedule: ${err}`, true)
}
} else {
defaultCfg?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultCfg) {
await loadScheduleCfg(defaultCfg)
return undefined
}
try {
const s = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedSchedule } = s as any
await loadScheduleCfg(deployedSchedule)
return draftFromBackend
? ({ ...deployedSchedule, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (err) {
sendUserToast(`Could not load schedule: ${err}`, true)
return undefined
}
}
@@ -5,7 +5,6 @@
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
@@ -139,14 +138,16 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
// Snapshot the *backend* config as the baseline before overlaying
// any local autosave, so hasChanged / onConfigChange correctly
// flag the local edits as unsaved changes.
const draftOverlay = await loadTrigger(defaultConfig)
// Snapshot the *deployed* config as the baseline before
// overlaying the saved draft, so hasChanged compares
// draft-vs-deployed and the banner fires whenever a draft
// exists.
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
@@ -222,42 +223,28 @@
}
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
try {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await SqsTriggerService.getSqsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_sqs',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await SqsTriggerService.getSqsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load SQS trigger: ${error.body}`, true)
return undefined
}
}
@@ -21,7 +21,6 @@
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { notifyDraftLoaded } from '$lib/userDraftToast'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2, X, Plus } from 'lucide-svelte'
@@ -188,11 +187,12 @@
edit = true
dirtyPath = false
dirtyUrl = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load websocket trigger: ${err}`, true)
@@ -303,39 +303,24 @@
}
}
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>,
opts: { getDraft?: boolean } = {}
): Promise<void> {
const getDraft = opts.getDraft ?? true
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await WebsocketTriggerService.getWebsocketTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft
})
if (s?.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
itemKind: 'trigger_websocket',
path: initialPath,
draftOnly: s.no_deployed,
onResetToDeployed: async () => {
await loadTrigger(undefined, { getDraft: false })
}
})
}
// Layer the saved draft (if any) over the deployed at the field
// level so `loadTriggerConfig` sees the editor's last-saved state.
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
const effective = draftFromBackend
? { ...deployedTrigger, ...draftFromBackend }
: deployedTrigger
loadTriggerConfig(effective)
return undefined
}
const s = await WebsocketTriggerService.getWebsocketTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
let initialMessageRunnableSchemas: Record<string, Schema> = $state({})