Skip to main content

tablo_core/
notification.rs

1//! Messages for a panel user: the toast a write flashes, and the
2//! [`StoredNotification`]s [`Notifications`] sends to the bell of a panel with
3//! [`Panel::notifications`](crate::Panel::notifications).
4
5mod bell;
6pub(crate) mod stored;
7
8use tablo_ui::{
9    icons, toast, toast_close, toast_content, toast_description, toast_icon, toast_title,
10};
11use topcoat::{
12    context::Cx,
13    icon::icon,
14    runtime::{Signal, shard, signal},
15    view::{Attributes, BoxView, View, ViewExt, attributes, view},
16};
17
18pub use self::{
19    bell::{
20        clear_notifications, mark_all_notifications_read, mark_notification, notification_bell,
21        remove_notification,
22    },
23    stored::StoredNotification,
24};
25pub(crate) use self::{
26    bell::{prefetch as prefetch_bell, topbar_bell},
27    stored::Notifications,
28};
29pub use crate::flash::{set_notification, take_notification};
30pub(crate) use crate::outcome::{Notification, NotificationStatus};
31
32/// The icon a notification of `status` shows, and its classes.
33pub(crate) fn status_icon(status: &NotificationStatus) -> (topcoat::icon::IconData, &'static str) {
34    match status {
35        NotificationStatus::Success => (icons::CIRCLE_CHECK, "size-4"),
36        NotificationStatus::Error => (icons::OCTAGON_X, "size-4 text-destructive"),
37        NotificationStatus::Info => (icons::INFO, "size-4"),
38        NotificationStatus::Warning => (icons::TRIANGLE_ALERT, "size-4"),
39    }
40}
41
42/// Renders one notification as the toast, with `attrs` merged onto its surface.
43pub async fn render_notification<'a>(
44    cx: &'a Cx,
45    notification: Notification,
46    attrs: Attributes,
47) -> topcoat::Result<BoxView<'a>> {
48    let Notification {
49        status,
50        title,
51        description,
52        actions,
53    } = notification;
54    let (icon_data, icon_class) = status_icon(&status);
55    let links: Vec<(String, String)> = actions
56        .iter()
57        .filter_map(|action| Some((action.label.clone(), action.safe_url()?.to_owned())))
58        .collect();
59    let status = status.as_str();
60    Ok(view! {
61        toast(
62            attrs: attributes! { data-type=(status) (attrs) },
63            toast_icon(icon(data: icon_data, attrs: attributes! { class=(icon_class) }))
64            toast_content(
65                toast_title((title))
66                if let Some(description) = description {
67                    toast_description((description))
68                }
69                if !links.is_empty() {
70                    <div class="flex flex-wrap gap-3 pt-1">
71                        for (label, url) in links {
72                            <a
73                                href=(url)
74                                class="font-medium text-primary underline-offset-4 hover:underline"
75                            >
76                                (label)
77                            </a>
78                        }
79                    </div>
80                }
81            )
82            toast_close(label: crate::t!(cx, "tablo-toast-close"))
83        )
84    }
85    .boxed())
86}
87
88/// Owns the signals that mount a [`Notification`] in place without navigation.
89#[derive(Clone)]
90pub struct LiveToast {
91    /// Sonner status token; empty means no toast mounted.
92    pub status: Signal<String>,
93    /// The toast title.
94    pub title: Signal<String>,
95    /// The optional supporting line; empty renders no description.
96    pub description: Signal<String>,
97    /// Bumped on every mount so an identical repeat still re-renders.
98    pub serial: Signal<u64>,
99}
100
101/// The live toast signals for this request (call once per page).
102pub fn live_toast(cx: &Cx) -> LiveToast {
103    LiveToast {
104        status: signal(cx, String::new),
105        title: signal(cx, String::new),
106        description: signal(cx, String::new),
107        serial: signal(cx, || 0u64),
108    }
109}
110
111/// Names the live-toaster endpoint so it stays stable across builds
112/// (topcoat#441); staying under `/_topcoat/runtime` keeps the panel's auth gate
113/// covering it.
114#[cfg(test)]
115pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
116
117/// Reads the page's [`LiveToast`] signals and mounts the toast in place when one
118/// is set.
119#[shard("/_topcoat/runtime/shards/tablo-live-toaster")]
120pub async fn live_toaster(
121    cx: &Cx,
122    status: Signal<String>,
123    title: Signal<String>,
124    description: Signal<String>,
125    serial: Signal<u64>,
126) -> topcoat::Result<impl View> {
127    render_live_toaster(cx, &status, &title, &description, &serial).await
128}
129
130async fn render_live_toaster<'a>(
131    cx: &'a Cx,
132    status: &Signal<String>,
133    title: &Signal<String>,
134    description: &Signal<String>,
135    serial: &Signal<u64>,
136) -> topcoat::Result<BoxView<'a>> {
137    // The shard restates the panel gate; an empty slot renders without auth,
138    // but a direct POST must not mount toasts unauthenticated.
139    crate::auth::guard(cx)?;
140    let status = status.get();
141    let mount = serial.get();
142    if status.is_empty() {
143        return Ok(().boxed());
144    }
145    let title = title.get();
146    let description = description.get();
147    let notification = match status.as_str() {
148        "success" => Notification::success(title),
149        "warning" => Notification::warning(title),
150        "error" => Notification::error(title),
151        _ => Notification::info(title),
152    };
153    let notification = if description.is_empty() {
154        notification
155    } else {
156        notification.description(description)
157    };
158    let mount_id = format!("live-toast-{mount}");
159    render_notification(cx, notification, attributes! { id=(mount_id) }).await
160}
161
162#[cfg(test)]
163mod tests;