Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I766c36cb53d3d7f9e85b91a67c4131a66a6a6964
329 lines
9.8 KiB
Rust
329 lines
9.8 KiB
Rust
//! 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<String, ActionDefinition>,
|
|
form_data: Option<&serde_json::Value>,
|
|
) -> Result<ActionResult, String> {
|
|
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<ActionResult, String> {
|
|
// 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<String, serde_json::Value> = 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));
|
|
}
|
|
}
|