use super::{
ComponentSpan, Dashboard, DashboardComponent, DashboardLayout, DashboardMetadata,
DashboardTheme, KpiCard, Typography,
};
use crate::error::PdfError;
use crate::graphics::Color;
use std::collections::HashMap;
#[derive(Debug)]
pub struct DashboardBuilder {
title: Option<String>,
subtitle: Option<String>,
theme: DashboardTheme,
layout_config: DashboardConfig,
components: Vec<Box<dyn DashboardComponent>>,
metadata: DashboardMetadata,
current_row: Vec<Box<dyn DashboardComponent>>,
}
impl DashboardBuilder {
pub fn new() -> Self {
Self {
title: None,
subtitle: None,
theme: DashboardTheme::default(),
layout_config: DashboardConfig::default(),
components: Vec::new(),
metadata: DashboardMetadata::default(),
current_row: Vec::new(),
}
}
pub fn title<T: Into<String>>(mut self, title: T) -> Self {
self.title = Some(title.into());
self
}
pub fn subtitle<T: Into<String>>(mut self, subtitle: T) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn theme(mut self, theme: DashboardTheme) -> Self {
self.theme = theme;
self
}
pub fn theme_by_name(mut self, theme_name: &str) -> Self {
self.theme = match theme_name.to_lowercase().as_str() {
"corporate" => DashboardTheme::corporate(),
"minimal" => DashboardTheme::minimal(),
"dark" => DashboardTheme::dark(),
"colorful" => DashboardTheme::colorful(),
_ => DashboardTheme::default(),
};
self
}
pub fn color_palette(mut self, colors: Vec<Color>) -> Self {
self.theme.set_color_palette(colors);
self
}
pub fn typography(mut self, typography: Typography) -> Self {
self.theme.set_typography(typography);
self
}
pub fn layout_config(mut self, config: DashboardConfig) -> Self {
self.layout_config = config;
self
}
pub fn add_component(mut self, component: Box<dyn DashboardComponent>) -> Self {
self.finish_current_row();
self.components.push(component);
self
}
pub fn add_row(mut self, components: Vec<Box<dyn DashboardComponent>>) -> Self {
self.finish_current_row();
let total_span: u8 = components.iter().map(|c| c.get_span().columns).sum();
if total_span > 12 {
tracing::warn!(
"Row components span {} columns, exceeding maximum of 12",
total_span
);
}
self.components.extend(components);
self
}
pub fn start_row(mut self) -> Self {
self.finish_current_row();
self
}
pub fn add_to_row(mut self, component: Box<dyn DashboardComponent>) -> Self {
self.current_row.push(component);
self
}
pub fn finish_row(mut self) -> Self {
self.finish_current_row();
self
}
pub fn add_kpi_row(mut self, kpi_cards: Vec<KpiCard>) -> Self {
let total_cards = kpi_cards.len();
if total_cards <= 2 {
let span_per_card = (12 / total_cards.max(1)) as u8;
let components: Vec<Box<dyn DashboardComponent>> = kpi_cards
.into_iter()
.map(|mut card| {
card.set_span(ComponentSpan::new(span_per_card));
Box::new(card) as Box<dyn DashboardComponent>
})
.collect();
self.add_row(components)
} else {
self.finish_current_row();
for chunk in kpi_cards.chunks(2) {
let span_per_card = (12 / chunk.len().max(1)) as u8;
let row_components: Vec<Box<dyn DashboardComponent>> = chunk
.iter()
.cloned()
.map(|mut card| {
card.set_span(ComponentSpan::new(span_per_card));
Box::new(card) as Box<dyn DashboardComponent>
})
.collect();
self.components.extend(row_components);
}
self
}
}
pub fn author<T: Into<String>>(mut self, author: T) -> Self {
self.metadata.author = Some(author.into());
self
}
pub fn data_source<T: Into<String>>(mut self, source: T) -> Self {
self.metadata.data_sources.push(source.into());
self
}
pub fn data_sources<T: Into<String>>(mut self, sources: Vec<T>) -> Self {
let sources: Vec<String> = sources.into_iter().map(|s| s.into()).collect();
self.metadata.data_sources.extend(sources);
self
}
pub fn tag<T: Into<String>>(mut self, tag: T) -> Self {
self.metadata.tags.push(tag.into());
self
}
pub fn tags<T: Into<String>>(mut self, tags: Vec<T>) -> Self {
let tags: Vec<String> = tags.into_iter().map(|t| t.into()).collect();
self.metadata.tags.extend(tags);
self
}
pub fn version<T: Into<String>>(mut self, version: T) -> Self {
self.metadata.version = version.into();
self
}
pub fn build(mut self) -> Result<Dashboard, PdfError> {
self.finish_current_row();
let title = self
.title
.ok_or_else(|| PdfError::InvalidOperation("Dashboard title is required".to_string()))?;
for component in &self.components {
component.validate()?;
}
let layout = DashboardLayout::new(self.layout_config);
Ok(Dashboard {
title,
subtitle: self.subtitle,
layout,
theme: self.theme,
components: self.components,
metadata: self.metadata,
})
}
fn finish_current_row(&mut self) {
if !self.current_row.is_empty() {
let row_components = std::mem::take(&mut self.current_row);
self.components.extend(row_components);
}
}
}
impl Default for DashboardBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct DashboardConfig {
pub margins: (f64, f64, f64, f64),
pub column_gutter: f64,
pub row_gutter: f64,
pub header_height: f64,
pub footer_height: f64,
pub max_content_width: f64,
pub center_content: bool,
pub default_component_height: f64,
pub breakpoints: HashMap<String, f64>,
}
impl DashboardConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_margins(mut self, top: f64, right: f64, bottom: f64, left: f64) -> Self {
self.margins = (top, right, bottom, left);
self
}
pub fn with_uniform_margins(mut self, margin: f64) -> Self {
self.margins = (margin, margin, margin, margin);
self
}
pub fn with_column_gutter(mut self, gutter: f64) -> Self {
self.column_gutter = gutter;
self
}
pub fn with_row_gutter(mut self, gutter: f64) -> Self {
self.row_gutter = gutter;
self
}
pub fn with_max_content_width(mut self, width: f64) -> Self {
self.max_content_width = width;
self
}
pub fn with_centered_content(mut self, center: bool) -> Self {
self.center_content = center;
self
}
pub fn with_default_component_height(mut self, height: f64) -> Self {
self.default_component_height = height;
self
}
pub fn with_breakpoint<T: Into<String>>(mut self, name: T, width: f64) -> Self {
self.breakpoints.insert(name.into(), width);
self
}
}
impl Default for DashboardConfig {
fn default() -> Self {
let mut breakpoints = HashMap::new();
breakpoints.insert("small".to_string(), 400.0);
breakpoints.insert("medium".to_string(), 600.0);
breakpoints.insert("large".to_string(), 800.0);
breakpoints.insert("xlarge".to_string(), 1000.0);
Self {
margins: (30.0, 30.0, 30.0, 30.0), column_gutter: 12.0, row_gutter: 30.0, header_height: 60.0, footer_height: 25.0, max_content_width: 0.0, center_content: false,
default_component_height: 120.0, breakpoints,
}
}
}
impl Default for DashboardMetadata {
fn default() -> Self {
Self {
created_at: chrono::Utc::now(),
version: "1.0.0".to_string(),
data_sources: Vec::new(),
author: None,
tags: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::Color;
#[test]
fn test_dashboard_builder_basic() {
let dashboard = DashboardBuilder::new()
.title("Test Dashboard")
.subtitle("Unit Test")
.author("Test Author")
.build()
.unwrap();
assert_eq!(dashboard.title, "Test Dashboard");
assert_eq!(dashboard.subtitle, Some("Unit Test".to_string()));
assert_eq!(dashboard.metadata.author, Some("Test Author".to_string()));
}
#[test]
fn test_dashboard_builder_validation() {
let result = DashboardBuilder::new().subtitle("Missing title").build();
assert!(result.is_err());
if let Err(PdfError::InvalidOperation(msg)) = result {
assert!(msg.contains("title is required"));
}
}
#[test]
fn test_dashboard_config() {
let config = DashboardConfig::new()
.with_uniform_margins(40.0)
.with_column_gutter(12.0)
.with_max_content_width(800.0)
.with_breakpoint("custom", 500.0);
assert_eq!(config.margins, (40.0, 40.0, 40.0, 40.0));
assert_eq!(config.column_gutter, 12.0);
assert_eq!(config.max_content_width, 800.0);
assert_eq!(config.breakpoints.get("custom"), Some(&500.0));
}
#[test]
fn test_dashboard_builder_theming() {
let dashboard = DashboardBuilder::new()
.title("Themed Dashboard")
.theme_by_name("corporate")
.color_palette(vec![Color::blue(), Color::green()])
.build()
.unwrap();
assert_eq!(dashboard.title, "Themed Dashboard");
}
#[test]
fn test_dashboard_builder_metadata() {
let dashboard = DashboardBuilder::new()
.title("Data Dashboard")
.data_sources(vec!["Sales DB", "Analytics API"])
.tags(vec!["sales", "q4", "executive"])
.version("2.1.0")
.build()
.unwrap();
assert_eq!(dashboard.metadata.data_sources.len(), 2);
assert_eq!(dashboard.metadata.tags.len(), 3);
assert_eq!(dashboard.metadata.version, "2.1.0");
}
}