Skip to main content

max / goingson

Describe the contacts screen through quasi The second screen ported, and the fattest cluster of esc() call sites in the frontend: contacts-render.js, contact-dashboard.js and contacts.js hold 57 of the 328 between them. The shipped screen is still those three files, behind the same off-by-default feature. One module per screen now. There is no Router::merge, so composition is a chain of routes() functions rather than a table kept in step with the modules by hand. projects moves out of mod.rs unchanged apart from that. Contacts is the first screen with sub-collections, and the first to describe a row that acts on itself rather than only selecting it. The removals are real routes rather than dangling actions, so the Act on a row is proved to reach a handler. Three more things the description could not say, recorded next to the code that ran into them: a card carries five facts where a row holds three, a row cannot be ticked without being activated (Row::selected means the app's selection, not the user's), and a row cannot carry a link, so a social handle's URL is text to copy rather than something to follow. Also corrects the Cargo.toml note on why the quasi deps point at astra. quasi is on makenot.work now and private there; goingson is public, and cargo resolves an optional dependency's source with the feature off.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 01:16 UTC
Signed with PGP, not checked
Commit: d786b98cd879c371b84e6845938a5e9a7fe4f351
Parent: 6867fd7
6 files changed, +1187 insertions, -279 deletions
@@ -36,8 +36,10 @@
36 36
37 37 # The quasi stack, behind the `quasi` feature. Git URLs redirected to the
38 38 # working copy by ~/Code/.cargo/config.toml, the way every cross-repo
39 - # dependency here is. astra is quasi's origin rather than a mirror, since it
40 - # has never been pushed to makenot.work.
39 + # dependency here is. These point at astra rather than makenot.work because
40 + # quasi is private there and goingson is public: cargo resolves an optional
41 + # dependency's source even with the feature off, so a public clone cannot
42 + # build against the makenot.work URL until quasi goes public.
41 43 quasi-router = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
42 44 quasi-http = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
43 45 quasi-webview = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
@@ -1,298 +1,51 @@
1 - //! The projects screen, described rather than built.
1 + //! The screens that are described rather than built.
2 2 //!
3 3 //! <!-- wiki: quasi-overview -->
4 4 //!
5 - //! The proving ground for [`quasi_webview`], chosen 2026-08-08 over testing the
6 - //! renderer against only an app written to make it pass. Behind the `quasi`
7 - //! feature, which is off: nothing in a default build reaches this module, and
8 - //! the shipped app is `frontend/js/projects.js` exactly as before.
5 + //! Behind the `quasi` feature, which is off: nothing in a default build reaches
6 + //! this module, and the shipped screens are the ones under `frontend/js/`
7 + //! exactly as before.
9 8 //!
10 9 //! # What it is for
11 10 //!
12 - //! Not to replace the screen. To find out what a real screen needs that the
13 - //! description layer cannot say, while that is still cheap to fix. Two things
14 - //! turned up immediately and are recorded in [`row_for`], because a finding
15 - //! that lives only in a commit message is a finding nobody acts on.
11 + //! Not to replace the screens one at a time for its own sake. `escape.js` and
12 + //! its `esc()` call sites exist because screens are built by concatenating
13 + //! strings in JavaScript; they retire as screens move here and escaping becomes
14 + //! typed in Rust at the renderer. The escapers delete last, when the count
15 + //! reaches zero, because deleting them first would remove the CHRONIC-XSS seal
16 + //! with nothing in its place.
17 + //!
18 + //! Each screen ported also asks the same question the first one did: what does
19 + //! a real screen need that the description layer cannot say? Those findings live
20 + //! next to the code that ran into them, because a finding that lives only in a
21 + //! commit message is a finding nobody acts on. Each screen module's `row_for`
22 + //! carries its own.
16 23 //!
17 24 //! # The shape
18 25 //!
19 - //! Three routes, which is the whole screen:
20 - //!
21 - //! - `GET /projects` — the document.
22 - //! - `GET /projects/list` — the grid alone, which is what the two filters swap.
23 - //! - `GET /projects/{id}` — the detail pane.
24 - //!
25 - //! The filters are routes rather than local state, per decision 2. `projects.js`
26 - //! holds `showSharedOnly` and `showRetired` in module scope and re-renders from a
27 - //! cached list; here they are query params, so the same screen is reachable by
28 - //! address and no state has to survive between two clicks.
29 -
30 - // Handlers take their params by value because `quasi_router::Handler` is a
31 - // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
32 - // choice made here. Same allow, for the same reason, as quasi-axum's tests.
33 - #![allow(clippy::needless_pass_by_value)]
26 + //! One module per screen, each contributing its own routes. There is no
27 + //! `Router::merge`, so composition is a chain of functions that each take the
28 + //! router and give it back, rather than a table assembled somewhere central
29 + //! that has to be kept in step with the modules.
34 30
35 31 use std::sync::Arc;
36 32
37 - use goingson_core::{Project, ProjectStatus, ProjectType};
38 - use quasi_router::screen::{Act, Row};
39 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
33 + use quasi_router::Router;
40 34
41 - use crate::state::{AppState, DESKTOP_USER_ID};
35 + use crate::state::AppState;
42 36
43 - #[cfg(test)]
44 - mod tests;
37 + pub mod contacts;
38 + pub mod projects;
45 39
46 - /// Whether a project has stopped being worked on.
47 - ///
48 - /// `projects.js:isRetired` reads the same two statuses. Duplicated rather than
49 - /// shared because the JS is what ships; when this module replaces it, this is
50 - /// the copy that survives.
51 - fn retired(project: &Project) -> bool {
52 - matches!(
53 - project.status,
54 - ProjectStatus::Completed | ProjectStatus::Archived
55 - )
56 - }
57 -
58 - /// The display name of a project type.
59 - fn type_label(project_type: &ProjectType) -> &'static str {
60 - match project_type {
61 - ProjectType::SideProject => "Side Project",
62 - ProjectType::Job => "Job",
63 - ProjectType::Company => "Company",
64 - ProjectType::Essay => "Essay",
65 - ProjectType::Article => "Article",
66 - ProjectType::Painting => "Painting",
67 - ProjectType::Other => "Other",
68 - }
69 - }
70 -
71 - /// The display name of a project status.
72 - fn status_label(status: &ProjectStatus) -> &'static str {
73 - match status {
74 - ProjectStatus::Active => "Active",
75 - ProjectStatus::OnHold => "On Hold",
76 - ProjectStatus::Completed => "Completed",
77 - ProjectStatus::Archived => "Archived",
78 - }
79 - }
80 -
81 - /// One project as a row.
82 - ///
83 - /// # The two things the description cannot say
84 - ///
85 - /// Both found here, on the first real screen, which is what the proving ground
86 - /// was for.
87 - ///
88 - /// **A row carries one trailing fact and this card has two.**
89 - /// `makeover_layout::RowPart` is `Primary | Secondary | Meta | Actions`, taken
90 - /// from Balanced Breakfast as the consumer that had all four. A project card
91 - /// carries a type badge *and* a status badge, and the status badge is toned:
92 - /// `projects.js` runs `statusTone(status)` and colours it. Joined into `meta`
93 - /// here, which keeps both facts and loses the tone — a status reads as text
94 - /// rather than as green or amber. `Node::Token` exists and says exactly the
95 - /// right thing, but only as a node in its own right, never inside a row.
96 - /// Naming it in `makeover-layout` first is what the admission test requires,
97 - /// so this is a finding rather than a patch.
98 - ///
99 - /// **A description carries text and this card carries markdown.**
100 - /// `ProjectResponse::description_html` is `docengine::render_standard`, and the
101 - /// card renders it as HTML. Nothing in the vocabulary names rich text, and it
102 - /// should not be smuggled in as a string the renderer trusts — that is the one
103 - /// door through which a description becomes a templating language. The raw
104 - /// description goes into `secondary` as text. A `Region::Bespoke` is the
105 - /// vocabulary's own answer for a place the app fills itself, and it is the
106 - /// shape this wants if it turns out to matter.
107 - fn row_for(project: &Project, selected: bool) -> Row {
108 - let mut row = Row::new(&project.name).meta(format!(
109 - "{} · {}",
110 - type_label(&project.project_type),
111 - status_label(&project.status)
112 - ));
113 -
114 - if !project.description.is_empty() {
115 - // Text, not the rendered HTML. See the note above.
116 - row = row.secondary(&project.description);
117 - }
118 -
119 - row.selected = selected;
120 - row.activate = Some(Action::get(format!("/projects/{}", project.id)));
121 - row
122 - }
123 -
124 - /// The grid, filtered the way the screen's two toggles filter it.
125 - fn grid(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Node, RouteError> {
126 - let all = state
127 - .projects
128 - .list_all(DESKTOP_USER_ID)
129 - .map_err(|error| RouteError::internal(error.to_string()))?;
130 -
131 - if all.is_empty() {
132 - return Ok(Node::text("No projects yet."));
133 - }
134 -
135 - let scoped: Vec<&Project> = all
136 - .iter()
137 - .filter(|project| !shared_only || project.group_id.is_some())
138 - .collect();
139 -
140 - if scoped.is_empty() {
141 - return Ok(Node::text(
142 - "No shared projects yet. Share a project from its menu to see it here.",
143 - ));
144 - }
145 -
146 - let (live, dormant): (Vec<&Project>, Vec<&Project>) =
147 - scoped.into_iter().partition(|project| !retired(project));
148 -
149 - if live.is_empty() && !show_retired {
150 - return Ok(Node::text("Every project is completed or archived."));
151 - }
152 -
153 - let shown = if show_retired {
154 - live.into_iter().chain(dormant).collect::<Vec<_>>()
155 - } else {
156 - live
157 - };
158 -
159 - Ok(Node::list(
160 - shown.into_iter().map(|project| row_for(project, false)),
161 - ))
162 - }
163 -
164 - /// How many projects are shared into a group, and how many are retired.
165 - ///
166 - /// Both counts drive whether a control appears at all, so they are read once
167 - /// per screen rather than per control.
168 - fn counts(state: &AppState) -> Result<(usize, usize), RouteError> {
169 - let all = state
170 - .projects
171 - .list_all(DESKTOP_USER_ID)
172 - .map_err(|error| RouteError::internal(error.to_string()))?;
173 - Ok((
174 - all.iter().filter(|p| p.group_id.is_some()).count(),
175 - all.iter().filter(|p| retired(p)).count(),
176 - ))
177 - }
178 -
179 - /// Whether a param is on. Absent is off, which is what a URL without it means.
180 - fn flag(params: &quasi_router::Params, name: &str) -> bool {
181 - matches!(params.get(name), Some("1" | "true"))
182 - }
183 -
184 - /// The address of the grid under a given pair of filters.
185 - fn list_action(shared_only: bool, show_retired: bool) -> Action {
186 - let mut action = Action::get("/projects/list");
187 - if shared_only {
188 - action = action.with("shared", "1");
189 - }
190 - if show_retired {
191 - action = action.with("retired", "1");
192 - }
193 - action
194 - }
195 -
196 - /// The whole screen.
197 - fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
198 - let shared_only = flag(&params, "shared");
199 - let show_retired = flag(&params, "retired");
200 - let (shared, dormant) = counts(state)?;
201 -
202 - let mut band = Slot::new("projects-band", RegionKind::Band)
203 - .with(Node::page("Projects"))
204 - .with(Node::act("New project", Action::get("/projects/new")));
205 -
206 - // The filter surfaces only when sharing is in play, which is the rule
207 - // `projects.js` already applies to the same control.
208 - if shared > 0 || shared_only {
209 - band = band.with(Node::Token {
210 - kind: makeover_layout::Token::Chip { removable: false },
211 - label: "Shared only".into(),
212 - tone: makeover_layout::Tone::Neutral,
213 - latched: shared_only,
214 - action: Some(list_action(!shared_only, show_retired)),
215 - });
216 - }
217 -
218 - if dormant > 0 {
219 - band = band.with(Node::Act(Act::new(
220 - if show_retired {
221 - "Hide completed and archived".to_owned()
222 - } else {
223 - format!("Show {dormant} completed or archived")
224 - },
225 - list_action(shared_only, !show_retired),
226 - )));
227 - }
228 -
229 - Ok(Screen::list_detail("Projects", false)
230 - .with(band)
231 - .with(Slot::new("projects-grid", RegionKind::Pane).with(grid(
232 - state,
233 - shared_only,
234 - show_retired,
235 - )?))
236 - .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
237 - .into())
238 - }
239 -
240 - /// The grid alone, which is what a filter toggle replaces.
241 - fn list(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
242 - let node = grid(state, flag(&params, "shared"), flag(&params, "retired"))?;
243 - Ok(Response::fragment("projects-grid", node))
244 - }
245 -
246 - /// One project's detail pane.
247 - fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
248 - let id = params
249 - .get("id")
250 - .ok_or_else(|| RouteError::not_found("no project id"))?;
251 - // `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the
252 - // uuid crate's. Not worth adding one upstream for a single call site.
253 - let id = goingson_core::ProjectId::from(
254 - uuid::Uuid::parse_str(id).map_err(|_| RouteError::not_found("not a project id"))?,
255 - );
256 -
257 - let project = state
258 - .projects
259 - .get_by_id(id, DESKTOP_USER_ID)
260 - .map_err(|error| RouteError::internal(error.to_string()))?
261 - .ok_or_else(|| RouteError::not_found("no such project"))?;
262 -
263 - let mut slot = Slot::new("projects-detail", RegionKind::Pane)
264 - .with(Node::section(&project.name))
265 - .with(Node::text(format!(
266 - "{} · {}",
267 - type_label(&project.project_type),
268 - status_label(&project.status)
269 - )));
270 -
271 - if !project.description.is_empty() {
272 - slot = slot.with(Node::text(&project.description));
273 - }
274 -
275 - slot = slot.with(Node::Act(
276 - Act::new(
277 - "Delete project",
278 - Action::post(format!("/projects/{}/delete", project.id)),
279 - )
280 - .tone(makeover_layout::Tone::Danger),
281 - ));
282 -
283 - Ok(Response::fragment("projects-detail", Node::Region(slot)))
284 - }
285 -
286 - /// The projects screen's routes.
40 + /// Every described screen's routes.
287 41 #[must_use]
288 42 pub fn router() -> Router<AppState> {
289 - Router::<AppState>::new()
290 - .get("/projects", index)
291 - .get("/projects/list", list)
292 - .get("/projects/:id", detail)
43 + let router = Router::<AppState>::new();
44 + let router = projects::routes(router);
45 + contacts::routes(router)
293 46 }
294 47
295 - /// The custom protocol serving the screen inside the app.
48 + /// The custom protocol serving the screens inside the app.
296 49 ///
297 50 /// `quasi://localhost/projects`. The assets come from the same scheme, which is
298 51 /// the one thing that differs from the same description served over HTTP.
@@ -11,7 +11,7 @@
11 11 use quasi_http::Render as _;
12 12 use quasi_router::{Method, Params, Response};
13 13
14 - use super::{protocol, router};
14 + use super::super::{protocol, router};
15 15 use crate::state::{AppState, DESKTOP_USER_ID};
16 16
17 17 /// State with the desktop user in place, which is who the handlers read as.
@@ -1,0 +1,475 @@
1 + //! The contacts screen, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The second screen ported, chosen for being the fattest cluster of `esc()`
6 + //! call sites in the frontend: `contacts-render.js`, `contact-dashboard.js` and
7 + //! `contacts.js` hold 57 of the 328 between them (measured 2026-08-08). The
8 + //! shipped screen is still those three files; see [the module above](super).
9 + //!
10 + //! Where the projects screen was a grid of one flat record, this one has
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`].
14 + //!
15 + //! # The shape
16 + //!
17 + //! - `GET /contacts` — the document.
18 + //! - `GET /contacts/list` — the grid alone, which is what search and the tag
19 + //! filter swap.
20 + //! - `GET /contacts/{id}` — the detail pane, sub-collections included.
21 + //! - `POST /contacts/{id}/email|phone|social|field/{sub}/delete` — remove one
22 + //! entry from a sub-collection and answer with the pane again.
23 + //!
24 + //! Search and the tag filter are query params rather than module state, per
25 + //! decision 2 and for the same reason as the projects filters: `contacts.js`
26 + //! holds them in module scope and re-renders from a cached list, so the view a
27 + //! user is looking at has no address. Here it does.
28 + //!
29 + //! The removals are routes rather than dangling actions because a described
30 + //! control that calls nothing is a screen that lies about what it does. They
31 + //! are the first writes in this module, and they are what proves the `Act` on a
32 + //! row reaches a handler at all.
33 +
34 + // Handlers take their params by value because `quasi_router::Handler` is a
35 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
36 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
37 + #![allow(clippy::needless_pass_by_value)]
38 +
39 + use goingson_core::{Contact, ContactId};
40 + use quasi_router::screen::{Act, Row};
41 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
42 +
43 + use crate::state::{AppState, DESKTOP_USER_ID};
44 +
45 + #[cfg(test)]
46 + mod tests;
47 +
48 + /// The name a contact is filed under, with the company it belongs to.
49 + ///
50 + /// `contacts-render.js:renderCard` puts the company on its own line under the
51 + /// name and the title only in the detail modal. Joined here because a row has
52 + /// one `secondary`, and dropping the company would lose the fact the card is
53 + /// actually scanned by.
54 + fn affiliation(contact: &Contact) -> Option<String> {
55 + match (contact.company.as_deref(), contact.title.as_deref()) {
56 + (Some(company), Some(title)) => Some(format!("{title}, {company}")),
57 + (Some(company), None) => Some(company.to_owned()),
58 + (None, Some(title)) => Some(title.to_owned()),
59 + (None, None) => None,
60 + }
61 + }
62 +
63 + /// One contact as a row.
64 + ///
65 + /// # What the description could not say
66 + ///
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.
76 + ///
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.
83 + ///
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 {
90 + let name = match contact.nickname.as_deref() {
91 + Some(nickname) if !nickname.is_empty() => {
92 + format!("{} \"{}\"", contact.display_name, nickname)
93 + }
94 + _ => contact.display_name.clone(),
95 + };
96 +
97 + let mut row = Row::new(name);
98 +
99 + if let Some(affiliation) = affiliation(contact) {
100 + row = row.secondary(affiliation);
101 + }
102 +
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 + 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(" · "));
114 + }
115 +
116 + row.selected = selected;
117 + row.activate = Some(Action::get(format!("/contacts/{}", contact.id)));
118 + row
119 + }
120 +
121 + /// The grid, filtered the way the screen's search box and tag filter filter it.
122 + ///
123 + /// Implicit contacts stay out, which is `list_filtered`'s own rule and the same
124 + /// one the contact list applies: it is a curated surface, and a contact that
125 + /// exists only because it was once emailed has not been curated into it.
126 + fn grid(state: &AppState, search: Option<&str>, tag: Option<&str>) -> Result<Node, RouteError> {
127 + let contacts = state
128 + .contacts
129 + .list_filtered(DESKTOP_USER_ID, search, tag, false)
130 + .map_err(|error| RouteError::internal(error.to_string()))?;
131 +
132 + if contacts.is_empty() {
133 + return Ok(Node::text(match (search, tag) {
134 + (Some(_), _) => "No contacts match that search.",
135 + (None, Some(_)) => "No contacts carry that tag.",
136 + (None, None) => "No contacts yet.",
137 + }));
138 + }
139 +
140 + Ok(Node::list(
141 + contacts.iter().map(|contact| row_for(contact, false)),
142 + ))
143 + }
144 +
145 + /// Every tag in use, in a stable order, so the filter is a list and not a guess.
146 + fn tags_in_use(state: &AppState) -> Result<Vec<String>, RouteError> {
147 + let contacts = state
148 + .contacts
149 + .list_all(DESKTOP_USER_ID)
150 + .map_err(|error| RouteError::internal(error.to_string()))?;
151 +
152 + let mut tags: Vec<String> = contacts
153 + .into_iter()
154 + .flat_map(|contact| contact.tags)
155 + .collect();
156 + tags.sort_unstable();
157 + tags.dedup();
158 + Ok(tags)
159 + }
160 +
161 + /// A param that is present and not blank. Blank is absent, which is what an
162 + /// emptied search box means.
163 + fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
164 + params.get(name).map(str::trim).filter(|v| !v.is_empty())
165 + }
166 +
167 + /// The address of the grid under a given search and tag.
168 + fn list_action(search: Option<&str>, tag: Option<&str>) -> Action {
169 + let mut action = Action::get("/contacts/list");
170 + if let Some(search) = search {
171 + action = action.with("q", search);
172 + }
173 + if let Some(tag) = tag {
174 + action = action.with("tag", tag);
175 + }
176 + action
177 + }
178 +
179 + /// Parse a path param into a typed id, or answer 404.
180 + ///
181 + /// The ids have no `FromStr`, only `From<Uuid>`, so the parse is the uuid
182 + /// crate's. Same reasoning as the projects screen: not worth adding one upstream
183 + /// for a handful of call sites.
184 + fn id_param<T: From<uuid::Uuid>>(
185 + params: &quasi_router::Params,
186 + name: &str,
187 + ) -> Result<T, RouteError> {
188 + let raw = params
189 + .get(name)
190 + .ok_or_else(|| RouteError::not_found("no id"))?;
191 + let uuid = uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an id"))?;
192 + Ok(T::from(uuid))
193 + }
194 +
195 + /// The whole screen.
196 + fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
197 + let search = text(&params, "q");
198 + let tag = text(&params, "tag");
199 +
200 + let mut band = Slot::new("contacts-band", RegionKind::Band)
201 + .with(Node::page("Contacts"))
202 + .with(Node::act("New contact", Action::get("/contacts/new")));
203 +
204 + // The tag filter surfaces only when there are tags to filter by, which is
205 + // the rule `contacts.js` already applies to the same control. A tag that is
206 + // filtered on stays offered even if it is the only one left, so the way back
207 + // is always on screen.
208 + for in_use in tags_in_use(state)? {
209 + 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 + }
218 +
219 + Ok(Screen::list_detail("Contacts", false)
220 + .with(band)
221 + .with(Slot::new("contacts-grid", RegionKind::Pane).with(grid(state, search, tag)?))
222 + .with(Slot::new("contacts-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
223 + .into())
224 + }
225 +
226 + /// The grid alone, which is what search and the tag filter replace.
227 + fn list(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
228 + let node = grid(state, text(&params, "q"), text(&params, "tag"))?;
229 + Ok(Response::fragment("contacts-grid", node))
230 + }
231 +
232 + /// One entry in a sub-collection, with the control that removes it.
233 + ///
234 + /// # What the description could not say
235 + ///
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 {
245 + let mut row = Row::new(primary);
246 +
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(" · "));
254 + }
255 +
256 + row.actions
257 + .push(Act::new("Remove", remove).tone(makeover_layout::Tone::Danger));
258 + row
259 + }
260 +
261 + /// A sub-collection as a titled list, or a line saying it is empty.
262 + fn sub_collection(title: &str, empty: &str, rows: Vec<Row>) -> Vec<Node> {
263 + let body = if rows.is_empty() {
264 + Node::text(empty)
265 + } else {
266 + Node::list(rows)
267 + };
268 + vec![Node::section(title), body]
269 + }
270 +
271 + /// The detail pane for one contact, which is also what a removal answers with.
272 + fn detail_pane(contact: &Contact) -> Node {
273 + let id = contact.id;
274 + let mut slot =
275 + Slot::new("contacts-detail", RegionKind::Pane).with(Node::section(&contact.display_name));
276 +
277 + if let Some(affiliation) = affiliation(contact) {
278 + slot = slot.with(Node::text(affiliation));
279 + }
280 +
281 + // The facts the modal lists one per row. Rendered only when present, which
282 + // is what `showDetailModal` does with the same five.
283 + let mut facts: Vec<String> = Vec::new();
284 + if let Some(nickname) = contact.nickname.as_deref().filter(|n| !n.is_empty()) {
285 + facts.push(format!("Nickname: {nickname}"));
286 + }
287 + if let Some(birthday) = contact.birthday {
288 + facts.push(format!("Birthday: {birthday}"));
289 + }
290 + if let Some(timezone) = contact.timezone.as_deref().filter(|t| !t.is_empty()) {
291 + facts.push(format!("Timezone: {timezone}"));
292 + }
293 + if !contact.tags.is_empty() {
294 + facts.push(format!("Tags: {}", contact.tags.join(", ")));
295 + }
296 + for fact in facts {
297 + slot = slot.with(Node::text(fact));
298 + }
299 +
300 + if !contact.notes.is_empty() {
301 + slot = slot.with(Node::section("Notes"));
302 + slot = slot.with(Node::text(&contact.notes));
303 + }
304 +
305 + let emails = contact
306 + .emails
307 + .iter()
308 + .map(|email| {
309 + let label = (!email.label.is_empty()).then_some(email.label.as_str());
310 + let primary = email.is_primary.then_some("Primary");
311 + let meta = [label, primary]
312 + .into_iter()
313 + .flatten()
314 + .collect::<Vec<_>>()
315 + .join(" · ");
316 + let mut row = Row::new(&email.address);
317 + if !meta.is_empty() {
318 + row = row.meta(meta);
319 + }
320 + row.actions.push(
321 + Act::new(
322 + "Remove",
323 + Action::post(format!("/contacts/{id}/email/{}/delete", email.id)),
324 + )
325 + .tone(makeover_layout::Tone::Danger),
326 + );
327 + row
328 + })
329 + .collect();
330 +
331 + let phones = contact
332 + .phones
333 + .iter()
334 + .map(|phone| {
335 + let label = (!phone.label.is_empty()).then_some(phone.label.as_str());
336 + let primary = phone.is_primary.then_some("Primary");
337 + let meta = [label, primary]
338 + .into_iter()
339 + .flatten()
340 + .collect::<Vec<_>>()
341 + .join(" · ");
342 + let mut row = Row::new(&phone.number);
343 + if !meta.is_empty() {
344 + row = row.meta(meta);
345 + }
346 + row.actions.push(
347 + Act::new(
348 + "Remove",
349 + Action::post(format!("/contacts/{id}/phone/{}/delete", phone.id)),
350 + )
351 + .tone(makeover_layout::Tone::Danger),
352 + );
353 + row
354 + })
355 + .collect();
356 +
357 + let socials = contact
358 + .social_handles
359 + .iter()
360 + .map(|handle| {
361 + link_row(
362 + format!("{}: {}", handle.platform, handle.handle),
363 + None,
364 + handle.url.as_deref(),
365 + Action::post(format!("/contacts/{id}/social/{}/delete", handle.id)),
366 + )
367 + })
368 + .collect();
369 +
370 + let fields = contact
371 + .custom_fields
372 + .iter()
373 + .map(|field| {
374 + link_row(
375 + format!("{}: {}", field.label, field.value),
376 + None,
377 + field.url.as_deref(),
378 + Action::post(format!("/contacts/{id}/field/{}/delete", field.id)),
379 + )
380 + })
381 + .collect();
382 +
383 + for node in sub_collection("Email Addresses", "No email addresses", emails) {
384 + slot = slot.with(node);
385 + }
386 + for node in sub_collection("Phone Numbers", "No phone numbers", phones) {
387 + slot = slot.with(node);
388 + }
389 + for node in sub_collection("Social Handles", "No social handles", socials) {
390 + slot = slot.with(node);
391 + }
392 + for node in sub_collection("Custom Fields", "No custom fields", fields) {
393 + slot = slot.with(node);
394 + }
395 +
396 + Node::Region(slot)
397 + }
398 +
399 + /// Read one contact, or answer 404.
400 + fn load(state: &AppState, id: ContactId) -> Result<Contact, RouteError> {
401 + state
402 + .contacts
403 + .get_by_id(id, DESKTOP_USER_ID)
404 + .map_err(|error| RouteError::internal(error.to_string()))?
405 + .ok_or_else(|| RouteError::not_found("no such contact"))
406 + }
407 +
408 + /// One contact's detail pane.
409 + fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
410 + let contact = load(state, id_param(&params, "id")?)?;
411 + Ok(Response::fragment("contacts-detail", detail_pane(&contact)))
412 + }
413 +
414 + /// Answer a removal with the pane it happened in, re-read.
415 + ///
416 + /// Re-read rather than patched in memory: the removal is the database's to
417 + /// confirm, and a pane rebuilt from what the handler hoped happened is how a
418 + /// screen ends up disagreeing with its own storage.
419 + fn removed(state: &AppState, id: ContactId) -> Result<Response, RouteError> {
420 + let contact = load(state, id)?;
421 + Ok(Response::fragment("contacts-detail", detail_pane(&contact)))
422 + }
423 +
424 + /// Remove one email address.
425 + fn remove_email(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
426 + let contact: ContactId = id_param(&params, "id")?;
427 + state
428 + .contacts
429 + .remove_email(id_param(&params, "sub")?, DESKTOP_USER_ID)
430 + .map_err(|error| RouteError::internal(error.to_string()))?;
431 + removed(state, contact)
432 + }
433 +
434 + /// Remove one phone number.
435 + fn remove_phone(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
436 + let contact: ContactId = id_param(&params, "id")?;
437 + state
438 + .contacts
439 + .remove_phone(id_param(&params, "sub")?, DESKTOP_USER_ID)
440 + .map_err(|error| RouteError::internal(error.to_string()))?;
441 + removed(state, contact)
442 + }
443 +
444 + /// Remove one social handle.
445 + fn remove_social(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
446 + let contact: ContactId = id_param(&params, "id")?;
447 + state
448 + .contacts
449 + .remove_social_handle(id_param(&params, "sub")?, DESKTOP_USER_ID)
450 + .map_err(|error| RouteError::internal(error.to_string()))?;
451 + removed(state, contact)
452 + }
453 +
454 + /// Remove one custom field.
455 + fn remove_field(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
456 + let contact: ContactId = id_param(&params, "id")?;
457 + state
458 + .contacts
459 + .remove_custom_field(id_param(&params, "sub")?, DESKTOP_USER_ID)
460 + .map_err(|error| RouteError::internal(error.to_string()))?;
461 + removed(state, contact)
462 + }
463 +
464 + /// The contacts screen's routes.
465 + #[must_use]
466 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
467 + router
468 + .get("/contacts", index)
469 + .get("/contacts/list", list)
470 + .get("/contacts/:id", detail)
471 + .post("/contacts/:id/email/:sub/delete", remove_email)
472 + .post("/contacts/:id/phone/:sub/delete", remove_phone)
473 + .post("/contacts/:id/social/:sub/delete", remove_social)
474 + .post("/contacts/:id/field/:sub/delete", remove_field)
475 + }
@@ -1,0 +1,391 @@
1 + //! The contacts screen, driven through the router against a real database.
2 + //!
3 + //! Same property as the projects tests: no Tauri runtime and no window, because
4 + //! a route is a function from state and params to a description. What is
5 + //! asserted is the description, and the markup only where the markup is the
6 + //! point.
7 +
8 + use std::sync::Arc;
9 +
10 + use goingson_core::{NewContact, NewContactEmail, NewSocialHandle};
11 + use quasi_http::Render as _;
12 + use quasi_router::{Method, Params, Response};
13 +
14 + use super::super::router;
15 + use crate::state::{AppState, DESKTOP_USER_ID};
16 +
17 + /// State with the desktop user in place, which is who the handlers read as.
18 + async fn state() -> Arc<AppState> {
19 + let (state, _) = crate::test_utils::setup_test_state().await;
20 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 + state
22 + .db
23 + .conn()
24 + .unwrap()
25 + .execute(
26 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 + VALUES (?, ?, ?, ?, ?)",
28 + rusqlite::params![
29 + DESKTOP_USER_ID.to_string(),
30 + "desktop@localhost",
31 + "x",
32 + "Desktop User",
33 + &now,
34 + ],
35 + )
36 + .unwrap();
37 + state
38 + }
39 +
40 + /// A contact carrying nothing but its name.
41 + ///
42 + /// `NewContact` has no `Default`, and giving it one here would be adding a trait
43 + /// to the core crate for the convenience of one test module.
44 + fn blank(name: &str) -> NewContact {
45 + NewContact {
46 + display_name: name.to_owned(),
47 + nickname: None,
48 + company: None,
49 + title: None,
50 + notes: String::new(),
51 + tags: Vec::new(),
52 + birthday: None,
53 + timezone: None,
54 + is_implicit: false,
55 + }
56 + }
57 +
58 + fn add(state: &AppState, name: &str) -> goingson_core::Contact {
59 + state.contacts.create(DESKTOP_USER_ID, blank(name)).unwrap()
60 + }
61 +
62 + fn get(state: &AppState, path: &str, params: Params) -> Response {
63 + router()
64 + .handle(state, Method::Get, path, params)
65 + .expect("the route answers")
66 + }
67 +
68 + fn post(state: &AppState, path: &str) -> Response {
69 + router()
70 + .handle(state, Method::Post, path, Params::new())
71 + .expect("the route answers")
72 + }
73 +
74 + fn screen_html(response: Response) -> String {
75 + let Response::Screen(screen) = response else {
76 + panic!("the route answers with a screen");
77 + };
78 + quasi_webview::Webview::new().screen(&screen)
79 + }
80 +
81 + fn fragment_html(response: Response) -> String {
82 + let Response::Fragment { node, .. } = response else {
83 + panic!("the route answers with a fragment");
84 + };
85 + quasi_webview::Webview::new().fragment(&node)
86 + }
87 +
88 + #[tokio::test]
89 + async fn an_empty_database_says_so_rather_than_rendering_nothing() {
90 + let state = state().await;
91 + let html = screen_html(get(&state, "/contacts", Params::new()));
92 + assert!(html.contains("No contacts yet."));
93 + }
94 +
95 + #[tokio::test]
96 + async fn an_empty_result_says_which_filter_emptied_it() {
97 + // Three different sentences, because "nothing here" after a search means
98 + // something different from "nothing here" on a fresh install, and the JS
99 + // screen already distinguishes them.
100 + let state = state().await;
101 + add(&state, "Ada");
102 +
103 + let searched = screen_html(get(&state, "/contacts", Params::new().with("q", "zzz")));
104 + assert!(searched.contains("No contacts match that search."));
105 +
106 + let tagged = screen_html(get(&state, "/contacts", Params::new().with("tag", "zzz")));
107 + assert!(tagged.contains("No contacts carry that tag."));
108 + }
109 +
110 + #[tokio::test]
111 + async fn a_blank_search_is_the_same_as_no_search() {
112 + // An emptied search box sends the param with nothing in it. Treating that as
113 + // a search for the empty string is how a screen goes blank when a user
114 + // deletes what they typed.
115 + let state = state().await;
116 + add(&state, "Ada");
117 +
118 + let html = screen_html(get(&state, "/contacts", Params::new().with("q", " ")));
119 + assert!(html.contains("Ada"));
120 + assert!(!html.contains("No contacts match"));
121 + }
122 +
123 + #[tokio::test]
124 + async fn search_is_an_address_not_a_piece_of_module_state() {
125 + let state = state().await;
126 + add(&state, "Ada Lovelace");
127 + add(&state, "Grace Hopper");
128 +
129 + let html = fragment_html(get(
130 + &state,
131 + "/contacts/list",
132 + Params::new().with("q", "Ada"),
133 + ));
134 + assert!(html.contains("Ada Lovelace"));
135 + assert!(!html.contains("Grace Hopper"));
136 + }
137 +
138 + #[tokio::test]
139 + async fn a_filter_swaps_the_grid_alone() {
140 + let state = state().await;
141 + add(&state, "Ada");
142 +
143 + let response = get(&state, "/contacts/list", Params::new());
144 + // Decision 7: the response names the region, so the whole document is not
145 + // reflowed to change one pane.
146 + assert_eq!(response.target(), Some("contacts-grid"));
147 +
148 + let html = fragment_html(response);
149 + assert!(html.starts_with("<ul"));
150 + assert!(!html.contains("<html"));
151 + assert!(html.contains("Ada"));
152 + }
153 +
154 + #[tokio::test]
155 + async fn the_tag_filter_offers_only_tags_that_are_in_use() {
156 + let state = state().await;
157 + let contact = add(&state, "Ada");
158 + state
159 + .contacts
160 + .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
161 + .unwrap();
162 +
163 + let html = screen_html(get(&state, "/contacts", Params::new()));
164 + assert!(html.contains("friend"));
165 +
166 + // And a tag nobody carries is not offered, which is the rule that keeps the
167 + // band from growing a control per tag ever used.
168 + assert!(!html.contains("colleague"));
169 + }
170 +
171 + #[tokio::test]
172 + async fn a_latched_tag_offers_the_way_back_out_of_itself() {
173 + let state = state().await;
174 + let contact = add(&state, "Ada");
175 + state
176 + .contacts
177 + .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
178 + .unwrap();
179 +
180 + let html = screen_html(get(
181 + &state,
182 + "/contacts",
183 + Params::new().with("tag", "friend"),
184 + ));
185 + // Still on screen while it is the active filter, and its action drops the
186 + // tag rather than re-applying it.
187 + assert!(html.contains("friend"));
188 + assert!(html.contains("hx-get=\"/contacts/list\""));
189 + }
190 +
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.
196 + let state = state().await;
197 + let contact = add(&state, "Ada");
198 + state
199 + .contacts
200 + .add_email(
201 + contact.id,
202 + DESKTOP_USER_ID,
203 + NewContactEmail {
204 + address: "ada@example.com".to_owned(),
205 + label: "work".to_owned(),
206 + is_primary: true,
207 + },
208 + )
209 + .unwrap();
210 + state
211 + .contacts
212 + .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
213 + .unwrap();
214 +
215 + let html = fragment_html(get(&state, "/contacts/list", Params::new()));
216 + assert!(html.contains("ada@example.com · friend"));
217 + }
218 +
219 + #[tokio::test]
220 + async fn a_nickname_joins_the_name_because_a_row_has_nowhere_else_to_put_it() {
221 + let state = state().await;
222 + state
223 + .contacts
224 + .create(
225 + DESKTOP_USER_ID,
226 + NewContact {
227 + nickname: Some("Countess".to_owned()),
228 + ..blank("Ada Lovelace")
229 + },
230 + )
231 + .unwrap();
232 +
233 + let html = fragment_html(get(&state, "/contacts/list", Params::new()));
234 + assert!(html.contains("Ada Lovelace &quot;Countess&quot;"));
235 + }
236 +
237 + #[tokio::test]
238 + async fn selecting_a_row_addresses_the_detail_pane() {
239 + let state = state().await;
240 + let contact = add(&state, "Ada");
241 +
242 + let html = fragment_html(get(&state, "/contacts/list", Params::new()));
243 + assert!(html.contains(&format!("hx-get=\"/contacts/{}\"", contact.id)));
244 +
245 + let response = get(&state, &format!("/contacts/{}", contact.id), Params::new());
246 + assert_eq!(response.target(), Some("contacts-detail"));
247 + }
248 +
249 + #[tokio::test]
250 + async fn an_empty_sub_collection_says_so_rather_than_rendering_a_bare_heading() {
251 + let state = state().await;
252 + let contact = add(&state, "Ada");
253 +
254 + let html = fragment_html(get(
255 + &state,
256 + &format!("/contacts/{}", contact.id),
257 + Params::new(),
258 + ));
259 + assert!(html.contains("No email addresses"));
260 + assert!(html.contains("No phone numbers"));
261 + assert!(html.contains("No social handles"));
262 + assert!(html.contains("No custom fields"));
263 + }
264 +
265 + #[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`.
268 + let state = state().await;
269 + let contact = add(&state, "Ada");
270 + state
271 + .contacts
272 + .add_social_handle(
273 + contact.id,
274 + DESKTOP_USER_ID,
275 + NewSocialHandle {
276 + platform: "Mastodon".to_owned(),
277 + handle: "@ada".to_owned(),
278 + url: Some("https://example.com/@ada".to_owned()),
279 + },
280 + )
281 + .unwrap();
282 +
283 + let html = fragment_html(get(
284 + &state,
285 + &format!("/contacts/{}", contact.id),
286 + Params::new(),
287 + ));
288 + 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\""));
292 + }
293 +
294 + #[tokio::test]
295 + async fn removing_an_email_answers_with_the_pane_it_happened_in() {
296 + let state = state().await;
297 + let contact = add(&state, "Ada");
298 + let email = state
299 + .contacts
300 + .add_email(
301 + contact.id,
302 + DESKTOP_USER_ID,
303 + NewContactEmail {
304 + address: "ada@example.com".to_owned(),
305 + label: String::new(),
306 + is_primary: true,
307 + },
308 + )
309 + .unwrap();
310 +
311 + let before = fragment_html(get(
312 + &state,
313 + &format!("/contacts/{}", contact.id),
314 + Params::new(),
315 + ));
316 + assert!(before.contains("ada@example.com"));
317 +
318 + let response = post(
319 + &state,
320 + &format!("/contacts/{}/email/{}/delete", contact.id, email.id),
321 + );
322 + assert_eq!(response.target(), Some("contacts-detail"));
323 +
324 + let after = fragment_html(response);
325 + assert!(!after.contains("ada@example.com"));
326 + assert!(after.contains("No email addresses"));
327 + }
328 +
329 + #[tokio::test]
330 + async fn a_missing_contact_is_a_not_found_rather_than_a_panic() {
331 + let state = state().await;
332 + let error = router()
333 + .handle(
334 + &state,
335 + Method::Get,
336 + &format!("/contacts/{}", uuid::Uuid::nil()),
337 + Params::new(),
338 + )
339 + .expect_err("no such contact");
340 + assert_eq!(error.class.http_status(), 404);
341 + }
342 +
343 + #[tokio::test]
344 + async fn an_id_that_is_not_a_uuid_is_a_not_found_rather_than_a_panic() {
345 + let state = state().await;
346 + let error = router()
347 + .handle(&state, Method::Get, "/contacts/nonsense", Params::new())
348 + .expect_err("not an id");
349 + assert_eq!(error.class.http_status(), 404);
350 + }
351 +
352 + #[tokio::test]
353 + async fn a_contact_name_cannot_become_markup() {
354 + // The reason the description carries text and the renderer owns escaping.
355 + // This is the whole point of the port: 57 `esc()` calls across the three
356 + // contacts files exist to do by hand what this asserts is done by type.
357 + let state = state().await;
358 + add(&state, "<script>alert(1)</script>");
359 +
360 + let html = screen_html(get(&state, "/contacts", Params::new()));
361 + assert!(!html.contains("<script>alert"));
362 + assert!(html.contains("&lt;script&gt;"));
363 + }
364 +
365 + #[tokio::test]
366 + async fn a_social_url_cannot_become_markup_either() {
367 + // `contacts-render.js` runs the URL through `safeUrl` and then `escAttr`
368 + // before it reaches an href. Here it is text like everything else, so the
369 + // only question is whether the renderer escapes it.
370 + let state = state().await;
371 + let contact = add(&state, "Ada");
372 + state
373 + .contacts
374 + .add_social_handle(
375 + contact.id,
376 + DESKTOP_USER_ID,
377 + NewSocialHandle {
378 + platform: "X".to_owned(),
379 + handle: "@ada".to_owned(),
380 + url: Some("\"><script>alert(1)</script>".to_owned()),
381 + },
382 + )
383 + .unwrap();
384 +
385 + let html = fragment_html(get(
386 + &state,
387 + &format!("/contacts/{}", contact.id),
388 + Params::new(),
389 + ));
390 + assert!(!html.contains("<script>alert"));
391 + }
@@ -1,0 +1,287 @@
1 + //! The projects screen, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The first screen ported, chosen 2026-08-08 over testing the renderer against
6 + //! only an app written to make it pass. The shipped screen is
7 + //! `frontend/js/projects.js` exactly as before; see [the module
8 + //! above](super) for why both exist at once.
9 + //!
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`].
12 + //!
13 + //! # The shape
14 + //!
15 + //! Three routes, which is the whole screen:
16 + //!
17 + //! - `GET /projects` — the document.
18 + //! - `GET /projects/list` — the grid alone, which is what the two filters swap.
19 + //! - `GET /projects/{id}` — the detail pane.
20 + //!
21 + //! The filters are routes rather than local state, per decision 2. `projects.js`
22 + //! holds `showSharedOnly` and `showRetired` in module scope and re-renders from a
23 + //! cached list; here they are query params, so the same screen is reachable by
24 + //! address and no state has to survive between two clicks.
25 +
26 + // Handlers take their params by value because `quasi_router::Handler` is a
27 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
28 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
29 + #![allow(clippy::needless_pass_by_value)]
30 +
31 + use goingson_core::{Project, ProjectStatus, ProjectType};
32 + use quasi_router::screen::{Act, Row};
33 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
34 +
35 + use crate::state::{AppState, DESKTOP_USER_ID};
36 +
37 + #[cfg(test)]
38 + mod tests;
39 +
40 + /// Whether a project has stopped being worked on.
41 + ///
42 + /// `projects.js:isRetired` reads the same two statuses. Duplicated rather than
43 + /// shared because the JS is what ships; when this module replaces it, this is
44 + /// the copy that survives.
45 + fn retired(project: &Project) -> bool {
46 + matches!(
47 + project.status,
48 + ProjectStatus::Completed | ProjectStatus::Archived
49 + )
50 + }
51 +
52 + /// The display name of a project type.
53 + fn type_label(project_type: &ProjectType) -> &'static str {
54 + match project_type {
55 + ProjectType::SideProject => "Side Project",
56 + ProjectType::Job => "Job",
57 + ProjectType::Company => "Company",
58 + ProjectType::Essay => "Essay",
59 + ProjectType::Article => "Article",
60 + ProjectType::Painting => "Painting",
61 + ProjectType::Other => "Other",
62 + }
63 + }
64 +
65 + /// The display name of a project status.
66 + fn status_label(status: &ProjectStatus) -> &'static str {
67 + match status {
68 + ProjectStatus::Active => "Active",
69 + ProjectStatus::OnHold => "On Hold",
70 + ProjectStatus::Completed => "Completed",
71 + ProjectStatus::Archived => "Archived",
72 + }
73 + }
74 +
75 + /// One project as a row.
76 + ///
77 + /// # The two things the description cannot say
78 + ///
79 + /// Both found here, on the first real screen, which is what the proving ground
80 + /// was for.
81 + ///
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.
92 + ///
93 + /// **A description carries text and this card carries markdown.**
94 + /// `ProjectResponse::description_html` is `docengine::render_standard`, and the
95 + /// card renders it as HTML. Nothing in the vocabulary names rich text, and it
96 + /// should not be smuggled in as a string the renderer trusts — that is the one
97 + /// door through which a description becomes a templating language. The raw
98 + /// description goes into `secondary` as text. A `Region::Bespoke` is the
99 + /// 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 + ));
107 +
108 + if !project.description.is_empty() {
109 + // Text, not the rendered HTML. See the note above.
110 + row = row.secondary(&project.description);
111 + }
112 +
113 + row.selected = selected;
114 + row.activate = Some(Action::get(format!("/projects/{}", project.id)));
115 + row
116 + }
117 +
118 + /// The grid, filtered the way the screen's two toggles filter it.
119 + fn grid(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Node, RouteError> {
120 + let all = state
121 + .projects
122 + .list_all(DESKTOP_USER_ID)
123 + .map_err(|error| RouteError::internal(error.to_string()))?;
124 +
125 + if all.is_empty() {
126 + return Ok(Node::text("No projects yet."));
127 + }
128 +
129 + let scoped: Vec<&Project> = all
130 + .iter()
131 + .filter(|project| !shared_only || project.group_id.is_some())
132 + .collect();
133 +
134 + if scoped.is_empty() {
135 + return Ok(Node::text(
136 + "No shared projects yet. Share a project from its menu to see it here.",
137 + ));
138 + }
139 +
140 + let (live, dormant): (Vec<&Project>, Vec<&Project>) =
141 + scoped.into_iter().partition(|project| !retired(project));
142 +
143 + if live.is_empty() && !show_retired {
144 + return Ok(Node::text("Every project is completed or archived."));
145 + }
146 +
147 + let shown = if show_retired {
148 + live.into_iter().chain(dormant).collect::<Vec<_>>()
149 + } else {
150 + live
151 + };
152 +
153 + Ok(Node::list(
154 + shown.into_iter().map(|project| row_for(project, false)),
155 + ))
156 + }
157 +
158 + /// How many projects are shared into a group, and how many are retired.
159 + ///
160 + /// Both counts drive whether a control appears at all, so they are read once
161 + /// per screen rather than per control.
162 + fn counts(state: &AppState) -> Result<(usize, usize), RouteError> {
163 + let all = state
164 + .projects
165 + .list_all(DESKTOP_USER_ID)
166 + .map_err(|error| RouteError::internal(error.to_string()))?;
167 + Ok((
168 + all.iter().filter(|p| p.group_id.is_some()).count(),
169 + all.iter().filter(|p| retired(p)).count(),
170 + ))
171 + }
172 +
173 + /// Whether a param is on. Absent is off, which is what a URL without it means.
174 + fn flag(params: &quasi_router::Params, name: &str) -> bool {
175 + matches!(params.get(name), Some("1" | "true"))
176 + }
177 +
178 + /// The address of the grid under a given pair of filters.
179 + fn list_action(shared_only: bool, show_retired: bool) -> Action {
180 + let mut action = Action::get("/projects/list");
181 + if shared_only {
182 + action = action.with("shared", "1");
183 + }
184 + if show_retired {
185 + action = action.with("retired", "1");
186 + }
187 + action
188 + }
189 +
190 + /// The whole screen.
191 + fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
192 + let shared_only = flag(&params, "shared");
193 + let show_retired = flag(&params, "retired");
194 + let (shared, dormant) = counts(state)?;
195 +
196 + let mut band = Slot::new("projects-band", RegionKind::Band)
197 + .with(Node::page("Projects"))
198 + .with(Node::act("New project", Action::get("/projects/new")));
199 +
200 + // The filter surfaces only when sharing is in play, which is the rule
201 + // `projects.js` already applies to the same control.
202 + 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 + });
210 + }
211 +
212 + if dormant > 0 {
213 + band = band.with(Node::Act(Act::new(
214 + if show_retired {
215 + "Hide completed and archived".to_owned()
216 + } else {
217 + format!("Show {dormant} completed or archived")
218 + },
219 + list_action(shared_only, !show_retired),
220 + )));
221 + }
222 +
223 + Ok(Screen::list_detail("Projects", false)
224 + .with(band)
225 + .with(Slot::new("projects-grid", RegionKind::Pane).with(grid(
226 + state,
227 + shared_only,
228 + show_retired,
229 + )?))
230 + .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
231 + .into())
232 + }
233 +
234 + /// The grid alone, which is what a filter toggle replaces.
235 + fn list(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
236 + let node = grid(state, flag(&params, "shared"), flag(&params, "retired"))?;
237 + Ok(Response::fragment("projects-grid", node))
238 + }
239 +
240 + /// One project's detail pane.
241 + fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
242 + let id = params
243 + .get("id")
244 + .ok_or_else(|| RouteError::not_found("no project id"))?;
245 + // `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the
246 + // uuid crate's. Not worth adding one upstream for a single call site.
247 + let id = goingson_core::ProjectId::from(
248 + uuid::Uuid::parse_str(id).map_err(|_| RouteError::not_found("not a project id"))?,
249 + );
250 +
251 + let project = state
252 + .projects
253 + .get_by_id(id, DESKTOP_USER_ID)
254 + .map_err(|error| RouteError::internal(error.to_string()))?
255 + .ok_or_else(|| RouteError::not_found("no such project"))?;
256 +
257 + let mut slot = Slot::new("projects-detail", RegionKind::Pane)
258 + .with(Node::section(&project.name))
259 + .with(Node::text(format!(
260 + "{} · {}",
261 + type_label(&project.project_type),
262 + status_label(&project.status)
263 + )));
264 +
265 + if !project.description.is_empty() {
266 + slot = slot.with(Node::text(&project.description));
267 + }
268 +
269 + slot = slot.with(Node::Act(
270 + Act::new(
271 + "Delete project",
272 + Action::post(format!("/projects/{}/delete", project.id)),
273 + )
274 + .tone(makeover_layout::Tone::Danger),
275 + ));
276 +
277 + Ok(Response::fragment("projects-detail", Node::Region(slot)))
278 + }
279 +
280 + /// The projects screen's routes.
281 + #[must_use]
282 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
283 + router
284 + .get("/projects", index)
285 + .get("/projects/list", list)
286 + .get("/projects/:id", detail)
287 + }