pinakes-ui: add special actions; add modal control to action executor

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If2e94d303e1e86f5e6cd7589c9ff58356a6a6964
This commit is contained in:
raf 2026-03-11 17:01:22 +03:00
commit 5d7076426c
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -3,8 +3,17 @@
//! This module provides the action execution system that handles //! This module provides the action execution system that handles
//! user interactions with plugin UI elements. //! user interactions with plugin UI elements.
use pinakes_plugin_api::{ActionDefinition, ActionRef, HttpMethod}; use std::collections::HashMap;
use pinakes_plugin_api::{
ActionDefinition,
ActionRef,
Expression,
SpecialAction,
UiElement,
};
use super::data::to_reqwest_method;
use crate::client::ApiClient; use crate::client::ApiClient;
/// Result of an action execution /// Result of an action execution
@ -18,18 +27,62 @@ pub enum ActionResult {
Navigate(String), Navigate(String),
/// No meaningful result (e.g. 204 No Content) /// No meaningful result (e.g. 204 No Content)
None, 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 /// 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( pub async fn execute_action(
client: &ApiClient, client: &ApiClient,
action_ref: &ActionRef, action_ref: &ActionRef,
page_actions: &HashMap<String, ActionDefinition>,
form_data: Option<&serde_json::Value>, form_data: Option<&serde_json::Value>,
) -> Result<ActionResult, String> { ) -> Result<ActionResult, String> {
match action_ref { 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) => { ActionRef::Name(name) => {
tracing::warn!("Named action '{}' not implemented yet", name); if let Some(action) = page_actions.get(name) {
Ok(ActionResult::None) 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) => { ActionRef::Inline(action) => {
execute_inline_action(client, action, form_data).await execute_inline_action(client, action, form_data).await
@ -48,13 +101,7 @@ async fn execute_inline_action(
// Merge action params with form data into query string for GET, body for // Merge action params with form data into query string for GET, body for
// others // others
let method = match action.method { let method = to_reqwest_method(&action.method);
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Patch => reqwest::Method::PATCH,
HttpMethod::Delete => reqwest::Method::DELETE,
};
let mut request = client.raw_request(method.clone(), &url); let mut request = client.raw_request(method.clone(), &url);
@ -82,12 +129,11 @@ async fn execute_inline_action(
.iter() .iter()
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
.collect(); .collect();
// action.params take precedence; form_data only fills in missing keys // action.params take precedence; form_data only fills in missing keys
if let Some(fd) = form_data { if let Some(obj) = form_data.and_then(serde_json::Value::as_object) {
if let Some(obj) = fd.as_object() { for (k, v) in obj {
for (k, v) in obj { merged.entry(k.clone()).or_insert_with(|| v.clone());
merged.entry(k.clone()).or_insert_with(|| v.clone());
}
} }
} }
if !merged.is_empty() { if !merged.is_empty() {
@ -178,10 +224,105 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_named_action_ref_returns_none() { async fn test_named_action_unknown_returns_none() {
let client = crate::client::ApiClient::default(); let client = crate::client::ApiClient::default();
let action_ref = ActionRef::Name("my-action".to_string()); let action_ref = ActionRef::Name("my-action".to_string());
let result = execute_action(&client, &action_ref, None).await.unwrap(); let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
assert!(matches!(result, ActionResult::None)); 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 = HashMap::new();
page_actions.insert("do-thing".to_string(), ActionDefinition {
method: pinakes_plugin_api::HttpMethod::Post,
path: "/api/v1/nonexistent-endpoint".to_string(),
params: HashMap::new(),
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, &HashMap::new(), 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, &HashMap::new(), 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, &HashMap::new(), 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, &HashMap::new(), None)
.await
.unwrap();
assert!(matches!(result, ActionResult::CloseModal));
}
} }