max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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(¶ms, "shared"); | |
| 199 | - | let show_retired = flag(¶ms, "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(¶ms, "shared"), flag(¶ms, "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(¶ms, "q"); | |
| 198 | + | let tag = text(¶ms, "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(¶ms, "q"), text(¶ms, "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(¶ms, "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(¶ms, "id")?; | |
| 427 | + | state | |
| 428 | + | .contacts | |
| 429 | + | .remove_email(id_param(¶ms, "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(¶ms, "id")?; | |
| 437 | + | state | |
| 438 | + | .contacts | |
| 439 | + | .remove_phone(id_param(¶ms, "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(¶ms, "id")?; | |
| 447 | + | state | |
| 448 | + | .contacts | |
| 449 | + | .remove_social_handle(id_param(¶ms, "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(¶ms, "id")?; | |
| 457 | + | state | |
| 458 | + | .contacts | |
| 459 | + | .remove_custom_field(id_param(¶ms, "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 "Countess"")); | |
| 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("<script>")); | |
| 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(¶ms, "shared"); | |
| 193 | + | let show_retired = flag(¶ms, "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(¶ms, "shared"), flag(¶ms, "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 | + | } |