Skip to main content

max / makenotwork

6.5 KB · 181 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_declare::declare;
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 declare! {
50 /// The control, in whichever of its two states the viewer is in.
51 ///
52 /// The count rides in the label rather than beside it, which is what both
53 /// templates did: a bare number next to a verb reads as a second control.
54 #[must_use]
55 pub shape control(
56 target_type: &str,
57 target_id: &str,
58 is_following: bool,
59 follower_count: i64
60 ) -> Node;
61
62 let id = region(target_type, target_id);
63 let route = "/api/follow/{target_type}/{target_id}";
64
65 region id as Group {
66 given is_following {
67 true -> act "Following ({follower_count})" to delete route replacing &id;
68 false -> act "Follow ({follower_count})" to post route replacing &id;
69 }
70 }
71 }
72
73 /// The control as a fragment, for the route that answers a press.
74 #[must_use]
75 pub fn answered(
76 target_type: &str,
77 target_id: &str,
78 is_following: bool,
79 follower_count: i64,
80 ) -> String {
81 use quasi_axum::Serves as _;
82
83 Webview::new().fragment(&control(
84 target_type,
85 target_id,
86 is_following,
87 follower_count,
88 ))
89 }
90
91 declare! {
92 /// What a page shows when the viewer cannot follow: the count, or nothing.
93 ///
94 /// A signed-out reader, or a creator looking at their own profile. Both
95 /// templates drew the number as plain text in that case and drew nothing at all
96 /// when it was zero, which is the right reading: "0 followers" is a fact nobody
97 /// wants published about them.
98 #[must_use]
99 pub shape count_only(follower_count: i64) -> Option<Node>;
100
101 let counted = given follower_count {
102 1 -> "1 follower",
103 otherwise -> "{follower_count} followers",
104 };
105
106 text counted when follower_count over 0;
107 }
108
109 #[cfg(test)]
110 mod tests {
111 use super::*;
112 use quasi_router::Node;
113
114 fn html(node: &Node) -> String {
115 use quasi_axum::Serves as _;
116 Webview::new().fragment(node)
117 }
118
119 /// The property the three copies could not hold: what the page draws and
120 /// what the route answers with are the same description, so a press cannot
121 /// leave a button the page would not have drawn.
122 #[test]
123 fn the_page_and_the_answer_are_the_same_markup() {
124 for following in [true, false] {
125 assert_eq!(
126 html(&control("user", "abc", following, 3)),
127 answered("user", "abc", following, 3),
128 );
129 }
130 }
131
132 /// Each state offers the other one, at the verb that performs it.
133 #[test]
134 fn each_state_offers_the_opposite_one() {
135 let not_yet = html(&control("project", "p1", false, 0));
136 assert!(
137 not_yet.contains("hx-post=\"/api/follow/project/p1\""),
138 "{not_yet}"
139 );
140 assert!(not_yet.contains(">Follow (0)<"), "{not_yet}");
141
142 let already = html(&control("project", "p1", true, 1));
143 assert!(
144 already.contains("hx-delete=\"/api/follow/project/p1\""),
145 "{already}"
146 );
147 assert!(already.contains(">Following (1)<"), "{already}");
148 }
149
150 /// The answer replaces the region rather than the button, so the second
151 /// press has something to aim at. `auth_pages::answered` is the ruling.
152 #[test]
153 fn the_answer_carries_the_region_it_replaces() {
154 let id = region("user", "abc");
155 let rendered = answered("user", "abc", false, 0);
156 assert!(rendered.contains(&format!("id=\"{id}\"")), "{rendered}");
157 assert!(
158 rendered.contains(&format!("hx-target=\"#{id}\"")),
159 "{rendered}"
160 );
161 assert!(rendered.contains("outerMorph"), "{rendered}");
162 }
163
164 /// Two targets on one page do not share a region, so one press cannot
165 /// answer into the other's place.
166 #[test]
167 fn every_target_owns_its_own_region() {
168 assert_ne!(region("user", "a"), region("project", "a"));
169 assert_ne!(region("user", "a"), region("user", "b"));
170 }
171
172 /// A reader who cannot follow sees the count, and a creator with no
173 /// followers is not told so on their own page.
174 #[test]
175 fn a_count_of_none_says_nothing() {
176 assert!(count_only(0).is_none());
177 assert!(html(&count_only(1).expect("drawn")).contains("1 follower"));
178 assert!(html(&count_only(4).expect("drawn")).contains("4 followers"));
179 }
180 }
181