//! The follow control, described once for the three places that drew it. //! //! A button that says whether the viewer follows a thing, how many people do, //! and offers the opposite. It existed three times: hand-written in //! `templates/pages/user.html`, hand-written again in //! `templates/pages/project.html`, and a third time in //! `templates/partials/follow_button.html`, which is what //! `crate::routes::api::follows` answered a press with. Three copies of one //! control, and the answer had to match the two pages by hand or a press left //! a button that no longer matched the page around it. //! //! # One description, two call sites //! //! [`control`] is the whole of it. A page puts it in its body and the API route //! answers with [`answered`], which renders the same node. The two cannot //! disagree because there is only one of them. //! //! The press aims at [`region`], which is the id of the region the control sits //! in, and [`quasi_router::Action::replacing`] is `hx-target` plus //! `outerMorph`: what the answer replaces is the region itself. So the answer //! carries the region, id and all, which is what makes a second press land //! somewhere. [`super::auth_pages::answered`] carries the whole of that //! reasoning. //! //! # What did not survive //! //! `.follow-btn.is-selected`. Both spellings of the button carried a class that //! dropped its opacity while the viewer was already following, and the //! vocabulary has no member for a control that is latched: [`layout::State`] //! names `Disabled` and nothing else. The label already says which state it is //! in -- "Following (12)" against "Follow (12)" -- so what is lost is a //! seven-tenths opacity on a button that says the same thing in words. use quasi_router::{Act, Action, Node, RegionKind, Slot}; use quasi_webview::Webview; /// The region a press on this control replaces. /// /// One per target, because a page may carry more than one of these one day and /// two regions sharing an id is two answers landing in the same place. Both /// halves are what the route already takes: a target type (`user`, `project`) /// and a UUID, so the id is a plain handle and `quasi-webview` will write a /// program that addresses it. #[must_use] pub fn region(target_type: &str, target_id: &str) -> String { format!("follow-{target_type}-{target_id}") } /// The control, in whichever of its two states the viewer is in. /// /// The count rides in the label rather than beside it, which is what both /// templates did: a bare number next to a verb reads as a second control. #[must_use] pub fn control( target_type: &str, target_id: &str, is_following: bool, follower_count: i64, ) -> Node { let id = region(target_type, target_id); let route = format!("/api/follow/{target_type}/{target_id}"); let act = if is_following { Act::new( format!("Following ({follower_count})"), Action::delete(route).replacing(&id), ) } else { Act::new( format!("Follow ({follower_count})"), Action::post(route).replacing(&id), ) }; Node::Region(Slot::new(id, RegionKind::Group).with(Node::Act(act))) } /// The control as a fragment, for the route that answers a press. #[must_use] pub fn answered( target_type: &str, target_id: &str, is_following: bool, follower_count: i64, ) -> String { use quasi_axum::Serves as _; Webview::new().fragment(&control( target_type, target_id, is_following, follower_count, )) } /// What a page shows when the viewer cannot follow: the count, or nothing. /// /// A signed-out reader, or a creator looking at their own profile. Both /// templates drew the number as plain text in that case and drew nothing at all /// when it was zero, which is the right reading: "0 followers" is a fact nobody /// wants published about them. #[must_use] pub fn count_only(follower_count: i64) -> Option { (follower_count > 0).then(|| { Node::text(if follower_count == 1 { "1 follower".to_owned() } else { format!("{follower_count} followers") }) }) } #[cfg(test)] mod tests { use super::*; fn html(node: &Node) -> String { use quasi_axum::Serves as _; Webview::new().fragment(node) } /// The property the three copies could not hold: what the page draws and /// what the route answers with are the same description, so a press cannot /// leave a button the page would not have drawn. #[test] fn the_page_and_the_answer_are_the_same_markup() { for following in [true, false] { assert_eq!( html(&control("user", "abc", following, 3)), answered("user", "abc", following, 3), ); } } /// Each state offers the other one, at the verb that performs it. #[test] fn each_state_offers_the_opposite_one() { let not_yet = html(&control("project", "p1", false, 0)); assert!( not_yet.contains("hx-post=\"/api/follow/project/p1\""), "{not_yet}" ); assert!(not_yet.contains(">Follow (0)<"), "{not_yet}"); let already = html(&control("project", "p1", true, 1)); assert!( already.contains("hx-delete=\"/api/follow/project/p1\""), "{already}" ); assert!(already.contains(">Following (1)<"), "{already}"); } /// The answer replaces the region rather than the button, so the second /// press has something to aim at. `auth_pages::answered` is the ruling. #[test] fn the_answer_carries_the_region_it_replaces() { let id = region("user", "abc"); let rendered = answered("user", "abc", false, 0); assert!(rendered.contains(&format!("id=\"{id}\"")), "{rendered}"); assert!( rendered.contains(&format!("hx-target=\"#{id}\"")), "{rendered}" ); assert!(rendered.contains("outerMorph"), "{rendered}"); } /// Two targets on one page do not share a region, so one press cannot /// answer into the other's place. #[test] fn every_target_owns_its_own_region() { assert_ne!(region("user", "a"), region("project", "a")); assert_ne!(region("user", "a"), region("user", "b")); } /// A reader who cannot follow sees the count, and a creator with no /// followers is not told so on their own page. #[test] fn a_count_of_none_says_nothing() { assert!(count_only(0).is_none()); assert!(html(&count_only(1).expect("drawn")).contains("1 follower")); assert!(html(&count_only(4).expect("drawn")).contains("4 followers")); } }