//! Action execution system for plugin UI pages //! //! This module provides the action execution system that handles //! user interactions with plugin UI elements. use pinakes_plugin_api::{ ActionDefinition, ActionRef, Expression, SpecialAction, UiElement, }; use rustc_hash::FxHashMap; use super::data::to_reqwest_method; use crate::client::ApiClient; /// Result of an action execution #[derive(Debug, Clone)] pub enum ActionResult { /// Action completed successfully Success(serde_json::Value), /// Action failed Error(String), /// Navigation action Navigate(String), /// No meaningful result (e.g. 204 No Content) None, /// Re-fetch all data sources for the current page Refresh, /// Update a local state key; value is kept as an unevaluated expression so /// the renderer can resolve it against the full data context. UpdateState { key: String, value_expr: Expression, }, /// Open a modal overlay containing the given element OpenModal(UiElement), /// Close the currently open modal overlay CloseModal, } /// Execute an action defined in the UI schema /// /// `page_actions` is the map of named actions from the current page definition. /// `ActionRef::Name` entries are resolved against this map. pub async fn execute_action( client: &ApiClient, action_ref: &ActionRef, page_actions: &FxHashMap, form_data: Option<&serde_json::Value>, ) -> Result { match action_ref { ActionRef::Special(special) => { match special { SpecialAction::Refresh => Ok(ActionResult::Refresh), SpecialAction::Navigate { to } => { Ok(ActionResult::Navigate(to.clone())) }, SpecialAction::Emit { event, payload } => { if let Err(e) = client.post_plugin_event(event, payload).await { tracing::warn!(event = %event, "plugin emit failed: {e}"); } Ok(ActionResult::None) }, SpecialAction::UpdateState { key, value } => { Ok(ActionResult::UpdateState { key: key.clone(), value_expr: value.clone(), }) }, SpecialAction::OpenModal { content } => { Ok(ActionResult::OpenModal(*content.clone())) }, SpecialAction::CloseModal => Ok(ActionResult::CloseModal), } }, ActionRef::Name(name) => { if let Some(action) = page_actions.get(name) { execute_inline_action(client, action, form_data).await } else { tracing::warn!(action = %name, "Unknown action - not defined in page actions"); Ok(ActionResult::None) } }, ActionRef::Inline(action) => { execute_inline_action(client, action, form_data).await }, } } /// Execute an inline action definition async fn execute_inline_action( client: &ApiClient, action: &ActionDefinition, form_data: Option<&serde_json::Value>, ) -> Result { // Merge action params with form data into query string for GET, body for // others let method = to_reqwest_method(&action.method); let mut request = client.raw_request(method.clone(), &action.path); // For GET, merge params into query string; for mutating methods, send as // JSON body if method == reqwest::Method::GET { let query_pairs: Vec<(String, String)> = action .params .iter() .map(|(k, v)| { let val = match v { serde_json::Value::String(s) => s.clone(), other => other.to_string(), }; (k.clone(), val) }) .collect(); if !query_pairs.is_empty() { request = request.query(&query_pairs); } } else { // Build body: merge action.params with form_data let mut merged: serde_json::Map = action .params .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); // action.params take precedence; form_data only fills in missing keys if let Some(obj) = form_data.and_then(serde_json::Value::as_object) { for (k, v) in obj { merged.entry(k.clone()).or_insert_with(|| v.clone()); } } if !merged.is_empty() { request = request.json(&merged); } } let response = request.send().await.map_err(|e| e.to_string())?; let status = response.status(); if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Ok(ActionResult::Error(format!( "Action failed: {} - {}", status.as_u16(), error_text ))); } if status.as_u16() == 204 { // Navigate on success if configured if let Some(route) = &action.navigate_to { return Ok(ActionResult::Navigate(route.clone())); } return Ok(ActionResult::None); } let value: serde_json::Value = response.json().await.unwrap_or(serde_json::Value::Null); if let Some(route) = &action.navigate_to { return Ok(ActionResult::Navigate(route.clone())); } Ok(ActionResult::Success(value)) } #[cfg(test)] mod tests { use pinakes_plugin_api::ActionRef; use super::*; #[test] fn test_action_result_variants() { let _ = ActionResult::None; let _ = ActionResult::Success(serde_json::json!({"ok": true})); let _ = ActionResult::Error("error".to_string()); let _ = ActionResult::Navigate("/page".to_string()); } #[test] fn test_action_result_clone() { let original = ActionResult::Success(serde_json::json!({"key": "value"})); let cloned = original.clone(); if let (ActionResult::Success(a), ActionResult::Success(b)) = (original, cloned) { assert_eq!(a, b); } else { panic!("clone produced wrong variant"); } } #[test] fn test_action_result_error_clone() { let original = ActionResult::Error("something went wrong".to_string()); let cloned = original.clone(); if let (ActionResult::Error(a), ActionResult::Error(b)) = (original, cloned) { assert_eq!(a, b); } else { panic!("clone produced wrong variant"); } } #[test] fn test_action_result_navigate_clone() { let original = ActionResult::Navigate("/dashboard".to_string()); let cloned = original.clone(); if let (ActionResult::Navigate(a), ActionResult::Navigate(b)) = (original, cloned) { assert_eq!(a, b); } else { panic!("clone produced wrong variant"); } } #[tokio::test] async fn test_named_action_unknown_returns_none() { let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Name("my-action".to_string()); let result = execute_action(&client, &action_ref, &FxHashMap::default(), None) .await .unwrap(); assert!(matches!(result, ActionResult::None)); } #[tokio::test] async fn test_named_action_resolves_from_map() { use pinakes_plugin_api::ActionDefinition; let client = crate::client::ApiClient::default(); let mut page_actions = FxHashMap::default(); page_actions.insert("do-thing".to_string(), ActionDefinition { method: pinakes_plugin_api::HttpMethod::Post, path: "/api/v1/nonexistent-endpoint".to_string(), params: FxHashMap::default(), success_message: None, error_message: None, navigate_to: None, }); let action_ref = ActionRef::Name("do-thing".to_string()); // The action is resolved; will error because there's no server, but // ActionResult::None would mean it was NOT resolved let result = execute_action(&client, &action_ref, &page_actions, None).await; // It should NOT be Ok(None); it should be either Error or a network error match result { Ok(ActionResult::None) => { panic!("Named action was not resolved from page_actions") }, _ => {}, /* Any other result (error, network failure) means it was * resolved */ } } #[tokio::test] async fn test_special_action_refresh() { use pinakes_plugin_api::SpecialAction; let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Special(SpecialAction::Refresh); let result = execute_action(&client, &action_ref, &FxHashMap::default(), None) .await .unwrap(); assert!(matches!(result, ActionResult::Refresh)); } #[tokio::test] async fn test_special_action_navigate() { use pinakes_plugin_api::SpecialAction; let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Special(SpecialAction::Navigate { to: "/dashboard".to_string(), }); let result = execute_action(&client, &action_ref, &FxHashMap::default(), None) .await .unwrap(); assert!( matches!(result, ActionResult::Navigate(ref p) if p == "/dashboard") ); } #[tokio::test] async fn test_special_action_update_state_preserves_expression() { use pinakes_plugin_api::{Expression, SpecialAction}; let client = crate::client::ApiClient::default(); let expr = Expression::Literal(serde_json::json!(42)); let action_ref = ActionRef::Special(SpecialAction::UpdateState { key: "count".to_string(), value: expr.clone(), }); let result = execute_action(&client, &action_ref, &FxHashMap::default(), None) .await .unwrap(); match result { ActionResult::UpdateState { key, value_expr } => { assert_eq!(key, "count"); assert_eq!(value_expr, expr); }, other => panic!("expected UpdateState, got {other:?}"), } } #[tokio::test] async fn test_special_action_close_modal() { use pinakes_plugin_api::SpecialAction; let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Special(SpecialAction::CloseModal); let result = execute_action(&client, &action_ref, &FxHashMap::default(), None) .await .unwrap(); assert!(matches!(result, ActionResult::CloseModal)); } }