mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(workflows): add responsive library card actions (#6008)
**Category:** improvement **User Impact:** Users can scan what each workflow does and trigger, edit, duplicate, enable, disable, or delete it directly from the library. **Problem:** The workflow list buried common actions and did not expose each automation's trigger-to-action shape at a glance. **Solution:** Add a responsive workflow library with a persistent create tile, compact trigger/action diagrams, prominent workflow titles with supporting descriptions, and shared card actions while preserving existing detail, editor, and run-history entry points. Card toggles refresh both list and open-detail caches so status and definition stay consistent. <details> <summary>File changes</summary> **desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx** Adds a shared card menu for trigger, edit, duplicate, enable/disable, and delete actions. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Reworks cards around the prototype's visual hierarchy: color-coded trigger, action flow, sentence-case eyebrow, prominent title, supporting description, status, channel, and update date without a footer clock icon. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Adds the responsive grid, create tile, mutation wiring, and list/detail cache invalidation. Container breakpoints keep cards two-across at medium widths and three-across in the 1280px desktop layout. **desktop/src/features/workflows/ui/workflowDefinition.ts** Adds immutable enabled-state updates plus narrow trigger and first-action readers used only to select card icons. **desktop/src/features/workflows/ui/workflowDefinition.test.mjs** Covers neutral icon selection, enabled-state immutability, and status presentation. **desktop/tests/e2e/workflows.spec.ts** Covers the create tile, title/description hierarchy, selected-card enable/disable consistency, and deterministic narrow/medium/wide captures while retaining existing action coverage. </details> ## Reproduction steps 1. Open **Workflows** and confirm the create tile stays first as cards flow from one to three columns with available width. 2. Confirm each card shows a sentence-case trigger eyebrow, prominent workflow title, supporting description when present, status, channel, and update date without a clock icon. 3. Open a card's overflow menu and trigger, edit, duplicate, enable/disable, or delete the workflow. 4. Leave the detail panel open while toggling and confirm its badge and JSON definition update with the card. ## Screenshots Real built E2E UI with representative workflow data at three viewport sizes. ### Narrow — 800 × 720  ### Medium — 1024 × 720  ### Wide — 1280 × 720  ### Card actions  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -126,8 +126,21 @@ pub async fn cmd_update_workflow(
|
||||
let wf_uuid = parse_uuid(workflow_id)?;
|
||||
let yaml_definition = read_or_stdin(yaml)?;
|
||||
|
||||
let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition)
|
||||
.map_err(sdk_err)?;
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#d": [workflow_id]
|
||||
});
|
||||
let resp = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
let expected_revision = events
|
||||
.first()
|
||||
.and_then(|event| event.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or_else(|| CliError::NotFound(format!("workflow {workflow_id} not found")))?;
|
||||
|
||||
let builder =
|
||||
buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition, expected_revision)
|
||||
.map_err(sdk_err)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
|
||||
@@ -100,19 +100,18 @@ enum PersistResult {
|
||||
/// operations (open_dm, hide_dm, update_approval, upsert_workflow).
|
||||
#[datastore_span(name = "persist_command_event", system = "postgresql")]
|
||||
async fn persist_command_event(
|
||||
state: &Arc<AppState>,
|
||||
db: &buzz_db::Db,
|
||||
tenant: &TenantContext,
|
||||
event: &Event,
|
||||
channel_id_override: Option<Uuid>,
|
||||
) -> Result<PersistResult, IngestError> {
|
||||
let channel_id = channel_id_override.or_else(|| extract_channel_id(event));
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
let mut tx = db
|
||||
.begin_transaction()
|
||||
.await
|
||||
.map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?;
|
||||
buzz_deletion::store(&state.db)
|
||||
buzz_deletion::store(db)
|
||||
.guard_transaction(&mut tx, tenant.community())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -188,10 +187,28 @@ async fn persist_command_event(
|
||||
.map_err(|e| IngestError::Internal(format!("error: query event coordinate: {e}")))?;
|
||||
|
||||
let incoming_id = event.id.as_bytes().as_slice();
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|(_, existing_id)| existing_id.as_slice() == incoming_id)
|
||||
{
|
||||
return Ok(PersistResult::Duplicate);
|
||||
}
|
||||
|
||||
let expected_revision = extract_tag(event, "expected-revision");
|
||||
validate_workflow_revision(
|
||||
kind_i32,
|
||||
expected_revision.as_deref(),
|
||||
existing.as_ref().map(|(_, id)| id.as_slice()),
|
||||
)?;
|
||||
if let Some((existing_ts, existing_id)) = existing {
|
||||
let dominated = created_at < existing_ts
|
||||
|| (created_at == existing_ts && incoming_id >= existing_id.as_slice());
|
||||
if dominated {
|
||||
if kind_i32 == KIND_WORKFLOW_DEF as i32 && expected_revision.is_some() {
|
||||
return Err(IngestError::Rejected(
|
||||
"conflict: workflow update was superseded; refresh and try again".into(),
|
||||
));
|
||||
}
|
||||
return Ok(PersistResult::Duplicate);
|
||||
}
|
||||
|
||||
@@ -239,6 +256,41 @@ async fn persist_command_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_workflow_revision(
|
||||
kind: i32,
|
||||
expected_revision: Option<&str>,
|
||||
existing_id: Option<&[u8]>,
|
||||
) -> Result<(), IngestError> {
|
||||
if kind != KIND_WORKFLOW_DEF as i32 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let expected_id = expected_revision
|
||||
.map(|expected| {
|
||||
let id = hex::decode(expected).map_err(|_| {
|
||||
IngestError::Rejected("invalid: bad expected workflow revision".into())
|
||||
})?;
|
||||
if id.len() != 32 {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: bad expected workflow revision".into(),
|
||||
));
|
||||
}
|
||||
Ok(id)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
match (expected_id.as_deref(), existing_id) {
|
||||
(None, _) => Ok(()),
|
||||
(Some(_), None) => Err(IngestError::Rejected(
|
||||
"conflict: workflow revision does not exist".into(),
|
||||
)),
|
||||
(Some(expected), Some(existing)) if expected != existing => Err(IngestError::Rejected(
|
||||
"conflict: workflow changed since it was loaded".into(),
|
||||
)),
|
||||
(Some(_), Some(_)) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all `p` tag values (hex pubkeys) from an event.
|
||||
fn extract_p_tags(event: &Event) -> Vec<String> {
|
||||
event
|
||||
@@ -354,7 +406,7 @@ async fn handle_dm_open(
|
||||
}
|
||||
|
||||
// Persist the command event (idempotency) — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -515,7 +567,7 @@ async fn handle_dm_add_member(
|
||||
}
|
||||
|
||||
// Persist the command event — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -621,7 +673,7 @@ async fn handle_dm_hide(
|
||||
}
|
||||
|
||||
// Persist the command event — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -758,7 +810,7 @@ async fn handle_workflow_def(
|
||||
let hash = compute_definition_hash(&definition_json_final);
|
||||
|
||||
// Persist the command event — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -897,7 +949,7 @@ async fn handle_workflow_trigger(
|
||||
// Persist the command event under the workflow channel even though the
|
||||
// trigger event itself only carries the workflow UUID. Storing channel
|
||||
// triggers as global events leaks workflow IDs to unrelated relay members.
|
||||
let tx = match persist_command_event(state, tenant, event, workflow.channel_id).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, workflow.channel_id).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -1081,7 +1133,7 @@ async fn handle_approval_grant(
|
||||
check_approver_spec(&approval.approver_spec, &self_hex)?;
|
||||
|
||||
// Persist the command event — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -1192,7 +1244,7 @@ async fn handle_approval_deny(
|
||||
check_approver_spec(&approval.approver_spec, &self_hex)?;
|
||||
|
||||
// Persist the command event — returns open transaction
|
||||
let tx = match persist_command_event(state, tenant, event, None).await? {
|
||||
let tx = match persist_command_event(&state.db, tenant, event, None).await? {
|
||||
PersistResult::Duplicate => {
|
||||
return Ok(IngestResult {
|
||||
event_id: event.id.to_hex(),
|
||||
@@ -1385,3 +1437,203 @@ async fn resume_workflow_after_approval(
|
||||
.finalize_run(community_id, run_id, result, existing_trace)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
async fn persistence_test_context() -> (buzz_db::Db, TenantContext) {
|
||||
let url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
|
||||
let pool = sqlx::PgPool::connect(&url)
|
||||
.await
|
||||
.expect("connect workflow persistence test database");
|
||||
let db = buzz_db::Db::from_pool(pool);
|
||||
db.migrate()
|
||||
.await
|
||||
.expect("migrate workflow persistence test database");
|
||||
let host = format!("workflow-cas-{}.example", Uuid::new_v4().simple());
|
||||
let community = db
|
||||
.ensure_configured_community(&host)
|
||||
.await
|
||||
.expect("create workflow persistence test community")
|
||||
.id;
|
||||
(db, TenantContext::resolved(community, host))
|
||||
}
|
||||
|
||||
fn workflow_event(
|
||||
keys: &Keys,
|
||||
workflow_id: Uuid,
|
||||
created_at: u64,
|
||||
expected_revision: Option<&str>,
|
||||
name: &str,
|
||||
) -> Event {
|
||||
let workflow_id = workflow_id.to_string();
|
||||
let channel_id = Uuid::new_v4().to_string();
|
||||
let mut tags = vec![
|
||||
Tag::parse(["d", workflow_id.as_str()]).expect("d tag"),
|
||||
Tag::parse(["h", channel_id.as_str()]).expect("h tag"),
|
||||
];
|
||||
if let Some(revision) = expected_revision {
|
||||
tags.push(Tag::parse(["expected-revision", revision]).expect("revision tag"));
|
||||
}
|
||||
EventBuilder::new(
|
||||
Kind::Custom(KIND_WORKFLOW_DEF as u16),
|
||||
format!("name: {name}\ntrigger:\n on: message_posted\nsteps: []\n"),
|
||||
)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.expect("workflow event")
|
||||
}
|
||||
|
||||
fn rejection_message(result: Result<(), IngestError>) -> String {
|
||||
match result {
|
||||
Err(IngestError::Rejected(message)) => message,
|
||||
Err(IngestError::AuthFailed(message)) => panic!("unexpected auth failure: {message}"),
|
||||
Err(IngestError::Internal(message)) => panic!("unexpected internal failure: {message}"),
|
||||
Ok(()) => panic!("expected revision validation to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_revision_accepts_create_and_matching_update() {
|
||||
let existing = [0x42; 32];
|
||||
assert!(validate_workflow_revision(KIND_WORKFLOW_DEF as i32, None, None).is_ok());
|
||||
assert!(validate_workflow_revision(
|
||||
KIND_WORKFLOW_DEF as i32,
|
||||
Some(&hex::encode(existing)),
|
||||
Some(&existing),
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_revision_rejects_stale_and_malformed_updates() {
|
||||
let existing = [0x42; 32];
|
||||
let stale = [0x24; 32];
|
||||
assert_eq!(
|
||||
rejection_message(validate_workflow_revision(
|
||||
KIND_WORKFLOW_DEF as i32,
|
||||
Some(&hex::encode(stale)),
|
||||
Some(&existing),
|
||||
)),
|
||||
"conflict: workflow changed since it was loaded",
|
||||
);
|
||||
assert!(
|
||||
validate_workflow_revision(KIND_WORKFLOW_DEF as i32, None, Some(&existing)).is_ok(),
|
||||
"tagless legacy workflow updates remain compatible during rollout",
|
||||
);
|
||||
for malformed in ["not-hex", "42"] {
|
||||
assert_eq!(
|
||||
rejection_message(validate_workflow_revision(
|
||||
KIND_WORKFLOW_DEF as i32,
|
||||
Some(malformed),
|
||||
Some(&existing),
|
||||
)),
|
||||
"invalid: bad expected workflow revision",
|
||||
);
|
||||
assert_eq!(
|
||||
rejection_message(validate_workflow_revision(
|
||||
KIND_WORKFLOW_DEF as i32,
|
||||
Some(malformed),
|
||||
None,
|
||||
)),
|
||||
"invalid: bad expected workflow revision",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_revision_rejects_update_for_missing_coordinate() {
|
||||
assert_eq!(
|
||||
rejection_message(validate_workflow_revision(
|
||||
KIND_WORKFLOW_DEF as i32,
|
||||
Some(&hex::encode([0x42; 32])),
|
||||
None,
|
||||
)),
|
||||
"conflict: workflow revision does not exist",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn workflow_persistence_preserves_replays_and_rejects_dominated_cas_updates() {
|
||||
let (db, tenant) = persistence_test_context().await;
|
||||
let keys = Keys::generate();
|
||||
let workflow_id = Uuid::new_v4();
|
||||
let created_at = Timestamp::now().as_secs();
|
||||
let create = workflow_event(&keys, workflow_id, created_at, None, "create");
|
||||
|
||||
let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &create, None)
|
||||
.await
|
||||
.expect("persist create")
|
||||
else {
|
||||
panic!("first create must insert");
|
||||
};
|
||||
tx.commit().await.expect("commit create");
|
||||
assert!(matches!(
|
||||
persist_command_event(&db, &tenant, &create, None)
|
||||
.await
|
||||
.expect("replay create"),
|
||||
PersistResult::Duplicate
|
||||
));
|
||||
|
||||
let create_revision = create.id.to_hex();
|
||||
let mut updates = (0..64).map(|index| {
|
||||
workflow_event(
|
||||
&keys,
|
||||
workflow_id,
|
||||
created_at,
|
||||
Some(&create_revision),
|
||||
&format!("update-{index}"),
|
||||
)
|
||||
});
|
||||
let update = updates
|
||||
.find(|candidate| candidate.id.as_bytes() < create.id.as_bytes())
|
||||
.expect("find same-second update that wins NIP-33 ordering");
|
||||
let dominated_update = (64..256)
|
||||
.map(|index| {
|
||||
workflow_event(
|
||||
&keys,
|
||||
workflow_id,
|
||||
created_at,
|
||||
Some(&update.id.to_hex()),
|
||||
&format!("update-{index}"),
|
||||
)
|
||||
})
|
||||
.find(|candidate| candidate.id.as_bytes() > update.id.as_bytes())
|
||||
.expect("find same-second CAS-matching update dominated by current head");
|
||||
|
||||
let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &update, None)
|
||||
.await
|
||||
.expect("persist update")
|
||||
else {
|
||||
panic!("matching update must insert");
|
||||
};
|
||||
tx.commit().await.expect("commit update");
|
||||
assert!(matches!(
|
||||
persist_command_event(&db, &tenant, &update, None)
|
||||
.await
|
||||
.expect("replay update"),
|
||||
PersistResult::Duplicate
|
||||
));
|
||||
|
||||
let error = match persist_command_event(&db, &tenant, &dominated_update, None).await {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("distinct dominated CAS update must not report duplicate success"),
|
||||
};
|
||||
assert!(matches!(
|
||||
error,
|
||||
IngestError::Rejected(ref message)
|
||||
if message == "conflict: workflow update was superseded; refresh and try again"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_tag_does_not_change_other_command_kinds() {
|
||||
assert!(validate_workflow_revision(KIND_DM_OPEN as i32, Some("not-hex"), None).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1618,11 +1618,13 @@ pub fn build_workflow_update(
|
||||
channel_id: Uuid,
|
||||
workflow_id: Uuid,
|
||||
yaml: &str,
|
||||
expected_revision: &str,
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
check_content(yaml, 64 * 1024)?;
|
||||
let tags = vec![
|
||||
tag(&["d", &workflow_id.to_string()])?,
|
||||
tag(&["h", &channel_id.to_string()])?,
|
||||
tag(&["expected-revision", expected_revision])?,
|
||||
];
|
||||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
|
||||
}
|
||||
@@ -3972,16 +3974,18 @@ mod tests {
|
||||
fn workflow_update_includes_h_tag() {
|
||||
let cid = uuid();
|
||||
let wid = uuid();
|
||||
let ev = sign(build_workflow_update(cid, wid, "name: updated").unwrap());
|
||||
let revision = "a".repeat(64);
|
||||
let ev = sign(build_workflow_update(cid, wid, "name: updated", &revision).unwrap());
|
||||
assert_eq!(ev.kind.as_u16(), 30620);
|
||||
assert!(has_tag(&ev, "d", &wid.to_string()));
|
||||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||||
assert!(has_tag(&ev, "expected-revision", &revision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_update_rejects_oversized_yaml() {
|
||||
let big = "x".repeat(65 * 1024);
|
||||
let err = build_workflow_update(uuid(), uuid(), &big).unwrap_err();
|
||||
let err = build_workflow_update(uuid(), uuid(), &big, &"a".repeat(64)).unwrap_err();
|
||||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ use crate::{
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct WorkflowWire {
|
||||
pub id: String,
|
||||
/// Event id of the current kind:30620 revision, used for conflict-protected updates.
|
||||
pub revision: String,
|
||||
pub name: String,
|
||||
pub owner_pubkey: String,
|
||||
pub channel_id: Option<String>,
|
||||
@@ -177,7 +179,8 @@ pub async fn create_workflow(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<WorkflowSaveWire, String> {
|
||||
let workflow_id = uuid::Uuid::new_v4().to_string();
|
||||
let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?;
|
||||
let builder =
|
||||
events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition, None)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
|
||||
// The relay returns `webhook_secret` in the OK response message for
|
||||
@@ -195,6 +198,7 @@ pub async fn create_workflow(
|
||||
let now = now_secs();
|
||||
let workflow = workflow_record(
|
||||
workflow_id,
|
||||
result.event_id,
|
||||
Some(channel_id),
|
||||
current_pubkey_hex(&state)?,
|
||||
&yaml_definition,
|
||||
@@ -212,6 +216,7 @@ pub async fn create_workflow(
|
||||
pub async fn update_workflow(
|
||||
workflow_id: String,
|
||||
yaml_definition: String,
|
||||
expected_revision: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<WorkflowSaveWire, String> {
|
||||
// Find the channel id (and creation time) from the existing workflow event
|
||||
@@ -230,15 +235,24 @@ pub async fn update_workflow(
|
||||
let prior_event = prior
|
||||
.first()
|
||||
.ok_or_else(|| "workflow not found".to_string())?;
|
||||
if prior_event.id.to_hex() != expected_revision {
|
||||
return Err("workflow changed since it was loaded; refresh and try again".to_string());
|
||||
}
|
||||
let channel_id = tag_value(prior_event, "h").ok_or_else(|| "workflow not found".to_string())?;
|
||||
let created_at = prior_event.created_at.as_secs() as i64;
|
||||
|
||||
let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?;
|
||||
submit_event(builder, &state).await?;
|
||||
let builder = events::build_workflow_definition(
|
||||
&workflow_id,
|
||||
&channel_id,
|
||||
&yaml_definition,
|
||||
Some(&expected_revision),
|
||||
)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
|
||||
let updated_at = now_secs();
|
||||
let workflow = workflow_record(
|
||||
workflow_id,
|
||||
result.event_id,
|
||||
Some(channel_id),
|
||||
current_pubkey_hex(&state)?,
|
||||
&yaml_definition,
|
||||
@@ -367,6 +381,7 @@ fn parse_definition(yaml: &str) -> Value {
|
||||
/// (from a relay event) and the write path (from local inputs).
|
||||
fn workflow_record(
|
||||
id: String,
|
||||
revision: String,
|
||||
channel_id: Option<String>,
|
||||
owner_pubkey: String,
|
||||
yaml_definition: &str,
|
||||
@@ -383,6 +398,7 @@ fn workflow_record(
|
||||
|
||||
WorkflowWire {
|
||||
id,
|
||||
revision,
|
||||
name,
|
||||
owner_pubkey,
|
||||
channel_id,
|
||||
@@ -398,7 +414,15 @@ fn workflow_from_event(ev: &nostr::Event) -> WorkflowWire {
|
||||
let id = tag_value(ev, "d").unwrap_or_default();
|
||||
let channel_id = tag_value(ev, "h");
|
||||
let ts = ev.created_at.as_secs() as i64;
|
||||
workflow_record(id, channel_id, ev.pubkey.to_hex(), &ev.content, ts, ts)
|
||||
workflow_record(
|
||||
id,
|
||||
ev.id.to_hex(),
|
||||
channel_id,
|
||||
ev.pubkey.to_hex(),
|
||||
&ev.content,
|
||||
ts,
|
||||
ts,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -41,6 +41,7 @@ fn workflow_from_event_maps_all_fields() {
|
||||
let wf = workflow_from_event(&ev);
|
||||
|
||||
assert_eq!(wf.id, WF);
|
||||
assert_eq!(wf.revision, ev.id.to_hex());
|
||||
assert_eq!(wf.channel_id.as_deref(), Some(CHAN));
|
||||
assert_eq!(wf.owner_pubkey, ev.pubkey.to_hex());
|
||||
assert_eq!(wf.name, "Greet on join");
|
||||
@@ -120,6 +121,7 @@ fn tag_value_reads_d_and_h_and_misses_absent() {
|
||||
fn workflow_record_shapes_save_inputs() {
|
||||
let wf = workflow_record(
|
||||
WF.to_string(),
|
||||
"revision-1".to_string(),
|
||||
Some(CHAN.to_string()),
|
||||
"deadbeef".to_string(),
|
||||
YAML,
|
||||
@@ -139,6 +141,7 @@ fn workflow_record_shapes_save_inputs() {
|
||||
fn save_wire_serializes_flat_with_optional_secret() {
|
||||
let workflow = workflow_record(
|
||||
WF.to_string(),
|
||||
"revision-1".to_string(),
|
||||
Some(CHAN.to_string()),
|
||||
"deadbeef".to_string(),
|
||||
YAML,
|
||||
@@ -176,6 +179,7 @@ fn workflow_wire_serializes_with_snake_case_keys() {
|
||||
let v = serde_json::to_value(workflow_from_event(&ev)).expect("serialize");
|
||||
for key in [
|
||||
"id",
|
||||
"revision",
|
||||
"name",
|
||||
"owner_pubkey",
|
||||
"channel_id",
|
||||
|
||||
@@ -756,47 +756,12 @@ pub fn build_dm_hide(channel_id: &str) -> Result<EventBuilder, String> {
|
||||
Ok(EventBuilder::new(Kind::Custom(41012), "").tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 30620 — replaceable workflow definition.
|
||||
///
|
||||
/// The `d` tag carries the workflow id; `h` tag carries the channel id; the
|
||||
/// content is the YAML definition. Same (pubkey, d) replaces the prior version.
|
||||
pub fn build_workflow_definition(
|
||||
workflow_id: &str,
|
||||
channel_id: &str,
|
||||
yaml_definition: &str,
|
||||
) -> Result<EventBuilder, String> {
|
||||
check_content(yaml_definition)?;
|
||||
let tags = vec![tag(vec!["d", workflow_id])?, tag(vec!["h", channel_id])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(30620), yaml_definition.to_string()).tags(tags))
|
||||
}
|
||||
mod workflows;
|
||||
|
||||
/// Kind 5 — NIP-09 deletion targeting a kind:30620 workflow definition.
|
||||
pub fn build_workflow_delete(
|
||||
workflow_id: &str,
|
||||
owner_pubkey_hex: &str,
|
||||
) -> Result<EventBuilder, String> {
|
||||
let coord = format!("30620:{owner_pubkey_hex}:{workflow_id}");
|
||||
let tags = vec![tag(vec!["a", &coord])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46020 — trigger a workflow run by id.
|
||||
pub fn build_workflow_trigger(workflow_id: &str) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["d", workflow_id])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46030 — grant an approval token (with optional note).
|
||||
pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["t", token])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46031 — deny an approval token (with optional note).
|
||||
pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["t", token])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags))
|
||||
}
|
||||
pub use workflows::{
|
||||
build_approval_deny, build_approval_grant, build_workflow_definition, build_workflow_delete,
|
||||
build_workflow_trigger,
|
||||
};
|
||||
|
||||
// ── Transport ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use nostr::{EventBuilder, EventId, Kind};
|
||||
|
||||
use super::{check_content, tag};
|
||||
|
||||
/// Kind 30620 — replaceable workflow definition.
|
||||
///
|
||||
/// The `d` tag carries the workflow id; `h` tag carries the channel id; the
|
||||
/// content is the YAML definition. Same (pubkey, d) replaces the prior version.
|
||||
pub fn build_workflow_definition(
|
||||
workflow_id: &str,
|
||||
channel_id: &str,
|
||||
yaml_definition: &str,
|
||||
expected_revision: Option<&str>,
|
||||
) -> Result<EventBuilder, String> {
|
||||
check_content(yaml_definition)?;
|
||||
let mut tags = vec![tag(vec!["d", workflow_id])?, tag(vec!["h", channel_id])?];
|
||||
if let Some(revision) = expected_revision {
|
||||
EventId::from_hex(revision).map_err(|_| "invalid workflow revision".to_string())?;
|
||||
tags.push(tag(vec!["expected-revision", revision])?);
|
||||
}
|
||||
Ok(EventBuilder::new(Kind::Custom(30620), yaml_definition.to_string()).tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 5 — NIP-09 deletion targeting a kind:30620 workflow definition.
|
||||
pub fn build_workflow_delete(
|
||||
workflow_id: &str,
|
||||
owner_pubkey_hex: &str,
|
||||
) -> Result<EventBuilder, String> {
|
||||
let coord = format!("30620:{owner_pubkey_hex}:{workflow_id}");
|
||||
let tags = vec![tag(vec!["a", &coord])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46020 — trigger a workflow run by id.
|
||||
pub fn build_workflow_trigger(workflow_id: &str) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["d", workflow_id])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46030 — grant an approval token (with optional note).
|
||||
pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["t", token])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags))
|
||||
}
|
||||
|
||||
/// Kind 46031 — deny an approval token (with optional note).
|
||||
pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result<EventBuilder, String> {
|
||||
let tags = vec![tag(vec!["t", token])?];
|
||||
Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags))
|
||||
}
|
||||
@@ -185,12 +185,15 @@ export function useCreateWorkflowMutation(channelId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMutation(workflowId: string) {
|
||||
export function useUpdateWorkflowMutation(
|
||||
workflowId: string,
|
||||
workflowRevision: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (yamlDefinition: string) =>
|
||||
updateWorkflow(workflowId, yamlDefinition),
|
||||
updateWorkflow(workflowId, yamlDefinition, workflowRevision),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: workflowQueryKey(workflowId),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
PowerOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
|
||||
type WorkflowActionsMenuProps = {
|
||||
isEnabled: boolean;
|
||||
isTogglingEnabled?: boolean;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
onEdit: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onTrigger: () => void;
|
||||
};
|
||||
|
||||
export function WorkflowActionsMenu({
|
||||
isEnabled,
|
||||
isTogglingEnabled = false,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
onToggleEnabled,
|
||||
onTrigger,
|
||||
}: WorkflowActionsMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Workflow actions"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-background/80 hover:text-foreground data-[state=open]:bg-background/80 data-[state=open]:text-foreground"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onTrigger}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Trigger
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDuplicate}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isEnabled}
|
||||
className="gap-2 pl-2 [&>span:first-child]:hidden"
|
||||
disabled={isTogglingEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked !== isEnabled) onToggleEnabled();
|
||||
}}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<Power className="mr-2 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<PowerOff className="mr-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span>Enable</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"ml-auto inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors " +
|
||||
(isEnabled ? "bg-primary" : "bg-input")
|
||||
}
|
||||
data-testid="workflow-enabled-switch-visual"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"block h-4 w-4 rounded-full bg-background transition-transform " +
|
||||
(isEnabled ? "translate-x-4" : "translate-x-0")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +1,181 @@
|
||||
import {
|
||||
Clock,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CircleCheckBig,
|
||||
GitPullRequest,
|
||||
Hash,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Send,
|
||||
SmilePlus,
|
||||
Timer,
|
||||
Webhook,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowEnabled,
|
||||
getWorkflowPrimaryAction,
|
||||
getWorkflowTriggerSummary,
|
||||
getWorkflowTriggerType,
|
||||
} from "./workflowDefinition";
|
||||
|
||||
type WorkflowCardProps = {
|
||||
workflow: Workflow;
|
||||
channelName?: string;
|
||||
isActive?: boolean;
|
||||
isTogglingEnabled?: boolean;
|
||||
onSelect: (workflowId: string) => void;
|
||||
onTrigger: (workflowId: string) => void;
|
||||
onToggleEnabled: (workflow: Workflow) => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
onDuplicate: (workflow: Workflow) => void;
|
||||
onDelete: (workflow: Workflow) => void;
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
const variants: Record<
|
||||
Workflow["status"],
|
||||
"success" | "secondary" | "warning"
|
||||
> = {
|
||||
active: "success",
|
||||
disabled: "secondary",
|
||||
archived: "warning",
|
||||
};
|
||||
const TRIGGER_ICONS: Record<string, LucideIcon> = {
|
||||
diff_posted: GitPullRequest,
|
||||
message_posted: MessageSquare,
|
||||
reaction_added: SmilePlus,
|
||||
schedule: CalendarClock,
|
||||
webhook: Webhook,
|
||||
};
|
||||
|
||||
return <Badge variant={variants[status]}>{status}</Badge>;
|
||||
const ACTION_ICONS: Record<string, LucideIcon> = {
|
||||
add_reaction: SmilePlus,
|
||||
call_webhook: Webhook,
|
||||
delay: Timer,
|
||||
request_approval: CircleCheckBig,
|
||||
send_dm: MessageCircle,
|
||||
send_message: Send,
|
||||
set_channel_topic: Hash,
|
||||
};
|
||||
|
||||
const TRIGGER_ACCENTS: Record<string, string> = {
|
||||
diff_posted: "border-violet-400/30 bg-violet-600 text-white",
|
||||
message_posted: "border-blue-400/30 bg-blue-600 text-white",
|
||||
reaction_added: "border-pink-400/30 bg-pink-600 text-white",
|
||||
schedule: "border-emerald-400/30 bg-emerald-600 text-white",
|
||||
webhook: "border-orange-300/30 bg-orange-500 text-white",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border border-border/65 bg-background/80 px-2 py-1 text-2xs font-semibold uppercase tracking-wider shadow-xs",
|
||||
status === "active" ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowCard({
|
||||
workflow,
|
||||
channelName,
|
||||
isActive = false,
|
||||
isTogglingEnabled = false,
|
||||
onSelect,
|
||||
onTrigger,
|
||||
onToggleEnabled,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: WorkflowCardProps) {
|
||||
const displayStatus = getWorkflowDisplayStatus(workflow);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const triggerType = getWorkflowTriggerType(workflow.definition);
|
||||
const actionType = getWorkflowPrimaryAction(workflow.definition);
|
||||
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
|
||||
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
|
||||
const triggerAccent = triggerType ? TRIGGER_ACCENTS[triggerType] : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50 ${
|
||||
isActive ? "border-primary/40 bg-primary/5 shadow-xs" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"group relative min-h-60 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
|
||||
isActive && "border-primary/50 bg-primary/5 ring-1 ring-primary/30",
|
||||
)}
|
||||
data-testid={`workflow-card-${workflow.id}`}
|
||||
>
|
||||
<button
|
||||
className="absolute inset-0 rounded-lg"
|
||||
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={() => onSelect(workflow.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">View {workflow.name}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{workflow.name}
|
||||
<div className="pointer-events-none relative z-10 flex h-full min-h-48 flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-xl border shadow-xs",
|
||||
triggerAccent ?? "border-slate-400/30 bg-slate-600 text-white",
|
||||
)}
|
||||
>
|
||||
{TriggerIcon ? (
|
||||
<TriggerIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<Zap className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
{ActionIcon ? (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/60" />
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
|
||||
<ActionIcon className="h-5 w-5" />
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<StatusBadge status={displayStatus} />
|
||||
<WorkflowActionsMenu
|
||||
isEnabled={getWorkflowEnabled(workflow.definition)}
|
||||
isTogglingEnabled={isTogglingEnabled}
|
||||
onDelete={() => onDelete(workflow)}
|
||||
onDuplicate={() => onDuplicate(workflow)}
|
||||
onEdit={() => onEdit(workflow)}
|
||||
onToggleEnabled={() => onToggleEnabled(workflow)}
|
||||
onTrigger={() => onTrigger(workflow.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3 pl-6 text-2xs text-muted-foreground">
|
||||
{channelName ? <span>{channelName}</span> : null}
|
||||
{triggerSummary ? <span>{triggerSummary}</span> : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-2 pl-6 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Workflow actions"
|
||||
className="relative z-10 h-7 w-7 shrink-0"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onTrigger(workflow.id)}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Trigger
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(workflow)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDuplicate(workflow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(workflow)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{triggerSummary ? (
|
||||
<p className="mt-4 line-clamp-1 text-xs font-semibold text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mt-1 line-clamp-2 text-xl font-bold leading-tight tracking-tight">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-muted-foreground">
|
||||
<p className="min-w-0 truncate text-2xs">
|
||||
{channelName ? `#${channelName}` : "Channel workflow"}
|
||||
</p>
|
||||
<span className="shrink-0 text-2xs">
|
||||
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,10 @@ export function WorkflowDialog({
|
||||
} | null>(null);
|
||||
|
||||
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
||||
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
|
||||
const updateMutation = useUpdateWorkflowMutation(
|
||||
workflow?.id ?? "",
|
||||
workflow?.revision ?? "",
|
||||
);
|
||||
const mutation = mode === "edit" ? updateMutation : createMutation;
|
||||
|
||||
const selectedChannel =
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Plus, RefreshCw, Zap } from "lucide-react";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
|
||||
import {
|
||||
allWorkflowsQueryKey,
|
||||
workflowListFocusRefetchPolicy,
|
||||
workflowQueryKey,
|
||||
} from "@/features/workflows/hooks";
|
||||
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
|
||||
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
|
||||
@@ -15,10 +18,12 @@ import {
|
||||
deleteWorkflow,
|
||||
getChannelsWorkflows,
|
||||
triggerWorkflow,
|
||||
updateWorkflow,
|
||||
} from "@/shared/api/tauriWorkflows";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { PageHeader } from "@/shared/ui/PageHeader";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { getWorkflowEnabled, withWorkflowEnabled } from "./workflowDefinition";
|
||||
|
||||
type WorkflowsViewProps = {
|
||||
channels: Channel[];
|
||||
@@ -38,35 +43,45 @@ type DialogState =
|
||||
| { mode: "edit"; workflow: Workflow }
|
||||
| { mode: "duplicate"; workflow: Workflow };
|
||||
|
||||
const WORKFLOW_CARD_GRID_CLASS =
|
||||
"grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3";
|
||||
|
||||
function WorkflowsListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={WORKFLOW_CARD_GRID_CLASS}>
|
||||
{["first", "second", "third", "fourth"].map((card) => (
|
||||
<Card className="p-4" key={card}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-2xl" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-60 flex-col rounded-2xl border bg-card p-5"
|
||||
key={card}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
</Card>
|
||||
<Skeleton className="mt-5 h-6 w-3/4" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-4/5" />
|
||||
<Skeleton className="mt-auto h-4 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWorkflowCard({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Create Workflow"
|
||||
className="group relative flex min-h-60 w-full min-w-0 items-center justify-center overflow-hidden rounded-2xl border border-dashed border-border/80 bg-transparent text-muted-foreground shadow-xs transition-colors hover:border-border hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="new-workflow-card"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-7 w-7 transition-colors" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowsView({
|
||||
channels,
|
||||
onCloseWorkflow,
|
||||
@@ -132,6 +147,38 @@ export function WorkflowsView({
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEnabledMutation = useMutation({
|
||||
mutationFn: (workflow: Workflow) =>
|
||||
updateWorkflow(
|
||||
workflow.id,
|
||||
yamlStringify(
|
||||
withWorkflowEnabled(
|
||||
workflow.definition,
|
||||
!getWorkflowEnabled(workflow.definition),
|
||||
),
|
||||
),
|
||||
workflow.revision,
|
||||
),
|
||||
onError: (error) => {
|
||||
toast.error("Couldn’t change workflow status", {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The workflow was not changed. Try again.",
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, workflow) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: workflowQueryKey(workflow.id),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "workflows" ||
|
||||
query.queryKey[0] === "workflows-all",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const triggerOne = triggerMutation.mutate;
|
||||
const handleTrigger = React.useCallback(
|
||||
(workflowId: string) => triggerOne(workflowId),
|
||||
@@ -162,6 +209,12 @@ export function WorkflowsView({
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleEnabled = toggleEnabledMutation.mutate;
|
||||
const handleToggleEnabled = React.useCallback(
|
||||
(workflow: Workflow) => toggleEnabled(workflow),
|
||||
[toggleEnabled],
|
||||
);
|
||||
|
||||
const handleDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setDialogState({ mode: "closed" });
|
||||
@@ -174,73 +227,67 @@ export function WorkflowsView({
|
||||
data-testid="workflows-view"
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-4"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8"
|
||||
data-scroll-restoration-id="workflows-list"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Workflows</h2>
|
||||
<Button
|
||||
aria-label="Refresh workflows"
|
||||
disabled={allWorkflowsQuery.isFetching}
|
||||
onClick={() => void allWorkflowsQuery.refetch()}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setDialogState({ mode: "create" })} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-6xl space-y-8 [container-type:inline-size]">
|
||||
<PageHeader
|
||||
action={
|
||||
<Button
|
||||
aria-label="Refresh workflows"
|
||||
disabled={allWorkflowsQuery.isFetching}
|
||||
onClick={() => void allWorkflowsQuery.refetch()}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
description="Automations that keep your community moving."
|
||||
title="Workflows"
|
||||
/>
|
||||
|
||||
{allWorkflowsQuery.isLoading ? (
|
||||
<WorkflowsListSkeleton />
|
||||
) : allWorkflowsQuery.isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<p className="text-sm text-red-400">Failed to load workflows</p>
|
||||
<Button
|
||||
onClick={() => void allWorkflowsQuery.refetch()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : allWorkflows.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Zap className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No workflows yet</p>
|
||||
<Button
|
||||
onClick={() => setDialogState({ mode: "create" })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create your first workflow
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{allWorkflows.map(({ workflow, channelName }) => (
|
||||
<WorkflowCard
|
||||
channelName={channelName}
|
||||
isActive={selectedWorkflowId === workflow.id}
|
||||
key={workflow.id}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onEdit={handleEdit}
|
||||
onSelect={onSelectWorkflow}
|
||||
onTrigger={handleTrigger}
|
||||
workflow={workflow}
|
||||
{allWorkflowsQuery.isLoading ? (
|
||||
<WorkflowsListSkeleton />
|
||||
) : allWorkflowsQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<p className="text-sm text-red-400">Failed to load workflows</p>
|
||||
<Button
|
||||
onClick={() => void allWorkflowsQuery.refetch()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={WORKFLOW_CARD_GRID_CLASS}>
|
||||
<CreateWorkflowCard
|
||||
onClick={() => setDialogState({ mode: "create" })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allWorkflows.map(({ workflow, channelName }) => (
|
||||
<WorkflowCard
|
||||
channelName={channelName}
|
||||
isActive={selectedWorkflowId === workflow.id}
|
||||
isTogglingEnabled={
|
||||
toggleEnabledMutation.isPending &&
|
||||
toggleEnabledMutation.variables?.id === workflow.id
|
||||
}
|
||||
key={workflow.id}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onEdit={handleEdit}
|
||||
onSelect={onSelectWorkflow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onTrigger={handleTrigger}
|
||||
workflow={workflow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWorkflowId ? (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowPrimaryAction,
|
||||
getWorkflowTriggerType,
|
||||
withWorkflowEnabled,
|
||||
} from "./workflowDefinition.ts";
|
||||
|
||||
test("reads only direct trigger and first-action types for card icons", () => {
|
||||
assert.equal(
|
||||
getWorkflowTriggerType({ trigger: { on: "message_posted" } }),
|
||||
"message_posted",
|
||||
);
|
||||
assert.equal(
|
||||
getWorkflowPrimaryAction({
|
||||
steps: [{ action: "send_message" }, { action: "delay" }],
|
||||
}),
|
||||
"send_message",
|
||||
);
|
||||
assert.equal(getWorkflowTriggerType({ trigger: { on: "" } }), null);
|
||||
assert.equal(getWorkflowPrimaryAction({ steps: [null] }), null);
|
||||
});
|
||||
|
||||
test("updates enabled state without mutating the workflow definition", () => {
|
||||
const definition = {
|
||||
name: "deploy",
|
||||
trigger: { on: "message_posted" },
|
||||
};
|
||||
const disabled = withWorkflowEnabled(definition, false);
|
||||
|
||||
assert.deepEqual(disabled, { ...definition, enabled: false });
|
||||
assert.deepEqual(definition, {
|
||||
name: "deploy",
|
||||
trigger: { on: "message_posted" },
|
||||
});
|
||||
assert.deepEqual(withWorkflowEnabled(disabled, true), definition);
|
||||
});
|
||||
|
||||
test("shows a disabled definition as disabled while preserving other statuses", () => {
|
||||
const workflow = {
|
||||
id: "workflow-id",
|
||||
name: "deploy",
|
||||
channelId: "channel-id",
|
||||
definition: { enabled: false },
|
||||
status: "active",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
assert.equal(getWorkflowDisplayStatus(workflow), "disabled");
|
||||
assert.equal(
|
||||
getWorkflowDisplayStatus({ ...workflow, status: "archived" }),
|
||||
"archived",
|
||||
);
|
||||
});
|
||||
@@ -9,12 +9,46 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getWorkflowTriggerType(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const trigger = asRecord(definition.trigger);
|
||||
return typeof trigger?.on === "string" && trigger.on.trim().length > 0
|
||||
? trigger.on
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getWorkflowPrimaryAction(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const firstStep = Array.isArray(definition.steps)
|
||||
? asRecord(definition.steps[0])
|
||||
: null;
|
||||
return typeof firstStep?.action === "string" &&
|
||||
firstStep.action.trim().length > 0
|
||||
? firstStep.action
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getWorkflowEnabled(
|
||||
definition: Record<string, unknown>,
|
||||
): boolean {
|
||||
return definition.enabled !== false;
|
||||
}
|
||||
|
||||
export function withWorkflowEnabled(
|
||||
definition: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
): Record<string, unknown> {
|
||||
const updated = { ...definition };
|
||||
if (enabled) {
|
||||
delete updated.enabled;
|
||||
} else {
|
||||
updated.enabled = false;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function getWorkflowDisplayStatus(
|
||||
workflow: Workflow,
|
||||
): Workflow["status"] | "disabled" {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
|
||||
type RawWorkflow = {
|
||||
id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
owner_pubkey: string;
|
||||
channel_id: string | null;
|
||||
@@ -94,6 +95,7 @@ type RawApprovalActionResponse = {
|
||||
function fromRawWorkflow(raw: RawWorkflow): Workflow {
|
||||
return {
|
||||
id: raw.id,
|
||||
revision: raw.revision,
|
||||
name: raw.name,
|
||||
ownerPubkey: raw.owner_pubkey,
|
||||
channelId: raw.channel_id,
|
||||
@@ -220,10 +222,12 @@ export async function createWorkflow(
|
||||
export async function updateWorkflow(
|
||||
workflowId: string,
|
||||
yamlDefinition: string,
|
||||
expectedRevision: string,
|
||||
): Promise<WorkflowSaveResult> {
|
||||
const raw = await invokeTauri<RawWorkflowSaveResponse>("update_workflow", {
|
||||
workflowId,
|
||||
yamlDefinition,
|
||||
expectedRevision,
|
||||
});
|
||||
return fromRawWorkflowSave(raw);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type WorkflowStatus = "active" | "disabled" | "archived";
|
||||
|
||||
export type Workflow = {
|
||||
id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
ownerPubkey: string;
|
||||
channelId: string | null;
|
||||
|
||||
@@ -236,6 +236,8 @@ type E2eConfig = {
|
||||
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
|
||||
acpAuthMethodsErrors?: Record<string, string>;
|
||||
acpAuthMethodsError?: string;
|
||||
/** When set, workflow updates fail with this message. */
|
||||
workflowUpdateError?: string;
|
||||
/** When set, the `delete_custom_harness` mock command throws with this message. */
|
||||
deleteCustomHarnessError?: string;
|
||||
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
|
||||
@@ -3356,6 +3358,7 @@ let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({
|
||||
|
||||
type MockWorkflow = {
|
||||
id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
owner_pubkey: string;
|
||||
channel_id: string | null;
|
||||
@@ -3443,6 +3446,7 @@ function handleCreateWorkflow(args: {
|
||||
: `workflow_${mockWorkflowIdCounter}`;
|
||||
const workflow: MockWorkflow = {
|
||||
id: `mock-wf-${mockWorkflowIdCounter}`,
|
||||
revision: `mock-revision-${mockWorkflowIdCounter}-1`,
|
||||
name,
|
||||
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
||||
channel_id: args.channelId,
|
||||
@@ -3466,13 +3470,22 @@ function handleCreateWorkflow(args: {
|
||||
function handleUpdateWorkflow(args: {
|
||||
workflowId: string;
|
||||
yamlDefinition: string;
|
||||
expectedRevision: string;
|
||||
}) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
const configuredError = window.__BUZZ_E2E__?.mock?.workflowUpdateError;
|
||||
if (configuredError) throw new Error(configuredError);
|
||||
if (workflow.revision !== args.expectedRevision) {
|
||||
throw new Error(
|
||||
"workflow changed since it was loaded; refresh and try again",
|
||||
);
|
||||
}
|
||||
const definition = parseWorkflowDefinition(args.yamlDefinition);
|
||||
if (typeof definition.name === "string") workflow.name = definition.name;
|
||||
workflow.definition = definition;
|
||||
workflow.updated_at = Math.floor(Date.now() / 1000);
|
||||
workflow.revision = `mock-revision-${workflow.id}-${workflow.updated_at}-${Math.random()}`;
|
||||
|
||||
const trigger = definition.trigger as Record<string, unknown> | undefined;
|
||||
return {
|
||||
|
||||
@@ -62,13 +62,13 @@ async function createWorkflow(
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("navigates to workflows view and shows empty state", async ({ page }) => {
|
||||
test("navigates to workflows view and shows the empty create tile", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create your first workflow" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creates a workflow via the form builder", async ({ page }) => {
|
||||
@@ -99,6 +99,49 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("captures workflow library across responsive viewports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, "Notify reviewers when source files change", {
|
||||
description: "Watches diff events for src/ changes",
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
});
|
||||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||||
description: "Keeps the team aligned every morning",
|
||||
trigger: "schedule",
|
||||
});
|
||||
await createWorkflow(
|
||||
page,
|
||||
"Request approval before deploying to production",
|
||||
{
|
||||
description: "Requires a final review before release",
|
||||
trigger: "reaction_added",
|
||||
},
|
||||
);
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 800, height: 720, name: "narrow" },
|
||||
{ width: 1024, height: 720, name: "medium" },
|
||||
{ width: 1280, height: 720, name: "wide" },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `test-results/workflow-library-${viewport.name}.png`,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
|
||||
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: "test-results/workflow-library-wide-actions.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
const workflowName = `diff_workflow_${Date.now()}`;
|
||||
const description = "Watches diff events for src/ changes";
|
||||
@@ -117,12 +160,145 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await expect(card).toContainText(workflowName);
|
||||
await expect(card).toContainText(description);
|
||||
await expect(card).toContainText("Diff Posted");
|
||||
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
|
||||
await expect(card.locator("h3")).toHaveText(workflowName);
|
||||
await expect(card.getByText(description, { exact: true })).toBeVisible();
|
||||
await expect(card).toContainText("disabled");
|
||||
});
|
||||
|
||||
test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
const workflowName = `toggle_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
const workflowCard = () =>
|
||||
page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
const workflowActions = () =>
|
||||
workflowCard().getByRole("button", { name: "Workflow actions" });
|
||||
|
||||
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
|
||||
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
const detailPanel = page.getByTestId("workflow-detail-panel");
|
||||
await expect(detailPanel).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
|
||||
await workflowActions().click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "true");
|
||||
await expect(enableItem.locator("button")).toHaveCount(0);
|
||||
await expect(
|
||||
enableItem.getByTestId("workflow-enabled-switch-visual"),
|
||||
).toHaveAttribute("aria-hidden", "true");
|
||||
await enableItem.click();
|
||||
await expect(
|
||||
workflowCard().getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await enableItem.click();
|
||||
await expect(
|
||||
workflowCard().getByText("active", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects a stale card toggle without overwriting a newer edit", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `stale_toggle_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
const workflowCard = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
|
||||
await page.evaluate(async (name) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((call) => call.command === "create_workflow");
|
||||
const channelId = (
|
||||
createCall?.payload as { channelId?: string } | undefined
|
||||
)?.channelId;
|
||||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||||
const workflows = (await invoke("get_channels_workflows", {
|
||||
channelIds: [channelId],
|
||||
})) as Array<{
|
||||
id: string;
|
||||
revision: string;
|
||||
definition: Record<string, unknown>;
|
||||
}>;
|
||||
const workflow = workflows.find(
|
||||
(candidate) => candidate.definition.name === name,
|
||||
);
|
||||
if (!workflow) throw new Error("created workflow unavailable");
|
||||
await invoke("update_workflow", {
|
||||
workflowId: workflow.id,
|
||||
expectedRevision: workflow.revision,
|
||||
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
|
||||
});
|
||||
}, workflowName);
|
||||
|
||||
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "workflow changed since it was loaded" }),
|
||||
).toBeVisible();
|
||||
const authoritativeName = await page.evaluate(async () => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((call) => call.command === "create_workflow");
|
||||
const channelId = (
|
||||
createCall?.payload as { channelId?: string } | undefined
|
||||
)?.channelId;
|
||||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||||
const workflows = (await invoke("get_channels_workflows", {
|
||||
channelIds: [channelId],
|
||||
})) as Array<{ name: string }>;
|
||||
return workflows[0]?.name;
|
||||
});
|
||||
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
|
||||
});
|
||||
|
||||
test("reports a rejected workflow status change", async ({ page }) => {
|
||||
const workflowName = `rejected_toggle_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ ??= {};
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
|
||||
});
|
||||
|
||||
const workflowCard = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
|
||||
|
||||
const errorToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Couldn’t change workflow status" });
|
||||
await expect(errorToast).toContainText("relay refused the update");
|
||||
await expect(workflowCard.getByText("active", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -215,8 +391,9 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(page.getByRole("alertdialog")).not.toBeVisible();
|
||||
|
||||
// Verify workflow is gone — back to empty state
|
||||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||||
// Verify workflow is gone — back to the empty create tile.
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
|
||||
@@ -192,6 +192,7 @@ type MockBridgeOptions = {
|
||||
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
|
||||
acpAuthMethodsError?: string;
|
||||
/** When set, the `delete_custom_harness` mock command throws with this message. */
|
||||
workflowUpdateError?: string;
|
||||
deleteCustomHarnessError?: string;
|
||||
connectAcpRuntimeResult?: { launched: boolean };
|
||||
connectAcpRuntimeDelayMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user