Skip to main content

max / makenotwork

24.5 KB · 601 lines History Blame Raw
1 //! The Askama entry point for the described media picker.
2 //!
3 //! Shape 4 of the conversion plan (wiki `mnw-shape-conversion-plans`), step 1.
4 //! [`Act::fills`](quasi_router::Act::fills) is what expresses the deposit, so
5 //! `static/media-picker.js` is gone.
6 //!
7 //! # What a card says now
8 //!
9 //! `Act::new(filename, Action::local()).filling("body", reference)`. The
10 //! destination is a field name and never a caret: a webview inserts at the
11 //! selection, and where inside a box a value lands was never the description's
12 //! to say. The action is [`Destination::Local`](quasi_router::Destination), so
13 //! a pick makes no request at all — the old picker made none either, and the
14 //! two round trips this shape does cost are the open and the dismiss.
15 //!
16 //! # Three defects the shipped picker has, which this does not
17 //!
18 //! Found reading `media-picker.js:83-84` against `MediaFileResponse`
19 //! (`routes/storage/media.rs:66`), and none of them is a conversion decision —
20 //! the described version cannot have any of the three, because the value is
21 //! held once on the server instead of being spelled again in a browser.
22 //!
23 //! 1. **Every card is unlabelled.** The JSON field is `filename`; the script
24 //! reads `f.file_name`, which is `undefined`. So `label.textContent` and
25 //! `img.alt` are both the empty string on every tile.
26 //! 2. **The name filter matches nothing.** `card.dataset.name` comes from the
27 //! same `undefined`, so typing one character hides the entire library.
28 //! 3. **The inserted reference is wrapped twice.** `markdown_ref` is already
29 //! `![](folder/file.png)` (`media.rs:109-113`) and `_mediaPickerSelect`
30 //! inserts `'![](' + ref + ')'`, so what lands in the box is
31 //! `![](![](folder/file.png))`.
32 //!
33 //! Filed as its own record rather than only fixed here, because (3) means the
34 //! documents people have already written with this button carry broken image
35 //! references that no redeploy repairs.
36 //!
37 //! # Why only the grid is replaced when a filter moves
38 //!
39 //! The plan said the filter controls carry `changes` and the server re-renders
40 //! the grid as a fragment. It said the fragment is aimed at the modal; it is
41 //! aimed at [`GRID`] instead, one region further in. Replacing the modal would
42 //! replace the box being typed into, which takes the caret with it on every
43 //! keystroke.
44 //!
45 //! The name box also asks through a [`Consult`] rather than through `changes`,
46 //! which is the plan's one other deviation and is the same call
47 //! [`crate::quasi::discover_typeahead`] made: `changes` fires per keystroke and
48 //! a media library is a Postgres round trip, where a consult carries the wait
49 //! that makes it one question per pause. The folder select keeps `changes`,
50 //! because a select settles rather than being typed into.
51 //!
52 //! # The destination has to be unique in the document, and that is new
53 //!
54 //! [`Act::fills`] names a [`Field::name`], and a name identifies a field within
55 //! one description. `Filling::id_prefix` exists because that stops being true
56 //! when a document holds two copies of one form, and
57 //! [`crate::quasi::rich_field`] leans on it: five editors, all named `body`,
58 //! told apart by their ids.
59 //!
60 //! So an editor a picker can write into has to be the only `body` in its
61 //! document. Both surfaces that had a working button already are:
62 //! `dashboard-blog-editor.html` holds `post-body` alone, and
63 //! `item_details.html` holds `text-body` alone under that name. The two section
64 //! editors on that same page were raw `<textarea>` elements with an id and no
65 //! name at all, which no description can address, so they are described here
66 //! too and carry names of their own. See `rich_field::named`.
67
68 use makeover_layout::{FieldKind, Fit};
69 use quasi_router::screen::Act;
70 use quasi_router::{Action, Choice, Consult, Field, Node, Picture, RegionKind, Slot};
71
72 /// The stem both region ids are built from.
73 const REGION: &str = "media-picker";
74
75 /// The region one editor's picker is drawn into, and the empty box its trigger
76 /// ships beside it.
77 ///
78 /// One id for both, so the answer replaces the placeholder rather than nesting
79 /// inside it, and a dismissal puts the placeholder back.
80 ///
81 /// **Per destination, not per page.** `item_details.html` carries three of
82 /// these buttons, and one shared id would be three elements answering to it —
83 /// which is a duplicate id whichever of them htmx then found first. A picker
84 /// belongs to the box it was opened for, so its region is named after that box.
85 #[must_use]
86 pub fn region_id(into: &str) -> String {
87 format!("{REGION}-{into}")
88 }
89
90 /// The panel inside the scrim.
91 ///
92 /// The modal region is the scrim — fixed, covering the viewport, and what stops
93 /// the page behind it being pressed — and this is the box that sits in the
94 /// middle of it. Two regions because a scrim and a panel are two boxes, and one
95 /// element cannot be both without the panel's contents being laid out across
96 /// the whole viewport.
97 #[must_use]
98 pub fn panel_id(into: &str) -> String {
99 format!("{REGION}-{into}-panel")
100 }
101
102 /// The region inside one picker that holds the tiles.
103 ///
104 /// Addressed separately from [`region_id`] so a filter answer leaves the filter
105 /// controls, and the caret in them, alone.
106 #[must_use]
107 pub fn grid_id(into: &str) -> String {
108 format!("{REGION}-{into}-grid")
109 }
110
111 /// Whether a destination name is one this module will build ids and selectors
112 /// out of.
113 ///
114 /// `into` arrives on a query string, so it is a reader's string by the time a
115 /// route hands it back here, and it lands in an `id` and inside a `#`-selector
116 /// in `hx-target`. Both are escaped by the emitter, so this is not what stops
117 /// an injection; what it stops is a selector that is escaped, well-formed and
118 /// means something other than the element it names. The same gate quasi's own
119 /// emitter puts in front of a handle it builds a program from.
120 ///
121 /// Every field name in the tree is already a plain handle.
122 #[must_use]
123 pub fn addressable(into: &str) -> bool {
124 !into.is_empty()
125 && into.len() <= 64
126 && into
127 .bytes()
128 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
129 }
130
131 /// Where the picker comes from, and where a filtered grid comes from.
132 pub const PATH: &str = "/media/picker";
133
134 /// Where the grid alone comes from.
135 pub const GRID_PATH: &str = "/media/picker/grid";
136
137 /// What dismissing it calls.
138 pub const CLOSE_PATH: &str = "/media/picker/close";
139
140 /// The name the destination field travels under.
141 ///
142 /// Carried on every action the picker emits, because the picker is opened *for*
143 /// a box and the route rebuilds the cards against it. The shipped script held
144 /// the same fact in a module-level `targetTextareaId`.
145 pub const INTO: &str = "into";
146
147 /// The name the typed filter travels under.
148 ///
149 /// Prefixed, and both of these are, because a fragment's ids come from its
150 /// field names and this fragment lands inside a document somebody else built.
151 /// A picker carrying `id="name"` into a page with a name field on it is two
152 /// elements answering to one id, which is what `Filling::id_prefix` exists to
153 /// stop inside a form and has no equivalent for a whole document.
154 pub const NAME: &str = "media-name";
155
156 /// The name the chosen folder travels under.
157 pub const FOLDER: &str = "media-folder";
158
159 /// What the panel is called, for a stylesheet.
160 const PANEL: &str = "media-picker";
161
162 /// What the grid of tiles is called.
163 const GRID_WIDGET: &str = "media-picker-grid";
164
165 /// A named assembly, which is the hook a stylesheet attaches to.
166 ///
167 /// `RegionKind::Widget` rather than `Group` on all three, because that is what
168 /// the member is for: "the hook a stylesheet or a script attaches to in order to
169 /// draw the assembly the way a browser does it", and a name that is app
170 /// vocabulary rather than a class this renderer owns. The ids stay unique per
171 /// destination and are what an answer is aimed at; the widget name is what the
172 /// grid and the tiles are drawn by, and it is the same string on every picker in
173 /// the document.
174 fn widget(name: &str) -> RegionKind {
175 RegionKind::Widget {
176 name: name.to_owned(),
177 }
178 }
179
180 /// How long the typed filter stands still before the grid is asked for.
181 ///
182 /// `discover_typeahead`'s number, and for its reason: it is the wait that makes
183 /// a typed filter one question per pause rather than one per keystroke.
184 const WAIT: std::time::Duration = std::time::Duration::from_millis(150);
185
186 /// One media file, as the picker needs it.
187 ///
188 /// A view of `MediaFileResponse` rather than that type, so this module names
189 /// what it draws and nothing else: the picker has no use for an id, a size or a
190 /// created-at, and taking the whole response would tie the description to the
191 /// JSON shape of an API route it does not call.
192 #[derive(Debug, Clone)]
193 pub struct Entry {
194 /// The media file's own id, which is the tile's region id.
195 ///
196 /// Only here because a tile is two members and the vocabulary has no way to
197 /// hold two things together without a region, and a region has an id. See
198 /// the header on the gap that costs.
199 pub id: String,
200 /// What is read on the tile.
201 pub filename: String,
202 /// Which folder it is in, empty for the root.
203 pub folder: String,
204 /// What lands in the editor. Already a markdown image reference; this
205 /// module never spells one.
206 pub reference: String,
207 /// Where the thumbnail comes from.
208 pub url: String,
209 /// Whether there is a thumbnail to show at all.
210 pub image: bool,
211 }
212
213 /// The button that opens the picker, and the empty region it lands in.
214 ///
215 /// Both together because they are one arrangement: a trigger whose answer has
216 /// nowhere to go is a button that swaps a modal into itself. `into` is the
217 /// [`Field::name`] of the editor the picked reference goes to.
218 #[must_use]
219 pub fn trigger(into: &str) -> String {
220 use quasi_axum::Serves as _;
221
222 let act = Act::new(
223 "Insert Image",
224 Action::get(PATH)
225 .carrying(INTO, into)
226 .replacing(region_id(into)),
227 );
228
229 format!(
230 "{}{}",
231 quasi_webview::Webview::new().fragment(&Node::Act(act)),
232 dismissed(into)
233 )
234 }
235
236 /// The picker itself: the filters, and the grid under them.
237 ///
238 /// `folders` is every folder the reader has, `name` and `folder` are the
239 /// filters as they currently stand, and `entries` is what those filters
240 /// already selected — the narrowing is the route's, not this module's, because
241 /// a filter the server applied is one query rather than a library sent to a
242 /// browser to hide most of.
243 #[must_use]
244 pub fn picker(
245 entries: &[Entry],
246 folders: &[String],
247 into: &str,
248 name: &str,
249 folder: &str,
250 ) -> String {
251 use quasi_axum::Serves as _;
252
253 let mut panel = Slot::new(panel_id(into), widget(PANEL))
254 .with(Node::section("Insert from Media Library"))
255 .with(Node::Field(Box::new(name_filter(into, name, folder))))
256 .with(Node::Field(Box::new(folder_filter(
257 folders, into, name, folder,
258 ))))
259 .with(Node::Region(grid_slot(entries, into)));
260
261 // Last, so the way out is the last thing a reader tabs to rather than the
262 // first. It is an ordinary act calling an ordinary route: the shipped
263 // dismissal was `display:none` plus a scrim click, and neither is
264 // describable — a region that is there and not shown is not a state the
265 // vocabulary has, and a scrim is not a control.
266 panel = panel.with(Node::Act(Act::new(
267 "Close",
268 Action::get(CLOSE_PATH)
269 .carrying(INTO, into)
270 .replacing(region_id(into)),
271 )));
272
273 let region = Slot::new(region_id(into), RegionKind::Modal).with(Node::Region(panel));
274
275 quasi_webview::Webview::new().fragment(&Node::Region(region))
276 }
277
278 /// The grid alone, for a filter that moved.
279 #[must_use]
280 pub fn grid(entries: &[Entry], into: &str) -> String {
281 use quasi_axum::Serves as _;
282
283 quasi_webview::Webview::new().fragment(&Node::Region(grid_slot(entries, into)))
284 }
285
286 /// The empty region, for a dismissal and for the page that has not opened one.
287 ///
288 /// The same markup in both cases on purpose: "closed" and "never opened" are
289 /// one state, so there is no third thing for a reader to be in.
290 #[must_use]
291 pub fn dismissed(into: &str) -> String {
292 use quasi_axum::Serves as _;
293
294 quasi_webview::Webview::new()
295 .fragment(&Node::Region(Slot::new(region_id(into), RegionKind::Group)))
296 }
297
298 /// The tiles, or the sentence that says why there are none.
299 fn grid_slot(entries: &[Entry], into: &str) -> Slot {
300 let mut slot = Slot::new(grid_id(into), widget(GRID_WIDGET));
301 if entries.is_empty() {
302 // Two different emptinesses read the same here, and deliberately: a
303 // library with nothing in it and a filter that matched nothing both
304 // leave the reader with the filters they can see and change.
305 return slot.with(Node::text(
306 "No media files match. Upload images in your Media Library tab first.",
307 ));
308 }
309 for entry in entries {
310 slot = slot.with(card(entry, into));
311 }
312 slot
313 }
314
315 /// One tile: one control, showing the thumbnail and saying the file name.
316 ///
317 /// The label is the file name, which is the fact the shipped script lost to a
318 /// misspelled JSON key. The deposit is `markdown_ref` verbatim, which is the
319 /// fact that script wrapped twice.
320 ///
321 /// `Action::local()`, so the press makes no request. The old picker made none
322 /// either; what it did instead was 155 lines of DOM building.
323 ///
324 /// # This was two members and half a tile until quasi 0.69.0
325 ///
326 /// The conversion found that an [`Act`] was a label and nothing said a control
327 /// reads as a picture, so the tile was a region holding the picture and the
328 /// control as siblings and only the file name answered a press -- a reader
329 /// aiming at the thumbnail, which is the whole affordance of a picture picker,
330 /// hit nothing. `Act::shows` (quasicoherent `db998898`) is that member, and the
331 /// tile is one control again.
332 ///
333 /// The region went with it, and so did the id it needed. [`Entry::id`] is kept
334 /// for the caller's convenience and no longer addresses anything.
335 fn card(entry: &Entry, into: &str) -> Node {
336 let mut act = Act::new(&entry.filename, Action::local()).filling(into, &entry.reference);
337 if entry.image {
338 // The alt is the file name, because that is what the tile is for: a
339 // reader who cannot see the thumbnail is choosing between file names,
340 // which is exactly what the control says.
341 let mut picture = Picture::new(&entry.url, &entry.filename).lazy();
342 // The letterbox, which is what the shipped tile did with
343 // `object-fit: contain`: a thumbnail grid whose files are not all one
344 // shape, and the whole picture matters more than filling the square.
345 picture.fit = Fit::Contain;
346 act = act.showing(picture);
347 }
348 Node::Act(act)
349 }
350
351 /// The typed filter.
352 ///
353 /// It asks rather than writes: see this module's header on why a consult and
354 /// not `changes`. The folder rides along under [`Consult::sending`], so
355 /// narrowing by name inside a folder stays inside it.
356 ///
357 /// The folder is also carried on the action, which looks redundant and is the
358 /// fallback: htmx drops an address parameter whose name the payload repeats, so
359 /// the live select wins whenever it is found, and the carried value is what a
360 /// document with no select in reach sends instead.
361 ///
362 /// `sending` names a field document-wide, which is the vocabulary's own
363 /// reading. A document holding two open pickers would therefore match two
364 /// selects and send both values. Not guarded, because a picker is a modal
365 /// covering the viewport and a reader cannot reach the second trigger while the
366 /// first is open; recorded so the next reader knows it was seen rather than
367 /// missed.
368 fn name_filter(into: &str, name: &str, folder: &str) -> Field {
369 let mut field = Field::new(FieldKind::Text, NAME, "Filter by name").consulting(
370 Consult::new(
371 Action::get(GRID_PATH)
372 .carrying(INTO, into)
373 .carrying(FOLDER, folder)
374 .replacing(grid_id(into)),
375 )
376 .after(WAIT)
377 .sending([FOLDER]),
378 );
379 field.placeholder = Some("Filter by name...".to_owned());
380 field.value = Some(name.to_owned());
381 field
382 }
383
384 /// The folder chooser.
385 ///
386 /// A consult and not a write: it narrows what the grid shows and puts nothing
387 /// anywhere. `Consult::at_once` because a select is at its next value or its
388 /// last one and there is nothing to wait out -- which is the half `aeb44860`
389 /// left with the description after moving the event to the renderer. It
390 /// carries the typed filter forward on the action, because a field's question
391 /// sends its own value and nothing else.
392 fn folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field {
393 let mut options = vec![Choice::new("", "All folders")];
394 options.extend(
395 folders
396 .iter()
397 .map(|folder| Choice::new(folder, if folder.is_empty() { "(root)" } else { folder })),
398 );
399
400 let mut field = Field::select(FOLDER, "Folder", options).consulting(Consult::at_once(
401 Action::get(GRID_PATH)
402 .carrying(INTO, into)
403 .carrying(NAME, name)
404 .replacing(grid_id(into)),
405 ));
406 field.value = Some(folder.to_owned());
407 field
408 }
409
410 #[cfg(test)]
411 mod tests {
412 use super::*;
413
414 fn entry(filename: &str, folder: &str) -> Entry {
415 let reference = if folder.is_empty() {
416 format!("![]({filename})")
417 } else {
418 format!("![]({folder}/{filename})")
419 };
420 Entry {
421 id: "11111111-1111-1111-1111-111111111111".to_owned(),
422 filename: filename.to_owned(),
423 folder: folder.to_owned(),
424 reference,
425 url: format!("https://cdn.test/{filename}"),
426 image: true,
427 }
428 }
429
430 /// The whole point of the conversion, and the member it waited eleven days
431 /// for: a card names the box it writes into and what lands there.
432 #[test]
433 fn a_card_names_the_editor_it_writes_into_and_the_reference_it_deposits() {
434 let html = grid(&[entry("kick.png", "drums")], "body");
435
436 assert!(html.contains(r#"data-fills="body""#), "{html}");
437 assert!(
438 html.contains(r#"data-fill="![](drums/kick.png)""#),
439 "{html}"
440 );
441 // Local, so the press is not a request. The shipped picker made none
442 // either; this says so in the description rather than by omission.
443 assert!(html.contains("data-local"), "{html}");
444 assert!(!html.contains("hx-get"), "{html}");
445 }
446
447 /// The tile is two members and the picture is one of them. Half a tile is
448 /// pressable, which is the gap this conversion found: an `Act` is a label,
449 /// so nothing says a control reads as a picture.
450 #[test]
451 fn a_tile_shows_the_thumbnail_beside_the_control() {
452 let html = grid(&[entry("kick.png", "drums")], "body");
453
454 assert!(
455 html.contains(r#"src="https://cdn.test/kick.png""#),
456 "{html}"
457 );
458 // The alt is the file name, because choosing without the thumbnail is
459 // choosing between names, which is what the control below says too.
460 assert!(html.contains(r#"alt="kick.png""#), "{html}");
461 assert!(html.contains(r#"loading="lazy""#), "{html}");
462 // The fit is in the description, and it is what lets `style.css` cap
463 // the tile's height with `max-height` alone: the design system already
464 // owns this picture's width, height and background, and an app rule
465 // taking one of those is what `makeover-build`'s drift check refuses.
466 assert!(html.contains(r#"data-fit="contain""#), "{html}");
467 // And the tile is the control, since quasi 0.69.0: the picture is
468 // inside the element that answers the press, so aiming at the thumbnail
469 // picks the file. It was a `media-card-<id>` region holding the picture
470 // and the button as siblings, and only the button answered.
471 assert!(
472 !html.contains("media-card-"),
473 "the per-tile region is gone: {html}"
474 );
475 let control = html
476 .split_once("<button")
477 .and_then(|(_, rest)| rest.split_once("</button>"))
478 .map_or("", |(inner, _)| inner);
479 assert!(control.contains("<img"), "{html}");
480 assert!(control.contains("kick.png"), "{html}");
481 }
482
483 /// A video has no thumbnail to show, so it is the control alone rather than
484 /// a broken image.
485 #[test]
486 fn a_file_with_nothing_to_show_is_the_control_alone() {
487 let mut clip = entry("intro.mp4", "");
488 clip.image = false;
489 let html = grid(&[clip], "body");
490
491 assert!(!html.contains("<img"), "{html}");
492 assert!(html.contains("intro.mp4"), "{html}");
493 }
494
495 /// Defect 1 of the three in the header. `media-picker.js` reads
496 /// `f.file_name` and the JSON says `filename`, so every tile shipped
497 /// unlabelled.
498 #[test]
499 fn a_card_reads_as_its_file_name() {
500 let html = grid(&[entry("kick.png", "drums")], "body");
501 assert!(html.contains("kick.png"), "{html}");
502 }
503
504 /// Defect 3. `markdown_ref` already carries the wrapper, so what the
505 /// shipped button inserted was `![](![](drums/kick.png))`.
506 #[test]
507 fn a_reference_is_deposited_exactly_once() {
508 let html = grid(&[entry("kick.png", "drums")], "body");
509 assert!(!html.contains("![](![]("), "{html}");
510 }
511
512 /// A file name is a reader's string on its way into an attribute, which is
513 /// the one thing the shipped script got right and got right the hard way,
514 /// with `textContent` and `dataset`. Here it is the emitter's ordinary
515 /// escaping, in both places a name lands.
516 #[test]
517 fn a_crafted_file_name_stays_a_value() {
518 let html = grid(&[entry(r#"" onclick="alert(1)"#, "")], "body");
519
520 assert!(!html.contains(r#"" onclick=""#), "{html}");
521 assert!(html.contains("&quot;"), "{html}");
522 }
523
524 /// The filters ask for the grid and not for the modal, so the box being
525 /// typed into survives its own answer.
526 #[test]
527 fn a_filter_replaces_the_grid_and_leaves_the_box_it_was_typed_into() {
528 let html = picker(
529 &[entry("kick.png", "")],
530 &["drums".to_owned()],
531 "body",
532 "",
533 "",
534 );
535
536 assert!(
537 html.contains(r##"hx-target="#media-picker-body-grid""##),
538 "{html}"
539 );
540 assert!(html.contains(r#"hx-get="/media/picker/grid?"#), "{html}");
541 // And the wait, which is what makes a typed filter one question per
542 // pause rather than one per keystroke against Postgres.
543 assert!(html.contains("delay:150ms"), "{html}");
544 }
545
546 /// The picker is opened for a box, so every action it emits carries which
547 /// one. The shipped script held this in a module-level variable.
548 #[test]
549 fn every_action_carries_the_editor_the_picker_was_opened_for() {
550 let html = picker(&[entry("kick.png", "")], &[], "post-body", "", "");
551 assert!(html.contains("into"), "{html}");
552 assert!(html.contains("post-body"), "{html}");
553 }
554
555 /// A modal, said in a word every renderer reads rather than in a fixed
556 /// position and a scrim.
557 #[test]
558 fn the_picker_is_a_modal_and_says_so() {
559 let html = picker(&[], &[], "body", "", "");
560 assert!(html.contains(r#"role="dialog""#), "{html}");
561 assert!(html.contains(r#"aria-modal="true""#), "{html}");
562 }
563
564 /// Closed and never-opened are one state, so a page holds the same empty
565 /// region before the first press and after the last dismissal.
566 #[test]
567 fn a_dismissal_leaves_the_region_the_page_started_with() {
568 let opened = trigger("body");
569 assert!(opened.contains(&dismissed("body")), "{opened}");
570 assert!(
571 dismissed("body").contains(r#"id="media-picker-body""#),
572 "{}",
573 dismissed("body")
574 );
575 assert!(!dismissed("body").contains("kick.png"));
576 }
577
578 /// The trigger opens the picker into the region beside it, rather than
579 /// swapping a modal into the button that asked for it.
580 #[test]
581 fn the_trigger_aims_at_the_region_it_ships_with() {
582 let html = trigger("body");
583 assert!(
584 html.contains(r#"hx-get="/media/picker?into=body""#),
585 "{html}"
586 );
587 assert!(
588 html.contains(r##"hx-target="#media-picker-body""##),
589 "{html}"
590 );
591 }
592
593 /// An empty library and an empty filter result read the same, and neither
594 /// is a blank box.
595 #[test]
596 fn nothing_to_show_says_so() {
597 let html = grid(&[], "body");
598 assert!(html.contains("No media files match"), "{html}");
599 }
600 }
601