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                data_source: None,
282            }),
283        ],
284        footer: Some(vec![Component::Button(Button {
285            id: Some("edit".to_string()),
286            label: "Edit Profile".to_string(),
287            action_id: "edit_profile".to_string(),
288            variant: ButtonVariant::Secondary,
289            disabled: false,
290            icon: None,
291        })]),
292    })]
293}
294
295fn settings_template(data: &TemplateData) -> Vec<Component> {
296    vec![Component::Card(Card {
297        id: Some("settings-card".to_string()),
298        title: Some(data.title.clone().unwrap_or_else(|| "Settings".to_string())),
299        description: data
300            .description
301            .clone()
302            .or_else(|| Some("Manage your preferences".to_string())),
303        content: vec![
304            Component::Switch(Switch {
305                id: Some("notifications".to_string()),
306                name: "notifications".to_string(),
307                label: "Email Notifications".to_string(),
308                default_checked: true,
309            }),
310            Component::Switch(Switch {
311                id: Some("dark_mode".to_string()),
312                name: "dark_mode".to_string(),
313                label: "Dark Mode".to_string(),
314                default_checked: false,
315            }),
316            Component::Select(Select {
317                id: Some("language".to_string()),
318                name: "language".to_string(),
319                label: "Language".to_string(),
320                options: vec![
321                    SelectOption {
322                        value: "en".to_string(),
323                        label: "English".to_string(),
324                    },
325                    SelectOption {
326                        value: "es".to_string(),
327                        label: "Spanish".to_string(),
328                    },
329                    SelectOption {
330                        value: "fr".to_string(),
331                        label: "French".to_string(),
332                    },
333                ],
334                required: false,
335                error: None,
336            }),
337        ],
338        footer: Some(vec![Component::Button(Button {
339            id: Some("save".to_string()),
340            label: "Save Settings".to_string(),
341            action_id: "save_settings".to_string(),
342            variant: ButtonVariant::Primary,
343            disabled: false,
344            icon: None,
345        })]),
346    })]
347}
348
349fn confirm_delete_template(data: &TemplateData) -> Vec<Component> {
350    vec![Component::Modal(Modal {
351        id: Some("confirm-delete-modal".to_string()),
352        title: data
353            .title
354            .clone()
355            .unwrap_or_else(|| "Confirm Deletion".to_string()),
356        content: vec![Component::Alert(Alert {
357            id: None,
358            title: "Warning".to_string(),
359            description: Some(data.message.clone().unwrap_or_else(|| {
360                "This action cannot be undone. All data will be permanently deleted.".to_string()
361            })),
362            variant: AlertVariant::Warning,
363        })],
364        footer: Some(vec![
365            Component::Button(Button {
366                id: Some("cancel".to_string()),
367                label: "Cancel".to_string(),
368                action_id: "cancel_delete".to_string(),
369                variant: ButtonVariant::Secondary,
370                disabled: false,
371                icon: None,
372            }),
373            Component::Button(Button {
374                id: Some("confirm".to_string()),
375                label: "Delete".to_string(),
376                action_id: "confirm_delete".to_string(),
377                variant: ButtonVariant::Danger,
378                disabled: false,
379                icon: None,
380            }),
381        ]),
382        size: ModalSize::Small,
383        closable: true,
384    })]
385}
386
387fn status_dashboard_template(data: &TemplateData) -> Vec<Component> {
388    let stats = if data.stats.is_empty() {
389        vec![
390            StatItem {
391                label: "CPU".to_string(),
392                value: "45%".to_string(),
393                status: Some("ok".to_string()),
394            },
395            StatItem {
396                label: "Memory".to_string(),
397                value: "78%".to_string(),
398                status: Some("warning".to_string()),
399            },
400            StatItem {
401                label: "Disk".to_string(),
402                value: "32%".to_string(),
403                status: Some("ok".to_string()),
404            },
405        ]
406    } else {
407        data.stats.clone()
408    };
409
410    vec![
411        Component::Text(Text {
412            id: None,
413            content: data
414                .title
415                .clone()
416                .unwrap_or_else(|| "System Status".to_string()),
417            variant: TextVariant::H2,
418        }),
419        Component::Grid(Grid {
420            id: None,
421            columns: stats.len().min(4) as u8,
422            gap: 4,
423            children: stats
424                .iter()
425                .map(|stat| {
426                    let status_variant = match stat.status.as_deref() {
427                        Some("ok") | Some("success") => BadgeVariant::Success,
428                        Some("warning") => BadgeVariant::Warning,
429                        Some("error") | Some("critical") => BadgeVariant::Error,
430                        _ => BadgeVariant::Default,
431                    };
432                    Component::Card(Card {
433                        id: None,
434                        title: None,
435                        description: None,
436                        content: vec![
437                            Component::Text(Text {
438                                id: None,
439                                content: stat.label.clone(),
440                                variant: TextVariant::Caption,
441                            }),
442                            Component::Text(Text {
443                                id: None,
444                                content: stat.value.clone(),
445                                variant: TextVariant::H3,
446                            }),
447                            Component::Badge(Badge {
448                                id: None,
449                                label: stat.status.clone().unwrap_or_else(|| "ok".to_string()),
450                                variant: status_variant,
451                            }),
452                        ],
453                        footer: None,
454                    })
455                })
456                .collect(),
457        }),
458    ]
459}
460
461fn data_table_template(data: &TemplateData) -> Vec<Component> {
462    let columns = if data.columns.is_empty() {
463        vec![
464            TableColumn {
465                header: "ID".to_string(),
466                accessor_key: "id".to_string(),
467                sortable: true,
468            },
469            TableColumn {
470                header: "Name".to_string(),
471                accessor_key: "name".to_string(),
472                sortable: true,
473            },
474            TableColumn {
475                header: "Status".to_string(),
476                accessor_key: "status".to_string(),
477                sortable: false,
478            },
479        ]
480    } else {
481        data.columns.clone()
482    };
483
484    vec![
485        Component::Text(Text {
486            id: None,
487            content: data.title.clone().unwrap_or_else(|| "Data".to_string()),
488            variant: TextVariant::H2,
489        }),
490        Component::Table(Table {
491            id: Some("data-table".to_string()),
492            columns,
493            data: data.rows.clone(),
494            data_source: None,
495            sortable: true,
496            page_size: Some(10),
497            striped: true,
498        }),
499    ]
500}
501
502fn success_message_template(data: &TemplateData) -> Vec<Component> {
503    vec![Component::Alert(Alert {
504        id: Some("success-alert".to_string()),
505        title: data.title.clone().unwrap_or_else(|| "Success!".to_string()),
506        description: data
507            .message
508            .clone()
509            .or_else(|| Some("Operation completed successfully.".to_string())),
510        variant: AlertVariant::Success,
511    })]
512}
513
514fn error_message_template(data: &TemplateData) -> Vec<Component> {
515    vec![Component::Alert(Alert {
516        id: Some("error-alert".to_string()),
517        title: data.title.clone().unwrap_or_else(|| "Error".to_string()),
518        description: data
519            .message
520            .clone()
521            .or_else(|| Some("Something went wrong. Please try again.".to_string())),
522        variant: AlertVariant::Error,
523    })]
524}
525
526fn loading_template(data: &TemplateData) -> Vec<Component> {
527    vec![Component::Spinner(Spinner {
528        id: Some("loading-spinner".to_string()),
529        size: SpinnerSize::Large,
530        label: data
531            .message
532            .clone()
533            .or_else(|| Some("Loading...".to_string())),
534    })]
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn test_registration_template() {
543        let response = render_template(UiTemplate::Registration, TemplateData::default());
544        assert_eq!(response.components.len(), 1);
545    }
546
547    #[test]
548    fn test_template_from_name() {
549        assert_eq!(
550            UiTemplate::from_name("registration"),
551            Some(UiTemplate::Registration)
552        );
553        assert_eq!(
554            UiTemplate::from_name("signup"),
555            Some(UiTemplate::Registration)
556        );
557        assert_eq!(UiTemplate::from_name("login"), Some(UiTemplate::Login));
558        assert_eq!(UiTemplate::from_name("unknown"), None);
559    }
560}