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")]
22#[non_exhaustive]
23pub enum NotificationStatus {
24 Success,
25 Error,
26 Info,
27 Warning,
28}
29
30impl NotificationStatus {
31 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#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Notification {
45 pub status: NotificationStatus,
46 pub title: String,
47 #[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 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
87fn 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
96pub 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
109pub(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
120pub 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 Err(_) => {
132 cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME).remove();
133 None
134 }
135 Ok(None) => None,
136 }
137}
138
139pub 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#[derive(Clone)]
181pub struct LiveToast {
182 pub status: Signal<String>,
184 pub title: Signal<String>,
186 pub description: Signal<String>,
188 pub serial: Signal<u64>,
190}
191
192pub 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#[cfg(test)]
206pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
207
208#[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 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;