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
+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);
}
}