max / makenotwork
4 files changed,
+374 insertions,
-243 deletions
| @@ -31,9 +31,9 @@ | |||
| 31 | 31 | //! what the Askama handlers do and is the right call for a tab: a non-success | |
| 32 | 32 | //! status renders an empty list. | |
| 33 | 33 | ||
| 34 | - | use makeover_layout as layout; | |
| 35 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 36 | - | use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 34 | + | use quasi_declare::declare; | |
| 35 | + | use quasi_router::screen::Tag; | |
| 36 | + | use quasi_router::{Request, Response, RouteError}; | |
| 37 | 37 | use quasi_webview::Webview; | |
| 38 | 38 | ||
| 39 | 39 | use super::Viewer; | |
| @@ -166,83 +166,99 @@ | |||
| 166 | 166 | .unwrap_or_default() | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | - | /// The sentence naming where these memberships are, with the link on the name. | |
| 170 | - | /// | |
| 171 | - | /// `Node::Rich` rather than two nodes and a control: it is one sentence with one | |
| 172 | - | /// word in it that goes somewhere, and splitting it into text, an act and more | |
| 173 | - | /// text is how a sentence stops reading as a sentence in every host. | |
| 174 | - | fn upstream_line(base: &str) -> Node { | |
| 175 | - | super::own_prose(format!( | |
| 169 | + | declare! { | |
| 170 | + | /// The sentence naming where these memberships are, with the link on the name. | |
| 171 | + | /// | |
| 172 | + | /// `Node::Rich` rather than two nodes and a control: it is one sentence with | |
| 173 | + | /// one word in it that goes somewhere, and splitting it into text, an act | |
| 174 | + | /// and more text is how a sentence stops reading as a sentence in every | |
| 175 | + | /// host. | |
| 176 | + | shape upstream_line(base: &str) -> Node; | |
| 177 | + | ||
| 178 | + | include super::own_prose( | |
| 176 | 179 | "Your memberships across [Multithreaded]({base}) forum communities." | |
| 177 | - | )) | |
| 180 | + | ); | |
| 178 | 181 | } | |
| 179 | 182 | ||
| 180 | - | /// Everything inside the library's tab pane. | |
| 181 | - | fn library_pane(memberships: &[MembershipView], base: &str) -> Node { | |
| 182 | - | let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane); | |
| 183 | + | declare! { | |
| 184 | + | /// Everything inside the library's tab pane. | |
| 185 | + | shape library_pane(memberships: &[MembershipView], base: &str) -> Node; | |
| 183 | 186 | ||
| 184 | - | if memberships.is_empty() { | |
| 185 | - | let mut nothing = Node::empty("You haven't joined any forum communities yet."); | |
| 187 | + | region LIBRARY_REGION as Pane { | |
| 186 | 188 | // The way out is offered only when there is somewhere to send them, | |
| 187 | 189 | // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped | |
| 188 | 190 | // its button in. | |
| 189 | - | if !base.is_empty() { | |
| 190 | - | nothing = nothing.offering(Act::new("Browse Communities", Action::external(base))); | |
| 191 | + | empty "You haven't joined any forum communities yet." | |
| 192 | + | when memberships.is_empty() | |
| 193 | + | { | |
| 194 | + | offering "Browse Communities" to external base unless base.is_empty(); | |
| 191 | 195 | } | |
| 192 | - | return Node::Region(slot.with(nothing)); | |
| 196 | + | ||
| 197 | + | include upstream_line(base) unless memberships.is_empty(); | |
| 198 | + | include table(memberships) unless memberships.is_empty(); | |
| 193 | 199 | } | |
| 194 | - | ||
| 195 | - | slot = slot.with(upstream_line(base)).with(table(memberships)); | |
| 196 | - | Node::Region(slot) | |
| 197 | 200 | } | |
| 198 | 201 | ||
| 199 | - | /// Everything inside the settings pane. | |
| 200 | - | fn settings_pane(memberships: &[MembershipView], base: &str) -> Node { | |
| 201 | - | let mut slot = Slot::new(SETTINGS_REGION, RegionKind::Pane) | |
| 202 | - | .with(Node::section("Forum Communities")) | |
| 203 | - | .with(upstream_line(base)); | |
| 202 | + | declare! { | |
| 203 | + | /// Everything inside the settings pane. | |
| 204 | + | /// | |
| 205 | + | /// The heading and the line are drawn either way here, unlike the library's, | |
| 206 | + | /// which is the one real difference between the two screens. | |
| 207 | + | shape settings_pane(memberships: &[MembershipView], base: &str) -> Node; | |
| 204 | 208 | ||
| 205 | - | // The heading and the line are drawn either way here, unlike the library's, | |
| 206 | - | // which is the one real difference between the two screens. | |
| 207 | - | slot = if memberships.is_empty() { | |
| 208 | - | slot.with(Node::empty("You haven't joined any forum communities yet.")) | |
| 209 | - | } else { | |
| 210 | - | slot.with(table(memberships)) | |
| 211 | - | }; | |
| 209 | + | region SETTINGS_REGION as Pane { | |
| 210 | + | section "Forum Communities"; | |
| 211 | + | include upstream_line(base); | |
| 212 | 212 | ||
| 213 | - | Node::Region(slot) | |
| 213 | + | given memberships.is_empty() { | |
| 214 | + | true -> empty "You haven't joined any forum communities yet."; | |
| 215 | + | otherwise -> include table(memberships); | |
| 216 | + | } | |
| 217 | + | } | |
| 214 | 218 | } | |
| 215 | 219 | ||
| 216 | - | /// The memberships, written once for both screens. | |
| 217 | - | fn table(memberships: &[MembershipView]) -> Node { | |
| 218 | - | // Positional cells. Both screens take this table whole rather than picking | |
| 219 | - | // columns out of it, so the headings and the row stay the one expression | |
| 220 | - | // below and every membership contributes the same four cells. Nothing is | |
| 221 | - | // paged, so no `more`: this is a whole set the handler already counted. | |
| 222 | - | Table::new(vec![ | |
| 223 | - | Column::new("Community") | |
| 224 | - | .width(layout::Width::Fill) | |
| 225 | - | .priority(layout::Priority::Essential), | |
| 226 | - | Column::new("Role").width(layout::Width::Content), | |
| 227 | - | Column::new("Posts").width(layout::Width::Content), | |
| 228 | - | Column::new("Joined") | |
| 229 | - | .width(layout::Width::Content) | |
| 230 | - | .priority(layout::Priority::Optional), | |
| 231 | - | ]) | |
| 232 | - | .rows(memberships.iter().map(|membership| { | |
| 233 | - | Cells::new([ | |
| 234 | - | // The destination is Multithreaded, so it leaves. That is | |
| 235 | - | // the description saying it rather than the reader finding | |
| 236 | - | // out: a host with no browser can decide what to do with a | |
| 237 | - | // link off its own service. | |
| 238 | - | Cell::new(membership.community.clone()) | |
| 239 | - | .activate(Action::external(membership.profile_url.clone())), | |
| 240 | - | Cell::tag(Tag::badge(membership.role.clone())), | |
| 241 | - | Cell::new(membership.posts.clone()), | |
| 242 | - | Cell::new(membership.joined.clone()), | |
| 243 | - | ]) | |
| 244 | - | })) | |
| 245 | - | .into() | |
| 220 | + | declare! { | |
| 221 | + | /// The memberships, written once for both screens. | |
| 222 | + | /// | |
| 223 | + | /// Positional cells. Both screens take this table whole rather than picking | |
| 224 | + | /// columns out of it, so every membership contributes the same four cells. | |
| 225 | + | /// Nothing is paged, so no `more`: this is a whole set the handler already | |
| 226 | + | /// counted. | |
| 227 | + | shape table(memberships: &[MembershipView]) -> Node; | |
| 228 | + | ||
| 229 | + | table { | |
| 230 | + | column "Community" { | |
| 231 | + | width Fill; | |
| 232 | + | priority Essential; | |
| 233 | + | } | |
| 234 | + | column "Role" { | |
| 235 | + | width Content; | |
| 236 | + | } | |
| 237 | + | column "Posts" { | |
| 238 | + | width Content; | |
| 239 | + | } | |
| 240 | + | column "Joined" { | |
| 241 | + | width Content; | |
| 242 | + | priority Optional; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | for membership in memberships.iter() { | |
| 246 | + | cells { | |
| 247 | + | // The destination is Multithreaded, so it leaves. That is the | |
| 248 | + | // description saying it rather than the reader finding out: a | |
| 249 | + | // host with no browser can decide what to do with a link off | |
| 250 | + | // its own service. | |
| 251 | + | cell membership.community.clone() { | |
| 252 | + | activate to external membership.profile_url.clone(); | |
| 253 | + | } | |
| 254 | + | cell "" { | |
| 255 | + | token Tag::badge(membership.role.clone()); | |
| 256 | + | } | |
| 257 | + | cell membership.posts.clone(); | |
| 258 | + | cell membership.joined.clone(); | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | } | |
| 246 | 262 | } | |
| 247 | 263 | ||
| 248 | 264 | /// The renderer both screens are drawn with. | |
| @@ -252,8 +268,10 @@ | |||
| 252 | 268 | ||
| 253 | 269 | #[cfg(test)] | |
| 254 | 270 | mod tests { | |
| 255 | - | use super::*; | |
| 256 | 271 | use quasi_axum::Serves; | |
| 272 | + | use quasi_router::Node; | |
| 273 | + | ||
| 274 | + | use super::*; | |
| 257 | 275 | ||
| 258 | 276 | fn membership(community: &str, role: &str) -> MembershipView { | |
| 259 | 277 | MembershipView { | |
| @@ -308,6 +326,18 @@ | |||
| 308 | 326 | ); | |
| 309 | 327 | } | |
| 310 | 328 | ||
| 329 | + | /// The four headings, which moved from a hand-written `Table::new` list | |
| 330 | + | /// into the declaration. A column dropped on the way is silent: the cells | |
| 331 | + | /// still render and land under the wrong name. | |
| 332 | + | #[test] | |
| 333 | + | fn every_column_the_table_had_is_still_named() { | |
| 334 | + | let html = render(&table(&[membership("rust", "Moderator")])); | |
| 335 | + | ||
| 336 | + | for heading in ["Community", "Role", "Posts", "Joined"] { | |
| 337 | + | assert!(html.contains(heading), "{heading} is gone from {html}"); | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 311 | 341 | #[test] | |
| 312 | 342 | fn one_table_serves_both_screens() { | |
| 313 | 343 | // The point of the batch. Two screens rendering the same rows differ in |
| @@ -36,8 +36,9 @@ | |||
| 36 | 36 | //! [`super::project_members`] left `/api/projects/{id}/members` alone. | |
| 37 | 37 | ||
| 38 | 38 | use makeover_layout as layout; | |
| 39 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 40 | - | use quasi_router::{Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 39 | + | use quasi_declare::declare; | |
| 40 | + | use quasi_router::screen::Tag; | |
| 41 | + | use quasi_router::{Method, Request, Response, RouteError}; | |
| 41 | 42 | use quasi_webview::Webview; | |
| 42 | 43 | ||
| 43 | 44 | use super::Viewer; | |
| @@ -65,52 +66,60 @@ | |||
| 65 | 66 | Webview::new().fragment(&pane(item, sales, export_route)) | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | - | /// The panel in its region. | |
| 69 | - | fn pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node { | |
| 70 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 71 | - | for node in body(item, sales, export_route) { | |
| 72 | - | slot = slot.with(node); | |
| 69 | + | declare! { | |
| 70 | + | /// The panel in its region. | |
| 71 | + | /// | |
| 72 | + | /// `body` is gone: it existed to build the contents as a `Vec<Node>` so the | |
| 73 | + | /// empty case could return early, and a guard says that inline. Nothing it | |
| 74 | + | /// drew has moved. | |
| 75 | + | shape pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node; | |
| 76 | + | ||
| 77 | + | region REGION as Pane { | |
| 78 | + | section "Sales"; | |
| 79 | + | ||
| 80 | + | empty "No sales yet. Sales will appear here once your first purchase is completed." | |
| 81 | + | when sales.is_empty(); | |
| 82 | + | ||
| 83 | + | // The export is offered only when there is something to export, which | |
| 84 | + | // is what the template said with `{% if !sales.is_empty() %}`. | |
| 85 | + | include super::export_act::act(export_route, "item-sales.csv") | |
| 86 | + | unless sales.is_empty(); | |
| 87 | + | include table(item, sales) unless sales.is_empty(); | |
| 73 | 88 | } | |
| 74 | - | Node::Region(slot) | |
| 75 | 89 | } | |
| 76 | 90 | ||
| 77 | - | /// The panel's contents, in order. | |
| 78 | - | fn body(item: ItemId, sales: &[SaleRow], export_route: &str) -> Vec<Node> { | |
| 79 | - | let mut out = vec![Node::section("Sales")]; | |
| 91 | + | declare! { | |
| 92 | + | /// The transaction history. | |
| 93 | + | /// | |
| 94 | + | /// The columns are declared here and the cells are built in [`row`], so the | |
| 95 | + | /// cells name their columns rather than counting to them. Position would ask | |
| 96 | + | /// a reader of either function to hold the other one in their head, and the | |
| 97 | + | /// refund column at the end is the one that would move if this list ever | |
| 98 | + | /// grew a heading in the middle. | |
| 99 | + | shape table(item: ItemId, sales: &[SaleRow]) -> Node; | |
| 80 | 100 | ||
| 81 | - | if sales.is_empty() { | |
| 82 | - | out.push(Node::empty( | |
| 83 | - | "No sales yet. Sales will appear here once your first purchase is completed.", | |
| 84 | - | )); | |
| 85 | - | return out; | |
| 101 | + | table { | |
| 102 | + | column COL_DATE { | |
| 103 | + | width Content; | |
| 104 | + | priority Essential; | |
| 105 | + | } | |
| 106 | + | column COL_BUYER { | |
| 107 | + | width Fill; | |
| 108 | + | } | |
| 109 | + | column COL_AMOUNT { | |
| 110 | + | width Content; | |
| 111 | + | } | |
| 112 | + | column COL_STATUS { | |
| 113 | + | width Content; | |
| 114 | + | } | |
| 115 | + | column COL_ACTS { | |
| 116 | + | width Content; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | for sale in sales.iter() { | |
| 120 | + | include row(item, sale); | |
| 121 | + | } | |
| 86 | 122 | } | |
| 87 | - | ||
| 88 | - | // The export is offered only when there is something to export, which is | |
| 89 | - | // what the template said with `{% if !sales.is_empty() %}`. | |
| 90 | - | out.push(super::export_act::act(export_route, "item-sales.csv")); | |
| 91 | - | out.push(table(item, sales)); | |
| 92 | - | out | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | /// The transaction history. | |
| 96 | - | /// | |
| 97 | - | /// The columns are declared here and the cells are built in [`row`], so the | |
| 98 | - | /// cells name their columns rather than counting to them. Position would ask a | |
| 99 | - | /// reader of either function to hold the other one in their head, and the | |
| 100 | - | /// refund column at the end is the one that would move if this list ever grew a | |
| 101 | - | /// heading in the middle. | |
| 102 | - | fn table(item: ItemId, sales: &[SaleRow]) -> Node { | |
| 103 | - | Table::new([ | |
| 104 | - | Column::new(COL_DATE) | |
| 105 | - | .width(layout::Width::Content) | |
| 106 | - | .priority(layout::Priority::Essential), | |
| 107 | - | Column::new(COL_BUYER).width(layout::Width::Fill), | |
| 108 | - | Column::new(COL_AMOUNT).width(layout::Width::Content), | |
| 109 | - | Column::new(COL_STATUS).width(layout::Width::Content), | |
| 110 | - | Column::new(COL_ACTS).width(layout::Width::Content), | |
| 111 | - | ]) | |
| 112 | - | .rows(sales.iter().map(|sale| row(item, sale))) | |
| 113 | - | .into() | |
| 114 | 123 | } | |
| 115 | 124 | ||
| 116 | 125 | /// The headings, written once so the two halves cannot drift apart. | |
| @@ -125,33 +134,35 @@ | |||
| 125 | 134 | const COL_STATUS: &str = "Status"; | |
| 126 | 135 | const COL_ACTS: &str = ""; | |
| 127 | 136 | ||
| 128 | - | /// One sale. | |
| 129 | - | fn row(item: ItemId, sale: &SaleRow) -> Cells { | |
| 130 | - | let mut status = Tag::badge(sale.status.clone()); | |
| 131 | - | status.tone = tone(sale.status_tone); | |
| 137 | + | declare! { | |
| 138 | + | /// One sale. | |
| 139 | + | shape row(item: ItemId, sale: &SaleRow) -> Cells; | |
| 132 | 140 | ||
| 133 | - | let mut action = Cell::new(String::new()); | |
| 134 | - | if sale.refundable { | |
| 135 | - | action = action.act( | |
| 136 | - | Act::new( | |
| 137 | - | "Refund", | |
| 138 | - | quasi_router::Action::post(format!("{NEST}/{item}/{}", sale.transaction_id)) | |
| 139 | - | .awaiting(), | |
| 140 | - | ) | |
| 141 | - | .tone(layout::Tone::Danger) | |
| 142 | - | .confirm(format!( | |
| 143 | - | "Issue a full refund for {}? This cannot be undone.", | |
| 144 | - | sale.amount_display | |
| 145 | - | )), | |
| 146 | - | ); | |
| 141 | + | cells { | |
| 142 | + | cell at COL_DATE sale.date.clone(); | |
| 143 | + | cell at COL_BUYER sale.buyer.clone(); | |
| 144 | + | cell at COL_AMOUNT sale.amount_display.clone(); | |
| 145 | + | cell at COL_STATUS "" { | |
| 146 | + | token badge(sale); | |
| 147 | + | } | |
| 148 | + | cell at COL_ACTS "" { | |
| 149 | + | act "Refund" | |
| 150 | + | to post "{NEST}/{item}/{sale.transaction_id}" awaiting | |
| 151 | + | when sale.refundable | |
| 152 | + | { | |
| 153 | + | tone Danger; | |
| 154 | + | confirm "Issue a full refund for {sale.amount_display}? This cannot be undone."; | |
| 155 | + | } | |
| 156 | + | } | |
| 147 | 157 | } | |
| 158 | + | } | |
| 148 | 159 | ||
| 149 | - | Cells::default() | |
| 150 | - | .at(COL_DATE, Cell::new(sale.date.clone())) | |
| 151 | - | .at(COL_BUYER, Cell::new(sale.buyer.clone())) | |
| 152 | - | .at(COL_AMOUNT, Cell::new(sale.amount_display.clone())) | |
| 153 | - | .at(COL_STATUS, Cell::new(String::new()).token(status)) | |
| 154 | - | .at(COL_ACTS, action) | |
| 160 | + | /// The status badge, toned. | |
| 161 | + | /// | |
| 162 | + | /// A supplier because the tone is a mapping from a string the row carries, and | |
| 163 | + | /// a mapping is what [`tone`] is. | |
| 164 | + | fn badge(sale: &SaleRow) -> Tag { | |
| 165 | + | Tag::badge(sale.status.clone()).tone(tone(sale.status_tone)) | |
| 155 | 166 | } | |
| 156 | 167 | ||
| 157 | 168 | /// The badge tone the template set with `data-tone`. | |
| @@ -369,6 +380,32 @@ | |||
| 369 | 380 | ); | |
| 370 | 381 | } | |
| 371 | 382 | ||
| 383 | + | /// The five headings. `COL_ACTS` is deliberately empty, so the four that | |
| 384 | + | /// read are what a dropped column would take with it. | |
| 385 | + | #[test] | |
| 386 | + | fn every_column_the_table_had_is_still_named() { | |
| 387 | + | let html = render(&[sale(true)]); | |
| 388 | + | ||
| 389 | + | for heading in [COL_DATE, COL_BUYER, COL_AMOUNT, COL_STATUS] { | |
| 390 | + | assert!(html.contains(heading), "{heading} is gone from {html}"); | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// The status badge's tone, which the template set with `data-tone` and a | |
| 395 | + | /// supplier now maps. A badge that lost its tone reads as neutral and says | |
| 396 | + | /// nothing about whether the sale went through. | |
| 397 | + | #[test] | |
| 398 | + | fn a_sale_status_keeps_the_tone_the_template_set() { | |
| 399 | + | let mut refunded = sale(false); | |
| 400 | + | refunded.status = "refunded".to_owned(); | |
| 401 | + | refunded.status_tone = "warning"; | |
| 402 | + | ||
| 403 | + | let html = render(&[refunded]); | |
| 404 | + | ||
| 405 | + | assert!(html.contains("refunded"), "{html}"); | |
| 406 | + | assert!(html.contains(r#"data-tone="warning""#), "{html}"); | |
| 407 | + | } | |
| 408 | + | ||
| 372 | 409 | #[test] | |
| 373 | 410 | fn a_buyer_cannot_smuggle_markup() { | |
| 374 | 411 | let mut hostile = sale(true); |
| @@ -65,9 +65,10 @@ | |||
| 65 | 65 | //! name at all, which no description can address, so they are described here | |
| 66 | 66 | //! too and carry names of their own. See `rich_field::named`. | |
| 67 | 67 | ||
| 68 | - | use makeover_layout::{FieldKind, Fit}; | |
| 68 | + | use makeover_layout::Fit; | |
| 69 | + | use quasi_declare::declare; | |
| 69 | 70 | use quasi_router::screen::Act; | |
| 70 | - | use quasi_router::{Action, Choice, Consult, Field, Image, Node, RegionKind, Slot}; | |
| 71 | + | use quasi_router::{Action, Choice, Consult, Image, Node, RegionKind, Slot}; | |
| 71 | 72 | ||
| 72 | 73 | /// The stem both region ids are built from. | |
| 73 | 74 | const REGION: &str = "media-picker"; | |
| @@ -295,116 +296,130 @@ | |||
| 295 | 296 | .fragment(&Node::Region(Slot::new(region_id(into), RegionKind::Group))) | |
| 296 | 297 | } | |
| 297 | 298 | ||
| 298 | - | /// The tiles, or the sentence that says why there are none. | |
| 299 | - | fn grid_slot(entries: &[Entry], into: &str) -> Slot { | |
| 300 | - | let mut slot = Slot::new(grid_id(into), widget(GRID_WIDGET)); | |
| 301 | - | if entries.is_empty() { | |
| 302 | - | // Two different emptinesses read the same here, and deliberately: a | |
| 303 | - | // library with nothing in it and a filter that matched nothing both | |
| 304 | - | // leave the reader with the filters they can see and change. | |
| 305 | - | return slot.with(Node::text( | |
| 306 | - | "No media files match. Upload images in your Media Library tab first.", | |
| 307 | - | )); | |
| 299 | + | declare! { | |
| 300 | + | /// The tiles, or the sentence that says why there are none. | |
| 301 | + | /// | |
| 302 | + | /// Two different emptinesses read the same here, and deliberately: a library | |
| 303 | + | /// with nothing in it and a filter that matched nothing both leave the | |
| 304 | + | /// reader with the filters they can see and change. | |
| 305 | + | shape grid_slot(entries: &[Entry], into: &str) -> Slot; | |
| 306 | + | ||
| 307 | + | region grid_id(into) as widget(GRID_WIDGET) { | |
| 308 | + | text "No media files match. Upload images in your Media Library tab first." | |
| 309 | + | when entries.is_empty(); | |
| 310 | + | ||
| 311 | + | for entry in entries.iter() { | |
| 312 | + | include card(entry, into); | |
| 313 | + | } | |
| 308 | 314 | } | |
| 309 | - | for entry in entries { | |
| 310 | - | slot = slot.with(card(entry, into)); | |
| 311 | - | } | |
| 312 | - | slot | |
| 313 | 315 | } | |
| 314 | 316 | ||
| 315 | - | /// One tile: one control, showing the thumbnail and saying the file name. | |
| 316 | - | /// | |
| 317 | - | /// The label is the file name, which is the fact the shipped script lost to a | |
| 318 | - | /// misspelled JSON key. The deposit is `markdown_ref` verbatim, which is the | |
| 319 | - | /// fact that script wrapped twice. | |
| 320 | - | /// | |
| 321 | - | /// `Action::local()`, so the press makes no request. The old picker made none | |
| 322 | - | /// either; what it did instead was 155 lines of DOM building. | |
| 323 | - | /// | |
| 324 | - | /// # This was two members and half a tile until quasi 0.69.0 | |
| 325 | - | /// | |
| 326 | - | /// The conversion found that an [`Act`] was a label and nothing said a control | |
| 327 | - | /// reads as a picture, so the tile was a region holding the picture and the | |
| 328 | - | /// control as siblings and only the file name answered a press -- a reader | |
| 329 | - | /// aiming at the thumbnail, which is the whole affordance of a picture picker, | |
| 330 | - | /// hit nothing. `Act::shows` (quasicoherent `db998898`) is that member, and the | |
| 331 | - | /// tile is one control again. | |
| 332 | - | /// | |
| 333 | - | /// The region went with it, and so did the id it needed. [`Entry::id`] is kept | |
| 334 | - | /// for the caller's convenience and no longer addresses anything. | |
| 335 | - | fn card(entry: &Entry, into: &str) -> Node { | |
| 336 | - | let mut act = Act::new(&entry.filename, Action::local()).filling(into, &entry.reference); | |
| 337 | - | if entry.image { | |
| 317 | + | declare! { | |
| 318 | + | /// One tile: one control, showing the thumbnail and saying the file name. | |
| 319 | + | /// | |
| 320 | + | /// The label is the file name, which is the fact the shipped script lost to | |
| 321 | + | /// a misspelled JSON key. The deposit is `markdown_ref` verbatim, which is | |
| 322 | + | /// the fact that script wrapped twice. | |
| 323 | + | /// | |
| 324 | + | /// `local`, so the press makes no request. The old picker made none either; | |
| 325 | + | /// what it did instead was 155 lines of DOM building. | |
| 326 | + | /// | |
| 327 | + | /// # This was two members and half a tile until quasi 0.69.0 | |
| 328 | + | /// | |
| 329 | + | /// The conversion found that an [`Act`] was a label and nothing said a | |
| 330 | + | /// control reads as a picture, so the tile was a region holding the picture | |
| 331 | + | /// and the control as siblings and only the file name answered a press -- a | |
| 332 | + | /// reader aiming at the thumbnail, which is the whole affordance of a | |
| 333 | + | /// picture picker, hit nothing. `Act::shows` (quasicoherent `db998898`) is | |
| 334 | + | /// that member, and the tile is one control again. | |
| 335 | + | /// | |
| 336 | + | /// The region went with it, and so did the id it needed. [`Entry::id`] is | |
| 337 | + | /// kept for the caller's convenience and no longer addresses anything. | |
| 338 | + | shape card(entry: &Entry, into: &str) -> Node; | |
| 339 | + | ||
| 340 | + | act &entry.filename to local { | |
| 341 | + | filling into &entry.reference; | |
| 338 | 342 | // The alt is the file name, because that is what the tile is for: a | |
| 339 | 343 | // reader who cannot see the thumbnail is choosing between file names, | |
| 340 | - | // which is exactly what the control says. | |
| 341 | - | let mut picture = Image::new(&entry.url, &entry.filename).lazy(); | |
| 342 | - | // The letterbox, which is what the shipped tile did with | |
| 343 | - | // `object-fit: contain`: a thumbnail grid whose files are not all one | |
| 344 | - | // shape, and the whole picture matters more than filling the square. | |
| 345 | - | picture.fit = Fit::Contain; | |
| 346 | - | act = act.showing(picture); | |
| 344 | + | // which is exactly what the control says. The fit is the letterbox the | |
| 345 | + | // shipped tile did with `object-fit: contain`: a thumbnail grid whose | |
| 346 | + | // files are not all one shape, and the whole picture matters more than | |
| 347 | + | // filling the square. | |
| 348 | + | showing Image::new(&entry.url, &entry.filename).lazy().fit(Fit::Contain) | |
| 349 | + | when entry.image; | |
| 347 | 350 | } | |
| 348 | - | Node::Act(act) | |
| 349 | 351 | } | |
| 350 | 352 | ||
| 351 | - | /// The typed filter. | |
| 352 | - | /// | |
| 353 | - | /// It asks rather than writes: see this module's header on why a consult and | |
| 354 | - | /// not `changes`. The folder rides along under [`Consult::sending`], so | |
| 355 | - | /// narrowing by name inside a folder stays inside it. | |
| 356 | - | /// | |
| 357 | - | /// The folder is also carried on the action, which looks redundant and is the | |
| 358 | - | /// fallback: htmx drops an address parameter whose name the payload repeats, so | |
| 359 | - | /// the live select wins whenever it is found, and the carried value is what a | |
| 360 | - | /// document with no select in reach sends instead. | |
| 361 | - | /// | |
| 362 | - | /// `sending` names a field document-wide, which is the vocabulary's own | |
| 363 | - | /// reading. A document holding two open pickers would therefore match two | |
| 364 | - | /// selects and send both values. Not guarded, because a picker is a modal | |
| 365 | - | /// covering the viewport and a reader cannot reach the second trigger while the | |
| 366 | - | /// first is open; recorded so the next reader knows it was seen rather than | |
| 367 | - | /// missed. | |
| 368 | - | fn name_filter(into: &str, name: &str, folder: &str) -> Field { | |
| 369 | - | let mut field = Field::new(FieldKind::Text, NAME, "Filter by name").consulting( | |
| 370 | - | Consult::new( | |
| 353 | + | declare! { | |
| 354 | + | /// The typed filter. | |
| 355 | + | /// | |
| 356 | + | /// It asks rather than writes: see this module's header on why a consult and | |
| 357 | + | /// not `changes`. The folder rides along under [`Consult::sending`], so | |
| 358 | + | /// narrowing by name inside a folder stays inside it. | |
| 359 | + | /// | |
| 360 | + | /// The folder is also carried on the action, which looks redundant and is | |
| 361 | + | /// the fallback: htmx drops an address parameter whose name the payload | |
| 362 | + | /// repeats, so the live select wins whenever it is found, and the carried | |
| 363 | + | /// value is what a document with no select in reach sends instead. | |
| 364 | + | /// | |
| 365 | + | /// `sending` names a field document-wide, which is the vocabulary's own | |
| 366 | + | /// reading. A document holding two open pickers would therefore match two | |
| 367 | + | /// selects and send both values. Not guarded, because a picker is a modal | |
| 368 | + | /// covering the viewport and a reader cannot reach the second trigger while | |
| 369 | + | /// the first is open; recorded so the next reader knows it was seen rather | |
| 370 | + | /// than missed. | |
| 371 | + | shape name_filter(into: &str, name: &str, folder: &str) -> Field; | |
| 372 | + | ||
| 373 | + | field Text NAME "Filter by name" { | |
| 374 | + | consulting Consult::new( | |
| 371 | 375 | Action::get(GRID_PATH) | |
| 372 | 376 | .carrying(INTO, into) | |
| 373 | 377 | .carrying(FOLDER, folder) | |
| 374 | 378 | .replacing(grid_id(into)), | |
| 375 | 379 | ) | |
| 376 | 380 | .after(WAIT) | |
| 377 | - | .sending([FOLDER]), | |
| 378 | - | ); | |
| 379 | - | field.placeholder = Some("Filter by name...".to_owned()); | |
| 380 | - | field.value = Some(name.to_owned()); | |
| 381 | - | field | |
| 381 | + | .sending([FOLDER]); | |
| 382 | + | placeholder "Filter by name..."; | |
| 383 | + | value name; | |
| 384 | + | } | |
| 382 | 385 | } | |
| 383 | 386 | ||
| 384 | - | /// The folder chooser. | |
| 387 | + | declare! { | |
| 388 | + | /// The folder chooser. | |
| 389 | + | /// | |
| 390 | + | /// A consult and not a write: it narrows what the grid shows and puts | |
| 391 | + | /// nothing anywhere. `Consult::at_once` because a select is at its next | |
| 392 | + | /// value or its last one and there is nothing to wait out -- which is the | |
| 393 | + | /// half `aeb44860` left with the description after moving the event to the | |
| 394 | + | /// renderer. It carries the typed filter forward on the action, because a | |
| 395 | + | /// field's question sends its own value and nothing else. | |
| 396 | + | shape folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field; | |
| 397 | + | ||
| 398 | + | field Select FOLDER "Folder" { | |
| 399 | + | options folder_choices(folders); | |
| 400 | + | consulting Consult::at_once( | |
| 401 | + | Action::get(GRID_PATH) | |
| 402 | + | .carrying(INTO, into) | |
| 403 | + | .carrying(NAME, name) | |
| 404 | + | .replacing(grid_id(into)), | |
| 405 | + | ); | |
| 406 | + | value folder; | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | /// Every folder the reader has, under an "All folders" that clears the filter. | |
| 385 | 411 | /// | |
| 386 | - | /// A consult and not a write: it narrows what the grid shows and puts nothing | |
| 387 | - | /// anywhere. `Consult::at_once` because a select is at its next value or its | |
| 388 | - | /// last one and there is nothing to wait out -- which is the half `aeb44860` | |
| 389 | - | /// left with the description after moving the event to the renderer. It | |
| 390 | - | /// carries the typed filter forward on the action, because a field's question | |
| 391 | - | /// sends its own value and nothing else. | |
| 392 | - | fn folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field { | |
| 412 | + | /// A supplier because a list built from an iterator is what the deferred table | |
| 413 | + | /// names one for. The root folder is the empty string and reads as `(root)`, | |
| 414 | + | /// which is the one place a folder's value and its label differ. | |
| 415 | + | fn folder_choices(folders: &[String]) -> Vec<Choice> { | |
| 393 | 416 | let mut options = vec![Choice::new("", "All folders")]; | |
| 394 | 417 | options.extend( | |
| 395 | 418 | folders | |
| 396 | 419 | .iter() | |
| 397 | 420 | .map(|folder| Choice::new(folder, if folder.is_empty() { "(root)" } else { folder })), | |
| 398 | 421 | ); | |
| 399 | - | ||
| 400 | - | let mut field = Field::select(FOLDER, "Folder", options).consulting(Consult::at_once( | |
| 401 | - | Action::get(GRID_PATH) | |
| 402 | - | .carrying(INTO, into) | |
| 403 | - | .carrying(NAME, name) | |
| 404 | - | .replacing(grid_id(into)), | |
| 405 | - | )); | |
| 406 | - | field.value = Some(folder.to_owned()); | |
| 407 | - | field | |
| 422 | + | options | |
| 408 | 423 | } | |
| 409 | 424 | ||
| 410 | 425 | #[cfg(test)] | |
| @@ -543,6 +558,24 @@ | |||
| 543 | 558 | assert!(html.contains("delay:150ms"), "{html}"); | |
| 544 | 559 | } | |
| 545 | 560 | ||
| 561 | + | /// The folder chooser's options, which are a supplier's list now rather | |
| 562 | + | /// than an argument to `Field::select`. The root folder reads as `(root)` | |
| 563 | + | /// and is the one place a folder's value and its label differ. | |
| 564 | + | #[test] | |
| 565 | + | fn the_folder_chooser_offers_every_folder_and_a_way_to_clear_it() { | |
| 566 | + | let html = picker( | |
| 567 | + | &[], | |
| 568 | + | &["drums".to_owned(), String::new()], | |
| 569 | + | "body", | |
| 570 | + | "", | |
| 571 | + | "drums", | |
| 572 | + | ); | |
| 573 | + | ||
| 574 | + | assert!(html.contains("All folders"), "{html}"); | |
| 575 | + | assert!(html.contains("drums"), "{html}"); | |
| 576 | + | assert!(html.contains("(root)"), "{html}"); | |
| 577 | + | } | |
| 578 | + | ||
| 546 | 579 | /// The picker is opened for a box, so every action it emits carries which | |
| 547 | 580 | /// one. The shipped script held this in a module-level variable. | |
| 548 | 581 | #[test] |
| @@ -31,6 +31,7 @@ | |||
| 31 | 31 | //! in the tree with many concurrent readers. It is not arguable, only | |
| 32 | 32 | //! measurable: see `tests/load` and the S3 numbers in the wiki note. | |
| 33 | 33 | ||
| 34 | + | use quasi_declare::declare; | |
| 34 | 35 | use tokio::runtime::Handle; | |
| 35 | 36 | ||
| 36 | 37 | use axum::extract::FromRef; | |
| @@ -764,27 +765,32 @@ | |||
| 764 | 765 | quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router() | |
| 765 | 766 | } | |
| 766 | 767 | ||
| 767 | - | /// This server's own copy, as markdown. | |
| 768 | - | /// | |
| 769 | - | /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its | |
| 770 | - | /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are | |
| 771 | - | /// filtered. That is right for a forum post and wrong for a page's own | |
| 772 | - | /// sentence, and until quasi grew the trust axis every described page here was | |
| 773 | - | /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`). | |
| 774 | - | /// | |
| 775 | - | /// The two axes stay separate in quasi -- `Richness` is what the format may | |
| 776 | - | /// express, `Trust` is who wrote it -- and this is the one combination this | |
| 777 | - | /// server reaches for often enough to name: a sentence, ours. A screen wanting | |
| 778 | - | /// tables says so with `Node::richness`, and a screen carrying a reader's | |
| 779 | - | /// markdown keeps `Node::rich`. | |
| 780 | - | /// | |
| 781 | - | /// **The test for using it is authorship, not tidiness.** The string has to be | |
| 782 | - | /// a literal in this repository, or interpolated from a value that cannot carry | |
| 783 | - | /// markup. A creator's description reaching a screen through the database is | |
| 784 | - | /// `Node::rich` however well-behaved it has been. | |
| 785 | - | #[must_use] | |
| 786 | - | pub fn own_prose(source: impl Into<String>) -> quasi_router::Node { | |
| 787 | - | quasi_router::Node::rich(source).trust(quasi_router::Trust::Trusted) | |
| 768 | + | declare! { | |
| 769 | + | /// This server's own copy, as markdown. | |
| 770 | + | /// | |
| 771 | + | /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so | |
| 772 | + | /// its links carry `nofollow`, its raw markup is dropped and fetchable | |
| 773 | + | /// schemes are filtered. That is right for a forum post and wrong for a | |
| 774 | + | /// page's own sentence, and until quasi grew the trust axis every described | |
| 775 | + | /// page here was telling crawlers not to follow its own links | |
| 776 | + | /// (quasicoherent `24a3b1df`). | |
| 777 | + | /// | |
| 778 | + | /// The two axes stay separate in quasi -- `Richness` is what the format may | |
| 779 | + | /// express, `Trust` is who wrote it -- and this is the one combination this | |
| 780 | + | /// server reaches for often enough to name: a sentence, ours. A screen | |
| 781 | + | /// wanting tables says so with `Node::richness`, and a screen carrying a | |
| 782 | + | /// reader's markdown keeps `Node::rich`. | |
| 783 | + | /// | |
| 784 | + | /// **The test for using it is authorship, not tidiness.** The string has to | |
| 785 | + | /// be a literal in this repository, or interpolated from a value that | |
| 786 | + | /// cannot carry markup. A creator's description reaching a screen through | |
| 787 | + | /// the database is `Node::rich` however well-behaved it has been. | |
| 788 | + | #[must_use] | |
| 789 | + | pub shape own_prose(source: impl Into<String>) -> Node; | |
| 790 | + | ||
| 791 | + | rich source { | |
| 792 | + | trust quasi_router::Trust::Trusted; | |
| 793 | + | } | |
| 788 | 794 | } | |
| 789 | 795 | ||
| 790 | 796 | /// A handler, spelled once so the screens and the mount agree about it. | |
| @@ -896,6 +902,31 @@ | |||
| 896 | 902 | mod tests { | |
| 897 | 903 | use super::*; | |
| 898 | 904 | ||
| 905 | + | /// The whole of what `own_prose` is for: a sentence this server wrote is | |
| 906 | + | /// trusted, so quasi stops telling crawlers not to follow its own links. | |
| 907 | + | /// `Node::rich` alone is untrusted and is the right default for a forum | |
| 908 | + | /// post; the two are one call apart and read the same at a glance. | |
| 909 | + | #[test] | |
| 910 | + | fn our_own_sentence_is_trusted_and_a_readers_is_not() { | |
| 911 | + | let ours = own_prose("Read the [docs](/docs)."); | |
| 912 | + | let theirs = quasi_router::Node::rich("Read the [docs](/docs)."); | |
| 913 | + | ||
| 914 | + | assert!(matches!( | |
| 915 | + | ours, | |
| 916 | + | quasi_router::Node::Rich { | |
| 917 | + | trust: quasi_router::Trust::Trusted, | |
| 918 | + | .. | |
| 919 | + | } | |
| 920 | + | )); | |
| 921 | + | assert!(matches!( | |
| 922 | + | theirs, | |
| 923 | + | quasi_router::Node::Rich { | |
| 924 | + | trust: quasi_router::Trust::Untrusted, | |
| 925 | + | .. | |
| 926 | + | } | |
| 927 | + | )); | |
| 928 | + | } | |
| 929 | + | ||
| 899 | 930 | #[test] | |
| 900 | 931 | fn every_screen_is_listed_in_paths() { | |
| 901 | 932 | // The two lists are written by hand and read by two different things, |