Skip to main content

max / makenotwork

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