Skip to main content

max / makenotwork

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