adk_ui/
templates.rs

1//! Pre-built UI Templates
2//!
3//! A library of ready-to-use UI patterns that agents can render with minimal configuration.
4//! Templates provide complete, production-ready layouts for common use cases.
5
6use crate::schema::*;
7use std::collections::HashMap;
8
9/// Available UI templates
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum UiTemplate {
12    /// User registration form with name, email, password
13    Registration,
14    /// User login form with email and password
15    Login,
16    /// User profile display card
17    UserProfile,
18    /// Settings page with form fields
19    Settings,
20    /// Confirmation dialog for destructive actions
21    ConfirmDelete,
22    /// System status dashboard with metrics
23    StatusDashboard,
24    /// Data table with pagination
25    DataTable,
26    /// Success message card
27    SuccessMessage,
28    /// Error message card
29    ErrorMessage,
30    /// Loading state with spinner
31    Loading,
32}
33
34impl UiTemplate {
35    /// Get all available template names
36    pub fn all_names() -> &'static [&'static str] {
37        &[
38            "registration",
39            "login",
40            "user_profile",
41            "settings",
42            "confirm_delete",
43            "status_dashboard",
44            "data_table",
45            "success_message",
46            "error_message",
47            "loading",
48        ]
49    }
50
51    /// Parse a template name
52    pub fn from_name(name: &str) -> Option<Self> {
53        match name.to_lowercase().as_str() {
54            "registration" | "register" | "signup" => Some(Self::Registration),
55            "login" | "signin" => Some(Self::Login),
56            "user_profile" | "profile" => Some(Self::UserProfile),
57            "settings" | "preferences" => Some(Self::Settings),
58            "confirm_delete" | "delete_confirm" => Some(Self::ConfirmDelete),
59            "status_dashboard" | "dashboard" | "status" => Some(Self::StatusDashboard),
60            "data_table" | "table" => Some(Self::DataTable),
61            "success_message" | "success" => Some(Self::SuccessMessage),
62            "error_message" | "error" => Some(Self::ErrorMessage),
63            "loading" | "spinner" => Some(Self::Loading),
64            _ => None,
65        }
66    }
67}
68
69/// Template data that can be customized
70#[derive(Debug, Clone, Default)]
71pub struct TemplateData {
72    /// Custom title
73    pub title: Option<String>,
74    /// Custom description
75    pub description: Option<String>,
76    /// User data (name, email, etc.)
77    pub user: Option<UserData>,
78    /// Key-value data for display
79    pub data: HashMap<String, String>,
80    /// Status items for dashboard
81    pub stats: Vec<StatItem>,
82    /// Table columns
83    pub columns: Vec<TableColumn>,
84    /// Table rows
85    pub rows: Vec<HashMap<String, serde_json::Value>>,
86    /// Custom message
87    pub message: Option<String>,
88    /// Theme override
89    pub theme: Option<Theme>,
90}
91
92/// User data for templates
93#[derive(Debug, Clone)]
94pub struct UserData {
95    pub name: String,
96    pub email: String,
97    pub avatar_url: Option<String>,
98    pub role: Option<String>,
99}
100
101/// Status item for dashboard templates
102#[derive(Debug, Clone)]
103pub struct StatItem {
104    pub label: String,
105    pub value: String,
106    pub status: Option<String>,
107}
108
109/// Generate a UI response from a template
110pub fn render_template(template: UiTemplate, data: TemplateData) -> UiResponse {
111    let components = match template {
112        UiTemplate::Registration => registration_template(&data),
113        UiTemplate::Login => login_template(&data),
114        UiTemplate::UserProfile => user_profile_template(&data),
115        UiTemplate::Settings => settings_template(&data),
116        UiTemplate::ConfirmDelete => confirm_delete_template(&data),
117        UiTemplate::StatusDashboard => status_dashboard_template(&data),
118        UiTemplate::DataTable => data_table_template(&data),
119        UiTemplate::SuccessMessage => success_message_template(&data),
120        UiTemplate::ErrorMessage => error_message_template(&data),
121        UiTemplate::Loading => loading_template(&data),
122    };
123
124    let mut response = UiResponse::new(components);
125    if let Some(theme) = data.theme {
126        response = response.with_theme(theme);
127    }
128    response
129}
130
131// --- Template Implementations ---
132
133fn registration_template(data: &TemplateData) -> Vec<Component> {
134    vec![Component::Card(Card {
135        id: Some("registration-card".to_string()),
136        title: Some(data.title.clone().unwrap_or_else(|| "Create Account".to_string())),
137        description: data
138            .description
139            .clone()
140            .or_else(|| Some("Enter your details to register".to_string())),
141        content: vec![
142            Component::TextInput(TextInput {
143                id: Some("name".to_string()),
144                name: "name".to_string(),
145                label: "Full Name".to_string(),
146                placeholder: Some("Enter your name".to_string()),
147                input_type: "text".to_string(),
148                required: true,
149                default_value: None,
150                error: None,
151                min_length: Some(2),
152                max_length: Some(100),
153            }),
154            Component::TextInput(TextInput {
155                id: Some("email".to_string()),
156                name: "email".to_string(),
157                label: "Email".to_string(),
158                placeholder: Some("you@example.com".to_string()),
159                input_type: "email".to_string(),
160                required: true,
161                default_value: None,
162                error: None,
163                min_length: None,
164                max_length: None,
165            }),
166            Component::TextInput(TextInput {
167                id: Some("password".to_string()),
168                name: "password".to_string(),
169                label: "Password".to_string(),
170                placeholder: Some("Choose a strong password".to_string()),
171                input_type: "password".to_string(),
172                required: true,
173                default_value: None,
174                error: None,
175                min_length: Some(8),
176                max_length: None,
177            }),
178        ],
179        footer: Some(vec![Component::Button(Button {
180            id: Some("submit".to_string()),
181            label: "Create Account".to_string(),
182            action_id: "register_submit".to_string(),
183            variant: ButtonVariant::Primary,
184            disabled: false,
185            icon: None,
186        })]),
187    })]
188}
189
190fn login_template(data: &TemplateData) -> Vec<Component> {
191    vec![Component::Card(Card {
192        id: Some("login-card".to_string()),
193        title: Some(data.title.clone().unwrap_or_else(|| "Welcome Back".to_string())),
194        description: data
195            .description
196            .clone()
197            .or_else(|| Some("Sign in to your account".to_string())),
198        content: vec![
199            Component::TextInput(TextInput {
200                id: Some("email".to_string()),
201                name: "email".to_string(),
202                label: "Email".to_string(),
203                placeholder: Some("you@example.com".to_string()),
204                input_type: "email".to_string(),
205                required: true,
206                default_value: None,
207                error: None,
208                min_length: None,
209                max_length: None,
210            }),
211            Component::TextInput(TextInput {
212                id: Some("password".to_string()),
213                name: "password".to_string(),
214                label: "Password".to_string(),
215                placeholder: Some("Enter your password".to_string()),
216                input_type: "password".to_string(),
217                required: true,
218                default_value: None,
219                error: None,
220                min_length: None,
221                max_length: None,
222            }),
223        ],
224        footer: Some(vec![Component::Button(Button {
225            id: Some("submit".to_string()),
226            label: "Sign In".to_string(),
227            action_id: "login_submit".to_string(),
228            variant: ButtonVariant::Primary,
229            disabled: false,
230            icon: None,
231        })]),
232    })]
233}
234
235fn user_profile_template(data: &TemplateData) -> Vec<Component> {
236    let user = data.user.as_ref();
237    let name = user.map(|u| u.name.clone()).unwrap_or_else(|| "User".to_string());
238    let email = user.map(|u| u.email.clone()).unwrap_or_else(|| "user@example.com".to_string());
239    let role = user.and_then(|u| u.role.clone()).unwrap_or_else(|| "Member".to_string());
240
241    vec![Component::Card(Card {
242        id: Some("profile-card".to_string()),
243        title: Some(data.title.clone().unwrap_or_else(|| "User Profile".to_string())),
244        description: None,
245        content: vec![
246            Component::Text(Text {
247                id: None,
248                content: format!("**{}**", name),
249                variant: TextVariant::H3,
250            }),
251            Component::Badge(Badge { id: None, label: role, variant: BadgeVariant::Info }),
252            Component::Divider(Divider { id: None }),
253            Component::KeyValue(KeyValue {
254                id: None,
255                pairs: vec![KeyValuePair { key: "Email".to_string(), value: email }],
256            }),
257        ],
258        footer: Some(vec![Component::Button(Button {
259            id: Some("edit".to_string()),
260            label: "Edit Profile".to_string(),
261            action_id: "edit_profile".to_string(),
262            variant: ButtonVariant::Secondary,
263            disabled: false,
264            icon: None,
265        })]),
266    })]
267}
268
269fn settings_template(data: &TemplateData) -> Vec<Component> {
270    vec![Component::Card(Card {
271        id: Some("settings-card".to_string()),
272        title: Some(data.title.clone().unwrap_or_else(|| "Settings".to_string())),
273        description: data
274            .description
275            .clone()
276            .or_else(|| Some("Manage your preferences".to_string())),
277        content: vec![
278            Component::Switch(Switch {
279                id: Some("notifications".to_string()),
280                name: "notifications".to_string(),
281                label: "Email Notifications".to_string(),
282                default_checked: true,
283            }),
284            Component::Switch(Switch {
285                id: Some("dark_mode".to_string()),
286                name: "dark_mode".to_string(),
287                label: "Dark Mode".to_string(),
288                default_checked: false,
289            }),
290            Component::Select(Select {
291                id: Some("language".to_string()),
292                name: "language".to_string(),
293                label: "Language".to_string(),
294                options: vec![
295                    SelectOption { value: "en".to_string(), label: "English".to_string() },
296                    SelectOption { value: "es".to_string(), label: "Spanish".to_string() },
297                    SelectOption { value: "fr".to_string(), label: "French".to_string() },
298                ],
299                required: false,
300                error: None,
301            }),
302        ],
303        footer: Some(vec![Component::Button(Button {
304            id: Some("save".to_string()),
305            label: "Save Settings".to_string(),
306            action_id: "save_settings".to_string(),
307            variant: ButtonVariant::Primary,
308            disabled: false,
309            icon: None,
310        })]),
311    })]
312}
313
314fn confirm_delete_template(data: &TemplateData) -> Vec<Component> {
315    vec![Component::Modal(Modal {
316        id: Some("confirm-delete-modal".to_string()),
317        title: data.title.clone().unwrap_or_else(|| "Confirm Deletion".to_string()),
318        content: vec![Component::Alert(Alert {
319            id: None,
320            title: "Warning".to_string(),
321            description: Some(data.message.clone().unwrap_or_else(|| {
322                "This action cannot be undone. All data will be permanently deleted.".to_string()
323            })),
324            variant: AlertVariant::Warning,
325        })],
326        footer: Some(vec![
327            Component::Button(Button {
328                id: Some("cancel".to_string()),
329                label: "Cancel".to_string(),
330                action_id: "cancel_delete".to_string(),
331                variant: ButtonVariant::Secondary,
332                disabled: false,
333                icon: None,
334            }),
335            Component::Button(Button {
336                id: Some("confirm".to_string()),
337                label: "Delete".to_string(),
338                action_id: "confirm_delete".to_string(),
339                variant: ButtonVariant::Danger,
340                disabled: false,
341                icon: None,
342            }),
343        ]),
344        size: ModalSize::Small,
345        closable: true,
346    })]
347}
348
349fn status_dashboard_template(data: &TemplateData) -> Vec<Component> {
350    let stats = if data.stats.is_empty() {
351        vec![
352            StatItem {
353                label: "CPU".to_string(),
354                value: "45%".to_string(),
355                status: Some("ok".to_string()),
356            },
357            StatItem {
358                label: "Memory".to_string(),
359                value: "78%".to_string(),
360                status: Some("warning".to_string()),
361            },
362            StatItem {
363                label: "Disk".to_string(),
364                value: "32%".to_string(),
365                status: Some("ok".to_string()),
366            },
367        ]
368    } else {
369        data.stats.clone()
370    };
371
372    vec![
373        Component::Text(Text {
374            id: None,
375            content: data.title.clone().unwrap_or_else(|| "System Status".to_string()),
376            variant: TextVariant::H2,
377        }),
378        Component::Grid(Grid {
379            id: None,
380            columns: stats.len().min(4) as u8,
381            gap: 4,
382            children: stats
383                .iter()
384                .map(|stat| {
385                    let status_variant = match stat.status.as_deref() {
386                        Some("ok") | Some("success") => BadgeVariant::Success,
387                        Some("warning") => BadgeVariant::Warning,
388                        Some("error") | Some("critical") => BadgeVariant::Error,
389                        _ => BadgeVariant::Default,
390                    };
391                    Component::Card(Card {
392                        id: None,
393                        title: None,
394                        description: None,
395                        content: vec![
396                            Component::Text(Text {
397                                id: None,
398                                content: stat.label.clone(),
399                                variant: TextVariant::Caption,
400                            }),
401                            Component::Text(Text {
402                                id: None,
403                                content: stat.value.clone(),
404                                variant: TextVariant::H3,
405                            }),
406                            Component::Badge(Badge {
407                                id: None,
408                                label: stat.status.clone().unwrap_or_else(|| "ok".to_string()),
409                                variant: status_variant,
410                            }),
411                        ],
412                        footer: None,
413                    })
414                })
415                .collect(),
416        }),
417    ]
418}
419
420fn data_table_template(data: &TemplateData) -> Vec<Component> {
421    let columns = if data.columns.is_empty() {
422        vec![
423            TableColumn {
424                header: "ID".to_string(),
425                accessor_key: "id".to_string(),
426                sortable: true,
427            },
428            TableColumn {
429                header: "Name".to_string(),
430                accessor_key: "name".to_string(),
431                sortable: true,
432            },
433            TableColumn {
434                header: "Status".to_string(),
435                accessor_key: "status".to_string(),
436                sortable: false,
437            },
438        ]
439    } else {
440        data.columns.clone()
441    };
442
443    vec![
444        Component::Text(Text {
445            id: None,
446            content: data.title.clone().unwrap_or_else(|| "Data".to_string()),
447            variant: TextVariant::H2,
448        }),
449        Component::Table(Table {
450            id: Some("data-table".to_string()),
451            columns,
452            data: data.rows.clone(),
453            sortable: true,
454            page_size: Some(10),
455            striped: true,
456        }),
457    ]
458}
459
460fn success_message_template(data: &TemplateData) -> Vec<Component> {
461    vec![Component::Alert(Alert {
462        id: Some("success-alert".to_string()),
463        title: data.title.clone().unwrap_or_else(|| "Success!".to_string()),
464        description: data
465            .message
466            .clone()
467            .or_else(|| Some("Operation completed successfully.".to_string())),
468        variant: AlertVariant::Success,
469    })]
470}
471
472fn error_message_template(data: &TemplateData) -> Vec<Component> {
473    vec![Component::Alert(Alert {
474        id: Some("error-alert".to_string()),
475        title: data.title.clone().unwrap_or_else(|| "Error".to_string()),
476        description: data
477            .message
478            .clone()
479            .or_else(|| Some("Something went wrong. Please try again.".to_string())),
480        variant: AlertVariant::Error,
481    })]
482}
483
484fn loading_template(data: &TemplateData) -> Vec<Component> {
485    vec![Component::Spinner(Spinner {
486        id: Some("loading-spinner".to_string()),
487        size: SpinnerSize::Large,
488        label: data.message.clone().or_else(|| Some("Loading...".to_string())),
489    })]
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_registration_template() {
498        let response = render_template(UiTemplate::Registration, TemplateData::default());
499        assert_eq!(response.components.len(), 1);
500    }
501
502    #[test]
503    fn test_template_from_name() {
504        assert_eq!(UiTemplate::from_name("registration"), Some(UiTemplate::Registration));
505        assert_eq!(UiTemplate::from_name("signup"), Some(UiTemplate::Registration));
506        assert_eq!(UiTemplate::from_name("login"), Some(UiTemplate::Login));
507        assert_eq!(UiTemplate::from_name("unknown"), None);
508    }
509}