//! The item dashboard's Files panel, described. //! //! `138ad5ab`, and the trigger three riders were waiting on. `super::item_tabs` //! has described the item tab *strip* since `6b24f2df` and named `item-files` //! as one of its panels; the panel's contents stayed Askama, so //! `9dbe9206` ("describe the version download button") read as ready to //! `/threads` while having nowhere to put an `Action::get`. //! //! # The split: the read half is described, the uploader is bespoke //! //! `templates/partials/tabs/item_files.html` was six lines wrapping //! `item_version_upload.html`, which is two different things in one file: a //! table of the versions that exist, and a file-upload machine driven by //! `static/item-upload.js` -- a queue, a progress bar, a speed readout, a //! cancel, a retry and an error surface, all of it client-side state. //! //! The table is described here. The upload machine stays markup, mounted as a //! [`RegionKind::Handover`] region, which is what a handover is for and //! what `super::user_analytics` already does with its chart. Describing a //! progress machine is its own conversion with its own vocabulary questions, //! and `138ad5ab`'s SCOPE says to split rather than widen. So it is split, and //! filed. //! //! # The download button, which is the rider //! //! `9dbe9206`, whose route half shipped as `MNW@34adcf76`: `GET //! /api/versions/{id}/download` answers **303 to the presigned URL** rather //! than JSON describing it (`8fc6b1af`, option (a)). That is what makes it //! sayable: [`Action::get`] with no target emits an `href`, the browser //! follows the redirect, and the download happens. The five-line click handler //! in `static/item-upload.js` that navigated by hand goes with it. //! //! # The delete beside it changes its spelling, not its meaning //! //! [`super::version_delete_act`] targeted `#version-row-{id}`, an id the //! template grew for it. A described table row carries `data-row` and no id, so //! the act now says [`Action::replacing_enclosing`] -- the same sentence in the //! spelling a described row understands. The route is untouched and still //! answers an empty 200, which is why the act has to name what it replaced //! either way. //! //! # What the reveal dance cost, and what replaces it //! //! A version with no file had an "Upload File" button in its row that unhid a //! panel below the table, with a Cancel that hid it again: two `querySelectorAll` //! blocks in `item-upload.js`, a `hidden` class, and a `data-version-id` the //! button carried so the handler could find the right panel. //! //! A described cell holds leaves and acts, not regions, so that button cannot //! name a panel to unhide. Rather than invent a member for it, the panels are //! simply shown: one labelled upload area per file-less version, in the //! uploader region, each already carrying its own address //! ([`super::upload_field::existing_version`]). Nothing is hidden, so nothing //! has to be revealed. //! //! **This is a visible change and it is recorded rather than buried.** A reader //! with three file-less versions now sees three upload areas where they saw //! three buttons. That is the honest shape of what the screen offers, and it is //! a affordance change Max may want to look at. //! //! # Can the uploader move to the description layer? //! //! Three shapes, counted across the tree (MNW server, goingson, //! balanced_breakfast, audiofiles, quasi, makeover-layout). Two of the three //! need no new vocabulary at all, and the third is earned by the count but is a //! cross-repo ruling rather than this pass. //! //! **Progress with a filename, a percent, a rate and a cancel: no member //! wanted.** This is already ruled. [`Action::awaiting`] says the call waits //! and [`Action::by_host`] says who makes it, and //! [`super::upload_field`]'s header states the consequence: the mark says the //! call waits, and the renderer observes the rest. `static/upload.js`'s //! `progressPanel` is that observation, built in the host with no markup //! written down anywhere, and it already serves every described upload field. //! `Node::Meter` is not the shape and does not need widening. What the region //! below still holds is progress markup for the *queue's* run, which is markup //! only because the queue is. //! //! **A surface that persists until reset, with one thing to do about it: no //! member wanted.** `layout::Notice::Banner` is spelled "persistent, in flow, //! one per region, dismissed by fixing the cause", and [`Node::Notice`] grew an //! optional act (`bde35298`) precisely so that a sentence about a situation and //! the one thing to do about it are one thing. Success, error and Try Again are //! all sayable today. They are not said here because they are client state //! reached after an XHR rather than anything the server renders, and the //! described upload path already answers a failure with a toast. //! //! **A queue of picked files, each with a status: 3 consumers, earned.** This //! panel's `#version-upload-queue`, the bundle wizard's `#batch-file-rows` //! (`templates/wizards/steps/item/content.html`, driven by //! `static/wizard-item-content-bundle.js`), and the media library's //! `#media-upload-progress` (`static/tab-user-media.js`). Nothing outside MNW //! has the shape: goingson, balanced_breakfast and audiofiles have no picked-file //! queue between them. //! //! **The same queue with an editable field per row: 2 consumers, earned by the //! count and deferred anyway.** This panel carries one label per file; the //! bundle wizard carries a title and a type per file. Two is the bar, and two //! consumers that disagree about how many fields a row holds means the member //! has to be a slot holding *several* fields. //! //! There are two near misses and neither is it. [`Repeat`] is a repeating //! *field*: a slot is one answer to one question, and its own docs name the //! wire naming that would have to grow (`name[0].label` beside `name[0]`). //! [`Repeating`] (`f7abbc08`, ruled by Max the same day, over widening //! `Repeat`) is the repeating *group*, which is the phrase this queue wants, //! and it still does not carry the queue. Its own text is what says so, on //! both of the points that separate a picked-file queue from a group of //! answers: //! //! - Its `add` is a whole [`Act`] "because adding a slot here is a route: the //! group's state is the app's". The queue's slots are not added by anything //! that can be a route. They arrive from a file picker, live in the browser, //! and are gone once the run finishes, which is the case `Repeating` names //! as the *other* one when it contrasts a repeating field "whose slots live //! in the renderer's own view until they are submitted". //! - Its wire section carries nothing, because "these regions each carry their //! own fields with their own writes, so every slot is already addressed by //! the routes inside it". A queue slot has no route of its own to be //! addressed by. Its label is read back out of the row at submit time //! (`item-upload.js` reads `.version-label-input[data-idx]`), which is //! exactly the `name[0].label` naming `Repeat` says it does not have. //! //! And the queue trips `Repeating`'s stated reopening condition head on: it //! deliberately carries no per-slot error, "**that is the reopening //! condition**", while a queue row's whole reason to exist is a per-row status //! that includes failure (`updateQueueStatus`, four states, one of them //! `error`). So the member is still missing, and the shape the evidence points //! at is the wider `Repeat` that ruling set aside rather than a second region //! member beside `Repeating`. //! //! That is a vocabulary ruling to reopen and a forward fix across two sibling //! repos, not a panel conversion, and reopening a call Max made hours earlier //! is his to make rather than this pass's. The counting is done and written //! down here so the next pass does not repeat it; the region stays until the //! member exists. //! //! # What went anyway, once the counting said which part was blocked //! //! Only the queue is blocked, so only the queue is still markup. Everything //! the new-version form said around it is described now //! ([`super::upload_field::version_details`], //! [`super::upload_field::version_queue`] and //! [`super::upload_field::version_upload_all`]): the version number, the //! notes, the picker with its own label and standing help, and the control the //! run starts at. The template had been writing a second `Files` label above //! the one the picker already emits, which is the duplication a description //! removes rather than a look to preserve. //! //! Two addresses came off `static/item-upload.js` with them and neither is //! written down twice any more. The route the run starts at is the act's //! `data-sends`, where it built `/api/items//versions` by hand from a //! `data-item-id` attribute on the wrapper; where to land afterwards is the //! wrapper's `data-upload-goes`, the attribute every described upload on this //! surface already answers to. The wrapper carries no item id at all now. //! //! What is left in the region is one thing and it is the blocked thing: a //! table of picked files with a label field per row, the progress surface the //! host draws for a run of several files, and the success and error surfaces //! that outlive it. //! //! [`Node::Notice`]: quasi_router::Node::Notice //! [`Repeat`]: quasi_router::screen::Repeat //! [`Repeating`]: quasi_router::Repeating //! [`Act`]: quasi_router::screen::Act //! [`Action::awaiting`]: quasi_router::Action::awaiting //! [`Action::by_host`]: quasi_router::Action::by_host //! [`Action::get`]: quasi_router::Action::get //! [`Action::replacing_enclosing`]: quasi_router::Action::replacing_enclosing use makeover_layout as layout; use quasi_declare::declare; use quasi_router::RegionKind; use quasi_router::screen::Tag; use quasi_webview::Webview; use crate::types::Version; /// The region the answer lands in: this panel's frame in the described item tab /// strip. `super::item_tabs` draws the frame from the same name. pub const REGION: &str = "item-files"; /// The slot the upload machine's own markup mounts into. /// /// Not `version-upload`, which is the id *inside* the fill: `item-upload.js` /// reads `#version-upload`'s `data-upload-goes`, and a described region cannot /// carry a host's dataset. So the region is a place and the host's wrapper /// keeps its own id, which is decision 4 working as intended rather than a /// workaround. const UPLOADER_SLOT: &str = "item-files-uploader"; /// The panel, for the tab route and the page handler to answer with. /// /// `uploader` is the markup for the bespoke region: everything below the table /// that `item-upload.js` drives. The caller renders it, the same way /// `super::item_tabs`' caller renders the shown panel. #[must_use] pub fn fragment(item_id: &str, versions: &[Version], uploader: &str) -> String { use quasi_axum::Serves as _; Webview::new() .with_fill(UPLOADER_SLOT, uploader) .fragment(&pane(item_id, versions)) } declare! { /// The panel in its region. /// /// The upload machine sits below the table on an empty panel too, which is /// what "create your first version below" points at. shape pane(item_id: &str, versions: &[Version]) -> Node; region REGION as Pane { section "Files & Versions"; empty "No versions uploaded yet. Create your first version below." when versions.is_empty(); include table(item_id, versions) unless versions.is_empty(); region UPLOADER_SLOT as RegionKind::handover("version-upload") {} } } declare! { /// The versions that exist. /// /// The column list lives here and the cells live in [`row`], a shape down, /// so the cells name their columns rather than counting to them: position is /// only safe while both halves are in front of you at once, and these two /// are not. The heading strings below are the whole of what [`row`] has to /// match. /// /// Nothing held back: every version of an item arrives in one query, so /// there is no rest to ask for. shape table(item_id: &str, versions: &[Version]) -> Node; table { column COL_VERSION { width Content; } column COL_LABEL { width Fill; } column COL_FILE { width Fill; priority Essential; } column COL_SIZE { width Content; } column COL_DOWNLOADS { width Content; } column COL_ACTS { width Content; } for version in versions.iter() { include row(item_id, version); } } } /// The headings, written once. /// /// A cell that names a column nothing has is dropped silently, so the two /// halves share the string rather than each spelling it. [`COL_ACTS`] is the /// unlabelled column the Download and Delete controls sit in: a heading over a /// pair of buttons says nothing a reader needs, and the empty name is still the /// name the cell has to match. const COL_VERSION: &str = "Version"; const COL_LABEL: &str = "Label"; const COL_FILE: &str = "File"; const COL_SIZE: &str = "Size"; const COL_DOWNLOADS: &str = "Downloads"; const COL_ACTS: &str = ""; /// The current version's badge is the one that reads as a state rather than a /// number. fn number_tone(version: &Version) -> layout::Tone { if version.is_current { layout::Tone::Success } else { layout::Tone::Neutral } } /// What the file cell says. /// /// The template's three-way `{% if %}`/`{% match %}`: the file's name when it /// has one, "1 file" when it has a file the name of which was not recorded, and /// "No file" when it has none. A supplier because the middle case is a fallback /// computed from an `Option`, which the form has no expression to hold. fn file(version: &Version) -> String { if !version.has_file { return "No file".to_string(); } version .file_name .clone() .unwrap_or_else(|| "1 file".to_string()) } declare! { /// One version. shape row(item_id: &str, version: &Version) -> Row; cells { cell at COL_VERSION "" { token Tag::badge("v{version.number}").tone(number_tone(version)); } cell at COL_LABEL version.label.clone().unwrap_or_default(); cell at COL_FILE file(version); cell at COL_SIZE version.size.clone(); cell at COL_DOWNLOADS version.downloads.to_string(); cell at COL_ACTS "" { // `9dbe9206`. The route answers 303 to the presigned URL, so going // there is the download and no host code has to know that. act "Download" to get "/api/versions/{version.id}/download" when version.has_file; include super::version_delete_act::act(item_id, &version.id); } } } #[cfg(test)] mod tests { use super::*; fn version(id: &str, has_file: bool, current: bool) -> Version { Version { id: id.to_string(), number: "1.0".to_string(), uploaded_date: "2026-08-30".to_string(), file_count: 1, size: "12 MB".to_string(), downloads: 7, status: "ready".to_string(), is_current: current, has_file, file_name: has_file.then(|| "plugin.zip".to_string()), label: Some("macOS (arm)".to_string()), } } fn panel(versions: &[Version]) -> String { fragment("itm_1", versions, "
") } /// The panel keeps the id the tab strip draws its frame from. If these two /// disagree, pressing Files swaps the answer into nothing. #[test] fn the_panel_is_the_frame_the_strip_names() { let html = panel(&[version("v1", true, true)]); assert!(html.contains(r#"id="item-files""#), "{html}"); } /// The rider. A described `Action::get` and nothing else: the route /// redirects, so there is no JSON to parse and no navigation to write by /// hand. #[test] fn a_version_with_a_file_offers_a_described_download() { let html = panel(&[version("v1", true, false)]); assert!( html.contains(r#"href="/api/versions/v1/download""#), "{html}" ); assert!(html.contains(">Download<"), "{html}"); // The class the click handler bound to is gone with the handler. assert!(!html.contains("download-version-btn"), "{html}"); } /// A version with nothing in it has nothing to download, and still has a /// delete. #[test] fn a_version_with_no_file_offers_no_download() { let html = panel(&[version("v1", false, false)]); assert!(!html.contains("/download"), "{html}"); assert!(html.contains("No file"), "{html}"); assert!(html.contains(">Delete<"), "{html}"); } /// The delete's new spelling. A described row carries `data-row` and no id, /// so naming `#version-row-v1` would target nothing. #[test] fn the_delete_replaces_the_row_it_sits_in() { let html = panel(&[version("v1", true, false)]); assert!( html.contains(r#"hx-delete="/api/items/itm_1/versions/v1""#), "{html}" ); assert!(!html.contains("version-row-v1"), "{html}"); assert!(html.contains("data-row"), "{html}"); assert!( html.contains(r#"hx-confirm="Delete this version?""#), "{html}" ); } /// The current version is marked, which the template did with a toned /// badge. #[test] fn the_current_version_is_toned() { assert!(panel(&[version("v1", true, true)]).contains(r#"data-tone="success""#)); assert!(!panel(&[version("v1", true, false)]).contains(r#"data-tone="success""#)); } /// The upload machine is mounted whatever the table says, including on a /// panel with no versions at all -- which is what "create your first /// version below" points at. #[test] fn the_uploader_is_mounted_either_way() { assert!(panel(&[version("v1", true, false)]).contains("the-uploader")); let empty = panel(&[]); assert!(empty.contains("the-uploader"), "{empty}"); assert!(empty.contains("No versions uploaded yet"), "{empty}"); assert!(!empty.contains("Downloads"), "{empty}"); } }