Skip to main content

max / makenotwork

17.2 KB · 389 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_router::screen::{Act, Cell, Cells, Column, Tag};
173 use quasi_router::{Action, Node, RegionKind, Slot};
174 use quasi_webview::Webview;
175
176 use crate::types::Version;
177
178 /// The region the answer lands in: this panel's frame in the described item tab
179 /// strip. `super::item_tabs` draws the frame from the same name.
180 pub const REGION: &str = "item-files";
181
182 /// The slot the upload machine's own markup mounts into.
183 ///
184 /// Not `version-upload`, which is the id *inside* the fill: `item-upload.js`
185 /// reads `#version-upload`'s `data-upload-goes`, and a described region cannot
186 /// carry a host's dataset. So the region is a place and the host's wrapper
187 /// keeps its own id, which is decision 4 working as intended rather than a
188 /// workaround.
189 const UPLOADER_SLOT: &str = "item-files-uploader";
190
191 /// The panel, for the tab route and the page handler to answer with.
192 ///
193 /// `uploader` is the markup for the bespoke region: everything below the table
194 /// that `item-upload.js` drives. The caller renders it, the same way
195 /// `super::item_tabs`' caller renders the shown panel.
196 #[must_use]
197 pub fn fragment(item_id: &str, versions: &[Version], uploader: &str) -> String {
198 use quasi_axum::Serves as _;
199
200 Webview::new()
201 .with_fill(UPLOADER_SLOT, uploader)
202 .fragment(&pane(item_id, versions))
203 }
204
205 /// The panel in its region.
206 fn pane(item_id: &str, versions: &[Version]) -> Node {
207 let mut slot = Slot::new(REGION, RegionKind::Pane).with(Node::section("Files & Versions"));
208
209 if versions.is_empty() {
210 slot = slot.with(Node::empty(
211 "No versions uploaded yet. Create your first version below.",
212 ));
213 } else {
214 slot = slot.with(table(item_id, versions));
215 }
216
217 // The upload machine, whatever state it is in. Below the table on an empty
218 // panel too, which is what "create your first version below" points at.
219 slot = slot.with(Node::Region(Slot::handover(
220 UPLOADER_SLOT,
221 "version-upload",
222 )));
223
224 Node::Region(slot)
225 }
226
227 /// The versions that exist.
228 fn table(item_id: &str, versions: &[Version]) -> Node {
229 Node::Table {
230 columns: vec![
231 Column::new("Version").width(layout::Width::Content),
232 Column::new("Label").width(layout::Width::Fill),
233 Column::new("File")
234 .width(layout::Width::Fill)
235 .priority(layout::Priority::Essential),
236 Column::new("Size").width(layout::Width::Content),
237 Column::new("Downloads").width(layout::Width::Content),
238 Column::new("").width(layout::Width::Content),
239 ],
240 rows: versions
241 .iter()
242 .map(|version| row(item_id, version))
243 .collect(),
244 // Every version of an item, in one query. Nothing is held back, so
245 // there is no rest to ask for.
246 more: None,
247 }
248 }
249
250 /// One version.
251 fn row(item_id: &str, version: &Version) -> Cells {
252 let mut number = Tag::badge(format!("v{}", version.number));
253 if version.is_current {
254 number.tone = layout::Tone::Success;
255 }
256
257 // What the template said in a three-way `{% if %}`/`{% match %}`: the
258 // file's name when it has one, "1 file" when it has a file the name of
259 // which was not recorded, and "No file" when it has none.
260 let file = if version.has_file {
261 version
262 .file_name
263 .clone()
264 .unwrap_or_else(|| "1 file".to_string())
265 } else {
266 "No file".to_string()
267 };
268
269 let mut acts = Vec::new();
270 if version.has_file {
271 // `9dbe9206`. The route answers 303 to the presigned URL, so going
272 // there is the download and no host code has to know that.
273 acts.push(Act::new(
274 "Download",
275 Action::get(format!("/api/versions/{}/download", version.id)),
276 ));
277 }
278 acts.push(super::version_delete_act::act(item_id, &version.id));
279
280 Cells::new([
281 Cell::new(String::new()).token(number),
282 Cell::new(version.label.clone().unwrap_or_default()),
283 Cell::new(file),
284 Cell::new(version.size.clone()),
285 Cell::new(version.downloads.to_string()),
286 Cell::acts(acts),
287 ])
288 }
289
290 #[cfg(test)]
291 mod tests {
292 use super::*;
293
294 fn version(id: &str, has_file: bool, current: bool) -> Version {
295 Version {
296 id: id.to_string(),
297 number: "1.0".to_string(),
298 uploaded_date: "2026-08-30".to_string(),
299 file_count: 1,
300 size: "12 MB".to_string(),
301 downloads: 7,
302 status: "ready".to_string(),
303 is_current: current,
304 has_file,
305 file_name: has_file.then(|| "plugin.zip".to_string()),
306 label: Some("macOS (arm)".to_string()),
307 }
308 }
309
310 fn panel(versions: &[Version]) -> String {
311 fragment("itm_1", versions, "<div id=\"the-uploader\"></div>")
312 }
313
314 /// The panel keeps the id the tab strip draws its frame from. If these two
315 /// disagree, pressing Files swaps the answer into nothing.
316 #[test]
317 fn the_panel_is_the_frame_the_strip_names() {
318 let html = panel(&[version("v1", true, true)]);
319
320 assert!(html.contains(r#"id="item-files""#), "{html}");
321 }
322
323 /// The rider. A described `Action::get` and nothing else: the route
324 /// redirects, so there is no JSON to parse and no navigation to write by
325 /// hand.
326 #[test]
327 fn a_version_with_a_file_offers_a_described_download() {
328 let html = panel(&[version("v1", true, false)]);
329
330 assert!(
331 html.contains(r#"href="/api/versions/v1/download""#),
332 "{html}"
333 );
334 assert!(html.contains(">Download<"), "{html}");
335 // The class the click handler bound to is gone with the handler.
336 assert!(!html.contains("download-version-btn"), "{html}");
337 }
338
339 /// A version with nothing in it has nothing to download, and still has a
340 /// delete.
341 #[test]
342 fn a_version_with_no_file_offers_no_download() {
343 let html = panel(&[version("v1", false, false)]);
344
345 assert!(!html.contains("/download"), "{html}");
346 assert!(html.contains("No file"), "{html}");
347 assert!(html.contains(">Delete<"), "{html}");
348 }
349
350 /// The delete's new spelling. A described row carries `data-row` and no id,
351 /// so naming `#version-row-v1` would target nothing.
352 #[test]
353 fn the_delete_replaces_the_row_it_sits_in() {
354 let html = panel(&[version("v1", true, false)]);
355
356 assert!(
357 html.contains(r#"hx-delete="/api/items/itm_1/versions/v1""#),
358 "{html}"
359 );
360 assert!(!html.contains("version-row-v1"), "{html}");
361 assert!(html.contains("data-row"), "{html}");
362 assert!(
363 html.contains(r#"hx-confirm="Delete this version?""#),
364 "{html}"
365 );
366 }
367
368 /// The current version is marked, which the template did with a toned
369 /// badge.
370 #[test]
371 fn the_current_version_is_toned() {
372 assert!(panel(&[version("v1", true, true)]).contains(r#"data-tone="success""#));
373 assert!(!panel(&[version("v1", true, false)]).contains(r#"data-tone="success""#));
374 }
375
376 /// The upload machine is mounted whatever the table says, including on a
377 /// panel with no versions at all -- which is what "create your first
378 /// version below" points at.
379 #[test]
380 fn the_uploader_is_mounted_either_way() {
381 assert!(panel(&[version("v1", true, false)]).contains("the-uploader"));
382
383 let empty = panel(&[]);
384 assert!(empty.contains("the-uploader"), "{empty}");
385 assert!(empty.contains("No versions uploaded yet"), "{empty}");
386 assert!(!empty.contains("Downloads"), "{empty}");
387 }
388 }
389