Skip to main content

max / makenotwork

8.8 KB · 253 lines History Blame Raw
1 //! The user dashboard's Support panel, described.
2 //!
3 //! Third of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab
4 //! inventory"): 63 lines, one form, no `data-action`, no `<details>`, and
5 //! nothing in `static/` or `frontend/src` reaches for any id it writes.
6 //!
7 //! A fill rather than a mounted screen, following [`super::user_projects`]:
8 //! `dashboard_tab_support` is the Askama handler and stays, and this is what it
9 //! answers. Unlike Projects there is no ETag to preserve here; the reason is
10 //! the other one, which is that Support is one of the four panels the dashboard
11 //! page can render inline, so it needs a [`fill`] either way and a mounted
12 //! screen would leave the inline path with nothing to call.
13 //!
14 //! # It closes part of `736f45a5`, and that is the point worth recording
15 //!
16 //! The template's submit button carried `<span id="support-spinner"
17 //! class="htmx-indicator"> ...</span>`, and the form carried
18 //! `hx-indicator="#support-spinner"` to drive it. Both are gone:
19 //! [`Action::awaiting`] says the same thing in one word, the renderer draws the
20 //! wait and locks the control, and no screen has to spell an indicator or
21 //! invent an id for one.
22 //!
23 //! That is three of the 122 spelling sites `736f45a5` counted (one
24 //! `htmx-indicator`, one `hx-indicator`, one `id="...spinner"`). The task
25 //! predicted the conversions would take them for free and this is one
26 //! conversion doing it.
27 //!
28 //! # What the description says that the markup did not
29 //!
30 //! The category `<select>` opened with `<option value="" disabled selected>`, a
31 //! markup idiom for "nothing chosen yet" that a description says by the field
32 //! simply having no value. Six real options remain, and a renderer that is not
33 //! a browser no longer has to know that one of the seven was a placeholder.
34 //!
35 //! The response-times block stays prose. It is three durations and a mailto,
36 //! not a set of things with addresses, so it is [`Node::rich`] carrying the
37 //! source rather than a described list: a terminal renders the same three lines
38 //! without being handed `<ul>`.
39
40 use makeover_layout as layout;
41 use quasi_router::screen::{Choice, Field};
42 use quasi_router::{Action, Node, RegionKind, Slot};
43 use quasi_webview::Webview;
44
45 /// The region the answer replaces, keeping the id the page already used.
46 pub const REGION: &str = "user-support";
47
48 /// Where a ticket goes.
49 const TICKET: &str = "/api/support/ticket";
50
51 /// The region the answer lands in, which the form aims at and the page leaves
52 /// empty until something is submitted.
53 const RESULT: &str = "support-result";
54
55 /// The panel as the route answers it: the region, carrying its own id.
56 #[must_use]
57 pub fn fragment(email: &str) -> String {
58 use quasi_axum::Serves as _;
59
60 let mut slot = Slot::new(REGION, RegionKind::Pane);
61 for node in body(email) {
62 slot = slot.with(node);
63 }
64 Webview::new().fragment(&Node::Region(slot))
65 }
66
67 /// The panel's contents as the page embeds them, without a region wrapper.
68 #[must_use]
69 pub fn fill(email: &str) -> String {
70 use quasi_axum::Serves as _;
71
72 let mut out = String::new();
73 for node in body(email) {
74 out.push_str(&Webview::new().fragment(&node));
75 }
76 out
77 }
78
79 /// The panel's contents, in order.
80 fn body(email: &str) -> Vec<Node> {
81 vec![
82 Node::section("Support"),
83 Node::text(
84 "Every response is from a real person. We will never use AI to handle your questions.",
85 ),
86 Node::text(format!(
87 "We monitor the platform proactively and may have already opened a ticket for your \
88 issue. Check your email at {email} before submitting."
89 )),
90 ticket_form(),
91 // Where the answer lands. Named by the form's own action, and empty
92 // until something is submitted.
93 Node::Region(Slot::new(RESULT, RegionKind::Pane)),
94 response_times(),
95 ]
96 }
97
98 /// The ticket form.
99 fn ticket_form() -> Node {
100 Node::Form {
101 // `awaiting` is the whole of what `hx-indicator="#support-spinner"` and
102 // the span it pointed at were doing. See the module header.
103 action: Action::post(TICKET).awaiting(),
104 submit: "Submit".into(),
105 fields: vec![
106 Field::select("category", "Category", categories()).required(),
107 length(
108 placeholder(
109 Field::new(layout::FieldKind::Text, "subject", "Subject").required(),
110 "Brief description of the issue",
111 ),
112 200,
113 ),
114 length(
115 placeholder(
116 Field::new(layout::FieldKind::Textarea, "message", "Message").required(),
117 "What happened? What did you expect? Include any relevant details.",
118 ),
119 5000,
120 ),
121 ],
122 }
123 }
124
125 /// What a ticket can be about.
126 ///
127 /// The template's seventh option was `value=""`, disabled and selected: the
128 /// markup spelling of "nothing chosen yet". A field with no value says it, so
129 /// only the six real categories are here.
130 fn categories() -> Vec<Choice> {
131 [
132 ("bug", "Bug report"),
133 ("billing", "Billing or payments"),
134 ("account", "Account access"),
135 ("content", "Content or uploads"),
136 ("security", "Security concern"),
137 ("other", "Other"),
138 ]
139 .into_iter()
140 .map(|(value, label)| Choice::new(value, label))
141 .collect()
142 }
143
144 /// How long an answer takes, and the way round the queue for a security issue.
145 fn response_times() -> Node {
146 super::own_prose(
147 "Response times:\n\n\
148 - Security issues: same day\n\
149 - Billing and account access: 24 hours\n\
150 - Everything else: 1-2 business days\n\n\
151 For urgent security issues, email <security@makenot.work> directly.",
152 )
153 }
154
155 /// Set a field's placeholder. A plain field assignment, since `Field` has no
156 /// builder for it and the field is public.
157 fn placeholder(mut field: Field, text: &str) -> Field {
158 field.placeholder = Some(text.to_owned());
159 field
160 }
161
162 /// Cap what a field accepts, matching the template's `maxlength`.
163 ///
164 /// Kept rather than dropped: it is the same limit the API validates against, and
165 /// a form that lets a reader type 6000 characters before refusing them is worse
166 /// than one that stops at 5000.
167 fn length(mut field: Field, max: u32) -> Field {
168 field.max_length = Some(max);
169 field
170 }
171
172 #[cfg(test)]
173 mod tests {
174 use super::*;
175 use quasi_axum::Serves;
176
177 fn render() -> String {
178 let mut out = String::new();
179 for node in &body("ada@example.com") {
180 out.push_str(&Webview::new().fragment(node));
181 }
182 out
183 }
184
185 #[test]
186 fn the_form_addresses_a_route_the_api_answers() {
187 // The S3 failure class: a control addressing a route registered nowhere
188 // renders fine and answers 404 when pressed.
189 let api = include_str!("../routes/api/mod.rs");
190 assert!(api.contains(TICKET), "registered route");
191 }
192
193 #[test]
194 fn the_readers_own_address_is_shown_and_escaped() {
195 let html = render();
196 assert!(html.contains("ada@example.com"), "{html}");
197
198 let mut out = String::new();
199 for node in &body("<script>x()</script>") {
200 out.push_str(&Webview::new().fragment(node));
201 }
202 assert!(!out.contains("<script>x()"), "{out}");
203 }
204
205 #[test]
206 fn every_category_is_offered_and_the_placeholder_option_is_not() {
207 let html = render();
208
209 for label in [
210 "Bug report",
211 "Billing or payments",
212 "Account access",
213 "Content or uploads",
214 "Security concern",
215 "Other",
216 ] {
217 assert!(html.contains(label), "missing {label}: {html}");
218 }
219 // The template's disabled `value=""` seventh option.
220 assert!(!html.contains("Select a category"), "{html}");
221 }
222
223 #[test]
224 fn the_wait_is_said_once_and_no_spinner_is_spelled() {
225 let html = render();
226
227 // What `736f45a5` is counting. None of the three spellings survive.
228 assert!(!html.contains("htmx-indicator"), "{html}");
229 assert!(!html.contains("hx-indicator"), "{html}");
230 assert!(!html.contains("spinner"), "{html}");
231 }
232
233 #[test]
234 fn both_free_text_fields_carry_the_limit_the_api_validates() {
235 let html = render();
236 assert!(html.contains("maxlength=\"200\""), "{html}");
237 assert!(html.contains("maxlength=\"5000\""), "{html}");
238 }
239
240 #[test]
241 fn the_answer_has_somewhere_to_land() {
242 let html = render();
243 assert!(html.contains(&format!("id=\"{RESULT}\"")), "{html}");
244 }
245
246 #[test]
247 fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
248 let inline = fill("ada@example.com");
249 assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
250 assert!(inline.contains("Support"), "{inline}");
251 }
252 }
253