use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use futures_util::{stream, StreamExt}; use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_axum::{ interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection, EffectResponse, InteractionRequest, PageRequest, PageResponse, }; use hemx_kanban_example::ui::board::{self as board}; use hemx_kanban_example::ui::board_card as card_board; use hemx_kanban_example::ui::{self, board as board_ui}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::convert::Infallible; use std::fs::{self, OpenOptions}; use std::io::Write; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")]; #[derive(Default)] struct AppState { board: Mutex, sync: Mutex, sync_store: Option, sync_sessions: SyncSessionTokens, } #[derive(Default)] struct SyncSessionTokens { alice_alpha_editor: Option, bob_alpha_viewer: Option, carol_beta_editor: Option, } impl SyncSessionTokens { fn from_env() -> Self { fn token(name: &str) -> Option { std::env::var(name).ok().filter(|value| !value.is_empty()) } Self { alice_alpha_editor: token("HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR"), bob_alpha_viewer: token("HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER"), carol_beta_editor: token("HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR"), } } fn is_configured(&self) -> bool { self.alice_alpha_editor.is_some() || self.bob_alpha_viewer.is_some() || self.carol_beta_editor.is_some() } fn matches(expected: &Option, candidate: &str) -> bool { let Some(expected) = expected else { return false; }; if expected.len() != candidate.len() { return false; } expected .bytes() .zip(candidate.bytes()) .fold(0_u8, |difference, (left, right)| { difference | (left ^ right) }) == 0 } } #[derive(Clone)] struct SyncStore(PathBuf); #[derive(Default)] struct SyncState { next_sequence: u64, acknowledgements: BTreeMap, retained_after: u64, reconnects: BTreeMap, transient_failure_limit: u8, transient_failures: BTreeMap, } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct CommandId(String); impl CommandId { fn parse(value: Option<&String>) -> Result { Self::parse_str(value.map(String::as_str).unwrap_or_default()) } fn parse_str(value: &str) -> Result { if value.is_empty() || value.len() > 128 || !value.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.') }) { return Err(SyncRejection::BadRequest("invalid command_id")); } Ok(Self(value.to_owned())) } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] enum CanonicalColumn { Todo, Doing, Done, } impl CanonicalColumn { fn parse(value: Option<&String>) -> Result { match value.map(String::as_str).unwrap_or("done") { "todo" => Ok(Self::Todo), "doing" => Ok(Self::Doing), "done" => Ok(Self::Done), _ => Err(SyncRejection::BadRequest("invalid column")), } } fn index(self) -> usize { match self { Self::Todo => 0, Self::Doing => 1, Self::Done => 2, } } } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct SyncAcknowledgement { command_id: String, server_sequence: u64, card_id: u64, canonical_column: CanonicalColumn, status: &'static str, #[serde(skip_serializing)] tenant: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SyncSnapshot { schema_version: u8, server_sequence: u64, cards: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SnapshotCard { id: u64, column: CanonicalColumn, } #[derive(Debug)] enum SyncRejection { BadRequest(&'static str), Unauthorized(&'static str), Forbidden(&'static str), Conflict(&'static str), Transient, Storage, } impl IntoResponse for SyncRejection { fn into_response(self) -> axum::response::Response { let (status, kind, error) = match self { Self::BadRequest(error) => (StatusCode::BAD_REQUEST, "invalid-command", error), Self::Unauthorized(error) => (StatusCode::UNAUTHORIZED, "authorization-denial", error), Self::Forbidden(error) => (StatusCode::FORBIDDEN, "authorization-denial", error), Self::Conflict(error) => (StatusCode::CONFLICT, "command-conflict", error), Self::Transient => ( StatusCode::SERVICE_UNAVAILABLE, "transport-failure", "transient sync failure", ), Self::Storage => ( StatusCode::INTERNAL_SERVER_ERROR, "storage-failure", "sync storage failed", ), }; ( status, Json(serde_json::json!({ "kind": kind, "error": error })), ) .into_response() } } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct PersistedSync { schema_version: u8, next_sequence: u64, acknowledgements: Vec, } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct PersistedAcknowledgement { command_id: String, server_sequence: u64, card_id: u64, canonical_column: CanonicalColumn, #[serde(default)] tenant: Option, } impl SyncStore { fn load(&self) -> Result { if !self.0.exists() { return Ok(SyncState { next_sequence: 1, ..SyncState::default() }); } let bytes = fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?; let persisted: PersistedSync = serde_json::from_slice(&bytes) .map_err(|error| format!("decode {}: {error}", self.0.display()))?; if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 { return Err(format!("unsupported sync store {}", self.0.display())); } let mut acknowledgements = BTreeMap::new(); for stored in persisted.acknowledgements { let command_id = CommandId::parse_str(&stored.command_id) .map_err(|_| format!("invalid command id in {}", self.0.display()))?; if stored.server_sequence == 0 || stored.card_id == 0 { return Err(format!("invalid acknowledgement in {}", self.0.display())); } let tenant = if persisted.schema_version == 1 { "demo".to_owned() } else { stored .tenant .filter(|tenant| matches!(tenant.as_str(), "demo" | "alpha" | "beta")) .ok_or_else(|| { format!("invalid acknowledgement tenant in {}", self.0.display()) })? }; let acknowledgement = SyncAcknowledgement { command_id: stored.command_id, server_sequence: stored.server_sequence, card_id: stored.card_id, canonical_column: stored.canonical_column, status: "accepted", tenant, }; if acknowledgements .insert(command_id, acknowledgement) .is_some() { return Err(format!("duplicate command id in {}", self.0.display())); } } Ok(SyncState { next_sequence: persisted.next_sequence, acknowledgements, ..SyncState::default() }) } fn persist( &self, sync: &SyncState, acknowledgement: &SyncAcknowledgement, ) -> Result<(), String> { let mut acknowledgements = sync .acknowledgements .values() .map(PersistedAcknowledgement::from) .collect::>(); acknowledgements.push(PersistedAcknowledgement::from(acknowledgement)); acknowledgements.sort_by_key(|item| item.server_sequence); let persisted = PersistedSync { schema_version: 2, next_sequence: sync.next_sequence + 1, acknowledgements, }; let bytes = serde_json::to_vec_pretty(&persisted) .map_err(|error| format!("encode {}: {error}", self.0.display()))?; if let Some(parent) = self.0.parent() { fs::create_dir_all(parent) .map_err(|error| format!("create {}: {error}", parent.display()))?; } let temporary = self.0.with_extension("tmp"); let mut file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&temporary) .map_err(|error| format!("open {}: {error}", temporary.display()))?; file.write_all(&bytes) .and_then(|()| file.sync_all()) .map_err(|error| format!("write {}: {error}", temporary.display()))?; fs::rename(&temporary, &self.0) .map_err(|error| format!("replace {}: {error}", self.0.display()))?; if let Some(parent) = self.0.parent() { fs::File::open(parent) .and_then(|directory| directory.sync_all()) .map_err(|error| format!("sync {}: {error}", parent.display()))?; } Ok(()) } } impl From<&SyncAcknowledgement> for PersistedAcknowledgement { fn from(value: &SyncAcknowledgement) -> Self { Self { command_id: value.command_id.clone(), server_sequence: value.server_sequence, card_id: value.card_id, canonical_column: value.canonical_column, tenant: Some(value.tenant.clone()), } } } #[derive(Default, Clone)] struct BoardState { next_id: u64, cards: Vec, } #[derive(Clone)] struct Card { id: u64, title: String, column: usize, } #[derive(Hemplate)] struct AppShell { runtime_src: &'static str, body: Html, } #[derive(Hemplate)] struct SyncShell { runtime_src: &'static str, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardColumns { columns: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardColumn { title: &'static str, cards: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardCard { id: u64, title: String, left_disabled: bool, right_disabled: bool, } #[derive(Hemplate)] struct Board { options: Html, board: Html, } #[derive(Hemplate)] #[hemplate = "partials"] struct ColumnOptions { options: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct ColumnOption { id: &'static str, title: &'static str, } #[derive(Hemplate)] #[hemplate = "partials"] struct Presence { count: u64, } #[tokio::main] async fn main() { let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE") .map(PathBuf::from) .map(SyncStore); let mut sync = sync_store .as_ref() .map(SyncStore::load) .transpose() .unwrap_or_else(|error| panic!("cannot start with sync store: {error}")) .unwrap_or_else(|| SyncState { next_sequence: 1, ..SyncState::default() }); sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or_default(); sync.transient_failure_limit = std::env::var("HEMX_KANBAN_SYNC_FAILURES") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or_else(|| u8::from(std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some())); let mut board = initial_board(); for acknowledgement in sync.acknowledgements.values() { if let Some(card) = board .cards .iter_mut() .find(|card| card.id == acknowledgement.card_id) { card.column = acknowledgement.canonical_column.index(); } } let state = Arc::new(AppState { board: Mutex::new(board), sync: Mutex::new(sync), sync_store, sync_sessions: SyncSessionTokens::from_env(), }); let app = Router::new() .route("/", get(home).post(interact)) .route("/events", get(events)) .route("/sync-demo", get(sync_demo)) .route("/sync.js", get(sync_js)) .route("/sync/context", get(sync_context)) .route("/sync/commands", post(sync_command)) .route("/sync/acknowledgements", get(sync_acknowledgements)) .route("/sync/snapshot", get(sync_snapshot)) .route(runtime_js_path(), get(runtime)) .with_state(state); let addr = std::env::var("HEMX_KANBAN_ADDR") .map(|value| value.parse::().expect("valid HEMX_KANBAN_ADDR")) .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3001))); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); println!("hemx Kanban example: http://{addr}"); axum::serve(listener, app).await.unwrap(); } fn initial_board() -> BoardState { BoardState { next_id: 4, cards: vec![ Card { id: 1, title: "Write requirements".into(), column: 0, }, Card { id: 2, title: "Build browser example".into(), column: 1, }, Card { id: 3, title: "Verify with HTTP".into(), column: 2, }, ], } } // req: examples/001 req: component/003 async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { let board = state.board.lock().unwrap().clone(); request .page_html(page_html(&board), shell) .title("hemx Kanban") .fingerprint(ui::BUILD_FINGERPRINT) } async fn runtime() -> impl IntoResponse { runtime_js() } async fn sync_demo() -> impl IntoResponse { PageResponse::full( ui::page(&SyncShell { runtime_src: runtime_js_path(), }) .into_string(), ) .title("hemx Kanban sync") .fingerprint(ui::BUILD_FINGERPRINT) } async fn sync_js() -> impl IntoResponse { ( [("content-type", "text/javascript; charset=utf-8")], include_str!("../static/sync.js"), ) } async fn interact( State(state): State>, request: InteractionRequest, ) -> Result { request.dispatch(registry(state)) } // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { let effect = board::presence.put(&Presence { count: 1 }); return sse(stream::iter([Ok::<_, Infallible>( effect.into_batch(ui::BUILD_FINGERPRINT), )]) .boxed()); } let batches = stream::unfold(1_u64, |count| async move { tokio::time::sleep(Duration::from_secs(4)).await; let effect = board::presence.put(&Presence { count }); Some(( Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1, )) }) .boxed(); sse(batches) } #[derive(Clone, Copy)] struct CurrentSyncPrincipal { principal: &'static str, tenant: &'static str, can_replay: bool, } fn current_sync_principal( headers: &HeaderMap, sessions: &SyncSessionTokens, ) -> Result { let session = headers .get(axum::http::header::COOKIE) .and_then(|value| value.to_str().ok()) .and_then(|cookies| { cookies.split(';').find_map(|cookie| { cookie .trim() .strip_prefix("hemx_kanban_session=") .map(str::trim) }) }); let Some(session) = session else { if sessions.is_configured() { return Err(SyncRejection::Unauthorized( "current sync session is required", )); } return Ok(CurrentSyncPrincipal { principal: "demo", tenant: "demo", can_replay: true, }); }; if SyncSessionTokens::matches(&sessions.alice_alpha_editor, session) { return Ok(CurrentSyncPrincipal { principal: "alice", tenant: "alpha", can_replay: true, }); } if SyncSessionTokens::matches(&sessions.bob_alpha_viewer, session) { return Ok(CurrentSyncPrincipal { principal: "bob", tenant: "alpha", can_replay: false, }); } if SyncSessionTokens::matches(&sessions.carol_beta_editor, session) { return Ok(CurrentSyncPrincipal { principal: "carol", tenant: "beta", can_replay: true, }); } Err(SyncRejection::Unauthorized( "current sync session is invalid", )) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SyncContext { account_partition: String, } // req: sync/020 req: auth/005 async fn sync_context( State(state): State>, headers: HeaderMap, ) -> Result, SyncRejection> { let principal = current_sync_principal(&headers, &state.sync_sessions)?; Ok(Json(SyncContext { account_partition: format!("{}:{}", principal.tenant, principal.principal), })) } fn visible_card(principal: CurrentSyncPrincipal, card_id: u64) -> bool { principal.tenant == "demo" || (principal.tenant == "beta" && card_id == 2) || (principal.tenant == "alpha" && card_id != 2) } fn visible_acknowledgement( principal: CurrentSyncPrincipal, acknowledgement: &SyncAcknowledgement, ) -> bool { principal.tenant == "demo" || acknowledgement.tenant == principal.tenant } fn authorize_sync_replay( principal: CurrentSyncPrincipal, card_id: u64, ) -> Result<(), SyncRejection> { if principal.principal == "demo" && principal.tenant == "demo" { return Ok(()); } if !principal.can_replay { return Err(SyncRejection::Forbidden( "current principal cannot replay commands", )); } let card_tenant = if card_id == 2 { "beta" } else { "alpha" }; if principal.tenant != card_tenant { return Err(SyncRejection::Forbidden( "current tenant cannot access command target", )); } Ok(()) } // req: sync/001 req: sync/008 req: sync/012 req: sync/019 req: security/004 async fn sync_command( State(state): State>, headers: HeaderMap, Query(params): Query>, ) -> Result, SyncRejection> { let command_id = CommandId::parse(params.get("command_id"))?; let card_id = params .get("card_id") .and_then(|value| value.parse::().ok()) .filter(|value| *value > 0) .ok_or(SyncRejection::BadRequest("invalid card_id"))?; let canonical_column = CanonicalColumn::parse(params.get("column"))?; let principal = current_sync_principal(&headers, &state.sync_sessions)?; authorize_sync_replay(principal, card_id)?; let mut sync = state.sync.lock().unwrap(); if let Some(existing) = sync.acknowledgements.get(&command_id) { if !visible_acknowledgement(principal, existing) { return Err(SyncRejection::Forbidden( "current tenant cannot access command acknowledgement", )); } if existing.card_id != card_id || existing.canonical_column != canonical_column { return Err(SyncRejection::Conflict( "command_id was already used for a different payload", )); } return Ok(Json(existing.clone())); } let transient_failure_limit = sync.transient_failure_limit; if transient_failure_limit > 0 { let failures = sync .transient_failures .entry(command_id.clone()) .or_default(); if *failures < transient_failure_limit { *failures += 1; return Err(SyncRejection::Transient); } } let mut board = state.board.lock().unwrap(); let card_index = board .cards .iter() .position(|card| card.id == card_id) .ok_or(SyncRejection::BadRequest("unknown card_id"))?; let server_sequence = sync .acknowledgements .values() .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) .map(|acknowledgement| acknowledgement.server_sequence) .max() .unwrap_or_default() + 1; let acknowledgement = SyncAcknowledgement { command_id: command_id.0.clone(), server_sequence, card_id, canonical_column, status: "accepted", tenant: principal.tenant.to_owned(), }; if let Some(store) = &state.sync_store { store.persist(&sync, &acknowledgement).map_err(|error| { eprintln!("sync persistence failed: {error}"); SyncRejection::Storage })?; } board.cards[card_index].column = canonical_column.index(); sync.next_sequence += 1; sync.acknowledgements .insert(command_id, acknowledgement.clone()); Ok(Json(acknowledgement)) } // req: sync/007 req: sync/020 req: auth/005 req: security/004 async fn sync_snapshot( State(state): State>, headers: HeaderMap, ) -> Result, SyncRejection> { let principal = current_sync_principal(&headers, &state.sync_sessions)?; let board = state.board.lock().unwrap(); let sync = state.sync.lock().unwrap(); let cards = board .cards .iter() .filter(|card| visible_card(principal, card.id)) .map(|card| SnapshotCard { id: card.id, column: canonical_column(card.column), }) .collect(); let server_sequence = sync .acknowledgements .values() .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) .map(|acknowledgement| acknowledgement.server_sequence) .max() .unwrap_or_default(); Ok(Json(SyncSnapshot { schema_version: 1, server_sequence, cards, })) } fn canonical_column(column: usize) -> CanonicalColumn { match column { 1 => CanonicalColumn::Doing, 2 => CanonicalColumn::Done, _ => CanonicalColumn::Todo, } } // req: sync/005 req: sync/006 req: sync/007 req: sync/013 req: auth/005 req: security/004 async fn sync_acknowledgements( State(state): State>, headers: HeaderMap, Query(params): Query>, ) -> Result>>, SyncRejection> { let principal = current_sync_principal(&headers, &state.sync_sessions)?; let after = params .get("after") .and_then(|value| value.parse::().ok()) .unwrap_or_default(); let reconnect_key = params .get("reconnect") .filter(|value| !value.is_empty()) .cloned(); let mut sync = state.sync.lock().unwrap(); if let Some(key) = reconnect_key { let attempts = sync.reconnects.entry(key).or_default(); *attempts += 1; if *attempts == 1 { return Ok(Sse::new( stream::iter([Ok(Event::default() .comment("reconnect") .retry(Duration::from_millis(25)))]) .boxed(), ) .keep_alive(KeepAlive::default())); } } let first_available = sync .acknowledgements .values() .filter(|acknowledgement| { visible_acknowledgement(principal, acknowledgement) && acknowledgement.server_sequence > sync.retained_after }) .map(|acknowledgement| acknowledgement.server_sequence) .min(); let latest = sync .acknowledgements .values() .filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement)) .map(|acknowledgement| acknowledgement.server_sequence) .max() .unwrap_or_default(); let history_missing = after < latest && first_available.is_none_or(|first_sequence| after.saturating_add(1) < first_sequence); let events = if history_missing { vec![Ok(Event::default() .id(latest.to_string()) .event("snapshot-required") .json_data(serde_json::json!({ "after": after, "firstAvailable": first_available, "latest": latest, "snapshotUrl": "/sync/snapshot", })) .expect("serializable missing history event"))] } else { sync.acknowledgements .values() .filter(|acknowledgement| { visible_acknowledgement(principal, acknowledgement) && acknowledgement.server_sequence > after && acknowledgement.server_sequence > sync.retained_after }) .map(|acknowledgement| { Ok(Event::default() .id(acknowledgement.server_sequence.to_string()) .event("acknowledgement") .json_data(acknowledgement) .expect("serializable acknowledgement")) }) .collect::>>() }; Ok(Sse::new(stream::iter(events).boxed()).keep_alive(KeepAlive::default())) } fn registry(state: Arc) -> impl DispatchRegistry { interactions(ui::BUILD_FINGERPRINT) .on(board::create_card, { let state = state.clone(); move |form| { // req: examples/001 req: form/002 let title = form.value("title").unwrap_or("").trim(); let column = parse_column(form.value("column")); let mut board = state.board.lock().unwrap(); if !title.is_empty() { let id = board.next_id; board.next_id += 1; board.cards.push(Card { id, title: title.into(), column, }); } board_effects(&board, "Card added") } }) .on(card_board::move_left, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 let mut board = state.board.lock().unwrap(); let moved = update_card(&mut board, form.parse("card_id"), |card| { card.column = card.column.saturating_sub(1); }); board_effects( &board, if moved { "Card moved left" } else { "Card not found" }, ) } }) .on(card_board::move_right, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 let mut board = state.board.lock().unwrap(); let moved = update_card(&mut board, form.parse("card_id"), |card| { card.column = (card.column + 1).min(COLUMNS.len() - 1); }); board_effects( &board, if moved { "Card moved right" } else { "Card not found" }, ) } }) .on(card_board::delete_card, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 let mut board = state.board.lock().unwrap(); let before = board.cards.len(); if let Some(id) = form.parse::("card_id") { board.cards.retain(|card| card.id != id); } board_effects( &board, if board.cards.len() < before { "Card deleted" } else { "Card not found" }, ) } }) } fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect { ( board::board.put(&board_view(board)), board::notice.text(notice), board::create_card_form.clear(), ) } fn update_card( board: &mut BoardState, card_id: Option, update: impl FnOnce(&mut Card), ) -> bool { let Some(id) = card_id else { return false; }; let Some(card) = board.cards.iter_mut().find(|card| card.id == id) else { return false; }; update(card); true } fn parse_column(value: Option<&str>) -> usize { let id = value.unwrap_or(COLUMNS[0].0); COLUMNS .iter() .position(|(column_id, _)| *column_id == id) .unwrap_or(0) } fn page_html(board: &BoardState) -> Html { // req: html_safety/002 req: view/001 board_ui::page(&Board { options: render_options(), board: ui::page(&board_view(board)), }) } fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 ui::page(&AppShell { runtime_src: runtime_js_path(), body, }) } fn render_options() -> Html { // req: html_safety/002 req: view/001 ui::page(&ColumnOptions { options: COLUMNS .iter() .map(|(id, title)| ColumnOption { id, title }) .collect(), }) } fn board_view(board: &BoardState) -> BoardColumns { // req: html_safety/002 req: view/001 BoardColumns { columns: COLUMNS .iter() .enumerate() .map(|(idx, (_, title))| BoardColumn { title, cards: board .cards .iter() .filter(|card| card.column == idx) .map(render_card) .collect(), }) .collect(), } } fn render_card(card: &Card) -> BoardCard { BoardCard { id: card.id, title: card.title.clone(), left_disabled: card.column == 0, right_disabled: card.column + 1 == COLUMNS.len(), } } #[cfg(test)] mod tests { use super::*; use hemx_test::{ class_child_selector, disabled_button_selector, element_class_selector, escaped_markup_selector, form_selector, keyed_selector, root_element_selector, select_options_selector, small_text_selector, strong_text_selector, }; use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } // req: html_safety/002 req: view/001 req: test/005 #[test] fn kanban_page_is_composed_by_a_hemplate_view() { let html = page_html(&BoardState::default()); assert!(!html.as_str().contains("__OPTIONS__")); assert!(!html.as_str().contains("__BOARD__")); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector(&root_element_selector("section", "kanban"))) .count(), 1 ); assert_eq!( document .select(&selector(&select_options_selector("column"))) .count(), 3 ); assert_eq!( document.select(&selector(&form_selector("header"))).count(), 1 ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn board_payload_is_rendered_by_a_hemplate_view() { let board = BoardState { next_id: 2, cards: vec![Card { id: 1, title: "Compile checked".to_owned(), column: 0, }], }; let html = ui::page(&board_view(&board)); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector(&class_child_selector( "columns", "section", "column" ))) .count(), 3 ); let card = document .select(&selector(&keyed_selector("article.card", 1))) .next() .expect("card renders"); let title = card .select(&selector(strong_text_selector())) .next() .expect("card title renders"); assert_eq!(title.text().collect::(), "Compile checked"); assert!(card .select(&selector(&escaped_markup_selector("b"))) .next() .is_none()); assert_eq!( card.select(&selector(disabled_button_selector())).count(), 1 ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn presence_payload_is_rendered_by_a_hemplate_view() { let html = ui::page(&Presence { count: 7 }); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector(&element_class_selector("span", "presence"))) .count(), 2 ); assert_eq!( document .select(&selector(small_text_selector())) .next() .map(|small| small.text().collect::()), Some("tick #7".to_owned()) ); } }