Skip to main content

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(
137            data.title
138                .clone()
139                .unwrap_or_else(|| "Create Account".to_string()),
140        ),
141        description: data
142            .description
143            .clone()
144            .or_else(|| Some("Enter your details to register".to_string())),
145        content: vec![
146            Component::TextInput(TextInput {
147                id: Some("name".to_string()),
148                name: "name".to_string(),
149                label: "Full Name".to_string(),
150                placeholder: Some("Enter your name".to_string()),
151                input_type: "text".to_string(),
152                required: true,
153                default_value: None,
154                error: None,
155                min_length: Some(2),
156                max_length: Some(100),
157            }),
158            Component::TextInput(TextInput {
159                id: Some("email".to_string()),
160                name: "email".to_string(),
161                label: "Email".to_string(),
162                placeholder: Some("you@example.com".to_string()),
163                input_type: "email".to_string(),
164                required: true,
165                default_value: None,
166                error: None,
167                min_length: None,
168                max_length: None,
169            }),
170            Component::TextInput(TextInput {
171                id: Some("password".to_string()),
172                name: "password".to_string(),
173                label: "Password".to_string(),
174                placeholder: Some("Choose a strong password".to_string()),
175                input_type: "password".to_string(),
176                required: true,
177                default_value: None,
178                error: None,
179                min_length: Some(8),
180                max_length: None,
181            }),
182        ],
183        footer: Some(vec![Component::Button(Button {
184            id: Some("submit".to_string()),
185            label: "Create Account".to_string(),
186            action_id: "register_submit".to_string(),
187            variant: ButtonVariant::Primary,
188            disabled: false,
189            icon: None,
190        })]),
191    })]
192}
193
194fn login_template(data: &TemplateData) -> Vec<Component> {
195    vec![Component::Card(Card {
196        id: Some("login-card".to_string()),
197        title: Some(
198            data.title
199                .clone()
200                .unwrap_or_else(|| "Welcome Back".to_string()),
201        ),
202        description: data
203            .description
204            .clone()
205            .or_else(|| Some("Sign in to your account".to_string())),
206        content: vec![
207            Component::TextInput(TextInput {
208                id: Some("email".to_string()),
209                name: "email".to_string(),
210                label: "Email".to_string(),
211                placeholder: Some("you@example.com".to_string()),
212                input_type: "email".to_string(),
213                required: true,
214                default_value: None,
215                error: None,
216                min_length: None,
217                max_length: None,
218            }),
219            Component::TextInput(TextInput {
220                id: Some("password".to_string()),
221                name: "password".to_string(),
222                label: "Password".to_string(),
223                placeholder: Some("Enter your password".to_string()),
224                input_type: "password".to_string(),
225                required: true,
226                default_value: None,
227                error: None,
228                min_length: None,
229                max_length: None,
230            }),
231        ],
232        footer: Some(vec![Component::Button(Button {
233            id: Some("submit".to_string()),
234            label: "Sign In".to_string(),
235            action_id: "login_submit".to_string(),
236            variant: ButtonVariant::Primary,
237            disabled: false,
238            icon: None,
239        })]),
240    })]
241}
242
243fn user_profile_template(data: &TemplateData) -> Vec<Component> {
244    let user = data.user.as_ref();
245    let name = user
246        .map(|u| u.name.clone())
247        .unwrap_or_else(|| "User".to_string());
248    let email = user
249        .map(|u| u.email.clone())
250        .unwrap_or_else(|| "user@example.com".to_string());
251    let role = user
252        .and_then(|u| u.role.clone())
253        .unwrap_or_else(|| "Member".to_string());
254
255    vec![Component::Card(Card {
256        id: Some("profile-card".to_string()),
257        title: Some(
258            data.title
259                .clone()
260                .unwrap_or_else(|| "User Profile".to_string()),
261        ),
262        description: None,
263        content: vec![
264            Component::Text(Text {
265                id: None,
266                content: format!("**{}**", name),
267                variant: TextVariant::H3,
268            }),
269            Component::Badge(Badge {
270                id: None,
271                label: role,
272                variant: BadgeVariant::Info,
273            }),
274            Component::Divider(Divider { id: None }),
275            Component::KeyValue(KeyValue {
276                id: None,
277                pairs: vec![KeyValuePair {
278                    key: "Email".to_string(),
279                    value: email,
280                }],
281            }),
282        ],
283        footer: Some(vec![Component::Button(Button {
284            id: Some("edit".to_string()),
285            label: "Edit Profile".to_string(),
286            action_id: "edit_profile".to_string(),
287            variant: ButtonVariant::Secondary,
288            disabled: false,
289            icon: None,
290        })]),
291    })]
292}
293
294fn settings_template(data: &TemplateData) -> Vec<Component> {
295    vec![Component::Card(Card {
296        id: Some("settings-card".to_string()),
297        title: Some(data.title.clone().unwrap_or_else(|| "Settings".to_string())),
298        description: data
299            .description
300            .clone()
301            .or_else(|| Some("Manage your preferences".to_string())),
302        content: vec![
303            Component::Switch(Switch {
304                id: Some("notifications".to_string()),
305                name: "notifications".to_string(),
306                label: "Email Notifications".to_string(),
307                default_checked: true,
308            }),
309            Component::Switch(Switch {
310                id: Some("dark_mode".to_string()),
311                name: "dark_mode".to_string(),
312                label: "Dark Mode".to_string(),
313                default_checked: false,
314            }),
315            Component::Select(Select {
316                id: Some("language".to_string()),
317                name: "language".to_string(),
318                label: "Language".to_string(),
319                options: vec![
320                    SelectOption {
321                        value: "en".to_string(),
322                        label: "English".to_string(),
323                    },
324                    SelectOption {
325                        value: "es".to_string(),
326                        label: "Spanish".to_string(),
327                    },
328                    SelectOption {
329                        value: "fr".to_string(),
330                        label: "French".to_string(),
331                    },
332                ],
333                required: false,
334                error: None,
335            }),
336        ],
337        footer: Some(vec![Component::Button(Button {
338            id: Some("save".to_string()),
339            label: "Save Settings".to_string(),
340            action_id: "save_settings".to_string(),
341            variant: ButtonVariant::Primary,
342            disabled: false,
343            icon: None,
344        })]),
345    })]
346}
347
348fn confirm_delete_template(data: &TemplateData) -> Vec<Component> {
349    vec![Component::Modal(Modal {
350        id: Some("confirm-delete-modal".to_string()),
351        title: data
352            .title
353            .clone()
354            .unwrap_or_else(|| "Confirm Deletion".to_string()),
355        content: vec![Component::Alert(Alert {
356            id: None,
357            title: "Warning".to_string(),
358            description: Some(data.message.clone().unwrap_or_else(|| {
359                "This action cannot be undone. All data will be permanently deleted.".to_string()
360            })),
361            variant: AlertVariant::Warning,
362        })],
363        footer: Some(vec![
364            Component::Button(Button {
365                id: Some("cancel".to_string()),
366                label: "Cancel".to_string(),
367                action_id: "cancel_delete".to_string(),
368                variant: ButtonVariant::Secondary,
369                disabled: false,
370                icon: None,
371            }),
372            Component::Button(Button {
373                id: Some("confirm".to_string()),
374                label: "Delete".to_string(),
375                action_id: "confirm_delete".to_string(),
376                variant: ButtonVariant::Danger,
377                disabled: false,
378                icon: None,
379            }),
380        ]),
381        size: ModalSize::Small,
382        closable: true,
383    })]
384}
385
386fn status_dashboard_template(data: &TemplateData) -> Vec<Component> {
387    let stats = if data.stats.is_empty() {
388        vec![
389            StatItem {
390                label: "CPU".to_string(),
391                value: "45%".to_string(),
392                status: Some("ok".to_string()),
393            },
394            StatItem {
395                label: "Memory".to_string(),
396                value: "78%".to_string(),
397                status: Some("warning".to_string()),
398            },
399            StatItem {
400                label: "Disk".to_string(),
401                value: "32%".to_string(),
402                status: Some("ok".to_string()),
403            },
404        ]
405    } else {
406        data.stats.clone()
407    };
408
409    vec![
410        Component::Text(Text {
411            id: None,
412            content: data
413                .title
414                .clone()
415                .unwrap_or_else(|| "System Status".to_string()),
416            variant: TextVariant::H2,
417        }),
418        Component::Grid(Grid {
419            id: None,
420            columns: stats.len().min(4) as u8,
421            gap: 4,
422            children: stats
423                .iter()
424                .map(|stat| {
425                    let status_variant = match stat.status.as_deref() {
426                        Some("ok") | Some("success") => BadgeVariant::Success,
427                        Some("warning") => BadgeVariant::Warning,
428                        Some("error") | Some("critical") => BadgeVariant::Error,
429                        _ => BadgeVariant::Default,
430                    };
431                    Component::Card(Card {
432                        id: None,
433                        title: None,
434                        description: None,
435                        content: vec![
436                            Component::Text(Text {
437                                id: None,
438                                content: stat.label.clone(),
439                                variant: TextVariant::Caption,
440                            }),
441                            Component::Text(Text {
442                                id: None,
443                                content: stat.value.clone(),
444                                variant: TextVariant::H3,
445                            }),
446                            Component::Badge(Badge {
447                                id: None,
448                                label: stat.status.clone().unwrap_or_else(|| "ok".to_string()),
449                                variant: status_variant,
450                            }),
451                        ],
452                        footer: None,
453                    })
454                })
455                .collect(),
456        }),
457    ]
458}
459
460fn data_table_template(data: &TemplateData) -> Vec<Component> {
461    let columns = if data.columns.is_empty() {
462        vec![
463            TableColumn {
464                header: "ID".to_string(),
465                accessor_key: "id".to_string(),
466                sortable: true,
467            },
468            TableColumn {
469                header: "Name".to_string(),
470                accessor_key: "name".to_string(),
471                sortable: true,
472            },
473            TableColumn {
474                header: "Status".to_string(),
475                accessor_key: "status".to_string(),
476                sortable: false,
477            },
478        ]
479    } else {
480        data.columns.clone()
481    };
482
483    vec![
484        Component::Text(Text {
485            id: None,
486            content: data.title.clone().unwrap_or_else(|| "Data".to_string()),
487            variant: TextVariant::H2,
488        }),
489        Component::Table(Table {
490            id: Some("data-table".to_string()),
491            columns,
492            data: data.rows.clone(),
493            sortable: true,
494            page_size: Some(10),
495            striped: true,
496        }),
497    ]
498}
499
500fn success_message_template(data: &TemplateData) -> Vec<Component> {
501    vec![Component::Alert(Alert {
502        id: Some("success-alert".to_string()),
503        title: data.title.clone().unwrap_or_else(|| "Success!".to_string()),
504        description: data
505            .message
506            .clone()
507            .or_else(|| Some("Operation completed successfully.".to_string())),
508        variant: AlertVariant::Success,
509    })]
510}
511
512fn error_message_template(data: &TemplateData) -> Vec<Component> {
513    vec![Component::Alert(Alert {
514        id: Some("error-alert".to_string()),
515        title: data.title.clone().unwrap_or_else(|| "Error".to_string()),
516        description: data
517            .message
518            .clone()
519            .or_else(|| Some("Something went wrong. Please try again.".to_string())),
520        variant: AlertVariant::Error,
521    })]
522}
523
524fn loading_template(data: &TemplateData) -> Vec<Component> {
525    vec![Component::Spinner(Spinner {
526        id: Some("loading-spinner".to_string()),
527        size: SpinnerSize::Large,
528        label: data
529            .message
530            .clone()
531            .or_else(|| Some("Loading...".to_string())),
532    })]
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn test_registration_template() {
541        let response = render_template(UiTemplate::Registration, TemplateData::default());
542        assert_eq!(response.components.len(), 1);
543    }
544
545    #[test]
546    fn test_template_from_name() {
547        assert_eq!(
548            UiTemplate::from_name("registration"),
549            Some(UiTemplate::Registration)
550        );
551        assert_eq!(
552            UiTemplate::from_name("signup"),
553            Some(UiTemplate::Registration)
554        );
555        assert_eq!(UiTemplate::from_name("login"), Some(UiTemplate::Login));
556        assert_eq!(UiTemplate::from_name("unknown"), None);
557    }
558}