Skip to main content

max / goingson

19.7 KB · 576 lines History Blame Raw
1 //! The contacts screen, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A grid of records with sub-collections, and rows that act on themselves
6 //! rather than only selecting: a row carries tokens (`RowPart::Tokens`), a
7 //! row's tick is distinct from the app's own pointer (`Row::selected` against
8 //! `Row::current`), and an action can go somewhere outside the app
9 //! (`Destination::External`). See [`row_for`] and [`link_row`].
10 //!
11 //! # The shape
12 //!
13 //! - `GET /contacts` — the document.
14 //! - `GET /contacts/list` — the grid alone, which is what search and the tag
15 //! filter swap.
16 //! - `GET /contacts/{id}` — the detail pane, sub-collections included.
17 //! - `POST /contacts/{id}/email|phone|social|field/{sub}/delete` — remove one
18 //! entry from a sub-collection and answer with the pane again.
19 //!
20 //! Search and the tag filter are query params rather than module state, per
21 //! decision 2, so the view a user is looking at has an address.
22 //!
23 //! The removals are routes rather than dangling actions: a described control
24 //! that calls nothing is a screen that lies about what it does.
25
26 // Handlers take their request by value because `quasi_router::Handler` is a
27 // plain `fn(&S, Request)` 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::{Contact, ContactId};
32 use quasi_declare::declare;
33 use quasi_router::screen::Tag;
34 use quasi_router::{Action, Node, Response, RouteError, Router};
35
36 use crate::state::{AppState, DESKTOP_USER_ID};
37
38 #[cfg(test)]
39 mod tests;
40
41 /// The name a contact is filed under, with the company it belongs to.
42 ///
43 /// `contacts-render.js:renderCard` puts the company on its own line under the
44 /// name and the title only in the detail modal. Joined here because a row has
45 /// one `secondary`, and dropping the company would lose the fact the card is
46 /// actually scanned by.
47 fn affiliation(contact: &Contact) -> Option<String> {
48 match (contact.company.as_deref(), contact.title.as_deref()) {
49 (Some(company), Some(title)) => Some(format!("{title}, {company}")),
50 (Some(company), None) => Some(company.to_owned()),
51 (None, Some(title)) => Some(title.to_owned()),
52 (None, None) => None,
53 }
54 }
55
56 /// The name a contact is filed under, with the nickname it goes by.
57 fn filed_as(contact: &Contact) -> String {
58 match contact.nickname.as_deref() {
59 Some(nickname) if !nickname.is_empty() => {
60 format!("{} \"{}\"", contact.display_name, nickname)
61 }
62 _ => contact.display_name.clone(),
63 }
64 }
65
66 declare! {
67 /// One contact as a row.
68 ///
69 /// # Two findings, both closed by makeover-layout 0.9.0
70 ///
71 /// This card was the evidence for two gaps when the screen was first
72 /// described, and both are now said properly rather than worked around.
73 ///
74 /// **The tags were joined into `meta` as text**, behind the primary email,
75 /// because a row had one trailing slot. They are [`Tag`]s now, against
76 /// `RowPart::Tokens`, and each one is a chip that filters the grid by
77 /// itself -- which is what `contacts.js` does when a badge is clicked, and
78 /// which the joined string could not express at all. Not latched: a row's
79 /// tag says what the contact carries, and whether that tag is the active
80 /// filter is the band's business rather than this row's. The email stays in
81 /// `meta`, where a plain fact belongs.
82 ///
83 /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the
84 /// detail pane is showing this", so there was one word for the app's
85 /// pointer and the user's tick. The two are now `current` and `selected`,
86 /// and this row is selectable because the screen has bulk actions.
87 ///
88 /// # What is still absent, and correctly
89 ///
90 /// **The avatar.** `getInitials` derives two letters from the display name
91 /// and the card shows them in a circle. That is a rendering of the primary
92 /// text rather than a fact about the contact, so it belongs to the renderer
93 /// and there is nothing for a description to say. Recorded because it is
94 /// absent by being correct, not by being missing.
95 shape row_for(contact: &Contact) -> Row;
96
97 row filed_as(contact) {
98 selectable false;
99
100 for affiliation in affiliation(contact).into_iter() {
101 secondary affiliation;
102 }
103
104 for email in contact.primary_email().into_iter() {
105 meta email;
106 }
107
108 for tag in contact.tags.iter() {
109 token Tag::chip(tag, list_action(None, Some(tag)));
110 }
111
112 activate to get "/contacts/{contact.id}";
113 }
114 }
115
116 /// The grid's contacts, and the filters they were read under.
117 ///
118 /// Implicit contacts stay out, which is `list_filtered`'s own rule and the same
119 /// one the contact list applies: it is a curated surface, and a contact that
120 /// exists only because it was once emailed has not been curated into it.
121 struct Listing {
122 contacts: Vec<Contact>,
123 search: Option<String>,
124 tag: Option<String>,
125 }
126
127 /// Read the grid the request asks for.
128 fn read(state: &AppState, request: &quasi_router::Request) -> Result<Listing, RouteError> {
129 let search = text(&request.carried, "q").map(str::to_owned);
130 let tag = text(&request.carried, "tag").map(str::to_owned);
131 let contacts = state
132 .contacts
133 .list_filtered(DESKTOP_USER_ID, search.as_deref(), tag.as_deref(), false)
134 .map_err(|error| RouteError::internal(error.to_string()))?;
135 Ok(Listing {
136 contacts,
137 search,
138 tag,
139 })
140 }
141
142 /// What to say when the filters matched nothing.
143 fn nothing_here(listing: &Listing) -> &'static str {
144 match (listing.search.as_deref(), listing.tag.as_deref()) {
145 (Some(_), _) => "No contacts match that search.",
146 (None, Some(_)) => "No contacts carry that tag.",
147 (None, None) => "No contacts yet.",
148 }
149 }
150
151 declare! {
152 /// The grid, filtered the way the screen's search box and tag filter filter
153 /// it.
154 shape grid(listing: &Listing) -> Node;
155
156 given listing.contacts.is_empty() {
157 true -> text nothing_here(listing);
158 otherwise -> list {
159 for contact in listing.contacts.iter() {
160 include row_for(contact);
161 }
162 }
163 }
164 }
165
166 /// Whether the band's chip for this tag is the one in force.
167 fn tag_latched(listing: &Listing, offered: &str) -> bool {
168 listing.tag.as_deref() == Some(offered)
169 }
170
171 /// The tag a press on this chip leaves the grid filtered to.
172 ///
173 /// A tag that is filtered on stays offered even if it is the only one left, so
174 /// the way back is always on screen: pressing a latched chip clears it.
175 fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> {
176 (!tag_latched(listing, offered)).then_some(offered)
177 }
178
179 declare! {
180 /// The whole screen.
181 ///
182 /// The tag filter surfaces only when there are tags to filter by, which is
183 /// the rule `contacts.js` already applies to the same control; an empty
184 /// `offered` draws no chips at all.
185 shape screen(listing: &Listing, offered: &[String]) -> Screen;
186
187 screen list_detail "Contacts" false {
188 at_place super::shell::CONTACTS;
189
190 region "contacts-band" as Band {
191 page "Contacts";
192 act "New contact" to get "/contacts/new";
193
194 for in_use in offered.iter() {
195 chip in_use
196 to doing list_action(listing.search.as_deref(), cleared(listing, in_use)) {
197 latched tag_latched(listing, in_use);
198 }
199 }
200 }
201
202 region "contacts-grid" as Pane {
203 include grid(listing);
204 }
205
206 region "contacts-detail" as Pane {
207 empty "Nothing selected";
208 }
209 }
210 }
211
212 /// Every tag in use, in a stable order, so the filter is a list and not a guess.
213 fn tags_in_use(state: &AppState) -> Result<Vec<String>, RouteError> {
214 let contacts = state
215 .contacts
216 .list_all(DESKTOP_USER_ID)
217 .map_err(|error| RouteError::internal(error.to_string()))?;
218
219 let mut tags: Vec<String> = contacts
220 .into_iter()
221 .flat_map(|contact| contact.tags)
222 .collect();
223 tags.sort_unstable();
224 tags.dedup();
225 Ok(tags)
226 }
227
228 /// A param that is present and not blank. Blank is absent, which is what an
229 /// emptied search box means.
230 fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
231 params.get(name).map(str::trim).filter(|v| !v.is_empty())
232 }
233
234 /// The address of the grid under a given search and tag.
235 fn list_action(search: Option<&str>, tag: Option<&str>) -> Action {
236 let mut action = Action::get("/contacts/list");
237 if let Some(search) = search {
238 action = action.carrying("q", search);
239 }
240 if let Some(tag) = tag {
241 action = action.carrying("tag", tag);
242 }
243 action
244 }
245
246 /// Parse a path param into a typed id, or answer 404.
247 ///
248 /// The ids have no `FromStr`, only `From<Uuid>`, so the parse is the uuid
249 /// crate's. Same reasoning as the projects screen: not worth adding one upstream
250 /// for a handful of call sites.
251 fn id_param<T: From<uuid::Uuid>>(
252 request: &quasi_router::Request,
253 name: &str,
254 ) -> Result<T, RouteError> {
255 let raw = request
256 .captures
257 .get(name)
258 .ok_or_else(|| RouteError::not_found("no id"))?;
259 let uuid = uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an id"))?;
260 Ok(T::from(uuid))
261 }
262
263 /// The whole screen, as an answer.
264 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
265 let listing = read(state, &request)?;
266 Ok(screen(&listing, &tags_in_use(state)?).into())
267 }
268
269 /// The grid alone, which is what search and the tag filter replace.
270 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
271 Ok(Response::fragment(
272 "contacts-grid",
273 grid(&read(state, &request)?),
274 ))
275 }
276
277 /// One entry in a contact's sub-collections.
278 ///
279 /// One struct for all four, because the four differ in what fills it and not in
280 /// what a row of it says. That is what retired the two shapes that were here: a
281 /// row builder per pair of collections, and a titled-list wrapper that took
282 /// rows and handed back nodes.
283 struct Entry {
284 /// What the row reads.
285 text: String,
286 /// The trailing fact: the label, and whether it is the primary one.
287 meta: String,
288 /// Where it goes outside the app, if it goes anywhere.
289 url: Option<String>,
290 /// The path that takes it off the contact.
291 remove: String,
292 }
293
294 /// The label and the primary mark, joined the way the modal joins them.
295 fn entry_meta(label: &str, primary: bool) -> String {
296 let label = (!label.is_empty()).then_some(label);
297 let primary = primary.then_some("Primary");
298 [label, primary]
299 .into_iter()
300 .flatten()
301 .collect::<Vec<_>>()
302 .join(" · ")
303 }
304
305 /// Whether the entry has a trailing fact.
306 fn has_meta(entry: &Entry) -> bool {
307 !entry.meta.is_empty()
308 }
309
310 declare! {
311 /// One entry in a sub-collection, with the control that removes it.
312 ///
313 /// # The third finding, also closed
314 ///
315 /// A social handle and a custom field both carry an optional `url`, and the
316 /// modal renders each as an anchor through `safeUrl`. When this screen was
317 /// first described there was nothing to say about that: an `Action` was a
318 /// route, and an address outside the app is not one. The URL went into the
319 /// trailing text, which made it something to copy rather than something to
320 /// follow.
321 ///
322 /// [`Action::external`] is the fix, and the renderer emits an anchor for it
323 /// rather than a button. Note what it is *not*: an `Act` whose path happens
324 /// to start with `https`. The renderer branches on the destination's
325 /// variant, never on the shape of the string, because that is how a route
326 /// called `/https-setup` ends up opening a browser.
327 ///
328 /// The address is a place to go rather than a fact about the entry, so it
329 /// is a control and not trailing text.
330 shape entry_row(entry: &Entry) -> Row;
331
332 row &entry.text {
333 meta &entry.meta when has_meta(entry);
334
335 for url in entry.url.iter() {
336 act "Open" to external url;
337 }
338
339 act "Remove" to post "{entry.remove}" {
340 tone Danger;
341 }
342 }
343 }
344
345 /// The detail pane's contact, with its four sub-collections read out as rows.
346 struct Shown {
347 contact: Contact,
348 /// The facts the modal lists one per row, the present ones only.
349 facts: Vec<String>,
350 emails: Vec<Entry>,
351 phones: Vec<Entry>,
352 socials: Vec<Entry>,
353 fields: Vec<Entry>,
354 }
355
356 /// Everything the pane draws, worked out once.
357 fn shown(contact: Contact) -> Shown {
358 let id = contact.id;
359
360 // The facts the modal lists one per row. Present ones only, which is what
361 // `showDetailModal` does with the same five.
362 let mut facts: Vec<String> = Vec::new();
363 if let Some(nickname) = contact.nickname.as_deref().filter(|n| !n.is_empty()) {
364 facts.push(format!("Nickname: {nickname}"));
365 }
366 if let Some(birthday) = contact.birthday {
367 facts.push(format!("Birthday: {birthday}"));
368 }
369 if let Some(timezone) = contact.timezone.as_deref().filter(|t| !t.is_empty()) {
370 facts.push(format!("Timezone: {timezone}"));
371 }
372 if !contact.tags.is_empty() {
373 facts.push(format!("Tags: {}", contact.tags.join(", ")));
374 }
375
376 let emails = contact
377 .emails
378 .iter()
379 .map(|email| Entry {
380 text: email.address.clone(),
381 meta: entry_meta(&email.label, email.is_primary),
382 url: None,
383 remove: format!("/contacts/{id}/email/{}/delete", email.id),
384 })
385 .collect();
386
387 let phones = contact
388 .phones
389 .iter()
390 .map(|phone| Entry {
391 text: phone.number.clone(),
392 meta: entry_meta(&phone.label, phone.is_primary),
393 url: None,
394 remove: format!("/contacts/{id}/phone/{}/delete", phone.id),
395 })
396 .collect();
397
398 let socials = contact
399 .social_handles
400 .iter()
401 .map(|handle| Entry {
402 text: format!("{}: {}", handle.platform, handle.handle),
403 meta: String::new(),
404 url: handle.url.clone(),
405 remove: format!("/contacts/{id}/social/{}/delete", handle.id),
406 })
407 .collect();
408
409 let fields = contact
410 .custom_fields
411 .iter()
412 .map(|field| Entry {
413 text: format!("{}: {}", field.label, field.value),
414 meta: String::new(),
415 url: field.url.clone(),
416 remove: format!("/contacts/{id}/field/{}/delete", field.id),
417 })
418 .collect();
419
420 Shown {
421 contact,
422 facts,
423 emails,
424 phones,
425 socials,
426 fields,
427 }
428 }
429
430 /// Whether the contact carries notes.
431 fn has_notes(shown: &Shown) -> bool {
432 !shown.contact.notes.is_empty()
433 }
434
435 declare! {
436 /// The detail pane for one contact, which is also what a removal answers
437 /// with.
438 ///
439 /// Each sub-collection is a heading, then either its rows or a line saying
440 /// there are none. Written out four times rather than through a shape that
441 /// takes rows and hands back nodes, which is the refusal wave 4 settled: a
442 /// list is built where it is placed.
443 shape detail_pane(shown: &Shown) -> Slot;
444
445 region "contacts-detail" as Pane {
446 section &shown.contact.display_name;
447
448 for affiliation in affiliation(&shown.contact).into_iter() {
449 text affiliation;
450 }
451
452 for fact in shown.facts.iter() {
453 text fact.as_str();
454 }
455
456 section "Notes" when has_notes(shown);
457 text &shown.contact.notes when has_notes(shown);
458
459 section "Email Addresses";
460 text "No email addresses" when shown.emails.is_empty();
461 list {
462 for entry in shown.emails.iter() {
463 include entry_row(entry);
464 }
465 } unless shown.emails.is_empty();
466
467 section "Phone Numbers";
468 text "No phone numbers" when shown.phones.is_empty();
469 list {
470 for entry in shown.phones.iter() {
471 include entry_row(entry);
472 }
473 } unless shown.phones.is_empty();
474
475 section "Social Handles";
476 text "No social handles" when shown.socials.is_empty();
477 list {
478 for entry in shown.socials.iter() {
479 include entry_row(entry);
480 }
481 } unless shown.socials.is_empty();
482
483 section "Custom Fields";
484 text "No custom fields" when shown.fields.is_empty();
485 list {
486 for entry in shown.fields.iter() {
487 include entry_row(entry);
488 }
489 } unless shown.fields.is_empty();
490 }
491 }
492
493 /// Read one contact, or answer 404.
494 fn load(state: &AppState, id: ContactId) -> Result<Contact, RouteError> {
495 state
496 .contacts
497 .get_by_id(id, DESKTOP_USER_ID)
498 .map_err(|error| RouteError::internal(error.to_string()))?
499 .ok_or_else(|| RouteError::not_found("no such contact"))
500 }
501
502 /// One contact's detail pane.
503 fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
504 let contact = load(state, id_param(&request, "id")?)?;
505 Ok(Response::fragment(
506 "contacts-detail",
507 Node::Region(detail_pane(&shown(contact))),
508 ))
509 }
510
511 /// Answer a removal with the pane it happened in, re-read.
512 ///
513 /// Re-read rather than patched in memory: the removal is the database's to
514 /// confirm, and a pane rebuilt from what the handler hoped happened is how a
515 /// screen ends up disagreeing with its own storage.
516 fn removed(state: &AppState, id: ContactId) -> Result<Response, RouteError> {
517 let contact = load(state, id)?;
518 Ok(Response::fragment(
519 "contacts-detail",
520 Node::Region(detail_pane(&shown(contact))),
521 ))
522 }
523
524 /// Remove one email address.
525 fn remove_email(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
526 let contact: ContactId = id_param(&request, "id")?;
527 state
528 .contacts
529 .remove_email(id_param(&request, "sub")?, DESKTOP_USER_ID)
530 .map_err(|error| RouteError::internal(error.to_string()))?;
531 removed(state, contact)
532 }
533
534 /// Remove one phone number.
535 fn remove_phone(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
536 let contact: ContactId = id_param(&request, "id")?;
537 state
538 .contacts
539 .remove_phone(id_param(&request, "sub")?, DESKTOP_USER_ID)
540 .map_err(|error| RouteError::internal(error.to_string()))?;
541 removed(state, contact)
542 }
543
544 /// Remove one social handle.
545 fn remove_social(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
546 let contact: ContactId = id_param(&request, "id")?;
547 state
548 .contacts
549 .remove_social_handle(id_param(&request, "sub")?, DESKTOP_USER_ID)
550 .map_err(|error| RouteError::internal(error.to_string()))?;
551 removed(state, contact)
552 }
553
554 /// Remove one custom field.
555 fn remove_field(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
556 let contact: ContactId = id_param(&request, "id")?;
557 state
558 .contacts
559 .remove_custom_field(id_param(&request, "sub")?, DESKTOP_USER_ID)
560 .map_err(|error| RouteError::internal(error.to_string()))?;
561 removed(state, contact)
562 }
563
564 /// The contacts screen's routes.
565 #[must_use]
566 pub fn routes(router: Router<AppState>) -> Router<AppState> {
567 router
568 .get("/contacts", index)
569 .get("/contacts/list", list)
570 .get("/contacts/{id}", detail)
571 .post("/contacts/{id}/email/{sub}/delete", remove_email)
572 .post("/contacts/{id}/phone/{sub}/delete", remove_phone)
573 .post("/contacts/{id}/social/{sub}/delete", remove_social)
574 .post("/contacts/{id}/field/{sub}/delete", remove_field)
575 }
576