max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+185 insertions,
-108 deletions
| @@ -9,8 +9,14 @@ | |||
| 9 | 9 | //! | |
| 10 | 10 | //! Where the projects screen was a grid of one flat record, this one has | |
| 11 | 11 | //! sub-collections, and it is the first to describe a row that acts on itself | |
| 12 | - | //! rather than only selecting. What it could not say is recorded in [`row_for`] | |
| 13 | - | //! and [`link_row`]. | |
| 12 | + | //! rather than only selecting. | |
| 13 | + | //! | |
| 14 | + | //! It found three things the description layer could not say, and all three are | |
| 15 | + | //! closed as of 2026-08-08 rather than worked around: a row now carries tokens | |
| 16 | + | //! (`RowPart::Tokens`, makeover-layout 0.9.0), a row's tick is distinct from the | |
| 17 | + | //! app's own pointer (`Row::selected` against `Row::current`), and an action can | |
| 18 | + | //! go somewhere outside the app (`Destination::External`). [`row_for`] and | |
| 19 | + | //! [`link_row`] carry the detail of what each one replaced. | |
| 14 | 20 | //! | |
| 15 | 21 | //! # The shape | |
| 16 | 22 | //! | |
| @@ -37,7 +43,7 @@ | |||
| 37 | 43 | #![allow(clippy::needless_pass_by_value)] | |
| 38 | 44 | ||
| 39 | 45 | use goingson_core::{Contact, ContactId}; | |
| 40 | - | use quasi_router::screen::{Act, Row}; | |
| 46 | + | use quasi_router::screen::{Act, Row, Tag}; | |
| 41 | 47 | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 42 | 48 | ||
| 43 | 49 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -62,31 +68,31 @@ | |||
| 62 | 68 | ||
| 63 | 69 | /// One contact as a row. | |
| 64 | 70 | /// | |
| 65 | - | /// # What the description could not say | |
| 71 | + | /// # Two findings, both closed by makeover-layout 0.9.0 | |
| 66 | 72 | /// | |
| 67 | - | /// **A card carries five facts and a row holds three.** The same gap | |
| 68 | - | /// [`super::projects::row_for`] found, biting harder rather than differently. | |
| 69 | - | /// `renderCard` shows a display name, a nickname in quotes, a company, a primary | |
| 70 | - | /// email and a strip of tag badges; `Row` is `primary`, `secondary`, `meta`. The | |
| 71 | - | /// nickname joins the name and the tags join `meta` behind the email, which | |
| 72 | - | /// keeps every fact and loses the shape: tags read as trailing text rather than | |
| 73 | - | /// as the pills they are. `Node::Token` says exactly the right thing and still | |
| 74 | - | /// exists only as a node in its own right, never inside a row. Naming it in | |
| 75 | - | /// `makeover-layout` first is what the admission test requires. | |
| 73 | + | /// This card was the evidence for two gaps when the screen was first described, | |
| 74 | + | /// and both are now said properly rather than worked around. | |
| 76 | 75 | /// | |
| 77 | - | /// **A row cannot be selected without being activated.** The card carries a bulk | |
| 78 | - | /// checkbox (`contacts.toggleSelection`, feeding `bulk-actions.js`), and the | |
| 79 | - | /// vocabulary has `Row::selected` — which means "this is the row the detail pane | |
| 80 | - | /// is showing", not "the user ticked it". One is the app's state and the other | |
| 81 | - | /// is the user's, and there is currently one word for both. Left out here rather | |
| 82 | - | /// than smuggled in as an `Act`, so the gap stays visible. | |
| 76 | + | /// **The tags were joined into `meta` as text**, behind the primary email, | |
| 77 | + | /// because a row had one trailing slot. They are [`Tag`]s now, against | |
| 78 | + | /// `RowPart::Tokens`, and each one is a chip that filters the grid by itself — | |
| 79 | + | /// which is what `contacts.js` does when a badge is clicked, and which the | |
| 80 | + | /// joined string could not express at all. The email stays in `meta`, where a | |
| 81 | + | /// plain fact belongs. | |
| 83 | 82 | /// | |
| 84 | - | /// **The avatar is dropped.** `getInitials` derives two letters from the display | |
| 85 | - | /// name and the card shows them in a circle. That is a rendering of the primary | |
| 86 | - | /// text, not a fact about the contact, so it belongs to the renderer and there | |
| 87 | - | /// is nothing for the description to say. Recorded because it is the one thing | |
| 88 | - | /// here that is absent by being correct rather than by being missing. | |
| 89 | - | fn row_for(contact: &Contact, selected: bool) -> Row { | |
| 83 | + | /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the detail | |
| 84 | + | /// pane is showing this", so there was one word for the app's pointer and the | |
| 85 | + | /// user's tick. The two are now `current` and `selected`, and this row is | |
| 86 | + | /// selectable because the screen has bulk actions. | |
| 87 | + | /// | |
| 88 | + | /// # What is still absent, and correctly | |
| 89 | + | /// | |
| 90 | + | /// **The avatar.** `getInitials` derives two letters from the display name and | |
| 91 | + | /// the card shows them in a circle. That is a rendering of the primary text | |
| 92 | + | /// rather than a fact about the contact, so it belongs to the renderer and there | |
| 93 | + | /// is nothing for a description to say. Recorded because it is absent by being | |
| 94 | + | /// correct, not by being missing. | |
| 95 | + | fn row_for(contact: &Contact, current: bool, ticked: bool) -> Row { | |
| 90 | 96 | let name = match contact.nickname.as_deref() { | |
| 91 | 97 | Some(nickname) if !nickname.is_empty() => { | |
| 92 | 98 | format!("{} \"{}\"", contact.display_name, nickname) | |
| @@ -94,26 +100,25 @@ | |||
| 94 | 100 | _ => contact.display_name.clone(), | |
| 95 | 101 | }; | |
| 96 | 102 | ||
| 97 | - | let mut row = Row::new(name); | |
| 103 | + | let mut row = Row::new(name).selectable(ticked); | |
| 98 | 104 | ||
| 99 | 105 | if let Some(affiliation) = affiliation(contact) { | |
| 100 | 106 | row = row.secondary(affiliation); | |
| 101 | 107 | } | |
| 102 | 108 | ||
| 103 | - | // Email first, because it is what the card leads its second line with, and | |
| 104 | - | // tags after it. See the note above for what this loses. | |
| 105 | - | let mut meta: Vec<String> = Vec::new(); | |
| 106 | 109 | if let Some(email) = contact.primary_email() { | |
| 107 | - | meta.push(email.to_owned()); | |
| 108 | - | } | |
| 109 | - | if !contact.tags.is_empty() { | |
| 110 | - | meta.push(contact.tags.join(", ")); | |
| 111 | - | } | |
| 112 | - | if !meta.is_empty() { | |
| 113 | - | row = row.meta(meta.join(" · ")); | |
| 110 | + | row = row.meta(email); | |
| 114 | 111 | } | |
| 115 | 112 | ||
| 116 | - | row.selected = selected; | |
| 113 | + | for tag in &contact.tags { | |
| 114 | + | // A tag filters the grid by itself, which is the click `contacts.js` | |
| 115 | + | // already binds on the same badge. Not latched: a row's tag says what | |
| 116 | + | // the contact carries, and whether that tag is the active filter is the | |
| 117 | + | // band's business rather than this row's. | |
| 118 | + | row = row.token(Tag::chip(tag, list_action(None, Some(tag)))); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | row.current = current; | |
| 117 | 122 | row.activate = Some(Action::get(format!("/contacts/{}", contact.id))); | |
| 118 | 123 | row | |
| 119 | 124 | } | |
| @@ -138,7 +143,9 @@ | |||
| 138 | 143 | } | |
| 139 | 144 | ||
| 140 | 145 | Ok(Node::list( | |
| 141 | - | contacts.iter().map(|contact| row_for(contact, false)), | |
| 146 | + | contacts | |
| 147 | + | .iter() | |
| 148 | + | .map(|contact| row_for(contact, false, false)), | |
| 142 | 149 | )) | |
| 143 | 150 | } | |
| 144 | 151 | ||
| @@ -207,13 +214,8 @@ | |||
| 207 | 214 | // is always on screen. | |
| 208 | 215 | for in_use in tags_in_use(state)? { | |
| 209 | 216 | let latched = tag == Some(in_use.as_str()); | |
| 210 | - | band = band.with(Node::Token { | |
| 211 | - | kind: makeover_layout::Token::Chip { removable: false }, | |
| 212 | - | label: in_use.clone(), | |
| 213 | - | tone: makeover_layout::Tone::Neutral, | |
| 214 | - | latched, | |
| 215 | - | action: Some(list_action(search, (!latched).then_some(in_use.as_str()))), | |
| 216 | - | }); | |
| 217 | + | let action = list_action(search, (!latched).then_some(in_use.as_str())); | |
| 218 | + | band = band.with(Node::Token(Tag::chip(&in_use, action).latched(latched))); | |
| 217 | 219 | } | |
| 218 | 220 | ||
| 219 | 221 | Ok(Screen::list_detail("Contacts", false) | |
| @@ -231,26 +233,30 @@ | |||
| 231 | 233 | ||
| 232 | 234 | /// One entry in a sub-collection, with the control that removes it. | |
| 233 | 235 | /// | |
| 234 | - | /// # What the description could not say | |
| 236 | + | /// # The third finding, also closed | |
| 235 | 237 | /// | |
| 236 | - | /// **A row cannot carry a link.** A social handle and a custom field both have | |
| 237 | - | /// an optional `url`, and the modal renders each as an anchor through | |
| 238 | - | /// `safeUrl`. `Act` calls a route in this app; an external address is not a | |
| 239 | - | /// route, and there is no node that means "somewhere else". The URL goes into | |
| 240 | - | /// the trailing text so the fact survives, which makes it something to copy | |
| 241 | - | /// rather than something to follow. `makeover-layout` naming a link node is the | |
| 242 | - | /// fix; smuggling one in as an `Act` whose action happens to be absolute would | |
| 243 | - | /// make every renderer guess. | |
| 244 | - | fn link_row(primary: String, meta: Option<&str>, url: Option<&str>, remove: Action) -> Row { | |
| 238 | + | /// A social handle and a custom field both carry an optional `url`, and the | |
| 239 | + | /// modal renders each as an anchor through `safeUrl`. When this screen was first | |
| 240 | + | /// described there was nothing to say about that: an `Action` was a route, and an | |
| 241 | + | /// address outside the app is not one. The URL went into the trailing text, | |
| 242 | + | /// which made it something to copy rather than something to follow. | |
| 243 | + | /// | |
| 244 | + | /// [`Action::external`] is the fix, and the renderer emits an anchor for it | |
| 245 | + | /// rather than a button. Note what it is *not*: an `Act` whose path happens to | |
| 246 | + | /// start with `https`. The renderer branches on the destination's variant, never | |
| 247 | + | /// on the shape of the string, because that is how a route called | |
| 248 | + | /// `/https-setup` ends up opening a browser. | |
| 249 | + | fn link_row(primary: String, label: Option<&str>, url: Option<&str>, remove: Action) -> Row { | |
| 245 | 250 | let mut row = Row::new(primary); | |
| 246 | 251 | ||
| 247 | - | // The URL and any label share the trailing slot, url last so a long address | |
| 248 | - | // does not push the label off the end of it. | |
| 249 | - | let mut trailing: Vec<&str> = Vec::new(); | |
| 250 | - | trailing.extend(meta); | |
| 251 | - | trailing.extend(url); | |
| 252 | - | if !trailing.is_empty() { | |
| 253 | - | row = row.meta(trailing.join(" · ")); | |
| 252 | + | if let Some(label) = label { | |
| 253 | + | row = row.meta(label); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // The address is a place to go rather than a fact about the entry, so it is | |
| 257 | + | // a control and not trailing text. | |
| 258 | + | if let Some(url) = url { | |
| 259 | + | row.actions.push(Act::new("Open", Action::external(url))); | |
| 254 | 260 | } | |
| 255 | 261 | ||
| 256 | 262 | row.actions |
| @@ -8,7 +8,9 @@ | |||
| 8 | 8 | //! above](super) for why both exist at once. | |
| 9 | 9 | //! | |
| 10 | 10 | //! Two things a real screen needed that the description layer could not say | |
| 11 | - | //! turned up here immediately, and are recorded in [`row_for`]. | |
| 11 | + | //! turned up here immediately. The first is closed: a row carries tokens as of | |
| 12 | + | //! makeover-layout 0.9.0, so the two badges keep their tone. The second, rich | |
| 13 | + | //! text in a description, is still open. Both are recorded in [`row_for`]. | |
| 12 | 14 | //! | |
| 13 | 15 | //! # The shape | |
| 14 | 16 | //! | |
| @@ -29,7 +31,7 @@ | |||
| 29 | 31 | #![allow(clippy::needless_pass_by_value)] | |
| 30 | 32 | ||
| 31 | 33 | use goingson_core::{Project, ProjectStatus, ProjectType}; | |
| 32 | - | use quasi_router::screen::{Act, Row}; | |
| 34 | + | use quasi_router::screen::{Act, Row, Tag}; | |
| 33 | 35 | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 34 | 36 | ||
| 35 | 37 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -72,23 +74,37 @@ | |||
| 72 | 74 | } | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 77 | + | /// The tone a status badge wears. | |
| 78 | + | /// | |
| 79 | + | /// The Rust half of `utils.js:statusTone`, which is the one table the whole | |
| 80 | + | /// frontend maps statuses through (decided 2026-08-06, task 724a0667). Kept in | |
| 81 | + | /// step by hand while both exist, and this is the copy that survives when the JS | |
| 82 | + | /// screen goes. | |
| 83 | + | /// | |
| 84 | + | /// Archived returning neutral is deliberate there and here: it is not news. | |
| 85 | + | const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone { | |
| 86 | + | match status { | |
| 87 | + | ProjectStatus::Active => makeover_layout::Tone::Info, | |
| 88 | + | ProjectStatus::OnHold => makeover_layout::Tone::Warning, | |
| 89 | + | ProjectStatus::Completed => makeover_layout::Tone::Success, | |
| 90 | + | ProjectStatus::Archived => makeover_layout::Tone::Neutral, | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 75 | 94 | /// One project as a row. | |
| 76 | 95 | /// | |
| 77 | - | /// # The two things the description cannot say | |
| 96 | + | /// # The first finding, now closed | |
| 78 | 97 | /// | |
| 79 | - | /// Both found here, on the first real screen, which is what the proving ground | |
| 80 | - | /// was for. | |
| 98 | + | /// **A row carried one trailing fact and this card has two.** `RowPart` was | |
| 99 | + | /// `Primary | Secondary | Meta | Actions`, so the type badge and the status badge | |
| 100 | + | /// were joined into `meta` as "Side Project · On Hold": both facts kept, the tone | |
| 101 | + | /// lost, and a status reading as text where `projects.js` colours it. | |
| 81 | 102 | /// | |
| 82 | - | /// **A row carries one trailing fact and this card has two.** | |
| 83 | - | /// `makeover_layout::RowPart` is `Primary | Secondary | Meta | Actions`, taken | |
| 84 | - | /// from Balanced Breakfast as the consumer that had all four. A project card | |
| 85 | - | /// carries a type badge *and* a status badge, and the status badge is toned: | |
| 86 | - | /// `projects.js` runs `statusTone(status)` and colours it. Joined into `meta` | |
| 87 | - | /// here, which keeps both facts and loses the tone — a status reads as text | |
| 88 | - | /// rather than as green or amber. `Node::Token` exists and says exactly the | |
| 89 | - | /// right thing, but only as a node in its own right, never inside a row. | |
| 90 | - | /// Naming it in `makeover-layout` first is what the admission test requires, | |
| 91 | - | /// so this is a finding rather than a patch. | |
| 103 | + | /// makeover-layout 0.9.0 added `RowPart::Tokens` for exactly this, and it is what | |
| 104 | + | /// this row uses now. The status keeps its tone through [`status_tone`], which is | |
| 105 | + | /// the Rust half of the same table the JS reads. | |
| 106 | + | /// | |
| 107 | + | /// # The second finding, still open | |
| 92 | 108 | /// | |
| 93 | 109 | /// **A description carries text and this card carries markdown.** | |
| 94 | 110 | /// `ProjectResponse::description_html` is `docengine::render_standard`, and the | |
| @@ -97,20 +113,18 @@ | |||
| 97 | 113 | /// door through which a description becomes a templating language. The raw | |
| 98 | 114 | /// description goes into `secondary` as text. A `Region::Bespoke` is the | |
| 99 | 115 | /// vocabulary's own answer for a place the app fills itself, and it is the | |
| 100 | - | /// shape this wants if it turns out to matter. | |
| 101 | - | fn row_for(project: &Project, selected: bool) -> Row { | |
| 102 | - | let mut row = Row::new(&project.name).meta(format!( | |
| 103 | - | "{} · {}", | |
| 104 | - | type_label(&project.project_type), | |
| 105 | - | status_label(&project.status) | |
| 106 | - | )); | |
| 116 | + | /// shape this wants if it turns out to matter. Filed as `25822137`. | |
| 117 | + | fn row_for(project: &Project, current: bool) -> Row { | |
| 118 | + | let mut row = Row::new(&project.name) | |
| 119 | + | .token(Tag::badge(type_label(&project.project_type))) | |
| 120 | + | .token(Tag::badge(status_label(&project.status)).tone(status_tone(&project.status))); | |
| 107 | 121 | ||
| 108 | 122 | if !project.description.is_empty() { | |
| 109 | 123 | // Text, not the rendered HTML. See the note above. | |
| 110 | 124 | row = row.secondary(&project.description); | |
| 111 | 125 | } | |
| 112 | 126 | ||
| 113 | - | row.selected = selected; | |
| 127 | + | row.current = current; | |
| 114 | 128 | row.activate = Some(Action::get(format!("/projects/{}", project.id))); | |
| 115 | 129 | row | |
| 116 | 130 | } | |
| @@ -200,13 +214,9 @@ | |||
| 200 | 214 | // The filter surfaces only when sharing is in play, which is the rule | |
| 201 | 215 | // `projects.js` already applies to the same control. | |
| 202 | 216 | if shared > 0 || shared_only { | |
| 203 | - | band = band.with(Node::Token { | |
| 204 | - | kind: makeover_layout::Token::Chip { removable: false }, | |
| 205 | - | label: "Shared only".into(), | |
| 206 | - | tone: makeover_layout::Tone::Neutral, | |
| 207 | - | latched: shared_only, | |
| 208 | - | action: Some(list_action(!shared_only, show_retired)), | |
| 209 | - | }); | |
| 217 | + | band = band.with(Node::Token( | |
| 218 | + | Tag::chip("Shared only", list_action(!shared_only, show_retired)).latched(shared_only), | |
| 219 | + | )); | |
| 210 | 220 | } | |
| 211 | 221 | ||
| 212 | 222 | if dormant > 0 { |
| @@ -189,10 +189,12 @@ | |||
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 191 | #[tokio::test] | |
| 192 | - | async fn a_row_carries_the_email_and_the_tags_as_one_trailing_fact() { | |
| 193 | - | // The vocabulary gap, asserted so the workaround is visible rather than | |
| 194 | - | // silently correct: five facts, three slots, and the tags read as text | |
| 195 | - | // rather than as pills. See `row_for`'s note. | |
| 192 | + | async fn a_row_carries_its_tags_as_tokens_and_its_email_as_the_plain_fact() { | |
| 193 | + | // Was `a_row_carries_the_email_and_the_tags_as_one_trailing_fact`, which | |
| 194 | + | // asserted the workaround: both joined into `meta` as | |
| 195 | + | // "ada@example.com · friend". makeover-layout 0.9.0 gave a row somewhere to | |
| 196 | + | // put tokens, so the two facts are now the two different kinds of thing they | |
| 197 | + | // always were. | |
| 196 | 198 | let state = state().await; | |
| 197 | 199 | let contact = add(&state, "Ada"); | |
| 198 | 200 | state | |
| @@ -213,7 +215,30 @@ | |||
| 213 | 215 | .unwrap(); | |
| 214 | 216 | ||
| 215 | 217 | let html = fragment_html(get(&state, "/contacts/list", Params::new())); | |
| 216 | - | assert!(html.contains("ada@example.com · friend")); | |
| 218 | + | ||
| 219 | + | // The email is a plain trailing fact and stays one. | |
| 220 | + | assert!(html.contains("class=\"row-meta\">ada@example.com</span>")); | |
| 221 | + | // The tag is a token, in the token strip, and clicking it filters the grid. | |
| 222 | + | assert!(html.contains("class=\"row-tokens\"")); | |
| 223 | + | assert!(html.contains("friend")); | |
| 224 | + | assert!(html.contains("hx-vals=\"{"tag":"friend"}\"")); | |
| 225 | + | // And no longer joined into one string. | |
| 226 | + | assert!(!html.contains("ada@example.com · friend")); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[tokio::test] | |
| 230 | + | async fn a_contact_row_can_be_ticked_without_being_the_current_one() { | |
| 231 | + | // The other closed gap. `Row::selected` used to mean "the detail pane is | |
| 232 | + | // showing this", so a bulk checkbox had no way to be described. It is now | |
| 233 | + | // the user's tick, and `current` is the app's pointer. | |
| 234 | + | let state = state().await; | |
| 235 | + | add(&state, "Ada"); | |
| 236 | + | ||
| 237 | + | let html = fragment_html(get(&state, "/contacts/list", Params::new())); | |
| 238 | + | assert!(html.contains("type=\"checkbox\"")); | |
| 239 | + | // Selectable but not ticked, and not the current row either. | |
| 240 | + | assert!(!html.contains(" checked")); | |
| 241 | + | assert!(!html.contains("aria-current")); | |
| 217 | 242 | } | |
| 218 | 243 | ||
| 219 | 244 | #[tokio::test] | |
| @@ -263,8 +288,12 @@ | |||
| 263 | 288 | } | |
| 264 | 289 | ||
| 265 | 290 | #[tokio::test] | |
| 266 | - | async fn a_social_handles_url_is_text_because_a_row_cannot_carry_a_link() { | |
| 267 | - | // The other vocabulary gap, asserted for the same reason. See `link_row`. | |
| 291 | + | async fn a_social_handles_url_is_a_real_link_that_leaves_the_app() { | |
| 292 | + | // Was `a_social_handles_url_is_text_because_a_row_cannot_carry_a_link`. | |
| 293 | + | // Worth noting how that test would have survived this change: it asserted | |
| 294 | + | // `!html.contains("<a href=\"...")`, and the renderer emits | |
| 295 | + | // `<a class="act" href=...`, so it passed for the wrong reason. Asserting | |
| 296 | + | // the absence of a literal string is only as good as the string. | |
| 268 | 297 | let state = state().await; | |
| 269 | 298 | let contact = add(&state, "Ada"); | |
| 270 | 299 | state | |
| @@ -286,9 +315,15 @@ | |||
| 286 | 315 | Params::new(), | |
| 287 | 316 | )); | |
| 288 | 317 | assert!(html.contains("Mastodon: @ada")); | |
| 289 | - | assert!(html.contains("https://example.com/@ada")); | |
| 290 | - | // Something to copy, not something to follow. | |
| 291 | - | assert!(!html.contains("<a href=\"https://example.com/@ada\"")); | |
| 318 | + | ||
| 319 | + | // An anchor, not a button, and not htmx's business: nothing swaps and no | |
| 320 | + | // route is called. | |
| 321 | + | assert!(html.contains("href=\"https://example.com/@ada\"")); | |
| 322 | + | assert!(html.contains("rel=\"noopener noreferrer\"")); | |
| 323 | + | assert!(!html.contains("hx-get=\"https://example.com/@ada\"")); | |
| 324 | + | ||
| 325 | + | // The removal beside it is still a route, so both kinds coexist in one row. | |
| 326 | + | assert!(html.contains(&format!("hx-post=\"/contacts/{}/social/", contact.id))); | |
| 292 | 327 | } | |
| 293 | 328 | ||
| 294 | 329 | #[tokio::test] |
| @@ -144,10 +144,12 @@ | |||
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | #[tokio::test] | |
| 147 | - | async fn a_row_carries_both_badges_as_one_trailing_fact() { | |
| 148 | - | // The vocabulary gap, asserted so the workaround is visible rather than | |
| 149 | - | // silently correct: two badges, one `meta`, and the status tone gone. See | |
| 150 | - | // `row_for`'s note. | |
| 147 | + | async fn a_row_carries_both_badges_as_tokens_and_keeps_the_status_tone() { | |
| 148 | + | // Was `a_row_carries_both_badges_as_one_trailing_fact`, which asserted the | |
| 149 | + | // workaround: "Side Project · On Hold" joined into `meta`, and | |
| 150 | + | // `!html.contains("tone-warning")` pinning down the loss. makeover-layout | |
| 151 | + | // 0.9.0's `RowPart::Tokens` is what this screen's finding asked for, and | |
| 152 | + | // this is the assertion inverted. | |
| 151 | 153 | let state = state().await; | |
| 152 | 154 | add(&state, "Mine", ProjectStatus::OnHold); | |
| 153 | 155 | ||
| @@ -156,8 +158,32 @@ | |||
| 156 | 158 | }; | |
| 157 | 159 | let html = quasi_webview::Webview::new().fragment(&node); | |
| 158 | 160 | ||
| 159 | - | assert!(html.contains("Side Project · On Hold")); | |
| 160 | - | assert!(!html.contains("tone-warning")); | |
| 161 | + | assert!(html.contains("class=\"row-tokens\"")); | |
| 162 | + | assert!(html.contains("Side Project")); | |
| 163 | + | assert!(html.contains("On Hold")); | |
| 164 | + | // The thing the join could not keep. `utils.js:statusTone` maps OnHold to | |
| 165 | + | // warning, and now so does the description. | |
| 166 | + | assert!(html.contains("tone-warning")); | |
| 167 | + | assert!(!html.contains("Side Project · On Hold")); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[tokio::test] | |
| 171 | + | async fn only_the_status_badge_is_toned() { | |
| 172 | + | // A type is not news and takes no tone, which is what keeps a row from | |
| 173 | + | // being a row of colours. Archived is the same case on the status side: | |
| 174 | + | // `statusTone` returns nothing for it deliberately. | |
| 175 | + | let state = state().await; | |
| 176 | + | add(&state, "Done", ProjectStatus::Archived); | |
| 177 | + | ||
| 178 | + | let Response::Fragment { node, .. } = | |
| 179 | + | answer(&state, "/projects/list", Params::new().with("retired", "1")) | |
| 180 | + | else { | |
| 181 | + | panic!("a fragment"); | |
| 182 | + | }; | |
| 183 | + | let html = quasi_webview::Webview::new().fragment(&node); | |
| 184 | + | ||
| 185 | + | assert!(html.contains("Archived")); | |
| 186 | + | assert!(!html.contains("tone-")); | |
| 161 | 187 | } | |
| 162 | 188 | ||
| 163 | 189 | #[tokio::test] |