|
1 |
+ |
//! The project dashboard, described rather than built.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! Fourth screen ported. `projects-render.js`, 241 lines: what a project has
|
|
6 |
+ |
//! linked to it, in four columns, plus its milestones. The shipped screen is
|
|
7 |
+ |
//! that file unchanged.
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! # Chosen over the next one by weight, deliberately
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! The porting order on goingson task `7c8e6be2` is by `esc()` count, and the
|
|
12 |
+ |
//! head of that list was `settings-sync.js` at 17. It is not a screen. It is an
|
|
13 |
+ |
//! OAuth handshake against an external browser with a localhost callback poll,
|
|
14 |
+ |
//! an encryption-password setup, and Stripe subscription polling — workflows
|
|
15 |
+ |
//! wearing a settings panel, and the description layer has nothing to say about
|
|
16 |
+ |
//! any of it.
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! Weight measures how much escaping a port retires, which is the payoff. It
|
|
19 |
+ |
//! says nothing about whether the thing is describable, which is the
|
|
20 |
+ |
//! precondition. Order by weight *among describable screens*; a count that picks
|
|
21 |
+ |
//! a workflow is a count being read as a plan.
|
|
22 |
+ |
//!
|
|
23 |
+ |
//! # The shape
|
|
24 |
+ |
//!
|
|
25 |
+ |
//! - `GET /projects/{id}/dashboard` — the whole thing.
|
|
26 |
+ |
//! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`.
|
|
27 |
+ |
//! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one.
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! `showCompletedMilestones` is module state in the JS, re-rendered from a
|
|
30 |
+ |
//! cached list. Here it is `?completed=1`, so the expanded dashboard is
|
|
31 |
+ |
//! reachable by address. Third time decision 2 has paid out on a real screen and
|
|
32 |
+ |
//! the first where the state was a disclosure rather than a filter.
|
|
33 |
+ |
//!
|
|
34 |
+ |
//! Two controls are described and two are not, and the line between them is the
|
|
35 |
+ |
//! finding this port turned up. See [`attachments_column`].
|
|
36 |
+ |
|
|
37 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
38 |
+ |
|
|
39 |
+ |
use chrono::Local;
|
|
40 |
+ |
use goingson_core::{
|
|
41 |
+ |
Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
|
|
42 |
+ |
};
|
|
43 |
+ |
use quasi_router::screen::{Act, Row, Tag};
|
|
44 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
45 |
+ |
|
|
46 |
+ |
use super::{filtered_by, project_id, status_tone, type_label};
|
|
47 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
|
48 |
+ |
|
|
49 |
+ |
#[cfg(test)]
|
|
50 |
+ |
mod tests;
|
|
51 |
+ |
|
|
52 |
+ |
/// One linked task.
|
|
53 |
+ |
///
|
|
54 |
+ |
/// The subtask bar the JS draws under the title is text in the meta slot, which
|
|
55 |
+ |
/// is the proportion finding meeting its third and fourth call sites — a project
|
|
56 |
+ |
/// dashboard draws one per task and one per milestone. makeover-layout
|
|
57 |
+ |
/// `d0b58239`.
|
|
58 |
+ |
fn task_row(task: &Task) -> Row {
|
|
59 |
+ |
let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str()));
|
|
60 |
+ |
if task.subtask_count() > 0 {
|
|
61 |
+ |
row = row.meta(format!(
|
|
62 |
+ |
"{}/{} subtasks",
|
|
63 |
+ |
task.subtasks_completed(),
|
|
64 |
+ |
task.subtask_count()
|
|
65 |
+ |
));
|
|
66 |
+ |
}
|
|
67 |
+ |
if task.due.is_some() {
|
|
68 |
+ |
row = row.token(Tag::badge(task.due_formatted()));
|
|
69 |
+ |
}
|
|
70 |
+ |
row.activate(Action::get(format!("/tasks/{}", task.id)))
|
|
71 |
+ |
}
|
|
72 |
+ |
|
|
73 |
+ |
/// The tasks column.
|
|
74 |
+ |
///
|
|
75 |
+ |
/// Three states rather than two, which is the JS's own distinction and worth
|
|
76 |
+ |
/// keeping: nothing linked yet is a different thing from everything being done.
|
|
77 |
+ |
fn tasks_column(tasks: &[Task]) -> Node {
|
|
78 |
+ |
if tasks.is_empty() {
|
|
79 |
+ |
return Node::text("No tasks linked yet.");
|
|
80 |
+ |
}
|
|
81 |
+ |
if tasks
|
|
82 |
+ |
.iter()
|
|
83 |
+ |
.all(|task| task.status == TaskStatus::Completed)
|
|
84 |
+ |
{
|
|
85 |
+ |
return Node::text("All tasks complete.");
|
|
86 |
+ |
}
|
|
87 |
+ |
Node::list(tasks.iter().map(task_row))
|
|
88 |
+ |
}
|
|
89 |
+ |
|
|
90 |
+ |
/// The events column.
|
|
91 |
+ |
fn events_column(events: &[Event]) -> Node {
|
|
92 |
+ |
if events.is_empty() {
|
|
93 |
+ |
return Node::text("No events linked yet.");
|
|
94 |
+ |
}
|
|
95 |
+ |
Node::list(events.iter().map(|event| {
|
|
96 |
+ |
let at = event.start_time.with_timezone(&Local);
|
|
97 |
+ |
Row::new(&event.title).meta(at.format("%b %-d, %-I:%M %p").to_string())
|
|
98 |
+ |
}))
|
|
99 |
+ |
}
|
|
100 |
+ |
|
|
101 |
+ |
/// The emails column.
|
|
102 |
+ |
fn emails_column(emails: &[Email]) -> Node {
|
|
103 |
+ |
if emails.is_empty() {
|
|
104 |
+ |
return Node::text("No emails linked yet.");
|
|
105 |
+ |
}
|
|
106 |
+ |
Node::list(emails.iter().map(|email| {
|
|
107 |
+ |
let mut row = Row::new(&email.subject)
|
|
108 |
+ |
.secondary(&email.from)
|
|
109 |
+ |
.meta(email.received_formatted());
|
|
110 |
+ |
if !email.is_read {
|
|
111 |
+ |
row = row.token(Tag::badge("Unread").tone(makeover_layout::Tone::Info));
|
|
112 |
+ |
}
|
|
113 |
+ |
row
|
|
114 |
+ |
}))
|
|
115 |
+ |
}
|
|
116 |
+ |
|
|
117 |
+ |
/// A file size the way the JS writes one.
|
|
118 |
+ |
fn file_size(bytes: i64) -> String {
|
|
119 |
+ |
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
|
|
120 |
+ |
let mut size = bytes as f64;
|
|
121 |
+ |
let mut unit = 0;
|
|
122 |
+ |
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
|
123 |
+ |
size /= 1024.0;
|
|
124 |
+ |
unit += 1;
|
|
125 |
+ |
}
|
|
126 |
+ |
if unit == 0 {
|
|
127 |
+ |
format!("{bytes} {}", UNITS[0])
|
|
128 |
+ |
} else {
|
|
129 |
+ |
format!("{size:.1} {}", UNITS[unit])
|
|
130 |
+ |
}
|
|
131 |
+ |
}
|
|
132 |
+ |
|
|
133 |
+ |
/// The attachments column.
|
|
134 |
+ |
///
|
|
135 |
+ |
/// # The finding this port turned up
|
|
136 |
+ |
///
|
|
137 |
+ |
/// **An action that opens a native dialog is not a route, and `Destination` has
|
|
138 |
+ |
/// nowhere to put it.** The empty state carries an "Attach File" button, and it
|
|
139 |
+ |
/// calls `attachments.pickAndAttach`, which opens the OS file picker. That is
|
|
140 |
+ |
/// neither of `Destination`'s two members: it is not a route this app answers,
|
|
141 |
+ |
/// and it is not an external address a browser navigates to. It is a capability
|
|
142 |
+ |
/// of the host, asked for by name.
|
|
143 |
+ |
///
|
|
144 |
+ |
/// The same is true of opening one: `attachments.openPanel` hands the blob to
|
|
145 |
+ |
/// the OS. So this column is read-only in the description, and both controls are
|
|
146 |
+ |
/// left out rather than pointed at routes that cannot exist.
|
|
147 |
+ |
///
|
|
148 |
+ |
/// This is a different shape from the other findings and may not be a gap at
|
|
149 |
+ |
/// all. `Region::Bespoke` already says "the app owns what goes here" for a
|
|
150 |
+ |
/// region; the honest reading may be that a host capability is the app's
|
|
151 |
+ |
/// business the same way, and a description that named file pickers would be
|
|
152 |
+ |
/// naming one host's abilities in a crate defined by not doing that. What argues
|
|
153 |
+ |
/// the other way is that every host has *some* answer — a TUI has a path prompt,
|
|
154 |
+ |
/// a server has an upload — which is the usual test for whether something
|
|
155 |
+ |
/// belongs in the vocabulary.
|
|
156 |
+ |
///
|
|
157 |
+ |
/// Filed as quasicoherent `844b5ae0`, on the router rather than the
|
|
158 |
+ |
/// vocabulary, for the same reason the goto finding went there: it is about what
|
|
159 |
+ |
/// an action can reach, not about what is on screen.
|
|
160 |
+ |
fn attachments_column(attachments: &[Attachment]) -> Node {
|
|
161 |
+ |
if attachments.is_empty() {
|
|
162 |
+ |
return Node::text("No attachments yet.");
|
|
163 |
+ |
}
|
|
164 |
+ |
Node::list(
|
|
165 |
+ |
attachments
|
|
166 |
+ |
.iter()
|
|
167 |
+ |
.map(|file| Row::new(&file.filename).meta(file_size(file.file_size))),
|
|
168 |
+ |
)
|
|
169 |
+ |
}
|
|
170 |
+ |
|
|
171 |
+ |
/// How far along one milestone is, and how that reads.
|
|
172 |
+ |
///
|
|
173 |
+ |
/// `list_milestones` computes the same three numbers in the command layer. Not
|
|
174 |
+ |
/// shared, because sharing it would mean lifting `MilestoneResponse` out of the
|
|
175 |
+ |
/// command module into something both can see, and the arithmetic is one line.
|
|
176 |
+ |
/// The comment is the guard: if the rule for what counts as done ever stops
|
|
177 |
+ |
/// being "status is Completed", both move together.
|
|
178 |
+ |
fn milestone_progress(tasks: &[Task], milestone: &Milestone) -> (usize, usize) {
|
|
179 |
+ |
let mine: Vec<&Task> = tasks
|
|
180 |
+ |
.iter()
|
|
181 |
+ |
.filter(|task| task.milestone_id == Some(milestone.id))
|
|
182 |
+ |
.collect();
|
|
183 |
+ |
let done = mine
|
|
184 |
+ |
.iter()
|
|
185 |
+ |
.filter(|task| task.status == TaskStatus::Completed)
|
|
186 |
+ |
.count();
|
|
187 |
+ |
(done, mine.len())
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
/// One open milestone, with the controls that act on it.
|
|
191 |
+ |
///
|
|
192 |
+ |
/// Reordering is two acts rather than a drag: the JS draws up and down arrows
|
|
193 |
+ |
/// and hides the one that would go off the end. `Act::disabled` says that
|
|
194 |
+ |
/// better than hiding does — a control that vanishes at the edge of a list is a
|
|
195 |
+ |
/// control the user has to discover twice.
|
|
196 |
+ |
fn milestone_row(
|
|
197 |
+ |
project: ProjectId,
|
|
198 |
+ |
milestone: &Milestone,
|
|
199 |
+ |
tasks: &[Task],
|
|
200 |
+ |
at: usize,
|
|
201 |
+ |
of: usize,
|
|
202 |
+ |
) -> Row {
|
|
203 |
+ |
let (done, total) = milestone_progress(tasks, milestone);
|
|
204 |
+ |
let mut row = Row::new(&milestone.name).meta(format!("{done}/{total}"));
|
|
205 |
+ |
|
|
206 |
+ |
if let Some(date) = milestone.target_date {
|
|
207 |
+ |
row = row.token(Tag::badge(date.format("%Y-%m-%d").to_string()));
|
|
208 |
+ |
}
|
|
209 |
+ |
|
|
210 |
+ |
let move_by = |by: i8| {
|
|
211 |
+ |
Action::post(format!(
|
|
212 |
+ |
"/projects/{project}/milestones/{}/move",
|
|
213 |
+ |
milestone.id
|
|
214 |
+ |
))
|
|
215 |
+ |
.with("by", by.to_string())
|
|
216 |
+ |
};
|
|
217 |
+ |
let up = Act::new("Move up", move_by(-1));
|
|
218 |
+ |
let down = Act::new("Move down", move_by(1));
|
|
219 |
+ |
|
|
220 |
+ |
row.act(if at == 0 { up.disabled() } else { up })
|
|
221 |
+ |
.act(if at + 1 == of { down.disabled() } else { down })
|
|
222 |
+ |
.act(
|
|
223 |
+ |
Act::new(
|
|
224 |
+ |
"Delete",
|
|
225 |
+ |
Action::post(format!(
|
|
226 |
+ |
"/projects/{project}/milestones/{}/delete",
|
|
227 |
+ |
milestone.id
|
|
228 |
+ |
)),
|
|
229 |
+ |
)
|
|
230 |
+ |
.tone(makeover_layout::Tone::Danger),
|
|
231 |
+ |
)
|
|
232 |
+ |
}
|
|
233 |
+ |
|
|
234 |
+ |
/// The milestones section.
|
|
235 |
+ |
///
|
|
236 |
+ |
/// Edit is absent for the reason it is absent on the task overview: it opens a
|
|
237 |
+ |
/// modal form, and an edit form is a screen of its own rather than a control on
|
|
238 |
+ |
/// the screen it edits. Add is absent for the same reason.
|
|
239 |
+ |
fn milestones(
|
|
240 |
+ |
project: ProjectId,
|
|
241 |
+ |
all: &[Milestone],
|
|
242 |
+ |
tasks: &[Task],
|
|
243 |
+ |
show_completed: bool,
|
|
244 |
+ |
) -> Vec<Node> {
|
|
245 |
+ |
let (open, done): (Vec<&Milestone>, Vec<&Milestone>) = all
|
|
246 |
+ |
.iter()
|
|
247 |
+ |
.partition(|milestone| milestone.status != MilestoneStatus::Completed);
|
|
248 |
+ |
|
|
249 |
+ |
let mut out = vec![Node::section("Milestones")];
|
|
250 |
+ |
if all.is_empty() {
|
|
251 |
+ |
out.push(Node::text("No milestones yet"));
|
|
252 |
+ |
return out;
|
|
253 |
+ |
}
|
|
254 |
+ |
|
|
255 |
+ |
let of = open.len();
|
|
256 |
+ |
out.push(Node::list(open.iter().enumerate().map(
|
|
257 |
+ |
|(at, milestone)| milestone_row(project, milestone, tasks, at, of),
|
|
258 |
+ |
)));
|
|
259 |
+ |
|
|
260 |
+ |
if !done.is_empty() {
|
|
261 |
+ |
// The disclosure is an address, so an expanded dashboard survives a
|
|
262 |
+ |
// reload and can be linked to. `showCompletedMilestones` in the JS is
|
|
263 |
+ |
// module state that a re-render throws away.
|
|
264 |
+ |
out.push(Node::act(
|
|
265 |
+ |
if show_completed {
|
|
266 |
+ |
"Hide completed".to_owned()
|
|
267 |
+ |
} else {
|
|
268 |
+ |
format!("Show {} completed", done.len())
|
|
269 |
+ |
},
|
|
270 |
+ |
filtered_by(
|
|
271 |
+ |
Action::get(format!("/projects/{project}/dashboard")),
|
|
272 |
+ |
"completed",
|
|
273 |
+ |
!show_completed,
|
|
274 |
+ |
),
|
|
275 |
+ |
));
|
|
276 |
+ |
if show_completed {
|
|
277 |
+ |
out.push(Node::list(done.iter().map(|milestone| {
|
|
278 |
+ |
Row::new(&milestone.name)
|
|
279 |
+ |
.token(Tag::badge("Complete").tone(makeover_layout::Tone::Success))
|
|
280 |
+ |
})));
|
|
281 |
+ |
}
|
|
282 |
+ |
}
|
|
283 |
+ |
|
|
284 |
+ |
out
|
|
285 |
+ |
}
|
|
286 |
+ |
|
|
287 |
+ |
/// One column, as a region of its own.
|
|
288 |
+ |
///
|
|
289 |
+ |
/// # The arrangement finding
|
|
290 |
+ |
///
|
|
291 |
+ |
/// **Four peer columns are neither of the two arrangements.** `Arrangement` is
|
|
292 |
+ |
/// `ListDetail` or `SidebarContent`, taken from what the two webview apps do,
|
|
293 |
+ |
/// and this screen does a third thing: four equal panes side by side under a
|
|
294 |
+ |
/// band, none of which chooses what another shows.
|
|
295 |
+ |
///
|
|
296 |
+ |
/// The regions themselves are fine — a `Pane` each, and `Slot` takes as many as
|
|
297 |
+ |
/// it is given — so the screen renders. What is wrong is that it has to claim
|
|
298 |
+ |
/// `ListDetail` while being nothing of the sort, and a renderer that laid out
|
|
299 |
+ |
/// list-detail faithfully would put the emails pane where the detail goes.
|
|
300 |
+ |
///
|
|
301 |
+ |
/// Not filed as its own task. `Arrangement`'s docs say the two members came from
|
|
302 |
+ |
/// measuring the two apps and that discovering the layer missing after the
|
|
303 |
+ |
/// renderers exist is a redesign; this is the first counter-example and one
|
|
304 |
+ |
/// counter-example is not a member. It is noted on the `Region::Bespoke`
|
|
305 |
+ |
/// reasoning instead: a dashboard is a candidate for the app owning its own
|
|
306 |
+ |
/// arrangement, the way it owns the heatmap.
|
|
307 |
+ |
fn column(id: &str, title: &str, body: Node) -> Slot {
|
|
308 |
+ |
Slot::new(id, RegionKind::Pane)
|
|
309 |
+ |
.with(Node::section(title))
|
|
310 |
+ |
.with(body)
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
/// The whole dashboard.
|
|
314 |
+ |
fn screen(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Screen, RouteError> {
|
|
315 |
+ |
let project: Project = state
|
|
316 |
+ |
.projects
|
|
317 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
318 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
319 |
+ |
.ok_or_else(|| RouteError::not_found("no such project"))?;
|
|
320 |
+ |
|
|
321 |
+ |
let internal = |error: goingson_core::CoreError| RouteError::internal(error.to_string());
|
|
322 |
+ |
let tasks = state
|
|
323 |
+ |
.tasks
|
|
324 |
+ |
.list_by_project(DESKTOP_USER_ID, id)
|
|
325 |
+ |
.map_err(internal)?;
|
|
326 |
+ |
let events = state
|
|
327 |
+ |
.events
|
|
328 |
+ |
.list_by_project(DESKTOP_USER_ID, id)
|
|
329 |
+ |
.map_err(internal)?;
|
|
330 |
+ |
let emails = state
|
|
331 |
+ |
.emails
|
|
332 |
+ |
.list_by_project(DESKTOP_USER_ID, id)
|
|
333 |
+ |
.map_err(internal)?;
|
|
334 |
+ |
let files = state
|
|
335 |
+ |
.attachments
|
|
336 |
+ |
.list_for_project(id, DESKTOP_USER_ID)
|
|
337 |
+ |
.map_err(internal)?;
|
|
338 |
+ |
let all_milestones = state
|
|
339 |
+ |
.milestones
|
|
340 |
+ |
.list_by_project(id, DESKTOP_USER_ID)
|
|
341 |
+ |
.map_err(internal)?;
|
|
342 |
+ |
|
|
343 |
+ |
let band = Slot::new("dashboard-band", RegionKind::Band)
|
|
344 |
+ |
.with(Node::page(&project.name))
|
|
345 |
+ |
.with(Node::Token(Tag::badge(type_label(&project.project_type))))
|
|
346 |
+ |
.with(Node::Token(
|
|
347 |
+ |
Tag::badge(project.status.as_str()).tone(status_tone(&project.status)),
|
|
348 |
+ |
))
|
|
349 |
+ |
.with(Node::act(
|
|
350 |
+ |
"Back to projects",
|
|
351 |
+ |
Action::get(format!("/projects/{id}")),
|
|
352 |
+ |
));
|
|
353 |
+ |
|
|
354 |
+ |
Ok(Screen::list_detail("Project", false)
|
|
355 |
+ |
.with(band)
|
|
356 |
+ |
.with(
|
|
357 |
+ |
Slot::new("dashboard-milestones", RegionKind::Pane).extend(milestones(
|
|
358 |
+ |
id,
|
|
359 |
+ |
&all_milestones,
|
|
360 |
+ |
&tasks,
|
|
361 |
+ |
show_completed,
|
|
362 |
+ |
)),
|
|
363 |
+ |
)
|
|
364 |
+ |
.with(column("dashboard-tasks", "Tasks", tasks_column(&tasks)))
|
|
365 |
+ |
.with(column("dashboard-events", "Events", events_column(&events)))
|
|
366 |
+ |
.with(column("dashboard-emails", "Emails", emails_column(&emails)))
|
|
367 |
+ |
.with(column(
|
|
368 |
+ |
"dashboard-attachments",
|
|
369 |
+ |
"Attachments",
|
|
370 |
+ |
attachments_column(&files),
|
|
371 |
+ |
)))
|
|
372 |
+ |
}
|
|
373 |
+ |
|
|
374 |
+ |
/// Whether a param is on.
|
|
375 |
+ |
fn flag(params: &quasi_router::Params, name: &str) -> bool {
|
|
376 |
+ |
matches!(params.get(name), Some("1" | "true"))
|
|
377 |
+ |
}
|
|
378 |
+ |
|
|
379 |
+ |
/// The dashboard.
|
|
380 |
+ |
fn dashboard(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
381 |
+ |
let id = project_id(¶ms)?;
|
|
382 |
+ |
Ok(screen(state, id, flag(¶ms, "completed"))?.into())
|
|
383 |
+ |
}
|
|
384 |
+ |
|
|
385 |
+ |
/// Answer a write with the dashboard it happened on, re-read.
|
|
386 |
+ |
fn wrote(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Response, RouteError> {
|
|
387 |
+ |
Ok(screen(state, id, show_completed)?.into())
|
|
388 |
+ |
}
|
|
389 |
+ |
|
|
390 |
+ |
/// The milestone a route was addressed at.
|
|
391 |
+ |
fn milestone_id(params: &quasi_router::Params) -> Result<goingson_core::MilestoneId, RouteError> {
|
|
392 |
+ |
let raw = params
|
|
393 |
+ |
.get("milestone")
|
|
394 |
+ |
.ok_or_else(|| RouteError::not_found("no milestone id"))?;
|
|
395 |
+ |
Ok(goingson_core::MilestoneId::from(
|
|
396 |
+ |
uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a milestone id"))?,
|
|
397 |
+ |
))
|
|
398 |
+ |
}
|
|
399 |
+ |
|
|
400 |
+ |
/// Move a milestone one place up or down among the open ones.
|
|
401 |
+ |
///
|
|
402 |
+ |
/// `reorder` takes the whole order rather than a swap, so the handler reads the
|
|
403 |
+ |
/// current order, moves one, and writes it back. The order it writes is every
|
|
404 |
+ |
/// milestone, completed ones included: they carry positions too, and sending
|
|
405 |
+ |
/// only the open ones would silently renumber the rest.
|
|
406 |
+ |
fn move_milestone(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
407 |
+ |
let project = project_id(¶ms)?;
|
|
408 |
+ |
let target = milestone_id(¶ms)?;
|
|
409 |
+ |
let by: i32 = params
|
|
410 |
+ |
.get("by")
|
|
411 |
+ |
.and_then(|raw| raw.parse().ok())
|
|
412 |
+ |
.filter(|by| *by == -1 || *by == 1)
|
|
413 |
+ |
.ok_or_else(|| RouteError::not_found("move by -1 or 1"))?;
|
|
414 |
+ |
|
|
415 |
+ |
let mut order: Vec<goingson_core::MilestoneId> = state
|
|
416 |
+ |
.milestones
|
|
417 |
+ |
.list_by_project(project, DESKTOP_USER_ID)
|
|
418 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
419 |
+ |
.iter()
|
|
420 |
+ |
.map(|milestone| milestone.id)
|
|
421 |
+ |
.collect();
|
|
422 |
+ |
|
|
423 |
+ |
let at = order
|
|
424 |
+ |
.iter()
|
|
425 |
+ |
.position(|id| *id == target)
|
|
426 |
+ |
.ok_or_else(|| RouteError::not_found("no such milestone"))?;
|
|
427 |
+ |
let to = at as i32 + by;
|
|
428 |
+ |
// Off either end is a no-op rather than an error: the control that sent it
|
|
429 |
+ |
// is disabled, so arriving here means a stale screen, and the right answer
|
|
430 |
+ |
// to a stale screen is a fresh one.
|
|
431 |
+ |
if to >= 0 && (to as usize) < order.len() {
|
|
432 |
+ |
order.swap(at, to as usize);
|
|
433 |
+ |
state
|
|
434 |
+ |
.milestones
|
|
435 |
+ |
.reorder(project, DESKTOP_USER_ID, &order)
|
|
436 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
437 |
+ |
}
|
|
438 |
+ |
|
|
439 |
+ |
wrote(state, project, flag(¶ms, "completed"))
|
|
440 |
+ |
}
|
|
441 |
+ |
|
|
442 |
+ |
/// Delete a milestone.
|
|
443 |
+ |
fn delete_milestone(
|
|
444 |
+ |
state: &AppState,
|
|
445 |
+ |
params: quasi_router::Params,
|
|
446 |
+ |
) -> Result<Response, RouteError> {
|
|
447 |
+ |
let project = project_id(¶ms)?;
|
|
448 |
+ |
let deleted = state
|
|
449 |
+ |
.milestones
|
|
450 |
+ |
.delete(milestone_id(¶ms)?, DESKTOP_USER_ID)
|
|
451 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
452 |
+ |
if !deleted {
|
|
453 |
+ |
return Err(RouteError::not_found("no such milestone"));
|
|
454 |
+ |
}
|
|
455 |
+ |
wrote(state, project, flag(¶ms, "completed"))
|
|
456 |
+ |
}
|
|
457 |
+ |
|
|
458 |
+ |
/// The dashboard's routes.
|
|
459 |
+ |
#[must_use]
|
|
460 |
+ |
pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
|
|
461 |
+ |
router
|
|
462 |
+ |
.get("/projects/:id/dashboard", dashboard)
|
|
463 |
+ |
.post("/projects/:id/milestones/:milestone/move", move_milestone)
|
|
464 |
+ |
.post(
|
|
465 |
+ |
"/projects/:id/milestones/:milestone/delete",
|
|
466 |
+ |
delete_milestone,
|
|
467 |
+ |
)
|
|
468 |
+ |
}
|