Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}