facett-kanban/src/lib.rs
//! **facett-kanban** — a Kanban board facet for ordning.
//!
//! Renders cases as draggable **cards** in **columns** (one per workflow status),
//! optionally grouped into **swimlanes** (by assignee/priority/kind), with
//! **WIP limits** per column. Dragging a card to another column is how a user
//! transitions a case's status; the move is recorded so the host can fire the
//! actual workflow transition (and validate it).
//!
//! Design: see `ordning/.nornir/widgets/facett-kanban.md`. Built in the ordning
//! repo temporarily; **destined for the facett repo** (it implements the facett
//! `Facet` contract — `title` / `ui` / `state_json` — and gains an `impl
//! facett_core::Facet` under the `facett` feature when it moves).
//!
//! Pure egui; no app framework. The host supplies columns + cards, calls
//! [`Board::ui`] each frame, and drains [`Board::take_moves`] to apply
//! transitions.
use egui::{Color32, Id, Sense, Stroke, Ui, Vec2};
use serde::{Deserialize, Serialize};
/// A board column — typically one workflow status.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Column {
/// Stable id (the status name/key).
pub id: String,
/// Display title.
pub title: String,
/// Optional work-in-progress cap. When the card count exceeds it, the
/// column header warns (the Kanban WIP-limit discipline).
pub wip_limit: Option<usize>,
}
impl Column {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self { id: id.into(), title: title.into(), wip_limit: None }
}
pub fn wip(mut self, n: usize) -> Self {
self.wip_limit = Some(n);
self
}
}
/// Priority shades a card's accent stripe. Maps onto ordning's P1..P5.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Priority {
P1,
P2,
P3,
#[default]
P4,
P5,
}
impl Priority {
fn color(self) -> Color32 {
match self {
Priority::P1 => Color32::from_rgb(220, 80, 80),
Priority::P2 => Color32::from_rgb(220, 140, 60),
Priority::P3 => Color32::from_rgb(220, 200, 70),
Priority::P4 => Color32::from_rgb(110, 160, 210),
Priority::P5 => Color32::from_rgb(130, 140, 160),
}
}
}
/// A card — one case on the board.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Card {
/// Stable id (the case key, e.g. "SUPP-1423" or a diarienummer).
pub id: String,
pub title: String,
/// Which column it's in (must match a [`Column::id`]).
pub column: String,
/// Optional swimlane key (assignee / priority / kind). `None` = single lane.
pub swimlane: Option<String>,
pub assignee: Option<String>,
pub priority: Priority,
pub tags: Vec<String>,
}
impl Card {
pub fn new(id: impl Into<String>, title: impl Into<String>, column: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
column: column.into(),
swimlane: None,
assignee: None,
priority: Priority::default(),
tags: Vec::new(),
}
}
}
/// A recorded drag-move: the host applies it as a workflow transition.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Move {
pub card: String,
pub from: String,
pub to: String,
}
/// The Kanban board facet.
pub struct Board {
title: String,
columns: Vec<Column>,
cards: Vec<Card>,
/// Swimlane grouping: `None` = one lane; `Some` lists the lane order.
swimlanes: Option<Vec<String>>,
/// Moves produced by drags since the last [`take_moves`](Board::take_moves).
pending: Vec<Move>,
/// Selected card (for keyboard/inspect); folded into `state_json`.
selected: Option<String>,
}
/// The drag payload egui carries between a card (source) and a column (target).
#[derive(Clone)]
struct DragCard(String);
impl Board {
pub fn new(title: impl Into<String>, columns: Vec<Column>) -> Self {
Self {
title: title.into(),
columns,
cards: Vec::new(),
swimlanes: None,
pending: Vec::new(),
selected: None,
}
}
/// Replace the cards (call when the underlying data changes).
pub fn set_cards(&mut self, cards: Vec<Card>) {
self.cards = cards;
}
/// Enable swimlanes in the given row order (lane key → a row of columns).
pub fn with_swimlanes(mut self, lanes: Vec<String>) -> Self {
self.swimlanes = Some(lanes);
self
}
/// Drain the moves produced by drags since last call. The host applies each
/// as a status transition (and may reject it — then re-`set_cards`).
pub fn take_moves(&mut self) -> Vec<Move> {
std::mem::take(&mut self.pending)
}
/// The currently selected card id, if any.
pub fn selected(&self) -> Option<&str> {
self.selected.as_deref()
}
/// Select a card **by id** — the headless/robot counterpart of clicking it.
/// Returns `false` for an unknown id (no silent selection of nothing).
pub fn select_id(&mut self, id: &str) -> bool {
if self.cards.iter().any(|c| c.id == id) {
self.selected = Some(id.to_string());
true
} else {
false
}
}
/// Clear the card selection.
pub fn clear_selection(&mut self) {
self.selected = None;
}
/// Move a card to another column — the headless/robot counterpart of the
/// drag-and-drop gesture, queueing the same [`Move`] the drop does so
/// `take_moves` sees an identical payload. Returns `false` when either the
/// card or the target column is unknown, or the card is already there.
pub fn move_card(&mut self, card_id: &str, to_column: &str) -> bool {
if !self.columns.iter().any(|c| c.id == to_column) {
return false;
}
let Some(card) = self.cards.iter_mut().find(|c| c.id == card_id) else { return false };
if card.column == to_column {
return false;
}
let from = std::mem::replace(&mut card.column, to_column.to_string());
self.pending.push(Move { card: card_id.to_string(), from, to: to_column.to_string() });
true
}
/// The **shared demo fixture** — a board with a WIP-breaching column (`active`
/// holds 3 cards over a WIP of 2, all in one lane so the *per-lane* breach
/// banner paints) and two swimlanes. Consumed by the robot and matrix tests
/// (L5: one fixture, not one per test file).
pub fn local() -> Self {
fn card(id: &str, title: &str, col: &str, lane: &str) -> Card {
let mut c = Card::new(id, title, col);
c.swimlane = Some(lane.to_string());
c
}
let mut b = Self::new(
"Support",
vec![
Column::new("triage", "Triage"),
Column::new("active", "In Progress").wip(2),
Column::new("done", "Done"),
],
)
.with_swimlanes(vec!["hugin".into(), "munin".into()]);
b.set_cards(vec![
card("SUPP-1", "Login loop", "active", "hugin"),
card("SUPP-2", "Export fails", "active", "hugin"),
card("SUPP-3", "Slow report", "active", "hugin"),
card("SUPP-4", "Typo", "triage", "munin"),
]);
b
}
fn count_in(&self, column: &str, lane: Option<&str>) -> usize {
self.cards
.iter()
.filter(|c| c.column == column && lane.is_none_or(|l| c.swimlane.as_deref() == Some(l)))
.count()
}
// ── facett Facet contract ────────────────────────────────────────────────
pub fn title(&self) -> &str {
&self.title
}
/// The full board state as JSON — the headless-observable surface (the
/// facett `state_json` contract + the `$FACETT_TRACE` discipline): every
/// column with its count and WIP-breach flag, the cards per column, the
/// selection, and pending moves. Lets a test assert what the board shows
/// without a display.
pub fn state_json(&self) -> serde_json::Value {
let columns: Vec<_> = self
.columns
.iter()
.map(|col| {
let count = self.count_in(&col.id, None);
serde_json::json!({
"id": col.id,
"title": col.title,
"count": count,
"wip_limit": col.wip_limit,
"wip_breach": col.wip_limit.is_some_and(|n| count > n),
"cards": self.cards.iter()
.filter(|c| c.column == col.id)
.map(|c| c.id.clone())
.collect::<Vec<_>>(),
})
})
.collect();
serde_json::json!({
"title": self.title,
"columns": columns,
"swimlanes": self.swimlanes,
"card_count": self.cards.len(),
"selected": self.selected,
"pending_moves": self.pending,
})
}
/// Paint the board for one frame. Drags that land in a different column are
/// recorded in `pending` (drain with [`take_moves`](Board::take_moves)).
pub fn ui(&mut self, ui: &mut Ui) {
ui.heading(&self.title);
ui.separator();
// Collect lane keys to render: explicit order, else a single anonymous lane.
let lanes: Vec<Option<String>> = match &self.swimlanes {
Some(order) => order.iter().cloned().map(Some).collect(),
None => vec![None],
};
egui::ScrollArea::both().show(ui, |ui| {
for lane in &lanes {
if let Some(lane_key) = lane {
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("⮡ {lane_key}")).strong());
}
ui.horizontal_top(|ui| {
// Clone the column list to avoid borrowing self while we mutate cards.
let cols = self.columns.clone();
for col in &cols {
self.column_ui(ui, col, lane.as_deref());
}
});
ui.add_space(8.0);
}
});
}
fn column_ui(&mut self, ui: &mut Ui, col: &Column, lane: Option<&str>) {
let count = self.count_in(&col.id, lane);
let breach = col.wip_limit.is_some_and(|n| count > n);
ui.allocate_ui(Vec2::new(240.0, ui.available_height().max(120.0)), |ui| {
let frame = egui::Frame::group(ui.style()).fill(Color32::from_rgb(28, 30, 36));
frame.show(ui, |ui| {
ui.set_min_width(220.0);
// Header: title + count / WIP limit (red on breach).
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&col.title).strong());
let label = match col.wip_limit {
Some(limit) => format!("{count}/{limit}"),
None => format!("{count}"),
};
let color = if breach { Color32::from_rgb(220, 80, 80) } else { Color32::GRAY };
ui.label(egui::RichText::new(label).color(color));
});
if breach {
ui.label(
egui::RichText::new("⚠ over WIP limit")
.small()
.color(Color32::from_rgb(220, 120, 120)),
);
}
ui.separator();
// The column is a drop zone; cards inside are drag sources.
let drop = ui.dnd_drop_zone::<DragCard, ()>(egui::Frame::default(), |ui| {
ui.set_min_height(80.0);
let ids: Vec<String> = self
.cards
.iter()
.filter(|c| {
c.column == col.id
&& lane.is_none_or(|l| c.swimlane.as_deref() == Some(l))
})
.map(|c| c.id.clone())
.collect();
for id in ids {
self.card_ui(ui, &id);
}
});
// A card was dropped here → record a move if the column changed.
if let Some(payload) = drop.1 {
let card_id = payload.0.clone();
if let Some(card) = self.cards.iter_mut().find(|c| c.id == card_id) && card.column != col.id {
let from = std::mem::replace(&mut card.column, col.id.clone());
self.pending.push(Move { card: card_id, from, to: col.id.clone() });
}
}
});
});
}
fn card_ui(&mut self, ui: &mut Ui, card_id: &str) {
let Some(card) = self.cards.iter().find(|c| c.id == card_id).cloned() else { return };
let selected = self.selected.as_deref() == Some(card_id);
let id = Id::new(("kanban-card", card_id));
let resp = ui
.dnd_drag_source(id, DragCard(card.id.clone()), |ui| {
let fill = if selected {
Color32::from_rgb(50, 54, 64)
} else {
Color32::from_rgb(38, 41, 49)
};
egui::Frame::default()
.fill(fill)
.inner_margin(6.0)
.stroke(Stroke::new(1.0_f32, card.priority.color()))
.show(ui, |ui| {
ui.set_width(196.0);
ui.label(
egui::RichText::new(&card.id)
.small()
.color(Color32::from_rgb(150, 160, 180)),
);
ui.label(egui::RichText::new(&card.title).size(13.0));
ui.horizontal_wrapped(|ui| {
if let Some(a) = &card.assignee {
ui.label(egui::RichText::new(format!("👤 {a}")).small());
}
for tag in &card.tags {
ui.label(
egui::RichText::new(format!("#{tag}"))
.small()
.color(Color32::from_rgb(120, 170, 140)),
);
}
});
});
})
.response;
if resp.interact(Sense::click()).clicked() {
self.selected = Some(card.id.clone());
}
}
}
// ── The host-facing `Facet` contract ─────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// thin adapter makes `Board` an object-safe `dyn Facet` so it composes into a
// `FacetDeck` and can be handed to `facett_core::testmatrix::probe_and_emit` for
// its robot matrix cell in ONE call.
impl facett_core::Facet for Board {
fn title(&self) -> &str {
Board::title(self)
}
fn ui(&mut self, ui: &mut Ui) {
Board::ui(self, ui)
}
fn state_json(&self) -> serde_json::Value {
Board::state_json(self)
}
/// The **robot drive seam**: `{"select":"SUPP-1"}` selects a card (the
/// headless twin of clicking it), `{"move":{"card":"SUPP-1","to":"done"}}` is
/// the headless twin of the drag-and-drop (it queues the SAME `Move` the drop
/// does), `"clear_selection"` drops the selection.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if v == serde_json::json!("clear_selection") {
return self.clear_selection();
}
if let Some(id) = v.get("select").and_then(|s| s.as_str()) {
self.select_id(id);
return;
}
if let Some(m) = v.get("move")
&& let (Some(card), Some(to)) =
(m.get("card").and_then(|c| c.as_str()), m.get("to").and_then(|c| c.as_str()))
{
self.move_card(card, to);
}
}
fn selection_json(&self) -> serde_json::Value {
match self.selected() {
Some(id) => serde_json::json!(id),
None => serde_json::Value::Null,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> Board {
let mut b = Board::new(
"Support",
vec![
Column::new("triage", "Triage"),
Column::new("active", "In Progress").wip(2),
Column::new("done", "Done"),
],
);
b.set_cards(vec![
Card::new("SUPP-1", "Login loop", "active"),
Card::new("SUPP-2", "Export fails", "active"),
Card::new("SUPP-3", "Slow report", "active"), // pushes "active" over its WIP=2
Card::new("SUPP-4", "Typo", "triage"),
]);
b
}
#[test]
fn state_json_reports_counts_and_wip_breach() {
let b = board();
let s = b.state_json();
let cols = s["columns"].as_array().unwrap();
let active = cols.iter().find(|c| c["id"] == "active").unwrap();
assert_eq!(active["count"], 3);
assert_eq!(active["wip_limit"], 2);
assert_eq!(active["wip_breach"], true, "3 cards over a WIP of 2 must breach");
let triage = cols.iter().find(|c| c["id"] == "triage").unwrap();
assert_eq!(triage["wip_breach"], false);
assert_eq!(s["card_count"], 4);
}
#[test]
fn count_in_filters_by_column_and_lane() {
let b = board();
assert_eq!(b.count_in("active", None), 3);
assert_eq!(b.count_in("triage", None), 1);
assert_eq!(b.count_in("done", None), 0);
}
#[test]
fn take_moves_drains() {
let mut b = board();
b.pending.push(Move { card: "SUPP-1".into(), from: "active".into(), to: "done".into() });
assert_eq!(b.take_moves().len(), 1);
assert!(b.take_moves().is_empty(), "second drain is empty");
}
}