1use 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Notification {
44 pub status: NotificationStatus,
45 pub title: String,
46 #[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 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
86fn 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
95pub 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
108pub(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
119pub 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 Err(_) => {
131 cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME).remove();
132 None
133 }
134 Ok(None) => None,
135 }
136}
137
138pub 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#[derive(Clone)]
180pub struct LiveToast {
181 pub status: Signal<String>,
183 pub title: Signal<String>,
185 pub description: Signal<String>,
187 pub serial: Signal<u64>,
189}
190
191pub 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#[cfg(test)]
205pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
206
207#[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 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;