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

![Workflow library at 800 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-narrow-482d1b4c8.png)

### Medium — 1024 × 720

![Workflow library at 1024 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-medium-482d1b4c8.png)

### Wide — 1280 × 720

![Workflow library at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-482d1b4c8.png)

### Card actions

![Workflow library actions at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-actions-482d1b4c8.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-17 09:56:34 -07:00
committed by GitHub
parent f716eef437
commit edc4a09aaa
19 changed files with 1038 additions and 238 deletions

View File

@@ -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?;

View File

@@ -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());
}
}

View File

@@ -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 { .. }));
}