Skip to main content

max / makenotwork

Describe the version delete button, and retire its handler Shape 3 step 4 (108557ec), the step the plan calls the clean tail. It waited on 27d5e5b8 (a described act inside an Askama page) and on 35756077 (the unlayered `button` rule taking the tone off every described act); both are settled. The act had no markup at all: a class, a data-version-id, and a click handler in item-upload.js holding a confirm() string, a fetch with CSRF headers and a closest('tr').remove(). As an Act it is one sentence, and the row grew the `version-row-{id}` id that makes Action::replaces sayable. The download button beside it stays hand-written, and the wiki's step-4 line is wrong about why: GET /api/versions/{id}/download answers JSON carrying a presigned URL, not the file, so a described Action::get would navigate the reader to a JSON body. That is a route change, not a control change.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 16:33 UTC
Signed with PGP, not checked
Commit: f335c74dd456cf3d19a41ec60bb5bd32d0ebafab
Parent: 4dce017
4 files changed, +121 insertions, -21 deletions
@@ -252,24 +252,10 @@
252 252 });
253 253 });
254 254
255 - document.querySelectorAll('.delete-version-btn').forEach(function(btn) {
256 - btn.addEventListener('click', function() {
257 - if (!confirm('Delete this version?')) return;
258 - var versionId = btn.dataset.versionId;
259 - fetch('/api/items/' + itemId + '/versions/' + versionId, {
260 - method: 'DELETE',
261 - headers: csrfHeaders()
262 - })
263 - .then(function(res) {
264 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
265 - throw new Error(d.error || 'Failed to delete version');
266 - });
267 - var row = btn.closest('tr');
268 - if (row) row.remove();
269 - })
270 - .catch(function(err) { showToast(err.message || 'Failed to delete version'); });
271 - });
272 - });
255 + // The delete button is described: `crate::quasi::version_delete_act`,
256 + // Shape 3 step 4. Route, row, prompt and tone all live on the act, and
257 + // htmx performs it. The download button above stays here because its
258 + // route answers JSON carrying a presigned URL rather than the file.
273 259
274 260 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
275 261 uploader.cancel();
@@ -49,6 +49,7 @@
49 49 pub mod upload_field;
50 50 pub mod user_analytics;
51 51 pub mod user_tabs;
52 + pub mod version_delete_act;
52 53 pub mod widgets;
53 54
54 55 /// The state one request is answered against.
@@ -17,7 +17,7 @@
17 17 </thead>
18 18 <tbody>
19 19 {% for version in versions %}
20 - <tr>
20 + <tr id="version-row-{{ version.id }}">
21 21 <td><span class="badge"{% if version.is_current %} data-tone="success"{% endif %}>v{{ version.number }}</span></td>
22 22 <td class="item-version-upload-cell-sm">{% if let Some(label) = version.label %}{{ label }}{% endif %}</td>
23 23 <td class="item-version-upload-cell-sm">{% if version.has_file %}{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}1 file{% endmatch %}{% else %}<span class="item-version-upload-no-file">No file</span>{% endif %}</td>
@@ -31,8 +31,7 @@
31 31 <button class="btn-secondary upload-to-version-btn item-version-upload-btn"
32 32 data-version-id="{{ version.id }}">Upload File</button>
33 33 {% endif %}
34 - <button class="btn-secondary delete-version-btn item-version-upload-btn"
35 - data-version-id="{{ version.id }}">Delete</button>
34 + {{ crate::quasi::version_delete_act::html(item.id.to_string().as_str(), version.id.to_string().as_str())|safe }}
36 35 </td>
37 36 </tr>
38 37 {% endfor %}
@@ -1,0 +1,114 @@
1 + //! The Askama entry point for a described "delete this version" button.
2 + //!
3 + //! Shape 3 step 4 (`108557ec`), the step the plan calls the clean tail: the one
4 + //! part of `static/item-upload.js` that retires with no host remnant. It waited
5 + //! on `27d5e5b8` (a described act inside an Askama page) and on `35756077` (the
6 + //! unlayered `button` rule taking the tone off every described act). Both are
7 + //! settled, so it converts.
8 + //!
9 + //! # What the description says that the markup did not
10 + //!
11 + //! Everything, in this case. There was no markup: the button carried a class
12 + //! and a `data-version-id`, and the whole act lived in a click handler in
13 + //! `item-upload.js` — a `confirm()` string, a `fetch` with CSRF headers, and a
14 + //! `btn.closest('tr').remove()` on success. None of that is legible to anything
15 + //! but a browser. As an [`Act`] it is one sentence: what it is called, where it
16 + //! goes, what it replaces, that it is destructive, and what to ask first.
17 + //!
18 + //! # Why the row id is a parameter, and why the row grew one
19 + //!
20 + //! `DELETE /api/items/{item}/versions/{version}` is a plain API route the
21 + //! description layer does not serve; it answers `200` with an empty body, so it
22 + //! cannot name what it changed and the control has to. See
23 + //! [`Action::replaces`].
24 + //!
25 + //! The blog pair ([`super::blog_delete_act`]) converted because its templates
26 + //! already gave each row a real id. This one's `<tr>` did not, and the handler
27 + //! it replaces reached the row through `closest('tr')`. So the template grew
28 + //! `id="version-row-{id}"` in the same change. That is worth telling apart from
29 + //! the two rows that are still hand-written: `partials/link_row.html` and
30 + //! `partials/tag.html` target `closest .<class>` on rows whose identity is a
31 + //! CSS class and nothing else, and inventing an id for them would be describing
32 + //! a fact the page does not have. A version row has an id in the database and
33 + //! the template was simply not writing it down.
34 + //!
35 + //! # The download button beside it is not here, and that is measured
36 + //!
37 + //! Wiki [[mnw-shape-conversion-plans]] Shape 3 step 4 says
38 + //! `download-version-btn` is `Action::get`. It is not.
39 + //! `GET /api/versions/{id}/download` (`routes::storage::downloads`) answers
40 + //! **JSON carrying a presigned URL**, and the handler then navigates to it, so
41 + //! a described `Action::get` would send the reader to a JSON body. Making it
42 + //! sayable is a route change — answer a redirect, or serve the route through
43 + //! the description layer so it can answer `Outcome::Goto` with a
44 + //! `Destination::External` — rather than a control change, so the handler
45 + //! stays.
46 +
47 + use makeover_layout::Tone;
48 + use quasi_router::screen::Act;
49 + use quasi_router::{Action, Node};
50 +
51 + /// The delete button for one version's row.
52 + ///
53 + /// Both ids are needed and they are not the same fact: the route is addressed
54 + /// by item and version together, and the row is named by the version alone.
55 + #[must_use]
56 + pub fn act(item_id: &str, version_id: &str) -> Node {
57 + Node::Act(
58 + Act::new(
59 + "Delete",
60 + Action::delete(format!("/api/items/{item_id}/versions/{version_id}"))
61 + .replacing(format!("version-row-{version_id}")),
62 + )
63 + .tone(Tone::Danger)
64 + .confirm("Delete this version?"),
65 + )
66 + }
67 +
68 + /// The same act as a fragment, for the Askama call site.
69 + #[must_use]
70 + pub fn html(item_id: &str, version_id: &str) -> String {
71 + use quasi_axum::Serves as _;
72 +
73 + quasi_webview::Webview::new().fragment(&act(item_id, version_id))
74 + }
75 +
76 + #[cfg(test)]
77 + mod tests {
78 + /// What the click handler said in four places, said once: where it goes,
79 + /// what it replaces, and what to ask first.
80 + #[test]
81 + fn a_delete_names_its_route_its_row_and_its_prompt() {
82 + let html = super::html("7", "42");
83 +
84 + assert!(
85 + html.contains(r#"hx-delete="/api/items/7/versions/42""#),
86 + "{html}"
87 + );
88 + assert!(html.contains(r##"hx-target="#version-row-42""##), "{html}");
89 + assert!(
90 + html.contains(r#"hx-confirm="Delete this version?""#),
91 + "{html}"
92 + );
93 + }
94 +
95 + /// The tone. The template said nothing at all here -- the button was
96 + /// `btn-secondary`, so the page did not mark the destructive act even to
97 + /// the stylesheet.
98 + #[test]
99 + fn a_delete_says_it_is_dangerous_in_a_word_every_renderer_reads() {
100 + let html = super::html("7", "42");
101 +
102 + assert!(html.contains(r#"data-tone="danger""#), "{html}");
103 + }
104 +
105 + /// The negative half, as `export_act` asserts it: the dispatcher is gone
106 + /// from this control, not merely bypassed.
107 + #[test]
108 + fn nothing_here_goes_through_the_dispatcher() {
109 + let html = super::html("7", "42");
110 +
111 + assert!(!html.contains("data-action"), "{html}");
112 + assert!(!html.contains("delete-version-btn"), "{html}");
113 + }
114 + }