use crate::prelude::*;
use crate::types::ArticleMeta;
use beet_core::prelude::*;
use heck::ToTitleCase;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SidebarInfo {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub order: Option<u32>,
}
#[template]
pub fn Sidebar(nodes: Vec<SidebarNode>) -> impl Bundle {
rsx! {
<nav id="sidebar" class="bt-u-print-hidden" aria-hidden="false">
{nodes.into_iter().map(|node|
rsx!{<SidebarItem root node=node/>}).collect::<Vec<_>>()
}
</nav>
<script hoist:body src="./sidebar.js"/>
<style>
nav{
--sidebar-width:15rem;
--sidebar-indent: 0.5rem;
background-color:var(--bt-color-surface-container-low);
padding: 0.5.em 0.5.em 0 0;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
}
</style>
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Serialize, Deserialize))]
pub struct SidebarNode {
pub display_name: String,
pub path: Option<RoutePath>,
pub children: Vec<SidebarNode>,
pub expanded: bool,
}
impl SidebarNode {
pub fn paths(&self) -> Vec<RoutePath> {
let mut paths = Vec::new();
if let Some(path) = &self.path {
paths.push(path.clone());
}
for child in &self.children {
paths.extend(child.paths());
}
paths
}
}
impl std::fmt::Display for SidebarNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path_str = match &self.path {
Some(p) => p.to_string(),
None => "None".to_string(),
};
writeln!(
f,
"SidebarNode: {} ({}){}",
self.display_name,
path_str,
if self.expanded { " [expanded]" } else { "" }
)?;
for child in &self.children {
let child_str = format!("{}", child)
.lines()
.map(|line| format!(" {}", line))
.collect::<Vec<_>>()
.join("\n");
writeln!(f, "{}", child_str)?;
}
Ok(())
}
}
pub struct CollectSidebarNode {
pub include_filter: GlobFilter,
pub expanded_filter: GlobFilter,
}
impl CollectSidebarNode {
pub fn new(
include_filter: GlobFilter,
expanded_filter: GlobFilter,
) -> Self {
Self {
include_filter,
expanded_filter,
}
}
pub fn collect(
In((this, endpoint_tree)): In<(Self, EndpointTree)>,
articles: Query<&ArticleMeta>,
) -> SidebarNode {
this.map_node(&endpoint_tree, &articles)
}
pub fn map_node(
&self,
node: &EndpointTree,
articles: &Query<&ArticleMeta>,
) -> SidebarNode {
let meta = node.endpoint.and_then(|e| articles.get(e).ok());
let has_endpoint = node.endpoint.is_some();
let route_path = node.pattern.annotated_route_path();
let children = node
.children
.iter()
.filter(|child| {
self.include_filter
.passes(&child.pattern.annotated_route_path().0)
})
.map(|child| self.map_node(child, articles))
.collect();
fn pretty_route_name(route: &RoutePath) -> String {
let str = route
.file_name()
.map(|name| name.to_str())
.flatten()
.unwrap_or("");
if str.is_empty() {
"Root".to_string()
} else {
str.to_title_case()
}
}
let expanded = self.expanded_filter.passes(&route_path.0);
let path = if has_endpoint {
Some(route_path.clone())
} else {
None
};
let display_name = meta
.and_then(|m| m.sidebar_label().map(|label| label.to_string()))
.unwrap_or_else(|| pretty_route_name(&route_path));
SidebarNode {
display_name,
path,
children,
expanded,
}
}
}
impl SidebarNode {}
#[cfg(test)]
mod test {
use crate::prelude::*;
use beet_core::prelude::*;
use beet_flow::prelude::*;
use beet_net::prelude::*;
use beet_router::prelude::*;
#[beet_core::test]
async fn collect_sidebar_node() {
#[template]
fn TestSidebar(
entity: Entity,
#[field(param)] bundle_query: HtmlBundleQuery,
#[field(param)] mut route_query: RouteQuery,
#[field(param)] articles: Query<&ArticleMeta>,
) -> Result<TextNode> {
let actions = bundle_query.actions_from_agent_descendant(entity)?;
let endpoint_tree = route_query.endpoint_tree(actions[0])?;
endpoint_tree.to_string().xpect_eq("/docs\n");
let sidebar_node = CollectSidebarNode {
include_filter: GlobFilter::default(),
expanded_filter: GlobFilter::default().with_include("/docs/"),
}
.map_node(&endpoint_tree, &articles);
sidebar_node.display_name.xpect_eq("Root");
sidebar_node.children.len().xpect_eq(1);
sidebar_node.children[0].display_name.xpect_eq("Docs");
TextNode::new("Success").xok()
}
RouterPlugin::world()
.spawn(ExchangeSpawner::new_flow(|| {
(Sequence, children![
EndpointBuilder::get()
.with_path("docs")
.with_handler(|| (BeetRoot, rsx! {<TestSidebar/>})),
html_bundle_to_response(),
])
}))
.oneshot_str(Request::get("/docs"))
.await
.xpect_eq("Success");
}
#[beet_core::test]
async fn works() {
#[template]
fn TestSidebarRender() -> impl Bundle {
let nodes = vec![SidebarNode {
display_name: "Home".to_string(),
path: None,
children: vec![SidebarNode {
display_name: "Docs".to_string(),
path: Some(RoutePath::new("/docs")),
children: vec![
SidebarNode {
display_name: "Testing".to_string(),
path: Some(RoutePath::new("/docs/testing")),
children: vec![],
expanded: false,
},
SidebarNode {
display_name: "Partying".to_string(),
path: Some(RoutePath::new("/docs/partying")),
children: vec![],
expanded: false,
},
],
expanded: false,
}],
expanded: true,
}];
rsx! { <Sidebar nodes=nodes /> }
}
RouterPlugin::world()
.spawn(ExchangeSpawner::new_flow(|| {
(Sequence, children![
EndpointBuilder::get().with_handler(|| (
BeetRoot,
rsx! { <TestSidebarRender /> }
)),
html_bundle_to_response(),
])
}))
.oneshot_str(Request::get("/"))
.await
.xpect_contains("Partying");
}
}