Calendar · Editing: an event's editor, its repeats, reminders and open times

EventEditor puts an event's parts in a form: its title, whole days or
hours, where, its color, how it repeats, its reminders and notes. Save
waits for a title and an end after the start and hands on an
EventDraft; Cancel and Delete show when the owner handles them. A new
draft from the owner starts the editor over.

RecurrenceEditor sets a rule after RFC 5545: daily, weekly on its days,
monthly or yearly, every so many, until a day or a count. A line reads
the rule back with the next days it falls on. ReminderPicker keeps
reminders from a fixed list, soonest first, and Add brings the nearest
one not yet set. AvailabilityPicker counts the open slots of each day
and lists the chosen day's, around what is busy, inside working hours
and after now.

FormField filled its column with a percent width, which taffy 0.9
leaves at content width when a block measures the column first and the
width comes out the same: at 280px the editor's fields shrank to their
labels. It now fills with min_w_full, as new roots do; older roots move
over in T44.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 04:22:02 -04:00
1 parent 48ff348357
commit 0bcfd82bf3
18 files changed
+1856 -12

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
use gpui::{Entity, IntoElement, Modifiers, TestAppContext, VisualTestContext, point, px};
use jiff::{civil::date, tz::TimeZone};
use super::{Planning, at, heard, note, planning, press, settle, tab_to};
use crate::calendar::{
AvailabilityPicker, EventDraft, EventEditor, RecurrenceEditor, ReminderPicker, When,
};
fn draft(title: &str, from: i8, to: i8) -> EventDraft {
EventDraft {
title: title.to_string().into(),
when: When::Timed {
start: at(22, from),
end: at(22, to),
},
place: "".into(),
hue: 0,
repeat: None,
reminders: Vec::new(),
notes: "".into(),
}
}
fn said(draft: &EventDraft) -> String {
match draft.when {
When::Timed { start, end } => format!("save {} {start} {end}", draft.title),
When::AllDay { first, last } => format!("save {} {first} to {last}", draft.title),
}
}
fn editor(host: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
let title = match host.month.day() {
1 => "",
_ => "Crit",
};
EventEditor::new("editor", draft(title, 9, 10))
.zone(TimeZone::UTC)
.on_save(move |draft, _, cx| note(&owner, said(draft), cx))
.into_any_element()
}
/// Presses the editor's last action, Save, at the right end of its row.
fn save(cx: &mut VisualTestContext) {
let row = cx.debug_bounds("editor-actions").expect("the actions draw");
cx.simulate_click(
point(row.right() - px(8.0), row.center().y),
Modifiers::none(),
);
settle(cx);
}
#[gpui::test]
fn save_waits_for_a_title_then_hands_on_the_draft(cx: &mut TestAppContext) {
let (host, cx) = planning(editor, cx);
save(cx);
assert!(heard(&host, cx).is_empty(), "no title, no save");
tab_to(1, cx);
cx.simulate_input(" Model review ");
settle(cx);
save(cx);
assert_eq!(
heard(&host, cx),
[format!("save Model review {} {}", at(22, 9), at(22, 10))]
);
}
#[gpui::test]
fn all_day_turns_the_hours_into_their_day(cx: &mut TestAppContext) {
let (host, cx) = planning(editor, cx);
tab_to(1, cx);
cx.simulate_input("Fair");
settle(cx);
tab_to(2, cx);
press("space", cx);
save(cx);
assert_eq!(heard(&host, cx), ["save Fair 2026-09-22 to 2026-09-22"]);
}
fn backward(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
EventEditor::new("editor", draft("Crit", 10, 9))
.zone(TimeZone::UTC)
.on_save(move |draft, _, cx| note(&owner, said(draft), cx))
.into_any_element()
}
#[gpui::test]
fn an_end_before_the_start_says_so_and_holds_save(cx: &mut TestAppContext) {
let (host, cx) = planning(backward, cx);
assert!(cx.debug_bounds("editor-fault").is_some());
save(cx);
assert!(heard(&host, cx).is_empty());
}
#[gpui::test]
fn a_new_draft_from_the_owner_starts_the_editor_over(cx: &mut TestAppContext) {
let (host, cx) = planning(editor, cx);
host.update(cx, |host, cx| {
host.month = date(2026, 10, 2);
cx.notify();
});
settle(cx);
save(cx);
assert_eq!(
heard(&host, cx),
[format!("save Crit {} {}", at(22, 9), at(22, 10))]
);
}
fn reminders(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
ReminderPicker::new("reminders", [10])
.on_change(move |minutes, _, cx| note(&owner, format!("{minutes:?}"), cx))
.into_any_element()
}
#[gpui::test]
fn add_brings_the_nearest_reminder_not_yet_set(cx: &mut TestAppContext) {
let (host, cx) = planning(reminders, cx);
tab_to(3, cx);
press("space", cx);
tab_to(2, cx);
press("space", cx);
assert_eq!(heard(&host, cx), ["[0, 10]", "[]"], "Add, then the way off");
}
fn repeat(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
RecurrenceEditor::new("repeat", date(2026, 9, 27), None)
.on_change(move |rule, _, cx| {
let words = rule.map(|rule| rule.describe(date(2026, 9, 27)));
note(&owner, format!("{words:?}"), cx)
})
.into_any_element()
}
#[gpui::test]
fn picking_a_frequency_starts_a_rule(cx: &mut TestAppContext) {
let (host, cx) = planning(repeat, cx);
tab_to(1, cx);
cx.simulate_keystrokes("down down down enter");
settle(cx);
assert_eq!(heard(&host, cx), ["Some(\"Every week on Sunday\")"]);
}
fn slots(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
AvailabilityPicker::new("slots", [date(2026, 9, 28), date(2026, 9, 29)])
.zone(TimeZone::UTC)
.hours(9, 12)
.busy([(at(28, 9), at(28, 10))])
.now(at(27, 12))
.on_pick(move |start, _, cx| note(&owner, format!("pick {start}"), cx))
.into_any_element()
}
#[gpui::test]
fn a_slot_pick_hands_on_its_start(cx: &mut TestAppContext) {
let (host, cx) = planning(slots, cx);
tab_to(3, cx);
press("space", cx);
assert_eq!(
heard(&host, cx),
[format!("pick {}", at(28, 10))],
"09:00 to 10:00 is taken"
);
}
+1
View File
@@ -7,6 +7,7 @@ use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{CalendarMonthView, Event, YearView};
mod editing;
mod people;
mod times;
use crate::{primitives::FocusNext, theme::Theme};