patternfly_yew/components/toast/
mod.rs

1//! Toast notifications
2use crate::prelude::{Action, Alert, AlertGroup, AlertType};
3use chrono::{DateTime, Utc};
4use core::cmp::Reverse;
5use gloo_timers::callback::Timeout;
6use std::fmt::Display;
7use std::{collections::BinaryHeap, time::Duration};
8use yew::{prelude::*, virtual_dom::VChild};
9
10/// Toasts are small alerts that get shown on the top right corner of the page.
11///
12/// A toast can be triggered by every component. The toast fill get sent to an agent, the Toaster.
13/// The toaster will delegate displaying the toast to an instance of a ToastViewer component.
14///
15/// In order for Toasts to be displayed your application must have exactly one [ToastViewer](`ToastViewer`) **before**
16/// creating the first Toast.
17///
18/// For example:
19/// ```
20/// # use yew::prelude::*;
21/// # use patternfly_yew::prelude::*;
22/// #[function_component(App)]
23/// fn app() -> Html {
24///   html! {
25///     <>
26///       <ToastViewer>
27///         <View/>
28///       </ToastViewer>
29///     </>
30///   }
31/// }
32/// #[function_component(View)]
33/// fn view() -> Html {
34///   let toaster = use_toaster().expect("Must be nested under a ToastViewer component");
35///   html!{
36///     <div>
37///       <button onclick={move |_| toaster.toast("Toast Title")}>
38///         { "Click me" }  
39///       </button>
40///     </div>
41///   }
42/// }
43/// ```
44#[derive(Clone, Debug, Default)]
45pub struct Toast {
46    pub title: String,
47    pub r#type: AlertType,
48    /// The timeout when the toast will be removed automatically.
49    ///
50    /// If no timeout is set, the toast will get a close button.
51    pub timeout: Option<Duration>,
52    pub body: Html,
53    pub actions: Vec<Action>,
54}
55
56impl From<&str> for Toast {
57    fn from(value: &str) -> Self {
58        Self::from(value.to_string())
59    }
60}
61
62impl From<String> for Toast {
63    fn from(value: String) -> Self {
64        Self {
65            title: value,
66            timeout: None,
67            body: Default::default(),
68            r#type: Default::default(),
69            actions: Vec::new(),
70        }
71    }
72}
73
74impl From<&String> for Toast {
75    fn from(value: &String) -> Self {
76        Self::from(value.clone())
77    }
78}
79
80/// Turn something into a [`Toast`] explicitly.
81pub trait ToToast {
82    fn to_toast(&self) -> Toast;
83}
84
85impl<T> ToToast for T
86where
87    T: Display,
88{
89    fn to_toast(&self) -> Toast {
90        Toast::from(self.to_string())
91    }
92}
93
94#[doc(hidden)]
95#[derive(Debug)]
96pub enum ToasterRequest {
97    Toast(Toast),
98}
99
100#[doc(hidden)]
101pub enum ToastAction {
102    ShowToast(Toast),
103}
104
105/// An agent for displaying toasts.
106#[derive(Clone, PartialEq)]
107pub struct Toaster {
108    callback: Callback<ToastAction>,
109}
110
111impl Toaster {
112    /// Request a toast from the toast viewer.
113    pub fn toast(&self, toast: impl Into<Toast>) {
114        self.callback.emit(ToastAction::ShowToast(toast.into()))
115    }
116}
117
118#[derive(Clone, PartialEq, Properties)]
119pub struct Props {
120    pub children: Html,
121}
122
123pub struct ToastEntry {
124    id: usize,
125    alert: VChild<Alert>,
126    timeout: Option<DateTime<Utc>>,
127}
128
129/// A component to view toast alerts.
130///
131/// Exactly one instance is required in your page in order to actually show the toasts. The instance
132/// must be on the body level of the HTML document.
133pub struct ToastViewer {
134    context: Toaster,
135    alerts: Vec<ToastEntry>,
136    counter: usize,
137
138    task: Option<Timeout>,
139    timeouts: BinaryHeap<Reverse<DateTime<Utc>>>,
140}
141
142pub enum ToastViewerMsg {
143    Perform(ToastAction),
144    Cleanup,
145    Close(usize),
146}
147
148impl Component for ToastViewer {
149    type Message = ToastViewerMsg;
150    type Properties = Props;
151
152    fn create(ctx: &Context<Self>) -> Self {
153        let context = Toaster {
154            callback: ctx.link().callback(ToastViewerMsg::Perform),
155        };
156        Self {
157            context,
158            alerts: Vec::new(),
159            counter: 0,
160            task: None,
161            timeouts: BinaryHeap::new(),
162        }
163    }
164
165    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
166        match msg {
167            ToastViewerMsg::Perform(action) => self.perform(ctx, action),
168            ToastViewerMsg::Cleanup => self.cleanup(ctx),
169            ToastViewerMsg::Close(id) => self.remove_toast(id),
170        }
171    }
172
173    fn view(&self, ctx: &Context<Self>) -> Html {
174        let context = self.context.clone();
175
176        html! {
177            <ContextProvider<Toaster> {context}>
178                <AlertGroup toast=true>
179                    { for self.alerts.iter().map(|entry|entry.alert.clone()) }
180                </AlertGroup>
181                { ctx.props().children.clone() }
182            </ContextProvider<Toaster>>
183        }
184    }
185}
186
187impl ToastViewer {
188    fn now() -> DateTime<Utc> {
189        Utc::now()
190    }
191
192    fn perform(&mut self, ctx: &Context<Self>, action: ToastAction) -> bool {
193        match action {
194            ToastAction::ShowToast(toast) => self.add_toast(ctx, toast),
195        }
196        true
197    }
198
199    fn add_toast(&mut self, ctx: &Context<Self>, toast: Toast) {
200        let now = Self::now();
201        let timeout = toast
202            .timeout
203            .and_then(|timeout| chrono::Duration::from_std(timeout).ok())
204            .map(|timeout| now + timeout);
205
206        let id = self.counter;
207        self.counter += 1;
208
209        let onclose = match toast.timeout {
210            None => Some(ctx.link().callback(move |_| ToastViewerMsg::Close(id))),
211            Some(_) => None,
212        };
213
214        self.alerts.push(ToastEntry {
215            id,
216            alert: html_nested! {
217                <Alert r#type={toast.r#type} title={toast.title} onclose={onclose} actions={toast.actions}>
218                    { toast.body }
219                </Alert>
220            },
221            timeout,
222        });
223
224        if let Some(timeout) = timeout {
225            self.schedule_cleanup(ctx, timeout);
226        }
227    }
228
229    fn schedule_cleanup(&mut self, ctx: &Context<Self>, timeout: DateTime<Utc>) {
230        log::debug!("Schedule cleanup: {:?}", timeout);
231
232        self.timeouts.push(Reverse(timeout));
233        self.trigger_next_cleanup(ctx);
234    }
235
236    fn trigger_next_cleanup(&mut self, ctx: &Context<Self>) {
237        if self.task.is_some() {
238            log::debug!("Already have a task");
239            return;
240        }
241
242        // We poll timeouts from the heap until we find one that is in the future, or we run
243        // out of candidates.
244        while let Some(next) = self.timeouts.pop() {
245            let timeout = next.0;
246            log::debug!("Next timeout: {:?}", timeout);
247            let duration = timeout - Self::now();
248            let duration = duration.to_std();
249            log::debug!("Duration: {:?}", duration);
250            if let Ok(duration) = duration {
251                let link = ctx.link().clone();
252                self.task = Some(Timeout::new(duration.as_millis() as u32, move || {
253                    link.send_message(ToastViewerMsg::Cleanup);
254                }));
255                log::debug!("Scheduled cleanup: {:?}", duration);
256                break;
257            }
258        }
259    }
260
261    fn remove_toast(&mut self, id: usize) -> bool {
262        self.retain_alert(|entry| entry.id != id)
263    }
264
265    fn cleanup(&mut self, ctx: &Context<Self>) -> bool {
266        let now = Self::now();
267
268        self.task = None;
269        self.trigger_next_cleanup(ctx);
270
271        self.retain_alert(|alert| {
272            if let Some(timeout) = alert.timeout {
273                timeout > now
274            } else {
275                true
276            }
277        })
278    }
279
280    fn retain_alert<F>(&mut self, f: F) -> bool
281    where
282        F: Fn(&ToastEntry) -> bool,
283    {
284        let before = self.alerts.len();
285        self.alerts.retain(f);
286        before != self.alerts.len()
287    }
288}
289
290/// Get a [`Toaster`] context.
291#[hook]
292pub fn use_toaster() -> Option<Toaster> {
293    use_context()
294}