//! 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::Bespoke`] region, which is what bespoke regions are 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. //! //! [`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_router::screen::{Act, Cell, Cells, Column, Tag}; use quasi_router::{Action, Node, RegionKind, Slot}; 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-item-id`, 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)) } /// The panel in its region. fn pane(item_id: &str, versions: &[Version]) -> Node { let mut slot = Slot::new(REGION, RegionKind::Pane).with(Node::section("Files & Versions")); if versions.is_empty() { slot = slot.with(Node::empty( "No versions uploaded yet. Create your first version below.", )); } else { slot = slot.with(table(item_id, versions)); } // The upload machine, whatever state it is in. Below the table on an empty // panel too, which is what "create your first version below" points at. slot = slot.with(Node::Region(Slot::bespoke(UPLOADER_SLOT, "version-upload"))); Node::Region(slot) } /// The versions that exist. fn table(item_id: &str, versions: &[Version]) -> Node { Node::Table { columns: vec![ Column::new("Version").width(layout::Width::Content), Column::new("Label").width(layout::Width::Fill), Column::new("File") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Size").width(layout::Width::Content), Column::new("Downloads").width(layout::Width::Content), Column::new("").width(layout::Width::Content), ], rows: versions .iter() .map(|version| row(item_id, version)) .collect(), // Every version of an item, in one query. Nothing is held back, so // there is no rest to ask for. more: None, } } /// One version. fn row(item_id: &str, version: &Version) -> Cells { let mut number = Tag::badge(format!("v{}", version.number)); if version.is_current { number.tone = layout::Tone::Success; } // What the template said in a 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. let file = if version.has_file { version .file_name .clone() .unwrap_or_else(|| "1 file".to_string()) } else { "No file".to_string() }; let mut acts = Vec::new(); if version.has_file { // `9dbe9206`. The route answers 303 to the presigned URL, so going // there is the download and no host code has to know that. acts.push(Act::new( "Download", Action::get(format!("/api/versions/{}/download", version.id)), )); } acts.push(super::version_delete_act::act(item_id, &version.id)); Cells::new([ Cell::new(String::new()).token(number), Cell::new(version.label.clone().unwrap_or_default()), Cell::new(file), Cell::new(version.size.clone()), Cell::new(version.downloads.to_string()), Cell::acts(acts), ]) } #[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}"); } }