max / makenotwork
| 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 ; |
| 149 | use ; |
| 150 | use Webview; |
| 151 | |
| 152 | use crateVersion; |
| 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 | |
| 173 | |
| 174 | use Serves as _; |
| 175 | |
| 176 | new |
| 177 | .with_fill |
| 178 | .fragment |
| 179 | |
| 180 | |
| 181 | /// The panel in its region. |
| 182 | |
| 183 | let mut slot = new.with; |
| 184 | |
| 185 | if versions.is_empty |
| 186 | slot = slot.with |
| 187 | "No versions uploaded yet. Create your first version below.", |
| 188 | ; |
| 189 | else |
| 190 | slot = slot.with; |
| 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; |
| 196 | |
| 197 | Region |
| 198 | |
| 199 | |
| 200 | /// The versions that exist. |
| 201 | |
| 202 | Table |
| 203 | columns: vec! |
| 204 | new.width, |
| 205 | new.width, |
| 206 | new |
| 207 | .width |
| 208 | .priority, |
| 209 | new.width, |
| 210 | new.width, |
| 211 | new.width, |
| 212 | ], |
| 213 | rows: versions |
| 214 | .iter |
| 215 | .map |
| 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 | |
| 225 | let mut number = badge; |
| 226 | if version.is_current |
| 227 | number.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 |
| 238 | else |
| 239 | "No file".to_string |
| 240 | ; |
| 241 | |
| 242 | let mut acts = Vecnew; |
| 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 |
| 247 | "Download", |
| 248 | get, |
| 249 | ; |
| 250 | |
| 251 | acts.push; |
| 252 | |
| 253 | new |
| 254 | new.token, |
| 255 | new, |
| 256 | new, |
| 257 | new, |
| 258 | new, |
| 259 | acts, |
| 260 | ] |
| 261 | |
| 262 | |
| 263 | |
| 264 | |
| 265 | use *; |
| 266 | |
| 267 | |
| 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, |
| 279 | label: Some, |
| 280 | |
| 281 | |
| 282 | |
| 283 | |
| 284 | fragment |
| 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 | |
| 290 | |
| 291 | let html = panel; |
| 292 | |
| 293 | assert!; |
| 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 | |
| 300 | |
| 301 | let html = panel; |
| 302 | |
| 303 | assert! |
| 304 | html.contains, |
| 305 | "{html}" |
| 306 | ; |
| 307 | assert!; |
| 308 | // The class the click handler bound to is gone with the handler. |
| 309 | assert!; |
| 310 | |
| 311 | |
| 312 | /// A version with nothing in it has nothing to download, and still has a |
| 313 | /// delete. |
| 314 | |
| 315 | |
| 316 | let html = panel; |
| 317 | |
| 318 | assert!; |
| 319 | assert!; |
| 320 | assert!; |
| 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 | |
| 326 | |
| 327 | let html = panel; |
| 328 | |
| 329 | assert! |
| 330 | html.contains, |
| 331 | "{html}" |
| 332 | ; |
| 333 | assert!; |
| 334 | assert!; |
| 335 | assert! |
| 336 | html.contains, |
| 337 | "{html}" |
| 338 | ; |
| 339 | |
| 340 | |
| 341 | /// The current version is marked, which the template did with a toned |
| 342 | /// badge. |
| 343 | |
| 344 | |
| 345 | assert!; |
| 346 | assert!; |
| 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 | |
| 353 | |
| 354 | assert!; |
| 355 | |
| 356 | let empty = panel; |
| 357 | assert!; |
| 358 | assert!; |
| 359 | assert!; |
| 360 | |
| 361 | |
| 362 |