mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 08:02:31 +00:00
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:
co-authored by
Claude Opus 5
parent
33d28456cc
commit
20226a15b6
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
+23
-24
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user