Skip to main content

tablo_core/
notification.rs

1//! Transient user-visible message produced by an `Action`'s result and rendered
2//! in the `Panel` shell; stored as a JSON flash cookie with hardened attributes
3//! that Topcoat flushes on error responses too (topcoat#408).
4
5use serde::{Deserialize, Serialize};
6use tablo_ui::{
7    icons, toast, toast_close, toast_content, toast_description, toast_icon, toast_title,
8};
9use topcoat::{
10    context::{Cx, try_request_context},
11    cookie::{CookieJar, CookieJarCell, Cookies, cookie_store, cookies},
12    icon::icon,
13    runtime::{Signal, shard, signal},
14    view::{Attributes, BoxView, View, ViewExt, attributes, view},
15};
16
17/// The kind of notification (status).
18///
19/// The serde tokens are lowercase so the JSON cookie reads naturally.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum NotificationStatus {
23    Success,
24    Error,
25    Info,
26    Warning,
27}
28
29impl NotificationStatus {
30    /// The lowercase token, shared with the flash cookie and `data-type`.
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            Self::Success => "success",
34            Self::Error => "error",
35            Self::Info => "info",
36            Self::Warning => "warning",
37        }
38    }
39}
40
41/// A transient message shown after a mutation.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Notification {
44    pub status: NotificationStatus,
45    pub title: String,
46    /// Supporting line under the title; `None` keeps the cookie wire format so
47    /// older cookies still decode.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50}
51
52impl Notification {
53    pub fn success(title: impl Into<String>) -> Self {
54        Self::new(NotificationStatus::Success, title)
55    }
56
57    pub fn error(title: impl Into<String>) -> Self {
58        Self::new(NotificationStatus::Error, title)
59    }
60
61    pub fn info(title: impl Into<String>) -> Self {
62        Self::new(NotificationStatus::Info, title)
63    }
64
65    pub fn warning(title: impl Into<String>) -> Self {
66        Self::new(NotificationStatus::Warning, title)
67    }
68
69    fn new(status: NotificationStatus, title: impl Into<String>) -> Self {
70        Self {
71            status,
72            title: title.into(),
73            description: None,
74        }
75    }
76
77    /// Attach the supporting line under the title.
78    pub fn description(mut self, description: impl Into<String>) -> Self {
79        self.description = Some(description.into());
80        self
81    }
82}
83
84pub(crate) const COOKIE_NAME: &str = "__Host-tablo_notification";
85
86/// Applies the hardened cookie attributes the flash cookie relies on to writes
87/// and removals alike.
88fn hardened(jar: &CookieJar) -> impl Cookies + '_ {
89    jar.default_path("/")
90        .default_http_only(true)
91        .default_secure(true)
92        .default_same_site(topcoat::cookie::SameSite::Lax)
93}
94
95/// Store a notification for the next request (flash).
96pub fn set_notification(cx: &Cx, notification: Notification) {
97    if try_request_context::<CookieJarCell>(cx).is_none() {
98        return;
99    }
100    if let Err(error) = cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME)
101        .set(notification)
102        .commit()
103    {
104        tracing::error!(error = %error, "flash notification commit failed");
105    }
106}
107
108/// Flashes the failure of a write that passed validation but did not land; the
109/// title names the operation, never the driver's text, and the message appears
110/// on the next panel page.
111pub(crate) fn notify_write_failure(cx: &Cx, action: &str) {
112    set_notification(
113        cx,
114        Notification::error(format!("Couldn't {action}"))
115            .description("Nothing was changed — try again."),
116    );
117}
118
119/// Take the notification from the request (if present) and clear it.
120pub fn take_notification(cx: &Cx) -> Option<Notification> {
121    try_request_context::<CookieJarCell>(cx)?;
122    let unparsed = cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME);
123    match unparsed.parse() {
124        Ok(Some(store)) => {
125            let notification = store.get();
126            store.remove();
127            Some(notification)
128        }
129        // Unreadable value: expire it with no toast.
130        Err(_) => {
131            cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME).remove();
132            None
133        }
134        Ok(None) => None,
135    }
136}
137
138/// Renders one notification as the toast, with `attrs` merged onto its surface.
139pub async fn render_notification<'a>(
140    cx: &'a Cx,
141    notification: Notification,
142    attrs: Attributes,
143) -> topcoat::Result<BoxView<'a>> {
144    let Notification {
145        status,
146        title,
147        description,
148    } = notification;
149    let icon_data = match status {
150        NotificationStatus::Success => icons::CIRCLE_CHECK,
151        NotificationStatus::Error => icons::OCTAGON_X,
152        NotificationStatus::Info => icons::INFO,
153        NotificationStatus::Warning => icons::TRIANGLE_ALERT,
154    };
155    let icon_class = if status == NotificationStatus::Error {
156        "size-4 text-destructive"
157    } else {
158        "size-4"
159    };
160    let status = status.as_str();
161    Ok(view! {
162        cx =>
163        toast(
164            attrs: attributes! { data-type=(status) (attrs) },
165            toast_icon(icon(data: icon_data, attrs: attributes! { class=(icon_class) }))
166            toast_content(
167                toast_title((title))
168                if let Some(description) = description {
169                    toast_description((description))
170                }
171            )
172            toast_close()
173        )
174    }
175    .boxed())
176}
177
178/// Owns the signals that mount a [`Notification`] in place without navigation.
179#[derive(Clone)]
180pub struct LiveToast {
181    /// Sonner status token; empty means no toast mounted.
182    pub status: Signal<String>,
183    /// The toast title.
184    pub title: Signal<String>,
185    /// The optional supporting line; empty renders no description.
186    pub description: Signal<String>,
187    /// Bumped on every mount so an identical repeat still re-renders.
188    pub serial: Signal<u64>,
189}
190
191/// The live toast signals for this request (call once per page).
192pub fn live_toast(cx: &Cx) -> LiveToast {
193    LiveToast {
194        status: signal(cx, String::new),
195        title: signal(cx, String::new),
196        description: signal(cx, String::new),
197        serial: signal(cx, || 0u64),
198    }
199}
200
201/// Names the live-toaster endpoint so it stays stable across builds
202/// (topcoat#441); staying under `/_topcoat/runtime` keeps the panel's auth gate
203/// covering it.
204#[cfg(test)]
205pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
206
207/// Reads the page's [`LiveToast`] signals and mounts the toast in place when one
208/// is set.
209#[shard("/_topcoat/runtime/shards/tablo-live-toaster")]
210pub async fn live_toaster(
211    cx: &Cx,
212    status: Signal<String>,
213    title: Signal<String>,
214    description: Signal<String>,
215    serial: Signal<u64>,
216) -> topcoat::Result<impl View> {
217    render_live_toaster(cx, &status, &title, &description, &serial).await
218}
219
220async fn render_live_toaster<'a>(
221    cx: &'a Cx,
222    status: &Signal<String>,
223    title: &Signal<String>,
224    description: &Signal<String>,
225    serial: &Signal<u64>,
226) -> topcoat::Result<BoxView<'a>> {
227    // The shard restates the panel gate; an empty slot renders without auth,
228    // but a direct POST must not mount toasts unauthenticated.
229    crate::auth::guard(cx)?;
230    let status = status.get();
231    let mount = serial.get();
232    if status.is_empty() {
233        return Ok(().boxed());
234    }
235    let title = title.get();
236    let description = description.get();
237    let notification = match status.as_str() {
238        "success" => Notification::success(title),
239        "warning" => Notification::warning(title),
240        "error" => Notification::error(title),
241        _ => Notification::info(title),
242    };
243    let notification = if description.is_empty() {
244        notification
245    } else {
246        notification.description(description)
247    };
248    let mount_id = format!("live-toast-{mount}");
249    render_notification(cx, notification, attributes! { cx => id=(mount_id) }).await
250}
251
252#[cfg(test)]
253mod tests;