diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 933eee5f71..7fed7ace78 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 4efb5e9a03..12605ddd9d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -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} + + {/if} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index fe052ac3bd..297dc7ef97 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 34e0b42d5a..4ec9728155 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } catch (error) { sendUserToast(`Could not load Azure trigger: ${error.body}`, true) + return undefined } } diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 157da3ffa5..8202f19a1a 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -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) 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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Partial + ): Promise | 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) + : undefined } async function triggerScript(): Promise { diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 5fd984d54e..2afcb643b2 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined + } catch (error) { + sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true) + return undefined } } diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 8a8a332d41..38ac33b729 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -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) 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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Partial + ): Promise | 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) + : undefined } async function triggerScript(): Promise { diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 10d24d8e68..95fac48422 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } function getSaveCfg(): Record { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index eed0747859..a16bc802a6 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } catch (error) { sendUserToast(`Could not load mqtt trigger: ${error.body}`, true) + return undefined } } diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index bb6adf9bac..88d6419069 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } function getSaveCfg() { diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index e1f6c7516e..f421dcc31c 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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() { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 2117264f23..add6fc1ff2 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - 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 + ): Promise | 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) + : undefined + } catch (err) { + sendUserToast(`Could not load schedule: ${err}`, true) + return undefined } } diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index d686a22d55..b6d0153375 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } catch (error) { sendUserToast(`Could not load SQS trigger: ${error.body}`, true) + return undefined } } diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 0749a7a5f6..50e6c4a9e3 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -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, - opts: { getDraft?: boolean } = {} - ): Promise { - const getDraft = opts.getDraft ?? true + defaultConfig?: Record + ): Promise | 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) + : undefined } let initialMessageRunnableSchemas: Record = $state({})