diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index eba6d57..2f4e2d0 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -10,7 +10,7 @@ use pinakes_plugin_api::{DataSource, HttpMethod}; use crate::client::ApiClient; /// Cached data for a plugin page -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct PluginPageData { data: HashMap, loading: HashMap, @@ -62,6 +62,19 @@ impl PluginPageData { self.errors.insert(source, error); } + /// Convert all resolved data to a single JSON object for expression + /// evaluation + #[must_use] + pub fn as_json(&self) -> serde_json::Value { + serde_json::Value::Object( + self + .data + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + } + /// Clear all data pub fn clear(&mut self) { self.data.clear(); @@ -114,7 +127,18 @@ pub async fn fetch_page_data( ) -> Result, String> { let mut results = HashMap::new(); - for (name, source) in data_sources { + // Process non-Transform sources first so Transform sources can reference them + let mut ordered: Vec<(&String, &DataSource)> = data_sources + .iter() + .filter(|(_, s)| !matches!(s, DataSource::Transform { .. })) + .collect(); + ordered.extend( + data_sources + .iter() + .filter(|(_, s)| matches!(s, DataSource::Transform { .. })), + ); + + for (name, source) in ordered { let value = match source { DataSource::Endpoint { path, method, .. } => { // Fetch from endpoint (ignoring params, poll_interval, transform for @@ -168,11 +192,13 @@ pub fn use_plugin_data( match fetch_page_data(&client.read(), &sources).await { Ok(results) => { for (name, value) in results { + data.write().set_loading(&name, false); data.write().set_data(name, value); } }, Err(e) => { for name in sources.keys() { + data.write().set_loading(name, false); data.write().set_error(name.clone(), e.clone()); } }, @@ -270,4 +296,118 @@ mod tests { let data = serde_json::json!({"foo": "bar"}); assert!(get_json_path(&data, "nonexistent").is_none()); } + + #[test] + fn test_get_json_path_array_out_of_bounds() { + let data = serde_json::json!({"items": ["a"]}); + assert!(get_json_path(&data, "items.5").is_none()); + } + + #[test] + fn test_get_json_path_non_array_index() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "foo.0").is_none()); + } + + #[test] + fn test_as_json_empty() { + let data = PluginPageData::default(); + assert_eq!(data.as_json(), serde_json::json!({})); + } + + #[test] + fn test_as_json_with_data() { + let mut data = PluginPageData::default(); + data.set_data("users".to_string(), serde_json::json!([{"id": 1}])); + data.set_data("count".to_string(), serde_json::json!(42)); + let json = data.as_json(); + assert_eq!(json["users"], serde_json::json!([{"id": 1}])); + assert_eq!(json["count"], serde_json::json!(42)); + } + + #[test] + fn test_set_loading_true_clears_error() { + let mut data = PluginPageData::default(); + data.set_error("src".to_string(), "oops".to_string()); + assert!(data.error("src").is_some()); + data.set_loading("src", true); + assert!(data.error("src").is_none()); + assert!(data.is_loading("src")); + } + + #[test] + fn test_set_loading_false_removes_flag() { + let mut data = PluginPageData::default(); + data.set_loading("src", true); + assert!(data.is_loading("src")); + data.set_loading("src", false); + assert!(!data.is_loading("src")); + } + + #[test] + fn test_clear_resets_all_state() { + let mut data = PluginPageData::default(); + data.set_data("x".to_string(), serde_json::json!(1)); + data.set_loading("x", true); + data.set_error("y".to_string(), "err".to_string()); + data.clear(); + assert!(!data.has_data("x")); + assert!(!data.is_loading("x")); + assert!(data.error("y").is_none()); + } + + #[test] + fn test_partial_eq() { + let mut a = PluginPageData::default(); + let mut b = PluginPageData::default(); + assert_eq!(a, b); + a.set_data("k".to_string(), serde_json::json!(1)); + assert_ne!(a, b); + b.set_data("k".to_string(), serde_json::json!(1)); + assert_eq!(a, b); + } + + #[tokio::test] + async fn test_fetch_page_data_static_only() { + use pinakes_plugin_api::DataSource; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + sources.insert("nums".to_string(), DataSource::Static { + value: serde_json::json!([1, 2, 3]), + }); + sources.insert("flag".to_string(), DataSource::Static { + value: serde_json::json!(true), + }); + + let results = super::fetch_page_data(&client, &sources).await.unwrap(); + assert_eq!(results["nums"], serde_json::json!([1, 2, 3])); + assert_eq!(results["flag"], serde_json::json!(true)); + } + + #[tokio::test] + async fn test_fetch_page_data_transform_after_static() { + use pinakes_plugin_api::{DataSource, Expression}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + // Insert Transform before Static in the map to test ordering + sources.insert("derived".to_string(), DataSource::Transform { + source_name: "raw".to_string(), + expression: Expression::Literal(serde_json::Value::Null), + }); + sources.insert("raw".to_string(), DataSource::Static { + value: serde_json::json!({"ok": true}), + }); + + let results = super::fetch_page_data(&client, &sources).await.unwrap(); + // raw must have been processed before derived + assert_eq!(results["raw"], serde_json::json!({"ok": true})); + // derived gets source_data from raw (transform is identity for now) + assert_eq!(results["derived"], serde_json::json!({"ok": true})); + } }