tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
//! The top bar's trail: the sidebar entry the request is under, then where in it.

use tablo_ui::{
    breadcrumb, breadcrumb_item, breadcrumb_link, breadcrumb_list, breadcrumb_page,
    breadcrumb_separator,
};
use topcoat::{
    context::Cx,
    view::{BoxView, ViewExt, view},
};

use crate::{
    Text,
    navigation::{NavigationItem, current_index},
    panel::state::ResourceTrail,
};

/// One step of a trail: its label, and its link unless it is the page the request is on.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Crumb {
    pub(super) label: Text,
    pub(super) href: Option<String>,
}

impl Crumb {
    fn link(label: impl Into<Text>, href: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            href: Some(href.into()),
        }
    }

    fn here(label: impl Into<Text>) -> Self {
        Self {
            label: label.into(),
            href: None,
        }
    }
}

/// The trail to `current_path` under `current_query`: the sidebar entry it is under, as the
/// sidebar marks it, then the page within the entry. Empty when no entry holds the path.
///
/// Under a resource's list, one of `resources`, a path names a resource's page:
/// `/admin/posts` is `Blog Posts`; `/admin/posts/-/create` adds `Create`, `/admin/posts/7`
/// adds `View`, and `/admin/posts/7/edit` adds `Edit`. A deeper path under a record links the
/// record's `View` when the resource has one, then names the page by its label, else by its last
/// segment. Under any other entry, the home page's included, a deeper path names its last
/// segment: `/admin/reports` is `Dashboard › Reports`.
pub(super) fn trail(
    nav_items: &[NavigationItem],
    resources: &[&ResourceTrail],
    current_path: &str,
    current_query: &str,
) -> Vec<Crumb> {
    let Some((item, path)) = entry(nav_items, current_path, current_query) else {
        return Vec::new();
    };
    let rest: Vec<&str> = current_path[path.len()..]
        .split('/')
        .filter(|segment| !segment.is_empty())
        .collect();
    let entry = || Crumb::link(item.label.clone(), path);
    let Some(resource) = resources.iter().find(|resource| resource.list == path) else {
        return match rest.last() {
            None => vec![Crumb::here(item.label.clone())],
            Some(last) => vec![entry(), Crumb::here(humanize(last))],
        };
    };
    match rest.as_slice() {
        [] => vec![Crumb::here(item.label.clone())],
        ["-", "create"] => vec![entry(), Crumb::here(Text::message("tablo-crumb-create"))],
        ["-", action] => vec![entry(), Crumb::here(humanize(action))],
        [_record] => vec![entry(), Crumb::here(Text::message("tablo-crumb-view"))],
        [_record, "edit"] => vec![entry(), Crumb::here(Text::message("tablo-crumb-edit"))],
        [record, .., last] => {
            let label = resource
                .pages
                .iter()
                .find(|(slug, _)| slug == last)
                .map_or_else(|| Text::from(humanize(last)), |(_, label)| label.clone());
            let view = resource.viewed.then(|| {
                Crumb::link(
                    Text::message("tablo-crumb-view"),
                    format!("{path}/{record}"),
                )
            });
            std::iter::once(entry())
                .chain(view)
                .chain([Crumb::here(label)])
                .collect()
        }
    }
}

/// `trail` under the area labelled `label` at `url`: the area first, linked, unless the trail is
/// empty.
pub(super) fn within(label: Text, url: &str, trail: Vec<Crumb>) -> Vec<Crumb> {
    if trail.is_empty() {
        return trail;
    }
    std::iter::once(Crumb::link(label, url))
        .chain(trail)
        .collect()
}

/// The trail to a page that failed at `current_path` under `current_query`: the sidebar entry it
/// is under, then `label`, the failure.
pub(super) fn failed(
    nav_items: &[NavigationItem],
    current_path: &str,
    current_query: &str,
    label: impl Into<Text>,
) -> Vec<Crumb> {
    let mut crumbs: Vec<Crumb> = entry(nav_items, current_path, current_query)
        .map(|(item, path)| Crumb::link(item.label.clone(), path))
        .into_iter()
        .collect();
    crumbs.push(Crumb::here(label));
    crumbs
}

/// The sidebar entry the request is under, as the sidebar marks it, with its path.
fn entry<'a>(
    nav_items: &'a [NavigationItem],
    current_path: &str,
    current_query: &str,
) -> Option<(&'a NavigationItem, &'a str)> {
    let item = &nav_items[current_index(nav_items, current_path, current_query)?];
    Some((item, item.path()?))
}

/// A path segment as a label: `audit-log` reads `Audit log`, and `Q1%20sales` reads `Q1 sales`.
fn humanize(segment: &str) -> String {
    let decoded = percent_encoding::percent_decode_str(segment).decode_utf8_lossy();
    let words = decoded.replace(['-', '_'], " ");
    let mut chars = words.chars();
    chars
        .next()
        .map(|first| first.to_uppercase().chain(chars).collect())
        .unwrap_or_default()
}

/// Render `crumbs`, or nothing for an empty trail. Below `sm` the top bar has no room for it.
pub(super) fn render(cx: &Cx, crumbs: Vec<Crumb>) -> BoxView<'_> {
    if crumbs.is_empty() {
        return ().boxed();
    }
    let last = crumbs.len() - 1;
    view! {
        breadcrumb(
            attrs: topcoat::view::attributes! { class="max-sm:hidden" },
            breadcrumb_list(
                for (index, crumb) in crumbs.into_iter().enumerate() {
                    breadcrumb_item(
                        if let Some(href) = crumb.href {
                            breadcrumb_link(
                                attrs: crate::navigation::runtime_link(cx, &href),
                                (crumb.label.localize(cx))
                            )
                        } else {
                            breadcrumb_page((crumb.label.localize(cx)))
                        }
                    )
                    if index < last {
                        breadcrumb_separator()
                    }
                }
            )
        )
    }
    .boxed()
}

#[cfg(test)]
mod tests;