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:
parent
9389af9fda
commit
5d7076426c
1 changed files with 158 additions and 17 deletions
|
|
@ -3,8 +3,17 @@
|
|||
//! This module provides the action execution system that handles
|
||||
//! 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;
|
||||
|
||||
/// Result of an action execution
|
||||
|
|
@ -18,19 +27,63 @@ pub enum ActionResult {
|
|||
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: &HashMap<String, ActionDefinition>,
|
||||
form_data: Option<&serde_json::Value>,
|
||||
) -> Result<ActionResult, String> {
|
||||
match action_ref {
|
||||
ActionRef::Name(name) => {
|
||||
tracing::warn!("Named action '{}' not implemented yet", name);
|
||||
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
|
||||
},
|
||||
|
|
@ -48,13 +101,7 @@ async fn execute_inline_action(
|
|||
|
||||
// Merge action params with form data into query string for GET, body for
|
||||
// others
|
||||
let method = match 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 method = to_reqwest_method(&action.method);
|
||||
|
||||
let mut request = client.raw_request(method.clone(), &url);
|
||||
|
||||
|
|
@ -82,14 +129,13 @@ async fn execute_inline_action(
|
|||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
// action.params take precedence; form_data only fills in missing keys
|
||||
if let Some(fd) = form_data {
|
||||
if let Some(obj) = fd.as_object() {
|
||||
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);
|
||||
}
|
||||
|
|
@ -178,10 +224,105 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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 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));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue