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}