Skip to main content

max / makenotwork

8.6 KB · 239 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 quasi_declare::declare;
41 use quasi_router::screen::Choice;
42 use quasi_router::{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 /// How long an answer takes, and the way round the queue for a security issue.
56 ///
57 /// Prose. It is three durations and a mailto, not a set of things with
58 /// addresses, so it is markdown carrying the source rather than a described
59 /// list: a terminal renders the same three lines without being handed a `<ul>`.
60 const RESPONSE_TIMES: &str = "Response times:\n\n\
61 - Security issues: same day\n\
62 - Billing and account access: 24 hours\n\
63 - Everything else: 1-2 business days\n\n\
64 For urgent security issues, email <security@makenot.work> directly.";
65
66 /// The panel as the route answers it: the region, carrying its own id.
67 #[must_use]
68 pub fn fragment(email: &str) -> String {
69 use quasi_axum::Serves as _;
70
71 let mut slot = Slot::new(REGION, RegionKind::Pane);
72 for node in body(email) {
73 slot = slot.with(node);
74 }
75 Webview::new().fragment(&Node::Region(slot))
76 }
77
78 /// The panel's contents as the page embeds them, without a region wrapper.
79 #[must_use]
80 pub fn fill(email: &str) -> String {
81 use quasi_axum::Serves as _;
82
83 let mut out = String::new();
84 for node in body(email) {
85 out.push_str(&Webview::new().fragment(&node));
86 }
87 out
88 }
89
90 declare! {
91 /// The panel's contents, in order.
92 ///
93 /// A list of members and not a region: the dashboard strip draws the frame
94 /// and its `id`, so [`fill`] must add no second one, and [`fragment`] wraps
95 /// the same members itself for the tab that answers over htmx.
96 shape body(email: &str) -> Vec<Node>;
97
98 section "Support";
99 text "Every response is from a real person. We will never use AI to handle your questions.";
100 text "We monitor the platform proactively and may have already opened a ticket for your \
101 issue. Check your email at {email} before submitting.";
102
103 include ticket_form();
104
105 // Where the answer lands. Named by the form's own action, and empty until
106 // something is submitted.
107 region RESULT as Pane {}
108
109 include super::own_prose(RESPONSE_TIMES);
110 }
111
112 declare! {
113 /// The ticket form.
114 ///
115 /// `awaiting` is the whole of what `hx-indicator="#support-spinner"` and the
116 /// span it pointed at were doing. See the module header.
117 ///
118 /// The category select opened with `<option value="" disabled selected>`, a
119 /// markup idiom for "nothing chosen yet" that a description says by the
120 /// field simply having no value. Six real options remain, and a renderer
121 /// that is not a browser no longer has to know that one of the seven was a
122 /// placeholder.
123 ///
124 /// The two lengths are the same limits the API validates against, and a
125 /// form that lets a reader type 6000 characters before refusing them is
126 /// worse than one that stops at 5000.
127 shape ticket_form() -> Node;
128
129 form post TICKET awaiting {
130 submit "Submit";
131
132 field Select "category" "Category" {
133 options [
134 Choice::new("bug", "Bug report"),
135 Choice::new("billing", "Billing or payments"),
136 Choice::new("account", "Account access"),
137 Choice::new("content", "Content or uploads"),
138 Choice::new("security", "Security concern"),
139 Choice::new("other", "Other"),
140 ];
141 required;
142 }
143
144 field Text "subject" "Subject" {
145 required;
146 placeholder "Brief description of the issue";
147 limited_to 200;
148 }
149
150 field Textarea "message" "Message" {
151 required;
152 placeholder "What happened? What did you expect? Include any relevant details.";
153 limited_to 5000;
154 }
155 }
156 }
157
158 #[cfg(test)]
159 mod tests {
160 use super::*;
161 use quasi_axum::Serves;
162
163 fn render() -> String {
164 let mut out = String::new();
165 for node in &body("ada@example.com") {
166 out.push_str(&Webview::new().fragment(node));
167 }
168 out
169 }
170
171 #[test]
172 fn the_form_addresses_a_route_the_api_answers() {
173 // The S3 failure class: a control addressing a route registered nowhere
174 // renders fine and answers 404 when pressed.
175 let api = include_str!("../routes/api/mod.rs");
176 assert!(api.contains(TICKET), "registered route");
177 }
178
179 #[test]
180 fn the_readers_own_address_is_shown_and_escaped() {
181 let html = render();
182 assert!(html.contains("ada@example.com"), "{html}");
183
184 let mut out = String::new();
185 for node in &body("<script>x()</script>") {
186 out.push_str(&Webview::new().fragment(node));
187 }
188 assert!(!out.contains("<script>x()"), "{out}");
189 }
190
191 #[test]
192 fn every_category_is_offered_and_the_placeholder_option_is_not() {
193 let html = render();
194
195 for label in [
196 "Bug report",
197 "Billing or payments",
198 "Account access",
199 "Content or uploads",
200 "Security concern",
201 "Other",
202 ] {
203 assert!(html.contains(label), "missing {label}: {html}");
204 }
205 // The template's disabled `value=""` seventh option.
206 assert!(!html.contains("Select a category"), "{html}");
207 }
208
209 #[test]
210 fn the_wait_is_said_once_and_no_spinner_is_spelled() {
211 let html = render();
212
213 // What `736f45a5` is counting. None of the three spellings survive.
214 assert!(!html.contains("htmx-indicator"), "{html}");
215 assert!(!html.contains("hx-indicator"), "{html}");
216 assert!(!html.contains("spinner"), "{html}");
217 }
218
219 #[test]
220 fn both_free_text_fields_carry_the_limit_the_api_validates() {
221 let html = render();
222 assert!(html.contains("maxlength=\"200\""), "{html}");
223 assert!(html.contains("maxlength=\"5000\""), "{html}");
224 }
225
226 #[test]
227 fn the_answer_has_somewhere_to_land() {
228 let html = render();
229 assert!(html.contains(&format!("id=\"{RESULT}\"")), "{html}");
230 }
231
232 #[test]
233 fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
234 let inline = fill("ada@example.com");
235 assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
236 assert!(inline.contains("Support"), "{inline}");
237 }
238 }
239