Skip to main content

max / goingson

Register the projects screen's two writes "New project" and "Delete project" were described controls calling routes that were never registered, so the screen said it could do two things it could not. The contacts port set the standard; this brings projects up to it. GET /projects/new, POST /projects, POST /projects/:id/delete. The filters are the only state this screen has and they live in the address, so every action now carries them: the detail address, the create form and both writes. A control that dropped them would be a filtered view you fall out of by using it. A write answers with the whole screen rather than a fragment. Creating or deleting changes the grid and the detail pane at once and a Response names one region, so the alternative was a pane still offering to delete something that is gone. Decision 7's morph swap is what keeps that from being destructive. Fourth vocabulary finding, and the first the renderer could already have honoured: a form that refuses cannot re-offer what was typed. Field carries no value by design, makeover-webview's Filling models one, and quasi-webview hardcodes Absent because nothing can say otherwise. Left lossy and asserted by a test rather than patched here; filed as makeover-layout 1c4a66a4.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 02:46 UTC
Signed with PGP, not checked
Commit: f57a259086a5969a68a56069d8859a1d7c5da26c
Parent: 21f9115
2 files changed, +557 insertions, -27 deletions
@@ -14,24 +14,38 @@
14 14 //!
15 15 //! # The shape
16 16 //!
17 - //! Three routes, which is the whole screen:
17 + //! Five routes, which is the whole screen:
18 18 //!
19 19 //! - `GET /projects` — the document.
20 20 //! - `GET /projects/list` — the grid alone, which is what the two filters swap.
21 21 //! - `GET /projects/{id}` — the detail pane.
22 + //! - `GET /projects/new` — the create form, in the same pane.
23 + //! - `POST /projects` — create.
24 + //! - `POST /projects/{id}/delete` — delete.
25 + //!
26 + //! The two writes were described but not registered when this screen first
27 + //! landed, so "New project" and "Delete project" were controls that called
28 + //! nothing. The contacts port set the standard they are brought up to here: a
29 + //! described control reaches a handler, or the screen is lying about what it
30 + //! does.
22 31 //!
23 32 //! The filters are routes rather than local state, per decision 2. `projects.js`
24 33 //! holds `showSharedOnly` and `showRetired` in module scope and re-renders from a
25 34 //! cached list; here they are query params, so the same screen is reachable by
26 35 //! address and no state has to survive between two clicks.
36 + //!
37 + //! That has a consequence the read-only routes did not have to face: every
38 + //! action a filtered screen offers has to carry the filters it was offered
39 + //! under, or acting resets the view. [`filtered`] is that, applied to the
40 + //! detail address, the create form and both writes.
27 41
28 42 // Handlers take their params by value because `quasi_router::Handler` is a
29 43 // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
30 44 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
31 45 #![allow(clippy::needless_pass_by_value)]
32 46
33 - use goingson_core::{Project, ProjectStatus, ProjectType};
34 - use quasi_router::screen::{Act, Row, Tag};
47 + use goingson_core::{DbValue as _, NewProject, Project, ProjectStatus, ProjectType};
48 + use quasi_router::screen::{Act, Choice, Field, Row, Tag};
35 49 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
36 50
37 51 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -64,6 +78,22 @@
64 78 }
65 79 }
66 80
81 + /// The types a project can be created as.
82 + ///
83 + /// `ProjectType` has seven members and `projects.js:PROJECT_TYPES` offers six:
84 + /// it has never offered `Painting`, which is reachable only by writing the row
85 + /// some other way. Six here too, so the described form offers what the shipped
86 + /// one does; the drift is the JS list's to answer for and is recorded rather
87 + /// than silently corrected by the port.
88 + const NEW_TYPES: [ProjectType; 6] = [
89 + ProjectType::SideProject,
90 + ProjectType::Job,
91 + ProjectType::Company,
92 + ProjectType::Essay,
93 + ProjectType::Article,
94 + ProjectType::Other,
95 + ];
96 +
67 97 /// The display name of a project status.
68 98 fn status_label(status: &ProjectStatus) -> &'static str {
69 99 match status {
@@ -114,7 +144,7 @@
114 144 /// description goes into `secondary` as text. A `Region::Bespoke` is the
115 145 /// vocabulary's own answer for a place the app fills itself, and it is the
116 146 /// shape this wants if it turns out to matter. Filed as `25822137`.
117 - fn row_for(project: &Project, current: bool) -> Row {
147 + fn row_for(project: &Project, current: bool, shared_only: bool, show_retired: bool) -> Row {
118 148 let mut row = Row::new(&project.name)
119 149 .token(Tag::badge(type_label(&project.project_type)))
120 150 .token(Tag::badge(status_label(&project.status)).tone(status_tone(&project.status)));
@@ -125,7 +155,13 @@
125 155 }
126 156
127 157 row.current = current;
128 - row.activate = Some(Action::get(format!("/projects/{}", project.id)));
158 + // Filtered, so the pane knows which view it was opened from and the delete
159 + // it offers can answer with that view rather than the unfiltered one.
160 + row.activate = Some(filtered(
161 + Action::get(format!("/projects/{}", project.id)),
162 + shared_only,
163 + show_retired,
164 + ));
129 165 row
130 166 }
131 167
@@ -164,9 +200,9 @@
164 200 live
165 201 };
166 202
167 - Ok(Node::list(
168 - shown.into_iter().map(|project| row_for(project, false)),
169 - ))
203 + Ok(Node::list(shown.into_iter().map(|project| {
204 + row_for(project, false, shared_only, show_retired)
205 + })))
170 206 }
171 207
172 208 /// How many projects are shared into a group, and how many are retired.
@@ -189,9 +225,12 @@
189 225 matches!(params.get(name), Some("1" | "true"))
190 226 }
191 227
192 - /// The address of the grid under a given pair of filters.
193 - fn list_action(shared_only: bool, show_retired: bool) -> Action {
194 - let mut action = Action::get("/projects/list");
228 + /// The same action, carrying the filters the screen was under.
229 + ///
230 + /// Every address on this screen goes through here, including the two writes.
231 + /// A filtered view whose controls drop the filters is a view you fall out of by
232 + /// using it, and the filters are the only state this screen has.
233 + fn filtered(mut action: Action, shared_only: bool, show_retired: bool) -> Action {
195 234 if shared_only {
196 235 action = action.with("shared", "1");
197 236 }
@@ -201,15 +240,25 @@
201 240 action
202 241 }
203 242
204 - /// The whole screen.
205 - fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
206 - let shared_only = flag(&params, "shared");
207 - let show_retired = flag(&params, "retired");
243 + /// The address of the grid under a given pair of filters.
244 + fn list_action(shared_only: bool, show_retired: bool) -> Action {
245 + filtered(Action::get("/projects/list"), shared_only, show_retired)
246 + }
247 +
248 + /// The whole screen under a given pair of filters.
249 + ///
250 + /// Built here rather than inside the route because a write answers with it
251 + /// too: creating or deleting changes the grid and the detail pane at once, and
252 + /// a [`Response`] names one region. See [`created`].
253 + fn screen(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Screen, RouteError> {
208 254 let (shared, dormant) = counts(state)?;
209 255
210 256 let mut band = Slot::new("projects-band", RegionKind::Band)
211 257 .with(Node::page("Projects"))
212 - .with(Node::act("New project", Action::get("/projects/new")));
258 + .with(Node::act(
259 + "New project",
260 + filtered(Action::get("/projects/new"), shared_only, show_retired),
261 + ));
213 262
214 263 // The filter surfaces only when sharing is in play, which is the rule
215 264 // `projects.js` already applies to the same control.
@@ -237,8 +286,12 @@
237 286 shared_only,
238 287 show_retired,
239 288 )?))
240 - .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
241 - .into())
289 + .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected"))))
290 + }
291 +
292 + /// The whole screen.
293 + fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
294 + Ok(screen(state, flag(&params, "shared"), flag(&params, "retired"))?.into())
242 295 }
243 296
244 297 /// The grid alone, which is what a filter toggle replaces.
@@ -247,16 +300,24 @@
247 300 Ok(Response::fragment("projects-grid", node))
248 301 }
249 302
250 - /// One project's detail pane.
251 - fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
252 - let id = params
303 + /// The project a route was addressed at.
304 + ///
305 + /// `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the uuid
306 + /// crate's. Not worth adding one upstream for two call sites.
307 + fn project_id(params: &quasi_router::Params) -> Result<goingson_core::ProjectId, RouteError> {
308 + let raw = params
253 309 .get("id")
254 310 .ok_or_else(|| RouteError::not_found("no project id"))?;
255 - // `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the
256 - // uuid crate's. Not worth adding one upstream for a single call site.
257 - let id = goingson_core::ProjectId::from(
258 - uuid::Uuid::parse_str(id).map_err(|_| RouteError::not_found("not a project id"))?,
259 - );
311 + Ok(goingson_core::ProjectId::from(
312 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a project id"))?,
313 + ))
314 + }
315 +
316 + /// One project's detail pane.
317 + fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
318 + let id = project_id(&params)?;
319 + let shared_only = flag(&params, "shared");
320 + let show_retired = flag(&params, "retired");
260 321
261 322 let project = state
262 323 .projects
@@ -279,7 +340,11 @@
279 340 slot = slot.with(Node::Act(
280 341 Act::new(
281 342 "Delete project",
282 - Action::post(format!("/projects/{}/delete", project.id)),
343 + filtered(
344 + Action::post(format!("/projects/{}/delete", project.id)),
345 + shared_only,
346 + show_retired,
347 + ),
283 348 )
284 349 .tone(makeover_layout::Tone::Danger),
285 350 ));
@@ -287,11 +352,237 @@
287 352 Ok(Response::fragment("projects-detail", Node::Region(slot)))
288 353 }
289 354
355 + /// The statuses a project can be created in.
356 + ///
357 + /// Two of the four. `projects.js` slices its status list to the same two on
358 + /// create and offers all four on edit, and the reason survives the port: a
359 + /// project you file as finished before it exists is a project the grid hides
360 + /// the moment it is made.
361 + const NEW_STATUSES: [ProjectStatus; 2] = [ProjectStatus::Active, ProjectStatus::OnHold];
362 +
363 + /// The questions the create form asks.
364 + ///
365 + /// The four `projects.js` asks, in its order. `errors` is what a rejected
366 + /// submission carries back, keyed by field name; on a first showing it is
367 + /// empty.
368 + ///
369 + /// # The finding this route ran into
370 + ///
371 + /// **A form that refuses cannot re-offer what was typed.** [`Field`] carries no
372 + /// value and says in its own docs that it is not going to, on the grounds that a
373 + /// value is renderer state. That is right for a form being shown, and it leaves
374 + /// the rejected case with nowhere to put the name the user typed: this answer
375 + /// names what is wrong and hands back an empty box to fix it in. `projects.js`
376 + /// validates in the browser and never loses a keystroke.
377 + ///
378 + /// The workaround is asserted by a test rather than left to be noticed, and it
379 + /// is filed as makeover-layout `1c4a66a4`. Not patched here: the
380 + /// admission test says a description that needs a new fact asks the vocabulary
381 + /// for it.
382 + fn form_fields(errors: &[(&str, String)]) -> Vec<Field> {
383 + let error_for = |name: &str| {
384 + errors
385 + .iter()
386 + .find(|(field, _)| *field == name)
387 + .map(|(_, message)| message.clone())
388 + };
389 + let apply = |field: Field, name: &str| match error_for(name) {
390 + Some(message) => field.error(message),
391 + None => field,
392 + };
393 +
394 + let mut name = Field::new(makeover_layout::FieldKind::Text, "name", "Project Name").required();
395 + name.placeholder = Some("My Awesome Project".to_owned());
396 +
397 + let mut description = Field::new(
398 + makeover_layout::FieldKind::Textarea,
399 + "description",
400 + "Description",
401 + );
402 + description.placeholder = Some("What's this project about?".to_owned());
403 +
404 + vec![
405 + apply(name, "name"),
406 + apply(description, "description"),
407 + apply(
408 + Field::select(
409 + "project_type",
410 + "Type",
411 + NEW_TYPES
412 + .iter()
413 + .map(|kind| Choice::new(kind.db_value(), type_label(kind)))
414 + .collect(),
415 + ),
416 + "project_type",
417 + ),
418 + apply(
419 + Field::select(
420 + "status",
421 + "Status",
422 + NEW_STATUSES
423 + .iter()
424 + .map(|status| Choice::new(status.db_value(), status_label(status)))
425 + .collect(),
426 + ),
427 + "status",
428 + ),
429 + ]
430 + }
431 +
432 + /// The create form, in the pane the detail pane uses.
433 + fn form_pane(shared_only: bool, show_retired: bool, errors: &[(&str, String)]) -> Node {
434 + Node::Region(
435 + Slot::new("projects-detail", RegionKind::Pane)
436 + .with(Node::section("New project"))
437 + .with(Node::Form {
438 + action: filtered(Action::post("/projects"), shared_only, show_retired),
439 + submit: "Create project".to_owned(),
440 + fields: form_fields(errors),
441 + }),
442 + )
443 + }
444 +
445 + /// The create form.
446 + fn new(_state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
447 + Ok(Response::fragment(
448 + "projects-detail",
449 + form_pane(flag(&params, "shared"), flag(&params, "retired"), &[]),
450 + ))
451 + }
452 +
453 + /// What `projects.js` refuses, refused here.
454 + ///
455 + /// The lengths are its two `validate` closures. Repeated rather than shared for
456 + /// the same reason [`retired`] is: the JS is what ships today, and this is the
457 + /// copy that survives when it goes.
458 + fn validate(name: &str, description: &str) -> Vec<(&'static str, String)> {
459 + let mut errors = Vec::new();
460 + if name.is_empty() {
461 + errors.push(("name", "A project needs a name.".to_owned()));
462 + } else if name.chars().count() > 100 {
463 + errors.push(("name", "Maximum 100 characters".to_owned()));
464 + }
465 + if description.chars().count() > 1000 {
466 + errors.push(("description", "Maximum 1000 characters".to_owned()));
467 + }
468 + errors
469 + }
470 +
471 + /// Answer a write with the screen it happened on.
472 + ///
473 + /// Creating and deleting both change the grid and the detail pane, and a
474 + /// [`Response`] names one region. Rather than pick one and leave the other
475 + /// stale — a pane still offering to delete a project that is gone — the answer
476 + /// is the whole screen, re-read under the filters the write carried.
477 + ///
478 + /// The cost is honest and worth naming: a write reflows the document where a
479 + /// filter toggle swaps one region. Decision 7 buys the narrow swap for reads;
480 + /// nothing in the vocabulary buys it for a write that lands in two places at
481 + /// once. What takes the sting out is decision 7's own slack — a whole-screen
482 + /// answer swaps with `hx-swap="morph"`, so focus, scroll and any half-typed
483 + /// input survive it.
484 + fn wrote(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Response, RouteError> {
485 + Ok(screen(state, shared_only, show_retired)?.into())
486 + }
487 +
488 + /// Create a project, or answer with the form saying why not.
489 + fn create(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
490 + let shared_only = flag(&params, "shared");
491 + let show_retired = flag(&params, "retired");
492 +
493 + let name = params.get("name").unwrap_or_default().trim().to_owned();
494 + let description = params
495 + .get("description")
496 + .unwrap_or_default()
497 + .trim()
498 + .to_owned();
499 +
500 + let mut errors = validate(&name, &description);
501 +
502 + // A select offers a fixed set, so an unparseable value did not come from the
503 + // form. Refused rather than defaulted: `from_str_or_default` would file a
504 + // typo as an `Other` project and say nothing.
505 + let project_type = parse_choice::<ProjectType>(&params, "project_type", &mut errors);
506 + let status = parse_choice::<ProjectStatus>(&params, "status", &mut errors)
507 + .filter(|status| NEW_STATUSES.contains(status));
508 + if status.is_none() && !errors.iter().any(|(field, _)| *field == "status") {
509 + errors.push(("status", "Not a status a project starts in.".to_owned()));
510 + }
511 +
512 + // Every complaint at once. Answering with the first one found is how a form
513 + // is fixed one round trip per mistake.
514 + let (Some(project_type), Some(status)) = (project_type, status) else {
515 + return Ok(Response::fragment(
516 + "projects-detail",
517 + form_pane(shared_only, show_retired, &errors),
518 + ));
519 + };
520 + if !errors.is_empty() {
521 + return Ok(Response::fragment(
522 + "projects-detail",
523 + form_pane(shared_only, show_retired, &errors),
524 + ));
525 + }
526 +
527 + state
528 + .projects
529 + .create(
530 + DESKTOP_USER_ID,
531 + NewProject {
532 + name,
533 + description,
534 + project_type,
535 + status,
536 + },
537 + )
538 + .map_err(|error| RouteError::internal(error.to_string()))?;
539 +
540 + wrote(state, shared_only, show_retired)
541 + }
542 +
543 + /// One choice, parsed strictly, adding its own complaint if it will not.
544 + fn parse_choice<T: std::str::FromStr>(
545 + params: &quasi_router::Params,
546 + name: &'static str,
547 + errors: &mut Vec<(&'static str, String)>,
548 + ) -> Option<T> {
549 + match params.get(name).unwrap_or_default().parse() {
550 + Ok(value) => Some(value),
551 + Err(_) => {
552 + errors.push((name, "Not one of the options offered.".to_owned()));
553 + None
554 + }
555 + }
556 + }
557 +
558 + /// Delete a project.
559 + ///
560 + /// A 404 for a project that is not there rather than a quiet success: the
561 + /// repository answers `false`, and a delete that reports done for something it
562 + /// never saw is how two panes end up disagreeing about what exists.
563 + fn remove(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
564 + let id = project_id(&params)?;
565 + let deleted = state
566 + .projects
567 + .delete(id, DESKTOP_USER_ID)
568 + .map_err(|error| RouteError::internal(error.to_string()))?;
569 + if !deleted {
570 + return Err(RouteError::not_found("no such project"));
571 + }
572 + wrote(state, flag(&params, "shared"), flag(&params, "retired"))
573 + }
574 +
290 575 /// The projects screen's routes.
291 576 #[must_use]
292 577 pub fn routes(router: Router<AppState>) -> Router<AppState> {
578 + // `/projects/new` and `/projects/:id` collide, and the router settles it by
579 + // specificity rather than by the order they are written in, so `new` is
580 + // tried first wherever it sits here.
293 581 router
294 582 .get("/projects", index)
295 583 .get("/projects/list", list)
584 + .get("/projects/new", new)
296 585 .get("/projects/:id", detail)
586 + .post("/projects", create)
587 + .post("/projects/:id/delete", remove)
297 588 }
@@ -58,6 +58,32 @@
58 58 .expect("the route answers")
59 59 }
60 60
61 + /// A write, which is the shape every action on this screen arrives in.
62 + fn post(state: &AppState, path: &str, params: Params) -> Response {
63 + router()
64 + .handle(state, Method::Post, path, params)
65 + .expect("the route answers")
66 + }
67 +
68 + /// The four the create form asks for.
69 + ///
70 + /// Every value spelled out at each call site rather than overridden on top of a
71 + /// valid default: `Params::get` answers with the *first* value under a name, so
72 + /// a `.with("status", ...)` after a valid one is silently ignored and the test
73 + /// passes for the wrong reason.
74 + fn a_project(name: &str, project_type: &str, status: &str) -> Params {
75 + Params::new()
76 + .with("name", name)
77 + .with("description", "")
78 + .with("project_type", project_type)
79 + .with("status", status)
80 + }
81 +
82 + /// A valid submission.
83 + fn valid(name: &str) -> Params {
84 + a_project(name, "SideProject", "Active")
85 + }
86 +
61 87 #[tokio::test]
62 88 async fn an_empty_database_says_so_rather_than_rendering_nothing() {
63 89 let state = state().await;
@@ -231,6 +257,219 @@
231 257 assert!(html.contains("&lt;script&gt;"));
232 258 }
233 259
260 + #[tokio::test]
261 + async fn the_two_described_writes_reach_a_handler() {
262 + // The standard the contacts port set, and what this screen was short of:
263 + // "New project" and "Delete project" were described controls calling routes
264 + // that were never registered, so the screen said it could do two things it
265 + // could not.
266 + let state = state().await;
267 + let project = add(&state, "Mine", ProjectStatus::Active);
268 +
269 + let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
270 + panic!("a screen");
271 + };
272 + let html = quasi_webview::Webview::new().screen(&screen);
273 + assert!(html.contains("hx-get=\"/projects/new\""));
274 +
275 + let Response::Fragment { node, .. } =
276 + answer(&state, &format!("/projects/{}", project.id), Params::new())
277 + else {
278 + panic!("a fragment");
279 + };
280 + let html = quasi_webview::Webview::new().fragment(&node);
281 + assert!(html.contains(&format!("hx-post=\"/projects/{}/delete\"", project.id)));
282 +
283 + // Both answer rather than 404, which is the whole claim.
284 + answer(&state, "/projects/new", Params::new());
285 + post(
286 + &state,
287 + &format!("/projects/{}/delete", project.id),
288 + Params::new(),
289 + );
290 + }
291 +
292 + #[tokio::test]
293 + async fn the_create_form_asks_what_the_modal_asks() {
294 + let state = state().await;
295 + let Response::Fragment { node, .. } = answer(&state, "/projects/new", Params::new()) else {
296 + panic!("a fragment");
297 + };
298 + let html = quasi_webview::Webview::new().fragment(&node);
299 +
300 + assert!(html.contains("hx-post=\"/projects\""));
301 + for field in ["name", "description", "project_type", "status"] {
302 + assert!(
303 + html.contains(&format!("name=\"{field}\"")),
304 + "{field} is asked"
305 + );
306 + }
307 + // A project cannot be created finished, which is `projects.js` slicing its
308 + // status list to the first two on create.
309 + assert!(html.contains("value=\"OnHold\""));
310 + assert!(!html.contains("value=\"Archived\""));
311 + }
312 +
313 + #[tokio::test]
314 + async fn creating_a_project_puts_it_in_the_grid() {
315 + let state = state().await;
316 + let Response::Screen(screen) = post(&state, "/projects", valid("Made here")) else {
317 + panic!("a write answers with the whole screen");
318 + };
319 + let html = quasi_webview::Webview::new().screen(&screen);
320 + assert!(html.contains("Made here"));
321 + // The pane the form was in goes back to saying nothing is selected, which is
322 + // the half a grid-only fragment would have left stale.
323 + assert!(html.contains("Nothing selected"));
324 + }
325 +
326 + #[tokio::test]
327 + async fn a_rejected_form_names_what_is_wrong_and_loses_what_was_typed() {
328 + // The fourth finding, asserted rather than left to be noticed. `Field`
329 + // carries no value, so the form comes back empty: the too-long name is
330 + // reported and then thrown away, and it is retyped to be shortened.
331 + // makeover-layout `1c4a66a4`.
332 + let state = state().await;
333 + let long = "x".repeat(101);
334 +
335 + let Response::Fragment { node, .. } = post(&state, "/projects", valid(&long)) else {
336 + panic!("a refusal answers with the form, not the screen");
337 + };
338 + let html = quasi_webview::Webview::new().fragment(&node);
339 +
340 + assert!(html.contains("Maximum 100 characters"));
341 + assert!(html.contains("aria-invalid=\"true\""));
342 + // The workaround. When this line has to change, the finding is closed.
343 + assert!(!html.contains(&long));
344 +
345 + // And nothing was written.
346 + assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
347 + }
348 +
349 + #[tokio::test]
350 + async fn a_nameless_project_is_refused_rather_than_created_blank() {
351 + let state = state().await;
352 + let Response::Fragment { .. } = post(&state, "/projects", valid(" ")) else {
353 + panic!("a refusal");
354 + };
355 + assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
356 + }
357 +
358 + #[tokio::test]
359 + async fn a_value_no_option_offered_is_refused_rather_than_defaulted() {
360 + // `from_str_or_default` is what the rest of goingson reads enums with, and
361 + // it would file this as an `Other` project and say nothing.
362 + let state = state().await;
363 + let params = a_project("Mine", "Sculpture", "Active");
364 +
365 + let Response::Fragment { node, .. } = post(&state, "/projects", params) else {
366 + panic!("a refusal");
367 + };
368 + let html = quasi_webview::Webview::new().fragment(&node);
369 + assert!(html.contains("Not one of the options offered."));
370 + assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
371 +
372 + // A status that parses but is not on offer is refused on the same grounds.
373 + let params = a_project("Mine", "SideProject", "Archived");
374 + let Response::Fragment { node, .. } = post(&state, "/projects", params) else {
375 + panic!("a refusal");
376 + };
377 + let html = quasi_webview::Webview::new().fragment(&node);
378 + assert!(html.contains("Not a status a project starts in."));
379 + assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
380 + }
381 +
382 + #[tokio::test]
383 + async fn deleting_a_project_takes_it_out_of_the_grid_and_empties_the_pane() {
384 + let state = state().await;
385 + let project = add(&state, "Going", ProjectStatus::Active);
386 + add(&state, "Staying", ProjectStatus::Active);
387 +
388 + let Response::Screen(screen) = post(
389 + &state,
390 + &format!("/projects/{}/delete", project.id),
391 + Params::new(),
392 + ) else {
393 + panic!("a write answers with the whole screen");
394 + };
395 + let html = quasi_webview::Webview::new().screen(&screen);
396 +
397 + assert!(!html.contains("Going"));
398 + assert!(html.contains("Staying"));
399 + // The reason a write does not answer with the grid alone: the pane would
400 + // still be offering to delete something that is gone.
401 + assert!(html.contains("Nothing selected"));
402 + assert!(!html.contains(&format!("/projects/{}/delete", project.id)));
403 + }
404 +
405 + #[tokio::test]
406 + async fn deleting_something_that_is_not_there_is_a_not_found() {
407 + let state = state().await;
408 + let error = router()
409 + .handle(
410 + &state,
411 + Method::Post,
412 + &format!("/projects/{}/delete", uuid::Uuid::nil()),
413 + Params::new(),
414 + )
415 + .expect_err("no such project");
416 + assert_eq!(error.class.http_status(), 404);
417 + }
418 +
419 + #[tokio::test]
420 + async fn an_action_carries_the_filters_it_was_offered_under() {
421 + // The filters are the only state this screen has, and they live in the
422 + // address. A control that dropped them would be a filtered view you fall
423 + // out of by using it.
424 + let state = state().await;
425 + let project = add(&state, "Finished", ProjectStatus::Completed);
426 + let retired = Params::new().with("retired", "1");
427 +
428 + let Response::Screen(screen) = answer(&state, "/projects", retired.clone()) else {
429 + panic!("a screen");
430 + };
431 + let html = quasi_webview::Webview::new().screen(&screen);
432 + // `hx-vals`, not a hand-built query string: htmx folds it into the query
433 + // for a GET and into the body for a POST, so nothing here concatenates a
434 + // `?`. The assertion is on the pair, adjacent, so a control that kept the
435 + // address and dropped the filters still fails.
436 + let vals = "hx-vals=\"{&quot;retired&quot;:&quot;1&quot;}\"";
437 + assert!(html.contains(&format!("hx-get=\"/projects/new\" {vals}")));
438 + assert!(html.contains(&format!("hx-get=\"/projects/{}\" {vals}", project.id)));
439 +
440 + let Response::Fragment { node, .. } =
441 + answer(&state, &format!("/projects/{}", project.id), retired)
442 + else {
443 + panic!("a fragment");
444 + };
445 + let html = quasi_webview::Webview::new().fragment(&node);
446 + assert!(html.contains(&format!(
447 + "hx-post=\"/projects/{}/delete\" {vals}",
448 + project.id
449 + )));
450 + }
451 +
452 + #[tokio::test]
453 + async fn a_write_answers_under_the_filters_it_carried() {
454 + let state = state().await;
455 + add(&state, "Finished", ProjectStatus::Completed);
456 + let doomed = add(&state, "Going", ProjectStatus::Completed);
457 +
458 + let Response::Screen(screen) = post(
459 + &state,
460 + &format!("/projects/{}/delete", doomed.id),
461 + Params::new().with("retired", "1"),
462 + ) else {
463 + panic!("a screen");
464 + };
465 + let html = quasi_webview::Webview::new().screen(&screen);
466 +
467 + // Still showing retired projects afterwards. Dropping the filter here is
468 + // how a delete reads as having emptied the grid.
469 + assert!(html.contains("Finished"));
470 + assert!(html.contains("Hide completed and archived"));
471 + }
472 +
234 473 #[tokio::test]
235 474 async fn the_protocol_serves_the_screen_from_its_own_scheme() {
236 475 let state = state().await;