Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-web/src/ui.rs

//! The console's shared pieces. Every status maps to one of four words, and the
//! word is always on screen next to the colour.

use leptos::prelude::*;

use crate::api::Fail;
pub use crate::fmt::Tone;

#[component]
pub fn Badge(tone: Tone, #[prop(into)] label: String) -> impl IntoView {
    view! { <span class=tone.class()>{label}</span> }
}

/// The dot on `docs/v2/07-console.md` screen 1: a colour and a glyph, with the word for its
/// `title`. `fmt::reach_dot` decides all three.
#[component]
pub fn Dot(tone: Tone, glyph: &'static str, #[prop(into)] title: String) -> impl IntoView {
    view! { <span class=format!("dot {}", tone.word()) title=title aria-hidden="true">{glyph}</span> }
}

/// **One bar track.** `state` and `width` come from `fmt` (`bar_state`,
/// `Figure::bar`); this only draws them. An UNCAPPED state draws a striped
/// empty track with no meter role — announcing `aria-valuenow=0` there would
/// tell a screen reader "zero percent", the same lie an empty bar is.
#[component]
pub fn Track(state: crate::fmt::BarState, width: u8, #[prop(into)] label: String) -> impl IntoView {
    let meaning = state.meaning();
    if state == crate::fmt::BarState::Uncapped {
        view! { <div class="track uncapped" title=meaning><div class=state.class()></div></div> }
            .into_any()
    } else {
        view! {
            <div class="track" role="meter" title=meaning aria-valuenow=width aria-valuemin="0" aria-valuemax="100" aria-label=format!("{label}: {meaning}")>
                <div class=state.class() style=format!("width:{width}%")></div>
            </div>
        }
        .into_any()
    }
}

/// The ladder, as a badge: colour and word from one `state` string.
#[component]
pub fn StateBadge(#[prop(into)] state: String) -> impl IntoView {
    view! { <Badge tone=crate::fmt::state_tone(&state) label=crate::fmt::state_label(&state)/> }
}

#[component]
pub fn PageHeader<T: IntoView + 'static>(
    title: T,
    #[prop(into, optional)] subtitle: String,
    #[prop(optional)] children: Option<Children>,
) -> impl IntoView {
    view! {
        <header class="page-header">
            <div>
                <h1>{title}</h1>
                {(!subtitle.is_empty()).then(|| view! { <p>{subtitle}</p> })}
            </div>
            {children.map(|c| view! { <div class="actions">{c()}</div> })}
        </header>
    }
}

#[component]
pub fn Panel(
    #[prop(into)] title: String,
    #[prop(optional)] actions: Option<Children>,
    children: Children,
) -> impl IntoView {
    view! {
        <section class="panel">
            <header>
                <h2>{title}</h2>
                {actions.map(|a| view! { <div class="actions">{a()}</div> })}
            </header>
            {children()}
        </section>
    }
}

#[component]
pub fn Loading(#[prop(default = 3)] rows: usize) -> impl IntoView {
    view! {
        <div class="panel-body" aria-busy="true" aria-live="polite">
            <span class="vh">"Loading …"</span>
            {(0..rows).map(|i| view! { <div class=match i % 3 { 0 => "skeleton w-70", 1 => "skeleton", _ => "skeleton w-40" }></div> }).collect_view()}
        </div>
    }
}

#[component]
pub fn Empty(#[prop(into)] title: String, #[prop(into)] detail: String) -> impl IntoView {
    view! { <div class="empty"><h3>{title}</h3><p>{detail}</p></div> }
}

/// A refusal, with the server's own words under the console's summary.
#[component]
pub fn FailBox(fail: Fail, #[prop(optional)] retry: Option<Callback<()>>) -> impl IntoView {
    let summary = fail.summary();
    let retry = retry.filter(|_| fail.worth_retrying());
    let footer = fail.footer().unwrap_or_default();
    view! {
        <div class="failure" role="alert">
            <h3>{summary}</h3>
            {(!fail.message.is_empty()).then(|| view! { <p class="detail">{fail.message.clone()}</p> })}
            <footer>
                <span>{footer}</span>
                {retry.map(|r| view! { <button class="btn quiet" on:click=move |_| r.run(())>"Try again"</button> })}
            </footer>
        </div>
    }
}

/// Render the three states every read can be in.
///
/// A 401 is not one of them: it leaves for the login page instead
/// (`fmt::login_redirect_for` says when), and shows the shimmer for the
/// instant before the browser goes — a red box that is replaced before it
/// can be read is worse than no box.
pub fn resolved<T: Clone + 'static, V: IntoView + 'static>(
    state: Option<Result<T, Fail>>,
    retry: Option<Callback<()>>,
    render: impl FnOnce(T) -> V,
) -> AnyView {
    match state {
        None => view! { <Loading/> }.into_any(),
        Some(Err(fail))
            if crate::fmt::login_redirect_for(fail.status, &current_path()).is_some() =>
        {
            crate::app::go_to_login_because("session");
            view! { <Loading/> }.into_any()
        }
        Some(Err(fail)) => match retry {
            Some(r) => {
                view! { <div class="panel-body"><FailBox fail=fail retry=r/></div> }.into_any()
            }
            None => view! { <div class="panel-body"><FailBox fail=fail/></div> }.into_any(),
        },
        Some(Ok(v)) => render(v).into_any(),
    }
}

/// One USING row: the meter (wire name and the product's one-line meaning go
/// in the title; `fmt::plain_meter` goes on the page) and the tenant's
/// standing on it, decided by `fmt::standing_of`.
pub struct BarRow {
    pub name: String,
    pub meaning: String,
    pub standing: crate::fmt::Standing,
    /// `wall` | `billed` | `not billed` | `still costs` — `fmt::billing_word`,
    /// read off the product's enforcement and the deployment's price list.
    pub billing: &'static str,
}

/// **One bar per meter, and its colour is a claim.**
///
/// Everything drawn here was decided in `fmt` and is tested there: the state
/// and width (`bar_state` through `standing_of`), the text (`Standing::text`,
/// which says "not measured yet" rather than drawing a zero), the word
/// (`Standing::word` — `AT CAP` only for a wall), the word beside it
/// (`billing_word` — `wall` / `billed` / `not billed` / `still costs`, the
/// price list's answer and not a word of the console's), the note for a cap
/// that is not a wall (`cap_note`) and the plain words for the meter
/// (`plain_meter`). Two things it deliberately does not do:
///
/// * escalate a meter nothing refuses on — a `measured` cap at 97% is a tenant
///   using what they are billed for, and red there teaches an operator to
///   ignore red;
/// * draw an UNCAPPED meter against the biggest number on the page. It gets a
///   striped, empty track and the words "no cap".
#[component]
pub fn Bars(meters: Vec<BarRow>) -> impl IntoView {
    view! {
        <div class="bars">
            {meters.into_iter().map(|BarRow { name, meaning, standing, billing }| {
                let plain = crate::fmt::plain_meter(&name);
                let title = format!("{name} — {meaning}");
                let (billing_tone, billing_why) = match billing {
                    "wall" => (Tone::Plain, "stops at this limit"),
                    "billed" => (Tone::Plain, "charged for"),
                    "still costs" => (Tone::Warn, "deleted but not yet removed: still charged"),
                    _ => (Tone::Plain, "free"),
                };
                // `wall` is said once, by the billing word; the cap note adds
                // only what that word does not say.
                let note = standing.note.filter(|n| *n != "wall").map(|label| {
                    let (tone, why) = match label {
                        "wall" => (Tone::Plain, "the product refuses over this cap"),
                        "provisioned" => (Tone::Plain, "bought at the provider; the product does not refuse on it"),
                        "not enforced" => (Tone::Warn, "counted and reported; the product does not refuse on it"),
                        _ => (Tone::Warn, "this monetize did not say whether the cap is enforced"),
                    };
                    (label, tone, why)
                });
                let at_cap = standing.state == crate::fmt::BarState::AtLimit;
                let uncapped = standing.used.is_some() && standing.cap.is_none();
                let word = standing.word();
                view! {
                    <span class="name" title=title>{plain.clone()}</span>
                    <Track state=standing.state width=standing.width label=plain/>
                    <span class="value">
                        {standing.text()}
                        {at_cap.then(|| view! { " " <span class="at-cap">{word.clone()}</span> })}
                        {uncapped.then(|| view! { " " <span class="no-cap" title=standing.state.meaning()>"no cap"</span> })}
                        " " <span title=billing_why><Badge tone=billing_tone label=billing/></span>
                        {note.map(|(label, tone, why)| view! { " " <span title=why><Badge tone=tone label=label/></span> })}
                    </span>
                }
            }).collect_view()}
        </div>
    }
}

/// The path the browser is on, `""` outside a browser.
fn current_path() -> String {
    web_sys::window()
        .and_then(|w| w.location().pathname().ok())
        .unwrap_or_default()
}