//! The contacts screen, described rather than built. //! //! //! //! A grid of records with sub-collections, and rows that act on themselves //! rather than only selecting: a row carries tokens (`RowPart::Tokens`), a //! row's tick is distinct from the app's own pointer (`Row::selected` against //! `Row::current`), and an action can go somewhere outside the app //! (`Destination::External`). See [`row_for`] and [`link_row`]. //! //! # The shape //! //! - `GET /contacts` — the document. //! - `GET /contacts/list` — the grid alone, which is what search and the tag //! filter swap. //! - `GET /contacts/{id}` — the detail pane, sub-collections included. //! - `POST /contacts/{id}/email|phone|social|field/{sub}/delete` — remove one //! entry from a sub-collection and answer with the pane again. //! //! Search and the tag filter are query params rather than module state, per //! decision 2, so the view a user is looking at has an address. //! //! The removals are routes rather than dangling actions: a described control //! that calls nothing is a screen that lies about what it does. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use goingson_core::{Contact, ContactId}; use quasi_declare::declare; use quasi_router::screen::Tag; use quasi_router::{Action, Node, Response, RouteError, Router}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The name a contact is filed under, with the company it belongs to. /// /// `contacts-render.js:renderCard` puts the company on its own line under the /// name and the title only in the detail modal. Joined here because a row has /// one `secondary`, and dropping the company would lose the fact the card is /// actually scanned by. fn affiliation(contact: &Contact) -> Option { match (contact.company.as_deref(), contact.title.as_deref()) { (Some(company), Some(title)) => Some(format!("{title}, {company}")), (Some(company), None) => Some(company.to_owned()), (None, Some(title)) => Some(title.to_owned()), (None, None) => None, } } /// The name a contact is filed under, with the nickname it goes by. fn filed_as(contact: &Contact) -> String { match contact.nickname.as_deref() { Some(nickname) if !nickname.is_empty() => { format!("{} \"{}\"", contact.display_name, nickname) } _ => contact.display_name.clone(), } } declare! { /// One contact as a row. /// /// # Two findings, both closed by makeover-layout 0.9.0 /// /// This card was the evidence for two gaps when the screen was first /// described, and both are now said properly rather than worked around. /// /// **The tags were joined into `meta` as text**, behind the primary email, /// because a row had one trailing slot. They are [`Tag`]s now, against /// `RowPart::Tokens`, and each one is a chip that filters the grid by /// itself -- which is what `contacts.js` does when a badge is clicked, and /// which the joined string could not express at all. Not latched: a row's /// tag says what the contact carries, and whether that tag is the active /// filter is the band's business rather than this row's. The email stays in /// `meta`, where a plain fact belongs. /// /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the /// detail pane is showing this", so there was one word for the app's /// pointer and the user's tick. The two are now `current` and `selected`, /// and this row is selectable because the screen has bulk actions. /// /// # What is still absent, and correctly /// /// **The avatar.** `getInitials` derives two letters from the display name /// and the card shows them in a circle. That is a rendering of the primary /// text rather than a fact about the contact, so it belongs to the renderer /// and there is nothing for a description to say. Recorded because it is /// absent by being correct, not by being missing. shape row_for(contact: &Contact) -> Row; row filed_as(contact) { selectable false; for affiliation in affiliation(contact).into_iter() { secondary affiliation; } for email in contact.primary_email().into_iter() { meta email; } for tag in contact.tags.iter() { token Tag::chip(tag, list_action(None, Some(tag))); } activate to get "/contacts/{contact.id}"; } } /// The grid's contacts, and the filters they were read under. /// /// Implicit contacts stay out, which is `list_filtered`'s own rule and the same /// one the contact list applies: it is a curated surface, and a contact that /// exists only because it was once emailed has not been curated into it. struct Listing { contacts: Vec, search: Option, tag: Option, } /// Read the grid the request asks for. fn read(state: &AppState, request: &quasi_router::Request) -> Result { let search = text(&request.carried, "q").map(str::to_owned); let tag = text(&request.carried, "tag").map(str::to_owned); let contacts = state .contacts .list_filtered(DESKTOP_USER_ID, search.as_deref(), tag.as_deref(), false) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Listing { contacts, search, tag, }) } /// What to say when the filters matched nothing. fn nothing_here(listing: &Listing) -> &'static str { match (listing.search.as_deref(), listing.tag.as_deref()) { (Some(_), _) => "No contacts match that search.", (None, Some(_)) => "No contacts carry that tag.", (None, None) => "No contacts yet.", } } declare! { /// The grid, filtered the way the screen's search box and tag filter filter /// it. shape grid(listing: &Listing) -> Node; given listing.contacts.is_empty() { true -> text nothing_here(listing); otherwise -> list { for contact in listing.contacts.iter() { include row_for(contact); } } } } /// Whether the band's chip for this tag is the one in force. fn tag_latched(listing: &Listing, offered: &str) -> bool { listing.tag.as_deref() == Some(offered) } /// The tag a press on this chip leaves the grid filtered to. /// /// A tag that is filtered on stays offered even if it is the only one left, so /// the way back is always on screen: pressing a latched chip clears it. fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> { (!tag_latched(listing, offered)).then_some(offered) } declare! { /// The whole screen. /// /// The tag filter surfaces only when there are tags to filter by, which is /// the rule `contacts.js` already applies to the same control; an empty /// `offered` draws no chips at all. shape screen(listing: &Listing, offered: &[String]) -> Screen; screen list_detail "Contacts" false { at_place super::shell::CONTACTS; region "contacts-band" as Band { page "Contacts"; act "New contact" to get "/contacts/new"; for in_use in offered.iter() { chip in_use to doing list_action(listing.search.as_deref(), cleared(listing, in_use)) { latched when tag_latched(listing, in_use); } } } region "contacts-grid" as Pane { include grid(listing); } region "contacts-detail" as Pane { empty "Nothing selected"; } } } /// Every tag in use, in a stable order, so the filter is a list and not a guess. fn tags_in_use(state: &AppState) -> Result, RouteError> { let contacts = state .contacts .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let mut tags: Vec = contacts .into_iter() .flat_map(|contact| contact.tags) .collect(); tags.sort_unstable(); tags.dedup(); Ok(tags) } /// A param that is present and not blank. Blank is absent, which is what an /// emptied search box means. fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> { params.get(name).map(str::trim).filter(|v| !v.is_empty()) } /// The address of the grid under a given search and tag. fn list_action(search: Option<&str>, tag: Option<&str>) -> Action { let mut action = Action::get("/contacts/list"); if let Some(search) = search { action = action.carrying("q", search); } if let Some(tag) = tag { action = action.carrying("tag", tag); } action } /// Parse a path param into a typed id, or answer 404. /// /// The ids have no `FromStr`, only `From`, so the parse is the uuid /// crate's. Same reasoning as the projects screen: not worth adding one upstream /// for a handful of call sites. fn id_param>( request: &quasi_router::Request, name: &str, ) -> Result { let raw = request .captures .get(name) .ok_or_else(|| RouteError::not_found("no id"))?; let uuid = uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an id"))?; Ok(T::from(uuid)) } /// The whole screen, as an answer. fn index(state: &AppState, request: quasi_router::Request) -> Result { let listing = read(state, &request)?; Ok(screen(&listing, &tags_in_use(state)?).into()) } /// The grid alone, which is what search and the tag filter replace. fn list(state: &AppState, request: quasi_router::Request) -> Result { Ok(Response::fragment( "contacts-grid", grid(&read(state, &request)?), )) } /// One entry in a contact's sub-collections. /// /// One struct for all four, because the four differ in what fills it and not in /// what a row of it says. That is what retired the two shapes that were here: a /// row builder per pair of collections, and a titled-list wrapper that took /// rows and handed back nodes. struct Entry { /// What the row reads. text: String, /// The trailing fact: the label, and whether it is the primary one. meta: String, /// Where it goes outside the app, if it goes anywhere. url: Option, /// The path that takes it off the contact. remove: String, } /// The label and the primary mark, joined the way the modal joins them. fn entry_meta(label: &str, primary: bool) -> String { let label = (!label.is_empty()).then_some(label); let primary = primary.then_some("Primary"); [label, primary] .into_iter() .flatten() .collect::>() .join(" · ") } /// Whether the entry has a trailing fact. fn has_meta(entry: &Entry) -> bool { !entry.meta.is_empty() } declare! { /// One entry in a sub-collection, with the control that removes it. /// /// # The third finding, also closed /// /// A social handle and a custom field both carry an optional `url`, and the /// modal renders each as an anchor through `safeUrl`. When this screen was /// first described there was nothing to say about that: an `Action` was a /// route, and an address outside the app is not one. The URL went into the /// trailing text, which made it something to copy rather than something to /// follow. /// /// [`Action::external`] is the fix, and the renderer emits an anchor for it /// rather than a button. Note what it is *not*: an `Act` whose path happens /// to start with `https`. The renderer branches on the destination's /// variant, never on the shape of the string, because that is how a route /// called `/https-setup` ends up opening a browser. /// /// The address is a place to go rather than a fact about the entry, so it /// is a control and not trailing text. shape entry_row(entry: &Entry) -> Row; row &entry.text { meta &entry.meta when has_meta(entry); for url in entry.url.iter() { act "Open" to external url; } act "Remove" to post "{entry.remove}" { tone Danger; } } } /// The detail pane's contact, with its four sub-collections read out as rows. struct Shown { contact: Contact, /// The facts the modal lists one per row, the present ones only. facts: Vec, emails: Vec, phones: Vec, socials: Vec, fields: Vec, } /// Everything the pane draws, worked out once. fn shown(contact: Contact) -> Shown { let id = contact.id; // The facts the modal lists one per row. Present ones only, which is what // `showDetailModal` does with the same five. let mut facts: Vec = Vec::new(); if let Some(nickname) = contact.nickname.as_deref().filter(|n| !n.is_empty()) { facts.push(format!("Nickname: {nickname}")); } if let Some(birthday) = contact.birthday { facts.push(format!("Birthday: {birthday}")); } if let Some(timezone) = contact.timezone.as_deref().filter(|t| !t.is_empty()) { facts.push(format!("Timezone: {timezone}")); } if !contact.tags.is_empty() { facts.push(format!("Tags: {}", contact.tags.join(", "))); } let emails = contact .emails .iter() .map(|email| Entry { text: email.address.clone(), meta: entry_meta(&email.label, email.is_primary), url: None, remove: format!("/contacts/{id}/email/{}/delete", email.id), }) .collect(); let phones = contact .phones .iter() .map(|phone| Entry { text: phone.number.clone(), meta: entry_meta(&phone.label, phone.is_primary), url: None, remove: format!("/contacts/{id}/phone/{}/delete", phone.id), }) .collect(); let socials = contact .social_handles .iter() .map(|handle| Entry { text: format!("{}: {}", handle.platform, handle.handle), meta: String::new(), url: handle.url.clone(), remove: format!("/contacts/{id}/social/{}/delete", handle.id), }) .collect(); let fields = contact .custom_fields .iter() .map(|field| Entry { text: format!("{}: {}", field.label, field.value), meta: String::new(), url: field.url.clone(), remove: format!("/contacts/{id}/field/{}/delete", field.id), }) .collect(); Shown { contact, facts, emails, phones, socials, fields, } } /// Whether the contact carries notes. fn has_notes(shown: &Shown) -> bool { !shown.contact.notes.is_empty() } declare! { /// The detail pane for one contact, which is also what a removal answers /// with. /// /// Each sub-collection is a heading, then either its rows or a line saying /// there are none. Written out four times rather than through a shape that /// takes rows and hands back nodes, which is the refusal wave 4 settled: a /// list is built where it is placed. shape detail_pane(shown: &Shown) -> Slot; region "contacts-detail" as Pane { section &shown.contact.display_name; for affiliation in affiliation(&shown.contact).into_iter() { text affiliation; } for fact in shown.facts.iter() { text fact.as_str(); } section "Notes" when has_notes(shown); text &shown.contact.notes when has_notes(shown); section "Email Addresses"; text "No email addresses" when shown.emails.is_empty(); list { for entry in shown.emails.iter() { include entry_row(entry); } } unless shown.emails.is_empty(); section "Phone Numbers"; text "No phone numbers" when shown.phones.is_empty(); list { for entry in shown.phones.iter() { include entry_row(entry); } } unless shown.phones.is_empty(); section "Social Handles"; text "No social handles" when shown.socials.is_empty(); list { for entry in shown.socials.iter() { include entry_row(entry); } } unless shown.socials.is_empty(); section "Custom Fields"; text "No custom fields" when shown.fields.is_empty(); list { for entry in shown.fields.iter() { include entry_row(entry); } } unless shown.fields.is_empty(); } } /// Read one contact, or answer 404. fn load(state: &AppState, id: ContactId) -> Result { state .contacts .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such contact")) } /// One contact's detail pane. fn detail(state: &AppState, request: quasi_router::Request) -> Result { let contact = load(state, id_param(&request, "id")?)?; Ok(Response::fragment( "contacts-detail", Node::Region(detail_pane(&shown(contact))), )) } /// Answer a removal with the pane it happened in, re-read. /// /// Re-read rather than patched in memory: the removal is the database's to /// confirm, and a pane rebuilt from what the handler hoped happened is how a /// screen ends up disagreeing with its own storage. fn removed(state: &AppState, id: ContactId) -> Result { let contact = load(state, id)?; Ok(Response::fragment( "contacts-detail", Node::Region(detail_pane(&shown(contact))), )) } /// Remove one email address. fn remove_email(state: &AppState, request: quasi_router::Request) -> Result { let contact: ContactId = id_param(&request, "id")?; state .contacts .remove_email(id_param(&request, "sub")?, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; removed(state, contact) } /// Remove one phone number. fn remove_phone(state: &AppState, request: quasi_router::Request) -> Result { let contact: ContactId = id_param(&request, "id")?; state .contacts .remove_phone(id_param(&request, "sub")?, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; removed(state, contact) } /// Remove one social handle. fn remove_social(state: &AppState, request: quasi_router::Request) -> Result { let contact: ContactId = id_param(&request, "id")?; state .contacts .remove_social_handle(id_param(&request, "sub")?, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; removed(state, contact) } /// Remove one custom field. fn remove_field(state: &AppState, request: quasi_router::Request) -> Result { let contact: ContactId = id_param(&request, "id")?; state .contacts .remove_custom_field(id_param(&request, "sub")?, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; removed(state, contact) } /// The contacts screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/contacts", index) .get("/contacts/list", list) .get("/contacts/{id}", detail) .post("/contacts/{id}/email/{sub}/delete", remove_email) .post("/contacts/{id}/phone/{sub}/delete", remove_phone) .post("/contacts/{id}/social/{sub}/delete", remove_social) .post("/contacts/{id}/field/{sub}/delete", remove_field) }