Skip to main content

max / makenotwork

6.6 KB · 182 lines History Blame Raw
1 //! The follow control, described once for the three places that drew it.
2 //!
3 //! A button that says whether the viewer follows a thing, how many people do,
4 //! and offers the opposite. It existed three times: hand-written in
5 //! `templates/pages/user.html`, hand-written again in
6 //! `templates/pages/project.html`, and a third time in
7 //! `templates/partials/follow_button.html`, which is what
8 //! `crate::routes::api::follows` answered a press with. Three copies of one
9 //! control, and the answer had to match the two pages by hand or a press left
10 //! a button that no longer matched the page around it.
11 //!
12 //! # One description, two call sites
13 //!
14 //! [`control`] is the whole of it. A page puts it in its body and the API route
15 //! answers with [`answered`], which renders the same node. The two cannot
16 //! disagree because there is only one of them.
17 //!
18 //! The press aims at [`region`], which is the id of the region the control sits
19 //! in, and [`quasi_router::Action::replacing`] is `hx-target` plus
20 //! `outerMorph`: what the answer replaces is the region itself. So the answer
21 //! carries the region, id and all, which is what makes a second press land
22 //! somewhere. [`super::auth_pages::answered`] carries the whole of that
23 //! reasoning.
24 //!
25 //! # What did not survive
26 //!
27 //! `.follow-btn.is-selected`. Both spellings of the button carried a class that
28 //! dropped its opacity while the viewer was already following, and the
29 //! vocabulary has no member for a control that is latched: [`layout::State`]
30 //! names `Disabled` and nothing else. The label already says which state it is
31 //! in -- "Following (12)" against "Follow (12)" -- so what is lost is a
32 //! seven-tenths opacity on a button that says the same thing in words.
33
34 use quasi_router::{Act, Action, Node, RegionKind, Slot};
35 use quasi_webview::Webview;
36
37 /// The region a press on this control replaces.
38 ///
39 /// One per target, because a page may carry more than one of these one day and
40 /// two regions sharing an id is two answers landing in the same place. Both
41 /// halves are what the route already takes: a target type (`user`, `project`)
42 /// and a UUID, so the id is a plain handle and `quasi-webview` will write a
43 /// program that addresses it.
44 #[must_use]
45 pub fn region(target_type: &str, target_id: &str) -> String {
46 format!("follow-{target_type}-{target_id}")
47 }
48
49 /// The control, in whichever of its two states the viewer is in.
50 ///
51 /// The count rides in the label rather than beside it, which is what both
52 /// templates did: a bare number next to a verb reads as a second control.
53 #[must_use]
54 pub fn control(
55 target_type: &str,
56 target_id: &str,
57 is_following: bool,
58 follower_count: i64,
59 ) -> Node {
60 let id = region(target_type, target_id);
61 let route = format!("/api/follow/{target_type}/{target_id}");
62 let act = if is_following {
63 Act::new(
64 format!("Following ({follower_count})"),
65 Action::delete(route).replacing(&id),
66 )
67 } else {
68 Act::new(
69 format!("Follow ({follower_count})"),
70 Action::post(route).replacing(&id),
71 )
72 };
73 Node::Region(Slot::new(id, RegionKind::Group).with(Node::Act(act)))
74 }
75
76 /// The control as a fragment, for the route that answers a press.
77 #[must_use]
78 pub fn answered(
79 target_type: &str,
80 target_id: &str,
81 is_following: bool,
82 follower_count: i64,
83 ) -> String {
84 use quasi_axum::Serves as _;
85
86 Webview::new().fragment(&control(
87 target_type,
88 target_id,
89 is_following,
90 follower_count,
91 ))
92 }
93
94 /// What a page shows when the viewer cannot follow: the count, or nothing.
95 ///
96 /// A signed-out reader, or a creator looking at their own profile. Both
97 /// templates drew the number as plain text in that case and drew nothing at all
98 /// when it was zero, which is the right reading: "0 followers" is a fact nobody
99 /// wants published about them.
100 #[must_use]
101 pub fn count_only(follower_count: i64) -> Option<Node> {
102 (follower_count > 0).then(|| {
103 Node::text(if follower_count == 1 {
104 "1 follower".to_owned()
105 } else {
106 format!("{follower_count} followers")
107 })
108 })
109 }
110
111 #[cfg(test)]
112 mod tests {
113 use super::*;
114
115 fn html(node: &Node) -> String {
116 use quasi_axum::Serves as _;
117 Webview::new().fragment(node)
118 }
119
120 /// The property the three copies could not hold: what the page draws and
121 /// what the route answers with are the same description, so a press cannot
122 /// leave a button the page would not have drawn.
123 #[test]
124 fn the_page_and_the_answer_are_the_same_markup() {
125 for following in [true, false] {
126 assert_eq!(
127 html(&control("user", "abc", following, 3)),
128 answered("user", "abc", following, 3),
129 );
130 }
131 }
132
133 /// Each state offers the other one, at the verb that performs it.
134 #[test]
135 fn each_state_offers_the_opposite_one() {
136 let not_yet = html(&control("project", "p1", false, 0));
137 assert!(
138 not_yet.contains("hx-post=\"/api/follow/project/p1\""),
139 "{not_yet}"
140 );
141 assert!(not_yet.contains(">Follow (0)<"), "{not_yet}");
142
143 let already = html(&control("project", "p1", true, 1));
144 assert!(
145 already.contains("hx-delete=\"/api/follow/project/p1\""),
146 "{already}"
147 );
148 assert!(already.contains(">Following (1)<"), "{already}");
149 }
150
151 /// The answer replaces the region rather than the button, so the second
152 /// press has something to aim at. `auth_pages::answered` is the ruling.
153 #[test]
154 fn the_answer_carries_the_region_it_replaces() {
155 let id = region("user", "abc");
156 let rendered = answered("user", "abc", false, 0);
157 assert!(rendered.contains(&format!("id=\"{id}\"")), "{rendered}");
158 assert!(
159 rendered.contains(&format!("hx-target=\"#{id}\"")),
160 "{rendered}"
161 );
162 assert!(rendered.contains("outerMorph"), "{rendered}");
163 }
164
165 /// Two targets on one page do not share a region, so one press cannot
166 /// answer into the other's place.
167 #[test]
168 fn every_target_owns_its_own_region() {
169 assert_ne!(region("user", "a"), region("project", "a"));
170 assert_ne!(region("user", "a"), region("user", "b"));
171 }
172
173 /// A reader who cannot follow sees the count, and a creator with no
174 /// followers is not told so on their own page.
175 #[test]
176 fn a_count_of_none_says_nothing() {
177 assert!(count_only(0).is_none());
178 assert!(html(&count_only(1).expect("drawn")).contains("1 follower"));
179 assert!(html(&count_only(4).expect("drawn")).contains("4 followers"));
180 }
181 }
182