From e46a8943cbf164d125da90afd0dbc3ab3e09e26e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:01:08 +0300 Subject: [PATCH] pinakes-ui: add plugin data fetching Signed-off-by: NotAShelf Change-Id: Ie28a544cf0df1a23b905e89b69db19c06a6a6964 --- crates/pinakes-ui/src/plugin_ui/data.rs | 273 ++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/data.rs diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs new file mode 100644 index 0000000..eba6d57 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -0,0 +1,273 @@ +//! Data fetching system for plugin UI pages +//! +//! Provides data fetching and caching for plugin data sources. + +use std::collections::HashMap; + +use dioxus::prelude::*; +use pinakes_plugin_api::{DataSource, HttpMethod}; + +use crate::client::ApiClient; + +/// Cached data for a plugin page +#[derive(Debug, Clone, Default)] +pub struct PluginPageData { + data: HashMap, + loading: HashMap, + errors: HashMap, +} + +impl PluginPageData { + /// Get data for a specific source + #[must_use] + pub fn get(&self, source: &str) -> Option<&serde_json::Value> { + self.data.get(source) + } + + /// Check if a source is currently loading + #[must_use] + pub fn is_loading(&self, source: &str) -> bool { + self.loading.get(source).copied().unwrap_or(false) + } + + /// Get error for a specific source + #[must_use] + pub fn error(&self, source: &str) -> Option<&String> { + self.errors.get(source) + } + + /// Check if there's data for a specific source + #[must_use] + pub fn has_data(&self, source: &str) -> bool { + self.data.contains_key(source) + } + + /// Set data for a source + pub fn set_data(&mut self, source: String, value: serde_json::Value) { + self.data.insert(source, value); + } + + /// Set loading state for a source + pub fn set_loading(&mut self, source: &str, loading: bool) { + if loading { + self.loading.insert(source.to_string(), true); + self.errors.remove(source); + } else { + self.loading.remove(source); + } + } + + /// Set error for a source + pub fn set_error(&mut self, source: String, error: String) { + self.errors.insert(source, error); + } + + /// Clear all data + pub fn clear(&mut self) { + self.data.clear(); + self.loading.clear(); + self.errors.clear(); + } +} + +/// Fetch data from an endpoint +async fn fetch_endpoint( + client: &ApiClient, + path: &str, + method: HttpMethod, +) -> Result { + let reqwest_method = match 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, + }; + + // Send request and parse response + let response = client + .raw_request(reqwest_method, path) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse JSON: {e}")) +} + +/// Fetch all data sources for a page +/// +/// # Errors +/// +/// Returns an error if any data source fails to fetch +pub async fn fetch_page_data( + client: &ApiClient, + data_sources: &HashMap, +) -> Result, String> { + let mut results = HashMap::new(); + + for (name, source) in data_sources { + let value = match source { + DataSource::Endpoint { path, method, .. } => { + // Fetch from endpoint (ignoring params, poll_interval, transform for + // now) + fetch_endpoint(client, path, method.clone()).await? + }, + DataSource::Static { value } => value.clone(), + DataSource::Transform { + source_name, + expression, + } => { + // Get source data and apply transform + let source_data = results + .get(source_name) + .cloned() + .unwrap_or(serde_json::Value::Null); + // TODO: Actually evaluate expression against source_data + // For now, return source_data unchanged + let _ = expression; + source_data + }, + }; + results.insert(name.clone(), value); + } + + Ok(results) +} + +/// Hook to fetch and cache plugin page data +/// +/// Returns a signal containing the data state +pub fn use_plugin_data( + client: Signal, + data_sources: HashMap, +) -> Signal { + let mut data = use_signal(PluginPageData::default); + + use_effect(move || { + let sources = data_sources.clone(); + + spawn(async move { + // Clear previous data + data.write().clear(); + + // Mark all sources as loading + for name in sources.keys() { + data.write().set_loading(name, true); + } + + // Fetch data + match fetch_page_data(&client.read(), &sources).await { + Ok(results) => { + for (name, value) in results { + data.write().set_data(name, value); + } + }, + Err(e) => { + for name in sources.keys() { + data.write().set_error(name.clone(), e.clone()); + } + }, + } + }); + }); + + data +} + +/// Get a value from JSON by path (dot notation) +/// +/// Supports object keys and array indices +#[must_use] +pub fn get_json_path<'a>( + value: &'a serde_json::Value, + path: &str, +) -> Option<&'a serde_json::Value> { + let mut current = value; + + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + current = map.get(key)?; + }, + serde_json::Value::Array(arr) => { + let idx = key.parse::().ok()?; + current = arr.get(idx)?; + }, + _ => return None, + } + } + + Some(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_page_data() { + let mut data = PluginPageData::default(); + + // Test empty state + assert!(!data.has_data("test")); + assert!(!data.is_loading("test")); + assert!(data.error("test").is_none()); + + // Test setting data + data.set_data("test".to_string(), serde_json::json!({"key": "value"})); + assert!(data.has_data("test")); + assert_eq!(data.get("test"), Some(&serde_json::json!({"key": "value"}))); + + // Test loading state + data.set_loading("loading", true); + assert!(data.is_loading("loading")); + data.set_loading("loading", false); + assert!(!data.is_loading("loading")); + + // Test error state + data.set_error("error".to_string(), "oops".to_string()); + assert_eq!(data.error("error"), Some(&"oops".to_string())); + } + + #[test] + fn test_get_json_path_object() { + let data = serde_json::json!({ + "user": { + "name": "John", + "age": 30 + } + }); + + assert_eq!( + get_json_path(&data, "user.name"), + Some(&serde_json::Value::String("John".to_string())) + ); + } + + #[test] + fn test_get_json_path_array() { + let data = serde_json::json!({ + "items": ["a", "b", "c"] + }); + + assert_eq!( + get_json_path(&data, "items.1"), + Some(&serde_json::Value::String("b".to_string())) + ); + } + + #[test] + fn test_get_json_path_invalid() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "nonexistent").is_none()); + } +}