use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Move {
pub item: String,
pub from: String,
pub to: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DragEffect {
Moved(Move),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DragMsg {
Start(String),
HoverZone(String),
LeaveZone,
Drop,
Cancel,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DragDrop {
placement: BTreeMap<String, String>,
dragging: Option<String>,
hover: Option<String>,
moves: Vec<Move>,
}
impl DragDrop {
pub fn new() -> Self {
Self::default()
}
pub fn place(&mut self, item: impl Into<String>, zone: impl Into<String>) {
self.placement.insert(item.into(), zone.into());
}
pub fn zone_of(&self, item: &str) -> Option<&str> {
self.placement.get(item).map(String::as_str)
}
pub fn items_in(&self, zone: &str) -> Vec<&str> {
self.placement
.iter()
.filter(|(_, z)| z.as_str() == zone)
.map(|(i, _)| i.as_str())
.collect()
}
pub fn dragging(&self) -> Option<&str> {
self.dragging.as_deref()
}
pub fn hover(&self) -> Option<&str> {
self.hover.as_deref()
}
pub fn is_dragging(&self) -> bool {
self.dragging.is_some()
}
pub fn drain_moves(&mut self) -> Vec<Move> {
std::mem::take(&mut self.moves)
}
pub fn update(&mut self, msg: DragMsg) -> Vec<DragEffect> {
match msg {
DragMsg::Start(item) => {
if self.placement.contains_key(&item) {
self.dragging = Some(item);
self.hover = None;
}
Vec::new()
}
DragMsg::HoverZone(zone) => {
if self.dragging.is_some() {
self.hover = Some(zone);
}
Vec::new()
}
DragMsg::LeaveZone => {
self.hover = None;
Vec::new()
}
DragMsg::Drop => {
let effects = match (self.dragging.take(), self.hover.take()) {
(Some(item), Some(zone)) => {
let from = self.placement.get(&item).cloned().unwrap_or_default();
if from != zone {
self.placement.insert(item.clone(), zone.clone());
let mv = Move { item, from, to: zone };
self.moves.push(mv.clone());
vec![DragEffect::Moved(mv)]
} else {
Vec::new() }
}
_ => Vec::new(), };
effects
}
DragMsg::Cancel => {
self.dragging = None;
self.hover = None;
Vec::new()
}
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"placement": self.placement,
"dragging": self.dragging,
"hover": self.hover,
"pending_moves": self.moves,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> DragDrop {
let mut dd = DragDrop::new();
dd.place("SUPP-1", "active");
dd.place("SUPP-2", "active");
dd.place("SUPP-4", "triage");
dd
}
#[test]
fn a_full_drag_across_zones_commits_one_move() {
let mut dd = board();
assert!(dd.update(DragMsg::Start("SUPP-1".into())).is_empty());
assert!(dd.is_dragging());
assert!(dd.update(DragMsg::HoverZone("done".into())).is_empty());
let fx = dd.update(DragMsg::Drop);
assert_eq!(
fx,
vec![DragEffect::Moved(Move { item: "SUPP-1".into(), from: "active".into(), to: "done".into() })]
);
assert_eq!(dd.zone_of("SUPP-1"), Some("done"), "placement updated");
assert!(!dd.is_dragging(), "drag ended");
assert_eq!(dd.drain_moves().len(), 1);
assert!(dd.drain_moves().is_empty(), "second drain empty");
}
#[test]
fn dropping_back_on_the_home_zone_is_a_noop() {
let mut dd = board();
dd.update(DragMsg::Start("SUPP-1".into()));
dd.update(DragMsg::HoverZone("active".into())); assert!(dd.update(DragMsg::Drop).is_empty(), "no move when zone unchanged");
assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
assert!(dd.drain_moves().is_empty());
}
#[test]
fn cancel_abandons_the_drag_with_no_move() {
let mut dd = board();
dd.update(DragMsg::Start("SUPP-2".into()));
dd.update(DragMsg::HoverZone("done".into()));
assert!(dd.update(DragMsg::Cancel).is_empty());
assert!(!dd.is_dragging());
assert_eq!(dd.zone_of("SUPP-2"), Some("active"), "unchanged after cancel");
assert!(dd.drain_moves().is_empty());
}
#[test]
fn cannot_drag_an_unknown_item() {
let mut dd = board();
assert!(dd.update(DragMsg::Start("GHOST".into())).is_empty());
assert!(!dd.is_dragging(), "unknown item never starts a drag");
}
#[test]
fn hover_only_registers_mid_drag() {
let mut dd = board();
dd.update(DragMsg::HoverZone("done".into()));
assert_eq!(dd.hover(), None);
assert!(dd.update(DragMsg::Drop).is_empty());
}
#[test]
fn leave_zone_clears_hover_but_keeps_dragging() {
let mut dd = board();
dd.update(DragMsg::Start("SUPP-1".into()));
dd.update(DragMsg::HoverZone("done".into()));
dd.update(DragMsg::LeaveZone);
assert_eq!(dd.hover(), None);
assert!(dd.is_dragging(), "still dragging after leaving a zone");
assert!(dd.update(DragMsg::Drop).is_empty());
assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
}
#[test]
fn items_in_zone_is_sorted_and_placement_survives_roundtrip() {
let dd = board();
assert_eq!(dd.items_in("active"), vec!["SUPP-1", "SUPP-2"]);
assert_eq!(dd.items_in("triage"), vec!["SUPP-4"]);
let json = serde_json::to_string(&dd).unwrap();
let back: DragDrop = serde_json::from_str(&json).unwrap();
assert_eq!(dd, back, "serde round-trip is identity (FC-3)");
}
}