| 42 |
42 |
|
//! repository read as equivalent would show snoozed events on every visit. See
|
| 43 |
43 |
|
//! [`sections`].
|
| 44 |
44 |
|
//!
|
| 45 |
|
- |
//! # What this port could not say, and did not fake
|
|
45 |
+ |
//! # The form, and the two words it waited for
|
| 46 |
46 |
|
//!
|
| 47 |
|
- |
//! The read side is complete. The write side is one route, delete, and the rest
|
| 48 |
|
- |
//! is left out rather than described badly. Four things refused, and none of
|
| 49 |
|
- |
//! them is new — each is a vocabulary gap already filed from another screen:
|
|
47 |
+ |
//! `8fdb814c`. The read side shipped first and refused the write side, because
|
|
48 |
+ |
//! two things about `events.js`'s form had no description. Both are vocabulary
|
|
49 |
+ |
//! now and both are used here:
|
| 50 |
50 |
|
//!
|
| 51 |
|
- |
//! 1. **The event form's timezone block.** `initTzKindConfig` shows and hides
|
| 52 |
|
- |
//! the zone picker depending on which of the three `TzKind` values is
|
| 53 |
|
- |
//! selected. `FieldKind` has no conditionally-present field, which is the
|
| 54 |
|
- |
//! same gap `quasi::tasks::edit_fields` records against the recurrence rule.
|
| 55 |
|
- |
//! A form that always showed the picker would be describing a different
|
| 56 |
|
- |
//! screen.
|
| 57 |
|
- |
//! 2. **Reminders.** `collectReminderOffsets` reads a variable number of offsets
|
| 58 |
|
- |
//! off the form. A field holds one value or one choice, so a repeating group
|
| 59 |
|
- |
//! has nothing to be.
|
| 60 |
|
- |
//! 3. **The recurring scope question.** `confirmRecurringScope` asks "this
|
| 61 |
|
- |
//! occurrence or the whole series?" before a delete or an edit lands. That is
|
| 62 |
|
- |
//! a write that pauses for an answer, and the delete route here refuses a
|
| 63 |
|
- |
//! template rather than guessing which the user meant.
|
| 64 |
|
- |
//! 4. **Bulk selection.** `toggleEventSelection` and `bulkDeleteEvents` act on a
|
| 65 |
|
- |
//! set gathered across rows. `Row::selectable` draws the tick, and nothing
|
| 66 |
|
- |
//! carries the set to a write.
|
|
51 |
+ |
//! 1. **The timezone block.** `initTzKindConfig` shows the "Anchored to" box on
|
|
52 |
+ |
//! one of the three `TzKind` values and hides it on the other two.
|
|
53 |
+ |
//! [`quasi_router::Field::revealed_by`] is that, and the box sits in the
|
|
54 |
+ |
//! form it submits with rather than in a section beside it.
|
|
55 |
+ |
//! 2. **Reminders.** `collectReminderOffsets` reads a variable number of
|
|
56 |
+ |
//! offsets off the form, and `Event::reminder_offsets_seconds` has always
|
|
57 |
+ |
//! been a `Vec<i64>`. [`quasi_router::Repeat`] is that: one question, N
|
|
58 |
+ |
//! slots, one submit, and `sanitize_reminder_offsets`'s cap of eight said on
|
|
59 |
+ |
//! the screen rather than applied silently on the way in.
|
| 67 |
60 |
|
//!
|
| 68 |
|
- |
//! Recording them here rather than filing four new tasks: they are one gap seen
|
| 69 |
|
- |
//! four times, they are already filed against the screens that found them
|
| 70 |
|
- |
//! first, and this port is evidence of how often they recur rather than a new
|
| 71 |
|
- |
//! finding.
|
|
61 |
+ |
//! Two of the four refusals stand, and neither is this screen's to invent:
|
|
62 |
+ |
//!
|
|
63 |
+ |
//! - **The recurring scope question.** `confirmRecurringScope` asks "this
|
|
64 |
+ |
//! occurrence or the whole series?" before a delete or an edit lands. That is
|
|
65 |
+ |
//! a write that pauses for an answer, and the delete route here refuses a
|
|
66 |
+ |
//! template rather than guessing which the user meant. The form threads a
|
|
67 |
+ |
//! stored rule through untouched for the same reason.
|
|
68 |
+ |
//! - **Bulk selection.** `toggleEventSelection` and `bulkDeleteEvents` act on a
|
|
69 |
+ |
//! set gathered across rows. `Row::selectable` draws the tick, and nothing
|
|
70 |
+ |
//! carries the set to a write.
|
|
71 |
+ |
//!
|
|
72 |
+ |
//! # The write goes through the command, not around it
|
|
73 |
+ |
//!
|
|
74 |
+ |
//! [`crate::commands::event::create_event_now`] and its update counterpart are
|
|
75 |
+ |
//! what the routes here call. All-day snapping, the four timezone columns and
|
|
76 |
+ |
//! re-deriving the UTC pair from the civil truth are decided there, and a
|
|
77 |
+ |
//! described form authoring its own copy of them would be a second answer to
|
|
78 |
+ |
//! what a wall clock means. It is the same instruction the read side records
|
|
79 |
+ |
//! about `list_events`: look at what the command does before assuming the
|
|
80 |
+ |
//! layer underneath it is the same thing.
|
| 72 |
81 |
|
|
| 73 |
82 |
|
// Handlers take their request by value because `quasi_router::Handler` is a
|
| 74 |
83 |
|
// plain `fn(&S, Request)` pointer, so the signature is the router's and not a
|
| 75 |
84 |
|
// choice made here.
|
| 76 |
85 |
|
#![allow(clippy::needless_pass_by_value)]
|
| 77 |
86 |
|
|
| 78 |
|
- |
use chrono::Utc;
|
| 79 |
|
- |
use goingson_core::{Event, EventId};
|
| 80 |
|
- |
use quasi_router::screen::{Row, Tag};
|
| 81 |
|
- |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
87 |
+ |
use chrono::{Local, NaiveDateTime, Utc};
|
|
88 |
+ |
use goingson_core::{BlockType, DbValue, Event, EventId, Recurrence, TzKind, parse_natural_date};
|
|
89 |
+ |
use quasi_router::screen::{Choice, Field, Repeat, Row, Tag};
|
|
90 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, Reveal, RouteError, Router, Screen, Slot};
|
| 82 |
91 |
|
|
|
92 |
+ |
use crate::commands::event::{EventInput, create_event_now, update_event_now};
|
| 83 |
93 |
|
use crate::state::{AppState, DESKTOP_USER_ID};
|
| 84 |
94 |
|
|
| 85 |
95 |
|
#[cfg(test)]
|
| 218 |
228 |
|
|
| 219 |
229 |
|
/// The whole screen.
|
| 220 |
230 |
|
fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
| 221 |
|
- |
let snoozed = snoozed_on(&request);
|
|
231 |
+ |
Ok(screen(state, snoozed_on(&request))?.into())
|
|
232 |
+ |
}
|
| 222 |
233 |
|
|
|
234 |
+ |
/// The screen under one filter, which a read and a write both answer with.
|
|
235 |
+ |
fn screen(state: &AppState, snoozed: bool) -> Result<Screen, RouteError> {
|
| 223 |
236 |
|
let band = Slot::new("events-band", RegionKind::Band)
|
| 224 |
237 |
|
.with(Node::page("Events"))
|
|
238 |
+ |
.with(Node::act("New event", Action::get("/events/new")))
|
| 225 |
239 |
|
.with(Node::Token(
|
| 226 |
240 |
|
Tag::chip("Snoozed", list_action(!snoozed)).latched(snoozed),
|
| 227 |
241 |
|
));
|
| 235 |
249 |
|
.at_place(super::shell::EVENTS)
|
| 236 |
250 |
|
.with(band)
|
| 237 |
251 |
|
.with(pane)
|
| 238 |
|
- |
.with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
|
| 239 |
|
- |
.into())
|
|
252 |
+ |
.with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))))
|
| 240 |
253 |
|
}
|
| 241 |
254 |
|
|
| 242 |
255 |
|
/// The list alone, which is what the filter and a delete swap.
|
| 313 |
326 |
|
// Delete is offered on an occurrence and withheld on a template, which is
|
| 314 |
327 |
|
// the scope question the JS asks with a dialog. Refusing is not the answer
|
| 315 |
328 |
|
// it should end at; it is the honest state until a write can pause for one.
|
|
329 |
+ |
nodes.push(Node::act(
|
|
330 |
+ |
"Edit".to_owned(),
|
|
331 |
+ |
Action::get(format!("/events/{}/edit", event.id)),
|
|
332 |
+ |
));
|
|
333 |
+ |
|
| 316 |
334 |
|
if is_template(&event) {
|
| 317 |
335 |
|
nodes.push(Node::empty(
|
| 318 |
336 |
|
"This is a recurring rule. Deleting it needs the scope question, which nothing describes yet.",
|
| 356 |
374 |
|
list(state, request)
|
| 357 |
375 |
|
}
|
| 358 |
376 |
|
|
|
377 |
+ |
// The form. `8fdb814c`.
|
|
378 |
+ |
|
|
379 |
+ |
/// How an event's time relates to a zone, labelled as `events.js` labels it:
|
|
380 |
+ |
/// for what the choice does rather than for the stored word, because "relative
|
|
381 |
+ |
/// vs local vs absolute" means nothing standing in front of a calendar.
|
|
382 |
+ |
const TZ_KINDS: [(&str, &str); 3] = [
|
|
383 |
+ |
("relative", "Relative to me (follows me when I travel)"),
|
|
384 |
+ |
("local", "Anchored to a place"),
|
|
385 |
+ |
("absolute", "Fixed point in time"),
|
|
386 |
+ |
];
|
|
387 |
+ |
|
|
388 |
+ |
/// The block types the shipped select offers, empty first for an ordinary
|
|
389 |
+ |
/// event. The empty option is what clears one, which is why it is a real
|
|
390 |
+ |
/// member here rather than an absent value.
|
|
391 |
+ |
const BLOCK_TYPES: [(&str, &str); 5] = [
|
|
392 |
+ |
("", "Regular Event"),
|
|
393 |
+ |
("free_time", "Free Time"),
|
|
394 |
+ |
("personal", "Personal"),
|
|
395 |
+ |
("vacation", "Vacation"),
|
|
396 |
+ |
("focus", "Focus"),
|
|
397 |
+ |
];
|
|
398 |
+ |
|
|
399 |
+ |
/// The four patterns the recurrence select offers.
|
|
400 |
+ |
const RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
|
|
401 |
+ |
|
|
402 |
+ |
/// The most reminders one event may carry.
|
|
403 |
+ |
///
|
|
404 |
+ |
/// `sanitize_reminder_offsets`'s cap, said on the screen. The write path
|
|
405 |
+ |
/// truncates silently, so a form that did not say it would take a ninth
|
|
406 |
+ |
/// reminder and throw it away without telling anyone.
|
|
407 |
+ |
const MOST_REMINDERS: usize = 8;
|
|
408 |
+ |
|
|
409 |
+ |
/// The shape a datetime box offers and accepts.
|
|
410 |
+ |
///
|
|
411 |
+ |
/// `getEventFormFields` builds the same string out of a `Date`, and
|
|
412 |
+ |
/// [`parse_natural_date`] reads it back, so the value the form offers is a
|
|
413 |
+ |
/// value the form accepts. A prefill the parser would reject is a field that
|
|
414 |
+ |
/// cannot be left alone.
|
|
415 |
+ |
const TYPED_TIME: &str = "%Y-%m-%dT%H:%M";
|
|
416 |
+ |
|
|
417 |
+ |
/// Why a submission was refused, in the three shapes a form can say it.
|
|
418 |
+ |
#[derive(Debug, Default)]
|
|
419 |
+ |
struct Refusal {
|
|
420 |
+ |
/// What is wrong with one question, by field name.
|
|
421 |
+ |
fields: Vec<(&'static str, String)>,
|
|
422 |
+ |
/// What is wrong with one reminder, by slot.
|
|
423 |
+ |
///
|
|
424 |
+ |
/// A repeating question needs this and [`fields`](Self::fields) cannot
|
|
425 |
+ |
/// carry it: one string on one field can say the set is wrong and never
|
|
426 |
+ |
/// which answer is.
|
|
427 |
+ |
slots: Vec<(usize, String)>,
|
|
428 |
+ |
/// A refusal from the write itself that names no question of this form.
|
|
429 |
+ |
///
|
|
430 |
+ |
/// Mapped where it can be and shown whole where it cannot, rather than
|
|
431 |
+ |
/// hung on whichever field is nearest: a message pinned to the wrong box
|
|
432 |
+ |
/// is worse than a message above the form.
|
|
433 |
+ |
notice: Option<String>,
|
|
434 |
+ |
}
|
|
435 |
+ |
|
|
436 |
+ |
impl Refusal {
|
|
437 |
+ |
/// Whether anything is wrong.
|
|
438 |
+ |
fn any(&self) -> bool {
|
|
439 |
+ |
!self.fields.is_empty() || !self.slots.is_empty() || self.notice.is_some()
|
|
440 |
+ |
}
|
|
441 |
+ |
|
|
442 |
+ |
/// What is wrong with one question, if anything is.
|
|
443 |
+ |
fn about(&self, name: &str) -> Option<String> {
|
|
444 |
+ |
self.fields
|
|
445 |
+ |
.iter()
|
|
446 |
+ |
.find(|(field, _)| *field == name)
|
|
447 |
+ |
.map(|(_, message)| message.clone())
|
|
448 |
+ |
}
|
|
449 |
+ |
}
|
|
450 |
+ |
|
|
451 |
+ |
/// The value a datetime box should show for a start or an end.
|
|
452 |
+ |
///
|
|
453 |
+ |
/// A civil-kind event's stored wall clock is used verbatim. An anchored
|
|
454 |
+ |
/// event's instant means 10:00 *there*, and rendering it through this
|
|
455 |
+ |
/// machine's zone would show the reader's offset instead of the time the user
|
|
456 |
+ |
/// set. The instant, converted to local, is the fallback and is the whole
|
|
457 |
+ |
/// answer for a fixed event. `prefillTime` makes the same three choices.
|
|
458 |
+ |
fn typed_time(
|
|
459 |
+ |
kind: TzKind,
|
|
460 |
+ |
civil: Option<NaiveDateTime>,
|
|
461 |
+ |
instant: Option<chrono::DateTime<Utc>>,
|
|
462 |
+ |
) -> String {
|
|
463 |
+ |
if kind.is_civil()
|
|
464 |
+ |
&& let Some(civil) = civil
|
|
465 |
+ |
{
|
|
466 |
+ |
return civil.format(TYPED_TIME).to_string();
|
|
467 |
+ |
}
|
|
468 |
+ |
instant.map_or_else(String::new, |instant| {
|
|
469 |
+ |
instant.with_timezone(&Local).format(TYPED_TIME).to_string()
|
|
470 |
+ |
})
|
|
471 |
+ |
}
|
|
472 |
+ |
|
|
473 |
+ |
/// The reminders standing in the form, as strings under one question.
|
|
474 |
+ |
///
|
|
475 |
+ |
/// From the submission when there is one, so a refusal hands back the slots
|
|
476 |
+ |
/// the reader had rather than the ones the record holds; from the event
|
|
477 |
+ |
/// otherwise. Seconds either way, which is the column's own unit.
|
|
478 |
+ |
fn reminder_slots(event: Option<&Event>, submitted: Option<&quasi_router::Params>) -> Vec<String> {
|
|
479 |
+ |
match submitted {
|
|
480 |
+ |
Some(params) => params
|
|
481 |
+ |
.repeated("reminder")
|
|
482 |
+ |
.into_iter()
|
|
483 |
+ |
.map(ToOwned::to_owned)
|
|
484 |
+ |
.collect(),
|
|
485 |
+ |
None => event.map_or_else(Vec::new, |event| {
|
|
486 |
+ |
event
|
|
487 |
+ |
.reminder_offsets_seconds
|
|
488 |
+ |
.iter()
|
|
489 |
+ |
.map(i64::to_string)
|
|
490 |
+ |
.collect()
|
|
491 |
+ |
}),
|
|
492 |
+ |
}
|
|
493 |
+ |
}
|
|
494 |
+ |
|
|
495 |
+ |
/// The questions the form asks, in `getEventFormFields`'s order.
|
|
496 |
+ |
///
|
|
497 |
+ |
/// # What it asks that the JS form does not
|
|
498 |
+ |
///
|
|
499 |
+ |
/// **The project.** `getEventFormFields` carries a project only as a hidden
|
|
500 |
+ |
/// field, set by `openNewForProject` and absent from the form reached from the
|
|
501 |
+ |
/// calendar. A described form with no project question would be worse than
|
|
502 |
+ |
/// that rather than equal to it: the update write takes the project from the
|
|
503 |
+ |
/// submission, so a form that never asked would clear the project of every
|
|
504 |
+ |
/// event edited from here. A select that offers "No Project" says the same
|
|
505 |
+ |
/// thing without losing anything.
|
|
506 |
+ |
///
|
|
507 |
+ |
/// # What it does not ask, and why
|
|
508 |
+ |
///
|
|
509 |
+ |
/// **The recurrence rule.** The select offers the four patterns and stops
|
|
510 |
+ |
/// there, which is exactly where `quasi::tasks::edit_fields` stops and for the
|
|
511 |
+ |
/// same reason: `buildRecurrenceConfigHtml` grows a second form underneath it
|
|
512 |
+ |
/// whose shape changes with the pattern, and a multi-select is still missing
|
|
513 |
+ |
/// from `FieldKind`. The consequence is handled rather than ignored:
|
|
514 |
+ |
/// [`answers`] threads the stored [`Event::recurrence_rule`] through
|
|
515 |
+ |
/// untouched, so editing the title of an event that repeats on Tuesdays leaves
|
|
516 |
+ |
/// it repeating on Tuesdays.
|
|
517 |
+ |
fn form_fields(
|
|
518 |
+ |
state: &AppState,
|
|
519 |
+ |
event: Option<&Event>,
|
|
520 |
+ |
refusal: &Refusal,
|
|
521 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
522 |
+ |
) -> Result<Vec<Field>, RouteError> {
|
|
523 |
+ |
let apply = |field: Field, name: &str| match refusal.about(name) {
|
|
524 |
+ |
Some(message) => field.error(message),
|
|
525 |
+ |
None => field,
|
|
526 |
+ |
};
|
|
527 |
+ |
let projects = state
|
|
528 |
+ |
.projects
|
|
529 |
+ |
.list_all(DESKTOP_USER_ID)
|
|
530 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
531 |
+ |
let contacts = state
|
|
532 |
+ |
.contacts
|
|
533 |
+ |
.list_all(DESKTOP_USER_ID)
|
|
534 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
535 |
+ |
|
|
536 |
+ |
let kind = event.map_or(TzKind::Relative, |event| event.tz_kind);
|
|
537 |
+ |
let all_day = event.is_some_and(|event| event.is_all_day_in(&Local));
|
|
538 |
+ |
|
|
539 |
+ |
let mut all_day_field = Field::new(
|
|
540 |
+ |
makeover_layout::FieldKind::Checkbox,
|
|
541 |
+ |
"is_all_day",
|
|
542 |
+ |
"All day",
|
|
543 |
+ |
)
|
|
544 |
+ |
.hint("Removes the time component. The event spans the whole day.");
|
|
545 |
+ |
if all_day {
|
|
546 |
+ |
all_day_field = all_day_field.value(Node::SELECTED);
|
|
547 |
+ |
}
|
|
548 |
+ |
|
|
549 |
+ |
let mut title = Field::new(makeover_layout::FieldKind::Text, "title", "Title")
|
|
550 |
+ |
.required()
|
|
551 |
+ |
.value(event.map_or("", |event| event.title.as_str()));
|
|
552 |
+ |
title.placeholder = Some("Event title".to_owned());
|
|
553 |
+ |
|
|
554 |
+ |
let mut description = Field::new(
|
|
555 |
+ |
makeover_layout::FieldKind::Textarea,
|
|
556 |
+ |
"description",
|
|
557 |
+ |
"Description",
|
|
558 |
+ |
)
|
|
559 |
+ |
.value(event.map_or("", |event| event.description.as_str()));
|
|
560 |
+ |
description.placeholder = Some("Event details...".to_owned());
|
|
561 |
+ |
|
|
562 |
+ |
// Opens on now for a new event, which is what `getEventFormFields` fills
|
|
563 |
+ |
// the box with: a calendar form with an empty start is a chore, and the
|
|
564 |
+ |
// common case is something soon.
|
|
565 |
+ |
let start_value = event.map_or_else(
|
|
566 |
+ |
|| Local::now().format(TYPED_TIME).to_string(),
|
|
567 |
+ |
|event| typed_time(event.tz_kind, event.start_local, Some(event.start_time)),
|
|
568 |
+ |
);
|
|
569 |
+ |
let mut start = Field::new(
|
|
570 |
+ |
makeover_layout::FieldKind::Text,
|
|
571 |
+ |
"start_time",
|
|
572 |
+ |
"Start Date & Time",
|
|
573 |
+ |
)
|
|
574 |
+ |
.required()
|
|
575 |
+ |
.value(start_value);
|
|
576 |
+ |
start.placeholder = Some("tomorrow 3pm, friday 10:00, 2026-12-25...".to_owned());
|
|
577 |
+ |
|
|
578 |
+ |
let mut end = Field::new(
|
|
579 |
+ |
makeover_layout::FieldKind::Text,
|
|
580 |
+ |
"end_time",
|
|
581 |
+ |
"End Time (optional)",
|
|
582 |
+ |
)
|
|
583 |
+ |
.value(event.map_or_else(String::new, |event| {
|
|
584 |
+ |
typed_time(event.tz_kind, event.end_local, event.end_time)
|
|
585 |
+ |
}));
|
|
586 |
+ |
end.placeholder = Some("tomorrow 5pm, friday 12:00...".to_owned());
|
|
587 |
+ |
|
|
588 |
+ |
let mut location = Field::new(makeover_layout::FieldKind::Text, "location", "Location")
|
|
589 |
+ |
.value(event.map_or("", Event::location_or_empty));
|
|
590 |
+ |
location.placeholder = Some("Zoom / Office / Coffee Shop".to_owned());
|
|
591 |
+ |
|
|
592 |
+ |
// The zone box, out on one of the three kinds. `initTzKindConfig` is 20
|
|
593 |
+ |
// lines of DOM wiring that this one member replaces, and every renderer
|
|
594 |
+ |
// answers it without asking a route.
|
|
595 |
+ |
let mut zone = Field::new(makeover_layout::FieldKind::Text, "timezone", "Anchored to")
|
|
596 |
+ |
.hint("IANA zone name, e.g. America/Denver.")
|
|
597 |
+ |
.revealed_by(Reveal::holding("tz_kind", TzKind::Local.db_value()))
|
|
598 |
+ |
.value(
|
|
599 |
+ |
event
|
|
600 |
+ |
.and_then(|event| event.timezone.clone())
|
|
601 |
+ |
.unwrap_or_default(),
|
|
602 |
+ |
);
|
|
603 |
+ |
zone.placeholder = Some(goingson_core::tz::system_tz().name().to_owned());
|
|
604 |
+ |
|
|
605 |
+ |
let mut project_options = vec![Choice::new("", "No Project")];
|
|
606 |
+ |
project_options.extend(
|
|
607 |
+ |
projects
|
|
608 |
+ |
.iter()
|
|
609 |
+ |
.map(|project| Choice::new(project.id.to_string(), &project.name)),
|
|
610 |
+ |
);
|
|
611 |
+ |
|
|
612 |
+ |
let mut contact_options = vec![Choice::new("", "No Contact")];
|
|
613 |
+ |
contact_options.extend(
|
|
614 |
+ |
contacts
|
|
615 |
+ |
.iter()
|
|
616 |
+ |
.map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)),
|
|
617 |
+ |
);
|
|
618 |
+ |
|
|
619 |
+ |
// The reminders, as one question answered zero or more times. The ceiling
|
|
620 |
+ |
// is `sanitize_reminder_offsets`'s and is stated rather than applied
|
|
621 |
+ |
// silently; a message about the set rides on the question, and a message
|
|
622 |
+ |
// about one answer rides on its own slot.
|
|
623 |
+ |
let mut standing = Repeat::answered(reminder_slots(event, submitted))
|
|
624 |
+ |
.most(MOST_REMINDERS)
|
|
625 |
+ |
.adding("Add reminder")
|
|
626 |
+ |
.removing("Remove");
|
|
627 |
+ |
for (at, message) in &refusal.slots {
|
|
628 |
+ |
standing = standing.wrong(*at, message.clone());
|
|
629 |
+ |
}
|
|
630 |
+ |
let mut reminders = Field::new(makeover_layout::FieldKind::Number, "reminder", "Reminder")
|
|
631 |
+ |
.hint("Seconds before the event starts: 300 is five minutes, 3600 an hour.")
|
|
632 |
+ |
.unit("s")
|
|
633 |
+ |
.repeating(standing);
|
|
634 |
+ |
reminders.min = Some("0".to_owned());
|
|
635 |
+ |
|
|
636 |
+ |
let fields = vec![
|
|
637 |
+ |
all_day_field,
|
|
638 |
+ |
apply(title, "title"),
|
|
639 |
+ |
apply(description, "description"),
|
|
640 |
+ |
apply(start, "start_time"),
|
|
641 |
+ |
apply(end, "end_time"),
|
|
642 |
+ |
apply(location, "location"),
|
|
643 |
+ |
apply(
|
|
644 |
+ |
Field::select(
|
|
645 |
+ |
"recurrence",
|
|
646 |
+ |
"Recurrence",
|
|
647 |
+ |
RECURRENCES
|
|
648 |
+ |
.iter()
|
|
649 |
+ |
.map(|pattern| Choice::new(*pattern, *pattern))
|
|
650 |
+ |
.collect(),
|
|
651 |
+ |
)
|
|
652 |
+ |
.hint("Recurring events appear automatically on matching days")
|
|
653 |
+ |
.value(event.map_or(Recurrence::None.db_value(), |event| {
|
|
654 |
+ |
event.recurrence.db_value()
|
|
655 |
+ |
})),
|
|
656 |
+ |
"recurrence",
|
|
657 |
+ |
),
|
|
658 |
+ |
apply(
|
|
659 |
+ |
Field::select(
|
|
660 |
+ |
"tz_kind",
|
|
661 |
+ |
"Time zone",
|
|
662 |
+ |
TZ_KINDS
|
|
663 |
+ |
.iter()
|
|
664 |
+ |
.map(|(value, label)| Choice::new(*value, *label))
|
|
665 |
+ |
.collect(),
|
|
666 |
+ |
)
|
|
667 |
+ |
.hint("Relative follows you when you travel. Anchored stays put. Fixed is a moment in time.")
|
|
668 |
+ |
.value(kind.db_value()),
|
|
669 |
+ |
"tz_kind",
|
|
670 |
+ |
),
|
|
671 |
+ |
apply(zone, "timezone"),
|
|
672 |
+ |
apply(
|
|
673 |
+ |
Field::select(
|
|
674 |
+ |
"block_type",
|
|
675 |
+ |
"Type",
|
|
676 |
+ |
BLOCK_TYPES
|
|
677 |
+ |
.iter()
|
|
678 |
+ |
.map(|(value, label)| Choice::new(*value, *label))
|
|
679 |
+ |
.collect(),
|
|
680 |
+ |
)
|
|
681 |
+ |
.value(
|
|
682 |
+ |
event
|
|
683 |
+ |
.and_then(|event| event.block_type.as_ref())
|
|
684 |
+ |
.map_or("", DbValue::db_value),
|
|
685 |
+ |
),
|
|
686 |
+ |
"block_type",
|
|
687 |
+ |
),
|
|
688 |
+ |
apply(
|
|
689 |
+ |
Field::select("contact_id", "Contact", contact_options).value(
|
|
690 |
+ |
event
|
|
691 |
+ |
.and_then(|event| event.contact_id)
|
|
692 |
+ |
.map(|id| id.to_string())
|
|
693 |
+ |
.unwrap_or_default(),
|
|
694 |
+ |
),
|
|
695 |
+ |
"contact_id",
|
|
696 |
+ |
),
|
|
697 |
+ |
apply(
|
|
698 |
+ |
Field::select("project_id", "Project", project_options).value(
|
|
699 |
+ |
event
|
|
700 |
+ |
.and_then(|event| event.project_id)
|
|
701 |
+ |
.map(|id| id.to_string())
|
|
702 |
+ |
.unwrap_or_default(),
|
|
703 |
+ |
),
|
|
704 |
+ |
"project_id",
|
|
705 |
+ |
),
|
|
706 |
+ |
apply(reminders, "reminder"),
|
|
707 |
+ |
];
|
|
708 |
+ |
|
|
709 |
+ |
// Everything the reader typed, back in the boxes it was typed into. The
|
|
710 |
+ |
// reminders are already the submitted slots, and `refilled` leaves a
|
|
711 |
+ |
// repeating question alone: what it re-offers is one value under one name,
|
|
712 |
+ |
// and a question answered N times has neither.
|
|
713 |
+ |
Ok(match submitted {
|
|
714 |
+ |
Some(params) => fields
|
|
715 |
+ |
.into_iter()
|
|
716 |
+ |
.map(|field| field.refilled(params))
|
|
717 |
+ |
.collect(),
|
|
718 |
+ |
None => fields,
|
|
719 |
+ |
})
|
|
720 |
+ |
}
|
|
721 |
+ |
|
|
722 |
+ |
/// The form, in the pane the detail pane uses.
|
|
723 |
+ |
///
|
|
724 |
+ |
/// Both create and edit are this: the same questions, a different address and
|
|
725 |
+ |
/// a different submit label. An event being edited is the only thing that
|
|
726 |
+ |
/// differs, and it is what fills the boxes.
|
|
727 |
+ |
fn form_pane(
|
|
728 |
+ |
state: &AppState,
|
|
729 |
+ |
event: Option<&Event>,
|
|
730 |
+ |
refusal: &Refusal,
|
|
731 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
732 |
+ |
) -> Result<Node, RouteError> {
|
|
733 |
+ |
let (heading, action, submit) = match event {
|
|
734 |
+ |
Some(event) => (
|
|
735 |
+ |
format!("Edit {}", event.title),
|
|
736 |
+ |
Action::post(format!("/events/{}", event.id)),
|
|
737 |
+ |
"Save event",
|
|
738 |
+ |
),
|
|
739 |
+ |
None => (
|
|
740 |
+ |
"New event".to_owned(),
|
|
741 |
+ |
Action::post("/events"),
|
|
742 |
+ |
"Create event",
|
|
743 |
+ |
),
|
|
744 |
+ |
};
|
|
745 |
+ |
|
|
746 |
+ |
let mut region = Slot::new("events-detail", RegionKind::Pane).with(Node::section(heading));
|
|
747 |
+ |
if let Some(notice) = &refusal.notice {
|
|
748 |
+ |
region = region.with(Node::Text {
|
|
749 |
+ |
text: notice.clone(),
|
|
750 |
+ |
tone: makeover_layout::Tone::Danger,
|
|
751 |
+ |
});
|
|
752 |
+ |
}
|
|
753 |
+ |
Ok(Node::Region(region.with(Node::Form {
|
|
754 |
+ |
action,
|
|
755 |
+ |
submit: submit.to_owned(),
|
|
756 |
+ |
fields: form_fields(state, event, refusal, submitted)?,
|