use icondata::Icon as IconId;
use leptos::prelude::*;
use leptos_icons::Icon;
#[derive(Clone, Debug, Default)]
#[allow(dead_code)]
pub enum TimelineStatus {
Warning,
Info,
Danger,
#[default]
Success,
Neutral,
}
#[derive(Clone)]
pub struct TimelineItem {
pub time_info: String,
pub title: String,
pub display_ping: bool,
pub more_info: Option<String>,
pub icon_head: Option<IconId>,
pub image_head: Option<String>,
pub content: ViewFn,
pub status: TimelineStatus,
}
impl Default for TimelineItem {
fn default() -> Self {
Self {
time_info: String::new(),
title: String::new(),
display_ping: false,
more_info: None,
icon_head: None,
image_head: None,
content: ViewFn::from(|| view! {}), status: TimelineStatus::default(),
}
}
}
#[allow(dead_code)]
impl TimelineItem {
pub fn builder(
time_info: impl Into<String>,
title: impl Into<String>,
display_ping: bool,
content: ViewFn,
) -> TimelineItem {
TimelineItem {
time_info: time_info.into(),
title: title.into(),
display_ping,
content,
..Default::default()
}
}
pub fn more_info(mut self, s: impl Into<String>) -> Self {
self.more_info = Some(s.into());
self
}
pub fn icon_head(mut self, icon: IconId) -> Self {
self.icon_head = Some(icon);
self
}
pub fn image_head(mut self, url: impl Into<String>) -> Self {
self.image_head = Some(url.into());
self
}
pub fn status(mut self, status: TimelineStatus) -> Self {
self.status = status;
self
}
pub fn pending(mut self) -> Self {
self.status = TimelineStatus::Info;
self
}
pub fn completed(mut self) -> Self {
self.status = TimelineStatus::Success;
self
}
pub fn failed(mut self) -> Self {
self.status = TimelineStatus::Danger;
self
}
pub fn build(self) -> TimelineItem {
TimelineItem {
time_info: self.time_info,
title: self.title,
display_ping: self.display_ping,
more_info: self.more_info,
icon_head: self.icon_head,
image_head: self.image_head,
content: self.content,
status: self.status,
}
}
}
#[component]
pub fn Timeline(#[prop(into)] steps: RwSignal<Vec<TimelineItem>>) -> impl IntoView {
view! {
<div class="relative">
<For
each=move || steps.get().into_iter().enumerate()
key=|(i, _)| *i
let:((_i, item))
>
{
let bg_status_classes = match item.status {
TimelineStatus::Warning => "bg-warning/50 text-warning",
TimelineStatus::Info => "bg-info/50 text-info",
TimelineStatus::Success => "bg-success/50 text-success",
TimelineStatus::Danger => "bg-danger/50 text-danger",
TimelineStatus::Neutral => "bg-primary/50 text-primary",
};
view! {
<div class="relative flex">
<div class="flex flex-col">
{
if let Some(icon_head) = &item.icon_head {
Some(view!{
<span class="relative flex size-6 cursor-pointer">
<span class=format!("absolute inline-flex h-full w-full rounded-full {} {}", if item.display_ping { "animate-ping" } else { "" }, bg_status_classes)></span>
<span class=format!("relative inline-flex items-center justify-center size-6 rounded-full {}", bg_status_classes)>
<Icon width="50%" height="50%" icon=icon_head.to_owned() />
</span>
</span>
})
} else {
None
}
}
{
if let Some(image_head) = &item.image_head {
Some(view!{
<span class="relative flex size-6 cursor-pointer">
<span class=format!("absolute inline-flex h-full w-full rounded-full {} {}", if item.display_ping { "animate-ping" } else { "" }, bg_status_classes)></span>
<span class=format!("relative inline-flex items-center justify-center size-6 rounded-full {}", bg_status_classes)>
<img alt="timeline-head" src=image_head.to_owned() class="w-full h-full rounded-full object-contain saturate-200" />
</span>
</span>
})
} else {
None
}
}
{
if item.image_head.is_none() && item.icon_head.is_none() {
Some(view!{
<span class="relative flex size-6 cursor-pointer">
<span class=format!("absolute inline-flex h-full w-full rounded-full {} {}", if item.display_ping { "animate-ping" } else { "" }, bg_status_classes)></span>
<span class=format!("relative inline-flex size-6 rounded-full {}", bg_status_classes)></span>
</span>
})
} else {
None
}
}
<div class="flex justify-center flex-1">
<div class="border-[1px] border-primary"></div>
</div>
</div>
<div class="ml-4 mb-4">
<p class="text-sm">{item.time_info}</p>
<div class="text-wrap">
<h4 class="text-primary">{item.title}<span class="text-sm text-secondary">{
item.more_info.as_ref().map(|info| format!(" - {}", info))
}</span></h4>
</div>
<div class="mt-2">
{item.content.run()}
</div>
</div>
</div>
}
}
</For>
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeline_status_default_is_success() {
assert!(matches!(TimelineStatus::default(), TimelineStatus::Success));
}
fn bg_status_classes(status: &TimelineStatus) -> &'static str {
match status {
TimelineStatus::Warning => "bg-warning/20 text-warning",
TimelineStatus::Info => "bg-info/20 text-info",
TimelineStatus::Success => "bg-success/20 text-success",
TimelineStatus::Danger => "bg-danger/20 text-danger",
TimelineStatus::Neutral => "bg-primary/20 text-primary",
}
}
#[test]
fn warning_status_classes() {
assert_eq!(
bg_status_classes(&TimelineStatus::Warning),
"bg-warning/20 text-warning"
);
}
#[test]
fn info_status_classes() {
assert_eq!(
bg_status_classes(&TimelineStatus::Info),
"bg-info/20 text-info"
);
}
#[test]
fn success_status_classes() {
assert_eq!(
bg_status_classes(&TimelineStatus::Success),
"bg-success/20 text-success"
);
}
#[test]
fn danger_status_classes() {
assert_eq!(
bg_status_classes(&TimelineStatus::Danger),
"bg-danger/20 text-danger"
);
}
#[test]
fn neutral_status_classes() {
assert_eq!(
bg_status_classes(&TimelineStatus::Neutral),
"bg-primary/20 text-primary"
);
}
fn resolve_head(icon: Option<()>, image: Option<()>) -> &'static str {
if icon.is_some() {
"icon"
} else if image.is_some() {
"image"
} else {
"circle"
}
}
#[test]
fn icon_head_takes_priority() {
assert_eq!(resolve_head(Some(()), Some(())), "icon");
}
#[test]
fn image_head_used_when_no_icon() {
assert_eq!(resolve_head(None, Some(())), "image");
}
#[test]
fn default_circle_when_neither() {
assert_eq!(resolve_head(None, None), "circle");
}
#[test]
fn builder_sets_required_fields() {
let item = TimelineItem::builder("now", "Deploy", true, ViewFn::from(|| view! {})).build();
assert_eq!(item.time_info, "now");
assert_eq!(item.title, "Deploy");
assert!(item.display_ping);
assert!(item.more_info.is_none());
assert!(item.icon_head.is_none());
assert!(item.image_head.is_none());
}
#[test]
fn builder_more_info() {
let item = TimelineItem::builder("now", "Step", false, ViewFn::from(|| view! {}))
.more_info("extra detail")
.build();
assert_eq!(item.more_info, Some("extra detail".to_string()));
}
#[test]
fn builder_image_head() {
let item = TimelineItem::builder("now", "Step", false, ViewFn::from(|| view! {}))
.image_head("https://example.com/img.png")
.build();
assert_eq!(
item.image_head,
Some("https://example.com/img.png".to_string())
);
}
#[test]
fn pending_sets_info_status() {
let item = TimelineItem::builder("now", "Step", false, ViewFn::from(|| view! {}))
.pending()
.build();
assert!(matches!(item.status, TimelineStatus::Info));
}
#[test]
fn completed_sets_success_status() {
let item = TimelineItem::builder("now", "Step", false, ViewFn::from(|| view! {}))
.completed()
.build();
assert!(matches!(item.status, TimelineStatus::Success));
}
#[test]
fn failed_sets_danger_status() {
let item = TimelineItem::builder("now", "Step", false, ViewFn::from(|| view! {}))
.failed()
.build();
assert!(matches!(item.status, TimelineStatus::Danger));
}
fn ping_class(display_ping: bool) -> &'static str {
if display_ping { "animate-ping" } else { "" }
}
#[test]
fn ping_class_when_true() {
assert_eq!(ping_class(true), "animate-ping");
}
#[test]
fn ping_class_when_false() {
assert_eq!(ping_class(false), "");
}
}