| 40 |
40 |
|
//! # The shape
|
| 41 |
41 |
|
//!
|
| 42 |
42 |
|
//! - `GET /tasks/{id}` — the whole overview.
|
|
43 |
+ |
//! - `GET /tasks/{id}/edit` — the edit form, which is a screen of its own.
|
|
44 |
+ |
//! - `POST /tasks/{id}` — save it.
|
| 43 |
45 |
|
//! - `POST /tasks/{id}/complete` — mark it done.
|
| 44 |
46 |
|
//! - `POST /tasks/{id}/delete` — delete it.
|
| 45 |
47 |
|
//! - `POST /tasks/{id}/subtasks` — add one.
|
| 49 |
51 |
|
//! - `POST /tasks/{id}/dependencies/{other}/remove` — cut one, carrying `role`.
|
| 50 |
52 |
|
//!
|
| 51 |
53 |
|
//! Every described control reaches one of those, which is the standard the
|
| 52 |
|
- |
//! contacts port set. The one control left out rather than dangled is Edit: it
|
| 53 |
|
- |
//! opens `form-modal.js` over the drawer, and a modal form over a screen is a
|
|
54 |
+ |
//! contacts port set.
|
|
55 |
+ |
//!
|
|
56 |
+ |
//! Edit was the one control left out rather than dangled, on the grounds that
|
|
57 |
+ |
//! `form-modal.js` opens over the drawer and a modal form over a screen is a
|
| 54 |
58 |
|
//! second arrangement this screen would have to describe before it could offer
|
| 55 |
|
- |
//! it. Recorded rather than faked.
|
|
59 |
+ |
//! it. It is described now, as [`edit_screen`] rather than as an arrangement:
|
|
60 |
+ |
//! the form is its own address, and the overview links to it. That was the
|
|
61 |
+ |
//! shape the finding itself named, and the two things it still cannot ask for
|
|
62 |
+ |
//! are recorded on [`edit_fields`].
|
| 56 |
63 |
|
|
| 57 |
64 |
|
// Handlers take their request by value because `quasi_router::Handler` is a
|
| 58 |
65 |
|
// plain `fn(&S, Request)` pointer, so the signature is the router's and not a
|
| 61 |
68 |
|
|
| 62 |
69 |
|
use chrono::{DateTime, Local, Utc};
|
| 63 |
70 |
|
use goingson_core::{
|
| 64 |
|
- |
Annotation, LinkedTaskRef, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession,
|
|
71 |
+ |
Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId,
|
|
72 |
+ |
TaskStatus, TimeSession, UpdateTask,
|
| 65 |
73 |
|
};
|
| 66 |
74 |
|
use quasi_router::screen::{Act, Choice, Field, Figure, Meter, Row, Tag};
|
| 67 |
75 |
|
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
| 697 |
705 |
|
/// for the reason the projects screen gives: a write lands in more than one
|
| 698 |
706 |
|
/// section and a `Response` names one region.
|
| 699 |
707 |
|
///
|
| 700 |
|
- |
/// # The fifth finding, left out rather than faked
|
|
708 |
+ |
/// # The fifth finding, closed by giving the form an address
|
| 701 |
709 |
|
///
|
| 702 |
|
- |
/// **Edit is not offered.** `tasks.openEdit` opens `form-modal.js` over the
|
| 703 |
|
- |
/// drawer. `Region::Modal` names the place, but a screen that offers a control
|
| 704 |
|
- |
/// which opens a form over itself has to describe two arrangements at once, and
|
| 705 |
|
- |
/// the router answers one screen at a time. Describing the edit form as its own
|
| 706 |
|
- |
/// address is the shape that fits, and it is a screen of its own rather than a
|
| 707 |
|
- |
/// control on this one.
|
|
710 |
+ |
/// **Edit is not offered** was true until the form became [`edit_screen`].
|
|
711 |
+ |
/// `tasks.openEdit` opens `form-modal.js` over the drawer, and a screen that
|
|
712 |
+ |
/// offers a control which opens a form over itself has to describe two
|
|
713 |
+ |
/// arrangements at once, which the router cannot answer. So Edit here is an
|
|
714 |
+ |
/// address rather than an overlay, and the thing it addresses is a screen.
|
| 708 |
715 |
|
fn screen(state: &AppState, id: TaskId) -> Result<Screen, RouteError> {
|
| 709 |
716 |
|
let task = load(state, id)?;
|
| 710 |
717 |
|
let sessions = state
|
| 723 |
730 |
|
let candidates = blocker_candidates(state, &task, &blockers)?;
|
| 724 |
731 |
|
|
| 725 |
732 |
|
let mut band = Slot::new("task-band", RegionKind::Band).with(Node::page(&task.title));
|
|
733 |
+ |
band = band.with(Node::act("Edit", Action::get(format!("/tasks/{id}/edit"))));
|
| 726 |
734 |
|
if task.status != TaskStatus::Completed {
|
| 727 |
735 |
|
band = band.with(Node::act(
|
| 728 |
736 |
|
"Complete",
|
| 766 |
774 |
|
Ok(screen(state, task_id(&request)?)?.into())
|
| 767 |
775 |
|
}
|
| 768 |
776 |
|
|
|
777 |
+ |
/// The statuses the edit form offers, which are `task-forms.js:STATUS_OPTIONS`.
|
|
778 |
+ |
///
|
|
779 |
+ |
/// [`TaskStatus::Deleted`] is not among them, for the reason
|
|
780 |
+ |
/// [`super::move_to`] gives: deleting is its own control, and a status select
|
|
781 |
+ |
/// that could delete would put it one option away from Completed.
|
|
782 |
+ |
const EDIT_STATUSES: [&str; 3] = ["Pending", "Started", "Completed"];
|
|
783 |
+ |
|
|
784 |
+ |
/// `task-forms.js:PRIORITIES`, in its order, which is lowest first.
|
|
785 |
+ |
const EDIT_PRIORITIES: [&str; 3] = ["Low", "Medium", "High"];
|
|
786 |
+ |
|
|
787 |
+ |
/// `task-forms.js:RECURRENCE_OPTIONS`.
|
|
788 |
+ |
const EDIT_RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
|
|
789 |
+ |
|
|
790 |
+ |
/// The due date as the form shows it, which is local wall clock.
|
|
791 |
+ |
///
|
|
792 |
+ |
/// `getTaskFormFields` builds the same `YYYY-MM-DDTHH:MM` out of a `Date`, and
|
|
793 |
+ |
/// [`goingson_core::parse_natural_date`] reads that format back, so the value
|
|
794 |
+ |
/// the form offers is a value the form accepts. A prefill the parser would
|
|
795 |
+ |
/// reject is a field that cannot be left alone.
|
|
796 |
+ |
fn due_value(task: &Task) -> String {
|
|
797 |
+ |
task.due
|
|
798 |
+ |
.map(|due| {
|
|
799 |
+ |
due.with_timezone(&Local)
|
|
800 |
+ |
.format("%Y-%m-%dT%H:%M")
|
|
801 |
+ |
.to_string()
|
|
802 |
+ |
})
|
|
803 |
+ |
.unwrap_or_default()
|
|
804 |
+ |
}
|
|
805 |
+ |
|
|
806 |
+ |
/// The questions the edit form asks, in `getTaskFormFields`'s order.
|
|
807 |
+ |
///
|
|
808 |
+ |
/// `errors` and `submitted` are the projects form's, and work the same way:
|
|
809 |
+ |
/// a refusal names what was wrong and hands back what was typed, through
|
|
810 |
+ |
/// [`Field::refilled`].
|
|
811 |
+ |
///
|
|
812 |
+ |
/// # What this form does not ask, and why
|
|
813 |
+ |
///
|
|
814 |
+ |
/// **The recurrence rule.** The select offers the four patterns and stops
|
|
815 |
+ |
/// there. `buildRecurrenceConfigHtml` grows a second form underneath it —
|
|
816 |
+ |
/// an interval, a weekday multi-select, a day-of-month or nth-weekday choice,
|
|
817 |
+ |
/// an end date — that appears and changes shape as the pattern changes. Two
|
|
818 |
+ |
/// things are missing before that can be described, and neither is this
|
|
819 |
+ |
/// screen's to invent: a field whose presence depends on another field's
|
|
820 |
+ |
/// value, and a multi-select. `FieldKind` has neither.
|
|
821 |
+ |
///
|
|
822 |
+ |
/// The consequence is handled rather than ignored: [`update`] threads the
|
|
823 |
+ |
/// stored [`Task::recurrence_rule`] through untouched, so editing the title of
|
|
824 |
+ |
/// a task that repeats on Tuesdays and Thursdays leaves it repeating on
|
|
825 |
+ |
/// Tuesdays and Thursdays. Dropping it would be the silent kind of loss.
|
|
826 |
+ |
///
|
|
827 |
+ |
/// **A milestone from another project.** The select offers the milestones of
|
|
828 |
+ |
/// the project the task is *in*, read now. `setupMilestoneSelect` re-fetches
|
|
829 |
+ |
/// them when the project select changes, which a form that is one address
|
|
830 |
+ |
/// cannot do, so moving a task to another project and filing it under that
|
|
831 |
+ |
/// project's milestone is two submissions here rather than one. [`update`]
|
|
832 |
+ |
/// refuses the pairing rather than storing it, which is the same answer the
|
|
833 |
+ |
/// JS's repopulating select gives by never offering it.
|
|
834 |
+ |
fn edit_fields(
|
|
835 |
+ |
state: &AppState,
|
|
836 |
+ |
task: &Task,
|
|
837 |
+ |
errors: &[(&str, String)],
|
|
838 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
839 |
+ |
) -> Result<Vec<Field>, RouteError> {
|
|
840 |
+ |
let error_for = |name: &str| {
|
|
841 |
+ |
errors
|
|
842 |
+ |
.iter()
|
|
843 |
+ |
.find(|(field, _)| *field == name)
|
|
844 |
+ |
.map(|(_, message)| message.clone())
|
|
845 |
+ |
};
|
|
846 |
+ |
let apply = |field: Field, name: &str| match error_for(name) {
|
|
847 |
+ |
Some(message) => field.error(message),
|
|
848 |
+ |
None => field,
|
|
849 |
+ |
};
|
|
850 |
+ |
let projects = state
|
|
851 |
+ |
.projects
|
|
852 |
+ |
.list_all(DESKTOP_USER_ID)
|
|
853 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
854 |
+ |
let contacts = state
|
|
855 |
+ |
.contacts
|
|
856 |
+ |
.list_all(DESKTOP_USER_ID)
|
|
857 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
858 |
+ |
let milestones = match task.project_id {
|
|
859 |
+ |
Some(project_id) => state
|
|
860 |
+ |
.milestones
|
|
861 |
+ |
.list_by_project(project_id, DESKTOP_USER_ID)
|
|
862 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?,
|
|
863 |
+ |
None => Vec::new(),
|
|
864 |
+ |
};
|
|
865 |
+ |
|
|
866 |
+ |
let mut title = Field::new(makeover_layout::FieldKind::Text, "title", "Title")
|
|
867 |
+ |
.required()
|
|
868 |
+ |
.value(&task.title);
|
|
869 |
+ |
title.placeholder = Some("What needs to be done?".to_owned());
|
|
870 |
+ |
|
|
871 |
+ |
let mut description = Field::new(
|
|
872 |
+ |
makeover_layout::FieldKind::Textarea,
|
|
873 |
+ |
"description",
|
|
874 |
+ |
"Details",
|
|
875 |
+ |
)
|
|
876 |
+ |
.value(&task.description);
|
|
877 |
+ |
description.placeholder = Some("Anything the title does not cover (optional)".to_owned());
|
|
878 |
+ |
|
|
879 |
+ |
let mut project_options = vec![Choice::new("", "No Project")];
|
|
880 |
+ |
project_options.extend(
|
|
881 |
+ |
projects
|
|
882 |
+ |
.iter()
|
|
883 |
+ |
.map(|project| Choice::new(project.id.to_string(), &project.name)),
|
|
884 |
+ |
);
|
|
885 |
+ |
|
|
886 |
+ |
let mut contact_options = vec![Choice::new("", "No Contact")];
|
|
887 |
+ |
contact_options.extend(
|
|
888 |
+ |
contacts
|
|
889 |
+ |
.iter()
|
|
890 |
+ |
.map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)),
|
|
891 |
+ |
);
|
|
892 |
+ |
|
|
893 |
+ |
let mut milestone_options = vec![Choice::new("", "No Milestone")];
|
|
894 |
+ |
milestone_options.extend(
|
|
895 |
+ |
milestones
|
|
896 |
+ |
.iter()
|
|
897 |
+ |
.map(|milestone| Choice::new(milestone.id.to_string(), &milestone.name)),
|
|
898 |
+ |
);
|
|
899 |
+ |
|
|
900 |
+ |
let mut due = Field::new(
|
|
901 |
+ |
makeover_layout::FieldKind::Text,
|
|
902 |
+ |
"due",
|
|
903 |
+ |
"Due Date (optional)",
|
|
904 |
+ |
)
|
|
905 |
+ |
.value(due_value(task));
|
|
906 |
+ |
due.placeholder = Some("tomorrow, friday 3pm, 2026-12-25...".to_owned());
|
|
907 |
+ |
|
|
908 |
+ |
let mut estimated = Field::new(
|
|
909 |
+ |
makeover_layout::FieldKind::Number,
|
|
910 |
+ |
"estimated_minutes",
|
|
911 |
+ |
"Estimated Time (minutes)",
|
|
912 |
+ |
)
|
|
913 |
+ |
.hint("Used for day plan scheduling and time tracking progress")
|
|
914 |
+ |
.value(
|
|
915 |
+ |
task.estimated_minutes
|
|
916 |
+ |
.map(|minutes| minutes.to_string())
|
|
917 |
+ |
.unwrap_or_default(),
|
|
918 |
+ |
);
|
|
919 |
+ |
estimated.placeholder = Some("e.g. 30, 60, 120".to_owned());
|
|
920 |
+ |
|
|
921 |
+ |
let mut tags = Field::new(
|
|
922 |
+ |
makeover_layout::FieldKind::Text,
|
|
923 |
+ |
"tags",
|
|
924 |
+ |
"Tags (comma-separated)",
|
|
925 |
+ |
)
|
|
926 |
+ |
.value(task.tags.join(", "));
|
|
927 |
+ |
tags.placeholder = Some("work, urgent, meeting".to_owned());
|
|
928 |
+ |
|
|
929 |
+ |
let fields = vec![
|
|
930 |
+ |
apply(title, "title"),
|
|
931 |
+ |
apply(description, "description"),
|
|
932 |
+ |
apply(
|
|
933 |
+ |
Field::select("project_id", "Project", project_options)
|
|
934 |
+ |
.value(option_id(task.project_id.map(|id| id.to_string()))),
|
|
935 |
+ |
"project_id",
|
|
936 |
+ |
),
|
|
937 |
+ |
apply(
|
|
938 |
+ |
Field::select(
|
|
939 |
+ |
"status",
|
|
940 |
+ |
"Status",
|
|
941 |
+ |
EDIT_STATUSES
|
|
942 |
+ |
.iter()
|
|
943 |
+ |
.map(|status| Choice::new(*status, *status))
|
|
944 |
+ |
.collect(),
|
|
945 |
+ |
)
|
|
946 |
+ |
.value(task.status.as_str()),
|
|
947 |
+ |
"status",
|
|
948 |
+ |
),
|
|
949 |
+ |
apply(
|
|
950 |
+ |
Field::select(
|
|
951 |
+ |
"priority",
|
|
952 |
+ |
"Priority",
|
|
953 |
+ |
EDIT_PRIORITIES
|
|
954 |
+ |
.iter()
|
|
955 |
+ |
.map(|priority| Choice::new(*priority, *priority))
|
|
956 |
+ |
.collect(),
|
|
957 |
+ |
)
|
|
958 |
+ |
.value(task.priority.db_value()),
|
|
959 |
+ |
"priority",
|
|
960 |
+ |
),
|
|
961 |
+ |
apply(due, "due"),
|
|
962 |
+ |
apply(tags, "tags"),
|
|
963 |
+ |
apply(
|
|
964 |
+ |
Field::select(
|
|
965 |
+ |
"recurrence",
|
|
966 |
+ |
"Recurrence",
|
|
967 |
+ |
EDIT_RECURRENCES
|
|
968 |
+ |
.iter()
|
|
969 |
+ |
.map(|pattern| Choice::new(*pattern, *pattern))
|
|
970 |
+ |
.collect(),
|
|
971 |
+ |
)
|
|
972 |
+ |
.hint("Completing a recurring task auto-creates the next occurrence")
|
|
973 |
+ |
.value(task.recurrence.db_value()),
|
|
974 |
+ |
"recurrence",
|
|
975 |
+ |
),
|
|
976 |
+ |
apply(estimated, "estimated_minutes"),
|
|
977 |
+ |
apply(
|
|
978 |
+ |
Field::select("contact_id", "Contact", contact_options)
|
|
979 |
+ |
.value(option_id(task.contact_id.map(|id| id.to_string()))),
|
|
980 |
+ |
"contact_id",
|
|
981 |
+ |
),
|
|
982 |
+ |
apply(
|
|
983 |
+ |
Field::select("milestone_id", "Milestone", milestone_options)
|
|
984 |
+ |
.hint("Group tasks into project phases; milestones are managed per project")
|
|
985 |
+ |
.value(option_id(task.milestone_id.map(|id| id.to_string()))),
|
|
986 |
+ |
"milestone_id",
|
|
987 |
+ |
),
|
|
988 |
+ |
];
|
|
989 |
+ |
|
|
990 |
+ |
Ok(match submitted {
|
|
991 |
+ |
Some(params) => fields
|
|
992 |
+ |
.into_iter()
|
|
993 |
+ |
.map(|field| field.refilled(params))
|
|
994 |
+ |
.collect(),
|
|
995 |
+ |
None => fields,
|
|
996 |
+ |
})
|
|
997 |
+ |
}
|
|
998 |
+ |
|
|
999 |
+ |
/// An optional id as the select spells it, which is the empty string for none.
|
|
1000 |
+ |
fn option_id(id: Option<String>) -> String {
|
|
1001 |
+ |
id.unwrap_or_default()
|
|
1002 |
+ |
}
|
|
1003 |
+ |
|
|
1004 |
+ |
/// The edit form, at its own address.
|
|
1005 |
+ |
///
|
|
1006 |
+ |
/// This is the fifth finding closed rather than recorded. It stood as **Edit is
|
|
1007 |
+ |
/// not offered**, because `tasks.openEdit` opens `form-modal.js` over the drawer
|
|
1008 |
+ |
/// and a screen offering a control that opens a form over itself has to describe
|
|
1009 |
+ |
/// two arrangements at once. The shape that fits was named in the same
|
|
1010 |
+ |
/// paragraph and is what this is: the form is a screen of its own, reached by
|
|
1011 |
+ |
/// address, and the control on the overview is a link to it rather than a
|
|
1012 |
+ |
/// second arrangement drawn on top.
|
|
1013 |
+ |
///
|
|
1014 |
+ |
/// What the modal bought — the screen behind staying put — an address gives
|
|
1015 |
+ |
/// back for free, because Cancel is the overview's own address and the overview
|
|
1016 |
+ |
/// is rebuilt from the database rather than restored from memory.
|
|
1017 |
+ |
fn edit_screen(
|
|
1018 |
+ |
state: &AppState,
|
|
1019 |
+ |
task: &Task,
|
|
1020 |
+ |
errors: &[(&str, String)],
|
|
1021 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
1022 |
+ |
) -> Result<Screen, RouteError> {
|
|
1023 |
+ |
let band = Slot::new("task-band", RegionKind::Band)
|
|
1024 |
+ |
.with(Node::page(format!("Edit {}", task.title)))
|
|
1025 |
+ |
.with(Node::act(
|
|
1026 |
+ |
"Cancel",
|
|
1027 |
+ |
Action::get(format!("/tasks/{}", task.id)),
|
|
1028 |
+ |
));
|
|
1029 |
+ |
|
|
1030 |
+ |
let pane = Slot::new("task-overview", RegionKind::Pane).with(Node::Form {
|
|
1031 |
+ |
action: Action::post(format!("/tasks/{}", task.id)),
|
|
1032 |
+ |
submit: "Save task".to_owned(),
|
|
1033 |
+ |
fields: edit_fields(state, task, errors, submitted)?,
|
|
1034 |
+ |
});
|
|
1035 |
+ |
|
|
1036 |
+ |
Ok(Screen::list_detail("Edit task", false)
|
|
1037 |
+ |
.with(band)
|
|
1038 |
+ |
.with(pane))
|
|
1039 |
+ |
}
|
|
1040 |
+ |
|
|
1041 |
+ |
/// The edit form.
|
|
1042 |
+ |
fn edit(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
1043 |
+ |
let task = load(state, task_id(&request)?)?;
|
|
1044 |
+ |
Ok(edit_screen(state, &task, &[], None)?.into())
|
|
1045 |
+ |
}
|
|
1046 |
+ |
|
|
1047 |
+ |
/// What the JS form refuses, refused here.
|
|
1048 |
+ |
///
|
|
1049 |
+ |
/// The title length is `getTaskFormFields`'s own `validate` closure. The empty
|
|
1050 |
+ |
/// title is `update_task`'s, which checks it server-side and is the only one of
|
|
1051 |
+ |
/// the two that a described form could not skip.
|
|
1052 |
+ |
fn validate_edit(title: &str) -> Vec<(&'static str, String)> {
|
|
1053 |
+ |
let mut errors = Vec::new();
|
|
1054 |
+ |
if title.is_empty() {
|
|
1055 |
+ |
errors.push(("title", "A task needs a title.".to_owned()));
|
|
1056 |
+ |
} else if title.chars().count() > 80 {
|
|
1057 |
+ |
errors.push(("title", "Maximum 80 characters".to_owned()));
|
|
1058 |
+ |
}
|
|
1059 |
+ |
errors
|
|
1060 |
+ |
}
|
|
1061 |
+ |
|
|
1062 |
+ |
/// Save the edited task, or answer with the form saying why not.
|
|
1063 |
+ |
///
|
|
1064 |
+ |
/// The write is [`UpdateTask`] through the repository rather than the
|
|
1065 |
+ |
/// `update_task` command, which is a Tauri wrapper around exactly this. What
|
|
1066 |
+ |
/// the command holds that is worth keeping — the urgency recalculation, the
|
|
1067 |
+ |
/// title/description split, and reading `scheduled_start` and
|
|
1068 |
+ |
/// `scheduled_duration` off the stored row so a time-blocked task does not lose
|
|
1069 |
+ |
/// its block on an unrelated edit — is in core and is called here for the same
|
|
1070 |
+ |
/// reason.
|
|
1071 |
+ |
fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
1072 |
+ |
let id = task_id(&request)?;
|
|
1073 |
+ |
let task = load(state, id)?;
|
|
1074 |
+ |
|
|
1075 |
+ |
let field = |name: &str| {
|
|
1076 |
+ |
request
|
|
1077 |
+ |
.payload
|
|
1078 |
+ |
.get(name)
|
|
1079 |
+ |
.unwrap_or_default()
|
|
1080 |
+ |
.trim()
|
|
1081 |
+ |
.to_owned()
|
|
1082 |
+ |
};
|
|
1083 |
+ |
let title = field("title");
|
|
1084 |
+ |
let description = field("description");
|
|
1085 |
+ |
let mut errors = validate_edit(&title);
|
|
1086 |
+ |
|
|
1087 |
+ |
let status = super::parse_choice::<TaskStatus>(&request.payload, "status", &mut errors)
|
|
1088 |
+ |
.filter(|status| EDIT_STATUSES.contains(&status.as_str()));
|
|
1089 |
+ |
if status.is_none() && !errors.iter().any(|(name, _)| *name == "status") {
|
|
1090 |
+ |
errors.push(("status", "Not a status a control can set.".to_owned()));
|
|
1091 |
+ |
}
|
|
1092 |
+ |
let priority = super::parse_choice::<Priority>(&request.payload, "priority", &mut errors);
|
|
1093 |
+ |
let recurrence = super::parse_choice::<Recurrence>(&request.payload, "recurrence", &mut errors);
|
|
1094 |
+ |
|
|
1095 |
+ |
// Blank is "no due date", which is how the field is cleared. Anything else
|
|
1096 |
+ |
// has to parse, and an unparseable date is refused rather than dropped:
|
|
1097 |
+ |
// dropping it is a task that silently loses its deadline on an edit that
|
|
1098 |
+ |
// was about something else. `parse_natural_date` is the same function the
|
|
1099 |
+ |
// JS reaches through the `parse_natural_date` command, so the two agree on
|
|
1100 |
+ |
// what "friday 3pm" means.
|
|
1101 |
+ |
let raw_due = field("due");
|
|
1102 |
+ |
let due = if raw_due.is_empty() {
|
|
1103 |
+ |
None
|
|
1104 |
+ |
} else {
|
|
1105 |
+ |
match goingson_core::parse_natural_date(&raw_due, Local::now().naive_local())
|
|
1106 |
+ |
.and_then(|when| when.and_local_timezone(Local).single())
|
|
1107 |
+ |
{
|
|
1108 |
+ |
Some(when) => Some(when.with_timezone(&Utc)),
|
|
1109 |
+ |
None => {
|
|
1110 |
+ |
errors.push((
|
|
1111 |
+ |
"due",
|
|
1112 |
+ |
"Date not recognized. Try \"tomorrow\", \"friday 3pm\", or \"2026-12-25\"."
|
|
1113 |
+ |
.to_owned(),
|
|
1114 |
+ |
));
|
|
1115 |
+ |
None
|
|
1116 |
+ |
}
|
|
1117 |
+ |
}
|
|
1118 |
+ |
};
|
|
1119 |
+ |
|
|
1120 |
+ |
let estimated_minutes = match field("estimated_minutes").as_str() {
|
|
1121 |
+ |
"" => None,
|
|
1122 |
+ |
raw => match raw.parse::<i32>() {
|
|
1123 |
+ |
Ok(minutes) if minutes >= 0 => Some(minutes),
|
|
1124 |
+ |
_ => {
|
|
1125 |
+ |
errors.push(("estimated_minutes", "A number of minutes.".to_owned()));
|
|
1126 |
+ |
None
|
|
1127 |
+ |
}
|
|
1128 |
+ |
},
|
|
1129 |
+ |
};
|
|
1130 |
+ |
|
|
1131 |
+ |
let project_id = parse_optional_id(&request.payload, "project_id", &mut errors);
|
|
1132 |
+ |
let contact_id = parse_optional_id(&request.payload, "contact_id", &mut errors);
|
|
1133 |
+ |
let milestone_id = parse_optional_id(&request.payload, "milestone_id", &mut errors);
|
|
1134 |
+ |
|
|
1135 |
+ |
// A milestone belongs to a project, and the form offered the milestones of
|
|
1136 |
+ |
// the project the task was in. Moving both at once is refused rather than
|
|
1137 |
+ |
// stored, because the pairing the form could offer and the pairing the
|
|
1138 |
+ |
// submission carries are not the same thing. See [`edit_fields`].
|
|
1139 |
+ |
if milestone_id.flatten().is_some() && project_id.flatten() != task.project_id {
|
|
1140 |
+ |
errors.push((
|
|
1141 |
+ |
"milestone_id",
|
|
1142 |
+ |
"Move the task first, then file it under a milestone of its new project.".to_owned(),
|
|
1143 |
+ |
));
|
|
1144 |
+ |
}
|
|
1145 |
+ |
|
|
1146 |
+ |
let (Some(status), Some(priority), Some(recurrence)) = (status, priority, recurrence) else {
|
|
1147 |
+ |
return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
|
|
1148 |
+ |
};
|
|
1149 |
+ |
let (Some(project_id), Some(contact_id), Some(milestone_id)) =
|
|
1150 |
+ |
(project_id, contact_id, milestone_id)
|
|
1151 |
+ |
else {
|
|
1152 |
+ |
return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
|
|
1153 |
+ |
};
|
|
1154 |
+ |
if !errors.is_empty() {
|
|
1155 |
+ |
return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
|
|
1156 |
+ |
}
|
|
1157 |
+ |
|
|
1158 |
+ |
let tags: Vec<String> = field("tags")
|
|
1159 |
+ |
.split(',')
|
|
1160 |
+ |
.map(|tag| tag.trim().to_owned())
|
|
1161 |
+ |
.filter(|tag| !tag.is_empty())
|
|
1162 |
+ |
.collect();
|
|
1163 |
+ |
|
|
1164 |
+ |
let context = state
|
|
1165 |
+ |
.tasks
|
|
1166 |
+ |
.get_update_context(id, DESKTOP_USER_ID)
|
|
1167 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
1168 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
1169 |
+ |
|
|
1170 |
+ |
state
|
|
1171 |
+ |
.tasks
|
|
1172 |
+ |
.update(
|
|
1173 |
+ |
id,
|
|
1174 |
+ |
DESKTOP_USER_ID,
|
|
1175 |
+ |
UpdateTask {
|
|
1176 |
+ |
project_id,
|
|
1177 |
+ |
milestone_id,
|
|
1178 |
+ |
contact_id,
|
|
1179 |
+ |
urgency: goingson_core::calculate_urgency(
|
|
1180 |
+ |
&priority,
|
|
1181 |
+ |
&status,
|
|
1182 |
+ |
due.as_ref(),
|
|
1183 |
+ |
&context.created_at,
|
|
1184 |
+ |
&tags,
|
|
1185 |
+ |
),
|
|
1186 |
+ |
title,
|
|
1187 |
+ |
description,
|
|
1188 |
+ |
status,
|
|
1189 |
+ |
priority,
|
|
1190 |
+ |
due,
|
|
1191 |
+ |
tags,
|
|
1192 |
+ |
recurrence,
|
|
1193 |
+ |
// Threaded rather than rebuilt: the form cannot ask for it.
|
|
1194 |
+ |
// See [`edit_fields`].
|
|
1195 |
+ |
recurrence_rule: task.recurrence_rule.clone(),
|
|
1196 |
+ |
scheduled_start: context.scheduled_start,
|
|
1197 |
+ |
scheduled_duration: context.scheduled_duration,
|
|
1198 |
+ |
estimated_minutes,
|
|
1199 |
+ |
},
|
|
1200 |
+ |
)
|
|
1201 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
1202 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
1203 |
+ |
|
|
1204 |
+ |
Ok(wrote(state, id)?.toast(makeover_layout::Tone::Success, "Task saved"))
|
|
1205 |
+ |
}
|
|
1206 |
+ |
|
|
1207 |
+ |
/// An id a select offers as an option, where the empty option means none.
|
|
1208 |
+ |
///
|
|
1209 |
+ |
/// Two layers of `Option` and both mean something: the outer is whether the
|
|
1210 |
+ |
/// value parsed, the inner is whether one was chosen. Flattening them here
|
|
1211 |
+ |
/// would make a typo indistinguishable from "No Project", which is the one
|