#[hemx::surface] pub mod ui {} #[cfg(feature = "client")] use hemx_sync::SyncEffect as DurableSync; #[cfg(feature = "client")] #[derive(Clone, Debug, Eq, PartialEq)] struct CardId(String); #[cfg(feature = "client")] struct ReorderCommand { card: CardId, input_kind: String, } #[cfg(feature = "client")] struct CardReordered { card: CardId, input_kind: String, } #[cfg(feature = "client")] struct BoardProjection { first: CardId, } #[cfg(feature = "client")] struct ProjectedReorder { card: CardId, before: Option, input_kind: String, } #[cfg(feature = "client")] impl ReorderCommand { fn from_client(event: hemx::wasm::ClientEvent) -> Self { Self { card: CardId( event .value .filter(|card| !card.is_empty()) .unwrap_or_else(|| "1".into()), ), input_kind: event.kind, } } fn decide(self) -> CardReordered { CardReordered { card: self.card, input_kind: self.input_kind, } } } #[cfg(feature = "client")] impl BoardProjection { fn restore(state: hemx::wasm::ClientState) -> Self { Self { first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()), } } fn apply(self, event: CardReordered) -> ProjectedReorder { let before = (event.card != self.first).then_some(self.first); ProjectedReorder { card: event.card, before, input_kind: event.input_kind, } } } #[cfg(feature = "client")] #[hemx::handler(client)] pub fn reorder_card( event: hemx::wasm::ClientEvent, state: hemx::wasm::ClientState, ) -> impl hemx::IntoEffect { let projected = BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide()); let card = projected.card.0; let patch = hemx_sync::FlatPatch::for_interaction( "cardColumn", hemx_sync::PatchValue::String("done".to_owned()), ) .expect("generated Kanban patch is valid"); let move_effect = match projected.before { Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0), None => ui::client_board::client_cards.move_to_end(card.clone()), }; DurableSync::durable( patch, ( move_effect, ui::client_board::client_notice .text(format!("Moved {card} with {}", projected.input_kind)), ), ui::BUILD_FINGERPRINT, ) } #[cfg(all(test, feature = "client"))] mod client_tests { use super::*; use hemx::IntoEffect; #[test] fn client_reorder_carries_flat_patch_in_ordinary_effect_batch() { let batch = reorder_card( hemx::wasm::ClientEvent { kind: "drop".to_owned(), value: None, checked: None, key: None, }, hemx::wasm::ClientState { encoded: "1|2".to_owned(), }, ) .into_batch(ui::BUILD_FINGERPRINT); assert_eq!(batch.ops.len(), 3); let wire = String::from_utf8_lossy(&batch.to_wire()).into_owned(); assert!(wire.contains(hemx_sync::PATCH_EVENT)); assert!(wire.contains("$hemx-interaction")); assert!(wire.contains("\"projection\":[")); } } #[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] mod fixture { use super::ui; use hemplate::Hemplate; use hemx::Html; #[derive(Hemplate)] struct ClientBoard { cards: Vec, } #[derive(Hemplate)] struct ClientCard { id: u64, title: &'static str, } pub fn render() -> Html { ui::client_board::page(&ClientBoard { cards: vec![ ClientCard { id: 1, title: "First", }, ClientCard { id: 2, title: "Second", }, ], }) } } #[cfg(all(feature = "fixture", not(target_arch = "wasm32")))] pub fn render_client_fixture() -> hemx::Html { fixture::render() } #[cfg(test)] mod tests { use super::ui::{board, board_card}; use hemplate::Hemplate; use hemx::IntoEffect; use hemx_test::inspect; use scraper::{Html, Selector}; #[derive(Hemplate)] #[hemplate = "partials"] struct BoardColumns { columns: Vec, } #[allow(dead_code)] #[derive(Clone, Debug)] #[hemx::form("create_card")] struct CreateCard { title: String, column: String, } // req: examples/001 req: codegen/002 req: list/003 #[test] fn kanban_board_updates_generated_slot() { fn render_board() -> impl IntoEffect { board::board.put(&empty_board()) } let effect = inspect(render_board()); assert!(effect.updates_html(board::board)); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn kanban_board_test_payload_is_rendered_by_a_hemplate_view() { let html = super::ui::page(&empty_board()); let document = Html::parse_fragment(html.as_str()); assert_eq!(document.select(&selector(".columns")).count(), 1); } fn empty_board() -> BoardColumns { // req: html_safety/002 req: view/001 BoardColumns { columns: Vec::new(), } } fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 #[test] fn kanban_form_handler_is_checked_against_hemplate_form() { #[hemx::handler] fn create_card(_form: hemx::Form) -> impl IntoEffect { board::notice.text("queued") } let effect = inspect(create_card(CreateCard::FORM)); assert!(effect.updates_text(board::notice)); } // req: examples/001 req: form/002 req: codegen/003 #[test] fn kanban_template_exports_form_and_card_handles() { assert_ne!(board::create_card.id(), board_card::move_right.id()); assert_eq!( board::create_card_form.field("title").resource, board::create_card_form.id() ); } }