webtest/src/kanban.rs
2025-08-08 08:39:55 +02:00

732 lines
27 KiB
Rust

use yew::prelude::*;
use serde::{Deserialize, Serialize};
use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent};
use wasm_bindgen::JsCast;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KanbanData {
pub title: String,
pub description: String,
pub columns: Vec<KanbanColumn>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KanbanColumn {
pub id: String,
pub title: String,
pub description: String,
pub cards: Vec<KanbanCard>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KanbanCard {
pub id: String,
pub title: String,
pub description: String,
pub priority: String,
pub assignee: String,
pub tags: Vec<String>,
#[serde(rename = "dueDate")]
pub due_date: String,
pub attachments: u32,
pub comments: u32,
pub checklist: ChecklistInfo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChecklistInfo {
pub completed: u32,
pub total: u32,
}
fn get_sample_data() -> KanbanData {
KanbanData {
title: "Project Management Board".to_string(),
description: "Track project progress with this kanban board".to_string(),
columns: vec![
KanbanColumn {
id: "todo".to_string(),
title: "To Do".to_string(),
description: "Tasks that need to be started".to_string(),
cards: vec![
KanbanCard {
id: "card-1".to_string(),
title: "Design User Interface".to_string(),
description: "Create wireframes and mockups for the new feature".to_string(),
priority: "high".to_string(),
assignee: "Alice Johnson".to_string(),
tags: vec!["design".to_string(), "ui/ux".to_string()],
due_date: "2024-01-15".to_string(),
attachments: 2,
comments: 3,
checklist: ChecklistInfo { completed: 1, total: 4 },
},
KanbanCard {
id: "card-2".to_string(),
title: "Research Market Trends".to_string(),
description: "Analyze current market trends and competitor analysis".to_string(),
priority: "medium".to_string(),
assignee: "Bob Smith".to_string(),
tags: vec!["research".to_string(), "analysis".to_string()],
due_date: "2024-01-20".to_string(),
attachments: 0,
comments: 1,
checklist: ChecklistInfo { completed: 0, total: 3 },
},
],
},
KanbanColumn {
id: "in-progress".to_string(),
title: "In Progress".to_string(),
description: "Tasks currently being worked on".to_string(),
cards: vec![
KanbanCard {
id: "card-3".to_string(),
title: "Implement Authentication".to_string(),
description: "Set up user authentication system with JWT tokens and secure password handling".to_string(),
priority: "high".to_string(),
assignee: "Charlie Brown".to_string(),
tags: vec!["backend".to_string(), "security".to_string()],
due_date: "2024-01-12".to_string(),
attachments: 1,
comments: 5,
checklist: ChecklistInfo { completed: 2, total: 5 },
},
KanbanCard {
id: "card-4".to_string(),
title: "Database Migration".to_string(),
description: "Migrate existing data to new database schema".to_string(),
priority: "medium".to_string(),
assignee: "Diana Prince".to_string(),
tags: vec!["database".to_string(), "migration".to_string()],
due_date: "2024-01-18".to_string(),
attachments: 3,
comments: 2,
checklist: ChecklistInfo { completed: 3, total: 6 },
},
],
},
KanbanColumn {
id: "review".to_string(),
title: "Review".to_string(),
description: "Tasks pending review and approval".to_string(),
cards: vec![
KanbanCard {
id: "card-5".to_string(),
title: "API Documentation".to_string(),
description: "Complete API documentation with examples and usage guidelines".to_string(),
priority: "low".to_string(),
assignee: "Eve Wilson".to_string(),
tags: vec!["documentation".to_string(), "api".to_string()],
due_date: "2024-01-10".to_string(),
attachments: 2,
comments: 4,
checklist: ChecklistInfo { completed: 4, total: 4 },
},
],
},
KanbanColumn {
id: "done".to_string(),
title: "Done".to_string(),
description: "Completed tasks".to_string(),
cards: vec![
KanbanCard {
id: "card-6".to_string(),
title: "Setup Development Environment".to_string(),
description: "Configure development tools and environment for the team".to_string(),
priority: "high".to_string(),
assignee: "Frank Miller".to_string(),
tags: vec!["setup".to_string(), "devops".to_string()],
due_date: "2024-01-05".to_string(),
attachments: 1,
comments: 2,
checklist: ChecklistInfo { completed: 3, total: 3 },
},
KanbanCard {
id: "card-7".to_string(),
title: "Initial Project Planning".to_string(),
description: "Define project scope, timeline, and resource allocation".to_string(),
priority: "high".to_string(),
assignee: "Grace Lee".to_string(),
tags: vec!["planning".to_string(), "management".to_string()],
due_date: "2024-01-03".to_string(),
attachments: 4,
comments: 8,
checklist: ChecklistInfo { completed: 5, total: 5 },
},
],
},
],
}
}
#[function_component(KanbanBoard)]
pub fn kanban_board() -> Html {
let data = use_state(|| get_sample_data());
let editing_card = use_state(|| None::<KanbanCard>);
let show_save_popup = use_state(|| false);
let dragging_card = use_state(|| None::<String>);
let on_edit_card = {
let editing_card = editing_card.clone();
Callback::from(move |card: KanbanCard| {
editing_card.set(Some(card));
})
};
let on_close_edit = {
let editing_card = editing_card.clone();
Callback::from(move |_| {
editing_card.set(None);
})
};
let on_save_card = {
let data = data.clone();
let editing_card = editing_card.clone();
let show_save_popup = show_save_popup.clone();
Callback::from(move |updated_card: KanbanCard| {
let mut new_data = (*data).clone();
// Find and update the card in the data
for column in &mut new_data.columns {
if let Some(card_index) = column.cards.iter().position(|c| c.id == updated_card.id) {
column.cards[card_index] = updated_card.clone();
break;
}
}
data.set(new_data);
editing_card.set(None);
show_save_popup.set(true);
// Hide popup after 2 seconds
let show_save_popup_clone = show_save_popup.clone();
wasm_bindgen_futures::spawn_local(async move {
gloo_utils::window().set_timeout_with_callback_and_timeout_and_arguments_0(
&wasm_bindgen::closure::Closure::wrap(Box::new(move || {
show_save_popup_clone.set(false);
}) as Box<dyn FnMut()>).into_js_value().unchecked_into(),
2000
).unwrap();
});
})
};
let on_card_drag_start = {
let dragging_card = dragging_card.clone();
Callback::from(move |card_id: String| {
dragging_card.set(Some(card_id));
})
};
let on_card_drop = {
let data = data.clone();
let dragging_card = dragging_card.clone();
Callback::from(move |(target_column_id, target_position): (String, usize)| {
if let Some(card_id) = (*dragging_card).clone() {
let mut new_data = (*data).clone();
let mut moved_card = None;
// Remove card from source column
for column in &mut new_data.columns {
if let Some(card_index) = column.cards.iter().position(|c| c.id == card_id) {
moved_card = Some(column.cards.remove(card_index));
break;
}
}
// Add card to target column
if let Some(card) = moved_card {
if let Some(target_column) = new_data.columns.iter_mut().find(|c| c.id == target_column_id) {
let insert_position = target_position.min(target_column.cards.len());
target_column.cards.insert(insert_position, card);
}
}
data.set(new_data);
dragging_card.set(None);
}
})
};
html! {
<div class="kanban-board">
<div class="container-fluid">
<div class="kanban-header">
<h1>{&data.title}</h1>
<p class="kanban-description">{&data.description}</p>
</div>
<div class="kanban-columns">
{for data.columns.iter().map(|column| {
html! {
<KanbanColumnComponent
column={column.clone()}
on_edit_card={on_edit_card.clone()}
on_card_drag_start={on_card_drag_start.clone()}
on_card_drop={on_card_drop.clone()}
/>
}
})}
</div>
// Card editing modal
{if let Some(card) = (*editing_card).clone() {
html! {
<CardEditModal
card={card}
on_save={on_save_card.clone()}
on_close={on_close_edit.clone()}
/>
}
} else {
html! {}
}}
// Save confirmation popup
{if *show_save_popup {
html! {
<div class="save-popup">
<div class="save-popup-content">
<i class="bi bi-check-circle-fill text-success me-2"></i>
{"Card saved successfully!"}
</div>
</div>
}
} else {
html! {}
}}
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct KanbanColumnProps {
pub column: KanbanColumn,
pub on_edit_card: Callback<KanbanCard>,
pub on_card_drag_start: Callback<String>,
pub on_card_drop: Callback<(String, usize)>,
}
#[function_component(KanbanColumnComponent)]
pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
let column = &props.column;
let drag_over = use_state(|| false);
let ondragover = {
let drag_over = drag_over.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
drag_over.set(true);
})
};
let ondragleave = {
let drag_over = drag_over.clone();
Callback::from(move |_: DragEvent| {
drag_over.set(false);
})
};
let ondrop = {
let on_card_drop = props.on_card_drop.clone();
let column_id = column.id.clone();
let drag_over = drag_over.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
drag_over.set(false);
on_card_drop.emit((column_id.clone(), 0));
})
};
let column_class = if *drag_over {
"kanban-column drag-over"
} else {
"kanban-column"
};
html! {
<div class={column_class} {ondragover} {ondragleave} {ondrop}>
<div class="column-header">
<div class="column-title">
{&column.title}
<span class="card-count">{column.cards.len()}</span>
</div>
<p class="column-description">{&column.description}</p>
</div>
<div class="column-cards">
{for column.cards.iter().enumerate().map(|(index, card)| {
html! {
<KanbanCardComponent
card={card.clone()}
on_edit={props.on_edit_card.clone()}
on_drag_start={props.on_card_drag_start.clone()}
index={index}
/>
}
})}
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct KanbanCardProps {
pub card: KanbanCard,
pub on_edit: Callback<KanbanCard>,
pub on_drag_start: Callback<String>,
pub index: usize,
}
#[function_component(KanbanCardComponent)]
pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
let card = &props.card;
let priority_class = match card.priority.as_str() {
"high" => "priority-high",
"medium" => "priority-medium",
"low" => "priority-low",
_ => "priority-medium",
};
let progress_percentage = if card.checklist.total > 0 {
(card.checklist.completed as f32 / card.checklist.total as f32 * 100.0) as u32
} else {
0
};
let assignee_initials = card.assignee
.split_whitespace()
.map(|word| word.chars().next().unwrap_or(' '))
.collect::<String>()
.to_uppercase();
let onclick = {
let on_edit = props.on_edit.clone();
let card = card.clone();
Callback::from(move |_| {
on_edit.emit(card.clone());
})
};
let ondragstart = {
let on_drag_start = props.on_drag_start.clone();
let card_id = card.id.clone();
Callback::from(move |_: DragEvent| {
on_drag_start.emit(card_id.clone());
})
};
html! {
<div class="kanban-card" draggable="true" {onclick} {ondragstart}>
<div class="card-header">
<h3 class="card-title">{&card.title}</h3>
<p class="card-description">{&card.description}</p>
</div>
<div class="card-meta">
<span class={classes!("priority-badge", priority_class)}>
{&card.priority}
</span>
<span class="due-date">
<i class="bi bi-calendar-event me-1"></i>
{&card.due_date}
</span>
</div>
{if !card.tags.is_empty() {
html! {
<div class="card-tags">
{for card.tags.iter().map(|tag| {
html! {
<span class="tag">{tag}</span>
}
})}
</div>
}
} else {
html! {}
}}
<div class="card-assignee">
<div class="assignee-avatar">
{assignee_initials}
</div>
<span>{&card.assignee}</span>
</div>
<div class="card-stats">
<div class="d-flex gap-3">
{if card.attachments > 0 {
html! {
<div class="stat-item">
<i class="bi bi-paperclip"></i>
<span>{card.attachments}</span>
</div>
}
} else {
html! {}
}}
{if card.comments > 0 {
html! {
<div class="stat-item">
<i class="bi bi-chat-dots"></i>
<span>{card.comments}</span>
</div>
}
} else {
html! {}
}}
</div>
{if card.checklist.total > 0 {
html! {
<div class="checklist-progress">
<div class="progress-bar">
<div class="progress-fill" style={format!("width: {}%", progress_percentage)}></div>
</div>
<span class="progress-text">
{format!("{}/{}", card.checklist.completed, card.checklist.total)}
</span>
</div>
}
} else {
html! {}
}}
</div>
</div>
}
}
// Card Edit Modal Component
#[derive(Properties, PartialEq)]
pub struct CardEditModalProps {
pub card: KanbanCard,
pub on_save: Callback<KanbanCard>,
pub on_close: Callback<()>,
}
#[function_component(CardEditModal)]
pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
let card = use_state(|| props.card.clone());
let on_title_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.title = input.value();
card.set(updated_card);
})
};
let on_description_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let textarea: HtmlTextAreaElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.description = textarea.value();
card.set(updated_card);
})
};
let on_priority_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.priority = select.value();
card.set(updated_card);
})
};
let on_assignee_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.assignee = input.value();
card.set(updated_card);
})
};
let on_due_date_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.due_date = input.value();
card.set(updated_card);
})
};
let on_tags_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.tags = input.value()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
card.set(updated_card);
})
};
let on_save_click = {
let on_save = props.on_save.clone();
let card = card.clone();
Callback::from(move |_| {
on_save.emit((*card).clone());
})
};
let on_close_click = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let on_close_click_modal = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let tags_string = card.tags.join(", ");
html! {
<div class="modal-overlay" onclick={on_close_click_modal.clone()}>
<div class="modal-content" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
<div class="modal-header">
<h2>{"Edit Card"}</h2>
<button type="button" class="btn-close" onclick={on_close_click}>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="card-title">{"Title"}</label>
<input
type="text"
id="card-title"
class="form-control"
value={card.title.clone()}
onchange={on_title_change}
/>
</div>
<div class="form-group">
<label for="card-description">{"Description"}</label>
<textarea
id="card-description"
class="form-control"
rows="4"
value={card.description.clone()}
onchange={on_description_change}
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="card-priority">{"Priority"}</label>
<select
id="card-priority"
class="form-control"
value={card.priority.clone()}
onchange={on_priority_change}
>
<option value="low">{"Low"}</option>
<option value="medium">{"Medium"}</option>
<option value="high">{"High"}</option>
</select>
</div>
<div class="form-group">
<label for="card-due-date">{"Due Date"}</label>
<input
type="date"
id="card-due-date"
class="form-control"
value={card.due_date.clone()}
onchange={on_due_date_change}
/>
</div>
</div>
<div class="form-group">
<label for="card-assignee">{"Assignee"}</label>
<input
type="text"
id="card-assignee"
class="form-control"
value={card.assignee.clone()}
onchange={on_assignee_change}
/>
</div>
<div class="form-group">
<label for="card-tags">{"Tags (comma separated)"}</label>
<input
type="text"
id="card-tags"
class="form-control"
value={tags_string}
onchange={on_tags_change}
/>
</div>
<div class="form-row">
<div class="form-group">
<label>{"Attachments"}</label>
<div class="stat-display">
<i class="bi bi-paperclip me-2"></i>
{card.attachments}
</div>
</div>
<div class="form-group">
<label>{"Comments"}</label>
<div class="stat-display">
<i class="bi bi-chat-dots me-2"></i>
{card.comments}
</div>
</div>
</div>
<div class="form-group">
<label>{"Checklist Progress"}</label>
<div class="checklist-display">
<div class="progress-bar-large">
<div
class="progress-fill"
style={format!("width: {}%", if card.checklist.total > 0 {
(card.checklist.completed as f32 / card.checklist.total as f32 * 100.0) as u32
} else { 0 })}
></div>
</div>
<span class="progress-text-large">
{format!("{}/{} completed", card.checklist.completed, card.checklist.total)}
</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick={on_close_click_modal.clone()}>
{"Cancel"}
</button>
<button type="button" class="btn btn-primary" onclick={on_save_click}>
{"Save Changes"}
</button>
</div>
</div>
</div>
}
}