feat: add a minimal skin for the approval page and slack/teams (#11061)

* feat: add an approval skin to the approval page and slack/teams messages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* chore: point ee-repo-ref at the teams approval skin commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* fix: resolve the approval skin from the step awaiting approval

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* fix: shorten the slack approval message to fit the button value limit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* fix: rename skins to detailed/minimal and keep long slack messages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* feat: title the minimal approval page from the step and flow summaries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* feat: let wait_for_approval set the description approvers see

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* fix: keep a finished workflow's approval description, still gated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* fix: keep a login-required approval locked after the run moves on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* feat: hide the windmill version on the approval page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9

* chore: update ee-repo-ref to e92abc9d1fba3ba898640df0cfeb0af8a50849b4

This commit updates the EE repository reference after PR #786 was merged in windmill-ee-private.

Previous ee-repo-ref: bf1766ff49458f62d3f11746f1a06893ae3c2325

New ee-repo-ref: e92abc9d1fba3ba898640df0cfeb0af8a50849b4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-09-10 18:50:09 +00:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot]
parent e62bfdcd8c
commit 63cb46d7bb
34 changed files with 984 additions and 160 deletions
+1 -1
View File
@@ -1 +1 @@
fe2418ff4e5630d6ad3fd85cd2c865bf51c87a2a
e92abc9d1fba3ba898640df0cfeb0af8a50849b4
+11
View File
@@ -16851,6 +16851,7 @@ paths:
- can_approve
- user_auth_required
- approvers
- skin
properties:
flow_id:
type: string
@@ -16883,6 +16884,16 @@ paths:
hide_cancel:
type: boolean
description: whether to hide the cancel button in the UI
skin:
type: string
enum: [detailed, minimal]
description: how the approval page presents the request
step_summary:
type: string
description: summary of the approval step, for the page title
flow_summary:
type: string
description: summary of the flow or workflow the approval belongs to
approvers:
type: array
items:
+107 -48
View File
@@ -17,6 +17,7 @@ use std::str::FromStr;
use uuid::Uuid;
use windmill_common::cache;
use windmill_common::error::Error;
use windmill_common::flows::{ApprovalSkin, Suspend};
use windmill_common::jobs::JobKind;
use windmill_common::scripts::ScriptHash;
@@ -94,6 +95,17 @@ pub struct ApprovalFormDetails {
pub message_str: String,
pub urls: ResumeUrls,
pub schema: Option<ResumeFormRow>,
pub skin: ApprovalSkin,
}
/// The suspended step an approval message is about, and the flow run it belongs to.
struct ApprovalStep {
created_by: String,
created_at: chrono::NaiveDateTime,
script_path: Option<String>,
parent_job_id: Option<Uuid>,
args: Option<sqlx::types::Json<Box<RawValue>>>,
suspend: Option<Suspend>,
}
#[allow(dead_code)]
@@ -205,6 +217,90 @@ pub async fn get_approval_form_details(
tracing::debug!("Job ID: {:?}", job_id);
let ApprovalStep { created_by, created_at, script_path, parent_job_id, args, suspend } =
fetch_approval_step(&db, w_id, job_id, flow_step_id).await?;
let schema = suspend.as_ref().map(|suspend| ResumeFormRow {
resume_form: suspend.resume_form.clone(),
hide_cancel: suspend.hide_cancel,
});
let skin = suspend.and_then(|s| s.skin).unwrap_or_default();
let bold_format = match format {
MessageFormat::Slack => "*{}*",
MessageFormat::Teams => "**{}**",
};
let message_str = match skin {
ApprovalSkin::Detailed => {
let args_str = args.map_or("None".to_string(), |a| {
serde_json::from_str::<serde_json::Value>(a.get())
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| a.get().to_string())
});
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
let script_path_str = script_path.as_deref().unwrap_or("None");
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let mut message_str = format!(
"A workflow has been suspended and is waiting for approval:\n\n\
{}: {created_by}\n\n\
{}: {created_at_formatted}\n\n\
{}: {script_path_str}\n\n\
{}:\n```\n{args_str}\n```\n\n\
{}: {parent_job_id_str}\n\n",
bold_format.replace("{}", "Created by"),
bold_format.replace("{}", "Created at"),
bold_format.replace("{}", "Script path"),
bold_format.replace("{}", "Args"),
bold_format.replace("{}", "Flow ID")
);
// Append custom message if provided
if let Some(msg) = message {
message_str.push_str(msg);
}
message_str
}
ApprovalSkin::Minimal => format!(
"{}\n\n{}: {created_by}",
message.unwrap_or("Your approval is requested."),
bold_format.replace("{}", "Requested by"),
),
};
tracing::debug!("Schema: {:#?}", schema);
Ok(ApprovalFormDetails { message_str, urls, schema, skin })
}
/// The skin of the approval step `flow_step_id` of the flow running `job_id`. Falls back to
/// the detailed skin when the step cannot be resolved, so a message is still sent.
/// Reads through the unrestricted pool without an authorization check of its own: only the
/// skin, which is not sensitive, leaves this function.
pub(crate) async fn get_approval_step_skin(
db: &DB,
w_id: &str,
job_id: Uuid,
flow_step_id: &str,
) -> ApprovalSkin {
match fetch_approval_step(db, w_id, job_id, Some(flow_step_id)).await {
Ok(step) => step.suspend.and_then(|s| s.skin).unwrap_or_default(),
Err(e) => {
tracing::warn!("Could not resolve approval step {flow_step_id} of job {job_id}: {e}");
ApprovalSkin::default()
}
}
}
async fn fetch_approval_step(
db: &DB,
w_id: &str,
job_id: Uuid,
flow_step_id: Option<&str>,
) -> Result<ApprovalStep, Error> {
// TODO: do we have a helper function for this?
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
"WITH job_info AS (
@@ -240,17 +336,17 @@ pub async fn get_approval_form_details(
job_id,
&w_id
)
.fetch_optional(&db)
.fetch_optional(db)
.await
.map_err(|e| Error::BadRequest(e.to_string()))?
.ok_or_else(|| Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
let flow_data = match cache::job::fetch_flow(&db, &job_kind, script_hash).await {
let flow_data = match cache::job::fetch_flow(db, &job_kind, script_hash).await {
Ok(data) => data,
Err(_) => {
if let Some(parent_job_id) = parent_job_id.as_ref() {
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
cache::job::fetch_preview_flow(db, parent_job_id, raw_flow).await?
} else {
return Err(Error::BadRequest(
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
@@ -265,49 +361,12 @@ pub async fn get_approval_form_details(
tracing::debug!("Module: {:#?}", module);
let schema = module.and_then(|module| {
module.suspend.as_ref().map(|suspend| ResumeFormRow {
resume_form: suspend.resume_form.clone(),
hide_cancel: suspend.hide_cancel,
})
});
let args_str = args.map_or("None".to_string(), |a| {
serde_json::from_str::<serde_json::Value>(a.get())
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| a.get().to_string())
});
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
let script_path_str = script_path.as_deref().unwrap_or("None");
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let bold_format = match format {
MessageFormat::Slack => "*{}*",
MessageFormat::Teams => "**{}**",
};
let mut message_str = format!(
"A workflow has been suspended and is waiting for approval:\n\n\
{}: {created_by}\n\n\
{}: {created_at_formatted}\n\n\
{}: {script_path_str}\n\n\
{}:\n```\n{args_str}\n```\n\n\
{}: {parent_job_id_str}\n\n",
bold_format.replace("{}", "Created by"),
bold_format.replace("{}", "Created at"),
bold_format.replace("{}", "Script path"),
bold_format.replace("{}", "Args"),
bold_format.replace("{}", "Flow ID")
);
// Append custom message if provided
if let Some(msg) = message {
message_str.push_str(msg);
}
tracing::debug!("Schema: {:#?}", schema);
Ok(ApprovalFormDetails { message_str, urls, schema })
Ok(ApprovalStep {
created_by,
created_at,
script_path,
parent_job_id,
args,
suspend: module.and_then(|m| m.suspend.clone()),
})
}
+172 -17
View File
@@ -107,7 +107,10 @@ use windmill_common::{
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
flows::{
add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue,
Suspend,
},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
query_builders,
@@ -4858,6 +4861,11 @@ struct ApprovalInfo {
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
skin: ApprovalSkin,
#[serde(skip_serializing_if = "Option::is_none")]
step_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
flow_summary: Option<String>,
approvers: Vec<Approval>,
/// Share-read-link token for the flow, minted only for callers allowed to view this
/// approval. Lets an authenticated workspace-member approver open the run details of
@@ -4911,6 +4919,48 @@ fn can_approve_step(
}
}
/// The latest approval step the run has passed: a step before the current `step` that ran
/// rather than being skipped. Steps from `step` on don't count, because while an approval is
/// pending the step after it already holds the `WaitingForEvents` status.
fn last_reached_approval_step<'a>(
flow: &'a FlowValue,
status: &FlowStatus,
) -> Option<&'a FlowModule> {
flow.modules
.iter()
.zip(status.modules.iter())
.take(usize::try_from(status.step).unwrap_or(0))
.rev()
.filter(|(_, m)| matches!(m, FlowStatusModule::Success { skipped: false, .. }))
.map(|(module, _)| module)
.find(|module| module.suspend.is_some())
}
/// The approval conditions a step's own settings give, as the worker records them when the step
/// suspends. The worker drops them from the run once the step is approved, so a run that has
/// moved on is gated by these. Groups computed by an expression can't be re-evaluated outside
/// the run, so such a step falls back to any signed-in user.
fn approval_conditions_from_settings(suspend: &Suspend) -> Option<ApprovalConditions> {
let user_auth_required = suspend.user_auth_required.unwrap_or(false);
let self_approval_disabled = suspend.self_approval_disabled.unwrap_or(false);
if !user_auth_required && !self_approval_disabled {
return None;
}
let user_groups_required = match &suspend.user_groups_required {
Some(InputTransform::Static { value }) if user_auth_required => {
serde_json::from_str(value.get()).unwrap_or_default()
}
_ => vec![],
};
Some(ApprovalConditions { user_auth_required, user_groups_required, self_approval_disabled })
}
/// How the approval step presents itself on the approval page.
struct ApprovalStepView {
skin: ApprovalSkin,
summary: Option<String>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
@@ -4939,13 +4989,33 @@ async fn get_approval_info(
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
workflow_as_code_status: Option<serde_json::Value>,
// `v2_job_status` only holds a run that hasn't finished, so the fields below also read
// the completed run's status: a finished run's page keeps its skin and, for workflows as
// code, its description, still gated by the approval conditions the run had.
completed_flow_status: Option<serde_json::Value>,
is_wac: bool,
wac_approval: Option<serde_json::Value>,
approval_conditions: Option<serde_json::Value>,
flow_summary: Option<String>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status, s.workflow_as_code_status
s.flow_status,
c.flow_status AS completed_flow_status,
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status) IS NOT NULL
AS is_wac,
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status)->'_approval'
AS wac_approval,
COALESCE(s.flow_status, c.flow_status)->'approval_conditions'
AS approval_conditions,
NULLIF(COALESCE(f.summary, sc.summary), '') AS flow_summary
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
LEFT JOIN v2_job_completed c ON c.id = j.id
LEFT JOIN flow f
ON j.kind = 'flow' AND f.workspace_id = j.workspace_id AND f.path = j.runnable_path
LEFT JOIN script sc
ON j.kind = 'script' AND sc.workspace_id = j.workspace_id AND sc.hash = j.runnable_id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
@@ -4954,31 +5024,31 @@ async fn get_approval_info(
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.workflow_as_code_status.is_some();
let is_wac = row.is_wac;
let run_ac = row
.approval_conditions
.as_ref()
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
// Extract approval info based on WAC vs classic flow
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel) =
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel, step) =
if is_wac {
let approval_meta = row
.workflow_as_code_status
.as_ref()
.and_then(|v| v.get("_approval"));
let approval_meta = row.wac_approval.as_ref();
let form = approval_meta.and_then(|m| m.get("form").cloned());
let default_args = approval_meta.and_then(|m| m.get("default_args").cloned());
let enums = approval_meta.and_then(|m| m.get("enums").cloned());
let description = approval_meta.and_then(|m| m.get("description").cloned());
let ac = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
(form, description, default_args, enums, ac, None)
let skin = approval_meta
.and_then(|m| m.get("skin"))
.and_then(|v| serde_json::from_value::<ApprovalSkin>(v.clone()).ok())
.unwrap_or_default();
let step = Some(ApprovalStepView { skin, summary: None });
(form, description, default_args, enums, run_ac, None, step)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
@@ -5038,6 +5108,28 @@ async fn get_approval_info(
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
let completed_fs = row
.completed_flow_status
.as_ref()
.filter(|_| fs.is_none())
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let approval_module = raw_flow
.as_ref()
.zip(fs.as_ref().or(completed_fs.as_ref()))
.and_then(|(flow, status)| last_reached_approval_step(flow, status));
let ac = run_ac.or_else(|| {
approval_module
.and_then(|module| module.suspend.as_ref())
.and_then(approval_conditions_from_settings)
});
let step = approval_module.map(|module| ApprovalStepView {
skin: module
.suspend
.as_ref()
.and_then(|s| s.skin)
.unwrap_or_default(),
summary: module.summary.clone().filter(|s| !s.trim().is_empty()),
});
// Fetch description, default_args, and enums from the step's completed job result
let step_job_id = fs
@@ -5061,9 +5153,12 @@ async fn get_approval_info(
(None, None, None)
};
(form, desc, default_args, enums, ac, hc)
(form, desc, default_args, enums, ac, hc, step)
};
let skin = step.as_ref().map(|s| s.skin).unwrap_or_default();
let step_summary = step.and_then(|s| s.summary);
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
@@ -5093,6 +5188,9 @@ async fn get_approval_info(
can_approve: false,
user_auth_required,
hide_cancel: None,
skin,
step_summary: None,
flow_summary: None,
approvers: vec![],
view_token: None,
}));
@@ -5128,6 +5226,9 @@ async fn get_approval_info(
can_approve,
user_auth_required,
hide_cancel,
skin,
step_summary,
flow_summary: row.flow_summary,
approvers,
view_token,
}))
@@ -11908,4 +12009,58 @@ mod approval_view_gate_tests {
"trigger@example.com"
));
}
#[test]
fn approval_step_is_the_last_one_passed() {
let flow: FlowValue = serde_json::from_value(serde_json::json!({ "modules": [
{ "id": "a", "value": { "type": "identity" }, "suspend": {} },
{ "id": "b", "value": { "type": "identity" }, "suspend": {} },
{ "id": "c", "value": { "type": "identity" } }
]}))
.unwrap();
let step_at = |step: i32, types: [(&str, bool); 3]| {
let mut status = FlowStatus::new(&flow);
status.step = step;
status.modules = ["a", "b", "c"]
.into_iter()
.zip(types)
.map(|(id, (kind, skipped))| {
serde_json::from_value(serde_json::json!({
"type": kind, "id": id, "job": Uuid::nil(), "count": 1,
"failed_retries": [], "skipped": skipped
}))
.unwrap()
})
.collect();
last_reached_approval_step(&flow, &status).map(|module| module.id.clone())
};
let waiting = ("WaitingForEvents", false);
let pending = ("WaitingForPriorSteps", false);
let ran = ("Success", false);
let skipped = ("Success", true);
// Awaiting a's approval: b, itself an approval step, already holds `WaitingForEvents`.
assert_eq!(step_at(1, [ran, waiting, pending]).as_deref(), Some("a"));
assert_eq!(step_at(2, [ran, ran, waiting]).as_deref(), Some("b"));
assert_eq!(step_at(3, [ran, skipped, ran]).as_deref(), Some("a"));
assert_eq!(step_at(0, [pending, pending, pending]), None);
}
#[test]
fn approved_step_stays_gated_by_its_settings() {
let from_settings = |suspend: serde_json::Value| {
approval_conditions_from_settings(&serde_json::from_value(suspend).unwrap())
};
let login = from_settings(serde_json::json!({
"user_auth_required": true,
"user_groups_required": { "type": "static", "value": ["approvers"] }
}));
assert!(!can_view(
&None,
&login,
Some("f/team/flow"),
"trigger@example.com"
));
assert_eq!(login.unwrap().user_groups_required, ["approvers"]);
assert!(from_settings(serde_json::json!({})).is_none());
}
}
+185 -45
View File
@@ -13,19 +13,25 @@ use sha2::Sha256;
use sqlx::types::Uuid;
use std::collections::HashMap;
use windmill_common::error::{to_anyhow, Error};
use windmill_common::flows::ApprovalSkin;
use windmill_common::utils::truncate_with_ellipsis;
use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key};
use crate::db::{ApiAuthed, DB};
use crate::jobs::{QueryApprover, ResumeUrls};
use crate::{
approvals::{
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
MessageFormat, QueryButtonText, QueryDefaultArgsJson, QueryDynamicEnumJson,
QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
extract_w_id_from_resume_url, get_approval_step_skin, handle_resume_action,
ApprovalFormDetails, FieldType, MessageFormat, QueryButtonText, QueryDefaultArgsJson,
QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
},
auth::OptTokened,
};
// Slack rejects a button value over 2000 characters, and with it the whole post. The button value
// carries the message on to the modal, so the message is shortened to fit.
const SLACK_BUTTON_VALUE_MAX_CHARS: usize = 2000;
#[derive(Deserialize, Debug)]
pub struct SlackFormData {
payload: String,
@@ -127,6 +133,9 @@ struct PrivateMetadata {
// HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is
// built, required by `handle_submission` before the resource_path is decrypted.
signature: Option<String>,
// Only selects the wording of the updated channel message, so it is left unsigned.
#[serde(default)]
skin: ApprovalSkin,
}
// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify
@@ -432,6 +441,7 @@ async fn handle_submission(
let container: Container = private_metadata.container;
let hide_cancel = private_metadata.hide_cancel;
let signature = private_metadata.signature;
let skin = private_metadata.skin;
// If hide_cancel is true, we don't need to extract information from the private_metadata
if hide_cancel.unwrap_or(false) && action == "cancel" {
@@ -463,7 +473,7 @@ async fn handle_submission(
tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}");
Error::BadRequest("Invalid Slack callback request".to_string())
})?;
update_original_slack_message(action, slack_token, container).await?;
update_original_slack_message(action, slack_token, container, skin).await?;
Ok(())
}
@@ -475,14 +485,19 @@ async fn transform_schemas(
required: Option<Vec<String>>,
default_args_json: Option<&serde_json::Value>,
dynamic_enums_json: Option<&serde_json::Value>,
skin: ApprovalSkin,
) -> Result<serde_json::Value, Error> {
tracing::debug!("Resume urls: {:#?}", urls);
let link_label = match skin {
ApprovalSkin::Detailed => "Flow suspension details",
ApprovalSkin::Minimal => "View in Windmill",
};
let mut blocks = vec![serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("{}\n<{}|Flow suspension details>", text, urls.approvalPage),
"text": format!("{}\n<{}|{link_label}>", text, urls.approvalPage),
}
})];
@@ -918,10 +933,6 @@ async fn send_slack_message(
value["approver"] = serde_json::json!(approver);
}
if let Some(message) = message {
value["message"] = serde_json::json!(message);
}
if let Some(default_args_json) = default_args_json {
value["default_args_json"] = default_args_json.clone();
}
@@ -950,33 +961,8 @@ async fn send_slack_message(
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
value["signature"] = serde_json::json!(signature);
let payload = serde_json::json!({
"channel": channel_id,
"text": "A flow has been suspended. Please approve or reject the flow.",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "A flow has been suspended. Please approve or reject the flow."
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View"
},
"action_id": "open_modal",
"value": value.to_string()
}
]
}
]
});
let skin = get_approval_step_skin(db, w_id, job_id, flow_step_id).await;
let payload = channel_message_payload(channel_id, skin, message, value);
tracing::debug!("Payload: {:?}", payload);
@@ -1000,6 +986,88 @@ async fn send_slack_message(
Ok(StatusCode::OK)
}
/// The channel post announcing the approval. Its button hands `button_value` to the modal, with
/// `message` added, shortened to what Slack's button value limit leaves room for.
fn channel_message_payload(
channel_id: &str,
skin: ApprovalSkin,
message: Option<&str>,
mut button_value: serde_json::Value,
) -> serde_json::Value {
let message = message.map(|m| message_fitting_button_value(&button_value, m));
if let Some(message) = &message {
button_value["message"] = serde_json::json!(message);
}
let (text, section, button_label) = match skin {
ApprovalSkin::Detailed => {
let text = "A flow has been suspended. Please approve or reject the flow.";
(text, text.to_string(), "View")
}
ApprovalSkin::Minimal => {
let mut section = "*Approval requested*".to_string();
if let Some(message) = &message {
section.push('\n');
section.push_str(message);
}
("Approval requested", section, "Review")
}
};
serde_json::json!({
"channel": channel_id,
"text": text,
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": section
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": button_label
},
"action_id": "open_modal",
"value": button_value.to_string()
}
]
}
]
})
}
/// The longest prefix of `message` that keeps `button_value` carrying it within Slack's limit.
fn message_fitting_button_value(button_value: &serde_json::Value, message: &str) -> String {
let mut with_message = button_value.clone();
let mut fits = |max_chars: usize| {
let fitted = truncate_with_ellipsis(message, max_chars);
with_message["message"] = serde_json::json!(fitted);
(with_message.to_string().chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS).then_some(fitted)
};
if let Some(whole) = fits(usize::MAX) {
return whole;
}
// Searched on the serialized length, which escaping makes longer than the raw prefix, and
// which grows with every character kept.
let (mut shortest, mut longest) =
(0, message.chars().count().min(SLACK_BUTTON_VALUE_MAX_CHARS));
while shortest < longest {
let mid = (shortest + longest + 1) / 2;
if fits(mid).is_some() {
shortest = mid;
} else {
longest = mid - 1;
}
}
fits(shortest).unwrap_or_else(|| truncate_with_ellipsis(message, 0))
}
async fn get_modal_blocks(
db: DB,
w_id: &str,
@@ -1034,7 +1102,7 @@ async fn get_modal_blocks(
)
.await?;
let ApprovalFormDetails { message_str, urls, schema } = approval_details;
let ApprovalFormDetails { message_str, urls, schema, skin } = approval_details;
// Get the card content
let card_content = transform_schemas(
@@ -1063,6 +1131,7 @@ async fn get_modal_blocks(
}),
default_args_json,
dynamic_enums_json,
skin,
)
.await?;
@@ -1077,6 +1146,7 @@ async fn get_modal_blocks(
resume_button_text,
cancel_button_text,
&private_metadata_signature,
skin,
)))
}
@@ -1090,27 +1160,32 @@ fn construct_payload(
resume_button_text: Option<&str>,
cancel_button_text: Option<&str>,
signature: &str,
skin: ApprovalSkin,
) -> serde_json::Value {
let (title, resume_label, cancel_label) = match skin {
ApprovalSkin::Detailed => ("Workflow Suspended", "Resume Workflow", "Cancel Workflow"),
ApprovalSkin::Minimal => ("Approval request", "Approve", "Reject"),
};
let mut view = serde_json::json!({
"type": "modal",
"callback_id": "submit_form",
"notify_on_close": true,
"title": {
"type": "plain_text",
"text": "Workflow Suspended"
"text": title
},
"blocks": blocks,
"submit": {
"type": "plain_text",
"text": resume_button_text.unwrap_or("Resume Workflow")
"text": resume_button_text.unwrap_or(resume_label)
},
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature }).to_string(),
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature, "skin": skin }).to_string(),
});
if !hide_cancel {
view["close"] = serde_json::json!({
"type": "plain_text",
"text": cancel_button_text.unwrap_or("Cancel Workflow")
"text": cancel_button_text.unwrap_or(cancel_label)
});
}
@@ -1193,11 +1268,13 @@ async fn update_original_slack_message(
action: &str,
token: String,
container: Container,
skin: ApprovalSkin,
) -> Result<(), Error> {
let message = if action == "resume" {
"\n\n*Workflow has been resumed!* :white_check_mark:"
} else {
"\n\n*Workflow has been canceled!* :x:"
let message = match (skin, action == "resume") {
(ApprovalSkin::Detailed, true) => "\n\n*Workflow has been resumed!* :white_check_mark:",
(ApprovalSkin::Detailed, false) => "\n\n*Workflow has been canceled!* :x:",
(ApprovalSkin::Minimal, true) => "*Approved* :white_check_mark:",
(ApprovalSkin::Minimal, false) => "*Rejected* :x:",
};
let final_blocks = vec![serde_json::json!({
@@ -1242,3 +1319,66 @@ async fn update_original_slack_message(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn long_message_keeps_the_channel_post_within_slack_limits() {
let button_value = serde_json::json!({
"w_id": "demo",
"job_id": Uuid::nil(),
"path": "u/admin/slack",
"channel": "C0123456789",
"flow_step_id": "a",
"signature": "f".repeat(64),
});
let carried = |skin, message: &str| {
let payload = channel_message_payload("C1", skin, Some(message), button_value.clone());
let button = payload["blocks"][1]["elements"][0]["value"]
.as_str()
.unwrap()
.to_string();
assert!(button.chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS);
let section = payload["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(section.chars().count() <= 3000);
serde_json::from_str::<ModalActionValue>(&button)
.unwrap()
.message
.unwrap()
};
// Quotes and newlines each cost two characters once escaped into the button value.
let message = "Expense \"offsite\" line\n".repeat(1000);
for skin in [ApprovalSkin::Detailed, ApprovalSkin::Minimal] {
let kept = carried(skin, &message);
let kept = kept.strip_suffix("...").unwrap();
assert!(message.starts_with(kept));
assert!(kept.chars().count() > 1_000);
assert_eq!(carried(skin, "Short message"), "Short message");
}
}
#[test]
fn minimal_skin_survives_the_modal_round_trip() {
let container = Container { message_ts: "1".to_string(), channel_id: "C1".to_string() };
let payload = construct_payload(
serde_json::json!([]),
false,
"trigger",
"https://example.com/resume",
"u/admin/slack",
container,
None,
None,
"signature",
ApprovalSkin::Minimal,
);
let view = &payload["view"];
assert_eq!(view["submit"]["text"], "Approve");
assert_eq!(view["close"]["text"], "Reject");
let metadata: PrivateMetadata =
serde_json::from_str(view["private_metadata"].as_str().unwrap()).unwrap();
assert_eq!(metadata.skin, ApprovalSkin::Minimal);
}
}
+31
View File
@@ -538,6 +538,20 @@ pub struct Suspend {
pub hide_cancel: Option<bool>,
#[serde(skip_serializing_if = "false_or_empty")]
pub continue_on_disapprove_timeout: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skin: Option<ApprovalSkin>,
}
/// How an approval request is presented, on the approval page and in Slack/Teams messages.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalSkin {
Minimal,
/// A skin this server does not know renders as the detailed one rather than failing to
/// deserialize the whole flow, so a flow authored against a newer version still runs.
#[default]
#[serde(other)]
Detailed,
}
fn false_or_empty(v: &Option<bool>) -> bool {
@@ -1365,6 +1379,23 @@ mod tests {
assert_eq!(val.modules.len(), 1);
}
#[test]
fn suspend_skin_unknown_value_falls_back_to_detailed() {
let skin_of = |skin: &str| {
let val: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {"type": "identity"},
"suspend": {"required_events": 1, "skin": skin}
}]
}))
.unwrap();
val.modules[0].suspend.as_ref().unwrap().skin
};
assert_eq!(skin_of("minimal"), Some(ApprovalSkin::Minimal));
assert_eq!(skin_of("not_a_skin_yet"), Some(ApprovalSkin::Detailed));
}
#[test]
fn agent_tool_keeps_description_through_locking() {
// #10244: the dependency job rebuilds each tool from its locked FlowModule; the
+7 -3
View File
@@ -1975,7 +1975,7 @@ async function run() {{
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
}}
if (dispatch.mode === "approval") {{
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled, skin: dispatch.skin, description: dispatch.description }};
}}
if (dispatch.mode === "sleep") {{
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
@@ -3206,7 +3206,7 @@ pub async fn handle_wac_v2_output(
job.id, num_steps
)))
}
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
WacOutput::Approval { key, timeout, form, self_approval_disabled, skin, description } => {
let db = match conn {
Connection::Sql(db) => db,
_ => {
@@ -3322,15 +3322,19 @@ pub async fn handle_wac_v2_output(
};
// Store approval form metadata for the approval page endpoint
let approval_meta = serde_json::json!({
let mut approval_meta = serde_json::json!({
"key": key,
"form": form,
"timeout": timeout_secs as u32,
"self_approval_disabled": sad,
"skin": skin.unwrap_or_default(),
"resume": resume_url,
"cancel": cancel_url,
"approvalPage": approval_page_url,
});
if let Some(description) = description.filter(|d| !d.is_null()) {
approval_meta["description"] = description;
}
sqlx::query(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
COALESCE(workflow_as_code_status, '{}'::jsonb),
@@ -46,6 +46,10 @@ pub enum WacOutput {
form: Option<Value>,
#[serde(default)]
self_approval_disabled: Option<bool>,
#[serde(default)]
skin: Option<windmill_common::flows::ApprovalSkin>,
#[serde(default)]
description: Option<Value>,
},
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
#[serde(rename = "sleep")]
+31 -7
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 48 registered actions across eighteen features (`ai_session`, `ai_chat`,
It currently carries 49 registered actions across eighteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
`usage_meter`, `sso_groups_claim`). Nearly all of the
@@ -14,6 +14,8 @@
centerVertically?: boolean
loading?: boolean
containOverflow?: boolean
/** The Windmill version and update notice in the header. */
showVersion?: boolean
children?: import('svelte').Snippet
}
@@ -25,6 +27,7 @@
centerVertically = true,
loading = false,
containOverflow = false,
showVersion = true,
children
}: Props = $props()
@@ -82,5 +85,5 @@
{/if}
</div>
<LoginPageHeader />
<LoginPageHeader {showVersion} />
</div>
@@ -1083,10 +1083,11 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
the flow editor, which skin approval steps are given, how data tables and their
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, and the name of any public hub project imported from the home page and how far
that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1146,10 +1147,11 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
the flow editor, which skin approval steps are given, how data tables and their
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, and the name of any public hub project imported from the home page and how far
that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -9,9 +9,10 @@
interface Props {
/** Off for the login page, which puts the mark and the instance name in the middle. */
showBrand?: boolean
showVersion?: boolean
}
let { showBrand = true }: Props = $props()
let { showBrand = true, showVersion = true }: Props = $props()
</script>
<div class="absolute top-0 inset-x-0 flex items-center justify-between gap-2 px-4 py-2">
@@ -31,9 +32,11 @@
<div class="flex flex-row gap-2 text-2xs text-gray-800 italic">
<DarkModeToggle forcedDarkMode={false} />
<div class="font-mono flex-col flex p-2 justify-center">
<Version />
<Uptodate />
</div>
{#if showVersion}
<div class="font-mono flex-col flex p-2 justify-center">
<Version />
<Uptodate />
</div>
{/if}
</div>
</div>
@@ -0,0 +1,219 @@
<script lang="ts">
import type { GetApprovalInfoResponse, Job } from '$lib/gen'
import { Alert, Badge, Button } from '$lib/components/common'
import type { BadgeColor } from '$lib/components/common'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Login from '$lib/components/Login.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { emptyString } from '$lib/utils'
import { mergeSchema } from '$lib/common'
import { CheckCircle2, CircleSlash, ExternalLink, XCircle } from 'lucide-svelte'
interface Props {
approvalInfo: GetApprovalInfoResponse & { enums?: Record<string, unknown> }
job: Job | undefined
completed: boolean
actionTaken: 'approved' | 'denied' | undefined
loading: boolean
schema: any
hasForm: boolean
args: any
valid: boolean
isLocked: boolean
isSelfApprovalBypass: boolean
isWorkspaceMember: boolean
runDetailsHref: string
rd: string
onApprove: () => void
onReject: () => void
}
let {
approvalInfo,
job,
completed,
actionTaken,
loading,
schema,
hasForm,
args = $bindable(),
valid = $bindable(),
isLocked,
isSelfApprovalBypass,
isWorkspaceMember,
runDetailsHref,
rd,
onApprove,
onReject
}: Props = $props()
type Status = 'pending' | 'approved' | 'rejected' | 'closed'
const STATUS_BADGE: Record<Status, { label: string; color: BadgeColor }> = {
pending: { label: 'Pending', color: 'yellow' },
approved: { label: 'Approved', color: 'green' },
rejected: { label: 'Rejected', color: 'red' },
closed: { label: 'Closed', color: 'gray' }
}
let status: Status = $derived(
actionTaken === 'approved'
? 'approved'
: actionTaken === 'denied'
? 'rejected'
: completed
? 'closed'
: 'pending'
)
// The page title leads with the step's summary, falling back to the flow's: name the flow here
// unless the title already does, and show the raw path only when the flow has no summary.
let context = $derived(
approvalInfo.flow_summary
? approvalInfo.step_summary
? approvalInfo.flow_summary
: undefined
: job?.script_path
)
let groupsRequired = $derived(approvalInfo.approval_conditions?.user_groups_required ?? [])
let isSelfApprovalRefused = $derived(
!!approvalInfo.approval_conditions?.self_approval_disabled &&
!!$userStore &&
$userStore.email === job?.email
)
</script>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
{#if context}
<span
class={approvalInfo.flow_summary
? 'text-xs font-semibold text-emphasis'
: 'text-2xs font-mono font-normal text-emphasis break-all'}
>
{context}
</span>
{/if}
{#if job}
<p class="text-xs font-normal text-secondary">
Requested by {job.created_by} · <TimeAgo date={job.created_at ?? ''} noSeconds />
</p>
{/if}
</div>
<Badge color={STATUS_BADGE[status].color}>{STATUS_BADGE[status].label}</Badge>
</div>
{#if typeof approvalInfo.description === 'string'}
<p class="text-xs font-normal text-primary whitespace-pre-wrap">{approvalInfo.description}</p>
{:else if approvalInfo.description != undefined}
<DisplayResult noControls result={approvalInfo.description} />
{/if}
{#if status === 'pending'}
{#if hasForm}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
bind:isValid={valid}
schema={mergeSchema(schema, approvalInfo.enums ?? {})}
bind:args
/>
{/if}
{/if}
{#if approvalInfo.can_approve}
<div class="flex flex-row flex-wrap justify-between gap-4">
{#if approvalInfo.hide_cancel !== true}
<Button
unifiedSize="lg"
variant="default"
destructive
onclick={onReject}
disabled={loading}
>
Reject
</Button>
{:else}
<div></div>
{/if}
<Button unifiedSize="lg" variant="accent" onclick={onApprove} disabled={!valid || loading}>
Approve
</Button>
</div>
{#if isSelfApprovalBypass}
<Alert type="warning" title="Warning">
As an administrator, by approving or rejecting this request, you bypass the self-approval
interdiction.
</Alert>
{/if}
{:else if approvalInfo.user_auth_required && !$userStore}
<p class="text-xs font-normal text-primary">Sign in to review this request.</p>
<Login {rd} />
{:else}
<div class="flex flex-col gap-1 text-xs font-normal text-primary">
<p>You are not authorized to approve this request.</p>
{#if isSelfApprovalRefused}
<p>Self-approval is disabled for this step.</p>
{/if}
{#if groupsRequired.length > 0}
<p>
Only members of the following groups can approve:
<span class="font-semibold text-emphasis">{groupsRequired.join(', ')}</span>
</p>
{/if}
</div>
{/if}
{:else}
<div class="flex flex-row items-start gap-3 rounded-md bg-surface-secondary p-4">
{#if status === 'approved'}
<CheckCircle2 size={20} class="shrink-0 text-green-500" />
{:else if status === 'rejected'}
<XCircle size={20} class="shrink-0 text-red-500" />
{:else}
<CircleSlash size={20} class="shrink-0 text-secondary" />
{/if}
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-emphasis">
{status === 'closed' ? 'This request is closed' : STATUS_BADGE[status].label}
</span>
<span class="text-xs font-normal text-secondary">
{#if status === 'approved'}
Your approval was recorded. You can close this page.
{:else if status === 'rejected'}
Your rejection was recorded. You can close this page.
{:else}
The flow is no longer waiting for approval.
{/if}
</span>
</div>
</div>
{/if}
{#if !isLocked && ((status === 'pending' && approvalInfo.approvers.length > 0) || isWorkspaceMember)}
<div
class="flex flex-row flex-wrap items-center justify-between gap-2 border-t border-border-light pt-4"
>
<span class="text-2xs font-normal text-secondary">
{#if status === 'pending' && approvalInfo.approvers.length > 0}
Already approved by {approvalInfo.approvers.map((a) => a.approver).join(', ')}
{/if}
</span>
{#if isWorkspaceMember}
<Button
unifiedSize="xs"
variant="subtle"
href={runDetailsHref}
target="_blank"
endIcon={{ icon: ExternalLink }}
>
Run details
</Button>
{/if}
</div>
{/if}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,13 @@
import SuspendDrawer from './SuspendDrawer.svelte'
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Pen, Plus } from 'lucide-svelte'
import { slideDynamic } from '$lib/transitions'
import { logFeatureUsage } from '$lib/utils/featureUsage'
type ApprovalSkin = NonNullable<NonNullable<FlowModule['suspend']>['skin']>
const { selectionManager, flowStateStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -81,6 +86,12 @@
}
formEditor?.openDrawer()
}
function setSkin(skin: ApprovalSkin) {
if (!flowModule.suspend) return
flowModule.suspend.skin = skin === 'detailed' ? undefined : skin
logFeatureUsage('flow_step', 'approval_skin', { key: skin })
}
</script>
<div class="flex w-full flex-col gap-2">
@@ -134,6 +145,34 @@
<SecondsInput disabled />
{/if}
</Label>
<Label label="Approval page skin">
<ToggleButtonGroup
noWFull
selected={flowModule.suspend?.skin ?? 'detailed'}
disabled={!flowModule.suspend}
onSelected={setSkin}
>
{#snippet children({ item })}
<ToggleButton
value="detailed"
label="Detailed"
tooltip="The request plus the flow's details: arguments, graph and approvers"
{item}
small
/>
<ToggleButton
value="minimal"
label="Minimal"
tooltip="Only the request: step description, form and approve/reject buttons"
{item}
small
/>
{/snippet}
</ToggleButtonGroup>
<span class="text-2xs font-normal text-secondary">
Slack and Teams approval messages use the same skin
</span>
</Label>
<Toggle
size="xs"
@@ -20,6 +20,7 @@
import DisplayResult from '$lib/components/DisplayResult.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import MinimalApprovalSkin from '$lib/components/approvals/MinimalApprovalSkin.svelte'
import { page } from '$app/state'
$workspaceStore = page.params.workspace
@@ -163,6 +164,15 @@
return url
})
let isWac = $derived(!!(job as any)?.workflow_as_code_status)
let skin = $derived(approvalInfo?.skin ?? 'detailed')
// Left blank until the approval info names the skin, so neither skin flashes the other's title.
let title = $derived(
!approvalInfo && !error
? ''
: skin === 'minimal'
? approvalInfo?.step_summary || approvalInfo?.flow_summary || 'Approval request'
: `Approval for resuming of ${isWac ? 'workflow' : 'flow'}`
)
let filteredArgs = $derived.by(() => {
if (!job?.args) return job?.args
const args = { ...(job.args as any) }
@@ -211,8 +221,10 @@
<ScheduleEditor bind:this={scheduleEditor} />
<CenteredModal
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
{title}
loading={!approvalInfo && !error}
centerVertically={false}
showVersion={false}
>
{#if error}
<div class="space-y-6">
@@ -237,6 +249,25 @@
<p class="text-sm">{error}</p>
{/if}
</div>
{:else if approvalInfo && skin === 'minimal'}
<MinimalApprovalSkin
{approvalInfo}
{job}
{completed}
{actionTaken}
{loading}
{schema}
{hasForm}
bind:args={default_payload}
bind:valid
{isLocked}
isSelfApprovalBypass={!!isSelfApprovalBypass}
{isWorkspaceMember}
{runDetailsHref}
{rd}
onApprove={resume}
onReject={cancel}
/>
{:else if approvalInfo}
{#if !isLocked}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
+8
View File
@@ -362,6 +362,14 @@ components:
continue_on_disapprove_timeout:
type: boolean
description: If true, continue flow on timeout instead of canceling
skin:
type: string
enum: [detailed, minimal]
description: >-
How the approval request is presented, on the approval page and in Slack/Teams
approval messages. 'detailed' (used when unset) shows the flow details
(arguments, graph, approvers); 'minimal' shows only the request: the step
description, form and approve/reject actions
priority:
type: number
description: Execution priority for this step (higher numbers run first)
+18 -1
View File
@@ -3085,6 +3085,8 @@ class WorkflowCtx:
form: dict | None = None,
self_approval: bool = True,
key: str | None = None,
skin: str | None = None,
description: str | dict | None = None,
):
if key is not None:
_assert_usable_step_key(key, "wait_for_approval key")
@@ -3113,6 +3115,8 @@ class WorkflowCtx:
"timeout": timeout,
"form": form,
"self_approval_disabled": not self_approval,
"skin": skin,
"description": description,
"steps": [],
})
@@ -3559,6 +3563,8 @@ async def wait_for_approval(
form: dict | None = None,
self_approval: bool = True,
key: str | None = None,
skin: Literal["detailed", "minimal"] | None = None,
description: str | dict | None = None,
) -> dict:
"""Suspend the workflow and wait for an external approval.
@@ -3573,6 +3579,10 @@ async def wait_for_approval(
form: Optional form schema for the approval page.
self_approval: Whether the user who triggered the flow can approve it (default True).
key: Optional checkpoint key naming this approval step.
skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
instead of the detailed page with the workflow's details.
description: Shown to approvers above the form: a string, or a rich value such as
``{"markdown": "..."}``.
Example::
@@ -3583,7 +3593,12 @@ async def wait_for_approval(
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
return await ctx._wait_for_approval(
timeout=timeout, form=form, self_approval=self_approval, key=key
timeout=timeout,
form=form,
self_approval=self_approval,
key=key,
skin=skin,
description=description,
)
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
@@ -3653,6 +3668,8 @@ async def _run_workflow_async(func, checkpoint: dict, input_args: dict):
"key": info["key"],
"timeout": info.get("timeout"),
"form": info.get("form"),
"skin": info.get("skin"),
"description": info.get("description"),
}
if mode == "sleep":
return {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -1927,12 +1927,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -2801,13 +2805,17 @@ async def sleep(seconds: int)
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
# instead of the detailed page with the workflow's details.
# description: Shown to approvers above the form: a string, or a rich value such as
# ``{"markdown": "..."}``.
#
# Example::
#
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
+5 -1
View File
@@ -749,13 +749,17 @@ async def sleep(seconds: int)
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
# instead of the detailed page with the workflow's details.
# description: Shown to approvers above the form: a string, or a rich value such as
# ``{"markdown": "..."}``.
#
# Example::
#
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -492,12 +492,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -135,13 +135,17 @@ async def sleep(seconds: int)
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
# instead of the detailed page with the workflow's details.
# description: Shown to approvers above the form: a string, or a rich value such as
# ``{"markdown": "..."}``.
#
# Example::
#
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
#
@@ -117,12 +117,16 @@ export async function sleep(seconds: number): Promise<void>
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
File diff suppressed because one or more lines are too long
@@ -663,12 +663,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -663,12 +663,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -665,12 +665,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -934,13 +934,17 @@ async def sleep(seconds: int)
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
# instead of the detailed page with the workflow's details.
# description: Shown to approvers above the form: a string, or a rich value such as
# ``{"markdown": "..."}``.
#
# Example::
#
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -360,12 +360,16 @@ export async function sleep(seconds: number): Promise<void>
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
@@ -541,13 +545,17 @@ async def sleep(seconds: int)
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
# instead of the detailed page with the workflow's details.
# description: Shown to approvers above the form: a string, or a rich value such as
# ``{"markdown": "..."}``.
#
# Example::
#
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
#
+10
View File
@@ -1962,6 +1962,8 @@ export class WorkflowCtx {
form?: object;
selfApproval?: boolean;
key?: string;
skin?: "detailed" | "minimal";
description?: string | object;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
this._rethrowSwallowed();
if (options?.key !== undefined) assertUsableStepKey(options.key, "waitForApproval key");
@@ -1996,6 +1998,8 @@ export class WorkflowCtx {
timeout: options?.timeout ?? 1800,
form: options?.form,
self_approval_disabled: !(options?.selfApproval ?? true),
skin: options?.skin,
description: options?.description,
steps: [],
});
}
@@ -2459,6 +2463,10 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
* resume exactly this approval route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
* instead of the detailed page with the workflow's details. `description` is
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
@@ -2469,6 +2477,8 @@ export function waitForApproval(options?: {
form?: object;
selfApproval?: boolean;
key?: string;
skin?: "detailed" | "minimal";
description?: string | object;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
if (!ctx) {