| 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 |
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 |
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 |
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 |
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 |
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 |
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(¶ms, "shared");
|
| 207 |
|
- |
let show_retired = flag(¶ms, "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 |
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(¶ms, "shared"), flag(¶ms, "retired"))?.into())
|
| 242 |
295 |
|
}
|
| 243 |
296 |
|
|
| 244 |
297 |
|
/// The grid alone, which is what a filter toggle replaces.
|
| 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(¶ms)?;
|
|
319 |
+ |
let shared_only = flag(¶ms, "shared");
|
|
320 |
+ |
let show_retired = flag(¶ms, "retired");
|
| 260 |
321 |
|
|
| 261 |
322 |
|
let project = state
|
| 262 |
323 |
|
.projects
|
| 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 |
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(¶ms, "shared"), flag(¶ms, "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(¶ms, "shared");
|
|
491 |
+ |
let show_retired = flag(¶ms, "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>(¶ms, "project_type", &mut errors);
|
|
506 |
+ |
let status = parse_choice::<ProjectStatus>(¶ms, "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(¶ms)?;
|
|
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(¶ms, "shared"), flag(¶ms, "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 |
|
}
|