Skip to main content

max / makenotwork

10.4 KB · 244 lines History Blame Raw
1 //! The Askama entry point for a described markdown field.
2 //!
3 //! Shape 5 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the
4 //! narrowest of them: one member, [`FieldKind::Rich`], a field whose value is
5 //! markdown. Autosave and section reordering stay hand-written by ruling, so
6 //! what moves here is the editor surface and nothing around it.
7 //!
8 //! Four templates declared a markdown textarea by hand and one of them wrapped
9 //! it in a Write/Preview pair it drew itself. All four call this instead, so
10 //! the box, the pair and the pane come out of the renderer that already ships
11 //! the CSS for them: `makeover_webview::form` emits the group, and
12 //! `static/layout.css` carries its `[data-format="markdown"]` rules because
13 //! `build.rs` generates that file from the same crate.
14 //!
15 //! # Why the id is built out of a prefix rather than taken whole
16 //!
17 //! `Filling::id_for` is `<prefix>-<name>`, and every one of the four ids these
18 //! templates already had reads that way: `text-body`, `post-body`,
19 //! `new-psec-body`, `edit-psec-body`, `new-section-body`. So the field is named
20 //! `body` on all five, which is what the value is, and the surface supplies the
21 //! prefix, which is what makes it unique in a document holding two of them.
22 //!
23 //! That is not a coincidence being leaned on. It is what keeps the conversion
24 //! from breaking the thing that reaches these controls by id from outside: each
25 //! surface's own script reads its value back with `getElementById`, and the id
26 //! is the same string it was.
27 //!
28 //! The described picker addresses its destination by [`Field::name`] rather
29 //! than by a textarea id, which is what makes [`named`] necessary; see its own
30 //! note.
31 //!
32 //! # Why height is the app's and not the description's
33 //!
34 //! The description says nothing about how tall a control is, deliberately, and
35 //! the four surfaces did not agree anyway: 400px of CSS on the item editor,
36 //! `rows="20"` on the blog editor, `rows="10"` twice in project settings and
37 //! `rows="6"` in the wizard. So the fact stays here, as [`Height`], and lands
38 //! as a class on a wrapper this module owns. Three sizes rather than four
39 //! because the item editor and the blog editor were already the same surface at
40 //! two spellings.
41
42 use makeover_layout::{Field, FieldKind};
43 use makeover_webview::{
44 Emit,
45 form::{Filling, Value, field_html},
46 };
47
48 /// How much room the editor takes before the reader drags it.
49 ///
50 /// A markdown editor is one surface, so the sizes are named for the room they
51 /// are given rather than for the screen that gives it. The classes are matched
52 /// in `css/60-interaction.css`, under `RICH FIELD`.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub enum Height {
55 /// A few lines, for an editor behind a disclosure the reader opened to add
56 /// one row. The wizard's Add Section.
57 Compact,
58 /// A screenful, for an editor that is one field among several.
59 Standard,
60 /// The page's main work surface, for a screen that exists to write prose.
61 Tall,
62 }
63
64 impl Height {
65 /// The wrapper class this size lands as.
66 const fn class(self) -> &'static str {
67 match self {
68 Self::Compact => "rich-field rich-field--compact",
69 Self::Standard => "rich-field rich-field--standard",
70 Self::Tall => "rich-field rich-field--tall",
71 }
72 }
73 }
74
75 /// The markup, for an Askama template to drop in.
76 ///
77 /// `prefix` scopes the control's `id` and must be unique in the document;
78 /// `label` and `placeholder` are the question and its ghost text; `value` is
79 /// what the field holds now, escaped by the emitter rather than by the caller.
80 ///
81 /// The wrapper is this app's and the group inside it is the renderer's, the
82 /// same division `widgets::carousel` makes for the same reason: a class this
83 /// host prefixes with nothing and a description that has no idea it exists.
84 #[must_use]
85 pub fn html(prefix: &str, label: &str, placeholder: &str, value: &str, height: Height) -> String {
86 named(Some(prefix), "body", label, placeholder, value, height)
87 }
88
89 /// The same editor, naming the value itself.
90 ///
91 /// The rule above ("the name is what the value is, and the id is what tells two
92 /// editors apart") holds while nothing has to *address* an editor.
93 /// [`Act::fills`](quasi_router::Act::fills) does: a picker names the
94 /// box it deposits into by [`Field::name`], because a name is what identifies a
95 /// field within a description.
96 ///
97 /// So a document holding two editors under one name has one addressable editor
98 /// and one unreachable one. `item_details.html` is the measured case and was
99 /// worse than that: its two section bodies were raw `<textarea>` elements with
100 /// an id and **no name at all**, which nothing can address. They come through
101 /// here with names of their own, `new-sec-body` and `edit-sec-body`, and no
102 /// prefix, so the ids their scripts read by are the strings they always were.
103 ///
104 /// The four callers of [`html`] are unaffected and stay named `body`: each is
105 /// the only markdown editor under that name in its document, which is what
106 /// makes them addressable.
107 ///
108 /// Passing `None` for the prefix is what makes the name the whole id, and is
109 /// the only way to give an editor a document-unique name without also changing
110 /// the id something reads it by.
111 #[must_use]
112 pub fn named(
113 prefix: Option<&str>,
114 name: &str,
115 label: &str,
116 placeholder: &str,
117 value: &str,
118 height: Height,
119 ) -> String {
120 let mut field = Field::new(FieldKind::Rich, name, label);
121 field.placeholder = Some(placeholder);
122
123 let filling = Filling {
124 value: Value::Text(value),
125 trailing: None,
126 // Nothing of this host's own goes on the control. The seam exists for a
127 // fact the description does not carry, and quasi's suggestion source is
128 // the one caller there is.
129 control_attrs: None,
130 id_prefix: prefix,
131 };
132
133 format!(
134 "<div class=\"{}\">{}</div>",
135 height.class(),
136 field_html(&field, &filling, &Emit::default())
137 )
138 }
139
140 #[cfg(test)]
141 mod tests {
142 use super::Height;
143
144 /// The id every caller depends on. Three scripts read their value back by
145 /// id, so the prefix-plus-`body` arrangement is the contract and not an
146 /// implementation detail.
147 #[test]
148 fn the_prefix_and_the_name_make_the_id_the_templates_already_had() {
149 for prefix in ["text", "post", "new-psec", "edit-psec", "new-section"] {
150 let html = super::html(prefix, "Body", "Write it", "", Height::Standard);
151 assert!(html.contains(&format!("id=\"{prefix}-body\"")), "{html}");
152 assert!(html.contains(&format!("for=\"{prefix}-body\"")), "{html}");
153 }
154 }
155
156 /// The name is the same on all four callers of `html`, because it is what
157 /// the value is. Only the id distinguishes two editors in one document,
158 /// which is what `Filling::id_prefix` exists to do.
159 #[test]
160 fn the_name_is_the_value_and_never_the_surface() {
161 let html = super::html("new-psec", "Body", "", "", Height::Standard);
162 assert!(html.contains("name=\"body\""), "{html}");
163 assert!(!html.contains("name=\"new-psec-body\""), "{html}");
164 }
165
166 /// And the exception the picker needs: an editor that has to be addressed
167 /// by name in a document that holds another one carries a name of its own,
168 /// with the id its scripts read by unchanged.
169 #[test]
170 fn a_named_editor_keeps_the_id_and_gains_a_name_that_tells_it_apart() {
171 for name in ["new-sec-body", "edit-sec-body"] {
172 let html = super::named(None, name, "Body (Markdown)", "", "", Height::Compact);
173 assert!(html.contains(&format!("id=\"{name}\"")), "{html}");
174 assert!(html.contains(&format!("name=\"{name}\"")), "{html}");
175 assert!(html.contains(&format!("for=\"{name}\"")), "{html}");
176 }
177 }
178
179 /// The mark and the chrome the binder looks for. Without all three
180 /// `markdown-editor.js` finds nothing and the reader is left with the plain
181 /// textarea, which is the no-script rendering rather than a failure, so
182 /// this is the test that says the enhancement has something to bind to.
183 #[test]
184 fn the_editor_chrome_is_there_for_the_binder() {
185 let html = super::html("text", "Content", "", "", Height::Tall);
186 assert!(html.contains(r#"<div data-format="markdown">"#), "{html}");
187 assert!(html.contains(r#"data-editor-mode="write""#), "{html}");
188 assert!(html.contains(r#"data-editor-mode="preview""#), "{html}");
189 assert!(html.contains("data-editor-preview"), "{html}");
190 }
191
192 /// The value is the emitter's to escape. Worth a test of its own because
193 /// the four templates it replaces escaped through Askama and this one does
194 /// not go through Askama at all.
195 #[test]
196 fn the_value_arrives_escaped() {
197 let html = super::html(
198 "text",
199 "Content",
200 "",
201 "</textarea><script>x</script>",
202 Height::Tall,
203 );
204 assert!(!html.contains("<script>"), "{html}");
205 assert!(html.contains("&lt;/textarea&gt;"), "{html}");
206 }
207
208 /// The wizard step, rendered whole. The four surfaces call this module
209 /// from Askama, so one of them is rendered here to say that the call site
210 /// is wired and that the id survived the conversion; the other three are
211 /// the same call with a different prefix.
212 #[test]
213 fn a_template_call_site_still_carries_the_id_its_script_reads() {
214 use askama::Template as _;
215
216 let html = crate::templates::WizardItemSectionsTemplate {
217 nav: Vec::new(),
218 project_slug: "a-project".into(),
219 item_id: "an-item".into(),
220 sections: Vec::new(),
221 }
222 .render()
223 .expect("render the sections step");
224
225 assert!(html.contains(r#"id="new-section-body""#), "{html}");
226 assert!(html.contains("rich-field--compact"), "{html}");
227 assert!(html.contains("data-editor-preview"), "{html}");
228 }
229
230 /// Each size is a class of its own, so a stylesheet rule can be missing
231 /// rather than silently shared.
232 #[test]
233 fn every_height_is_its_own_class() {
234 for (height, class) in [
235 (Height::Compact, "rich-field--compact"),
236 (Height::Standard, "rich-field--standard"),
237 (Height::Tall, "rich-field--tall"),
238 ] {
239 let html = super::html("text", "Body", "", "", height);
240 assert!(html.contains(class), "{html}");
241 }
242 }
243 }
244