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        cx =>
62        toast(
63            attrs: attributes! { data-type=(status) (attrs) },
64            toast_icon(icon(data: icon_data, attrs: attributes! { class=(icon_class) }))
65            toast_content(
66                toast_title((title))
67                if let Some(description) = description {
68                    toast_description((description))
69                }
70                if !links.is_empty() {
71                    <div class="flex flex-wrap gap-3 pt-1">
72                        for (label, url) in links {
73                            <a
74                                href=(url)
75                                class="font-medium text-primary underline-offset-4 hover:underline"
76                            >
77                                (label)
78                            </a>
79                        }
80                    </div>
81                }
82            )
83            toast_close(label: crate::t!(cx, "tablo-toast-close"))
84        )
85    }
86    .boxed())
87}
88
89/// Owns the signals that mount a [`Notification`] in place without navigation.
90#[derive(Clone)]
91pub struct LiveToast {
92    /// Sonner status token; empty means no toast mounted.
93    pub status: Signal<String>,
94    /// The toast title.
95    pub title: Signal<String>,
96    /// The optional supporting line; empty renders no description.
97    pub description: Signal<String>,
98    /// Bumped on every mount so an identical repeat still re-renders.
99    pub serial: Signal<u64>,
100}
101
102/// The live toast signals for this request (call once per page).
103pub fn live_toast(cx: &Cx) -> LiveToast {
104    LiveToast {
105        status: signal(cx, String::new),
106        title: signal(cx, String::new),
107        description: signal(cx, String::new),
108        serial: signal(cx, || 0u64),
109    }
110}
111
112/// Names the live-toaster endpoint so it stays stable across builds
113/// (topcoat#441); staying under `/_topcoat/runtime` keeps the panel's auth gate
114/// covering it.
115#[cfg(test)]
116pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
117
118/// Reads the page's [`LiveToast`] signals and mounts the toast in place when one
119/// is set.
120#[shard("/_topcoat/runtime/shards/tablo-live-toaster")]
121pub async fn live_toaster(
122    cx: &Cx,
123    status: Signal<String>,
124    title: Signal<String>,
125    description: Signal<String>,
126    serial: Signal<u64>,
127) -> topcoat::Result<impl View> {
128    render_live_toaster(cx, &status, &title, &description, &serial).await
129}
130
131async fn render_live_toaster<'a>(
132    cx: &'a Cx,
133    status: &Signal<String>,
134    title: &Signal<String>,
135    description: &Signal<String>,
136    serial: &Signal<u64>,
137) -> topcoat::Result<BoxView<'a>> {
138    // The shard restates the panel gate; an empty slot renders without auth,
139    // but a direct POST must not mount toasts unauthenticated.
140    crate::auth::guard(cx)?;
141    let status = status.get();
142    let mount = serial.get();
143    if status.is_empty() {
144        return Ok(().boxed());
145    }
146    let title = title.get();
147    let description = description.get();
148    let notification = match status.as_str() {
149        "success" => Notification::success(title),
150        "warning" => Notification::warning(title),
151        "error" => Notification::error(title),
152        _ => Notification::info(title),
153    };
154    let notification = if description.is_empty() {
155        notification
156    } else {
157        notification.description(description)
158    };
159    let mount_id = format!("live-toast-{mount}");
160    render_notification(cx, notification, attributes! { cx => id=(mount_id) }).await
161}
162
163#[cfg(test)]
164mod tests;