tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
//! The handlers behind every registered [`Page`]: its `GET`, and its header actions' `POST`; and
//! the page as the request's panel mounted it, with the header buttons it renders.

use std::{any::TypeId, sync::Arc};

use topcoat::{
    context::Cx,
    router::{
        Body, RouteFuture,
        error::{forbidden, not_found},
        path_param_segment,
    },
    view::{BoxView, ViewExt, view},
};

use super::actions::{input_options, run_header};
use crate::{
    FormInput, FormValue, FormValues, HeaderAction, Page, page::MountedPage,
    topcoat_compat::async_page,
};

/// `P` as the request's panel mounted it, if it did.
pub(crate) fn mounted_page<P: Page>(cx: &Cx) -> Option<&Arc<MountedPage>> {
    mounted(cx, TypeId::of::<P>())
}

/// The page of type `page` as the request's panel mounted it, if it did.
fn mounted(cx: &Cx, page: TypeId) -> Option<&Arc<MountedPage>> {
    super::state::current(cx)?.mounted_pages.get(&page)
}

/// The page of type `page` as the request's panel mounted it, once the panel's sign-in and the
/// page's [`PageDef::access`](crate::PageDef::access) admit the request.
pub(super) fn mounted_admitted(cx: &Cx, page: TypeId) -> Option<&Arc<MountedPage>> {
    mounted(cx, page).filter(|page| crate::auth::guard(cx).is_ok() && page.admits(cx))
}

/// The body [`Page::render`] defaults to, installed as the [`PageBody`](crate::page::PageBody):
/// the page's title and description, its header buttons, and its widget grid.
pub(super) fn declared_body(cx: &Cx, page: TypeId) -> BoxView<'_> {
    let Some(mounted) = mounted_admitted(cx, page) else {
        return ().boxed();
    };
    let title = mounted.title.localize(cx);
    let description = mounted
        .description
        .as_ref()
        .map(|description| description.localize(cx));
    let buttons = header_buttons(cx, page, mounted);
    let filters = filter_bar(cx, page, mounted);
    let grid = super::widgets::page_grid(cx, mounted);
    view! {
        tablo_ui::page(
            tablo_ui::page_header(
                tablo_ui::page_title((title))
                if let Some(description) = description {
                    tablo_ui::page_description((description))
                }
                tablo_ui::page_actions((buttons))
            )
            if let Some(filters) = filters {
                (filters)
            }
            tablo_ui::page_content((grid))
        )
    }
    .boxed()
}

/// The filter bar of `mounted`, the page of type `page`; `None` when it declares no filter.
fn filter_bar<'a>(cx: &'a Cx, page: TypeId, mounted: &MountedPage) -> Option<BoxView<'a>> {
    let url = super::state::current(cx)?.urls.get(&page)?;
    let held = crate::page::filters::read(&mounted.filters, &crate::table::request_query(cx));
    super::page_filters::bar(cx, &mounted.filters, &held, url)
}

/// The filter bar of the page `P`, for a page that renders itself: a form holding each
/// [`PageDef::filter`](crate::PageDef::filter)'s value, which submits to the page, so its widgets
/// read the new values through [`page_filters`](crate::page_filters). The default
/// [`Page::render`](crate::Page::render) places it under the header.
///
/// It renders nothing outside a request to `P`'s panel, for a request the panel's sign-in or
/// [`PageDef::access`](crate::PageDef::access) refuses, and for a page declaring no filter.
pub fn page_filter_bar<P: Page>(cx: &Cx) -> BoxView<'_> {
    let page = TypeId::of::<P>();
    mounted_admitted(cx, page)
        .and_then(|mounted| filter_bar(cx, page, mounted))
        .unwrap_or_else(|| ().boxed())
}

/// The registered page the request's path names, once the panel's sign-in and the page's access
/// admit the request.
pub(crate) fn current_page(cx: &Cx) -> Option<&Arc<MountedPage>> {
    current_entry(cx).and_then(|(page, _)| mounted_admitted(cx, page))
}

/// The registered page the request is to, and its URL.
fn current_entry(cx: &Cx) -> Option<(TypeId, &str)> {
    let panel = super::state::current(cx)?;
    let path = topcoat::router::request::uri(cx)
        .path()
        .trim_end_matches('/');
    panel
        .urls
        .iter()
        .find(|(page, url)| {
            url.trim_end_matches('/') == path && panel.mounted_pages.contains_key(*page)
        })
        .map(|(page, url)| (*page, url.as_str()))
}

/// The header bar of the registered page the request is to, holding the actions it may run; empty
/// on a request to no such page or one it may not open. A page whose body auth renders, such as
/// the profile placed in an area, shows it so.
pub(super) fn current_header_bar(cx: &Cx) -> super::bar::ActionBar {
    current_entry(cx)
        .and_then(|(page, url)| {
            let mounted = mounted_admitted(cx, page)?;
            Some(super::header_bar(
                cx,
                url,
                &mounted.header_actions,
                |_| true,
                &[],
            ))
        })
        .unwrap_or_default()
}

/// The header buttons of `mounted`, the page of type `page`, that the request may run.
fn header_buttons<'a>(cx: &'a Cx, page: TypeId, mounted: &MountedPage) -> BoxView<'a> {
    let Some(url) = super::state::current(cx).and_then(|panel| panel.urls.get(&page)) else {
        return ().boxed();
    };
    super::header_bar(cx, url, &mounted.header_actions, |_| true, &[]).render(cx)
}

/// The header buttons of the page `P` the request may run, for its
/// [`page_actions`](tablo_ui::page_actions):
///
/// ```rust
/// # use tablo_core::{HeaderAction, Page, PageDef};
/// # use topcoat::{Result, context::Cx, view::{View, view}};
/// # struct ClearCache;
/// # impl HeaderAction for ClearCache {
/// #     type Input = ();
/// #     const NAME: &'static str = "clear-cache";
/// #     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> { Ok(tablo_core::Outcome::done()) }
/// # }
/// struct MaintenancePage;
///
/// impl Page for MaintenancePage {
///     fn declare() -> PageDef {
///         PageDef::new().header_action::<ClearCache>()
///     }
///
///     async fn render(cx: &Cx) -> Result<impl View> {
///         Ok(view! {
///             tablo_ui::page(
///                 tablo_ui::page_header(
///                     tablo_ui::page_title("Maintenance")
///                     tablo_ui::page_actions((tablo_core::header_action_buttons::<Self>(cx)))
///                 )
///             )
///         })
///     }
/// }
/// ```
///
/// It renders nothing outside a request to `P`'s panel, for a request the panel's sign-in or
/// [`PageDef::access`](crate::PageDef::access) refuses, and no button for an action whose
/// [`can_run`](crate::HeaderAction::can_run) refuses the request.
pub fn header_action_buttons<P: Page>(cx: &Cx) -> BoxView<'_> {
    let page = TypeId::of::<P>();
    match mounted_admitted(cx, page) {
        Some(mounted) => header_buttons(cx, page, mounted),
        None => ().boxed(),
    }
}

/// The button of `P`'s header action `A`, posting `input` instead of asking for it, to place in the
/// page's content: a plan card's Subscribe, carrying the plan and the interval it offers.
///
/// ```rust
/// # use tablo_core::{FormInput, HeaderAction, Page, PageDef};
/// # use topcoat::{Result, context::Cx, view::{View, view}};
/// #[derive(FormInput)]
/// struct Plan {
///     name: String,
/// }
///
/// struct Subscribe;
///
/// impl HeaderAction for Subscribe {
///     type Input = Plan;
///     const NAME: &'static str = "subscribe";
///     # async fn run(_: &Cx, _: Plan, _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<PlanField>> { Ok(tablo_core::Outcome::done()) }
/// }
///
/// struct PlansPage;
///
/// impl Page for PlansPage {
///     fn declare() -> PageDef {
///         PageDef::new().header_action::<Subscribe>()
///     }
///
///     async fn render(cx: &Cx) -> Result<impl View> {
///         let plan = Plan { name: "pro".to_string() };
///         Ok(view! {
///             tablo_ui::page(
///                 tablo_ui::page_content(
///                     (tablo_core::header_action_button::<Self, Subscribe>(cx, &plan))
///                 )
///             )
///         })
///     }
/// }
/// ```
///
/// The POST runs as the header bar's would: `input` is parsed and checked again, and a refused one
/// lands on the action's input page. `A` confirms first when it sets
/// [`CONFIRM`](HeaderAction::CONFIRM). It renders nothing where
/// [`header_action_buttons`] renders no button for `A`, when `P` does not declare `A`, and when
/// `input` holds a repeater's rows, which no hidden field carries.
pub fn header_action_button<'a, P: Page, A: HeaderAction>(
    cx: &'a Cx,
    input: &A::Input,
) -> BoxView<'a> {
    let mut values = FormValues::new();
    input.write(&mut values);
    preset_button(cx, TypeId::of::<P>(), A::NAME, values)
}

/// [`header_action_button`] past its types, so it compiles once.
fn preset_button<'a>(
    cx: &'a Cx,
    page: TypeId,
    name: &'static str,
    values: FormValues,
) -> BoxView<'a> {
    let mut pairs = Vec::new();
    for (key, value) in values {
        match value {
            FormValue::Text(text) => pairs.push((key, text)),
            FormValue::List(items) => {
                pairs.extend(items.into_iter().map(|item| (key.clone(), item)))
            }
            FormValue::Rows(_) => {
                tracing::error!(
                    action = name,
                    key,
                    "a preset input cannot post a repeater's rows"
                );
                return ().boxed();
            }
        }
    }
    let Some(mounted) = mounted_admitted(cx, page) else {
        return ().boxed();
    };
    let (Some(action), Some(url)) = (
        mounted.header_actions.find(name),
        super::state::current(cx).and_then(|panel| panel.urls.get(&page)),
    ) else {
        return ().boxed();
    };
    super::bar::preset_button(cx, url, action, pairs).render(cx)
}

/// `P` as the request's panel mounted it, once the auth re-check every panel handler runs and the
/// page's [`PageDef::access`](crate::PageDef::access) admit the request.
fn admitted<P: Page>(cx: &Cx) -> topcoat::Result<&Arc<MountedPage>> {
    crate::auth::guard(cx)?;
    let page = mounted_page::<P>(cx).ok_or_else(not_found)?;
    if !page.admits(cx) {
        return Err(forbidden().into());
    }
    Ok(page)
}

/// `GET` for a registered page: [`admitted`], then the page's own body.
pub(super) fn page_handler<P: Page>(cx: &Cx, _body: Body) -> BoxView<'_> {
    async_page(async move {
        admitted::<P>(cx)?;
        P::render(cx).await
    })
}

/// `POST {url}/-/actions/{name}` for a registered page: the same checks as its `GET`, then the
/// header action's [`can_run`](crate::HeaderAction::can_run), landing back on the page.
pub(super) fn page_action<P: Page>(cx: &Cx, body: Body) -> BoxView<'_> {
    async_page(async move {
        let page = admitted::<P>(cx)?;
        let Some(action) = page
            .header_actions
            .find(path_param_segment(cx, "action"))
            .copied()
        else {
            return Err(not_found().into());
        };
        if !(action.can_run)(cx) {
            return Err(forbidden().into());
        }
        let Some(home) = crate::panel::url::page::<P>(cx) else {
            return Err(not_found().into());
        };
        run_header(cx, body, action, &home, None).await
    })
}

/// `GET {url}/-/actions/{name}/options` for a registered page: the option search of a header
/// action's input, behind the same checks as the action's `POST`.
pub(super) fn page_action_options<P: Page>(cx: &Cx, _body: Body) -> RouteFuture<'_> {
    Box::pin(async move {
        let page = admitted::<P>(cx)?;
        let Some(action) = page.header_actions.find(path_param_segment(cx, "action")) else {
            return Err(not_found().into());
        };
        if !(action.can_run)(cx) {
            return Err(forbidden().into());
        }
        input_options(cx, action.input).await
    })
}

#[cfg(test)]
mod tests;