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