refactor: back editor trigger drafts with the per-user draft table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-29 19:53:54 +02:00
co-authored by Claude Opus 5
parent 33d28456cc
commit 20226a15b6
60 changed files with 1012 additions and 545 deletions
@@ -234,3 +234,110 @@ async fn test_schedule_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// The flow/script editor lists its own triggers with `path` + `is_flow`, and
/// needs its undeployed ones back. Draft-only rows are synthesized from the
/// `draft` table, which no SQL filter reaches, so they are matched on the draft
/// value's own `script_path`/`is_flow` — and reported under the draft row's key,
/// with `draft_path` carrying the path the user renamed it to.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_list_draft_only_schedules_scoped_to_runnable(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let list_url = format!("http://localhost:{port}/api/w/test-workspace/schedules/list");
// Two drafts on the target flow (one of them renamed away from its key) and
// one on a different runnable.
for (key, value) in [
(
"u/test-user/target_schedule",
json!({
"path": "u/test-user/target_schedule",
"schedule": "0 0 * * * *",
"timezone": "UTC",
"script_path": "f/test/target_flow",
"is_flow": true,
}),
),
(
"u/test-user/renamed_key",
json!({
"path": "u/test-user/nicer_name",
"schedule": "0 0 * * * *",
"timezone": "UTC",
"script_path": "f/test/target_flow",
"is_flow": true,
}),
),
(
"u/test-user/other_schedule",
json!({
"path": "u/test-user/other_schedule",
"schedule": "0 0 * * * *",
"timezone": "UTC",
"script_path": "f/test/other_flow",
"is_flow": true,
}),
),
] {
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/trigger_schedule/{key}"
)))
.json(&json!({ "value": value }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "seeding draft {key}");
}
let rows: Vec<serde_json::Value> = authed(client().get(&list_url))
.query(&[
("path", "f/test/target_flow"),
("is_flow", "true"),
("include_draft_only", "true"),
])
.send()
.await
.unwrap()
.json()
.await?;
let mut paths: Vec<&str> = rows
.iter()
.map(|r| r["path"].as_str().unwrap())
.collect::<Vec<_>>();
paths.sort();
assert_eq!(
paths,
vec!["u/test-user/renamed_key", "u/test-user/target_schedule"],
"only this runnable's drafts, keyed by their draft row"
);
let renamed = rows
.iter()
.find(|r| r["path"] == "u/test-user/renamed_key")
.unwrap();
assert_eq!(renamed["draft_path"], "u/test-user/nicer_name");
assert_eq!(renamed["draft_only"], true);
let unrenamed = rows
.iter()
.find(|r| r["path"] == "u/test-user/target_schedule")
.unwrap();
assert!(
unrenamed.get("draft_path").is_none(),
"no draft_path when the key already is the intended path"
);
// Without the flag the same query stays deployed-only.
let rows: Vec<serde_json::Value> = authed(client().get(&list_url))
.query(&[("path", "f/test/target_flow"), ("is_flow", "true")])
.send()
.await
.unwrap()
.json()
.await?;
assert!(rows.is_empty(), "drafts must not leak without the flag");
Ok(())
}
+21 -11
View File
@@ -28,8 +28,8 @@ use windmill_common::{
error::{Error, JsonResult, Result},
schedule::Schedule,
user_drafts::{
delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only,
UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
delete_all_drafts_for_path, draft_targets_runnable, fetch_draft_only_list_rows,
overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
@@ -732,6 +732,13 @@ pub struct ScheduleLight {
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub inherited_labels: Option<Vec<String>>,
/// The path a synthesized draft-only row will deploy to, when the user
/// renamed it away from the `draft` row's key. `path` stays the key — it is
/// what get/update/delete address — so a renamed draft can still be opened.
/// Same split as flows and apps. `None` when they agree.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
async fn list_schedule(
authed: ApiAuthed,
@@ -832,8 +839,6 @@ async fn list_schedule(
if lsq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& offset == 0
&& lsq.path.is_none()
&& lsq.is_flow.is_none()
&& lsq.args.is_none()
&& lsq.path_start.is_none()
&& lsq.schedule_path.is_none()
@@ -853,15 +858,19 @@ async fn list_schedule(
for row in draft_only_rows {
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
// Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? }
let path = v
.get("path")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if path.is_empty() {
if !draft_targets_runnable(&v, lsq.path.as_deref(), lsq.is_flow) {
continue;
}
// Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? }
// The `draft` row's key addresses the schedule; the value's `path` is
// only where it will deploy. Reporting the key as `path` is what lets
// a renamed draft still be opened, updated and deleted.
let intended_path = v.get("path").and_then(|s| s.as_str()).unwrap_or("");
if intended_path.is_empty() {
continue;
}
let draft_path = (intended_path != row.path).then(|| intended_path.to_string());
let path = row.path.clone();
let schedule = v
.get("schedule")
.and_then(|x| x.as_str())
@@ -909,6 +918,7 @@ async fn list_schedule(
draft_only: Some(true),
// Synthesized rows are the authed user's draft.
is_draft: Some(true),
draft_path,
});
}
}
+18
View File
@@ -23918,6 +23918,10 @@ components:
so the home page can render a "Draft" badge. Gated to
non-operators + page 0 + no narrowing filters on the backend so
picker callers stay deployed-only and pagination stays clean.
On the trigger and schedule list endpoints the `path`/`is_flow`
pair is exempt from that rule: it narrows to a runnable, and the
appended rows are matched on the draft's own `script_path`/`is_flow`,
so a flow/script editor can list its undeployed triggers.
schema:
type: boolean
Id:
@@ -27465,6 +27469,13 @@ components:
(over a deployed row or a synthesized draft-only row).
Frontend appends a `*` to the displayed name.
type: boolean
draft_path:
description: |
Where a synthesized draft-only row will deploy to, when it was
renamed away from the `draft` row's key. `path` stays the key —
what get/update/delete address — so a renamed draft is still
addressable. Absent when the two agree.
type: string
inherited_labels:
type: array
items:
@@ -27804,6 +27815,13 @@ components:
(over a deployed row or a synthesized draft-only row).
Frontend appends a `*` to the displayed name.
type: boolean
draft_path:
description: |
Where a synthesized draft-only row will deploy to, when it was
renamed away from the `draft` row's key. `path` stays the key —
what get/update/delete address — so a renamed draft is still
addressable. Absent when the two agree.
type: string
required:
- path
- script_path
@@ -398,6 +398,35 @@ pub async fn fetch_draft_only_list_rows(
Ok(rows)
}
/// Whether a trigger draft's JSON targets the runnable a list query narrows to.
/// The trigger list endpoints narrow by `(path, is_flow)` — meaning the
/// *runnable* the trigger fires, matched in SQL against `script_path`/`is_flow`
/// on deployed rows. Synthesized draft-only rows have no SQL to filter them, so
/// the same pair is read out of the draft value here. A query that narrows on
/// neither matches every draft.
pub fn draft_targets_runnable(
value: &serde_json::Value,
script_path: Option<&str>,
is_flow: Option<bool>,
) -> bool {
if let Some(p) = script_path {
if value.get("script_path").and_then(|x| x.as_str()) != Some(p) {
return false;
}
}
if let Some(f) = is_flow {
if value
.get("is_flow")
.and_then(|x| x.as_bool())
.unwrap_or(false)
!= f
{
return false;
}
}
true
}
/// The get-by-path draft choreography, shared by every entity's "get by path"
/// route. Given the deployed entity as an `Option` (caller maps its own "not
/// found" to `None`):
+28 -12
View File
@@ -17,8 +17,9 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
user_drafts::{
delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows,
overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
delete_all_drafts_for_path, delete_own_draft_for_path, draft_targets_runnable,
fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay,
WithDraftQuery,
},
utils::{paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
@@ -599,21 +600,28 @@ async fn list_triggers<T: TriggerCrud>(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
Query(query): Query<StandardTriggerQuery>,
) -> JsonResult<Vec<T::Trigger>> {
) -> JsonResult<Vec<serde_json::Value>> {
let mut tx = user_db.begin(&authed).await?;
let mut triggers = handler
let deployed = handler
.list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email))
.await?;
tx.commit().await?;
// Serialized rather than kept as `T::Trigger`: the synthesized draft rows
// below are the editor's config shape, which does NOT round-trip through
// `T::TriggerConfig` — required fields the editor never writes (`route_path_key`
// on HTTP) and fields a half-configured new trigger hasn't got yet would make
// `from_value` fail, silently hiding the draft. The response body is the same
// either way, so the merge happens on JSON.
let mut triggers = deployed
.into_iter()
.map(|t| serde_json::to_value(t).map_err(Error::from))
.collect::<Result<Vec<serde_json::Value>>>()?;
// Append the authed user's draft-only triggers of this kind; see scripts.rs.
// Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind
// config can deviate, so drop a row on deserialize failure rather than fail the list.
if query.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& query.page.unwrap_or(0) == 0
&& query.path.is_none()
&& query.is_flow.is_none()
&& query.path_start.is_none()
&& query.label.is_none()
{
@@ -631,6 +639,9 @@ async fn list_triggers<T: TriggerCrud>(
Ok(v) => v,
Err(_) => continue,
};
if !draft_targets_runnable(&v, query.path.as_deref(), query.is_flow) {
continue;
}
let serde_json::Value::Object(mut map) = v else {
continue;
};
@@ -657,15 +668,20 @@ async fn list_triggers<T: TriggerCrud>(
map.insert("draft_only".into(), serde_json::Value::Bool(true));
// Synthesized rows are the authed user's draft.
map.insert("is_draft".into(), serde_json::Value::Bool(true));
match serde_json::from_value::<T::Trigger>(serde_json::Value::Object(map)) {
Ok(t) => triggers.push(t),
Err(_) => continue,
// The `draft` row's key addresses the trigger; the value's `path` is
// only where it will deploy. Surfacing the key as `path` is what lets
// a renamed draft still be opened, updated and deleted.
if let Some(intended) = map.insert("path".into(), row.path.clone().into()) {
if intended.as_str() != Some(row.path.as_str()) {
map.insert("draft_path".into(), intended);
}
}
triggers.push(serde_json::Value::Object(map));
}
}
let allowed = build_scope_path_predicate(&authed, T::scope_domain_name(), "read");
triggers.retain(|t| allowed(t.trigger_path()));
triggers.retain(|t| allowed(t.get("path").and_then(|p| p.as_str()).unwrap_or("")));
Ok(Json(triggers))
}
+12 -2
View File
@@ -28,8 +28,11 @@ pub struct StandardTriggerQuery {
pub path_start: Option<String>,
pub label: Option<String>,
/// When true, append per-user draft rows whose path has no
/// deployed trigger of this kind. Same gate as scripts/flows/apps:
/// non-operators, offset 0, no narrowing filters.
/// deployed trigger of this kind. Gated to non-operators at offset 0
/// with no `path_start`/`label` filter. `path`/`is_flow` ARE honored —
/// they narrow to a runnable, and the appended rows are filtered on the
/// draft's own `script_path`/`is_flow` so the flow/script editor can list
/// its undeployed triggers.
pub include_draft_only: Option<bool>,
}
@@ -62,6 +65,13 @@ pub struct BaseTrigger {
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub is_draft: Option<bool>,
/// The path a synthesized draft-only row will deploy to, when the user
/// renamed it away from the `draft` row's key. `path` stays the key — it
/// is what get/update/delete address — so a renamed draft can still be
/// opened. Same split as flows and apps. `None` when they agree.
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub draft_path: Option<String>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
+3 -1
View File
@@ -88,6 +88,7 @@
import { ModulesTestStates } from './modulesTest.svelte'
import type { GraphModuleState } from './graph'
import { setCopilotInfo } from '$lib/aiStore'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
let {
initial = undefined
@@ -679,7 +680,8 @@
triggersCount: triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
triggersState: new Triggers(),
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
let pathStore = writable('')
+65 -52
View File
@@ -3,7 +3,6 @@
FlowService,
type Flow,
type PathScript,
type OpenFlow,
type InputTransform,
type TriggersCount,
CaptureService,
@@ -96,7 +95,13 @@
import DeployButton from './DeployButton.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { Trigger } from './triggers/utils'
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import {
deployTriggers,
handleSelectTriggerFromKind,
repointTriggerDrafts,
triggerHasPendingChanges
} from './triggers/utils'
import type { TriggerDraftTarget } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
@@ -132,7 +137,6 @@
savedPrimarySchedule = undefined,
version = undefined,
draftBaseVersion = undefined,
draftTriggersFromUrl = undefined,
selectedTriggerIndexFromUrl = undefined,
children,
loadedFromHistoryFromUrl,
@@ -246,8 +250,7 @@
// `flowStore.val.path` doesn't track those edits, so without this the
// rename wouldn't show up in the diff and the unsaved-changes warning
// wouldn't fire when leaving with a pending rename.
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
path: $pathStore
}
}
}
@@ -289,13 +292,23 @@
primaryScheduleStore.set(schedule)
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
// Trigger drafts carry the runnable's path in `script_path`. That path stays
// editable until the first deploy, so follow renames here rather than only at
// deploy time — a draft deployed on its own (triggers page, Review & Deploy)
// reads what is stored. Debounced: the path field rewrites on every keystroke.
let repointTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const target = triggerDraftTarget()
if (!target.runnablePath || !target.workspace) return
clearTimeout(repointTimer)
repointTimer = setTimeout(
() => void repointTriggerDrafts($state.snapshot(triggersState.triggers), target),
800
)
return () => clearTimeout(repointTimer)
})
export function setSelectedTriggerIndex(index: number | undefined) {
triggersState.selectedTriggerIndex = index
@@ -393,7 +406,7 @@
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
const draftTriggers = triggersState.triggers.filter(triggerHasPendingChanges)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
@@ -474,27 +487,7 @@
},
runnableKind: 'flow'
})
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
)
}
} else {
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
initialPath
)
}
await FlowService.updateFlow({
workspace: opWorkspace!,
path: initialPath,
@@ -516,18 +509,31 @@
})
}
// Triggers deploy after the flow, never before: their `script_path`
// points at the path this deploy just created or renamed to.
if (triggersToDeploy?.length) {
const failed = await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
)
for (const { trigger, error } of failed) {
sendUserToast(`Could not deploy ${trigger.type} trigger: ${error}`, true)
}
}
// New/updated path now exists server-side — drop the autocomplete
// cache so it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(opWorkspace!)
const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & {
draft_triggers: Trigger[]
}
savedFlow = {
...structuredClone($state.snapshot(newSavedFlow)),
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore
} as Flow
setDraftTriggers([])
await loadTriggers()
loadingSave = false
onDeploy?.({ path: $pathStore })
} catch (err) {
@@ -782,32 +788,41 @@
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(untrack(() => draftTriggersFromUrl) ?? [])
{ type: 'default_email', path: '', isDraft: false }
],
untrack(() => selectedTriggerIndexFromUrl)
)
)
const triggerDraftTarget = (): TriggerDraftTarget => ({
runnablePath: $pathStore,
isFlow: true,
workspace: opWorkspace
})
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll,
showCaptureHint,
triggersState
triggersState,
draftTarget: triggerDraftTarget
})
export async function loadTriggers() {
if (initialPath == '') return
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: opWorkspace!,
path: initialPath
})
// An undeployed flow has no deployed triggers to count, but it can already
// have drafted ones — fetch against the path they were drafted at.
if (initialPath != '') {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: opWorkspace!,
path: initialPath
})
}
// Initialize triggers using utility function
await triggersState.fetchTriggers(
triggersCount,
opWorkspace,
initialPath,
initialPath || $pathStore,
true,
$primaryScheduleStore,
$userStore
@@ -947,7 +962,6 @@
async function openDiffDrawer() {
if (!savedFlow) return
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
diffDrawer?.openDrawer()
const currentFlow = flowStore.val
diffDrawer?.setDiff({
@@ -955,8 +969,7 @@
deployed: deployedValue ?? savedFlow,
current: {
...currentFlow,
path: $pathStore,
draft_triggers: currentDraftTriggers
path: $pathStore
}
})
}
@@ -1268,7 +1281,7 @@
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
draftTriggers={triggersState.triggers.filter(triggerHasPendingChanges)}
isFlow={true}
on:canceled={() => {
draftTriggersModalOpen = false
@@ -1385,7 +1398,7 @@
unifiedSize={headerBtnSize}
on:openTriggers={(e) => {
select('Trigger')
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
handleSelectTriggerFromKind(triggersState, triggersCount, triggerDraftTarget(), e.detail.kind)
captureOn.set(true)
showCaptureHint.set(true)
}}
@@ -14,6 +14,7 @@
import { untrack } from 'svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import { linkedToolsScope } from './flows/linkedAgentToolsStore.svelte'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
interface Props {
flow: {
@@ -61,7 +62,8 @@
triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
triggersState: new Triggers(),
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
}
@@ -13,9 +13,9 @@
...restProps
} = $props()
function openScheduleEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
scheduleEditor?.openNew(isFlow, path, defaultValues)
function openScheduleEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
scheduleEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -24,7 +24,7 @@
onMount(() => {
selectedTrigger?.type === 'schedule' &&
scheduleEditor &&
openScheduleEditor(isFlow, selectedTrigger.isDraft ?? false)
openScheduleEditor(isFlow)
})
</script>
@@ -35,7 +35,6 @@
emptyString,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
@@ -97,7 +96,14 @@
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import {
type Trigger,
deployTriggers,
handleSelectTriggerFromKind,
repointTriggerDrafts,
triggerHasPendingChanges
} from './triggers/utils'
import type { TriggerDraftTarget } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
@@ -153,8 +159,7 @@
return {
savedValue: savedScript,
modifiedValue: {
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
...script
}
}
}
@@ -268,13 +273,21 @@
loadTriggers()
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
// Trigger drafts carry the runnable's path in `script_path`. That path stays
// editable until the first deploy, so follow renames here rather than only at
// deploy time — a draft deployed on its own (triggers page, Review & Deploy)
// reads what is stored. Debounced: the path field rewrites on every keystroke.
let repointTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const target = triggerDraftTarget()
if (!target.runnablePath || !target.workspace) return
clearTimeout(repointTimer)
repointTimer = setTimeout(
() => void repointTriggerDrafts($state.snapshot(triggersState.triggers), target),
800
)
return () => clearTimeout(repointTimer)
})
onMount(() => {
if (functionExports) {
@@ -295,7 +308,7 @@
}
})
async function loadTriggers() {
export async function loadTriggers() {
if (!initialPath) {
return
}
@@ -318,18 +331,24 @@
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
{ type: 'default_email', path: '', isDraft: false }
])
)
const captureOn = writable<boolean | undefined>(undefined)
const showCaptureHint = writable<boolean | undefined>(undefined)
const triggerDraftTarget = (): TriggerDraftTarget => ({
runnablePath: script.path,
isFlow: false,
workspace: opWorkspace
})
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll,
showCaptureHint: showCaptureHint,
triggersState
triggersState,
draftTarget: triggerDraftTarget
})
const enterpriseLangs = ['mssql', 'oracledb']
@@ -600,7 +619,7 @@
): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
const draftTriggers = triggersState.triggers.filter(triggerHasPendingChanges)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
@@ -700,20 +719,24 @@
})
}
if (triggersToDeploy) {
await deployTriggers(
// Triggers deploy after the script, never before: their `script_path`
// points at the path this deploy just created or renamed to.
if (triggersToDeploy?.length) {
const failed = await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
script.path,
true
false
)
for (const { trigger, error } of failed) {
sendUserToast(`Could not deploy ${trigger.type} trigger: ${error}`, true)
}
}
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript))
setDraftTriggers([])
savedScript = structuredClone($state.snapshot(script))
await loadTriggers()
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
@@ -781,8 +804,6 @@
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const deployed = deployedValue ?? savedScript
// `script` comes from the full DB row (since #9351), so it carries `lock`/`extra_perms`
// while the deployed side is trimmed in `syncWithDeployed` — null them here to match
@@ -791,8 +812,7 @@
const current = {
...script,
lock: undefined,
extra_perms: undefined,
draft_triggers: currentDraftTriggers
extra_perms: undefined
}
if (current.assets && !current.assets.length) delete current.assets
@@ -942,7 +962,7 @@
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
handleSelectTriggerFromKind(triggersState, triggersCount, triggerDraftTarget(), ev.detail.kind)
captureOn.set(true)
}
@@ -1018,16 +1038,6 @@
})
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
// localStorage autosave that explicitly snapshotted triggersState; the
// switch to a unified UserDraft handle dropped that bridge.
$effect(() => {
readFieldsRecursively(triggersState.triggers)
script.draft_triggers = triggersState.getDraftTriggersSnapshot()
})
loadWorkerTags()
async function loadWorkerTags() {
if (usesLocalTags) {
@@ -1054,7 +1064,7 @@
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
draftTriggers={triggersState.triggers.filter(triggerHasPendingChanges)}
on:canceled={() => {
draftTriggersModalOpen = false
}}
@@ -5,7 +5,7 @@
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { triggerDraftKind, triggerIconMap } from '$lib/components/triggers/utils'
import { Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
@@ -27,26 +27,23 @@
confirmed: { selectedTriggers: Trigger[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
/** Identity across the list. Draft-backed triggers are keyed by their draft
* path; the kinds without a draft row have no path until they deploy, so they
* fall back to the client id assigned when they were added. */
function triggerKey(trigger: Trigger): string {
return `${trigger.type}:${trigger.path ?? trigger.id ?? ''}`
}
function toggleTrigger(trigger: Trigger, selected: 'skip' | 'deploy') {
if (selected === 'skip') {
selectedTriggers = selectedTriggers.filter((t) => triggerKey(t) !== triggerKey(trigger))
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
return triggers.some((t) => triggerKey(t) === triggerKey(trigger))
}
function checkSavePermissions(trigger: Trigger) {
@@ -57,7 +54,10 @@
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
// Only the kinds without a draft row carry their config here. For the rest
// it lives server-side, where an incomplete one surfaces as a deploy error —
// non-destructive, since a failed deploy leaves the draft in place.
const invalidConfig = !triggerDraftKind(trigger.type) && !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
@@ -70,7 +70,7 @@
<ConfirmationModal
{open}
title="Draft triggers detected !"
title="Undeployed trigger changes"
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
@@ -79,8 +79,7 @@
>
<div class="flex flex-col w-full gap-8 pb-4">
<div class="text-secondary text-sm">
{`${isFlow ? 'Your flow' : 'Your script'} has draft triggers. Select which draft triggers to deploy with the ${isFlow ? 'flow' : 'script'}. Undeployed
draft triggers will be permanently deleted.`}
{`${isFlow ? 'Your flow' : 'Your script'} has undeployed trigger changes. Select which to deploy alongside the ${isFlow ? 'flow' : 'script'}; the rest stay as drafts.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
@@ -124,19 +123,19 @@
<div class="flex justify-start">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
selected={isSelectedTrigger ? 'deploy' : 'skip'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
label="Skip"
value={'skip'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
label={trigger.isDraft ? 'Deploy' : 'Update'}
value={'deploy'}
{item}
small
@@ -157,7 +156,7 @@
{#if draftTriggers.length === 0}
<tr>
<td colspan="3" class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
No draft triggers found
No undeployed trigger changes
</td>
</tr>
{/if}
@@ -1,7 +1,6 @@
import type { Flow, OpenFlow } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { FlowState } from './flows/flowState'
import type { Trigger } from './triggers/utils'
import type { DiffDrawerI } from './diff_drawer'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import type { ScheduleTrigger } from './triggers'
@@ -27,7 +26,6 @@ export type FlowBuilderProps = {
/** flow_version the draft was forked from; when set, the deploy-time staleness
* check compares it (not the load-time head `version`) against the latest. */
draftBaseVersion?: number | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
loadedFromHistoryFromUrl?: {
@@ -77,7 +77,7 @@
const selectedId = $derived(selectionManager.getSelectedId())
const { showCaptureHint, triggersState, triggersCount } =
const { showCaptureHint, triggersState, triggersCount, draftTarget } =
getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
@@ -124,7 +124,7 @@
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
selectionManager.selectId('Trigger')
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
handleSelectTriggerFromKind(triggersState, triggersCount, draftTarget(), ev.detail.kind)
showCaptureHint.set(true)
}}
on:applyArgs
@@ -26,7 +26,7 @@
getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersState, triggersCount, draftTarget } = getContext<TriggerContext>('TriggerContext')
let scriptKind: 'script' | 'trigger' | 'approval' = $state('script')
let scriptTemplate: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' = $state('script')
@@ -67,25 +67,9 @@
args: {},
schedule: formatCron('0 */15 * * *'),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true,
is_flow: true
}
triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, primaryCfg)
} else if (triggersState.triggers[primaryIndex].draftConfig) {
//If there is a primary schedule draft update it
const newCfg = { ...triggersState.triggers[primaryIndex].draftConfig }
let updated = false
if (!newCfg.schedule) {
newCfg.schedule = formatCron('0 */15 * * *')
updated = true
}
if (!newCfg.enabled) {
newCfg.enabled = true
updated = true
}
if (updated) {
triggersState.triggers[primaryIndex].draftConfig = newCfg
enabled: true
}
triggersState.addDraftTrigger(triggersCount, 'schedule', draftTarget(), primaryCfg)
}
module.stop_after_if = {
@@ -10,6 +10,7 @@
import { getContext, setContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import { writable } from 'svelte/store'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
interface Props {
path: string
@@ -32,7 +33,8 @@
triggersCount: triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
triggersState: new Triggers(),
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
async function loadFlow(path: string) {
@@ -136,7 +136,7 @@
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
const moveManager = new MoveManager()
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, triggersState, draftTarget } = getContext<TriggerContext>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
@@ -820,7 +820,7 @@
})
}
if (detail.kind == 'trigger') {
setScheduledPollSchedule(triggersState, triggersCount)
setScheduledPollSchedule(triggersState, triggersCount, draftTarget())
}
if (detail.flow?.path) {
loadLastJob(detail.flow.path, module.id)
@@ -32,20 +32,17 @@
const { selectionManager } = getGraphContext()
const { triggersCount, triggersState } = $state(getContext<TriggerContext>('TriggerContext'))
const { triggersCount, triggersState, draftTarget } = $state(
getContext<TriggerContext>('TriggerContext')
)
function getScheduleCfg(primary: Trigger | undefined, triggersCount: TriggersCount | undefined) {
return primary?.draftConfig
? {
enabled: primary?.draftConfig?.enabled,
schedule: primary?.draftConfig?.schedule
return primary?.lightConfig
? { enabled: primary.lightConfig.enabled, schedule: primary.lightConfig.schedule }
: {
enabled: !!triggersCount?.primary_schedule,
schedule: triggersCount?.primary_schedule?.schedule
}
: primary?.lightConfig
? { enabled: primary?.lightConfig?.enabled, schedule: primary?.lightConfig?.schedule }
: {
enabled: !!triggersCount?.primary_schedule,
schedule: triggersCount?.primary_schedule?.schedule
}
}
let colorClasses = $derived(
@@ -89,7 +86,7 @@
triggersState.selectedTriggerIndex = triggerIndex
}}
onAddDraftTrigger={async (type: TriggerType) => {
const newTrigger = triggersState.addDraftTrigger(triggersCount, type)
const newTrigger = await triggersState.addDraftTrigger(triggersCount, type, draftTarget())
data?.eventHandlers?.select('Trigger')
await tick()
triggersState.selectedTriggerIndex = newTrigger
@@ -123,7 +120,7 @@
<button
class="px-2 py-1 hover:bg-surface-inverse w-full hover:text-primary-inverse"
onclick={() => {
setScheduledPollSchedule(triggersState, triggersCount)
setScheduledPollSchedule(triggersState, triggersCount, draftTarget())
}}
>
Set primary schedule
+9 -5
View File
@@ -2,6 +2,7 @@ import type { CaptureTriggerKind, TriggersCount } from '$lib/gen'
import { type Writable } from 'svelte/store'
import { formatCron } from '$lib/utils'
import { Triggers } from './triggers/triggers.svelte'
import type { TriggerDraftTarget } from './triggers/utils'
export type ScheduleTrigger = {
summary: string | undefined
@@ -17,11 +18,15 @@ export type TriggerContext = {
simplifiedPoll: Writable<boolean | undefined>
showCaptureHint: Writable<boolean | undefined>
triggersState: Triggers
/** Read at call time, not at mount: the runnable's path is editable until it
* is deployed, and a trigger drafted after a rename must point at the new one. */
draftTarget: () => TriggerDraftTarget
}
export function setScheduledPollSchedule(
export async function setScheduledPollSchedule(
triggersState: Triggers,
triggersCount: Writable<TriggersCount | undefined>
triggersCount: Writable<TriggersCount | undefined>,
target: TriggerDraftTarget
) {
const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)
if (primarySchedule !== -1) {
@@ -32,11 +37,10 @@ export function setScheduledPollSchedule(
summary: 'Check for new events every 5 minutes',
schedule: formatCron('0 */5 * * * *'),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {},
is_flow: true
args: {}
}
triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, draftCfg)
await triggersState.addDraftTrigger(triggersCount, 'schedule', target, draftCfg)
triggersState.selectedTriggerIndex = triggersState.triggers.length - 1
}
}
@@ -5,7 +5,7 @@
import { Tooltip } from '../meltComponents'
import DeleteTriggerButton from './DeleteTriggerButton.svelte'
import { type Trigger } from './utils'
import { triggerHasPendingChanges, type Trigger } from './utils'
import TriggerSuspendedJobsModal from './TriggerSuspendedJobsModal.svelte'
import type { TriggerMode } from '$lib/gen'
import TriggerModeToggle from './TriggerModeToggle.svelte'
@@ -49,6 +49,7 @@
}: Props = $props()
const canSave = $derived((permissions === 'write' && edit) || permissions === 'create')
const triggerPending = $derived(!!trigger && triggerHasPendingChanges(trigger))
</script>
{#if !allowDraft}
@@ -78,7 +79,7 @@
{/if}
{:else}
<div class="flex flex-row gap-2 items-center">
{#if !trigger?.draftConfig}
{#if !triggerPending}
<div class="center-center">
<TriggerModeToggle
canWrite={permissions !== 'none'}
@@ -91,7 +92,7 @@
{/if}
{#if trigger?.isDraft || permissions === 'create'}
<DeleteTriggerButton {onDelete} {trigger} />
{:else if !trigger?.isDraft && trigger?.draftConfig}
{:else if !trigger?.isDraft && triggerPending}
<Button
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
@@ -109,7 +110,7 @@
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Save }}
disabled={saveDisabled || cloudDisabled || !isDeployed || !trigger?.draftConfig}
disabled={saveDisabled || cloudDisabled || !isDeployed || !triggerPending}
on:click={() => {
onUpdate?.()
}}
@@ -1,6 +1,6 @@
<script lang="ts">
import Badge from '../common/badge/Badge.svelte'
import { getTriggerLabel, type Trigger } from './utils'
import { getTriggerLabel, triggerHasPendingChanges, type Trigger } from './utils'
import { twMerge } from 'tailwind-merge'
let { trigger, discard }: { trigger: Trigger; discard?: boolean } = $props()
@@ -20,7 +20,7 @@
<Badge color="blue">Primary</Badge>
{/if}
{#if trigger.draftConfig && !trigger.isDraft}
{#if triggerHasPendingChanges(trigger) && !trigger.isDraft}
<Badge color="orange">Modified</Badge>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import { userStore, workspaceStore } from '$lib/stores'
import { UserDraft } from '$lib/userDraft.svelte'
import FlowCard from '../flows/common/FlowCard.svelte'
import { getContext, onDestroy, createEventDispatcher } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
@@ -16,7 +17,8 @@
triggerTypeToCaptureKind,
type TriggerType,
CLOUD_DISABLED_TRIGGER_TYPES,
type Trigger
type Trigger,
triggerDraftKind
} from './utils'
import { isCloudHosted } from '$lib/cloud'
import {
@@ -83,7 +85,7 @@
let loading = $state(false)
const useVerticalTriggerBar = $derived(width < 1000)
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersState, triggersCount, draftTarget } = getContext<TriggerContext>('TriggerContext')
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const chatInputEnabled = $derived(
@@ -161,16 +163,21 @@
if (triggerIndex === undefined) {
return
}
const trigger = triggersState.triggers[triggerIndex]
// If the trigger is deployed, delete the trigger from the db
if (
!triggersState.triggers[triggerIndex].isDraft &&
triggersState.triggers[triggerIndex].path
) {
if (!trigger.isDraft && trigger.path) {
await deleteDeployedTrigger(triggerIndex)
}
triggersState.deleteTrigger(triggersCount, triggerIndex)
triggersState.selectedTriggerIndex = triggersState.triggers.length - 1
// Drop the draft row too, else the trigger reappears on the next fetch.
// After the list update, so the editor panel's autosave has unmounted
// rather than re-persisting the config it still holds.
const draftKind = triggerDraftKind(trigger.type)
if (draftKind && trigger.path && $workspaceStore) {
UserDraft.remove(draftKind, trigger.path, { workspace: $workspaceStore })
}
}
async function handleUpdate(trigger: number | undefined, path: string) {
@@ -318,32 +325,42 @@
onDeployTrigger?.({ type: triggerType, id: triggerId, path: triggerPath })
}
/** Editor-local config for the kinds with no draft row (native). Every other
* kind persists through its own `useTriggerDraftSync`, and mirroring here
* would put the same pending change in two places. */
function handleUpdateDraftConfig(
triggerIndex: number | undefined,
newConfig: Record<string, any>,
saveDisabled: boolean
) {
console.log('handleUpdateDraftConfig', triggerIndex, newConfig, saveDisabled)
if (triggerIndex && triggerIndex !== -1 && newConfig) {
triggersState.setDraftConfig(triggerIndex, { ...newConfig, canSave: !saveDisabled })
}
if (triggerIndex === undefined || triggerIndex === -1 || !newConfig) return
if (triggerDraftKind(triggersState.triggers[triggerIndex].type)) return
triggersState.setDraftConfig(triggerIndex, { ...newConfig, canSave: !saveDisabled })
}
function handleResetDraft(trigger: number | undefined) {
if (!trigger) {
/** Discard the pending changes on a deployed trigger, restoring what is live.
* `renderCount` remounts the panel so its form reloads from the backend. */
async function handleResetDraft(triggerIndex: number | undefined) {
if (triggerIndex === undefined || triggerIndex === -1) {
return
}
triggersState.setDraftConfig(trigger, undefined)
const trigger = triggersState.triggers[triggerIndex]
const draftKind = triggerDraftKind(trigger.type)
if (draftKind && trigger.path && $workspaceStore) {
UserDraft.remove(draftKind, trigger.path, { workspace: $workspaceStore })
triggersState.triggers[triggerIndex].hasDraft = false
} else {
triggersState.setDraftConfig(triggerIndex, undefined)
}
renderCount++
}
function handleAddTrigger(type: TriggerType) {
const newTrigger = triggersState.addDraftTrigger(
async function handleAddTrigger(type: TriggerType) {
triggersState.selectedTriggerIndex = await triggersState.addDraftTrigger(
triggersCount,
type,
type === 'schedule' ? initialPath : undefined
draftTarget()
)
triggersState.selectedTriggerIndex = newTrigger
}
const cloudDisabled = $derived(
@@ -3,7 +3,12 @@
import Button from '$lib/components/common/button/Button.svelte'
import { Plus, Star, Loader2, RotateCcw } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { triggerIconMap, type Trigger, type TriggerType } from './utils'
import {
triggerHasPendingChanges,
triggerIconMap,
type Trigger,
type TriggerType
} from './utils'
import AddTriggersButton from './AddTriggersButton.svelte'
import TriggerLabel from './TriggerLabel.svelte'
import DeleteTriggerButton from './DeleteTriggerButton.svelte'
@@ -103,7 +108,7 @@
{#if !['default_email', 'webhook', 'cli'].includes(trigger.type)}
{#if trigger.isDraft}
<DeleteTriggerButton {trigger} onDelete={() => onDeleteDraft?.(index)} small />
{:else if !!trigger.draftConfig && !trigger.isDraft}
{:else if triggerHasPendingChanges(trigger) && !trigger.isDraft}
<Button
size="xs"
color="light"
@@ -55,10 +55,6 @@
onEmailDomain,
...props
}: Props = $props()
$effect(() => {
console.log('selectedTrigger', selectedTrigger)
})
</script>
{#if selectedTrigger.type === 'http'}
@@ -203,7 +203,10 @@
options = defaultValues?.options ?? undefined
enablePrefetch = options?.prefetch_count != undefined
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
@@ -215,6 +218,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -17,16 +17,16 @@
} = $props()
let amqpTriggerEditor: AmqpTriggerEditorInner | undefined = $state(undefined)
async function openAmqpTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
amqpTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openAmqpTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
amqpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
amqpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
amqpTriggerEditor && openAmqpTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
amqpTriggerEditor && openAmqpTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -172,7 +172,10 @@
subscription_name = defaultValues?.subscription_name ?? ''
event_type_filters = defaultValues?.event_type_filters ?? undefined
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
@@ -184,6 +187,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
drawerLoading = false
}
@@ -17,16 +17,16 @@
} = $props()
let azureTriggerEditor: AzureTriggerEditorInner | undefined = $state(undefined)
async function openAzureTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
azureTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openAzureTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
azureTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
azureTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
azureTriggerEditor && openAzureTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
azureTriggerEditor && openAzureTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from 'vitest'
import { newDraftTriggerPath } from './utils'
vi.mock('$lib/stores', () => ({
userStore: { subscribe: (run: any) => (run(undefined), () => {}) }
}))
describe('newDraftTriggerPath', () => {
it('gives the primary schedule the runnable path, so it deploys as the primary', () => {
expect(newDraftTriggerPath('f/team/my_flow', 'schedule', [], true)).toBe('f/team/my_flow')
})
it('suffixes by kind for everything else', () => {
expect(newDraftTriggerPath('f/team/my_flow', 'http', [])).toBe('f/team/my_flow_http')
expect(newDraftTriggerPath('f/team/my_flow', 'schedule', [], false)).toBe(
'f/team/my_flow_schedule'
)
})
it('never reuses a path already taken by another trigger of the kind', () => {
const first = newDraftTriggerPath('f/team/my_flow', 'http', [])
const second = newDraftTriggerPath('f/team/my_flow', 'http', [first])
const third = newDraftTriggerPath('f/team/my_flow', 'http', [first, second])
expect(new Set([first, second, third]).size).toBe(3)
expect(second.startsWith('f/team/my_flow_http_')).toBe(true)
})
it('falls back off the runnable path when the primary schedule slot is taken', () => {
expect(newDraftTriggerPath('f/team/my_flow', 'schedule', ['f/team/my_flow'], true)).toBe(
'f/team/my_flow_schedule'
)
})
})
@@ -179,7 +179,10 @@
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
dirtyPath = false
workspaced_local_part = defaultValues?.workspaced_local_part ?? false
@@ -192,6 +195,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loader)
drawerLoading = false
@@ -29,9 +29,9 @@
...restProps
}: Props = $props()
async function openEmailTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
emailTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openEmailTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
emailTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
emailTriggerEditor?.openEdit(selectedTrigger.path ?? '', isFlow, defaultValues)
}
@@ -39,7 +39,7 @@
onMount(() => {
if (emailTriggerEditor) {
openEmailTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
openEmailTriggerEditor(isFlow)
}
})
@@ -178,7 +178,10 @@
topic_id = defaultValues?.topic_id ?? ''
subscription_mode = defaultValues?.subscription_mode ?? 'create_update'
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
@@ -192,6 +195,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
drawerLoading = false
}
@@ -17,16 +17,16 @@
} = $props()
let gcpTriggerEditor: GcpTriggerEditorInner | undefined = $state(undefined)
async function openGcpTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
gcpTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openGcpTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
gcpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
gcpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
gcpTriggerEditor && openGcpTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
gcpTriggerEditor && openGcpTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -279,7 +279,10 @@
static_asset_config = defaultValues?.static_asset_config ?? undefined
s3FileUploadRawMode = defaultValues?.s3FileUploadRawMode ?? false
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
mode = defaultValues?.mode ?? 'enabled'
dirtyPath = false
is_static_website = defaultValues?.is_static_website ?? false
@@ -299,6 +302,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loader)
drawerLoading = false
@@ -16,9 +16,9 @@
...restProps
} = $props()
async function openRouteEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
routeEditor?.openNew(isFlow, path, defaultValues)
async function openRouteEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
routeEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
routeEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -26,7 +26,7 @@
onMount(() => {
if (routeEditor) {
openRouteEditor(isFlow, selectedTrigger.isDraft ?? false)
openRouteEditor(isFlow)
}
})
</script>
@@ -208,7 +208,10 @@
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = nDefaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
dirtyPath = false
error_handler_path = nDefaultValues?.error_handler_path ?? undefined
error_handler_args = nDefaultValues?.error_handler_args ?? {}
@@ -221,6 +224,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -18,16 +18,16 @@
} = $props()
let kafkaTriggerEditor: KafkaTriggerEditorInner | undefined = $state(undefined)
async function openKafkaTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
kafkaTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openKafkaTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
kafkaTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
kafkaTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
openKafkaTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
openKafkaTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -198,7 +198,10 @@
script_path = fixedScriptPath
subscribe_topics = defaultValues?.topics ?? []
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
edit = false
dirtyPath = false
client_version = defaultValues?.client_version ?? 'v5'
@@ -218,6 +221,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -17,16 +17,16 @@
} = $props()
let mqttTriggerEditor: MqttTriggerEditorInner | undefined = $state(undefined)
async function openMqttTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
mqttTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openMqttTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
mqttTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
mqttTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
mqttTriggerEditor && openMqttTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
mqttTriggerEditor && openMqttTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -199,7 +199,10 @@
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = nDefaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
dirtyPath = false
defaultValues = nDefaultValues
mode = nDefaultValues?.mode ?? 'enabled'
@@ -211,6 +214,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -17,16 +17,16 @@
} = $props()
let natsTriggerEditor: NatsTriggerEditorInner | undefined = $state(undefined)
async function openNatsTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
natsTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openNatsTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
natsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
natsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
natsTriggerEditor && openNatsTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
natsTriggerEditor && openNatsTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -295,7 +295,10 @@
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
postgres_resource_path = defaultValues?.postgres_resource_path ?? ''
edit = false
dirtyPath = false
@@ -322,6 +325,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -19,16 +19,16 @@
let postgresTriggerEditor: PostgresTriggerEditorInner | undefined = $state(undefined)
async function openPostgresTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
postgresTriggerEditor?.openNew(isFlow, path, defaultValues, newDraft)
async function openPostgresTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
postgresTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }, newDraft)
} else {
postgresTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
postgresTriggerEditor && openPostgresTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
postgresTriggerEditor && openPostgresTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -365,6 +365,8 @@
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -524,9 +526,12 @@
initialCronVersion = cronVersion
isLatestCron = cronVersion == 'v2'
enabled = cfg.enabled
schedule = cfg.schedule
// A draft is partial by nature — a half-configured trigger, or one written
// by the AI chat. Fall back to the same defaults `openNew` seeds rather
// than leaving `schedule` unset, which `formatCron` cannot parse.
schedule = cfg.schedule ?? '0 0 12 * *'
initialSchedule = schedule
timezone = cfg.timezone
timezone = cfg.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
paused_until = cfg.paused_until
showPauseUntil = paused_until !== undefined
summary = cfg.summary ?? ''
@@ -187,7 +187,10 @@
path = defaultValues?.path ?? ''
message_attributes = defaultValues?.message_attributes ?? []
aws_auth_resource_type = defaultValues?.aws_auth_resource_type ?? 'credentials'
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
@@ -199,6 +202,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
clearTimeout(loadingTimeout)
@@ -18,16 +18,16 @@
} = $props()
let sqsTriggerEditor: SqsTriggerEditorInner | undefined = $state(undefined)
async function openSqsTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
sqsTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openSqsTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
sqsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
sqsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
sqsTriggerEditor && openSqsTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
sqsTriggerEditor && openSqsTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
@@ -25,7 +25,15 @@ import {
} from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils'
import {
getLightConfig,
newDraftTriggerPath,
sortTriggers,
triggerDraftKind,
updateTriggersCount,
type Trigger,
type TriggerDraftTarget
} from './utils'
import { get, type Writable } from 'svelte/store'
import type { TriggerType } from './utils'
import type { UserExt } from '$lib/stores'
@@ -77,38 +85,58 @@ export class Triggers {
this.#triggers[triggerIndex].draftConfig = draftConfig
}
getDraftTriggersSnapshot(): Trigger[] | undefined {
const draftTriggers = this.#triggers.filter((t) => t.draftConfig)
return draftTriggers.length > 0 ? $state.snapshot(draftTriggers) : undefined
}
getSelectedTriggerSnapshot(): number | undefined {
return $state.snapshot(this.#selectedTriggerIndex)
}
/** Add an undeployed trigger to the editor's list. For the draft-backed kinds
* this writes the `trigger_*` draft row up front — draft rows are path-keyed,
* so the trigger needs a path before the user has typed one, and persisting
* immediately is what makes it survive a reload. Native kinds have no draft
* row yet and keep their config in `draftConfig` until deploy. */
addDraftTrigger(
triggersCountStore: Writable<TriggersCount | undefined>,
type: TriggerType,
path?: string,
draftCfg?: Record<string, any>
target: TriggerDraftTarget,
seedCfg?: Record<string, any>
): number {
const primaryScheduleExists = this.#triggers.some((t) => t.type === 'schedule' && t.isPrimary)
// Create the new draft trigger
const draftId = generateRandomString()
const isPrimary = type === 'schedule' && !primaryScheduleExists
const newTrigger = {
id: draftId,
const draftKind = triggerDraftKind(type)
const { runnablePath, isFlow, workspace } = target
// Reserve the path the draft row will be keyed at. The row itself is
// written by the editor panel's autosave, which is what turns the form's
// defaults into a complete config — seeding a partial one here would leave
// required fields unset and crash the editors that don't default them.
const path =
draftKind && workspace
? newDraftTriggerPath(
runnablePath,
type,
this.#triggers.filter((t) => t.type === type).map((t) => t.path ?? ''),
isPrimary
)
: undefined
const newTrigger: Trigger = {
id: generateRandomString(),
type,
path,
isPrimary,
isDraft: true,
draftConfig: draftCfg
isNew: true,
hasDraft: !!draftKind,
// Until the row exists this is the editor's starting point, whatever the
// kind — `TriggersWrapper` hands it to `openNew` as defaults. It carries
// the runnable explicitly: the editors otherwise fall back to the fake
// capture path an undeployed runnable is given.
draftConfig: { ...seedCfg, script_path: runnablePath, is_flow: isFlow }
}
this.#triggers.push(newTrigger)
updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig)
updateTriggersCount(triggersCountStore, type, 'add', seedCfg)
return this.#triggers.length - 1
}
@@ -133,31 +161,39 @@ export class Triggers {
user: UserExt | undefined = undefined
): number {
const currentTriggers = this.#triggers
// Identify triggers with draftConfig to preserve
const configuredTriggers = currentTriggers.filter(
(t) => t.type === type && !t.isDraft && t.draftConfig
// Preserve the editor-local config of the kinds that have no draft row
// (native); every other kind's pending state lives in the backend rows.
const configMap = new Map<string, Record<string, any>>(
currentTriggers
.filter((t) => t.type === type && t.draftConfig)
.map((t) => [t.path ?? '', t.draftConfig!])
)
const configMap = new Map<string, { draftConfig: Record<string, any> }>()
const backendTriggers = remoteTriggers.map((trigger) => ({
type: type as TriggerType,
path: trigger.path,
isPrimary: type === 'schedule' && trigger.path === trigger.script_path,
// `draft_only` rows are synthesized from a draft with no deployed
// counterpart; `is_draft` also covers a draft layered on a deployed row.
isDraft: !!trigger.draft_only,
hasDraft: !!trigger.is_draft,
draftPath: trigger.draft_path,
canWrite: canWrite(trigger.path, trigger.extra_perms, user),
draftConfig: configMap.get(trigger.path),
lightConfig: getLightConfig(type, trigger)
}))
configuredTriggers.forEach((t) => {
configMap.set(t.path ?? '', { draftConfig: t.draftConfig! })
})
const backendTriggers = remoteTriggers.map((trigger) => {
const { draftConfig } = configMap.get(trigger.path) ?? {}
return {
type: type as TriggerType,
path: trigger.path,
isPrimary: type === 'schedule' && trigger.path === trigger.script_path,
isDraft: false,
canWrite: canWrite(trigger.path, trigger.extra_perms, user),
draftConfig: draftConfig,
lightConfig: getLightConfig(type, trigger)
}
})
const filteredTriggers = currentTriggers.filter((t) => t.type !== type || t.isDraft)
// A locally-added trigger survives only until the backend knows about it:
// an `isNew` one until its editor's autosave creates the draft row, a
// native one (no draft row at all) until it deploys.
const backendPaths = new Set(remoteTriggers.map((t) => t.path))
const filteredTriggers = currentTriggers.filter(
(t) =>
t.type !== type ||
(t.isDraft &&
!backendPaths.has(t.path ?? '') &&
(t.isNew || !triggerDraftKind(t.type)))
)
const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers])
this.#triggers = newTriggers
@@ -196,7 +232,8 @@ export class Triggers {
const allDeployedSchedules: Schedule[] = await ScheduleService.listSchedules({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', user)
@@ -232,7 +269,8 @@ export class Triggers {
const wsTriggers = await WebsocketTriggerService.listWebsocketTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const wsCount = this.updateTriggers(wsTriggers, 'websocket', user)
triggersCountStore.update((triggersCount) => {
@@ -258,7 +296,8 @@ export class Triggers {
const pgTriggers: PostgresTrigger[] = await PostgresTriggerService.listPostgresTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const pgCount = this.updateTriggers(pgTriggers, 'postgres', user)
triggersCountStore.update((triggersCount) => {
@@ -284,7 +323,8 @@ export class Triggers {
const kafkaTriggers: KafkaTrigger[] = await KafkaTriggerService.listKafkaTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', user)
triggersCountStore.update((triggersCount) => {
@@ -310,7 +350,8 @@ export class Triggers {
const natsTriggers = await NatsTriggerService.listNatsTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const natsCount = this.updateTriggers(natsTriggers, 'nats', user)
triggersCountStore.update((triggersCount) => {
@@ -336,7 +377,8 @@ export class Triggers {
const mqttTriggers = await MqttTriggerService.listMqttTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', user)
triggersCountStore.update((triggersCount) => {
@@ -362,7 +404,8 @@ export class Triggers {
const amqpTriggers = await AmqpTriggerService.listAmqpTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const amqpCount = this.updateTriggers(amqpTriggers, 'amqp', user)
triggersCountStore.update((triggersCount) => {
@@ -388,7 +431,8 @@ export class Triggers {
const sqsTriggers = await SqsTriggerService.listSqsTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', user)
triggersCountStore.update((triggersCount) => {
@@ -414,7 +458,8 @@ export class Triggers {
const gcpTriggers: GcpTrigger[] = await GcpTriggerService.listGcpTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', user)
triggersCountStore.update((triggersCount) => {
@@ -440,7 +485,8 @@ export class Triggers {
const azureTriggers: AzureTrigger[] = await AzureTriggerService.listAzureTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const azureCount = this.updateTriggers(azureTriggers, 'azure', user)
triggersCountStore.update((triggersCount) => {
@@ -466,7 +512,8 @@ export class Triggers {
const httpTriggers: HttpTrigger[] = await HttpTriggerService.listHttpTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const httpCount = this.updateTriggers(httpTriggers, 'http', user)
triggersCountStore.update((triggersCount) => {
@@ -492,7 +539,8 @@ export class Triggers {
const emailTriggers: EmailTrigger[] = await EmailTriggerService.listEmailTriggers({
workspace: workspaceId,
path,
isFlow
isFlow,
includeDraftOnly: true
})
const emailCount = this.updateTriggers(emailTriggers, 'email', user)
triggersCountStore.update((triggersCount) => {
@@ -64,6 +64,14 @@ export interface TriggerDraftSync {
* without a deployed→draft flash. The banner (via `hasDraft`) surfaces it.
*/
maybeRestore(): Promise<void>
/**
* Persist the form's current config as the draft for a trigger that has no
* row yet — an `openNew` opened from a flow/script editor, where the trigger
* is already listed and must survive a reload. Also consumes the entry's
* one-shot seed guard, so the user's first edit isn't swallowed. No-op
* without a path (a list page's ad-hoc "New trigger" has nothing to key on).
*/
persistNew(): Promise<void>
/**
* Drop the local draft for `path` and reset the form to the deployed
* baseline. Backs the banner's "Discard" action.
@@ -226,6 +234,15 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
})
}
},
async persistNew() {
const ws = opts.workspace()
const p = opts.path()
const cfg = opts.getCfg()
if (!ws || !p || cfg == null) return
const snapshot = structuredClone($state.snapshot(cfg)) as Cfg
UserDraft.seed(opts.itemKind, p, snapshot, { workspace: ws })
await UserDraft.forcePersist(opts.itemKind, p, { workspace: ws })
},
async resetToDeployed(path: string) {
const deployedCfg = structuredClone($state.snapshot(opts.deployed())) as Cfg
discard(path, deployedCfg)
+223 -174
View File
@@ -25,27 +25,20 @@ import type {
NewScript,
TriggersCount
} from '$lib/gen/types.gen'
import type { Writable } from 'svelte/store'
import { get, type Writable } from 'svelte/store'
import type { UserDraftItemKind } from '$lib/userDraft.svelte'
import { userStore } from '$lib/stores'
import SchedulePollIcon from '../icons/SchedulePollIcon.svelte'
import type { TriggerKind } from '../triggers'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { saveHttpRouteFromCfg } from './http/utils'
import { saveWebsocketTriggerFromCfg } from './websocket/utils'
import { savePostgresTriggerFromCfg } from './postgres/utils'
import { saveKafkaTriggerFromCfg } from './kafka/utils'
import { saveSqsTriggerFromCfg } from './sqs/utils'
import { saveNatsTriggerFromCfg } from './nats/utils'
import { saveMqttTriggerFromCfg } from './mqtt/utils'
import { saveAmqpTriggerFromCfg } from './amqp/utils'
import { saveGcpTriggerFromCfg } from './gcp/utils'
import { saveAzureTriggerFromCfg } from './azure/utils'
import type { Triggers } from './triggers.svelte'
import { emptyString } from '$lib/utils'
import { saveEmailTriggerFromCfg } from './email/utils'
import { emptyString, generateRandomString } from '$lib/utils'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import { saveNativeTriggerFromCfg } from './native/utils'
import { DraftService, type NativeServiceName } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { deployDraft } from '$lib/utils_draft_deploy'
export const CLOUD_DISABLED_TRIGGER_TYPES = [
'nats',
@@ -106,16 +99,110 @@ export const jobTriggerKinds: JobTriggerKind[] = [
export type Trigger = {
type: TriggerType
path?: string
/** No deployed row at this path — the trigger exists only as a draft. */
isDraft?: boolean
isPrimary?: boolean
canWrite?: boolean
id?: string
/** This user has a `trigger_*` draft row at `path`. On a deployed trigger it
* means "modified"; on a draft-only one it is implied by `isDraft`. */
hasDraft?: boolean
/** Where a draft-only trigger will deploy to, when renamed away from `path`
* (which stays the draft row's key). Display this, address by `path`. */
draftPath?: string
/** Added in this editor session and not yet written to the `draft` table. Its
* editor opens through `openNew`, the path that fills a complete config; the
* resulting autosave is what creates the row. */
isNew?: boolean
/** Editor-local unsaved config, for the trigger kinds that have no draft row
* yet (native only — see `triggerDraftKind`). */
draftConfig?: Record<string, any>
captureConfig?: Record<string, any>
extra?: Record<string, any>
lightConfig?: Record<string, any>
}
/** The runnable a trigger drafted in an editor fires, resolved live: the flow or
* script path can still be renamed before it is deployed. */
export type TriggerDraftTarget = {
runnablePath: string
isFlow: boolean
workspace: string | undefined
}
/** For the read-only `TriggerContext` mounts (details pages, webhook viewer):
* they render triggers but have no editor, so nothing can be drafted. The
* absent workspace makes `addDraftTrigger` skip the draft row rather than
* write one against a runnable it doesn't know. */
export const NO_TRIGGER_DRAFT_TARGET: TriggerDraftTarget = {
runnablePath: '',
isFlow: false,
workspace: undefined
}
/** The `draft` table kind backing each trigger type's per-user drafts.
* `undefined` means the type has no draft row: the pseudo-triggers (webhook,
* default_email, cli, poll) have no config to draft, and the native kinds
* (nextcloud/google/github) are keyed by a service-assigned `external_id` with
* permissions on the runnable, which the path-keyed draft table can't express —
* they stay on the editor-local `draftConfig` until that is wired. */
const TRIGGER_DRAFT_KIND = {
webhook: undefined,
default_email: undefined,
poll: undefined,
cli: undefined,
nextcloud: undefined,
google: undefined,
github: undefined,
schedule: 'trigger_schedule',
http: 'trigger_http',
websocket: 'trigger_websocket',
postgres: 'trigger_postgres',
kafka: 'trigger_kafka',
nats: 'trigger_nats',
mqtt: 'trigger_mqtt',
amqp: 'trigger_amqp',
sqs: 'trigger_sqs',
gcp: 'trigger_gcp',
azure: 'trigger_azure',
email: 'trigger_email'
} as const satisfies Record<TriggerType, UserDraftItemKind | undefined>
export function triggerDraftKind(type: TriggerType): UserDraftItemKind | undefined {
return TRIGGER_DRAFT_KIND[type]
}
/** Whether the trigger diverges from what is deployed — a draft row for the
* draft-backed kinds, an editor-local config for the native ones. Drives the
* "Modified" badge, the reset affordance and the deploy-time confirmation. */
export function triggerHasPendingChanges(trigger: Trigger): boolean {
return !!trigger.hasDraft || !!trigger.draftConfig
}
/** Storage path for a trigger drafted from the flow/script editor. Draft rows
* are path-keyed, so one is needed the moment the trigger is added — before the
* user has typed anything. A schedule that will be the primary takes the
* runnable's own path (what the deployed primary schedule uses); everything else
* is suffixed by kind, then disambiguated so two triggers of a kind don't
* collide. `taken` is every path already in use for this kind. */
export function newDraftTriggerPath(
runnablePath: string,
type: TriggerType,
taken: string[],
isPrimarySchedule = false
): string {
if (type === 'schedule' && isPrimarySchedule && runnablePath && !taken.includes(runnablePath)) {
return runnablePath
}
const base = `${runnablePath || `u/${get(userStore)?.username ?? 'user'}/trigger`}_${type}`
if (!taken.includes(base)) return base
let candidate = base
while (taken.includes(candidate)) {
candidate = `${base}_${generateRandomString(4)}`
}
return candidate
}
// Map of trigger kinds to icons
export const triggerIconMap = {
webhook: Webhook,
@@ -310,176 +397,138 @@ export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undef
}
}
/** The stored draft config for a trigger. Prefers the live in-memory cell — an
* open editor's not-yet-flushed keystrokes — and falls back to the server row,
* the only source for a trigger whose editor panel was never mounted this
* session. `undefined` when there is no draft at all. */
async function readTriggerDraft(
kind: UserDraftItemKind,
path: string,
workspace: string
): Promise<Record<string, any> | undefined> {
const local = UserDraft.get<Record<string, any>>(kind, path, { workspace })
if (local !== undefined) return local
try {
// `getOwnDraft` returns the draft ROW (`{ value, created_at }`), not the
// config — writing the wrapper back would nest the draft inside itself.
const row = (await DraftService.getOwnDraft({ workspace, kind, path })) as
| { value?: Record<string, any> }
| undefined
return row?.value ?? undefined
} catch (err) {
console.error(`Could not read ${kind} draft at ${path}`, err)
return undefined
}
}
/** Repoint a runnable's undeployed trigger drafts at its current path. The path
* stays editable until the runnable is first deployed, and a trigger drafted
* before a rename would otherwise fire the old path — including when it is
* deployed on its own from the triggers page or Review & Deploy. Only rewrites
* drafts that have actually fallen behind. */
export async function repointTriggerDrafts(
triggers: Trigger[],
target: TriggerDraftTarget
): Promise<void> {
const { runnablePath, isFlow, workspace } = target
if (!workspace || !runnablePath) return
await Promise.all(
triggers.map(async (trigger) => {
const kind = triggerDraftKind(trigger.type)
if (!kind || !trigger.path || !triggerHasPendingChanges(trigger)) return
const draft = await readTriggerDraft(kind, trigger.path, workspace)
if (!draft) return
if (draft.script_path === runnablePath && draft.is_flow === isFlow) return
UserDraft.save(
kind,
trigger.path,
{ ...draft, script_path: runnablePath, is_flow: isFlow },
{ workspace }
)
})
)
}
/** Deploy the pending changes of `triggersToDeploy` as part of deploying their
* runnable. The draft-backed kinds replay their stored draft through the same
* `deployDraft` the drafts page uses, after re-pointing it at `runnablePath` —
* a never-deployed runnable's path is still editable, and the primary schedule
* additionally takes the runnable's path as its own. The native kinds have no
* draft row and deploy from their editor-local config.
*
* Returns the triggers that failed, so the caller can report them without
* aborting the rest of the deploy. */
export async function deployTriggers(
triggersToDeploy: Trigger[],
workspaceId: string | undefined,
isAdmin: boolean,
usedTriggerKinds: Writable<string[]>,
initialPath?: string,
isNew?: boolean
) {
if (!workspaceId) return
runnablePath: string,
isFlow: boolean
): Promise<{ trigger: Trigger; error: string }[]> {
if (!workspaceId) return []
if (isNew && initialPath) {
triggersToDeploy.forEach((trigger) => {
trigger.draftConfig = {
...trigger.draftConfig,
script_path: initialPath
}
})
const nativeSavers: Partial<Record<TriggerType, NativeServiceName>> = {
nextcloud: 'nextcloud',
google: 'google',
github: 'github'
}
// Map of trigger types to their save functions
const triggerSaveFunctions: Record<TriggerType, Function | undefined> = {
webhook: undefined,
default_email: undefined,
schedule: (trigger: Trigger) => {
if (trigger.isPrimary && initialPath) {
trigger.draftConfig = {
...trigger.draftConfig,
path: initialPath,
script_path: initialPath
}
}
return saveScheduleFromCfg(trigger.draftConfig ?? {}, !trigger.isDraft, workspaceId)
},
http: (trigger: Trigger) =>
saveHttpRouteFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
isAdmin,
usedTriggerKinds
),
websocket: (trigger: Trigger) =>
saveWebsocketTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
postgres: (trigger: Trigger) =>
savePostgresTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
kafka: (trigger: Trigger) =>
saveKafkaTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
nats: (trigger: Trigger) =>
saveNatsTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
mqtt: (trigger: Trigger) =>
saveMqttTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
amqp: (trigger: Trigger) =>
saveAmqpTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
sqs: (trigger: Trigger) =>
saveSqsTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
gcp: (trigger: Trigger) =>
saveGcpTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
azure: (trigger: Trigger) =>
saveAzureTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
email: (trigger: Trigger) =>
saveEmailTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
isAdmin,
usedTriggerKinds
),
poll: undefined,
cli: undefined,
nextcloud: (trigger: Trigger) =>
saveNativeTriggerFromCfg(
'nextcloud',
trigger.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
google: (trigger: Trigger) =>
saveNativeTriggerFromCfg(
'google',
trigger.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
),
github: (trigger: Trigger) =>
saveNativeTriggerFromCfg(
'github',
trigger.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
)
}
await Promise.all(
const results = await Promise.all(
triggersToDeploy.map(async (trigger) => {
const saveFunction = triggerSaveFunctions[trigger.type]
if (saveFunction) {
await saveFunction(trigger)
} else {
console.warn(`No save function defined for trigger type: ${trigger.type}`)
const draftKind = triggerDraftKind(trigger.type)
if (draftKind && trigger.path) {
// Re-point the draft at the path the runnable is deploying to. The
// primary schedule is identified by sharing that path, so it moves too.
const draft = await readTriggerDraft(draftKind, trigger.path, workspaceId)
if (!draft) {
return { trigger, error: 'Draft not found' }
}
const repointed = {
...draft,
script_path: runnablePath,
is_flow: isFlow,
...(trigger.isPrimary ? { path: runnablePath } : {})
}
UserDraft.save(draftKind, trigger.path, repointed, { workspace: workspaceId })
await UserDraft.forcePersist(draftKind, trigger.path, { workspace: workspaceId })
const res = await deployDraft(draftKind, trigger.path, workspaceId, {
draftOnly: trigger.isDraft
})
if (!res.success) {
return { trigger, error: res.error ?? 'Deploy failed' }
}
// `deployDraft` deleted the row, but an editor panel open on this
// trigger still holds the config in its cell and would autosave it
// straight back as a fresh draft. Reset the cell to what was just
// deployed so the persist-effect sees no divergence.
UserDraft.discard(draftKind, trigger.path, repointed, { workspace: workspaceId })
return undefined
}
const service = nativeSavers[trigger.type]
if (service) {
const ok = await saveNativeTriggerFromCfg(
service,
trigger.path ?? '',
{ ...trigger.draftConfig, script_path: runnablePath, is_flow: isFlow },
!trigger.isDraft,
workspaceId,
usedTriggerKinds
)
return ok ? undefined : { trigger, error: `Could not deploy ${trigger.type} trigger` }
}
return undefined
})
)
return results.filter((r) => r !== undefined)
}
export function handleSelectTriggerFromKind(
export async function handleSelectTriggerFromKind(
triggersState: Triggers,
triggersCountStore: Writable<TriggersCount | undefined>,
initialPath: string | undefined,
target: TriggerDraftTarget,
triggerKind: TriggerKind
) {
const triggerType = triggerKindToTriggerType(triggerKind)
@@ -495,12 +544,11 @@ export function handleSelectTriggerFromKind(
if (existingTriggerIndex !== -1) {
triggersState.selectedTriggerIndex = existingTriggerIndex
} else {
const newTrigger = triggersState.addDraftTrigger(
triggersState.selectedTriggerIndex = await triggersState.addDraftTrigger(
triggersCountStore,
triggerType,
triggerType === 'schedule' ? initialPath : undefined
target
)
triggersState.selectedTriggerIndex = newTrigger
}
}
@@ -537,7 +585,7 @@ export function getLightConfig(
trigger: Record<string, any>
): Record<string, any> | undefined {
if (triggerType === 'schedule') {
return { schedule: trigger.schedule, enable: trigger.enable, summary: trigger.summary }
return { schedule: trigger.schedule, enabled: trigger.enabled, summary: trigger.summary }
} else if (triggerType === 'http') {
return { route_path: trigger.route_path, http_method: trigger.http_method }
} else if (triggerType === 'websocket') {
@@ -639,10 +687,11 @@ export function getTriggerLabel(trigger: Trigger): string {
return `${config.owner}/${config.repo}`
} else if (isDraft && draftConfig?.path) {
return `${draftConfig?.path}`
} else if (isDraft) {
} else if (isDraft && !trigger.draftPath && !path) {
return `New ${type.replace(/s$/, '')} trigger`
} else {
return path ?? ''
// A renamed draft is keyed at its original path but deploys to `draftPath`.
return trigger.draftPath ?? path ?? ''
}
}
@@ -7,6 +7,7 @@
import type { TriggerContext } from '$lib/components/triggers'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import type { TriggersCount } from '$lib/gen'
import { NO_TRIGGER_DRAFT_TARGET } from '../utils'
// WebhooksPanel → TriggerTokens reads the 'TriggerContext' to publish the
// webhook token count. The pipeline canvas isn't a trigger editor, so no
@@ -16,7 +17,8 @@
triggersCount: writable<TriggersCount | undefined>(undefined),
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
triggersState: new Triggers(),
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
let open = $state(false)
@@ -227,7 +227,10 @@
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = defaultValues?.path ?? ''
initialPath = ''
// Key the local autosave at the path this trigger will live at, so a
// never-deployed one is still drafted. Empty for an ad-hoc "New
// trigger" from a list page, which has nothing to key on until saved.
initialPath = path
filters = []
filterLogic = 'and'
initial_messages = []
@@ -244,6 +247,8 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
// Editor-created trigger: it is already listed, so write its draft now.
await draftSync.persistNew()
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -16,16 +16,16 @@
} = $props()
let wsTriggerEditor: WebsocketTriggerEditorInner | undefined = $state(undefined)
async function openWebsocketTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
wsTriggerEditor?.openNew(isFlow, path, defaultValues)
async function openWebsocketTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
wsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
} else {
wsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
openWebsocketTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
openWebsocketTriggerEditor(isFlow)
})
const cloudDisabled = $derived(isCloudHosted())
+55
View File
@@ -0,0 +1,55 @@
/**
* One-shot conversion of the `draft_triggers` array that runnable drafts used to
* carry.
*
* Before triggers had their own `draft` rows, the editors kept undeployed
* trigger configs inside the runnable's own draft value. Those drafts are still
* in the database, and nothing reads the field any more, so promote each entry
* to a real `trigger_*` draft row on load — the trigger then behaves like any
* other: it survives a reload, lists on the triggers pages, and deploys through
* `deployDraft`.
*
* Entries of a kind with no draft row (native triggers, see `triggerDraftKind`)
* have nowhere to go and are dropped. That combination — a native trigger added
* in an editor, never deployed, from before the upgrade — has no representation
* in the new model.
*
* Idempotent: the caller strips the field, so a converted draft has none.
*/
import { UserDraft } from '$lib/userDraft.svelte'
import { newDraftTriggerPath, triggerDraftKind, type Trigger } from '$lib/components/triggers/utils'
export async function migrateLegacyDraftTriggers(opts: {
legacy: Trigger[] | undefined
runnablePath: string
isFlow: boolean
workspace: string
}): Promise<boolean> {
const { legacy, runnablePath, isFlow, workspace } = opts
if (!Array.isArray(legacy) || legacy.length === 0) return false
const taken: string[] = []
let migrated = false
for (const trigger of legacy) {
const cfg = trigger?.draftConfig
const kind = trigger?.type ? triggerDraftKind(trigger.type) : undefined
if (!cfg || !kind) continue
const path =
typeof cfg.path === 'string' && cfg.path
? cfg.path
: newDraftTriggerPath(runnablePath, trigger.type, taken, !!trigger.isPrimary)
taken.push(path)
// `canSave` was the old editor's validity flag on the in-memory config; it
// is not part of a trigger's config and must not reach the deploy call.
const { canSave: _canSave, ...rest } = cfg
UserDraft.save(
kind,
path,
{ ...rest, path, script_path: cfg.script_path ?? runnablePath, is_flow: isFlow },
{ workspace }
)
await UserDraft.forcePersist(kind, path, { workspace })
migrated = true
}
return migrated
}
+1 -31
View File
@@ -44,7 +44,6 @@ import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { userStore } from '$lib/stores'
import { deployTriggers, type Trigger } from '$lib/components/triggers/utils'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils'
import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils'
@@ -404,26 +403,6 @@ export async function fetchDraftBaseStale(
}
}
/**
* Deploy a script/flow draft's trigger changes the same way the editors do.
* Scripts and flows can carry `draft_triggers`; the create/update call below
* deletes the draft row, so without this the saved trigger edits would be
* silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds`
* store is fine — it only tracks kinds for the editor UI). `isNew` forces each
* trigger's `script_path` to the deployed path (matches the editors' new path).
*/
async function deployDraftTriggers(
draftTriggers: Trigger[] | undefined,
workspace: string,
path: string,
isNew: boolean
): Promise<void> {
const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig)
if (triggers.length === 0) return
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew)
}
/**
* Promote a draft to deployed by replaying the editor's create/update call with
* the stored draft value. The matching draft row is deleted server-side by the
@@ -449,7 +428,7 @@ export async function deployDraft(
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
// Drop editor-only / server-managed keys; deploy as a real (non-draft) version.
const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d
const { draft_triggers: _t, draft_only: _o, ...rest } = d
const scriptPath = d.path ?? path
// Deploy at the draft's path so a rename in the draft is honored (same as
// the editor: createScript at the new path with parent_hash links lineage).
@@ -462,8 +441,6 @@ export async function deployDraft(
deployment_message: deploymentMessage
}
})
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
await deployDraftTriggers(draftTriggers, workspace, scriptPath, true)
} else if (kind === 'flow') {
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
@@ -493,13 +470,6 @@ export async function deployDraft(
} else {
await FlowService.updateFlow({ workspace, path, requestBody })
}
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
await deployDraftTriggers(
d.draft_triggers,
workspace,
d.draft_path ?? d.path ?? path,
draftOnly
)
} else if (kind === 'app') {
// `raw_app` is handled above; only visual apps reach here.
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
@@ -16,7 +16,6 @@
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
import { type OtherDraftUser } from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { onDestroy, tick, untrack } from 'svelte'
import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
@@ -115,7 +114,6 @@
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
let loadedFromHistoryFromUrl:
| { flowJobInitial: boolean | undefined; stepsState: Record<string, stepState> }
@@ -141,7 +139,6 @@
// Builder-dependent setup is captured here and applied AFTER the builder
// remounts (see end of loadFlow): during a reload renderEditor is false,
// so flowBuilder is unmounted and direct calls would no-op.
let draftTriggersToApply: Trigger[] | undefined = undefined
let applyPrimarySchedule = false
// `?new_draft=true` (from `/flows/add`'s redirect): a fresh, never-saved
// `draft_{uuid}` path. Skip both fetches (they 404) and seed empty with
@@ -248,7 +245,6 @@
if (forkState.initialArgs) {
initialArgs = forkState.initialArgs
}
draftTriggersFromUrl = forkState.draft_triggers
selectedTriggerIndexFromUrl = forkState.selected_trigger
seedSelectedId = forkState.selectedId
} else if (templatePath) {
@@ -428,8 +424,6 @@
draftSync.draft = flowToRender
}
flowBuilder?.setDraftTriggers(undefined)
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false
@@ -441,7 +435,6 @@
await tick()
if (tok !== loadFlowToken) return
if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(draftTriggersToApply)
flowBuilder?.loadFlowState()
}
@@ -580,7 +573,6 @@
bind:savedFlow
{diffDrawer}
{savedPrimarySchedule}
{draftTriggersFromUrl}
{selectedTriggerIndexFromUrl}
{version}
{draftBaseVersion}
@@ -78,6 +78,7 @@
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
let flow: Flow | undefined = $state()
let can_write = $state(false)
@@ -110,7 +111,8 @@
triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState
triggersState,
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
setContext(
@@ -376,7 +376,7 @@
<div class="text-center text-xs font-semibold text-emphasis mt-2"> No schedules </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft, draft_path } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
@@ -405,7 +405,9 @@
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
<!-- A draft renamed away from its row key deploys to `draft_path`; `path`
stays the key the editor and deploy address it by. -->
schedule: {draft_path ?? path}
</div>
</a>
{#if labels?.length}
@@ -25,6 +25,7 @@
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
import { migrateLegacyDraftTriggers } from '$lib/legacyDraftTriggers'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -381,7 +382,19 @@
if (draftSync.draft) {
initialPath = draftSync.draft.path
scriptBuilder?.setDraftTriggers(draftSync.draft.draft_triggers)
// Drafts written before triggers had their own draft rows carry their
// trigger configs inline; promote them and drop the dead field.
if (draftSync.draft.draft_triggers && $workspaceStore) {
const migrated = await migrateLegacyDraftTriggers({
legacy: draftSync.draft.draft_triggers,
runnablePath: draftSync.draft.path,
isFlow: false,
workspace: $workspaceStore
})
const { draft_triggers: _legacy, ...rest } = draftSync.draft
draftSync.draft = rest
if (migrated) await scriptBuilder?.loadTriggers()
}
scriptBuilder?.setCode(draftSync.draft.content)
}
fullyLoaded = true
@@ -95,6 +95,7 @@
import WacDiagram from '$lib/components/graph/WacDiagram.svelte'
import { twMerge } from 'tailwind-merge'
import CiTestResults from '$lib/components/CiTestResults.svelte'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
let script: Script | undefined = $state()
let topHash: string | undefined = $state()
@@ -137,7 +138,8 @@
triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState
triggersState,
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
async function deleteScript(hash: string): Promise<void> {
+3 -1
View File
@@ -28,6 +28,7 @@
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { StepsInputArgs } from '$lib/components/flows/stepsInputArgs.svelte'
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import { NO_TRIGGER_DRAFT_TARGET } from '$lib/components/triggers/utils'
let token = page.url.searchParams.get('wm_token') ?? undefined
let workspace = page.url.searchParams.get('workspace') ?? undefined
@@ -88,7 +89,8 @@
triggersCount: triggersCount,
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
triggersState: new Triggers(),
draftTarget: () => NO_TRIGGER_DRAFT_TARGET
})
setContext<FlowEditorContext>('FlowEditorContext', {