Skip to main content

max / goingson

29.1 KB · 839 lines History Blame Raw
1 //! The projects screen, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # The shape
6 //!
7 //! Five routes, which is the whole screen:
8 //!
9 //! - `GET /projects` — the document.
10 //! - `GET /projects/list` — the grid alone, which is what the two filters swap.
11 //! - `GET /projects/{id}` — the detail pane.
12 //! - `GET /projects/new` — the create form, in the same pane.
13 //! - `POST /projects` — create.
14 //! - `POST /projects/{id}/delete` — delete.
15 //!
16 //! A described control reaches a handler, or the screen is lying about what it
17 //! does.
18 //!
19 //! The filters are routes rather than local state, per decision 2: they are
20 //! query params, so the same screen is reachable by address and no state has to
21 //! survive between two clicks.
22 //!
23 //! Every action a filtered screen offers has to carry the filters it was
24 //! offered under, or acting resets the view. [`filtered`] is that, applied to
25 //! the detail address, the create form and both writes.
26 //!
27 //! # Sharing
28 //!
29 //! A row carries a `Shared` badge when `group_id` is set. A personal project
30 //! offers a picker over the user's groups; a shared one offers the way back.
31 //!
32 //! A handler is `fn(&S, Request) -> Result<Response, RouteError>`, so it cannot
33 //! await, and both halves of sharing want the network: the picker needs the
34 //! group names, and sharing confirms the user is in the group before stamping a
35 //! scope. That check is worth making, since a scope the engine holds no key for
36 //! routes the whole subtree into a changelog that goes nowhere. Both halves
37 //! read a local table instead: [`known_groups`] for the options,
38 //! `directory::is_member` for the check, both filled by synckit's `sync_groups`
39 //! on its own cycle, outside the request loop.
40 //!
41 //! The picker is withheld when the directory is empty rather than drawn with no
42 //! options. An empty directory means this device has not synced since groups
43 //! existed, which is a different fact from having no groups, and a control that
44 //! cannot populate itself is worse than none.
45
46 // Handlers take their request by value because `quasi_router::Handler` is a
47 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
48 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
49 #![allow(clippy::needless_pass_by_value)]
50
51 use goingson_core::{DbValue as _, NewProject, Project, ProjectStatus, ProjectType};
52 use quasi_declare::declare;
53 use quasi_router::layout::Tone;
54 use quasi_router::screen::{Choice, Prose, Tag};
55 use quasi_router::{Action, Node, Response, RouteError, Router};
56
57 use super::parse_choice;
58 use crate::state::{AppState, DESKTOP_USER_ID};
59
60 mod dashboard;
61
62 #[cfg(test)]
63 mod tests;
64
65 /// Whether a project has stopped being worked on.
66 fn retired(project: &Project) -> bool {
67 matches!(
68 project.status,
69 ProjectStatus::Completed | ProjectStatus::Archived
70 )
71 }
72
73 /// The display name of a project type.
74 pub(super) fn type_label(project_type: &ProjectType) -> &'static str {
75 match project_type {
76 ProjectType::SideProject => "Side Project",
77 ProjectType::Job => "Job",
78 ProjectType::Company => "Company",
79 ProjectType::Essay => "Essay",
80 ProjectType::Article => "Article",
81 ProjectType::Painting => "Painting",
82 ProjectType::Other => "Other",
83 }
84 }
85
86 /// The types a project can be created as.
87 ///
88 /// Six of `ProjectType`'s seven members. `Painting` is not offered here, and is
89 /// reachable only by writing the row some other way.
90 const NEW_TYPES: [ProjectType; 6] = [
91 ProjectType::SideProject,
92 ProjectType::Job,
93 ProjectType::Company,
94 ProjectType::Essay,
95 ProjectType::Article,
96 ProjectType::Other,
97 ];
98
99 /// The display name of a project status.
100 fn status_label(status: &ProjectStatus) -> &'static str {
101 match status {
102 ProjectStatus::Active => "Active",
103 ProjectStatus::OnHold => "On Hold",
104 ProjectStatus::Completed => "Completed",
105 ProjectStatus::Archived => "Archived",
106 }
107 }
108
109 /// The tone a status badge wears.
110 ///
111 /// The one table statuses map through. Archived returns neutral deliberately:
112 /// it is not news.
113 pub(super) const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone {
114 match status {
115 ProjectStatus::Active => makeover_layout::Tone::Info,
116 ProjectStatus::OnHold => makeover_layout::Tone::Warning,
117 ProjectStatus::Completed => makeover_layout::Tone::Success,
118 ProjectStatus::Archived => makeover_layout::Tone::Neutral,
119 }
120 }
121
122 /// The filters the screen is under.
123 ///
124 /// Every address on it carries them, or acting resets the view, and the filters
125 /// are the only state this screen has.
126 #[derive(Clone, Copy)]
127 struct View {
128 shared_only: bool,
129 show_retired: bool,
130 }
131
132 impl View {
133 /// The view a request is asking for.
134 fn of(request: &quasi_router::Request) -> Self {
135 Self {
136 shared_only: flag(request, "shared"),
137 show_retired: flag(request, "retired"),
138 }
139 }
140
141 /// The same view with the shared filter the other way round.
142 const fn sharing_toggled(self) -> Self {
143 Self {
144 shared_only: !self.shared_only,
145 ..self
146 }
147 }
148
149 /// The same view with the retired filter the other way round.
150 const fn retired_toggled(self) -> Self {
151 Self {
152 show_retired: !self.show_retired,
153 ..self
154 }
155 }
156 }
157
158 /// Why the grid has nothing to show.
159 ///
160 /// Three different facts that all draw as an empty state, and only the first has
161 /// a way out of it.
162 enum Grid {
163 /// No projects at all.
164 Fresh,
165 /// Filtered to shared, and nothing is shared.
166 NoneShared,
167 /// Everything is completed or archived, and retired is hidden.
168 AllRetired,
169 /// There are rows.
170 Showing,
171 }
172
173 /// Everything the screen draws, read once.
174 ///
175 /// One read for the grid and the two counts, where the grid and the band used to
176 /// list the whole table separately.
177 struct Loaded {
178 /// What the grid shows, in order: live first, then retired if they are
179 /// shown at all.
180 shown: Vec<Project>,
181 /// Why it shows nothing, when it shows nothing.
182 grid: Grid,
183 /// How many projects are shared into a group.
184 shared: usize,
185 /// How many have stopped being worked on.
186 dormant: usize,
187 view: View,
188 }
189
190 /// Read it.
191 fn read(state: &AppState, view: View) -> Result<Loaded, RouteError> {
192 let all = state
193 .projects
194 .list_all(DESKTOP_USER_ID)
195 .map_err(|error| RouteError::internal(error.to_string()))?;
196
197 let shared = all.iter().filter(|p| p.group_id.is_some()).count();
198 let dormant = all.iter().filter(|p| retired(p)).count();
199 let nothing_at_all = all.is_empty();
200
201 let scoped: Vec<Project> = all
202 .into_iter()
203 .filter(|project| !view.shared_only || project.group_id.is_some())
204 .collect();
205 let nothing_scoped = scoped.is_empty();
206
207 let (live, sleeping): (Vec<Project>, Vec<Project>) =
208 scoped.into_iter().partition(|project| !retired(project));
209
210 let (grid, shown) = if nothing_at_all {
211 (Grid::Fresh, Vec::new())
212 } else if nothing_scoped {
213 (Grid::NoneShared, Vec::new())
214 } else if live.is_empty() && !view.show_retired {
215 (Grid::AllRetired, Vec::new())
216 } else if view.show_retired {
217 (Grid::Showing, live.into_iter().chain(sleeping).collect())
218 } else {
219 (Grid::Showing, live)
220 };
221
222 Ok(Loaded {
223 shown,
224 grid,
225 shared,
226 dormant,
227 view,
228 })
229 }
230
231 /// Whether the project says anything about itself.
232 fn has_description(project: &Project) -> bool {
233 !project.description.is_empty()
234 }
235
236 /// The project's own description, as the markdown it is.
237 ///
238 /// A row part holds a string and never a node, so this goes into `secondary` as
239 /// [`Prose`], which says which kind of string it is. `Prose::rich` says markdown
240 /// once and each renderer decides: quasi-webview draws it through docengine's
241 /// `phrase` preset, inline and one line tall, and a terminal can emit bold from
242 /// exactly the same description. Never flatten markdown at the call site: that
243 /// throws the fact away and every site with markdown copies the same three
244 /// lines.
245 fn described(project: &Project) -> Prose {
246 Prose::rich(&project.description)
247 }
248
249 declare! {
250 /// One project as a row.
251 ///
252 /// Two trailing facts, the type badge and the status badge, carried as
253 /// `RowPart::Tokens` so the status keeps its tone through [`status_tone`].
254 /// A scope is a third fact about the project, and the row is where a fact
255 /// about the project goes.
256 ///
257 /// The address is filtered, so the pane knows which view it was opened from
258 /// and the delete it offers can answer with that view rather than the
259 /// unfiltered one.
260 shape row_for(loaded: &Loaded, project: &Project) -> Row;
261
262 row &project.name {
263 token Tag::badge(type_label(&project.project_type));
264 token Tag::badge(status_label(&project.status)).tone(status_tone(&project.status));
265 token Tag::badge("Shared") when project.group_id.is_some();
266 secondary described(project) when has_description(project);
267 activate to doing filtered(Action::get("/projects/{project.id}"), loaded.view);
268 }
269 }
270
271 declare! {
272 /// The grid, filtered the way the screen's two toggles filter it.
273 ///
274 /// The first empty state is the one in the app with a way out of it, which
275 /// is what `Node::StandIn`'s optional act is for: 2 of goingson's 27 offer
276 /// one and 25 say a sentence and stop. `projects.js` draws the same button.
277 shape grid(loaded: &Loaded) -> Node;
278
279 given loaded.grid {
280 Grid::Fresh -> empty "No projects yet." {
281 offering "Create your first project"
282 to doing filtered(Action::get("/projects/new"), loaded.view);
283 }
284 Grid::NoneShared -> empty "No shared projects yet. Share a project from its menu \
285 to see it here.";
286 Grid::AllRetired -> empty "Every project is completed or archived.";
287 otherwise -> list {
288 for project in loaded.shown.iter() {
289 include row_for(loaded, project);
290 }
291 }
292 }
293 }
294
295 /// Whether the shared filter is on the band at all.
296 ///
297 /// It surfaces only when sharing is in play, which is the rule `projects.js`
298 /// already applies to the same control.
299 fn offers_sharing(loaded: &Loaded) -> bool {
300 loaded.shared > 0 || loaded.view.shared_only
301 }
302
303 /// What the retired toggle reads.
304 fn retired_label(loaded: &Loaded) -> String {
305 if loaded.view.show_retired {
306 "Hide completed and archived".to_owned()
307 } else {
308 format!("Show {} completed or archived", loaded.dormant)
309 }
310 }
311
312 declare! {
313 /// The whole screen under a given pair of filters.
314 ///
315 /// Declared rather than built inside the route because a write answers with
316 /// it too: creating or deleting changes the grid and the detail pane at
317 /// once, and a [`Response`] names one region. See [`wrote`].
318 shape screen(loaded: &Loaded) -> Screen;
319
320 screen list_detail "Projects" false {
321 at_place super::shell::PROJECTS;
322
323 region "projects-band" as Band {
324 page "Projects";
325 act "New project" to doing filtered(Action::get("/projects/new"), loaded.view);
326
327 chip "Shared only" to doing list_action(loaded.view.sharing_toggled())
328 when offers_sharing(loaded) {
329 latched loaded.view.shared_only;
330 }
331
332 act retired_label(loaded) to doing list_action(loaded.view.retired_toggled())
333 when loaded.dormant over 0;
334 }
335
336 region "projects-grid" as Pane {
337 include grid(loaded);
338 }
339
340 region "projects-detail" as Pane {
341 empty "Nothing selected";
342 }
343 }
344 }
345
346 /// Whether a param is on. Absent is off, which is what a URL without it means.
347 fn flag(request: &quasi_router::Request, name: &str) -> bool {
348 matches!(request.carried.get(name), Some("1" | "true"))
349 }
350
351 /// The same action, carrying one flag if it is on.
352 ///
353 /// Absent means off, which is what a URL without it means, so an off flag is
354 /// never written. That is what keeps two addresses for the same view from
355 /// existing.
356 pub(super) fn filtered_by(action: Action, name: &str, on: bool) -> Action {
357 if on {
358 action.carrying(name, "1")
359 } else {
360 action
361 }
362 }
363
364 /// The same action, carrying the filters the screen was under.
365 ///
366 /// Every address on this screen goes through here, including the two writes.
367 /// A filtered view whose controls drop the filters is a view you fall out of by
368 /// using it, and the filters are the only state this screen has.
369 fn filtered(action: Action, view: View) -> Action {
370 let action = filtered_by(action, "shared", view.shared_only);
371 filtered_by(action, "retired", view.show_retired)
372 }
373
374 /// The address of the grid under a given pair of filters.
375 fn list_action(view: View) -> Action {
376 filtered(Action::get("/projects/list"), view)
377 }
378
379 /// The whole screen.
380 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
381 let view = View::of(&request);
382 Ok(screen(&read(state, view)?).into())
383 }
384
385 /// The grid alone, which is what a filter toggle replaces.
386 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
387 let view = View::of(&request);
388 Ok(Response::fragment(
389 "projects-grid",
390 grid(&read(state, view)?),
391 ))
392 }
393
394 /// The project a route was addressed at.
395 ///
396 /// `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the uuid
397 /// crate's. Not worth adding one upstream for two call sites.
398 pub(super) fn project_id(
399 request: &quasi_router::Request,
400 ) -> Result<goingson_core::ProjectId, RouteError> {
401 let raw = request
402 .captures
403 .get("id")
404 .ok_or_else(|| RouteError::not_found("no project id"))?;
405 Ok(goingson_core::ProjectId::from(
406 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a project id"))?,
407 ))
408 }
409
410 /// One project and what this device knows about sharing it, read once.
411 fn showing(state: &AppState, request: &quasi_router::Request) -> Result<Showing, RouteError> {
412 let id = project_id(request)?;
413 let project = state
414 .projects
415 .get_by_id(id, DESKTOP_USER_ID)
416 .map_err(|error| RouteError::internal(error.to_string()))?
417 .ok_or_else(|| RouteError::not_found("no such project"))?;
418
419 // Read only where a picker could be drawn, so a shared project pays nothing
420 // for the directory it would not offer.
421 let groups = if project.group_id.is_none() {
422 known_groups(state)?
423 } else {
424 Vec::new()
425 };
426
427 Ok(Showing {
428 project,
429 groups,
430 view: View::of(request),
431 })
432 }
433
434 /// One project's detail pane.
435 fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
436 let showing = showing(state, &request)?;
437 Ok(Response::fragment(
438 "projects-detail",
439 Node::Region(detail_pane(&showing)),
440 ))
441 }
442
443 /// The statuses a project can be created in.
444 ///
445 /// Two of the four. All four are offered on edit: a project you file as
446 /// finished before it exists is a project the grid hides the moment it is
447 /// made.
448 const NEW_STATUSES: [ProjectStatus; 2] = [ProjectStatus::Active, ProjectStatus::OnHold];
449
450 /// What the create form is answering.
451 ///
452 /// `errors` is what a rejected submission carries back, keyed by field name, and
453 /// `submitted` is what that submission held; on a first showing both are empty.
454 /// A form that refuses must re-offer what was typed, or a name reported as too
455 /// long is thrown away and retyped, which is what [`Field::refilled`] is for.
456 struct Asking<'a> {
457 view: View,
458 errors: &'a [(&'a str, String)],
459 submitted: Option<&'a quasi_router::Params>,
460 }
461
462 impl Asking<'_> {
463 /// A first showing.
464 const fn fresh(view: View) -> Self {
465 Self {
466 view,
467 errors: &[],
468 submitted: None,
469 }
470 }
471 }
472
473 /// Nothing was typed, which is what a form that is not answering a refusal
474 /// refills from.
475 static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new();
476
477 /// What was typed, or nothing.
478 ///
479 /// Empty rather than absent, because [`Field::refilled`] leaves a name it finds
480 /// nothing under alone: refilling from nothing is the same field back, so the
481 /// setting needs no guard.
482 fn typed<'a>(asking: &Asking<'a>) -> &'a quasi_router::Params {
483 asking.submitted.unwrap_or(&NOTHING_TYPED)
484 }
485
486 /// Whether a named question was refused.
487 fn has_error(asking: &Asking, name: &str) -> bool {
488 asking.errors.iter().any(|(field, _)| *field == name)
489 }
490
491 /// Why it was refused, or nothing.
492 fn error_for(asking: &Asking, name: &str) -> String {
493 asking
494 .errors
495 .iter()
496 .find(|(field, _)| *field == name)
497 .map(|(_, message)| message.clone())
498 .unwrap_or_default()
499 }
500
501 declare! {
502 /// The create form, in the pane the detail pane uses.
503 shape form_pane(asking: &Asking) -> Slot;
504
505 region "projects-detail" as Pane {
506 section "New project";
507
508 form doing filtered(Action::post("/projects"), asking.view) {
509 submit "Create project";
510
511 field Text "name" "Project Name" {
512 required;
513 placeholder "My Awesome Project";
514 error error_for(asking, "name") when has_error(asking, "name");
515 refilled typed(asking);
516 }
517
518 field Textarea "description" "Description" {
519 placeholder "What's this project about?";
520 error error_for(asking, "description") when has_error(asking, "description");
521 refilled typed(asking);
522 }
523
524 field Select "project_type" "Type" {
525 for kind in NEW_TYPES.iter() {
526 option Choice::new(kind.db_value(), type_label(kind));
527 }
528 error error_for(asking, "project_type") when has_error(asking, "project_type");
529 refilled typed(asking);
530 }
531
532 field Select "status" "Status" {
533 for status in NEW_STATUSES.iter() {
534 option Choice::new(status.db_value(), status_label(status));
535 }
536 error error_for(asking, "status") when has_error(asking, "status");
537 refilled typed(asking);
538 }
539 }
540 }
541 }
542
543 /// One project, and what this device knows about sharing it.
544 struct Showing {
545 project: Project,
546 /// The groups there are to share into, which is empty on a project that is
547 /// already shared.
548 groups: Vec<synckit_client::store::sync::KnownGroup>,
549 view: View,
550 }
551
552 /// What the pane says the project is.
553 fn kind_and_status(showing: &Showing) -> String {
554 format!(
555 "{} · {}",
556 type_label(&showing.project.project_type),
557 status_label(&showing.project.status)
558 )
559 }
560
561 /// Whether the project is personal and this device knows a group to offer.
562 ///
563 /// Both halves read the directory synckit writes each cycle; see the module
564 /// header for why that had to exist first. The picker is withheld when the
565 /// directory is empty rather than drawn with no options.
566 fn offers_sharing_into(showing: &Showing) -> bool {
567 showing.project.group_id.is_none() && !showing.groups.is_empty()
568 }
569
570 /// Whether the project is already in a group.
571 fn is_shared(showing: &Showing) -> bool {
572 showing.project.group_id.is_some()
573 }
574
575 declare! {
576 /// One project's detail pane.
577 shape detail_pane(showing: &Showing) -> Slot;
578
579 region "projects-detail" as Pane {
580 section &showing.project.name;
581 text kind_and_status(showing);
582 text &showing.project.description when has_description(&showing.project);
583
584 form doing filtered(
585 Action::post("/projects/{showing.project.id}/share"),
586 showing.view
587 ) when offers_sharing_into(showing) {
588 submit "Share into a group";
589
590 field Select "group_id" "Group" {
591 for group in showing.groups.iter() {
592 option Choice::new(group.id.to_string(), &group.name);
593 }
594 required;
595 hint "Everything in the project goes with it: its tasks, events, \
596 milestones and attachments.";
597 }
598 }
599
600 text "Shared into a group. Its tasks, events, milestones and attachments \
601 are shared with it."
602 when is_shared(showing);
603
604 act "Move back to personal"
605 to doing filtered(
606 Action::post("/projects/{showing.project.id}/unshare"),
607 showing.view
608 )
609 when is_shared(showing) {
610 confirm "Move this project and everything in it back to personal scope? \
611 Other members of the group will stop seeing it.";
612 }
613
614 act "Delete project"
615 to doing filtered(
616 Action::post("/projects/{showing.project.id}/delete"),
617 showing.view
618 ) {
619 tone Danger;
620 confirm "Are you sure you want to delete this project? This cannot be undone.";
621 }
622 }
623 }
624
625 /// The create form.
626 fn new(_state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
627 Ok(Response::fragment(
628 "projects-detail",
629 Node::Region(form_pane(&Asking::fresh(View::of(&request)))),
630 ))
631 }
632
633 /// What a submitted project must satisfy: a name, and both lengths.
634 fn validate(name: &str, description: &str) -> Vec<(&'static str, String)> {
635 let mut errors = Vec::new();
636 if name.is_empty() {
637 errors.push(("name", "A project needs a name.".to_owned()));
638 } else if name.chars().count() > 100 {
639 errors.push(("name", "Maximum 100 characters".to_owned()));
640 }
641 if description.chars().count() > 1000 {
642 errors.push(("description", "Maximum 1000 characters".to_owned()));
643 }
644 errors
645 }
646
647 /// Answer a write with the screen it happened on.
648 ///
649 /// Creating and deleting both change the grid and the detail pane, and a
650 /// [`Response`] names one region. Rather than pick one and leave the other
651 /// stale — a pane still offering to delete a project that is gone — the answer
652 /// is the whole screen, re-read under the filters the write carried.
653 ///
654 /// The cost is honest and worth naming: a write reflows the document where a
655 /// filter toggle swaps one region. Decision 7 buys the narrow swap for reads;
656 /// nothing in the vocabulary buys it for a write that lands in two places at
657 /// once. What takes the sting out is decision 7's own slack — a whole-screen
658 /// answer swaps with `hx-swap="outerMorph"`, so focus, scroll and any half-typed
659 /// input survive it.
660 fn wrote(state: &AppState, view: View) -> Result<Response, RouteError> {
661 Ok(screen(&read(state, view)?).into())
662 }
663
664 /// Create a project, or answer with the form saying why not.
665 fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
666 let view = View::of(&request);
667
668 let name = request
669 .payload
670 .get("name")
671 .unwrap_or_default()
672 .trim()
673 .to_owned();
674 let description = request
675 .payload
676 .get("description")
677 .unwrap_or_default()
678 .trim()
679 .to_owned();
680
681 let mut errors = validate(&name, &description);
682
683 // A select offers a fixed set, so an unparseable value did not come from the
684 // form. Refused rather than defaulted: `from_str_or_default` would file a
685 // typo as an `Other` project and say nothing.
686 let project_type = parse_choice::<ProjectType>(&request.payload, "project_type", &mut errors);
687 let status = parse_choice::<ProjectStatus>(&request.payload, "status", &mut errors)
688 .filter(|status| NEW_STATUSES.contains(status));
689 if status.is_none() && !errors.iter().any(|(field, _)| *field == "status") {
690 errors.push(("status", "Not a status a project starts in.".to_owned()));
691 }
692
693 // Every complaint at once. Answering with the first one found is how a form
694 // is fixed one round trip per mistake.
695 let refused = Asking {
696 view,
697 errors: &errors,
698 submitted: Some(&request.payload),
699 };
700 let (Some(project_type), Some(status)) = (project_type, status) else {
701 return Ok(Response::fragment(
702 "projects-detail",
703 Node::Region(form_pane(&refused)),
704 ));
705 };
706 if !errors.is_empty() {
707 return Ok(Response::fragment(
708 "projects-detail",
709 Node::Region(form_pane(&refused)),
710 ));
711 }
712
713 state
714 .projects
715 .create(
716 DESKTOP_USER_ID,
717 NewProject {
718 name,
719 description,
720 project_type,
721 status,
722 },
723 )
724 .map_err(|error| RouteError::internal(error.to_string()))?;
725
726 wrote(state, view)
727 }
728
729 /// Delete a project.
730 ///
731 /// A 404 for a project that is not there rather than a quiet success: the
732 /// repository answers `false`, and a delete that reports done for something it
733 /// never saw is how two panes end up disagreeing about what exists.
734 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
735 let id = project_id(&request)?;
736 let deleted = state
737 .projects
738 .delete(id, DESKTOP_USER_ID)
739 .map_err(|error| RouteError::internal(error.to_string()))?;
740 if !deleted {
741 return Err(RouteError::not_found("no such project"));
742 }
743 wrote(state, View::of(&request))
744 }
745
746 /// The groups this device knows the user belongs to, by name.
747 ///
748 /// `synckit_client::store::directory`, read through goingson's own pool. The
749 /// directory is written by the sync loop each cycle out of an answer it was
750 /// already fetching, which is what makes a group nameable from a synchronous
751 /// handler at all. An empty answer means this device has not synced since groups
752 /// existed, not that the user has none, so a screen offers no picker rather than
753 /// claiming there is nothing to share into.
754 fn known_groups(
755 state: &AppState,
756 ) -> Result<Vec<synckit_client::store::sync::KnownGroup>, RouteError> {
757 let conn = state
758 .db
759 .conn()
760 .map_err(|error| RouteError::internal(error.to_string()))?;
761 synckit_client::store::directory::groups(&conn)
762 .map_err(|error| RouteError::internal(error.to_string()))
763 }
764
765 /// Share a project and its whole subtree into a group.
766 ///
767 /// The write is [`crate::commands::group::share_project_local`], which is
768 /// synchronous because synckit 0.9.0 made its membership check a local read of
769 /// the directory. Before that it was `client.list_groups().await` and this route
770 /// could not have existed.
771 fn share(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
772 let id = project_id(&request)?;
773 let group = request
774 .payload
775 .get("group_id")
776 .unwrap_or_default()
777 .trim()
778 .to_owned();
779
780 // A select offers a fixed set, so a value that is not one of them did not
781 // come from the form. The refusal below covers both that and a group this
782 // device does not know it belongs to, which is the same answer to the user.
783 let view = View::of(&request);
784 if let Err(error) = crate::commands::group::share_project_local(state, &id.to_string(), &group)
785 {
786 return Ok(
787 Response::from(screen(&read(state, view)?)).toast(Tone::Danger, error.to_string())
788 );
789 }
790
791 Ok(Response::from(screen(&read(state, view)?)).toast(
792 Tone::Success,
793 "Shared. Everything in the project went with it.",
794 ))
795 }
796
797 /// Move a project and its whole subtree back to personal scope.
798 ///
799 /// The write is [`crate::commands::group::set_project_scope`] with `None`, which
800 /// is what `unshare_project` does after resolving the project. Called directly
801 /// rather than through the command because the command is `async` for the sake
802 /// of its siblings and this path awaits nothing: the engine's UPDATE triggers
803 /// capture each row with its new scope and the next sync re-routes them.
804 fn unshare(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
805 let id = project_id(&request)?;
806
807 // Resolved first, for `share_project`'s reason: without it a stale id stamps
808 // zero rows and still reports success, and the user believes a project moved
809 // when nothing did.
810 state
811 .projects
812 .get_by_id(id, DESKTOP_USER_ID)
813 .map_err(|error| RouteError::internal(error.to_string()))?
814 .ok_or_else(|| RouteError::not_found("no such project"))?;
815
816 crate::commands::group::set_project_scope(&state.db, DESKTOP_USER_ID, &id.to_string(), None)
817 .map_err(|error| RouteError::internal(error.to_string()))?;
818
819 wrote(state, View::of(&request))
820 }
821
822 /// The projects screen's routes.
823 #[must_use]
824 pub fn routes(router: Router<AppState>) -> Router<AppState> {
825 // `/projects/new` and `/projects/{id}` collide, and the router settles it by
826 // specificity rather than by the order they are written in, so `new` is
827 // tried first wherever it sits here.
828 let router = router
829 .get("/projects", index)
830 .get("/projects/list", list)
831 .get("/projects/new", new)
832 .get("/projects/{id}", detail)
833 .post("/projects", create)
834 .post("/projects/{id}/delete", remove)
835 .post("/projects/{id}/share", share)
836 .post("/projects/{id}/unshare", unshare);
837 dashboard::routes(router)
838 }
839