use serde::{Deserialize, Serialize};
use tablo_ui::{
icons, toast, toast_close, toast_content, toast_description, toast_icon, toast_title,
};
use topcoat::{
context::{Cx, try_request_context},
cookie::{CookieJar, CookieJarCell, Cookies, cookie_store, cookies},
icon::icon,
runtime::{Signal, shard, signal},
view::{Attributes, BoxView, View, ViewExt, attributes, view},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotificationStatus {
Success,
Error,
Info,
Warning,
}
impl NotificationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
pub status: NotificationStatus,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl Notification {
pub fn success(title: impl Into<String>) -> Self {
Self::new(NotificationStatus::Success, title)
}
pub fn error(title: impl Into<String>) -> Self {
Self::new(NotificationStatus::Error, title)
}
pub fn info(title: impl Into<String>) -> Self {
Self::new(NotificationStatus::Info, title)
}
pub fn warning(title: impl Into<String>) -> Self {
Self::new(NotificationStatus::Warning, title)
}
fn new(status: NotificationStatus, title: impl Into<String>) -> Self {
Self {
status,
title: title.into(),
description: None,
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
pub(crate) const COOKIE_NAME: &str = "__Host-tablo_notification";
fn hardened(jar: &CookieJar) -> impl Cookies + '_ {
jar.default_path("/")
.default_http_only(true)
.default_secure(true)
.default_same_site(topcoat::cookie::SameSite::Lax)
}
pub fn set_notification(cx: &Cx, notification: Notification) {
if try_request_context::<CookieJarCell>(cx).is_none() {
return;
}
if let Err(error) = cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME)
.set(notification)
.commit()
{
tracing::error!(error = %error, "flash notification commit failed");
}
}
pub(crate) fn notify_write_failure(cx: &Cx, action: &str) {
set_notification(
cx,
Notification::error(format!("Couldn't {action}"))
.description("Nothing was changed — try again."),
);
}
pub fn take_notification(cx: &Cx) -> Option<Notification> {
try_request_context::<CookieJarCell>(cx)?;
let unparsed = cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME);
match unparsed.parse() {
Ok(Some(store)) => {
let notification = store.get();
store.remove();
Some(notification)
}
Err(_) => {
cookie_store::<Notification, _>(hardened(cookies(cx)), COOKIE_NAME).remove();
None
}
Ok(None) => None,
}
}
pub async fn render_notification<'a>(
cx: &'a Cx,
notification: Notification,
attrs: Attributes,
) -> topcoat::Result<BoxView<'a>> {
let Notification {
status,
title,
description,
} = notification;
let icon_data = match status {
NotificationStatus::Success => icons::CIRCLE_CHECK,
NotificationStatus::Error => icons::OCTAGON_X,
NotificationStatus::Info => icons::INFO,
NotificationStatus::Warning => icons::TRIANGLE_ALERT,
};
let icon_class = if status == NotificationStatus::Error {
"size-4 text-destructive"
} else {
"size-4"
};
let status = status.as_str();
Ok(view! {
cx =>
toast(
attrs: attributes! { data-type=(status) (attrs) },
toast_icon(icon(data: icon_data, attrs: attributes! { class=(icon_class) }))
toast_content(
toast_title((title))
if let Some(description) = description {
toast_description((description))
}
)
toast_close()
)
}
.boxed())
}
#[derive(Clone)]
pub struct LiveToast {
pub status: Signal<String>,
pub title: Signal<String>,
pub description: Signal<String>,
pub serial: Signal<u64>,
}
pub fn live_toast(cx: &Cx) -> LiveToast {
LiveToast {
status: signal(cx, String::new),
title: signal(cx, String::new),
description: signal(cx, String::new),
serial: signal(cx, || 0u64),
}
}
#[cfg(test)]
pub(crate) const LIVE_TOASTER_PATH: &str = "/_topcoat/runtime/shards/tablo-live-toaster";
#[shard("/_topcoat/runtime/shards/tablo-live-toaster")]
pub async fn live_toaster(
cx: &Cx,
status: Signal<String>,
title: Signal<String>,
description: Signal<String>,
serial: Signal<u64>,
) -> topcoat::Result<impl View> {
render_live_toaster(cx, &status, &title, &description, &serial).await
}
async fn render_live_toaster<'a>(
cx: &'a Cx,
status: &Signal<String>,
title: &Signal<String>,
description: &Signal<String>,
serial: &Signal<u64>,
) -> topcoat::Result<BoxView<'a>> {
crate::auth::guard(cx)?;
let status = status.get();
let mount = serial.get();
if status.is_empty() {
return Ok(().boxed());
}
let title = title.get();
let description = description.get();
let notification = match status.as_str() {
"success" => Notification::success(title),
"warning" => Notification::warning(title),
"error" => Notification::error(title),
_ => Notification::info(title),
};
let notification = if description.is_empty() {
notification
} else {
notification.description(description)
};
let mount_id = format!("live-toast-{mount}");
render_notification(cx, notification, attributes! { cx => id=(mount_id) }).await
}
#[cfg(test)]
mod tests;