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
|
//! 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue