Skip to main content

max / makenotwork

18.2 KB · 424 lines History Blame Raw
1 //! The item dashboard's Files panel, described.
2 //!
3 //! `138ad5ab`, and the trigger three riders were waiting on. `super::item_tabs`
4 //! has described the item tab *strip* since `6b24f2df` and named `item-files`
5 //! as one of its panels; the panel's contents stayed Askama, so
6 //! `9dbe9206` ("describe the version download button") read as ready to
7 //! `/threads` while having nowhere to put an `Action::get`.
8 //!
9 //! # The split: the read half is described, the uploader is bespoke
10 //!
11 //! `templates/partials/tabs/item_files.html` was six lines wrapping
12 //! `item_version_upload.html`, which is two different things in one file: a
13 //! table of the versions that exist, and a file-upload machine driven by
14 //! `static/item-upload.js` -- a queue, a progress bar, a speed readout, a
15 //! cancel, a retry and an error surface, all of it client-side state.
16 //!
17 //! The table is described here. The upload machine stays markup, mounted as a
18 //! [`RegionKind::Handover`] region, which is what a handover is for and
19 //! what `super::user_analytics` already does with its chart. Describing a
20 //! progress machine is its own conversion with its own vocabulary questions,
21 //! and `138ad5ab`'s SCOPE says to split rather than widen. So it is split, and
22 //! filed.
23 //!
24 //! # The download button, which is the rider
25 //!
26 //! `9dbe9206`, whose route half shipped as `MNW@34adcf76`: `GET
27 //! /api/versions/{id}/download` answers **303 to the presigned URL** rather
28 //! than JSON describing it (`8fc6b1af`, option (a)). That is what makes it
29 //! sayable: [`Action::get`] with no target emits an `href`, the browser
30 //! follows the redirect, and the download happens. The five-line click handler
31 //! in `static/item-upload.js` that navigated by hand goes with it.
32 //!
33 //! # The delete beside it changes its spelling, not its meaning
34 //!
35 //! [`super::version_delete_act`] targeted `#version-row-{id}`, an id the
36 //! template grew for it. A described table row carries `data-row` and no id, so
37 //! the act now says [`Action::replacing_enclosing`] -- the same sentence in the
38 //! spelling a described row understands. The route is untouched and still
39 //! answers an empty 200, which is why the act has to name what it replaced
40 //! either way.
41 //!
42 //! # What the reveal dance cost, and what replaces it
43 //!
44 //! A version with no file had an "Upload File" button in its row that unhid a
45 //! panel below the table, with a Cancel that hid it again: two `querySelectorAll`
46 //! blocks in `item-upload.js`, a `hidden` class, and a `data-version-id` the
47 //! button carried so the handler could find the right panel.
48 //!
49 //! A described cell holds leaves and acts, not regions, so that button cannot
50 //! name a panel to unhide. Rather than invent a member for it, the panels are
51 //! simply shown: one labelled upload area per file-less version, in the
52 //! uploader region, each already carrying its own address
53 //! ([`super::upload_field::existing_version`]). Nothing is hidden, so nothing
54 //! has to be revealed.
55 //!
56 //! **This is a visible change and it is recorded rather than buried.** A reader
57 //! with three file-less versions now sees three upload areas where they saw
58 //! three buttons. That is the honest shape of what the screen offers, and it is
59 //! a affordance change Max may want to look at.
60 //!
61 //! # Can the uploader move to the description layer?
62 //!
63 //! Three shapes, counted across the tree (MNW server, goingson,
64 //! balanced_breakfast, audiofiles, quasi, makeover-layout). Two of the three
65 //! need no new vocabulary at all, and the third is earned by the count but is a
66 //! cross-repo ruling rather than this pass.
67 //!
68 //! **Progress with a filename, a percent, a rate and a cancel: no member
69 //! wanted.** This is already ruled. [`Action::awaiting`] says the call waits
70 //! and [`Action::by_host`] says who makes it, and
71 //! [`super::upload_field`]'s header states the consequence: the mark says the
72 //! call waits, and the renderer observes the rest. `static/upload.js`'s
73 //! `progressPanel` is that observation, built in the host with no markup
74 //! written down anywhere, and it already serves every described upload field.
75 //! `Node::Meter` is not the shape and does not need widening. What the region
76 //! below still holds is progress markup for the *queue's* run, which is markup
77 //! only because the queue is.
78 //!
79 //! **A surface that persists until reset, with one thing to do about it: no
80 //! member wanted.** `layout::Notice::Banner` is spelled "persistent, in flow,
81 //! one per region, dismissed by fixing the cause", and [`Node::Notice`] grew an
82 //! optional act (`bde35298`) precisely so that a sentence about a situation and
83 //! the one thing to do about it are one thing. Success, error and Try Again are
84 //! all sayable today. They are not said here because they are client state
85 //! reached after an XHR rather than anything the server renders, and the
86 //! described upload path already answers a failure with a toast.
87 //!
88 //! **A queue of picked files, each with a status: 3 consumers, earned.** This
89 //! panel's `#version-upload-queue`, the bundle wizard's `#batch-file-rows`
90 //! (`templates/wizards/steps/item/content.html`, driven by
91 //! `static/wizard-item-content-bundle.js`), and the media library's
92 //! `#media-upload-progress` (`static/tab-user-media.js`). Nothing outside MNW
93 //! has the shape: goingson, balanced_breakfast and audiofiles have no picked-file
94 //! queue between them.
95 //!
96 //! **The same queue with an editable field per row: 2 consumers, earned by the
97 //! count and deferred anyway.** This panel carries one label per file; the
98 //! bundle wizard carries a title and a type per file. Two is the bar, and two
99 //! consumers that disagree about how many fields a row holds means the member
100 //! has to be a slot holding *several* fields.
101 //!
102 //! There are two near misses and neither is it. [`Repeat`] is a repeating
103 //! *field*: a slot is one answer to one question, and its own docs name the
104 //! wire naming that would have to grow (`name[0].label` beside `name[0]`).
105 //! [`Repeating`] (`f7abbc08`, ruled by Max the same day, over widening
106 //! `Repeat`) is the repeating *group*, which is the phrase this queue wants,
107 //! and it still does not carry the queue. Its own text is what says so, on
108 //! both of the points that separate a picked-file queue from a group of
109 //! answers:
110 //!
111 //! - Its `add` is a whole [`Act`] "because adding a slot here is a route: the
112 //! group's state is the app's". The queue's slots are not added by anything
113 //! that can be a route. They arrive from a file picker, live in the browser,
114 //! and are gone once the run finishes, which is the case `Repeating` names
115 //! as the *other* one when it contrasts a repeating field "whose slots live
116 //! in the renderer's own view until they are submitted".
117 //! - Its wire section carries nothing, because "these regions each carry their
118 //! own fields with their own writes, so every slot is already addressed by
119 //! the routes inside it". A queue slot has no route of its own to be
120 //! addressed by. Its label is read back out of the row at submit time
121 //! (`item-upload.js` reads `.version-label-input[data-idx]`), which is
122 //! exactly the `name[0].label` naming `Repeat` says it does not have.
123 //!
124 //! And the queue trips `Repeating`'s stated reopening condition head on: it
125 //! deliberately carries no per-slot error, "**that is the reopening
126 //! condition**", while a queue row's whole reason to exist is a per-row status
127 //! that includes failure (`updateQueueStatus`, four states, one of them
128 //! `error`). So the member is still missing, and the shape the evidence points
129 //! at is the wider `Repeat` that ruling set aside rather than a second region
130 //! member beside `Repeating`.
131 //!
132 //! That is a vocabulary ruling to reopen and a forward fix across two sibling
133 //! repos, not a panel conversion, and reopening a call Max made hours earlier
134 //! is his to make rather than this pass's. The counting is done and written
135 //! down here so the next pass does not repeat it; the region stays until the
136 //! member exists.
137 //!
138 //! # What went anyway, once the counting said which part was blocked
139 //!
140 //! Only the queue is blocked, so only the queue is still markup. Everything
141 //! the new-version form said around it is described now
142 //! ([`super::upload_field::version_details`],
143 //! [`super::upload_field::version_queue`] and
144 //! [`super::upload_field::version_upload_all`]): the version number, the
145 //! notes, the picker with its own label and standing help, and the control the
146 //! run starts at. The template had been writing a second `Files` label above
147 //! the one the picker already emits, which is the duplication a description
148 //! removes rather than a look to preserve.
149 //!
150 //! Two addresses came off `static/item-upload.js` with them and neither is
151 //! written down twice any more. The route the run starts at is the act's
152 //! `data-sends`, where it built `/api/items/<id>/versions` by hand from a
153 //! `data-item-id` attribute on the wrapper; where to land afterwards is the
154 //! wrapper's `data-upload-goes`, the attribute every described upload on this
155 //! surface already answers to. The wrapper carries no item id at all now.
156 //!
157 //! What is left in the region is one thing and it is the blocked thing: a
158 //! table of picked files with a label field per row, the progress surface the
159 //! host draws for a run of several files, and the success and error surfaces
160 //! that outlive it.
161 //!
162 //! [`Node::Notice`]: quasi_router::Node::Notice
163 //! [`Repeat`]: quasi_router::screen::Repeat
164 //! [`Repeating`]: quasi_router::Repeating
165 //! [`Act`]: quasi_router::screen::Act
166 //! [`Action::awaiting`]: quasi_router::Action::awaiting
167 //! [`Action::by_host`]: quasi_router::Action::by_host
168 //! [`Action::get`]: quasi_router::Action::get
169 //! [`Action::replacing_enclosing`]: quasi_router::Action::replacing_enclosing
170
171 use makeover_layout as layout;
172 use quasi_declare::declare;
173 use quasi_router::RegionKind;
174 use quasi_router::screen::Tag;
175 use quasi_webview::Webview;
176
177 use crate::types::Version;
178
179 /// The region the answer lands in: this panel's frame in the described item tab
180 /// strip. `super::item_tabs` draws the frame from the same name.
181 pub const REGION: &str = "item-files";
182
183 /// The slot the upload machine's own markup mounts into.
184 ///
185 /// Not `version-upload`, which is the id *inside* the fill: `item-upload.js`
186 /// reads `#version-upload`'s `data-upload-goes`, and a described region cannot
187 /// carry a host's dataset. So the region is a place and the host's wrapper
188 /// keeps its own id, which is decision 4 working as intended rather than a
189 /// workaround.
190 const UPLOADER_SLOT: &str = "item-files-uploader";
191
192 /// The panel, for the tab route and the page handler to answer with.
193 ///
194 /// `uploader` is the markup for the bespoke region: everything below the table
195 /// that `item-upload.js` drives. The caller renders it, the same way
196 /// `super::item_tabs`' caller renders the shown panel.
197 #[must_use]
198 pub fn fragment(item_id: &str, versions: &[Version], uploader: &str) -> String {
199 use quasi_axum::Serves as _;
200
201 Webview::new()
202 .with_fill(UPLOADER_SLOT, uploader)
203 .fragment(&pane(item_id, versions))
204 }
205
206 declare! {
207 /// The panel in its region.
208 ///
209 /// The upload machine sits below the table on an empty panel too, which is
210 /// what "create your first version below" points at.
211 shape pane(item_id: &str, versions: &[Version]) -> Node;
212
213 region REGION as Pane {
214 section "Files & Versions";
215 empty "No versions uploaded yet. Create your first version below."
216 when versions.is_empty();
217 include table(item_id, versions) unless versions.is_empty();
218
219 region UPLOADER_SLOT as RegionKind::handover("version-upload") {}
220 }
221 }
222
223 declare! {
224 /// The versions that exist.
225 ///
226 /// The column list lives here and the cells live in [`row`], a shape down,
227 /// so the cells name their columns rather than counting to them: position is
228 /// only safe while both halves are in front of you at once, and these two
229 /// are not. The heading strings below are the whole of what [`row`] has to
230 /// match.
231 ///
232 /// Nothing held back: every version of an item arrives in one query, so
233 /// there is no rest to ask for.
234 shape table(item_id: &str, versions: &[Version]) -> Node;
235
236 table {
237 column COL_VERSION {
238 width Content;
239 }
240 column COL_LABEL {
241 width Fill;
242 }
243 column COL_FILE {
244 width Fill;
245 priority Essential;
246 }
247 column COL_SIZE {
248 width Content;
249 }
250 column COL_DOWNLOADS {
251 width Content;
252 }
253 column COL_ACTS {
254 width Content;
255 }
256
257 for version in versions.iter() {
258 include row(item_id, version);
259 }
260 }
261 }
262
263 /// The headings, written once.
264 ///
265 /// A cell that names a column nothing has is dropped silently, so the two
266 /// halves share the string rather than each spelling it. [`COL_ACTS`] is the
267 /// unlabelled column the Download and Delete controls sit in: a heading over a
268 /// pair of buttons says nothing a reader needs, and the empty name is still the
269 /// name the cell has to match.
270 const COL_VERSION: &str = "Version";
271 const COL_LABEL: &str = "Label";
272 const COL_FILE: &str = "File";
273 const COL_SIZE: &str = "Size";
274 const COL_DOWNLOADS: &str = "Downloads";
275 const COL_ACTS: &str = "";
276
277 /// The current version's badge is the one that reads as a state rather than a
278 /// number.
279 fn number_tone(version: &Version) -> layout::Tone {
280 if version.is_current {
281 layout::Tone::Success
282 } else {
283 layout::Tone::Neutral
284 }
285 }
286
287 /// What the file cell says.
288 ///
289 /// The template's three-way `{% if %}`/`{% match %}`: the file's name when it
290 /// has one, "1 file" when it has a file the name of which was not recorded, and
291 /// "No file" when it has none. A supplier because the middle case is a fallback
292 /// computed from an `Option`, which the form has no expression to hold.
293 fn file(version: &Version) -> String {
294 if !version.has_file {
295 return "No file".to_string();
296 }
297 version
298 .file_name
299 .clone()
300 .unwrap_or_else(|| "1 file".to_string())
301 }
302
303 declare! {
304 /// One version.
305 shape row(item_id: &str, version: &Version) -> Row;
306
307 cells {
308 cell at COL_VERSION "" {
309 token Tag::badge("v{version.number}").tone(number_tone(version));
310 }
311 cell at COL_LABEL version.label.clone().unwrap_or_default();
312 cell at COL_FILE file(version);
313 cell at COL_SIZE version.size.clone();
314 cell at COL_DOWNLOADS version.downloads.to_string();
315 cell at COL_ACTS "" {
316 // `9dbe9206`. The route answers 303 to the presigned URL, so going
317 // there is the download and no host code has to know that.
318 act "Download" to get "/api/versions/{version.id}/download"
319 when version.has_file;
320 include super::version_delete_act::act(item_id, &version.id);
321 }
322 }
323 }
324
325 #[cfg(test)]
326 mod tests {
327 use super::*;
328
329 fn version(id: &str, has_file: bool, current: bool) -> Version {
330 Version {
331 id: id.to_string(),
332 number: "1.0".to_string(),
333 uploaded_date: "2026-08-30".to_string(),
334 file_count: 1,
335 size: "12 MB".to_string(),
336 downloads: 7,
337 status: "ready".to_string(),
338 is_current: current,
339 has_file,
340 file_name: has_file.then(|| "plugin.zip".to_string()),
341 label: Some("macOS (arm)".to_string()),
342 }
343 }
344
345 fn panel(versions: &[Version]) -> String {
346 fragment("itm_1", versions, "<div id=\"the-uploader\"></div>")
347 }
348
349 /// The panel keeps the id the tab strip draws its frame from. If these two
350 /// disagree, pressing Files swaps the answer into nothing.
351 #[test]
352 fn the_panel_is_the_frame_the_strip_names() {
353 let html = panel(&[version("v1", true, true)]);
354
355 assert!(html.contains(r#"id="item-files""#), "{html}");
356 }
357
358 /// The rider. A described `Action::get` and nothing else: the route
359 /// redirects, so there is no JSON to parse and no navigation to write by
360 /// hand.
361 #[test]
362 fn a_version_with_a_file_offers_a_described_download() {
363 let html = panel(&[version("v1", true, false)]);
364
365 assert!(
366 html.contains(r#"href="/api/versions/v1/download""#),
367 "{html}"
368 );
369 assert!(html.contains(">Download<"), "{html}");
370 // The class the click handler bound to is gone with the handler.
371 assert!(!html.contains("download-version-btn"), "{html}");
372 }
373
374 /// A version with nothing in it has nothing to download, and still has a
375 /// delete.
376 #[test]
377 fn a_version_with_no_file_offers_no_download() {
378 let html = panel(&[version("v1", false, false)]);
379
380 assert!(!html.contains("/download"), "{html}");
381 assert!(html.contains("No file"), "{html}");
382 assert!(html.contains(">Delete<"), "{html}");
383 }
384
385 /// The delete's new spelling. A described row carries `data-row` and no id,
386 /// so naming `#version-row-v1` would target nothing.
387 #[test]
388 fn the_delete_replaces_the_row_it_sits_in() {
389 let html = panel(&[version("v1", true, false)]);
390
391 assert!(
392 html.contains(r#"hx-delete="/api/items/itm_1/versions/v1""#),
393 "{html}"
394 );
395 assert!(!html.contains("version-row-v1"), "{html}");
396 assert!(html.contains("data-row"), "{html}");
397 assert!(
398 html.contains(r#"hx-confirm="Delete this version?""#),
399 "{html}"
400 );
401 }
402
403 /// The current version is marked, which the template did with a toned
404 /// badge.
405 #[test]
406 fn the_current_version_is_toned() {
407 assert!(panel(&[version("v1", true, true)]).contains(r#"data-tone="success""#));
408 assert!(!panel(&[version("v1", true, false)]).contains(r#"data-tone="success""#));
409 }
410
411 /// The upload machine is mounted whatever the table says, including on a
412 /// panel with no versions at all -- which is what "create your first
413 /// version below" points at.
414 #[test]
415 fn the_uploader_is_mounted_either_way() {
416 assert!(panel(&[version("v1", true, false)]).contains("the-uploader"));
417
418 let empty = panel(&[]);
419 assert!(empty.contains("the-uploader"), "{empty}");
420 assert!(empty.contains("No versions uploaded yet"), "{empty}");
421 assert!(!empty.contains("Downloads"), "{empty}");
422 }
423 }
424