1use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Move {
24 pub item: String,
25 pub from: String,
26 pub to: String,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum DragEffect {
33 Moved(Move),
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum DragMsg {
41 Start(String),
43 HoverZone(String),
45 LeaveZone,
47 Drop,
50 Cancel,
52}
53
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct DragDrop {
59 placement: BTreeMap<String, String>,
61 dragging: Option<String>,
63 hover: Option<String>,
65 moves: Vec<Move>,
67}
68
69impl DragDrop {
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn place(&mut self, item: impl Into<String>, zone: impl Into<String>) {
77 self.placement.insert(item.into(), zone.into());
78 }
79
80 pub fn zone_of(&self, item: &str) -> Option<&str> {
82 self.placement.get(item).map(String::as_str)
83 }
84
85 pub fn items_in(&self, zone: &str) -> Vec<&str> {
87 self.placement
88 .iter()
89 .filter(|(_, z)| z.as_str() == zone)
90 .map(|(i, _)| i.as_str())
91 .collect()
92 }
93
94 pub fn dragging(&self) -> Option<&str> {
96 self.dragging.as_deref()
97 }
98
99 pub fn hover(&self) -> Option<&str> {
101 self.hover.as_deref()
102 }
103
104 pub fn is_dragging(&self) -> bool {
106 self.dragging.is_some()
107 }
108
109 pub fn drain_moves(&mut self) -> Vec<Move> {
112 std::mem::take(&mut self.moves)
113 }
114
115 pub fn update(&mut self, msg: DragMsg) -> Vec<DragEffect> {
118 match msg {
119 DragMsg::Start(item) => {
120 if self.placement.contains_key(&item) {
122 self.dragging = Some(item);
123 self.hover = None;
124 }
125 Vec::new()
126 }
127 DragMsg::HoverZone(zone) => {
128 if self.dragging.is_some() {
129 self.hover = Some(zone);
130 }
131 Vec::new()
132 }
133 DragMsg::LeaveZone => {
134 self.hover = None;
135 Vec::new()
136 }
137 DragMsg::Drop => {
138 let effects = match (self.dragging.take(), self.hover.take()) {
139 (Some(item), Some(zone)) => {
140 let from = self.placement.get(&item).cloned().unwrap_or_default();
141 if from != zone {
142 self.placement.insert(item.clone(), zone.clone());
143 let mv = Move { item, from, to: zone };
144 self.moves.push(mv.clone());
145 vec![DragEffect::Moved(mv)]
146 } else {
147 Vec::new() }
149 }
150 _ => Vec::new(), };
152 effects
153 }
154 DragMsg::Cancel => {
155 self.dragging = None;
156 self.hover = None;
157 Vec::new()
158 }
159 }
160 }
161
162 pub fn state_json(&self) -> serde_json::Value {
166 serde_json::json!({
167 "placement": self.placement,
168 "dragging": self.dragging,
169 "hover": self.hover,
170 "pending_moves": self.moves,
171 })
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn board() -> DragDrop {
180 let mut dd = DragDrop::new();
181 dd.place("SUPP-1", "active");
182 dd.place("SUPP-2", "active");
183 dd.place("SUPP-4", "triage");
184 dd
185 }
186
187 #[test]
188 fn a_full_drag_across_zones_commits_one_move() {
189 let mut dd = board();
190 assert!(dd.update(DragMsg::Start("SUPP-1".into())).is_empty());
191 assert!(dd.is_dragging());
192 assert!(dd.update(DragMsg::HoverZone("done".into())).is_empty());
193 let fx = dd.update(DragMsg::Drop);
194 assert_eq!(
195 fx,
196 vec![DragEffect::Moved(Move { item: "SUPP-1".into(), from: "active".into(), to: "done".into() })]
197 );
198 assert_eq!(dd.zone_of("SUPP-1"), Some("done"), "placement updated");
199 assert!(!dd.is_dragging(), "drag ended");
200 assert_eq!(dd.drain_moves().len(), 1);
201 assert!(dd.drain_moves().is_empty(), "second drain empty");
202 }
203
204 #[test]
205 fn dropping_back_on_the_home_zone_is_a_noop() {
206 let mut dd = board();
207 dd.update(DragMsg::Start("SUPP-1".into()));
208 dd.update(DragMsg::HoverZone("active".into())); assert!(dd.update(DragMsg::Drop).is_empty(), "no move when zone unchanged");
210 assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
211 assert!(dd.drain_moves().is_empty());
212 }
213
214 #[test]
215 fn cancel_abandons_the_drag_with_no_move() {
216 let mut dd = board();
217 dd.update(DragMsg::Start("SUPP-2".into()));
218 dd.update(DragMsg::HoverZone("done".into()));
219 assert!(dd.update(DragMsg::Cancel).is_empty());
220 assert!(!dd.is_dragging());
221 assert_eq!(dd.zone_of("SUPP-2"), Some("active"), "unchanged after cancel");
222 assert!(dd.drain_moves().is_empty());
223 }
224
225 #[test]
226 fn cannot_drag_an_unknown_item() {
227 let mut dd = board();
228 assert!(dd.update(DragMsg::Start("GHOST".into())).is_empty());
229 assert!(!dd.is_dragging(), "unknown item never starts a drag");
230 }
231
232 #[test]
233 fn hover_only_registers_mid_drag() {
234 let mut dd = board();
235 dd.update(DragMsg::HoverZone("done".into()));
237 assert_eq!(dd.hover(), None);
238 assert!(dd.update(DragMsg::Drop).is_empty());
240 }
241
242 #[test]
243 fn leave_zone_clears_hover_but_keeps_dragging() {
244 let mut dd = board();
245 dd.update(DragMsg::Start("SUPP-1".into()));
246 dd.update(DragMsg::HoverZone("done".into()));
247 dd.update(DragMsg::LeaveZone);
248 assert_eq!(dd.hover(), None);
249 assert!(dd.is_dragging(), "still dragging after leaving a zone");
250 assert!(dd.update(DragMsg::Drop).is_empty());
252 assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
253 }
254
255 #[test]
256 fn items_in_zone_is_sorted_and_placement_survives_roundtrip() {
257 let dd = board();
258 assert_eq!(dd.items_in("active"), vec!["SUPP-1", "SUPP-2"]);
259 assert_eq!(dd.items_in("triage"), vec!["SUPP-4"]);
260 let json = serde_json::to_string(&dd).unwrap();
261 let back: DragDrop = serde_json::from_str(&json).unwrap();
262 assert_eq!(dd, back, "serde round-trip is identity (FC-3)");
263 }
264}