| 23 |
23 |
|
//! # The shape
|
| 24 |
24 |
|
//!
|
| 25 |
25 |
|
//! - `GET /projects/{id}/dashboard` — the whole thing.
|
|
26 |
+ |
//! - `GET /projects/{id}/milestones/new` — the add form.
|
|
27 |
+ |
//! - `POST /projects/{id}/milestones` — create one.
|
|
28 |
+ |
//! - `GET /projects/{id}/milestones/{milestone}/edit` — the edit form.
|
|
29 |
+ |
//! - `POST /projects/{id}/milestones/{milestone}` — save one.
|
| 26 |
30 |
|
//! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`.
|
| 27 |
31 |
|
//! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one.
|
| 28 |
32 |
|
//! - `POST /projects/{id}/attachments` — attach the picked file.
|
| 44 |
48 |
|
use goingson_core::{
|
| 45 |
49 |
|
Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
|
| 46 |
50 |
|
};
|
| 47 |
|
- |
use quasi_router::screen::{Act, Field, Meter, Row, Tag};
|
|
51 |
+ |
use quasi_router::screen::{Act, Choice, Field, Meter, Row, Tag};
|
| 48 |
52 |
|
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
| 49 |
53 |
|
|
| 50 |
54 |
|
use super::{filtered_by, project_id, status_tone, type_label};
|
| 281 |
285 |
|
let up = Act::new("Move up", move_by(-1));
|
| 282 |
286 |
|
let down = Act::new("Move down", move_by(1));
|
| 283 |
287 |
|
|
| 284 |
|
- |
row.act(if at == 0 { up.disabled() } else { up })
|
| 285 |
|
- |
.act(if at + 1 == of { down.disabled() } else { down })
|
| 286 |
|
- |
.act(
|
| 287 |
|
- |
Act::new(
|
| 288 |
|
- |
"Delete",
|
| 289 |
|
- |
Action::post(format!(
|
| 290 |
|
- |
"/projects/{project}/milestones/{}/delete",
|
| 291 |
|
- |
milestone.id
|
| 292 |
|
- |
)),
|
| 293 |
|
- |
)
|
| 294 |
|
- |
.tone(makeover_layout::Tone::Danger),
|
|
288 |
+ |
row.act(Act::new(
|
|
289 |
+ |
"Edit",
|
|
290 |
+ |
Action::get(format!(
|
|
291 |
+ |
"/projects/{project}/milestones/{}/edit",
|
|
292 |
+ |
milestone.id
|
|
293 |
+ |
)),
|
|
294 |
+ |
))
|
|
295 |
+ |
.act(if at == 0 { up.disabled() } else { up })
|
|
296 |
+ |
.act(if at + 1 == of { down.disabled() } else { down })
|
|
297 |
+ |
.act(
|
|
298 |
+ |
Act::new(
|
|
299 |
+ |
"Delete",
|
|
300 |
+ |
Action::post(format!(
|
|
301 |
+ |
"/projects/{project}/milestones/{}/delete",
|
|
302 |
+ |
milestone.id
|
|
303 |
+ |
)),
|
| 295 |
304 |
|
)
|
|
305 |
+ |
.tone(makeover_layout::Tone::Danger),
|
|
306 |
+ |
)
|
| 296 |
307 |
|
}
|
| 297 |
308 |
|
|
| 298 |
309 |
|
/// The milestones section.
|
| 299 |
310 |
|
///
|
| 300 |
|
- |
/// Edit is absent for the reason it is absent on the task overview: it opens a
|
| 301 |
|
- |
/// modal form, and an edit form is a screen of its own rather than a control on
|
| 302 |
|
- |
/// the screen it edits. Add is absent for the same reason.
|
|
311 |
+ |
/// Add and edit are addresses rather than modals, on the shape the task
|
|
312 |
+ |
/// overview established: an edit form is a screen of its own rather than a
|
|
313 |
+ |
/// control on the screen it edits. That is why they were absent when this
|
|
314 |
+ |
/// screen first landed, and it is the same reason they are here now — the
|
|
315 |
+ |
/// forms exist as `/projects/{id}/milestones/new` and
|
|
316 |
+ |
/// `/projects/{id}/milestones/{milestone}/edit`, so the controls are links.
|
| 303 |
317 |
|
fn milestones(
|
| 304 |
318 |
|
project: ProjectId,
|
| 305 |
319 |
|
all: &[Milestone],
|
| 310 |
324 |
|
.iter()
|
| 311 |
325 |
|
.partition(|milestone| milestone.status != MilestoneStatus::Completed);
|
| 312 |
326 |
|
|
|
327 |
+ |
let add = Node::act(
|
|
328 |
+ |
"New milestone".to_owned(),
|
|
329 |
+ |
Action::get(format!("/projects/{project}/milestones/new")),
|
|
330 |
+ |
);
|
|
331 |
+ |
|
| 313 |
332 |
|
let mut out = vec![Node::section("Milestones")];
|
| 314 |
333 |
|
if all.is_empty() {
|
| 315 |
334 |
|
out.push(Node::empty("No milestones yet"));
|
|
335 |
+ |
out.push(add);
|
| 316 |
336 |
|
return out;
|
| 317 |
337 |
|
}
|
|
338 |
+ |
out.push(add);
|
| 318 |
339 |
|
|
| 319 |
340 |
|
let of = open.len();
|
| 320 |
341 |
|
out.push(Node::list(open.iter().enumerate().map(
|
| 587 |
608 |
|
))
|
| 588 |
609 |
|
}
|
| 589 |
610 |
|
|
|
611 |
+ |
/// The milestone form, for adding and for editing.
|
|
612 |
+ |
///
|
|
613 |
+ |
/// One function for both, because the two differ by exactly one field. The JS
|
|
614 |
+ |
/// draws the same modal twice (`openNewMilestone` and `openEditMilestone` in
|
|
615 |
+ |
/// `projects.js`) and differs the same way: status is on the edit form only,
|
|
616 |
+ |
/// since a milestone nobody has created yet cannot already be completed.
|
|
617 |
+ |
///
|
|
618 |
+ |
/// `existing` is the milestone being edited, or `None` for a new one.
|
|
619 |
+ |
/// `submitted` carries a refused submission back so the user's typing survives
|
|
620 |
+ |
/// being told what was wrong with it, which is the shape `edit_fields` in
|
|
621 |
+ |
/// `quasi::tasks` established.
|
|
622 |
+ |
fn milestone_fields(
|
|
623 |
+ |
existing: Option<&Milestone>,
|
|
624 |
+ |
errors: &[(&str, String)],
|
|
625 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
626 |
+ |
) -> Vec<Field> {
|
|
627 |
+ |
let error_for = |name: &str| {
|
|
628 |
+ |
errors
|
|
629 |
+ |
.iter()
|
|
630 |
+ |
.find(|(field, _)| *field == name)
|
|
631 |
+ |
.map(|(_, message)| message.clone())
|
|
632 |
+ |
};
|
|
633 |
+ |
let apply = |field: Field, name: &str| match error_for(name) {
|
|
634 |
+ |
Some(message) => field.error(message),
|
|
635 |
+ |
None => field,
|
|
636 |
+ |
};
|
|
637 |
+ |
// A refused submission wins over the stored value, and the stored value
|
|
638 |
+ |
// over nothing. Reading the submission first is what stops a validation
|
|
639 |
+ |
// error from handing back the row as it was and losing the edit.
|
|
640 |
+ |
let submitted_value = |name: &str| {
|
|
641 |
+ |
submitted.and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
|
|
642 |
+ |
};
|
|
643 |
+ |
let value_of = |name: &str, stored: String| submitted_value(name).unwrap_or(stored);
|
|
644 |
+ |
|
|
645 |
+ |
let mut name = Field::new(makeover_layout::FieldKind::Text, "name", "Name")
|
|
646 |
+ |
.required()
|
|
647 |
+ |
.value(value_of(
|
|
648 |
+ |
"name",
|
|
649 |
+ |
existing.map(|m| m.name.clone()).unwrap_or_default(),
|
|
650 |
+ |
));
|
|
651 |
+ |
name.placeholder = Some("What does reaching it mean?".to_owned());
|
|
652 |
+ |
|
|
653 |
+ |
let mut description = Field::new(
|
|
654 |
+ |
makeover_layout::FieldKind::Textarea,
|
|
655 |
+ |
"description",
|
|
656 |
+ |
"Description",
|
|
657 |
+ |
)
|
|
658 |
+ |
.value(value_of(
|
|
659 |
+ |
"description",
|
|
660 |
+ |
existing.map(|m| m.description.clone()).unwrap_or_default(),
|
|
661 |
+ |
));
|
|
662 |
+ |
description.placeholder = Some("Anything the name does not cover (optional)".to_owned());
|
|
663 |
+ |
|
|
664 |
+ |
let mut target = Field::new(
|
|
665 |
+ |
makeover_layout::FieldKind::Text,
|
|
666 |
+ |
"target_date",
|
|
667 |
+ |
"Target Date (optional)",
|
|
668 |
+ |
)
|
|
669 |
+ |
.value(value_of(
|
|
670 |
+ |
"target_date",
|
|
671 |
+ |
existing
|
|
672 |
+ |
.and_then(|m| m.target_date)
|
|
673 |
+ |
.map(|date| date.format("%Y-%m-%d").to_string())
|
|
674 |
+ |
.unwrap_or_default(),
|
|
675 |
+ |
));
|
|
676 |
+ |
target.placeholder = Some("next friday, 2026-03-01...".to_owned());
|
|
677 |
+ |
|
|
678 |
+ |
let mut fields = vec![
|
|
679 |
+ |
apply(name, "name"),
|
|
680 |
+ |
apply(description, "description"),
|
|
681 |
+ |
apply(target, "target_date"),
|
|
682 |
+ |
];
|
|
683 |
+ |
|
|
684 |
+ |
if let Some(milestone) = existing {
|
|
685 |
+ |
fields.push(apply(
|
|
686 |
+ |
Field::select(
|
|
687 |
+ |
"status",
|
|
688 |
+ |
"Status",
|
|
689 |
+ |
MILESTONE_STATUSES
|
|
690 |
+ |
.iter()
|
|
691 |
+ |
.map(|status| Choice::new(*status, *status))
|
|
692 |
+ |
.collect(),
|
|
693 |
+ |
)
|
|
694 |
+ |
.value(value_of("status", milestone.status.as_str().to_owned())),
|
|
695 |
+ |
"status",
|
|
696 |
+ |
));
|
|
697 |
+ |
}
|
|
698 |
+ |
|
|
699 |
+ |
fields
|
|
700 |
+ |
}
|
|
701 |
+ |
|
|
702 |
+ |
/// The two states a milestone can be put into by hand.
|
|
703 |
+ |
///
|
|
704 |
+ |
/// Spelled as the display strings rather than the db values, because
|
|
705 |
+ |
/// `MilestoneStatus` parses the display form (`#[strum(serialize = "Open")]`)
|
|
706 |
+ |
/// and `as_str` produces it, so the field's value and its options agree without
|
|
707 |
+ |
/// a mapping in between.
|
|
708 |
+ |
const MILESTONE_STATUSES: &[&str] = &["Open", "Completed"];
|
|
709 |
+ |
|
|
710 |
+ |
/// The add or edit form as a screen of its own.
|
|
711 |
+ |
///
|
|
712 |
+ |
/// Both are addresses rather than overlays, on the shape task edit established
|
|
713 |
+ |
/// (goingson@2384df1) and for the reason recorded there: a modal is a second
|
|
714 |
+ |
/// arrangement drawn over the first, and a screen that offers a control which
|
|
715 |
+ |
/// opens a form over itself has to describe two arrangements at once. Cancel is
|
|
716 |
+ |
/// the dashboard's own address, and the dashboard is rebuilt from the database
|
|
717 |
+ |
/// rather than restored from memory.
|
|
718 |
+ |
fn milestone_form(
|
|
719 |
+ |
project: ProjectId,
|
|
720 |
+ |
existing: Option<&Milestone>,
|
|
721 |
+ |
errors: &[(&str, String)],
|
|
722 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
723 |
+ |
) -> Screen {
|
|
724 |
+ |
let (title, action) = match existing {
|
|
725 |
+ |
Some(milestone) => (
|
|
726 |
+ |
format!("Edit {}", milestone.name),
|
|
727 |
+ |
Action::post(format!("/projects/{project}/milestones/{}", milestone.id)),
|
|
728 |
+ |
),
|
|
729 |
+ |
None => (
|
|
730 |
+ |
"New milestone".to_owned(),
|
|
731 |
+ |
Action::post(format!("/projects/{project}/milestones")),
|
|
732 |
+ |
),
|
|
733 |
+ |
};
|
|
734 |
+ |
|
|
735 |
+ |
let band = Slot::new("milestone-band", RegionKind::Band)
|
|
736 |
+ |
.with(Node::page(title))
|
|
737 |
+ |
.with(Node::act(
|
|
738 |
+ |
"Cancel",
|
|
739 |
+ |
Action::get(format!("/projects/{project}/dashboard")),
|
|
740 |
+ |
));
|
|
741 |
+ |
|
|
742 |
+ |
let pane = Slot::new("milestone-form", RegionKind::Pane).with(Node::Form {
|
|
743 |
+ |
action,
|
|
744 |
+ |
submit: if existing.is_some() {
|
|
745 |
+ |
"Save milestone".to_owned()
|
|
746 |
+ |
} else {
|
|
747 |
+ |
"Create milestone".to_owned()
|
|
748 |
+ |
},
|
|
749 |
+ |
fields: milestone_fields(existing, errors, submitted),
|
|
750 |
+ |
});
|
|
751 |
+ |
|
|
752 |
+ |
Screen::list_detail("Milestone", false)
|
|
753 |
+ |
.with(band)
|
|
754 |
+ |
.with(pane)
|
|
755 |
+ |
}
|
|
756 |
+ |
|
|
757 |
+ |
/// Load one milestone, refusing one that belongs to another project.
|
|
758 |
+ |
///
|
|
759 |
+ |
/// `get_by_id` scopes by user and not by project, so the project in the address
|
|
760 |
+ |
/// is checked here. Without it `/projects/{a}/milestones/{b-of-another}/edit`
|
|
761 |
+ |
/// would render a form that saves to a milestone the address does not name.
|
|
762 |
+ |
fn load_milestone(
|
|
763 |
+ |
state: &AppState,
|
|
764 |
+ |
project: ProjectId,
|
|
765 |
+ |
id: goingson_core::MilestoneId,
|
|
766 |
+ |
) -> Result<Milestone, RouteError> {
|
|
767 |
+ |
let milestone = state
|
|
768 |
+ |
.milestones
|
|
769 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
770 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
771 |
+ |
.ok_or_else(|| RouteError::not_found("no such milestone"))?;
|
|
772 |
+ |
if milestone.project_id != project {
|
|
773 |
+ |
return Err(RouteError::not_found("no such milestone"));
|
|
774 |
+ |
}
|
|
775 |
+ |
Ok(milestone)
|
|
776 |
+ |
}
|
|
777 |
+ |
|
|
778 |
+ |
/// What the JS form refuses, refused here.
|
|
779 |
+ |
///
|
|
780 |
+ |
/// `required: true` on the name field is the whole of the JS validation, and a
|
|
781 |
+ |
/// described `.required()` is the same claim to the renderer. Neither is a
|
|
782 |
+ |
/// check: the form can be submitted past both, so the refusal has to exist on
|
|
783 |
+ |
/// this side too.
|
|
784 |
+ |
fn validate_milestone(name: &str) -> Vec<(&'static str, String)> {
|
|
785 |
+ |
let mut errors = Vec::new();
|
|
786 |
+ |
if name.is_empty() {
|
|
787 |
+ |
errors.push(("name", "A milestone needs a name.".to_owned()));
|
|
788 |
+ |
}
|
|
789 |
+ |
errors
|
|
790 |
+ |
}
|
|
791 |
+ |
|
|
792 |
+ |
/// The target date, parsed the way the JS parses it.
|
|
793 |
+ |
///
|
|
794 |
+ |
/// `projects.js` runs the field through `parseNaturalDate` before it submits,
|
|
795 |
+ |
/// so "next friday" reaches the API as a date. The described form has no
|
|
796 |
+ |
/// transform step, so the parse happens here instead, against the same core
|
|
797 |
+ |
/// function the task form uses. A date is a day rather than an instant, so the
|
|
798 |
+ |
/// time half of the parse is dropped.
|
|
799 |
+ |
fn milestone_target(
|
|
800 |
+ |
raw: &str,
|
|
801 |
+ |
errors: &mut Vec<(&'static str, String)>,
|
|
802 |
+ |
) -> Option<chrono::NaiveDate> {
|
|
803 |
+ |
if raw.is_empty() {
|
|
804 |
+ |
return None;
|
|
805 |
+ |
}
|
|
806 |
+ |
match goingson_core::parse_natural_date(raw, Local::now().naive_local()) {
|
|
807 |
+ |
Some(when) => Some(when.date()),
|
|
808 |
+ |
None => {
|
|
809 |
+ |
errors.push((
|
|
810 |
+ |
"target_date",
|
|
811 |
+ |
"Date not recognized. Try \"next friday\" or \"2026-03-01\".".to_owned(),
|
|
812 |
+ |
));
|
|
813 |
+ |
None
|
|
814 |
+ |
}
|
|
815 |
+ |
}
|
|
816 |
+ |
}
|
|
817 |
+ |
|
|
818 |
+ |
/// The add form.
|
|
819 |
+ |
fn new_milestone(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
820 |
+ |
let project = project_id(&request)?;
|
|
821 |
+ |
// Loaded rather than trusted: the form posts to this project, so an address
|
|
822 |
+ |
// naming one that does not exist should say so now rather than on submit.
|
|
823 |
+ |
state
|
|
824 |
+ |
.projects
|
|
825 |
+ |
.get_by_id(project, DESKTOP_USER_ID)
|
|
826 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
827 |
+ |
.ok_or_else(|| RouteError::not_found("no such project"))?;
|
|
828 |
+ |
Ok(milestone_form(project, None, &[], None).into())
|
|
829 |
+ |
}
|
|
830 |
+ |
|
|
831 |
+ |
/// Create it, or answer with the form saying why not.
|
|
832 |
+ |
///
|
|
833 |
+ |
/// `position` is the count of what is already there, which is what
|
|
834 |
+ |
/// `list_by_project` orders by and what the JS relies on: a new milestone lands
|
|
835 |
+ |
/// last, and `move` is how it gets anywhere else.
|
|
836 |
+ |
fn create_milestone(
|
|
837 |
+ |
state: &AppState,
|
|
838 |
+ |
request: quasi_router::Request,
|
|
839 |
+ |
) -> Result<Response, RouteError> {
|
|
840 |
+ |
let project = project_id(&request)?;
|
|
841 |
+ |
let field = |name: &str| {
|
|
842 |
+ |
request
|
|
843 |
+ |
.payload
|
|
844 |
+ |
.get(name)
|
|
845 |
+ |
.unwrap_or_default()
|
|
846 |
+ |
.trim()
|
|
847 |
+ |
.to_owned()
|
|
848 |
+ |
};
|
|
849 |
+ |
let name = field("name");
|
|
850 |
+ |
let mut errors = validate_milestone(&name);
|
|
851 |
+ |
let target_date = milestone_target(&field("target_date"), &mut errors);
|
|
852 |
+ |
|
|
853 |
+ |
if !errors.is_empty() {
|
|
854 |
+ |
return Ok(milestone_form(project, None, &errors, Some(&request.payload)).into());
|
|
855 |
+ |
}
|
|
856 |
+ |
|
|
857 |
+ |
let existing = state
|
|
858 |
+ |
.milestones
|
|
859 |
+ |
.list_by_project(project, DESKTOP_USER_ID)
|
|
860 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
861 |
+ |
|
|
862 |
+ |
state
|
|
863 |
+ |
.milestones
|
|
864 |
+ |
.create(
|
|
865 |
+ |
DESKTOP_USER_ID,
|
|
866 |
+ |
goingson_core::NewMilestone {
|
|
867 |
+ |
project_id: project,
|
|
868 |
+ |
name,
|
|
869 |
+ |
description: field("description"),
|
|
870 |
+ |
position: i32::try_from(existing.len()).unwrap_or(i32::MAX),
|
|
871 |
+ |
target_date,
|
|
872 |
+ |
},
|
|
873 |
+ |
)
|
|
874 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
875 |
+ |
|
|
876 |
+ |
wrote(state, project, flag(&request, "completed"))
|
|
877 |
+ |
}
|
|
878 |
+ |
|
|
879 |
+ |
/// The edit form.
|
|
880 |
+ |
fn edit_milestone(
|
|
881 |
+ |
state: &AppState,
|
|
882 |
+ |
request: quasi_router::Request,
|
|
883 |
+ |
) -> Result<Response, RouteError> {
|
|
884 |
+ |
let project = project_id(&request)?;
|
|
885 |
+ |
let milestone = load_milestone(state, project, milestone_id(&request)?)?;
|
|
886 |
+ |
Ok(milestone_form(project, Some(&milestone), &[], None).into())
|
|
887 |
+ |
}
|
|
888 |
+ |
|
|
889 |
+ |
/// Save the edited milestone, or answer with the form saying why not.
|
|
890 |
+ |
fn update_milestone(
|
|
891 |
+ |
state: &AppState,
|
|
892 |
+ |
request: quasi_router::Request,
|
|
893 |
+ |
) -> Result<Response, RouteError> {
|
|
894 |
+ |
let project = project_id(&request)?;
|
|
895 |
+ |
let milestone = load_milestone(state, project, milestone_id(&request)?)?;
|
|
896 |
+ |
|
|
897 |
+ |
let field = |name: &str| {
|
|
898 |
+ |
request
|
|
899 |
+ |
.payload
|
|
900 |
+ |
.get(name)
|
|
901 |
+ |
.unwrap_or_default()
|
|
902 |
+ |
.trim()
|
|
903 |
+ |
.to_owned()
|
|
904 |
+ |
};
|
|
905 |
+ |
let name = field("name");
|
|
906 |
+ |
let mut errors = validate_milestone(&name);
|
|
907 |
+ |
let target_date = milestone_target(&field("target_date"), &mut errors);
|
|
908 |
+ |
let status =
|
|
909 |
+ |
super::super::parse_choice::<MilestoneStatus>(&request.payload, "status", &mut errors);
|
|
910 |
+ |
|
|
911 |
+ |
let (Some(status), true) = (status, errors.is_empty()) else {
|
|
912 |
+ |
return Ok(
|
|
913 |
+ |
milestone_form(project, Some(&milestone), &errors, Some(&request.payload)).into(),
|
|
914 |
+ |
);
|
|
915 |
+ |
};
|
|
916 |
+ |
|
|
917 |
+ |
state
|
|
918 |
+ |
.milestones
|
|
919 |
+ |
.update(
|
|
920 |
+ |
milestone.id,
|
|
921 |
+ |
DESKTOP_USER_ID,
|
|
922 |
+ |
&name,
|
|
923 |
+ |
&field("description"),
|
|
924 |
+ |
target_date,
|
|
925 |
+ |
&status,
|
|
926 |
+ |
)
|
|
927 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
928 |
+ |
.ok_or_else(|| RouteError::not_found("no such milestone"))?;
|
|
929 |
+ |
|
|
930 |
+ |
wrote(state, project, flag(&request, "completed"))
|
|
931 |
+ |
}
|
|
932 |
+ |
|
| 590 |
933 |
|
/// Move a milestone one place up or down among the open ones.
|
| 591 |
934 |
|
///
|
| 592 |
935 |
|
/// `reorder` takes the whole order rather than a swap, so the handler reads the
|
| 654 |
997 |
|
pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
|
| 655 |
998 |
|
router
|
| 656 |
999 |
|
.get("/projects/{id}/dashboard", dashboard)
|
|
1000 |
+ |
// Above `/milestones/{milestone}/...`, because `new` and `edit` are
|
|
1001 |
+ |
// literal segments where the others capture: a router that matched the
|
|
1002 |
+ |
// capture first would read `new` as a milestone id.
|
|
1003 |
+ |
.get("/projects/{id}/milestones/new", new_milestone)
|
|
1004 |
+ |
.post("/projects/{id}/milestones", create_milestone)
|
|
1005 |
+ |
.get("/projects/{id}/milestones/{milestone}/edit", edit_milestone)
|
|
1006 |
+ |
.post("/projects/{id}/milestones/{milestone}", update_milestone)
|
| 657 |
1007 |
|
.post("/projects/{id}/milestones/{milestone}/move", move_milestone)
|
| 658 |
1008 |
|
.post(
|
| 659 |
1009 |
|
"/projects/{id}/milestones/{milestone}/delete",
|