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::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 declare; |
| 173 | use RegionKind; |
| 174 | use Tag; |
| 175 | use Webview; |
| 176 | |
| 177 | use crateVersion; |
| 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 | |
| 198 | |
| 199 | use Serves as _; |
| 200 | |
| 201 | new |
| 202 | .with_fill |
| 203 | .fragment |
| 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 ; |
| 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 unless versions.is_empty; |
| 218 | |
| 219 | region UPLOADER_SLOT as handover |
| 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 ; |
| 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; |
| 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 | |
| 280 | if version.is_current |
| 281 | Success |
| 282 | else |
| 283 | 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 | |
| 294 | if !version.has_file |
| 295 | return "No file".to_string; |
| 296 | |
| 297 | version |
| 298 | .file_name |
| 299 | .clone |
| 300 | .unwrap_or_else |
| 301 | |
| 302 | |
| 303 | declare! |
| 304 | /// One version. |
| 305 | shape row ; |
| 306 | |
| 307 | cells |
| 308 | cell at COL_VERSION "" |
| 309 | token badge.tone; |
| 310 | |
| 311 | cell at COL_LABEL version.label.clone.unwrap_or_default; |
| 312 | cell at COL_FILE file; |
| 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 act; |
| 321 | |
| 322 | |
| 323 | |
| 324 | |
| 325 | |
| 326 | |
| 327 | use *; |
| 328 | |
| 329 | |
| 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, |
| 341 | label: Some, |
| 342 | |
| 343 | |
| 344 | |
| 345 | |
| 346 | fragment |
| 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 | |
| 352 | |
| 353 | let html = panel; |
| 354 | |
| 355 | assert!; |
| 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 | |
| 362 | |
| 363 | let html = panel; |
| 364 | |
| 365 | assert! |
| 366 | html.contains, |
| 367 | "{html}" |
| 368 | ; |
| 369 | assert!; |
| 370 | // The class the click handler bound to is gone with the handler. |
| 371 | assert!; |
| 372 | |
| 373 | |
| 374 | /// A version with nothing in it has nothing to download, and still has a |
| 375 | /// delete. |
| 376 | |
| 377 | |
| 378 | let html = panel; |
| 379 | |
| 380 | assert!; |
| 381 | assert!; |
| 382 | assert!; |
| 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 | |
| 388 | |
| 389 | let html = panel; |
| 390 | |
| 391 | assert! |
| 392 | html.contains, |
| 393 | "{html}" |
| 394 | ; |
| 395 | assert!; |
| 396 | assert!; |
| 397 | assert! |
| 398 | html.contains, |
| 399 | "{html}" |
| 400 | ; |
| 401 | |
| 402 | |
| 403 | /// The current version is marked, which the template did with a toned |
| 404 | /// badge. |
| 405 | |
| 406 | |
| 407 | assert!; |
| 408 | assert!; |
| 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 | |
| 415 | |
| 416 | assert!; |
| 417 | |
| 418 | let empty = panel; |
| 419 | assert!; |
| 420 | assert!; |
| 421 | assert!; |
| 422 | |
| 423 | |
| 424 |