max / makenotwork
5 files changed,
+219 insertions,
-79 deletions
| @@ -6,11 +6,16 @@ | |||
| 6 | 6 | * described field apiece now (`crate::quasi::upload_field`), and the binder in | |
| 7 | 7 | * `upload.js` runs the presign chain behind each. What stays here is the part | |
| 8 | 8 | * that is not one file going to one place: the queue a new version is built | |
| 9 | - | * from, which is a version number, a label per file and three requests per | |
| 10 | - | * file, and the download and delete buttons on the version table. | |
| 9 | + | * from, which is a label per file and three requests per file. | |
| 10 | + | * | |
| 11 | + | * The version number, the notes, the picker and Upload All are all described | |
| 12 | + | * (`crate::quasi::upload_field`), so nothing here writes a route or a label | |
| 13 | + | * down: the run starts at the act's `data-sends` and lands on the surface's | |
| 14 | + | * `data-upload-goes`. What is left with no member is the queue itself, a row | |
| 15 | + | * per picked file with a field in it; the counting behind that is in | |
| 16 | + | * `crate::quasi::item_files`. | |
| 11 | 17 | * | |
| 12 | 18 | * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap. | |
| 13 | - | * Reads item ID from data-item-id on the container element. | |
| 14 | 19 | * Depends on: upload.js (S3Uploader), the core module (csrfHeaders, showToast). | |
| 15 | 20 | */ | |
| 16 | 21 | (function() { | |
| @@ -38,8 +43,14 @@ | |||
| 38 | 43 | function initVersionUpload() { | |
| 39 | 44 | var container = document.getElementById('version-upload'); | |
| 40 | 45 | if (!container) return; | |
| 41 | - | var itemId = container.dataset.itemId; | |
| 42 | - | if (!itemId) return; | |
| 46 | + | ||
| 47 | + | // Both addresses are on the markup now: the run starts at the act's | |
| 48 | + | // own `data-sends` and lands on the surface's `data-upload-goes`. | |
| 49 | + | var createBtn = container.querySelector('button[data-act][data-sends]'); | |
| 50 | + | if (!createBtn) return; | |
| 51 | + | var createRoute = createBtn.dataset.sends; | |
| 52 | + | var createLabel = createBtn.textContent; | |
| 53 | + | var landsOn = container.dataset.uploadGoes; | |
| 43 | 54 | ||
| 44 | 55 | var fileQueue = []; | |
| 45 | 56 | ||
| @@ -93,7 +104,7 @@ | |||
| 93 | 104 | } | |
| 94 | 105 | ||
| 95 | 106 | // Upload all button | |
| 96 | - | document.getElementById('create-version-btn').addEventListener('click', function() { | |
| 107 | + | createBtn.addEventListener('click', function() { | |
| 97 | 108 | var versionNumber = document.getElementById('new-version-number').value.trim(); | |
| 98 | 109 | var changelog = document.getElementById('version-changelog').value.trim(); | |
| 99 | 110 | var entries = fileQueue.filter(function(e) { return e !== null; }); | |
| @@ -142,11 +153,12 @@ | |||
| 142 | 153 | if (i >= entries.length) { | |
| 143 | 154 | document.getElementById('version-upload-progress').classList.add('hidden'); | |
| 144 | 155 | document.getElementById('version-upload-success').classList.remove('hidden'); | |
| 145 | - | // Land back on the files tab, filled. This clicked | |
| 146 | - | // `#tab-files`, the tab button, which the described strip | |
| 147 | - | // (`6b24f2df`) stopped emitting -- so the refresh had been a | |
| 148 | - | // no-op. `?tab=files` is what `item_tabs::shown_at` reads. | |
| 149 | - | window.location.href = '/dashboard/item/' + itemId + '?tab=files'; | |
| 156 | + | // Land back on the files tab, filled. The address is the | |
| 157 | + | // surface's `data-upload-goes` rather than one built here: this | |
| 158 | + | // clicked `#tab-files`, the tab button, which the described | |
| 159 | + | // strip (`6b24f2df`) stopped emitting, so the refresh had been | |
| 160 | + | // a no-op. `?tab=files` is what `item_tabs::shown_at` reads. | |
| 161 | + | if (landsOn) window.location.href = landsOn; | |
| 150 | 162 | return; | |
| 151 | 163 | } | |
| 152 | 164 | ||
| @@ -157,7 +169,7 @@ | |||
| 157 | 169 | updateQueueStatus(entry.idx, 'uploading'); | |
| 158 | 170 | uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : ''); | |
| 159 | 171 | ||
| 160 | - | fetch('/api/items/' + itemId + '/versions', { | |
| 172 | + | fetch(createRoute, { | |
| 161 | 173 | method: 'POST', | |
| 162 | 174 | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 163 | 175 | body: JSON.stringify({ | |
| @@ -256,9 +268,10 @@ | |||
| 256 | 268 | document.getElementById('version-upload-error').classList.add('hidden'); | |
| 257 | 269 | fileQueue = []; | |
| 258 | 270 | fileRows.innerHTML = ''; | |
| 259 | - | var btn = document.getElementById('create-version-btn'); | |
| 260 | - | btn.disabled = false; | |
| 261 | - | btn.textContent = 'Upload All'; | |
| 271 | + | createBtn.disabled = false; | |
| 272 | + | // The label is the description's, read off the control rather than | |
| 273 | + | // written out here a second time. | |
| 274 | + | createBtn.textContent = createLabel; | |
| 262 | 275 | } | |
| 263 | 276 | } | |
| 264 | 277 |
| @@ -9204,36 +9204,16 @@ | |||
| 9204 | 9204 | ITEM VERSION UPLOAD (replaces inline styles in partials/item_version_upload.html) | |
| 9205 | 9205 | =========================================== */ | |
| 9206 | 9206 | ||
| 9207 | - | .item-version-upload-cell-sm { | |
| 9208 | - | font-size: var(--text-note); | |
| 9209 | - | } | |
| 9210 | - | ||
| 9211 | - | .item-version-upload-no-file { | |
| 9212 | - | opacity: 0.5; | |
| 9213 | - | } | |
| 9214 | - | ||
| 9215 | - | .item-version-upload-btn { | |
| 9216 | - | padding: var(--gap-peer) var(--gap-section); | |
| 9217 | - | font-size: var(--text-note); | |
| 9218 | - | } | |
| 9219 | - | ||
| 9220 | - | .item-version-upload-add-btn { | |
| 9221 | - | padding: var(--gap-peer) var(--gap-section); | |
| 9222 | - | } | |
| 9223 | - | ||
| 9224 | 9207 | .item-version-upload-grid { | |
| 9225 | 9208 | display: grid; | |
| 9226 | 9209 | grid-template-columns: 1fr 1fr; | |
| 9227 | 9210 | gap: var(--gap-section); | |
| 9228 | 9211 | } | |
| 9229 | 9212 | ||
| 9230 | - | .item-version-upload-hint { | |
| 9231 | - | margin-bottom: var(--gap-peer); | |
| 9232 | - | } | |
| 9233 | - | ||
| 9234 | 9213 | .item-version-upload-file-table { | |
| 9235 | 9214 | width: 100%; | |
| 9236 | 9215 | border-collapse: collapse; | |
| 9216 | + | margin-top: var(--gap-peer); | |
| 9237 | 9217 | } | |
| 9238 | 9218 | ||
| 9239 | 9219 | .item-version-upload-file-table thead tr { | |
| @@ -9256,10 +9236,6 @@ | |||
| 9256 | 9236 | width: 40px; | |
| 9257 | 9237 | } | |
| 9258 | 9238 | ||
| 9259 | - | .item-version-upload-add-row { | |
| 9260 | - | margin-top: var(--gap-peer); | |
| 9261 | - | } | |
| 9262 | - | ||
| 9263 | 9239 | .item-version-upload-queue { | |
| 9264 | 9240 | margin-bottom: var(--gap-section); | |
| 9265 | 9241 | } |
| @@ -135,6 +135,30 @@ | |||
| 135 | 135 | //! down here so the next pass does not repeat it; the region stays until the | |
| 136 | 136 | //! member exists. | |
| 137 | 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 | + | //! | |
| 138 | 162 | //! [`Node::Notice`]: quasi_router::Node::Notice | |
| 139 | 163 | //! [`Repeat`]: quasi_router::screen::Repeat | |
| 140 | 164 | //! [`Repeating`]: quasi_router::Repeating | |
| @@ -158,7 +182,7 @@ | |||
| 158 | 182 | /// The slot the upload machine's own markup mounts into. | |
| 159 | 183 | /// | |
| 160 | 184 | /// 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 | |
| 185 | + | /// reads `#version-upload`'s `data-upload-goes`, and a described region cannot | |
| 162 | 186 | /// carry a host's dataset. So the region is a place and the host's wrapper | |
| 163 | 187 | /// keeps its own id, which is decision 4 working as intended rather than a | |
| 164 | 188 | /// workaround. |
| @@ -37,7 +37,7 @@ | |||
| 37 | 37 | //! places, which is the duplication this shape exists to remove. So the lists | |
| 38 | 38 | //! live here once and each surface is named for what it is. | |
| 39 | 39 | ||
| 40 | - | use makeover_layout::Family; | |
| 40 | + | use makeover_layout::{Family, FieldKind}; | |
| 41 | 41 | use quasi_router::{Accepted, Action, Field, Node}; | |
| 42 | 42 | ||
| 43 | 43 | /// Every suffix a version file may carry. | |
| @@ -181,17 +181,73 @@ | |||
| 181 | 181 | /// | |
| 182 | 182 | /// Several at once, and deliberately with no destination: nothing can be sent | |
| 183 | 183 | /// until the reader has named the version and labelled each file, so the | |
| 184 | - | /// address belongs to the button that does that and not to this field. What is | |
| 185 | - | /// described is what it takes and how many, which is the half that was written | |
| 186 | - | /// out by hand in the template. | |
| 184 | + | /// address belongs to [`version_upload_all`] and not to this field. What is | |
| 185 | + | /// described is what it takes, how many, and the standing help beside it, | |
| 186 | + | /// which is the half that was written out by hand in the template. | |
| 187 | + | /// | |
| 188 | + | /// The hint arrived here rather than staying markup because the template was | |
| 189 | + | /// writing a second `Files` label above the one this field already emits. A | |
| 190 | + | /// field owns its own label and its own standing help; anything writing either | |
| 191 | + | /// beside it is describing the same question twice. | |
| 187 | 192 | #[must_use] | |
| 188 | 193 | pub fn version_queue() -> String { | |
| 189 | 194 | use quasi_axum::Serves as _; | |
| 190 | 195 | ||
| 191 | - | let field = Field::upload("version-files", "Files", version_suffixes()).many(); | |
| 196 | + | let field = Field::upload("version-files", "Files", version_suffixes()) | |
| 197 | + | .many() | |
| 198 | + | .hint( | |
| 199 | + | "Add one file per platform. Each gets its own label \ | |
| 200 | + | (e.g. \"macOS (arm)\", \"Linux (x86_64)\").", | |
| 201 | + | ); | |
| 192 | 202 | quasi_webview::Webview::new().fragment(&Node::field(field)) | |
| 193 | 203 | } | |
| 194 | 204 | ||
| 205 | + | /// What a new version carries besides its files. | |
| 206 | + | /// | |
| 207 | + | /// Two ordinary questions, and they were only ever markup because the surface | |
| 208 | + | /// around them was. Their names are the ids `item-upload.js` reads them back | |
| 209 | + | /// by: a field's name is its id in this renderer, which is the same fact | |
| 210 | + | /// [`version_queue`]'s picker already relies on. | |
| 211 | + | /// | |
| 212 | + | /// Emitted as two fragments side by side rather than one region, because the | |
| 213 | + | /// grid they sit in is the template's and a region would put a box around | |
| 214 | + | /// them. | |
| 215 | + | #[must_use] | |
| 216 | + | pub fn version_details() -> String { | |
| 217 | + | use quasi_axum::Serves as _; | |
| 218 | + | ||
| 219 | + | let mut number = Field::new(FieldKind::Text, "new-version-number", "Version Number"); | |
| 220 | + | number.placeholder = Some("e.g., 1.0".to_owned()); | |
| 221 | + | ||
| 222 | + | let mut changelog = Field::new(FieldKind::Text, "version-changelog", "Notes (optional)"); | |
| 223 | + | changelog.placeholder = Some("What changed in this version...".to_owned()); | |
| 224 | + | ||
| 225 | + | let mut html = quasi_webview::Webview::new().fragment(&Node::field(number)); | |
| 226 | + | html.push_str(&quasi_webview::Webview::new().fragment(&Node::field(changelog))); | |
| 227 | + | html | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// The control that starts the run, and the address it starts at. | |
| 231 | + | /// | |
| 232 | + | /// [`Action::by_host`] for the reason every other upload here is: the host | |
| 233 | + | /// makes three calls per file behind this one address, and a renderer posting | |
| 234 | + | /// the form at the first of them would be wrong about the response shape and | |
| 235 | + | /// about where the bytes go. What is described is where the run begins and | |
| 236 | + | /// that it waits; the sequence stays `item-upload.js`. | |
| 237 | + | /// | |
| 238 | + | /// This is what took the last string literal for a route out of that file. It | |
| 239 | + | /// built `/api/items/<id>/versions` by hand from a `data-item-id` attribute on | |
| 240 | + | /// an ancestor, which is an address written down twice in two languages. | |
| 241 | + | #[must_use] | |
| 242 | + | pub fn version_upload_all(item_id: &str) -> String { | |
| 243 | + | use quasi_axum::Serves as _; | |
| 244 | + | ||
| 245 | + | let action = Action::post(format!("/api/items/{item_id}/versions")) | |
| 246 | + | .awaiting() | |
| 247 | + | .by_host(); | |
| 248 | + | quasi_webview::Webview::new().fragment(&Node::act("Upload All", action)) | |
| 249 | + | } | |
| 250 | + | ||
| 195 | 251 | #[cfg(test)] | |
| 196 | 252 | mod tests { | |
| 197 | 253 | /// The four axes `f7261a5a` settled, on the surface that has all of them. | |
| @@ -538,4 +594,71 @@ | |||
| 538 | 594 | "{html}" | |
| 539 | 595 | ); | |
| 540 | 596 | } | |
| 597 | + | ||
| 598 | + | /// The two names `item-upload.js` reads the new version's own facts back | |
| 599 | + | /// by. A field's name is its id in this renderer, so renaming either of | |
| 600 | + | /// these renames the id, and the script would silently read an empty | |
| 601 | + | /// version number and post one with no name. | |
| 602 | + | #[test] | |
| 603 | + | fn the_new_version_questions_keep_the_names_the_host_reads() { | |
| 604 | + | let html = super::version_details(); | |
| 605 | + | ||
| 606 | + | assert!(html.contains(r#"id="new-version-number""#), "{html}"); | |
| 607 | + | assert!(html.contains(r#"name="new-version-number""#), "{html}"); | |
| 608 | + | assert!(html.contains(r#"id="version-changelog""#), "{html}"); | |
| 609 | + | assert!(html.contains(r#"placeholder="e.g., 1.0""#), "{html}"); | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | /// Where the run starts and who makes it. `data-sends` is the whole of | |
| 613 | + | /// what the script needs: it used to build this address out of a | |
| 614 | + | /// `data-item-id` attribute, which is one route written down twice. | |
| 615 | + | #[test] | |
| 616 | + | fn upload_all_carries_its_address_and_no_transport() { | |
| 617 | + | let html = super::version_upload_all("11111111-1111-1111-1111-111111111111"); | |
| 618 | + | ||
| 619 | + | assert!(html.contains("<button"), "{html}"); | |
| 620 | + | assert!(html.contains("data-act"), "{html}"); | |
| 621 | + | assert!( | |
| 622 | + | html.contains( | |
| 623 | + | r#"data-sends="/api/items/11111111-1111-1111-1111-111111111111/versions""# | |
| 624 | + | ), | |
| 625 | + | "{html}" | |
| 626 | + | ); | |
| 627 | + | assert!(html.contains("Upload All"), "{html}"); | |
| 628 | + | assert!(!html.contains("hx-post"), "{html}"); | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | /// One label per question. The template wrote a second `Files` label above | |
| 632 | + | /// the picker and a hint beside it, both of which the field already owns. | |
| 633 | + | #[test] | |
| 634 | + | fn the_queue_owns_its_label_and_its_hint() { | |
| 635 | + | let html = super::version_queue(); | |
| 636 | + | ||
| 637 | + | assert_eq!(html.matches(">Files<").count(), 1, "{html}"); | |
| 638 | + | assert!(html.contains("Add one file per platform"), "{html}"); | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | /// The uploader surface, rendered whole: exactly one control the script | |
| 642 | + | /// can find by the address it carries, so `button[data-act][data-sends]` | |
| 643 | + | /// is unambiguous, and the landing address is on the wrapper. | |
| 644 | + | #[test] | |
| 645 | + | fn the_uploader_surface_names_one_control_and_where_it_lands() { | |
| 646 | + | use askama::Template as _; | |
| 647 | + | ||
| 648 | + | let html = crate::templates::ItemVersionUploadTemplate { | |
| 649 | + | item: audio_item(None), | |
| 650 | + | versions: Vec::new(), | |
| 651 | + | } | |
| 652 | + | .render() | |
| 653 | + | .expect("render the uploader"); | |
| 654 | + | ||
| 655 | + | assert_eq!(html.matches("<button").count(), 3, "{html}"); | |
| 656 | + | assert!( | |
| 657 | + | html.contains( | |
| 658 | + | r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""# | |
| 659 | + | ), | |
| 660 | + | "{html}" | |
| 661 | + | ); | |
| 662 | + | assert!(!html.contains("data-item-id"), "{html}"); | |
| 663 | + | } | |
| 541 | 664 | } |
| @@ -4,8 +4,11 @@ | |||
| 4 | 4 | progress bar, a speed readout, a cancel, a retry and an error surface, all | |
| 5 | 5 | of it `static/item-upload.js`. | |
| 6 | 6 | ||
| 7 | - | The wrapper keeps its own id and `data-item-id`: the script reads them, and | |
| 8 | - | a described region carries no host dataset. | |
| 7 | + | The wrapper keeps its own id and the address to land on when the run | |
| 8 | + | finishes: the script reads both, and a described region carries no host | |
| 9 | + | dataset. `data-upload-goes` is the same attribute the described uploads | |
| 10 | + | already answer to (`static/upload.js`), so the surface names where it goes | |
| 11 | + | once rather than the script rebuilding the URL from an item id. | |
| 9 | 12 | ||
| 10 | 13 | Whether this region can be described was counted on 2026-08-30 and the | |
| 11 | 14 | answer is written down in `crate::quasi::item_files`, "Can the uploader move | |
| @@ -15,46 +18,47 @@ | |||
| 15 | 18 | field per row. `quasi_router::Repeating` is the near miss and its own docs | |
| 16 | 19 | rule it out; the answer is a ruling in `quasi-router` and `makeover-layout` | |
| 17 | 20 | rather than a change here. -#} | |
| 18 | - | <div class="version-upload" id="version-upload" data-item-id="{{ item.id }}"> | |
| 21 | + | <div class="version-upload" id="version-upload" | |
| 22 | + | data-upload-goes="/dashboard/item/{{ item.id }}?tab=files"> | |
| 19 | 23 | ||
| 20 | 24 | <!-- New Version Form --> | |
| 21 | 25 | <div id="new-version-form"> | |
| 26 | + | {#- The version number and the notes beside it, described: | |
| 27 | + | `crate::quasi::upload_field::version_details`. The grid is this | |
| 28 | + | template's, because two questions sitting in two columns is a fact | |
| 29 | + | about this panel and not about either question. -#} | |
| 22 | 30 | <div class="item-version-upload-grid"> | |
| 23 | - | <div class="form-group"> | |
| 24 | - | <label for="new-version-number">Version Number</label> | |
| 25 | - | <input type="text" id="new-version-number" placeholder="e.g., 1.0"> | |
| 26 | - | </div> | |
| 27 | - | <div class="form-group"> | |
| 28 | - | <label for="version-changelog">Notes (optional)</label> | |
| 29 | - | <input type="text" id="version-changelog" placeholder="What changed in this version..."> | |
| 30 | - | </div> | |
| 31 | + | {{ crate::quasi::upload_field::version_details()|safe }} | |
| 31 | 32 | </div> | |
| 32 | 33 | ||
| 33 | - | <div class="form-group"> | |
| 34 | - | <label>Files</label> | |
| 35 | - | <div class="upload-hint item-version-upload-hint">Add one file per platform. Each gets its own label (e.g. "macOS (arm)", "Linux (x86_64)").</div> | |
| 34 | + | {#- What the queue takes, how many of them, and the help beside it is | |
| 35 | + | described: `crate::quasi::upload_field::version_queue`. It carries | |
| 36 | + | no destination on purpose, because nothing is sent until Upload All | |
| 37 | + | names the version. | |
| 36 | 38 | ||
| 37 | - | <table class="item-version-upload-file-table" id="version-file-table"> | |
| 38 | - | <thead> | |
| 39 | - | <tr> | |
| 40 | - | <th>File</th> | |
| 41 | - | <th>Label</th> | |
| 42 | - | <th></th> | |
| 43 | - | </tr> | |
| 44 | - | </thead> | |
| 45 | - | <tbody id="version-file-rows"></tbody> | |
| 46 | - | </table> | |
| 39 | + | The picker sits above the rows it fills rather than below them, | |
| 40 | + | which is also the order the described field's own label reads in. -#} | |
| 41 | + | {{ crate::quasi::upload_field::version_queue()|safe }} | |
| 47 | 42 | ||
| 48 | - | {#- What the queue takes and how many of them is described, not | |
| 49 | - | written out here: `crate::quasi::upload_field::version_queue`. | |
| 50 | - | It carries no destination on purpose, because nothing is sent | |
| 51 | - | until Upload All names the version. -#} | |
| 52 | - | <div class="item-version-upload-add-row" id="version-file-picker"> | |
| 53 | - | {{ crate::quasi::upload_field::version_queue()|safe }} | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 43 | + | {#- The one part of the form still written out: a row per picked file | |
| 44 | + | with a label field in it. That shape has no member yet and the | |
| 45 | + | counting behind that is in `crate::quasi::item_files`. -#} | |
| 46 | + | <table class="item-version-upload-file-table" id="version-file-table"> | |
| 47 | + | <thead> | |
| 48 | + | <tr> | |
| 49 | + | <th>File</th> | |
| 50 | + | <th>Label</th> | |
| 51 | + | <th></th> | |
| 52 | + | </tr> | |
| 53 | + | </thead> | |
| 54 | + | <tbody id="version-file-rows"></tbody> | |
| 55 | + | </table> | |
| 56 | 56 | ||
| 57 | - | <button class="btn-primary" id="create-version-btn">Upload All</button> | |
| 57 | + | {#- Where the run begins and that it waits: | |
| 58 | + | `crate::quasi::upload_field::version_upload_all`. The host makes | |
| 59 | + | the calls, so the script finds this control by the address it | |
| 60 | + | carries rather than by an id. -#} | |
| 61 | + | {{ crate::quasi::upload_field::version_upload_all(item.id.as_str())|safe }} | |
| 58 | 62 | </div> | |
| 59 | 63 | ||
| 60 | 64 | {#- One described field per version that has no file yet. Each carries its |