Skip to main content

wavefunk_ui/
layouts.rs

1use crate::{
2    assets,
3    components::{Avatar, HtmlAttr, MenuItem, MenuItemKind},
4    html,
5};
6use askama::Template;
7
8pub const DEFAULT_PROFILE_LOGOUT_CONFIRM: &str = "Log out of this session?";
9
10pub const DEFAULT_PROFILE_LOGOUT_ATTRS: [HtmlAttr<'static>; 2] = [
11    HtmlAttr::hx_post("/logout"),
12    HtmlAttr::hx_confirm(DEFAULT_PROFILE_LOGOUT_CONFIRM),
13];
14
15pub const DEFAULT_PROFILE_MENU_ITEMS: [MenuItem<'static>; 3] = [
16    MenuItem::link("Settings", "/settings"),
17    MenuItem::separator(),
18    MenuItem::button("Logout")
19        .danger()
20        .with_attrs(&DEFAULT_PROFILE_LOGOUT_ATTRS),
21];
22
23#[derive(Debug, Template)]
24#[non_exhaustive]
25#[template(path = "layouts/sidebar_profile.html")]
26pub struct SidebarProfile<'a> {
27    pub name: Option<&'a str>,
28    pub email: Option<&'a str>,
29    pub avatar: Option<Avatar<'a>>,
30    pub menu_items: &'a [MenuItem<'a>],
31}
32
33impl<'a> SidebarProfile<'a> {
34    pub const fn new() -> Self {
35        Self {
36            name: None,
37            email: None,
38            avatar: None,
39            menu_items: &DEFAULT_PROFILE_MENU_ITEMS,
40        }
41    }
42
43    pub const fn with_name(mut self, name: &'a str) -> Self {
44        self.name = Some(name);
45        self
46    }
47
48    pub const fn with_email(mut self, email: &'a str) -> Self {
49        self.email = Some(email);
50        self
51    }
52
53    pub const fn with_avatar(mut self, avatar: Avatar<'a>) -> Self {
54        self.avatar = Some(avatar);
55        self
56    }
57
58    pub const fn with_menu_items(mut self, menu_items: &'a [MenuItem<'a>]) -> Self {
59        self.menu_items = menu_items;
60        self
61    }
62
63    pub fn display_label(&self) -> &str {
64        self.name.or(self.email).unwrap_or("Profile")
65    }
66
67    pub const fn secondary_email(&self) -> Option<&'a str> {
68        if self.name.is_some() {
69            self.email
70        } else {
71            None
72        }
73    }
74}
75
76impl<'a> Default for SidebarProfile<'a> {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl<'a> askama::filters::HtmlSafe for SidebarProfile<'a> {}
83
84#[derive(Debug, Template)]
85#[template(path = "layouts/app_shell.html")]
86pub struct AppShell<'a> {
87    pub title: &'a str,
88    pub app_name: &'a str,
89    pub mode: &'a str,
90    pub density_class: &'a str,
91    pub asset_base_path: &'a str,
92    pub nav_html: &'a str,
93    pub actions_html: &'a str,
94    pub content_html: &'a str,
95    pub profile: Option<SidebarProfile<'a>>,
96    pub status_left: &'a str,
97    pub status_right: &'a str,
98}
99
100impl<'a> AppShell<'a> {
101    pub const fn new(title: &'a str, app_name: &'a str, content_html: &'a str) -> Self {
102        Self {
103            title,
104            app_name,
105            mode: "dark",
106            density_class: "density-dense",
107            asset_base_path: assets::DEFAULT_BASE_PATH,
108            nav_html: "",
109            actions_html: "",
110            content_html,
111            profile: None,
112            status_left: app_name,
113            status_right: "",
114        }
115    }
116
117    pub const fn with_mode(mut self, mode: &'a str) -> Self {
118        self.mode = mode;
119        self
120    }
121
122    pub const fn light(self) -> Self {
123        self.with_mode("light")
124    }
125
126    pub const fn dark(self) -> Self {
127        self.with_mode("dark")
128    }
129
130    pub const fn dense(mut self) -> Self {
131        self.density_class = "density-dense";
132        self
133    }
134
135    pub const fn default_density(mut self) -> Self {
136        self.density_class = "";
137        self
138    }
139
140    pub const fn with_asset_base_path(mut self, asset_base_path: &'a str) -> Self {
141        self.asset_base_path = asset_base_path;
142        self
143    }
144
145    pub const fn with_nav(mut self, nav_html: &'a str) -> Self {
146        self.nav_html = nav_html;
147        self
148    }
149
150    pub const fn with_actions(mut self, actions_html: &'a str) -> Self {
151        self.actions_html = actions_html;
152        self
153    }
154
155    pub const fn with_profile(mut self, profile: SidebarProfile<'a>) -> Self {
156        self.profile = Some(profile);
157        self
158    }
159
160    pub const fn with_status(mut self, status_left: &'a str, status_right: &'a str) -> Self {
161        self.status_left = status_left;
162        self.status_right = status_right;
163        self
164    }
165
166    pub fn stylesheet_link(&self) -> String {
167        html::stylesheet_link(self.asset_base_path)
168    }
169
170    pub fn htmx_script_link(&self) -> String {
171        html::htmx_script_link(self.asset_base_path)
172    }
173
174    pub fn script_link(&self) -> String {
175        html::script_link(self.asset_base_path)
176    }
177}
178
179impl<'a> askama::filters::HtmlSafe for AppShell<'a> {}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::components::{HtmlAttr, MenuItem};
185
186    #[test]
187    fn app_shell_builders_render_variants() {
188        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
189            .light()
190            .default_density()
191            .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
192            .with_actions(r#"<button class="wf-btn">Save</button>"#)
193            .with_status("Ready", "v0.1")
194            .render()
195            .unwrap();
196
197        assert!(html.contains(r#"data-mode="light""#));
198        assert!(html.contains(r#"class="wf-app ""#));
199        assert!(html.contains(r#"<a class="wf-nav-item" href="/">Home</a>"#));
200        assert!(html.contains(r#"<button class="wf-btn">Save</button>"#));
201        assert!(html.contains(">Ready<"));
202        assert!(html.contains(">v0.1<"));
203    }
204
205    #[test]
206    fn app_shell_omits_sidebar_profile_until_supplied() {
207        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
208            .render()
209            .unwrap();
210
211        assert!(!html.contains("wf-sidebar-profile"));
212        assert!(!html.contains("hx-post=\"/logout\""));
213        assert!(!html.contains("Log out of this session?"));
214    }
215
216    #[test]
217    fn app_shell_renders_default_sidebar_profile_menu_after_nav() {
218        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
219            .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
220            .with_profile(
221                SidebarProfile::new()
222                    .with_name("Sandeep Nambiar")
223                    .with_email("sandeep@wavefunk.test"),
224            )
225            .render()
226            .unwrap();
227
228        let nav_position = html.find(r#"id="app-nav""#).unwrap();
229        let profile_position = html.find("wf-sidebar-profile").unwrap();
230
231        assert!(profile_position > nav_position);
232        assert!(html.contains(">Sandeep Nambiar<"));
233        assert!(html.contains(">sandeep@wavefunk.test<"));
234        assert!(html.contains(r#"href="/settings""#));
235        assert!(html.contains(">Settings<"));
236        assert!(html.contains(r#"hx-post="/logout""#));
237        assert!(html.contains(r#"hx-confirm="Log out of this session?""#));
238        assert!(html.contains(">Logout<"));
239    }
240
241    #[test]
242    fn sidebar_profile_label_falls_back_and_menu_items_can_be_overridden() {
243        let logout_attrs = [
244            HtmlAttr::hx_post("/sessions/current/delete"),
245            HtmlAttr::hx_confirm("Really log out?"),
246        ];
247        let custom_menu = [
248            MenuItem::link("Account", "/account"),
249            MenuItem::button("Sign out")
250                .danger()
251                .with_attrs(&logout_attrs),
252        ];
253
254        let email_only = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
255            .with_profile(
256                SidebarProfile::new()
257                    .with_email("team@example.test")
258                    .with_menu_items(&custom_menu),
259            )
260            .render()
261            .unwrap();
262
263        assert_eq!(email_only.matches("team@example.test").count(), 1);
264        assert!(email_only.contains(">Account<"));
265        assert!(email_only.contains(r#"href="/account""#));
266        assert!(email_only.contains(">Sign out<"));
267        assert!(email_only.contains(r#"hx-post="/sessions/current/delete""#));
268        assert!(email_only.contains(r#"hx-confirm="Really log out?""#));
269        assert!(!email_only.contains(r#"href="/settings""#));
270        assert!(!email_only.contains(r#"hx-post="/logout""#));
271
272        let anonymous = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
273            .with_profile(SidebarProfile::new())
274            .render()
275            .unwrap();
276
277        assert!(anonymous.contains(">Profile<"));
278    }
279}