qorzen_oxide/ui/layout/
sidebar.rs

1// src/ui/layout/sidebar.rs - Navigation sidebar with menu items and plugin integration
2
3use dioxus::prelude::*;
4#[allow(unused_imports)]
5use dioxus_router::prelude::*;
6
7use crate::ui::{
8    router::{nav, Route},
9    state::auth::use_has_permission,
10};
11
12/// Sidebar component props
13#[derive(Props, Clone, PartialEq)]
14pub struct SidebarProps {
15    /// Whether the sidebar is collapsed on desktop
16    pub collapsed: bool,
17    /// Whether the mobile menu is open
18    pub mobile_open: bool,
19    /// Callback for closing mobile menu
20    pub on_close: Callback<Event<MouseData>>,
21}
22
23/// Navigation item definition
24#[derive(Debug, Clone, PartialEq)]
25pub struct NavItem {
26    pub id: String,
27    pub label: String,
28    pub icon: String,
29    pub route: Option<Route>,
30    pub children: Vec<NavItem>,
31    pub required_permission: Option<(String, String)>, // (resource, action)
32    pub badge: Option<String>,
33    pub external_url: Option<String>,
34}
35
36/// Main sidebar component
37#[component]
38pub fn Sidebar(props: SidebarProps) -> Element {
39    // let app_state = use_app_state();
40    let current_route = use_route::<Route>();
41    let has_permission = use_has_permission();
42
43    // Navigation items configuration
44    let nav_items = get_navigation_items();
45
46    // Filter navigation items based on user permissions
47    let filtered_nav_items = nav_items
48        .into_iter()
49        .filter(|item| {
50            if let Some((resource, action)) = &item.required_permission {
51                has_permission(resource, action)
52            } else {
53                true
54            }
55        })
56        .collect::<Vec<_>>();
57
58    rsx! {
59        // Desktop sidebar
60        div {
61            class: format!(
62                "hidden lg:flex lg:flex-col lg:fixed lg:inset-y-0 lg:z-40 lg:transition-all lg:duration-200 lg:ease-in-out {}",
63                if props.collapsed { "lg:w-16" } else { "lg:w-64" }
64            ),
65            div {
66                class: "flex flex-col flex-grow bg-white border-r border-gray-200 pt-16 pb-4 overflow-y-auto",
67                nav {
68                    class: "flex-1 px-2 space-y-1",
69                    for item in &filtered_nav_items {
70                        NavigationItem {
71                            key: "{item.id}",
72                            item: item.clone(),
73                            collapsed: props.collapsed,
74                            current_route: current_route.clone()
75                        }
76                    }
77                }
78
79                // Plugin section
80                if !props.collapsed {
81                    div {
82                        class: "px-2 mt-6",
83                        div {
84                            class: "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2",
85                            "Plugins"
86                        }
87                        PluginNavigation {}
88                    }
89                }
90            }
91        }
92
93        // Mobile sidebar
94        if props.mobile_open {
95            div {
96                class: "lg:hidden fixed inset-0 z-50 flex",
97
98                // Sidebar
99                div {
100                    class: "relative flex flex-col flex-1 w-64 bg-white",
101
102                    // Close button
103                    div {
104                        class: "absolute top-0 right-0 -mr-12 pt-2",
105                        button {
106                            r#type: "button",
107                            class: "ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white",
108                            onclick: move |e| props.on_close.call(e),
109                            span {
110                                class: "sr-only",
111                                "Close sidebar"
112                            }
113                            // X icon
114                            svg {
115                                class: "h-6 w-6 text-white",
116                                xmlns: "http://www.w3.org/2000/svg",
117                                fill: "none",
118                                view_box: "0 0 24 24",
119                                stroke: "currentColor",
120                                path {
121                                    stroke_linecap: "round",
122                                    stroke_linejoin: "round",
123                                    stroke_width: "2",
124                                    d: "M6 18L18 6M6 6l12 12"
125                                }
126                            }
127                        }
128                    }
129
130                    // Mobile navigation
131                    div {
132                        class: "flex-1 h-0 pt-5 pb-4 overflow-y-auto",
133                        nav {
134                            class: "px-2 space-y-1",
135                            for item in &filtered_nav_items {
136                                NavigationItem {
137                                    key: "{item.id}",
138                                    item: item.clone(),
139                                    collapsed: false,
140                                    current_route: current_route.clone(),
141                                    on_click: Some(props.on_close)
142                                }
143                            }
144                        }
145
146                        // Mobile plugin section
147                        div {
148                            class: "px-2 mt-6",
149                            div {
150                                class: "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2",
151                                "Plugins"
152                            }
153                            PluginNavigation {
154                                on_click: Some(props.on_close)
155                            }
156                        }
157                    }
158                }
159            }
160        }
161    }
162}
163
164/// Individual navigation item component
165#[component]
166fn NavigationItem(
167    item: NavItem,
168    collapsed: bool,
169    current_route: Route,
170    #[props(default = None)] on_click: Option<Callback<Event<MouseData>>>,
171) -> Element {
172    let is_active = item
173        .route
174        .as_ref()
175        .map(|route| nav::is_active_route(&current_route, route))
176        .unwrap_or(false);
177
178    // If item has children, render as expandable group
179    if !item.children.is_empty() {
180        let mut expanded = use_signal(|| false);
181
182        rsx! {
183            div {
184                // Group header
185                button {
186                    r#type: "button",
187                    class: format!(
188                        "group w-full flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900 {}",
189                        if collapsed { "justify-center" } else { "justify-between" }
190                    ),
191                    onclick: move |_| {
192                        if !collapsed {
193                            expanded.set(!expanded());
194                        }
195                    },
196
197                    div {
198                        class: "flex items-center",
199                        span {
200                            class: "text-lg mr-3",
201                            "{item.icon}"
202                        }
203                        if !collapsed {
204                            span { "{item.label}" }
205                        }
206                    }
207
208                    if !collapsed && !item.children.is_empty() {
209                        // Expand/collapse icon
210                        svg {
211                            class: format!(
212                                "h-4 w-4 transition-transform {}",
213                                if expanded() { "transform rotate-90" } else { "" }
214                            ),
215                            xmlns: "http://www.w3.org/2000/svg",
216                            view_box: "0 0 20 20",
217                            fill: "currentColor",
218                            path {
219                                fill_rule: "evenodd",
220                                d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z",
221                                clip_rule: "evenodd"
222                            }
223                        }
224                    }
225                }
226
227                // Children (when not collapsed and expanded)
228                if !collapsed && expanded() {
229                    div {
230                        class: "ml-6 space-y-1",
231                        for child in &item.children {
232                            NavigationItem {
233                                key: "{child.id}",
234                                item: child.clone(),
235                                collapsed: false,
236                                current_route: current_route.clone(),
237                                on_click: on_click
238                            }
239                        }
240                    }
241                }
242            }
243        }
244    } else {
245        // Regular navigation item
246        rsx! {
247            if let Some(route) = &item.route {
248                Link {
249                    to: route.clone(),
250                    class: format!(
251                        "group flex items-center px-2 py-2 text-sm font-medium rounded-md {} {}",
252                        if is_active {
253                            "bg-blue-50 border-r-4 border-blue-600 text-blue-700"
254                        } else {
255                            "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
256                        },
257                        if collapsed { "justify-center" } else { "" }
258                    ),
259                    onclick: move |e| {
260                        if let Some(callback) = &on_click {
261                            callback.call(e);
262                        }
263                    },
264                    span {
265                        class: format!(
266                            "text-lg {}",
267                            if collapsed { "" } else { "mr-3" }
268                        ),
269                        "{item.icon}"
270                    }
271                    if !collapsed {
272                        span {
273                            class: "flex-1",
274                            "{item.label}"
275                        }
276                        if let Some(badge) = &item.badge {
277                            span {
278                                class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800",
279                                "{badge}"
280                            }
281                        }
282                    }
283                }
284            } else if let Some(url) = &item.external_url {
285                a {
286                    href: "{url}",
287                    target: "_blank",
288                    rel: "noopener noreferrer",
289                    class: format!(
290                        "group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900 {}",
291                        if collapsed { "justify-center" } else { "" }
292                    ),
293                    span {
294                        class: format!(
295                            "text-lg {}",
296                            if collapsed { "" } else { "mr-3" }
297                        ),
298                        "{item.icon}"
299                    }
300                    if !collapsed {
301                        span {
302                            class: "flex-1",
303                            "{item.label}"
304                        }
305                        // External link icon
306                        svg {
307                            class: "h-4 w-4 text-gray-400",
308                            xmlns: "http://www.w3.org/2000/svg",
309                            fill: "none",
310                            view_box: "0 0 24 24",
311                            stroke: "currentColor",
312                            path {
313                                stroke_linecap: "round",
314                                stroke_linejoin: "round",
315                                stroke_width: "2",
316                                d: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
317                            }
318                        }
319                    }
320                }
321            }
322        }
323    }
324}
325
326/// Plugin navigation section
327#[component]
328fn PluginNavigation(
329    #[props(default = None)] on_click: Option<Callback<Event<MouseData>>>,
330) -> Element {
331    // Mock plugin data - in real app this would come from plugin manager
332    let plugins = vec![
333        ("inventory", "📦", "Inventory"),
334        ("sales", "💰", "Sales"),
335        ("reports", "📊", "Reports"),
336        ("users", "👥", "User Management"),
337    ];
338
339    rsx! {
340        div {
341            class: "space-y-1",
342            for (plugin_id, icon, label) in plugins {
343                Link {
344                    key: "{plugin_id}",
345                    to: Route::Plugin { plugin_id: plugin_id.to_string() },
346                    class: "group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900",
347                    onclick: move |e| {
348                        if let Some(callback) = &on_click {
349                            callback.call(e);
350                        }
351                    },
352                    span {
353                        class: "text-lg mr-3",
354                        "{icon}"
355                    }
356                    "{label}"
357                }
358            }
359        }
360    }
361}
362
363/// Get the navigation items configuration
364fn get_navigation_items() -> Vec<NavItem> {
365    vec![
366        NavItem {
367            id: "dashboard".to_string(),
368            label: "Dashboard".to_string(),
369            icon: "📊".to_string(),
370            route: Some(Route::Dashboard {}),
371            children: vec![],
372            required_permission: None,
373            badge: None,
374            external_url: None,
375        },
376        NavItem {
377            id: "profile".to_string(),
378            label: "Profile".to_string(),
379            icon: "👤".to_string(),
380            route: Some(Route::Profile {}),
381            children: vec![],
382            required_permission: None,
383            badge: None,
384            external_url: None,
385        },
386        NavItem {
387            id: "plugins".to_string(),
388            label: "Plugins".to_string(),
389            icon: "🧩".to_string(),
390            route: Some(Route::Plugins {}),
391            children: vec![],
392            required_permission: Some(("plugins".to_string(), "read".to_string())),
393            badge: None,
394            external_url: None,
395        },
396        NavItem {
397            id: "settings".to_string(),
398            label: "Settings".to_string(),
399            icon: "⚙️".to_string(),
400            route: Some(Route::Settings {}),
401            children: vec![],
402            required_permission: Some(("settings".to_string(), "read".to_string())),
403            badge: None,
404            external_url: None,
405        },
406        NavItem {
407            id: "admin".to_string(),
408            label: "Administration".to_string(),
409            icon: "👑".to_string(),
410            route: Some(Route::Admin {}),
411            children: vec![],
412            required_permission: Some(("admin".to_string(), "read".to_string())),
413            badge: Some("Admin".to_string()),
414            external_url: None,
415        },
416        NavItem {
417            id: "help".to_string(),
418            label: "Help & Support".to_string(),
419            icon: "❓".to_string(),
420            route: None,
421            children: vec![
422                NavItem {
423                    id: "documentation".to_string(),
424                    label: "Documentation".to_string(),
425                    icon: "📚".to_string(),
426                    route: None,
427                    children: vec![],
428                    required_permission: None,
429                    badge: None,
430                    external_url: Some("https://docs.qorzen.com".to_string()),
431                },
432                NavItem {
433                    id: "support".to_string(),
434                    label: "Contact Support".to_string(),
435                    icon: "💬".to_string(),
436                    route: None,
437                    children: vec![],
438                    required_permission: None,
439                    badge: None,
440                    external_url: Some("mailto:support@qorzen.com".to_string()),
441                },
442            ],
443            required_permission: None,
444            badge: None,
445            external_url: None,
446        },
447    ]
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_navigation_items() {
456        let items = get_navigation_items();
457        assert!(!items.is_empty());
458
459        // Check that dashboard item exists
460        let dashboard = items.iter().find(|item| item.id == "dashboard");
461        assert!(dashboard.is_some());
462        assert_eq!(dashboard.unwrap().label, "Dashboard");
463    }
464
465    #[test]
466    fn test_nav_item_with_children() {
467        let items = get_navigation_items();
468        let help_item = items.iter().find(|item| item.id == "help");
469        assert!(help_item.is_some());
470        assert!(!help_item.unwrap().children.is_empty());
471    }
472
473    #[test]
474    fn test_sidebar_component_creation() {
475        let on_close = Callback::new(|_| {});
476
477        let _sidebar = rsx! {
478            Sidebar {
479                collapsed: false,
480                mobile_open: false,
481                on_close: on_close
482            }
483        };
484    }
485}