Skip to main content

wavefunk_ui/
layouts.rs

1use crate::{
2    assets,
3    components::{Avatar, HtmlAttr, MenuItem, MenuItemKind, TrustedHtml},
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 html_attrs: &'a [HtmlAttr<'a>],
91    pub mode_locked: bool,
92    pub density_class: &'a str,
93    pub asset_base_path: &'a str,
94    pub brand_href: Option<&'a str>,
95    pub head_html: Option<TrustedHtml<'a>>,
96    pub nav_html: &'a str,
97    pub nav_aria_label: &'a str,
98    pub breadcrumbs_html: Option<TrustedHtml<'a>>,
99    pub topbar_html: Option<TrustedHtml<'a>>,
100    pub page_header_html: Option<TrustedHtml<'a>>,
101    pub actions_html: &'a str,
102    pub content_html: &'a str,
103    pub main_class: &'a str,
104    pub profile: Option<SidebarProfile<'a>>,
105    pub status_left: &'a str,
106    pub status_right: &'a str,
107    pub footer_html: Option<TrustedHtml<'a>>,
108    pub include_htmx_sse: bool,
109    pub scripts_html: Option<TrustedHtml<'a>>,
110    pub body_hx_boost: bool,
111}
112
113impl<'a> AppShell<'a> {
114    pub const fn new(title: &'a str, app_name: &'a str, content_html: &'a str) -> Self {
115        Self {
116            title,
117            app_name,
118            mode: "dark",
119            html_attrs: &[],
120            mode_locked: false,
121            density_class: "density-dense",
122            asset_base_path: assets::DEFAULT_BASE_PATH,
123            brand_href: None,
124            head_html: None,
125            nav_html: "",
126            nav_aria_label: "primary navigation",
127            breadcrumbs_html: None,
128            topbar_html: None,
129            page_header_html: None,
130            actions_html: "",
131            content_html,
132            main_class: "",
133            profile: None,
134            status_left: app_name,
135            status_right: "",
136            footer_html: None,
137            include_htmx_sse: false,
138            scripts_html: None,
139            body_hx_boost: true,
140        }
141    }
142
143    pub const fn with_mode(mut self, mode: &'a str) -> Self {
144        self.mode = mode;
145        self
146    }
147
148    pub const fn with_html_attrs(mut self, html_attrs: &'a [HtmlAttr<'a>]) -> Self {
149        self.html_attrs = html_attrs;
150        self
151    }
152
153    pub const fn mode_locked(mut self) -> Self {
154        self.mode_locked = true;
155        self
156    }
157
158    pub const fn light(self) -> Self {
159        self.with_mode("light")
160    }
161
162    pub const fn dark(self) -> Self {
163        self.with_mode("dark")
164    }
165
166    pub const fn dense(mut self) -> Self {
167        self.density_class = "density-dense";
168        self
169    }
170
171    pub const fn default_density(mut self) -> Self {
172        self.density_class = "";
173        self
174    }
175
176    pub const fn with_asset_base_path(mut self, asset_base_path: &'a str) -> Self {
177        self.asset_base_path = asset_base_path;
178        self
179    }
180
181    pub const fn with_brand_href(mut self, brand_href: &'a str) -> Self {
182        self.brand_href = Some(brand_href);
183        self
184    }
185
186    pub const fn with_head(mut self, head_html: TrustedHtml<'a>) -> Self {
187        self.head_html = Some(head_html);
188        self
189    }
190
191    pub const fn with_nav(mut self, nav_html: &'a str) -> Self {
192        self.nav_html = nav_html;
193        self
194    }
195
196    pub const fn with_nav_aria_label(mut self, nav_aria_label: &'a str) -> Self {
197        self.nav_aria_label = nav_aria_label;
198        self
199    }
200
201    pub const fn with_breadcrumbs(mut self, breadcrumbs_html: TrustedHtml<'a>) -> Self {
202        self.breadcrumbs_html = Some(breadcrumbs_html);
203        self
204    }
205
206    pub const fn with_topbar(mut self, topbar_html: TrustedHtml<'a>) -> Self {
207        self.topbar_html = Some(topbar_html);
208        self
209    }
210
211    pub const fn with_page_header(mut self, page_header_html: TrustedHtml<'a>) -> Self {
212        self.page_header_html = Some(page_header_html);
213        if self.main_class.is_empty() {
214            self.main_class = "has-header";
215        }
216        self
217    }
218
219    pub const fn with_actions(mut self, actions_html: &'a str) -> Self {
220        self.actions_html = actions_html;
221        self
222    }
223
224    pub const fn with_main_class(mut self, main_class: &'a str) -> Self {
225        self.main_class = main_class;
226        self
227    }
228
229    pub const fn with_profile(mut self, profile: SidebarProfile<'a>) -> Self {
230        self.profile = Some(profile);
231        self
232    }
233
234    pub const fn with_status(mut self, status_left: &'a str, status_right: &'a str) -> Self {
235        self.status_left = status_left;
236        self.status_right = status_right;
237        self
238    }
239
240    pub const fn with_footer(mut self, footer_html: TrustedHtml<'a>) -> Self {
241        self.footer_html = Some(footer_html);
242        self
243    }
244
245    pub const fn with_htmx_sse(mut self) -> Self {
246        self.include_htmx_sse = true;
247        self
248    }
249
250    pub const fn with_scripts(mut self, scripts_html: TrustedHtml<'a>) -> Self {
251        self.scripts_html = Some(scripts_html);
252        self
253    }
254
255    pub const fn with_body_hx_boost(mut self, body_hx_boost: bool) -> Self {
256        self.body_hx_boost = body_hx_boost;
257        self
258    }
259
260    pub const fn without_body_hx_boost(self) -> Self {
261        self.with_body_hx_boost(false)
262    }
263
264    pub fn stylesheet_link(&self) -> String {
265        html::stylesheet_link(self.asset_base_path)
266    }
267
268    pub fn htmx_script_link(&self) -> String {
269        html::htmx_script_link(self.asset_base_path)
270    }
271
272    pub fn htmx_sse_script_link(&self) -> String {
273        html::htmx_sse_script_link(self.asset_base_path)
274    }
275
276    pub fn script_link(&self) -> String {
277        html::script_link(self.asset_base_path)
278    }
279
280    pub fn main_class_name(&self) -> String {
281        if self.main_class.is_empty() {
282            "wf-main".to_owned()
283        } else {
284            format!("wf-main {}", self.main_class)
285        }
286    }
287}
288
289impl<'a> askama::filters::HtmlSafe for AppShell<'a> {}
290
291#[derive(Debug, Template)]
292#[template(path = "layouts/htmx_partial.html")]
293pub struct HtmxPartial<'a> {
294    pub title: &'a str,
295    pub content_html: TrustedHtml<'a>,
296    pub content_id: Option<&'a str>,
297    pub nav_html: Option<TrustedHtml<'a>>,
298    pub nav_target_id: &'a str,
299}
300
301impl<'a> HtmxPartial<'a> {
302    pub const fn new(title: &'a str, content_html: TrustedHtml<'a>) -> Self {
303        Self {
304            title,
305            content_html,
306            content_id: None,
307            nav_html: None,
308            nav_target_id: "app-nav",
309        }
310    }
311
312    pub const fn with_content_id(mut self, content_id: &'a str) -> Self {
313        self.content_id = Some(content_id);
314        self
315    }
316
317    pub const fn with_nav(mut self, nav_html: TrustedHtml<'a>) -> Self {
318        self.nav_html = Some(nav_html);
319        self
320    }
321
322    pub const fn with_nav_target_id(mut self, nav_target_id: &'a str) -> Self {
323        self.nav_target_id = nav_target_id;
324        self
325    }
326}
327
328impl<'a> askama::filters::HtmlSafe for HtmxPartial<'a> {}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::components::{HtmlAttr, MenuItem, TrustedHtml};
334
335    #[test]
336    fn app_shell_builders_render_variants() {
337        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
338            .light()
339            .default_density()
340            .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
341            .with_actions(r#"<button class="wf-btn">Save</button>"#)
342            .with_status("Ready", "v0.1")
343            .render()
344            .unwrap();
345
346        assert!(html.contains(r#"data-mode="light""#));
347        assert!(html.contains(r#"class="wf-app ""#));
348        assert!(html.contains(r#"<a class="wf-nav-item" href="/">Home</a>"#));
349        assert!(html.contains(r#"<button class="wf-btn">Save</button>"#));
350        assert!(html.contains(">Ready<"));
351        assert!(html.contains(">v0.1<"));
352        assert!(html.contains(r#"aria-label="primary navigation""#));
353        assert!(html.contains(r#"hx-boost="true""#));
354    }
355
356    #[test]
357    fn app_shell_can_label_nav_and_disable_body_hx_boost() {
358        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
359            .with_nav_aria_label("workspace navigation")
360            .without_body_hx_boost()
361            .render()
362            .unwrap();
363
364        assert!(html.contains(r#"aria-label="workspace navigation""#));
365        assert!(html.contains(r#"<body class="wf-app density-dense">"#));
366        assert!(!html.contains(r#"hx-boost="true""#));
367    }
368
369    #[test]
370    fn app_shell_renders_extension_points_for_consumer_chrome() {
371        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
372            .with_head(TrustedHtml::new(
373                r##"<link rel="icon" href="/favicon.svg"><meta name="theme-color" content="#f59e0b">"##,
374            ))
375            .with_breadcrumbs(TrustedHtml::new(
376                r#"<a href="/hooks">Hooks</a><span aria-current="page">Build</span>"#,
377            ))
378            .with_topbar(TrustedHtml::new(
379                r#"<header class="wf-topbar custom"><span>Custom topbar</span></header>"#,
380            ))
381            .with_htmx_sse()
382            .with_scripts(TrustedHtml::new(
383                r#"<script src="/static/app.js" defer></script>"#,
384            ))
385            .render()
386            .unwrap();
387
388        assert!(html.contains(r#"<link rel="icon" href="/favicon.svg">"#));
389        assert!(html.contains(r##"<meta name="theme-color" content="#f59e0b">"##));
390        assert!(html.contains(r#"<header class="wf-topbar custom">"#));
391        assert!(html.contains(">Custom topbar<"));
392        assert!(html.contains(r#"<script src="/static/app.js" defer></script>"#));
393        assert!(html.contains(r#"/static/wavefunk/js/htmx-sse.js"#));
394        assert!(!html.contains(r#"<span aria-current="page">Wave Funk</span>"#));
395    }
396
397    #[test]
398    fn app_shell_can_override_default_breadcrumbs_without_replacing_topbar() {
399        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
400            .with_breadcrumbs(TrustedHtml::new(
401                r#"<a href="/projects">Projects</a><span aria-current="page">Deploy</span>"#,
402            ))
403            .with_actions(r#"<button class="wf-btn">Run</button>"#)
404            .render()
405            .unwrap();
406
407        assert!(html.contains(r#"<a href="/projects">Projects</a>"#));
408        assert!(html.contains(r#"<span aria-current="page">Deploy</span>"#));
409        assert!(html.contains(r#"<button class="wf-btn">Run</button>"#));
410        assert!(!html.contains(r#"<span aria-current="page">Wave Funk</span>"#));
411    }
412
413    #[test]
414    fn app_shell_supports_locked_mode_brand_link_main_modifiers_and_footer_slot() {
415        let html_attrs = [HtmlAttr::new("data-region", "eu <north>")];
416        let html = AppShell::new("Title", "Wave <Funk>", "<section>Content</section>")
417            .with_html_attrs(&html_attrs)
418            .mode_locked()
419            .with_brand_href("/home?team=<core>")
420            .with_main_class("has-header has-tablewrap")
421            .with_page_header(TrustedHtml::new(
422                r#"<section class="wf-pageheader"><h1>Deployments</h1></section>"#,
423            ))
424            .with_footer(TrustedHtml::new(
425                r#"<footer class="wf-modeline"><span class="wf-ml-seg">Ready</span></footer>"#,
426            ))
427            .render()
428            .unwrap();
429
430        assert!(html.contains(r#"data-mode-locked"#));
431        assert!(html.contains(r#"data-region="eu "#));
432        assert!(!html.contains(r#"data-region="eu <north>""#));
433        assert!(html.contains(r#"<a class="wf-brand-name" href="/home?team="#));
434        assert!(!html.contains(r#"href="/home?team=<core>""#));
435        assert!(html.contains(">Wave "));
436        assert!(!html.contains(">Wave <Funk><"));
437        assert!(html.contains(r#"class="wf-main has-header has-tablewrap""#));
438        assert!(html.contains(r#"<section class="wf-pageheader"><h1>Deployments</h1></section>"#));
439        assert!(html.contains(r#"<footer class="wf-modeline">"#));
440        assert!(!html.contains(r#"<div class="wf-statusbar wf-hair">"#));
441    }
442
443    #[test]
444    fn htmx_partial_carries_title_content_and_optional_oob_nav() {
445        let html = HtmxPartial::new(
446            "Deployments <prod>",
447            TrustedHtml::new(r#"<section class="wf-panel">Rows</section>"#),
448        )
449        .with_content_id("main-content")
450        .with_nav(TrustedHtml::new(
451            r#"<a class="wf-nav-item is-active" href="/deployments">Deployments</a>"#,
452        ))
453        .render()
454        .unwrap();
455
456        assert!(html.contains("<title>Deployments "));
457        assert!(!html.contains("<title>Deployments <prod>"));
458        assert!(html.contains(r#"<span id="page-title" hidden>Deployments "#));
459        assert!(html.contains(r#"<div id="main-content">"#));
460        assert!(html.contains(r#"<section class="wf-panel">Rows</section>"#));
461        assert!(html.contains(r#"<nav class="wf-nav-list" id="app-nav" hx-swap-oob="outerHTML">"#));
462        assert!(html.contains(r#"<a class="wf-nav-item is-active" href="/deployments">"#));
463    }
464
465    #[test]
466    fn app_shell_omits_sidebar_profile_until_supplied() {
467        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
468            .render()
469            .unwrap();
470
471        assert!(!html.contains("wf-sidebar-profile"));
472        assert!(!html.contains("hx-post=\"/logout\""));
473        assert!(!html.contains("Log out of this session?"));
474    }
475
476    #[test]
477    fn app_shell_renders_default_sidebar_profile_menu_after_nav() {
478        let html = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
479            .with_nav(r#"<a class="wf-nav-item" href="/">Home</a>"#)
480            .with_profile(
481                SidebarProfile::new()
482                    .with_name("Sandeep Nambiar")
483                    .with_email("sandeep@wavefunk.test"),
484            )
485            .render()
486            .unwrap();
487
488        let nav_position = html.find(r#"id="app-nav""#).unwrap();
489        let profile_position = html.find("wf-sidebar-profile").unwrap();
490
491        assert!(profile_position > nav_position);
492        assert!(html.contains(">Sandeep Nambiar<"));
493        assert!(html.contains(">sandeep@wavefunk.test<"));
494        assert!(html.contains(r#"href="/settings""#));
495        assert!(html.contains(">Settings<"));
496        assert!(html.contains(r#"hx-post="/logout""#));
497        assert!(html.contains(r#"hx-confirm="Log out of this session?""#));
498        assert!(html.contains(">Logout<"));
499    }
500
501    #[test]
502    fn sidebar_profile_label_falls_back_and_menu_items_can_be_overridden() {
503        let logout_attrs = [
504            HtmlAttr::hx_post("/sessions/current/delete"),
505            HtmlAttr::hx_confirm("Really log out?"),
506        ];
507        let custom_menu = [
508            MenuItem::link("Account", "/account"),
509            MenuItem::button("Sign out")
510                .danger()
511                .with_attrs(&logout_attrs),
512        ];
513
514        let email_only = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
515            .with_profile(
516                SidebarProfile::new()
517                    .with_email("team@example.test")
518                    .with_menu_items(&custom_menu),
519            )
520            .render()
521            .unwrap();
522
523        assert_eq!(email_only.matches("team@example.test").count(), 1);
524        assert!(email_only.contains(">Account<"));
525        assert!(email_only.contains(r#"href="/account""#));
526        assert!(email_only.contains(">Sign out<"));
527        assert!(email_only.contains(r#"hx-post="/sessions/current/delete""#));
528        assert!(email_only.contains(r#"hx-confirm="Really log out?""#));
529        assert!(!email_only.contains(r#"href="/settings""#));
530        assert!(!email_only.contains(r#"hx-post="/logout""#));
531
532        let anonymous = AppShell::new("Title", "Wave Funk", "<section>Content</section>")
533            .with_profile(SidebarProfile::new())
534            .render()
535            .unwrap();
536
537        assert!(anonymous.contains(">Profile<"));
538    }
539}