Skip to main content

max / makenotwork

Describe the new version's own questions, and leave only the queue bespoke The counting in item_files says one shape in this region has no member: a queue of picked files with a field per row. Everything else the form said around it is sayable today, so it is said. version_details, version_queue's own hint and version_upload_all replace the two hand-written form-groups, a second Files label above the one the picker already emits, and the Upload All button. Two addresses come off item-upload.js with them: the run starts at the act's data-sends where it was built by hand from a data-item-id, and lands on the surface's data-upload-goes. Four already-dead classes go from style.css and the table takes the spacing the picker wrapper was carrying.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 16:06 UTC
Signed with PGP, not checked
Commit: b6eabf07688eeb0e3a147c4b18461e674eb240f7
Parent: 0318be4
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