mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00: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 { .. }));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user