From 5d7076426c39042711b0a9c307b2f7bd6aaefe73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:01:22 +0300 Subject: [PATCH] pinakes-ui: add special actions; add modal control to action executor Signed-off-by: NotAShelf Change-Id: If2e94d303e1e86f5e6cd7589c9ff58356a6a6964 --- crates/pinakes-ui/src/plugin_ui/actions.rs | 175 +++++++++++++++++++-- 1 file changed, 158 insertions(+), 17 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index f123359..8c9eb64 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -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,18 +27,62 @@ 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, 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) => { - tracing::warn!("Named action '{}' not implemented yet", name); - Ok(ActionResult::None) + 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,12 +129,11 @@ 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() { - for (k, v) in obj { - merged.entry(k.clone()).or_insert_with(|| v.clone()); - } + 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() { @@ -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)); + } }