//! The projects screen, described rather than built. //! //! //! //! # The shape //! //! Five routes, which is the whole screen: //! //! - `GET /projects` — the document. //! - `GET /projects/list` — the grid alone, which is what the two filters swap. //! - `GET /projects/{id}` — the detail pane. //! - `GET /projects/new` — the create form, in the same pane. //! - `POST /projects` — create. //! - `POST /projects/{id}/delete` — delete. //! //! A described control reaches a handler, or the screen is lying about what it //! does. //! //! The filters are routes rather than local state, per decision 2: they are //! query params, so the same screen is reachable by address and no state has to //! survive between two clicks. //! //! Every action a filtered screen offers has to carry the filters it was //! offered under, or acting resets the view. [`filtered`] is that, applied to //! the detail address, the create form and both writes. //! //! # Sharing //! //! A row carries a `Shared` badge when `group_id` is set. A personal project //! offers a picker over the user's groups; a shared one offers the way back. //! //! A handler is `fn(&S, Request) -> Result`, so it cannot //! await, and both halves of sharing want the network: the picker needs the //! group names, and sharing confirms the user is in the group before stamping a //! scope. That check is worth making, since a scope the engine holds no key for //! routes the whole subtree into a changelog that goes nowhere. Both halves //! read a local table instead: [`known_groups`] for the options, //! `directory::is_member` for the check, both filled by synckit's `sync_groups` //! on its own cycle, outside the request loop. //! //! The picker is withheld when the directory is empty rather than drawn with no //! options. An empty directory means this device has not synced since groups //! existed, which is a different fact from having no groups, and a control that //! cannot populate itself is worse than none. // 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::{DbValue as _, NewProject, Project, ProjectStatus, ProjectType}; use quasi_declare::declare; use quasi_router::layout::Tone; use quasi_router::screen::{Choice, Prose, Tag}; use quasi_router::{Action, Node, Response, RouteError, Router}; use super::parse_choice; use crate::state::{AppState, DESKTOP_USER_ID}; mod dashboard; #[cfg(test)] mod tests; /// Whether a project has stopped being worked on. fn retired(project: &Project) -> bool { matches!( project.status, ProjectStatus::Completed | ProjectStatus::Archived ) } /// The display name of a project type. pub(super) fn type_label(project_type: &ProjectType) -> &'static str { match project_type { ProjectType::SideProject => "Side Project", ProjectType::Job => "Job", ProjectType::Company => "Company", ProjectType::Essay => "Essay", ProjectType::Article => "Article", ProjectType::Painting => "Painting", ProjectType::Other => "Other", } } /// The types a project can be created as. /// /// Six of `ProjectType`'s seven members. `Painting` is not offered here, and is /// reachable only by writing the row some other way. const NEW_TYPES: [ProjectType; 6] = [ ProjectType::SideProject, ProjectType::Job, ProjectType::Company, ProjectType::Essay, ProjectType::Article, ProjectType::Other, ]; /// The display name of a project status. fn status_label(status: &ProjectStatus) -> &'static str { match status { ProjectStatus::Active => "Active", ProjectStatus::OnHold => "On Hold", ProjectStatus::Completed => "Completed", ProjectStatus::Archived => "Archived", } } /// The tone a status badge wears. /// /// The one table statuses map through. Archived returns neutral deliberately: /// it is not news. pub(super) const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone { match status { ProjectStatus::Active => makeover_layout::Tone::Info, ProjectStatus::OnHold => makeover_layout::Tone::Warning, ProjectStatus::Completed => makeover_layout::Tone::Success, ProjectStatus::Archived => makeover_layout::Tone::Neutral, } } /// The filters the screen is under. /// /// Every address on it carries them, or acting resets the view, and the filters /// are the only state this screen has. #[derive(Clone, Copy)] struct View { shared_only: bool, show_retired: bool, } impl View { /// The view a request is asking for. fn of(request: &quasi_router::Request) -> Self { Self { shared_only: flag(request, "shared"), show_retired: flag(request, "retired"), } } /// The same view with the shared filter the other way round. const fn sharing_toggled(self) -> Self { Self { shared_only: !self.shared_only, ..self } } /// The same view with the retired filter the other way round. const fn retired_toggled(self) -> Self { Self { show_retired: !self.show_retired, ..self } } } /// Why the grid has nothing to show. /// /// Three different facts that all draw as an empty state, and only the first has /// a way out of it. enum Grid { /// No projects at all. Fresh, /// Filtered to shared, and nothing is shared. NoneShared, /// Everything is completed or archived, and retired is hidden. AllRetired, /// There are rows. Showing, } /// Everything the screen draws, read once. /// /// One read for the grid and the two counts, where the grid and the band used to /// list the whole table separately. struct Loaded { /// What the grid shows, in order: live first, then retired if they are /// shown at all. shown: Vec, /// Why it shows nothing, when it shows nothing. grid: Grid, /// How many projects are shared into a group. shared: usize, /// How many have stopped being worked on. dormant: usize, view: View, } /// Read it. fn read(state: &AppState, view: View) -> Result { let all = state .projects .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let shared = all.iter().filter(|p| p.group_id.is_some()).count(); let dormant = all.iter().filter(|p| retired(p)).count(); let nothing_at_all = all.is_empty(); let scoped: Vec = all .into_iter() .filter(|project| !view.shared_only || project.group_id.is_some()) .collect(); let nothing_scoped = scoped.is_empty(); let (live, sleeping): (Vec, Vec) = scoped.into_iter().partition(|project| !retired(project)); let (grid, shown) = if nothing_at_all { (Grid::Fresh, Vec::new()) } else if nothing_scoped { (Grid::NoneShared, Vec::new()) } else if live.is_empty() && !view.show_retired { (Grid::AllRetired, Vec::new()) } else if view.show_retired { (Grid::Showing, live.into_iter().chain(sleeping).collect()) } else { (Grid::Showing, live) }; Ok(Loaded { shown, grid, shared, dormant, view, }) } /// Whether the project says anything about itself. fn has_description(project: &Project) -> bool { !project.description.is_empty() } /// The project's own description, as the markdown it is. /// /// A row part holds a string and never a node, so this goes into `secondary` as /// [`Prose`], which says which kind of string it is. `Prose::rich` says markdown /// once and each renderer decides: quasi-webview draws it through docengine's /// `phrase` preset, inline and one line tall, and a terminal can emit bold from /// exactly the same description. Never flatten markdown at the call site: that /// throws the fact away and every site with markdown copies the same three /// lines. fn described(project: &Project) -> Prose { Prose::rich(&project.description) } declare! { /// One project as a row. /// /// Two trailing facts, the type badge and the status badge, carried as /// `RowPart::Tokens` so the status keeps its tone through [`status_tone`]. /// A scope is a third fact about the project, and the row is where a fact /// about the project goes. /// /// The address is filtered, so the pane knows which view it was opened from /// and the delete it offers can answer with that view rather than the /// unfiltered one. shape row_for(loaded: &Loaded, project: &Project) -> Row; row &project.name { token Tag::badge(type_label(&project.project_type)); token Tag::badge(status_label(&project.status)).tone(status_tone(&project.status)); token Tag::badge("Shared") when project.group_id.is_some(); secondary described(project) when has_description(project); activate to doing filtered(Action::get("/projects/{project.id}"), loaded.view); } } declare! { /// The grid, filtered the way the screen's two toggles filter it. /// /// The first empty state is the one in the app with a way out of it, which /// is what `Node::StandIn`'s optional act is for: 2 of goingson's 27 offer /// one and 25 say a sentence and stop. `projects.js` draws the same button. shape grid(loaded: &Loaded) -> Node; given loaded.grid { Grid::Fresh -> empty "No projects yet." { offering "Create your first project" to doing filtered(Action::get("/projects/new"), loaded.view); } Grid::NoneShared -> empty "No shared projects yet. Share a project from its menu \ to see it here."; Grid::AllRetired -> empty "Every project is completed or archived."; otherwise -> list { for project in loaded.shown.iter() { include row_for(loaded, project); } } } } /// Whether the shared filter is on the band at all. /// /// It surfaces only when sharing is in play, which is the rule `projects.js` /// already applies to the same control. fn offers_sharing(loaded: &Loaded) -> bool { loaded.shared > 0 || loaded.view.shared_only } /// What the retired toggle reads. fn retired_label(loaded: &Loaded) -> String { if loaded.view.show_retired { "Hide completed and archived".to_owned() } else { format!("Show {} completed or archived", loaded.dormant) } } declare! { /// The whole screen under a given pair of filters. /// /// Declared rather than built inside the route because a write answers with /// it too: creating or deleting changes the grid and the detail pane at /// once, and a [`Response`] names one region. See [`wrote`]. shape screen(loaded: &Loaded) -> Screen; screen list_detail "Projects" false { at_place super::shell::PROJECTS; region "projects-band" as Band { page "Projects"; act "New project" to doing filtered(Action::get("/projects/new"), loaded.view); chip "Shared only" to doing list_action(loaded.view.sharing_toggled()) when offers_sharing(loaded) { latched when loaded.view.shared_only; } act retired_label(loaded) to doing list_action(loaded.view.retired_toggled()) when loaded.dormant over 0; } region "projects-grid" as Pane { include grid(loaded); } region "projects-detail" as Pane { empty "Nothing selected"; } } } /// Whether a param is on. Absent is off, which is what a URL without it means. fn flag(request: &quasi_router::Request, name: &str) -> bool { matches!(request.carried.get(name), Some("1" | "true")) } /// The same action, carrying one flag if it is on. /// /// Absent means off, which is what a URL without it means, so an off flag is /// never written. That is what keeps two addresses for the same view from /// existing. pub(super) fn filtered_by(action: Action, name: &str, on: bool) -> Action { if on { action.carrying(name, "1") } else { action } } /// The same action, carrying the filters the screen was under. /// /// Every address on this screen goes through here, including the two writes. /// A filtered view whose controls drop the filters is a view you fall out of by /// using it, and the filters are the only state this screen has. fn filtered(action: Action, view: View) -> Action { let action = filtered_by(action, "shared", view.shared_only); filtered_by(action, "retired", view.show_retired) } /// The address of the grid under a given pair of filters. fn list_action(view: View) -> Action { filtered(Action::get("/projects/list"), view) } /// The whole screen. fn index(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); Ok(screen(&read(state, view)?).into()) } /// The grid alone, which is what a filter toggle replaces. fn list(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); Ok(Response::fragment( "projects-grid", grid(&read(state, view)?), )) } /// The project a route was addressed at. /// /// `ProjectId` has no `FromStr`, only `From`, so the parse is the uuid /// crate's. Not worth adding one upstream for two call sites. pub(super) fn project_id( request: &quasi_router::Request, ) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no project id"))?; Ok(goingson_core::ProjectId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a project id"))?, )) } /// One project and what this device knows about sharing it, read once. fn showing(state: &AppState, request: &quasi_router::Request) -> Result { let id = project_id(request)?; let project = state .projects .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such project"))?; // Read only where a picker could be drawn, so a shared project pays nothing // for the directory it would not offer. let groups = if project.group_id.is_none() { known_groups(state)? } else { Vec::new() }; Ok(Showing { project, groups, view: View::of(request), }) } /// One project's detail pane. fn detail(state: &AppState, request: quasi_router::Request) -> Result { let showing = showing(state, &request)?; Ok(Response::fragment( "projects-detail", Node::Region(detail_pane(&showing)), )) } /// The statuses a project can be created in. /// /// Two of the four. All four are offered on edit: a project you file as /// finished before it exists is a project the grid hides the moment it is /// made. const NEW_STATUSES: [ProjectStatus; 2] = [ProjectStatus::Active, ProjectStatus::OnHold]; /// What the create form is answering. /// /// `errors` is what a rejected submission carries back, keyed by field name, and /// `submitted` is what that submission held; on a first showing both are empty. /// A form that refuses must re-offer what was typed, or a name reported as too /// long is thrown away and retyped, which is what [`Field::refilled`] is for. struct Asking<'a> { view: View, errors: &'a [(&'a str, String)], submitted: Option<&'a quasi_router::Params>, } impl Asking<'_> { /// A first showing. const fn fresh(view: View) -> Self { Self { view, errors: &[], submitted: None, } } } /// Nothing was typed, which is what a form that is not answering a refusal /// refills from. static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new(); /// What was typed, or nothing. /// /// Empty rather than absent, because [`Field::refilled`] leaves a name it finds /// nothing under alone: refilling from nothing is the same field back, so the /// setting needs no guard. fn typed<'a>(asking: &Asking<'a>) -> &'a quasi_router::Params { asking.submitted.unwrap_or(&NOTHING_TYPED) } /// Whether a named question was refused. fn has_error(asking: &Asking, name: &str) -> bool { asking.errors.iter().any(|(field, _)| *field == name) } /// Why it was refused, or nothing. fn error_for(asking: &Asking, name: &str) -> String { asking .errors .iter() .find(|(field, _)| *field == name) .map(|(_, message)| message.clone()) .unwrap_or_default() } declare! { /// The create form, in the pane the detail pane uses. shape form_pane(asking: &Asking) -> Slot; region "projects-detail" as Pane { section "New project"; form doing filtered(Action::post("/projects"), asking.view) { submit "Create project"; field Text "name" "Project Name" { required; placeholder "My Awesome Project"; error error_for(asking, "name") when has_error(asking, "name"); refilled typed(asking); } field Textarea "description" "Description" { placeholder "What's this project about?"; error error_for(asking, "description") when has_error(asking, "description"); refilled typed(asking); } field Select "project_type" "Type" { for kind in NEW_TYPES.iter() { option Choice::new(kind.db_value(), type_label(kind)); } error error_for(asking, "project_type") when has_error(asking, "project_type"); refilled typed(asking); } field Select "status" "Status" { for status in NEW_STATUSES.iter() { option Choice::new(status.db_value(), status_label(status)); } error error_for(asking, "status") when has_error(asking, "status"); refilled typed(asking); } } } } /// One project, and what this device knows about sharing it. struct Showing { project: Project, /// The groups there are to share into, which is empty on a project that is /// already shared. groups: Vec, view: View, } /// What the pane says the project is. fn kind_and_status(showing: &Showing) -> String { format!( "{} · {}", type_label(&showing.project.project_type), status_label(&showing.project.status) ) } /// Whether the project is personal and this device knows a group to offer. /// /// Both halves read the directory synckit writes each cycle; see the module /// header for why that had to exist first. The picker is withheld when the /// directory is empty rather than drawn with no options. fn offers_sharing_into(showing: &Showing) -> bool { showing.project.group_id.is_none() && !showing.groups.is_empty() } /// Whether the project is already in a group. fn is_shared(showing: &Showing) -> bool { showing.project.group_id.is_some() } declare! { /// One project's detail pane. shape detail_pane(showing: &Showing) -> Slot; region "projects-detail" as Pane { section &showing.project.name; text kind_and_status(showing); text &showing.project.description when has_description(&showing.project); form doing filtered( Action::post("/projects/{showing.project.id}/share"), showing.view ) when offers_sharing_into(showing) { submit "Share into a group"; field Select "group_id" "Group" { for group in showing.groups.iter() { option Choice::new(group.id.to_string(), &group.name); } required; hint "Everything in the project goes with it: its tasks, events, \ milestones and attachments."; } } text "Shared into a group. Its tasks, events, milestones and attachments \ are shared with it." when is_shared(showing); act "Move back to personal" to doing filtered( Action::post("/projects/{showing.project.id}/unshare"), showing.view ) when is_shared(showing) { confirm "Move this project and everything in it back to personal scope? \ Other members of the group will stop seeing it."; } act "Delete project" to doing filtered( Action::post("/projects/{showing.project.id}/delete"), showing.view ) { tone Danger; confirm "Are you sure you want to delete this project? This cannot be undone."; } } } /// The create form. fn new(_state: &AppState, request: quasi_router::Request) -> Result { Ok(Response::fragment( "projects-detail", Node::Region(form_pane(&Asking::fresh(View::of(&request)))), )) } /// What a submitted project must satisfy: a name, and both lengths. fn validate(name: &str, description: &str) -> Vec<(&'static str, String)> { let mut errors = Vec::new(); if name.is_empty() { errors.push(("name", "A project needs a name.".to_owned())); } else if name.chars().count() > 100 { errors.push(("name", "Maximum 100 characters".to_owned())); } if description.chars().count() > 1000 { errors.push(("description", "Maximum 1000 characters".to_owned())); } errors } /// Answer a write with the screen it happened on. /// /// Creating and deleting both change the grid and the detail pane, and a /// [`Response`] names one region. Rather than pick one and leave the other /// stale — a pane still offering to delete a project that is gone — the answer /// is the whole screen, re-read under the filters the write carried. /// /// The cost is honest and worth naming: a write reflows the document where a /// filter toggle swaps one region. Decision 7 buys the narrow swap for reads; /// nothing in the vocabulary buys it for a write that lands in two places at /// once. What takes the sting out is decision 7's own slack — a whole-screen /// answer swaps with `hx-swap="outerMorph"`, so focus, scroll and any half-typed /// input survive it. fn wrote(state: &AppState, view: View) -> Result { Ok(screen(&read(state, view)?).into()) } /// Create a project, or answer with the form saying why not. fn create(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let name = request .payload .get("name") .unwrap_or_default() .trim() .to_owned(); let description = request .payload .get("description") .unwrap_or_default() .trim() .to_owned(); let mut errors = validate(&name, &description); // A select offers a fixed set, so an unparseable value did not come from the // form. Refused rather than defaulted: `from_str_or_default` would file a // typo as an `Other` project and say nothing. let project_type = parse_choice::(&request.payload, "project_type", &mut errors); let status = parse_choice::(&request.payload, "status", &mut errors) .filter(|status| NEW_STATUSES.contains(status)); if status.is_none() && !errors.iter().any(|(field, _)| *field == "status") { errors.push(("status", "Not a status a project starts in.".to_owned())); } // Every complaint at once. Answering with the first one found is how a form // is fixed one round trip per mistake. let refused = Asking { view, errors: &errors, submitted: Some(&request.payload), }; let (Some(project_type), Some(status)) = (project_type, status) else { return Ok(Response::fragment( "projects-detail", Node::Region(form_pane(&refused)), )); }; if !errors.is_empty() { return Ok(Response::fragment( "projects-detail", Node::Region(form_pane(&refused)), )); } state .projects .create( DESKTOP_USER_ID, NewProject { name, description, project_type, status, }, ) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, view) } /// Delete a project. /// /// A 404 for a project that is not there rather than a quiet success: the /// repository answers `false`, and a delete that reports done for something it /// never saw is how two panes end up disagreeing about what exists. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = project_id(&request)?; let deleted = state .projects .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such project")); } wrote(state, View::of(&request)) } /// The groups this device knows the user belongs to, by name. /// /// `synckit_client::store::directory`, read through goingson's own pool. The /// directory is written by the sync loop each cycle out of an answer it was /// already fetching, which is what makes a group nameable from a synchronous /// handler at all. An empty answer means this device has not synced since groups /// existed, not that the user has none, so a screen offers no picker rather than /// claiming there is nothing to share into. fn known_groups( state: &AppState, ) -> Result, RouteError> { let conn = state .db .conn() .map_err(|error| RouteError::internal(error.to_string()))?; synckit_client::store::directory::groups(&conn) .map_err(|error| RouteError::internal(error.to_string())) } /// Share a project and its whole subtree into a group. /// /// The write is [`crate::commands::group::share_project_local`], which is /// synchronous because synckit 0.9.0 made its membership check a local read of /// the directory. Before that it was `client.list_groups().await` and this route /// could not have existed. fn share(state: &AppState, request: quasi_router::Request) -> Result { let id = project_id(&request)?; let group = request .payload .get("group_id") .unwrap_or_default() .trim() .to_owned(); // A select offers a fixed set, so a value that is not one of them did not // come from the form. The refusal below covers both that and a group this // device does not know it belongs to, which is the same answer to the user. let view = View::of(&request); if let Err(error) = crate::commands::group::share_project_local(state, &id.to_string(), &group) { return Ok( Response::from(screen(&read(state, view)?)).toast(Tone::Danger, error.to_string()) ); } Ok(Response::from(screen(&read(state, view)?)).toast( Tone::Success, "Shared. Everything in the project went with it.", )) } /// Move a project and its whole subtree back to personal scope. /// /// The write is [`crate::commands::group::set_project_scope`] with `None`, which /// is what `unshare_project` does after resolving the project. Called directly /// rather than through the command because the command is `async` for the sake /// of its siblings and this path awaits nothing: the engine's UPDATE triggers /// capture each row with its new scope and the next sync re-routes them. fn unshare(state: &AppState, request: quasi_router::Request) -> Result { let id = project_id(&request)?; // Resolved first, for `share_project`'s reason: without it a stale id stamps // zero rows and still reports success, and the user believes a project moved // when nothing did. state .projects .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such project"))?; crate::commands::group::set_project_scope(&state.db, DESKTOP_USER_ID, &id.to_string(), None) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, View::of(&request)) } /// The projects screen's routes. #[must_use] pub fn routes(router: Router) -> Router { // `/projects/new` and `/projects/{id}` collide, and the router settles it by // specificity rather than by the order they are written in, so `new` is // tried first wherever it sits here. let router = router .get("/projects", index) .get("/projects/list", list) .get("/projects/new", new) .get("/projects/{id}", detail) .post("/projects", create) .post("/projects/{id}/delete", remove) .post("/projects/{id}/share", share) .post("/projects/{id}/unshare", unshare); dashboard::routes(router) }