Add and change what the sidebar lists
Check / guardrails (push) Successful in 1m27s
Check / bundle (push) Successful in 1m24s
Image / image (push) Successful in 2m39s
Check / check (push) Failing after 1m4s

Every one of these calls has existed in the typed client since M9 and M10 and
had nothing wired to it: create, delete and update for calendars, subscribe,
update and unsubscribe for feeds. The only field the interface ever sent was
`visible`, from the checkbox. So a person could see the calendars on their
server and hide them, and could not make one, rename one, recolour one, reorder
them, or subscribe to anything at all -- the "Subscribed" section did not even
draw unless a feed was already in the database, which no interface could put
there. M14 was scoped as "calendar list, visibility toggles" and nothing after
it picked up the rest.

It goes next to the list rather than into Settings. The sidebar already is the
list of sources; Settings is about the person reading them. A calendar's name
and colour are neither -- they belong to the calendar, they are written to the
CalDAV server, and every other client sees them.

That distinction needed one thing from the API. `color_source` was already
reported so the interface could offer "use the calendar's own colour", but the
colour itself was only ever sent after the override was applied, so an editor
had no way to know what the calendar's own colour was. One control for both
would have written somebody's private override onto the shared calendar the
first time they saved anything. `CalendarView` now carries `own_color` beside
`color`, and the editor shows them as the two different things they are.

Reordering sends only the rows that actually move. Each one is a PATCH and a
round trip to the CalDAV server, and "no position at all" is a different state
from "position 0" -- so the first move on a fresh account writes every row and
every move after it writes two. The arithmetic for that is pure and tested, as
is turning a CalDAV colour into something a colour input will accept: Apple's
`calendar-color` is `#RRGGBB` or `#RRGGBBAA`, and a control with no notion of
alpha reads the eight-digit form as garbage and silently shows black.

Deleting a calendar destroys everything in it and there is no undo underneath,
so the menu item leads into the editor with its confirmation already showing
rather than doing it from the menu. The menu offers what exists: a calendar at
the top of the list is not offered "Move up".

The browser checks in `shoot.mjs` cover the whole round trip against a real
server -- made, coloured, renamed, deleted -- and that a link which cannot work
is refused before it costs a request. They cannot run yet: the file stops at
its all-day overflow assertion, because `seed.py` hard-codes the week of
2026-08-24 and that week has now passed. That is a separate problem and older
than this change.
This commit is contained in:
2026-08-31 14:54:27 -04:00
parent e1e253319e
commit fd298f4977
12 changed files with 1440 additions and 67 deletions
+3 -2
View File
@@ -83,8 +83,9 @@ at the app from another machine on a network you trust — the dev stack runs wi
Driving a real browser is the loop v1 never had, and the reason its week grid
could only be checked by building, deploying and squinting. `shoot.mjs` also
asserts: that hiding a calendar removes its events, and that every preference
survives a reload.
asserts: that hiding a calendar removes its events, that a calendar made from
the sidebar reaches the CalDAV server and can be renamed and deleted there
again, and that every preference survives a reload.
Some tests can be pointed at a whole real calendar rather than the committed
fixtures:
@@ -38,6 +38,14 @@ pub struct CalendarView {
/// Reported so the interface can offer "use the calendar's own colour"
/// rather than making a local override indistinguishable from the real one.
pub color_source: ColorSource,
/// The colour the CalDAV server itself reports, whatever this user has
/// overridden it with.
///
/// Sent alongside `color` rather than instead of it, because an editor
/// showing one control for both would write somebody's private override
/// back to the shared calendar the first time they saved anything.
#[serde(skip_serializing_if = "Option::is_none")]
pub own_color: Option<String>,
pub visible: bool,
/// `None` when nobody has arranged this calendar.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -102,6 +110,7 @@ fn merge(discovered: Vec<Calendar>, settings: &[CalendarSetting]) -> Vec<Calenda
description: calendar.description.clone(),
color,
color_source,
own_color: calendar.color.clone(),
// A calendar nobody has hidden is shown. Absence of a row is
// not absence of an opinion about visibility, it is the
// default one.
+7
View File
@@ -299,6 +299,13 @@ caldav_test!(
"reported so the interface can offer to go back to the calendar's own \
colour instead of hiding that an override exists",
);
assert_eq!(
mine["own_color"],
json!("#0CCE6B"),
"an editor showing one control for both colours would write this \
person's private override onto the shared calendar the first time \
they saved anything",
);
// And the server's colour is untouched, because an override is personal.
let (url, name, password) = server().unwrap();
+4
View File
@@ -16,6 +16,10 @@ pub struct Calendar {
#[serde(default)]
pub color: Option<String>,
pub color_source: ColorSource,
/// The colour the CalDAV server itself reports, whatever this person has
/// overridden it with. What the shared colour control edits.
#[serde(default)]
pub own_color: Option<String>,
pub visible: bool,
/// `None` when nobody has arranged this calendar.
#[serde(default)]
@@ -22,7 +22,10 @@ use runway_core::model::View;
///
/// Read from the stylesheet at open time rather than hard-coded here, so a
/// density that changes the row height changes the placement with it.
fn size_of(items: usize) -> Size {
///
/// Shared with the sidebar's own row menus, which are the same menu opened
/// from a button rather than from the pointer.
pub(super) fn menu_size(items: usize) -> Size {
let row = css_pixels("--menu-row", 30.0);
// Padding above and below the items, and the border around the lot.
let edges = css_pixels("--space-2", 8.0) * 2.0 + css_pixels("--border-width", 1.0) * 2.0;
@@ -54,7 +57,7 @@ pub fn ContextMenu() -> impl IntoView {
let (x, y) = menu::place(
open.at.0,
open.at.1,
size_of(items.len()),
menu_size(items.len()),
viewport(),
);
@@ -106,7 +109,7 @@ pub fn ContextMenu() -> impl IntoView {
}
}
fn viewport() -> Viewport {
pub(super) fn viewport() -> Viewport {
let read = || {
let window = web_sys::window()?;
Some(Viewport {
+1
View File
@@ -14,6 +14,7 @@ mod reminders;
mod scope_chooser;
mod shell;
mod sidebar;
mod sources;
mod time_grid;
mod year_view;
+175 -62
View File
@@ -1,11 +1,16 @@
//! The sidebar: what can be shown, and whether it is.
//! The sidebar: what can be shown, whether it is, and what is in the list.
//!
//! Calendars and feeds are one list of sources with one kind of control,
//! because that is what they are to somebody reading a calendar. The colour
//! shown is the calendar's own — read from the CalDAV server, not invented.
//! v1 hashed the calendar's path to pick one and disagreed with every other
//! client about what colour a calendar was.
//!
//! Adding, changing and removing a source happens here rather than in Settings.
//! The sidebar already is the list; Settings is about the person reading it.
//! What each control does lives in [`super::sources`].
use super::sources::{Editing, Managing, Source, SourceEditor, SourceMenu};
use crate::api::{Calendar, ColorSource, Feed};
use crate::state::{CalendarsStore, FeedsStore};
use leptos::prelude::*;
@@ -14,6 +19,11 @@ use leptos::prelude::*;
pub fn Sidebar() -> impl IntoView {
let calendars = expect_context::<CalendarsStore>();
let feeds = expect_context::<FeedsStore>();
// What is being changed, offered rather than passed down: the button that
// opens a menu is two components deep and the rows in between have no
// business carrying a callback they never call.
let managing = Managing::new();
provide_context(managing);
view! {
<aside
@@ -22,7 +32,12 @@ pub fn Sidebar() -> impl IntoView {
style="width: var(--sidebar-width); padding: var(--space-4); \
background: var(--surface); border-color: var(--border)"
>
<Section title="Calendars">
<Section
title="Calendars"
add="New calendar"
add_testid="add-calendar"
on_add=Callback::new(move |()| managing.edit(Editing::NewCalendar))
>
{move || {
let items = calendars.all();
if items.is_empty() && calendars.is_loading() {
@@ -31,29 +46,41 @@ pub fn Sidebar() -> impl IntoView {
if items.is_empty() {
return view! { <Note text="No calendars on this server." /> }.into_any();
}
let last = items.len() - 1;
items
.into_iter()
.map(|calendar| view! { <CalendarRow calendar /> })
.enumerate()
.map(|(index, calendar)| {
view! {
<CalendarRow calendar first=index == 0 last=index == last />
}
})
.collect_view()
.into_any()
}}
</Section>
{move || {
let items = feeds.all();
if items.is_empty() {
return ().into_any();
}
view! {
<Section title="Subscribed">
{items
.into_iter()
.map(|feed| view! { <FeedRow feed /> })
.collect_view()}
</Section>
}
.into_any()
}}
<Section
title="Subscribed"
add="Subscribe to a calendar"
add_testid="add-feed"
on_add=Callback::new(move |()| managing.edit(Editing::NewFeed))
>
{move || {
let items = feeds.all();
if items.is_empty() {
// Said rather than left blank. A section with nothing
// in it and no explanation reads as something that
// failed to load.
return view! { <Note text="Nothing subscribed yet." /> }.into_any();
}
items
.into_iter()
.map(|feed| view! { <FeedRow feed /> })
.collect_view()
.into_any()
}}
</Section>
{move || {
calendars
@@ -73,18 +100,48 @@ pub fn Sidebar() -> impl IntoView {
})
}}
</aside>
<SourceMenu />
<SourceEditor />
}
}
#[component]
fn Section(title: &'static str, children: Children) -> impl IntoView {
fn Section(
title: &'static str,
/// What the "+" adds, as a title for the hand that hovers it.
add: &'static str,
add_testid: &'static str,
on_add: Callback<()>,
children: Children,
) -> impl IntoView {
view! {
<section style="margin-bottom: var(--space-6)">
<h2 style="margin: 0 0 var(--space-2); color: var(--text-subtle); \
font-size: var(--font-size-xs); font-weight: var(--weight-strong); \
text-transform: var(--label-transform); letter-spacing: var(--label-tracking)">
{title}
</h2>
<div
class="flex items-center justify-between"
style="margin: 0 0 var(--space-2)"
>
<h2 style="margin: 0; color: var(--text-subtle); \
font-size: var(--font-size-xs); font-weight: var(--weight-strong); \
text-transform: var(--label-transform); \
letter-spacing: var(--label-tracking)">
{title}
</h2>
<button
data-testid=add_testid
title=add
aria-label=add
on:click=move |_| on_add.run(())
class="cursor-pointer"
style="display: flex; align-items: center; justify-content: center; \
width: 1.25rem; height: 1.25rem; padding: 0; border: 0; \
border-radius: var(--radius-sm); background: none; \
color: var(--text-subtle); font-family: inherit; \
font-size: var(--font-size); line-height: 1"
>
"+"
</button>
</div>
{children()}
</section>
}
@@ -98,10 +155,12 @@ fn Note(text: &'static str) -> impl IntoView {
}
#[component]
fn CalendarRow(calendar: Calendar) -> impl IntoView {
fn CalendarRow(calendar: Calendar, first: bool, last: bool) -> impl IntoView {
let store = expect_context::<CalendarsStore>();
let managing = expect_context::<Managing>();
let href = calendar.href.clone();
let visible = calendar.visible;
let source = calendar.clone();
view! {
<Row
@@ -110,7 +169,19 @@ fn CalendarRow(calendar: Calendar) -> impl IntoView {
unset=calendar.color_source == ColorSource::Unset
visible=visible
testid=format!("calendar-{}", slug(&calendar.name))
menu_testid=format!("calendar-menu-{}", slug(&calendar.name))
on_toggle=Callback::new(move |next: bool| store.set_visible(&href, next))
on_menu=Callback::new(move |at: (f64, f64)| {
managing
.open_menu(
at,
Source::Calendar {
calendar: Box::new(source.clone()),
first,
last,
},
)
})
/>
}
}
@@ -118,8 +189,10 @@ fn CalendarRow(calendar: Calendar) -> impl IntoView {
#[component]
fn FeedRow(feed: Feed) -> impl IntoView {
let store = expect_context::<FeedsStore>();
let managing = expect_context::<Managing>();
let id = feed.id.clone();
let visible = feed.visible;
let source = feed.clone();
view! {
<Row
@@ -128,12 +201,19 @@ fn FeedRow(feed: Feed) -> impl IntoView {
unset=feed.color.is_none()
visible=visible
testid=format!("feed-{}", slug(&feed.name))
menu_testid=format!("feed-menu-{}", slug(&feed.name))
on_toggle=Callback::new(move |next: bool| store.set_visible(&id, next))
on_menu=Callback::new(move |at: (f64, f64)| {
managing.open_menu(at, Source::Feed(Box::new(source.clone())))
})
/>
}
}
/// One source: a swatch, a name, and whether it is shown.
/// One source: a swatch, a name, whether it is shown, and a way in.
///
/// The label wraps only the parts that mean "show this" — a button inside it
/// would toggle the checkbox on its way to doing its own job.
#[component]
fn Row(
label: String,
@@ -141,55 +221,88 @@ fn Row(
unset: bool,
visible: bool,
testid: String,
menu_testid: String,
on_toggle: Callback<bool>,
on_menu: Callback<(f64, f64)>,
) -> impl IntoView {
let shown = RwSignal::new(visible);
// A calendar the server has no colour for gets the accent rather than an
// invented one, and the interface can say so later.
// invented one, and the interface can say so.
let swatch = color.unwrap_or_else(|| "var(--accent)".to_owned());
let title = if unset {
format!("{label} — no colour set on the server")
} else {
label.clone()
};
let button = NodeRef::<leptos::html::Button>::new();
view! {
<label
class="flex cursor-pointer items-center"
title=title
style="gap: var(--space-2); padding: var(--space-1) 0"
>
<input
type="checkbox"
data-testid=testid
prop:checked=move || shown.get()
on:change:target=move |event| {
let next = event.target().checked();
shown.set(next);
on_toggle.run(next);
}
style="accent-color: var(--accent); cursor: pointer"
/>
<span
aria-hidden="true"
style=format!(
"width: 0.75rem; height: 0.75rem; flex-shrink: 0; \
border-radius: var(--radius-sm); background: {swatch}",
)
/>
<span
class="truncate"
style=move || {
if shown.get() {
"font-size: var(--font-size-sm)".to_owned()
} else {
"font-size: var(--font-size-sm); color: var(--text-subtle)".to_owned()
}
}
<div class="flex items-center" style="gap: var(--space-1)">
<label
class="flex min-w-0 flex-1 cursor-pointer items-center"
title=title
style="gap: var(--space-2); padding: var(--space-1) 0"
>
{label}
</span>
</label>
<input
type="checkbox"
data-testid=testid
prop:checked=move || shown.get()
on:change:target=move |event| {
let next = event.target().checked();
shown.set(next);
on_toggle.run(next);
}
style="accent-color: var(--accent); cursor: pointer"
/>
<span
aria-hidden="true"
style=format!(
"width: 0.75rem; height: 0.75rem; flex-shrink: 0; \
border-radius: var(--radius-sm); background: {swatch}",
)
/>
<span
class="truncate"
style=move || {
if shown.get() {
"font-size: var(--font-size-sm)".to_owned()
} else {
"font-size: var(--font-size-sm); color: var(--text-subtle)".to_owned()
}
}
>
{label.clone()}
</span>
</label>
<button
node_ref=button
data-testid=menu_testid
aria-label=format!("Settings for {label}")
aria-haspopup="menu"
on:click=move |_| {
// The corner of the button, so the menu hangs off the
// control that opened it. Read at the moment of the click:
// the list scrolls, so where the row was when it was drawn
// is not where it is now.
let at = button
.get_untracked()
.map(|element| {
let seen = element.get_bounding_client_rect();
(seen.left(), seen.bottom())
})
.unwrap_or((0.0, 0.0));
on_menu.run(at);
}
class="shrink-0 cursor-pointer"
style="width: 1.25rem; height: 1.25rem; padding: 0; border: 0; \
border-radius: var(--radius-sm); background: none; \
color: var(--text-subtle); font-family: inherit; \
font-size: var(--font-size-sm); line-height: 1"
>
""
</button>
</div>
}
}
+802
View File
@@ -0,0 +1,802 @@
//! Adding, changing and removing what the sidebar lists.
//!
//! One editor for all four cases — a new calendar, an existing one, a new
//! subscription, an existing one — because the difference between them is
//! which fields they hold and where they are sent, and nothing else.
//!
//! It lives next to the list rather than inside Settings on purpose. The
//! sidebar already *is* the list of sources; Settings is about the person
//! reading them. A calendar's name and colour are neither: they belong to the
//! calendar, they go to the CalDAV server, and every other client sees them.
//! The one thing that is personal — a colour override for a calendar somebody
//! else owns — is offered as exactly that, and says so.
//!
//! The arithmetic is in [`crate::sources`]; this draws it.
use super::context_menu::{menu_size, viewport};
use crate::api;
use crate::menu;
use crate::sources::{self, NEW_COLOR};
use crate::state::{CalendarsStore, FeedsStore};
use leptos::prelude::*;
/// What the sidebar is in the middle of changing.
///
/// Held in one place and offered through context, so opening a menu closes an
/// editor without either of them having to know the other exists — the same
/// reason [`crate::state::Overlay`] exists for the grid. The sidebar cannot
/// reach that one: it is a sibling of the view area, not inside it.
#[derive(Clone, Copy)]
pub struct Managing {
editing: RwSignal<Option<Editing>>,
menu: RwSignal<Option<RowMenu>>,
}
/// What is open in the editor.
#[derive(Clone, PartialEq)]
pub enum Editing {
NewCalendar,
/// An existing calendar. `removing` is true when the editor was opened by
/// asking to delete it, so the confirmation is already showing.
Calendar {
calendar: Box<api::Calendar>,
removing: bool,
},
NewFeed,
Feed {
feed: Box<api::Feed>,
removing: bool,
},
}
/// A menu opened from one row of the list.
#[derive(Clone, PartialEq)]
pub struct RowMenu {
/// The bottom-left corner of the button that opened it.
pub at: (f64, f64),
pub on: Source,
}
/// Which row a menu belongs to, and what can be done to it.
#[derive(Clone, PartialEq)]
pub enum Source {
Calendar {
calendar: Box<api::Calendar>,
/// Whether it is already at the top or bottom, so a move that would do
/// nothing is not offered. An item that greys itself out is a promise
/// the interface cannot keep.
first: bool,
last: bool,
},
Feed(Box<api::Feed>),
}
impl Managing {
pub fn new() -> Self {
Self {
editing: RwSignal::new(None),
menu: RwSignal::new(None),
}
}
pub fn editing(&self) -> Option<Editing> {
self.editing.get()
}
pub fn menu(&self) -> Option<RowMenu> {
self.menu.get()
}
pub fn edit(&self, what: Editing) {
self.menu.set(None);
self.editing.set(Some(what));
}
pub fn close(&self) {
self.editing.set(None);
}
pub fn open_menu(&self, at: (f64, f64), on: Source) {
self.editing.set(None);
self.menu.set(Some(RowMenu { at, on }));
}
pub fn close_menu(&self) {
self.menu.set(None);
}
}
impl Default for Managing {
fn default() -> Self {
Self::new()
}
}
// -------------------------------------------------------------------- menu --
/// The menu under one row's button.
///
/// Placed by [`crate::menu`] from the button's own corner rather than the
/// pointer, so it hangs off the control that opened it wherever that control
/// happens to be. Fixed rather than absolute because the list it sits in
/// scrolls, and a menu clipped by its own sidebar is a menu with items nobody
/// can reach.
#[component]
pub fn SourceMenu() -> impl IntoView {
let managing = expect_context::<Managing>();
let calendars = expect_context::<CalendarsStore>();
view! {
{move || {
let open = managing.menu()?;
let items = items_for(&open.on, managing, calendars);
let (x, y) = menu::place(open.at.0, open.at.1, menu_size(items.len()), viewport());
Some(
view! {
<div
data-testid="source-menu-backdrop"
on:pointerdown=move |_| managing.close_menu()
style="position: fixed; inset: 0; z-index: 40"
/>
<menu
data-testid="source-menu"
role="menu"
on:keydown=move |key: leptos::ev::KeyboardEvent| {
if key.key() == "Escape" {
managing.close_menu();
}
}
style=format!(
"position: fixed; left: {x:.0}px; top: {y:.0}px; z-index: 41; \
width: var(--menu-width); margin: 0; \
padding: var(--space-2) 0; list-style: none; \
border: var(--border-width) solid var(--border); \
border-radius: var(--radius); \
border-width: var(--panel-border-width); \
background: var(--panel-surface); box-shadow: var(--shadow-md)",
)
>
{items
.into_iter()
.enumerate()
.map(|(index, item)| view! { <MenuRow item first=index == 0 /> })
.collect_view()}
</menu>
}
.into_view(),
)
}}
}
}
/// One thing a row's menu can do.
struct Item {
label: &'static str,
testid: &'static str,
danger: bool,
run: Box<dyn Fn()>,
}
fn items_for(source: &Source, managing: Managing, calendars: CalendarsStore) -> Vec<Item> {
match source {
Source::Calendar {
calendar,
first,
last,
} => {
let opened = calendar.clone();
let deleted = calendar.clone();
let mut items = vec![Item {
label: "Edit…",
testid: "menu-source-edit",
danger: false,
run: Box::new(move || {
managing.edit(Editing::Calendar {
calendar: opened.clone(),
removing: false,
})
}),
}];
if !*first {
let href = calendar.href.clone();
items.push(Item {
label: "Move up",
testid: "menu-source-up",
danger: false,
run: Box::new(move || {
managing.close_menu();
calendars.move_by(&href, -1);
}),
});
}
if !*last {
let href = calendar.href.clone();
items.push(Item {
label: "Move down",
testid: "menu-source-down",
danger: false,
run: Box::new(move || {
managing.close_menu();
calendars.move_by(&href, 1);
}),
});
}
// Straight into the editor with its confirmation showing, rather
// than deleting from here. A menu item that destroys a calendar
// and everything in it on one click is a menu item nobody can
// afford to mis-aim at.
items.push(Item {
label: "Delete…",
testid: "menu-source-delete",
danger: true,
run: Box::new(move || {
managing.edit(Editing::Calendar {
calendar: deleted.clone(),
removing: true,
})
}),
});
items
}
Source::Feed(feed) => {
let opened = feed.clone();
let dropped = feed.clone();
vec![
Item {
label: "Edit…",
testid: "menu-source-edit",
danger: false,
run: Box::new(move || {
managing.edit(Editing::Feed {
feed: opened.clone(),
removing: false,
})
}),
},
Item {
label: "Unsubscribe…",
testid: "menu-source-delete",
danger: true,
run: Box::new(move || {
managing.edit(Editing::Feed {
feed: dropped.clone(),
removing: true,
})
}),
},
]
}
}
}
#[component]
fn MenuRow(item: Item, first: bool) -> impl IntoView {
let Item {
label,
testid,
danger,
run,
} = item;
let button = NodeRef::<leptos::html::Button>::new();
Effect::new(move |_| {
if let Some(button) = button.get().filter(|_| first) {
let _ = button.focus();
}
});
view! {
<li role="none" style="margin: 0">
<button
node_ref=button
role="menuitem"
data-testid=testid
on:click=move |_| run()
class="w-full cursor-pointer truncate"
style=format!(
"display: block; height: var(--menu-row); \
padding: 0 var(--space-3); border: 0; background: none; \
color: {}; font-family: inherit; \
font-size: var(--font-size-sm); text-align: left",
if danger { "var(--danger)" } else { "var(--text)" },
)
>
{label}
</button>
</li>
}
}
// ------------------------------------------------------------------ editor --
/// The dialog behind every one of those menu items.
#[component]
pub fn SourceEditor() -> impl IntoView {
let managing = expect_context::<Managing>();
view! {
{move || {
let open = managing.editing()?;
Some(view! { <Panel open /> }.into_view())
}}
}
}
#[component]
fn Panel(open: Editing) -> impl IntoView {
let managing = expect_context::<Managing>();
let calendars = expect_context::<CalendarsStore>();
let feeds = expect_context::<FeedsStore>();
let is_feed = matches!(open, Editing::NewFeed | Editing::Feed { .. });
let is_new = matches!(open, Editing::NewCalendar | Editing::NewFeed);
// Where every control starts, and what "changed" is measured against. Only
// what moved is sent: a calendar whose name was not touched must not have
// its name written back to the server, because writing it back is a
// `PROPPATCH` that other clients see.
let (was_name, was_color, was_own) = match &open {
Editing::NewCalendar | Editing::NewFeed => {
(String::new(), NEW_COLOR.to_owned(), None::<String>)
}
Editing::Calendar { calendar, .. } => (
calendar.name.clone(),
sources::swatch(calendar.own_color.as_deref()),
(calendar.color_source == api::ColorSource::User)
.then(|| sources::swatch(calendar.color.as_deref())),
),
Editing::Feed { feed, .. } => (
feed.name.clone(),
sources::swatch(feed.color.as_deref()),
None,
),
};
let name = RwSignal::new(was_name.clone());
let url = RwSignal::new(String::new());
let color = RwSignal::new(was_color.clone());
// A colour for this person alone, over the top of the calendar's own.
let mine = RwSignal::new(was_own.clone().unwrap_or_else(|| was_color.clone()));
let overriding = RwSignal::new(was_own.is_some());
let problem = RwSignal::new(None::<&'static str>);
let failure = RwSignal::new(None::<api::ApiError>);
let saving = RwSignal::new(false);
let confirming = RwSignal::new(match &open {
Editing::Calendar { removing, .. } | Editing::Feed { removing, .. } => *removing,
_ => false,
});
let first = NodeRef::<leptos::html::Input>::new();
Effect::new(move |_| {
if let Some(field) = first.get() {
let _ = field.focus();
}
});
let done = move |result: Result<(), api::ApiError>| {
match result {
Ok(()) => managing.close(),
Err(refused) => failure.set(Some(refused)),
}
saving.set(false);
};
let save = {
let open = open.clone();
move |_| {
problem.set(None);
failure.set(None);
if let Some(said) = sources::check_name(&name.get_untracked()) {
problem.set(Some(said));
return;
}
let typed = name.get_untracked().trim().to_owned();
let chosen = color.get_untracked();
saving.set(true);
match open.clone() {
Editing::NewCalendar => {
leptos::task::spawn_local(async move {
done(
calendars
.create(api::CreateCalendar {
name: typed,
color: Some(chosen),
})
.await,
);
});
}
Editing::Calendar { calendar, .. } => {
let mut request = api::UpdateCalendar::to(&calendar.href);
request.name = (typed != was_name).then_some(typed);
request.color = (chosen != was_color).then_some(chosen);
match (overriding.get_untracked(), was_own.clone()) {
(true, previous) => {
let wanted = mine.get_untracked();
request.local_color =
(previous.as_deref() != Some(&wanted)).then_some(wanted);
}
// Going back to the calendar's own colour, which is a
// distinct thing from setting an override that happens
// to match it.
(false, Some(_)) => request.clear_local_color = true,
(false, None) => {}
}
leptos::task::spawn_local(async move {
done(calendars.apply(request).await);
});
}
Editing::NewFeed => {
let link = url.get_untracked().trim().to_owned();
if let Some(said) = sources::check_url(&link) {
problem.set(Some(said));
saving.set(false);
return;
}
leptos::task::spawn_local(async move {
done(
feeds
.subscribe(api::SubscribeFeed {
name: typed,
url: link,
color: Some(chosen),
})
.await,
);
});
}
Editing::Feed { feed, .. } => {
let request = api::UpdateFeed {
id: feed.id.clone(),
name: (typed != was_name).then_some(typed),
color: (chosen != was_color).then_some(chosen),
..api::UpdateFeed::default()
};
leptos::task::spawn_local(async move {
done(feeds.apply(request).await);
});
}
}
}
};
let remove = {
let open = open.clone();
move |_| {
failure.set(None);
saving.set(true);
match open.clone() {
Editing::Calendar { calendar, .. } => {
leptos::task::spawn_local(async move {
done(calendars.remove(calendar.href.clone()).await);
});
}
Editing::Feed { feed, .. } => {
leptos::task::spawn_local(async move {
done(feeds.unsubscribe(feed.id.clone()).await);
});
}
// Nothing exists yet to remove; the button is not drawn.
Editing::NewCalendar | Editing::NewFeed => saving.set(false),
}
}
};
let heading = match (&open, is_feed) {
(Editing::NewCalendar, _) => "New calendar",
(Editing::NewFeed, _) => "Subscribe to a calendar",
(_, true) => "Subscription",
(_, false) => "Calendar",
};
view! {
<div
data-testid="source-backdrop"
on:click=move |_| managing.close()
style="position: fixed; inset: 0; z-index: 60; \
display: flex; align-items: center; justify-content: center; \
padding: var(--space-6); background: var(--scrim)"
>
<div
data-testid="source-editor"
role="dialog"
aria-modal="true"
aria-label=heading
on:click=|click: leptos::ev::MouseEvent| click.stop_propagation()
on:keydown=move |key: leptos::ev::KeyboardEvent| {
if key.key() == "Escape" {
managing.close();
}
}
class="flex flex-col overflow-hidden border"
style="width: min(26rem, 100%); max-height: 100%; \
border-color: var(--border); border-radius: var(--radius-lg); \
border-width: var(--panel-border-width); \
background: var(--panel-surface); box-shadow: var(--shadow-lg)"
>
<header style="padding: var(--space-3) var(--space-4); \
border-bottom: var(--border-width) solid var(--border-subtle)">
<h2 style="margin: 0; font-size: var(--font-size); \
font-weight: var(--weight-strong)">{heading}</h2>
</header>
<div class="min-h-0 flex-1 overflow-y-auto" style="padding: var(--space-4)">
<Field label="Name">
<input
node_ref=first
data-testid="source-name"
type="text"
prop:value=move || name.get()
on:input:target=move |typed| name.set(typed.target().value())
style=INPUT
/>
</Field>
{(open == Editing::NewFeed)
.then(|| {
view! {
<Field label="Link">
<input
data-testid="source-url"
type="url"
placeholder="https://…"
prop:value=move || url.get()
on:input:target=move |typed| {
let link = typed.target().value();
// A name nobody has typed gets one
// from the link. It is filled in
// rather than assumed: the moment
// somebody types their own, this
// stops touching it.
if name.get_untracked().trim().is_empty()
|| name.get_untracked()
== sources::name_from_url(&url.get_untracked())
{
name.set(sources::name_from_url(&link));
}
url.set(link);
}
style=INPUT
/>
</Field>
}
})}
{match &open {
Editing::Feed { feed, .. } => {
let link = feed.url.clone();
let hover = feed.url.clone();
// Shown and not editable: changing where a
// subscription points is subscribing to a
// different calendar, and the cache behind it is
// keyed to this one.
let read = feed.fetched_at.map(last_read);
Some(
view! {
<Field label="Link">
<p
data-testid="source-link"
class="truncate"
style="margin: 0; color: var(--text-muted); \
font-size: var(--font-size-sm)"
title=hover
>
{link}
</p>
</Field>
<p style="margin: 0 0 var(--space-3); color: var(--text-subtle); \
font-size: var(--font-size-xs)">
{read.unwrap_or_else(|| "Never read yet.".to_owned())}
</p>
},
)
}
_ => None,
}}
<Field label=if is_feed || is_new { "Colour" } else { "Colour · everyone" }>
<input
data-testid="source-color"
type="color"
prop:value=move || color.get()
on:input:target=move |picked| color.set(picked.target().value())
class="cursor-pointer border"
style="width: 100%; height: 2rem; padding: 0; \
border-radius: var(--radius-sm); \
border-color: var(--border); background: var(--surface)"
/>
</Field>
{(!is_feed && !is_new)
.then(|| {
view! {
<p style="margin: 0 0 var(--space-3); color: var(--text-subtle); \
font-size: var(--font-size-xs)">
"Stored on the calendar server, so every client shows it."
</p>
<label
class="flex cursor-pointer items-center"
style="gap: var(--space-2); margin-bottom: var(--space-2)"
>
<input
type="checkbox"
data-testid="source-override"
prop:checked=move || overriding.get()
on:change:target=move |event| {
overriding.set(event.target().checked());
}
style="accent-color: var(--accent); cursor: pointer"
/>
<span style="font-size: var(--font-size-sm)">
"Show it to me in a different colour"
</span>
</label>
<Show when=move || overriding.get()>
<input
data-testid="source-own-color"
type="color"
prop:value=move || mine.get()
on:input:target=move |picked| mine.set(picked.target().value())
class="cursor-pointer border"
style="width: 100%; height: 2rem; \
margin-bottom: var(--space-2); padding: 0; \
border-radius: var(--radius-sm); \
border-color: var(--border); \
background: var(--surface)"
/>
</Show>
}
})}
</div>
<footer
class="flex items-center justify-between"
style="gap: var(--space-2); padding: var(--space-3) var(--space-4); \
border-top: var(--border-width) solid var(--border-subtle)"
>
<div class="min-w-0 flex-1">
{move || {
let said = failure
.get()
.map(|refused| refused.message)
.or_else(|| problem.get().map(str::to_owned));
said.map(|said| {
view! {
<p
role="alert"
data-testid="source-error"
style="margin: 0; color: var(--danger); \
font-size: var(--font-size-xs)"
>
{said}
</p>
}
})
}}
</div>
{(!is_new)
.then(|| {
view! {
// Two presses. There is no undo on the CalDAV
// side, and the second one says what it does.
{move || {
if confirming.get() {
view! {
<button
data-testid="source-delete-confirm"
on:click=remove.clone()
disabled=move || saving.get()
class="cursor-pointer border"
style=DANGER
>
{if is_feed {
"Unsubscribe"
} else {
"Delete it and everything in it"
}}
</button>
}
.into_any()
} else {
view! {
<button
data-testid="source-delete"
on:click=move |_| confirming.set(true)
class="cursor-pointer border"
style=QUIET_DANGER
>
{if is_feed { "Unsubscribe" } else { "Delete" }}
</button>
}
.into_any()
}
}}
}
})}
<button
data-testid="source-cancel"
on:click=move |_| managing.close()
class="cursor-pointer border"
style=SECONDARY
>
"Cancel"
</button>
<button
data-testid="source-save"
on:click=save
disabled=move || saving.get()
class="cursor-pointer border"
style=PRIMARY
>
{move || match (saving.get(), is_new, is_feed) {
(true, true, true) => "Subscribing…",
(true, _, _) => "Saving…",
(false, true, true) => "Subscribe",
(false, true, false) => "Create",
(false, false, _) => "Save",
}}
</button>
</footer>
</div>
</div>
}
}
/// When a subscription was last read, in the browser's own zone.
///
/// The browser's, not the calendar's: converting into the zone the calendar is
/// *drawn* in needs a zone database, and the whole reason times are resolved on
/// the server is that this bundle deliberately does not carry one. What this
/// answers is "has it been fetched recently", which the machine in front of the
/// person is the right clock for.
fn last_read(at: chrono::DateTime<chrono::Utc>) -> String {
format!(
"Last read {}",
at.with_timezone(&chrono::Local).format("%-d %B %Y, %-H:%M"),
)
}
#[component]
fn Field(label: &'static str, children: Children) -> impl IntoView {
view! {
<label style="display: block; margin-bottom: var(--space-3)">
<span style="display: block; margin-bottom: var(--space-1); \
color: var(--text-subtle); font-size: var(--font-size-xs)">{label}</span>
{children()}
</label>
}
}
const INPUT: &str = "width: 100%; padding: var(--space-1) var(--space-2); \
border: var(--border-width) solid var(--border); \
border-radius: var(--radius-sm); background: var(--surface); \
color: var(--text); font-family: inherit; font-size: var(--font-size-sm)";
const PRIMARY: &str = "padding: var(--space-1) var(--space-4); border-radius: var(--radius); \
border-color: var(--accent); background: var(--accent); \
color: var(--accent-text); font-family: inherit; \
font-size: var(--font-size-sm); font-weight: var(--weight-strong)";
const SECONDARY: &str = "padding: var(--space-1) var(--space-3); border-radius: var(--radius); \
border-color: var(--border); background: var(--surface); \
color: var(--text); font-family: inherit; font-size: var(--font-size-sm)";
const QUIET_DANGER: &str = "padding: var(--space-1) var(--space-3); border-radius: var(--radius); \
border-color: var(--border); background: var(--surface); \
color: var(--danger); font-family: inherit; \
font-size: var(--font-size-sm)";
const DANGER: &str = "padding: var(--space-1) var(--space-3); border-radius: var(--radius); \
border-color: var(--danger); background: var(--danger); \
color: var(--danger-text); font-family: inherit; \
font-size: var(--font-size-sm)";
+1
View File
@@ -22,6 +22,7 @@ pub mod notify;
pub mod paper;
pub mod placed;
pub mod rrule;
pub mod sources;
pub mod state;
pub mod theme;
pub mod timegrid;
+228
View File
@@ -0,0 +1,228 @@
//! The arithmetic behind changing what is in the sidebar.
//!
//! Pure, and tested without a browser, in the same way [`crate::menu`] holds
//! where a menu goes and the component only draws it. Turning a CalDAV colour
//! into something an `<input type="color">` will accept, and working out which
//! calendars a reorder actually moves, are both easy to get quietly wrong and
//! neither is worth a browser to check.
/// What a colour control shows for a source nobody has given a colour.
///
/// A grey, and deliberately not a guess at what the calendar "should" be: v1
/// hashed a calendar's path into a hue and then disagreed with every other
/// client about what colour that calendar was. Nothing is stored until
/// somebody moves the control.
pub const NO_COLOR: &str = "#9aa0a6";
/// Where a new source's colour starts.
///
/// A literal rather than the accent token, because the control takes `#rrggbb`
/// and the palette is written in `oklch()` — and because a new calendar wants
/// a colour of its own rather than the one every other calendar would get.
pub const NEW_COLOR: &str = "#3f6fb5";
/// A CalDAV colour as a colour input will take it.
///
/// Apple's `calendar-color` is `#RRGGBB` **or** `#RRGGBBAA`, and a control with
/// no notion of alpha reads the eight-digit form as garbage and silently shows
/// black. The alpha is dropped rather than approximated: this is the value the
/// person is about to edit, and a colour that changes just by being looked at
/// is worse than one that ignores a channel nothing here can set.
pub fn swatch(color: Option<&str>) -> String {
let Some(text) = color.map(str::trim).filter(|text| !text.is_empty()) else {
return NO_COLOR.to_owned();
};
let Some(digits) = text.strip_prefix('#') else {
// A named colour, `rgb(…)`, anything else a server might have stored.
// Shown as the unset grey rather than refused, because it is still a
// colour somebody can replace.
return NO_COLOR.to_owned();
};
if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return NO_COLOR.to_owned();
}
let lowered = digits.to_ascii_lowercase();
match lowered.len() {
3 => {
let mut doubled = String::with_capacity(7);
doubled.push('#');
for c in lowered.chars() {
doubled.push(c);
doubled.push(c);
}
doubled
}
6 | 8 => format!("#{}", &lowered[..6]),
_ => NO_COLOR.to_owned(),
}
}
/// Which calendars a move actually moves, and where to.
///
/// `positions` is the stored position of each source in the order they are
/// shown — `None` for one nobody has ever arranged. The answer is a list of
/// `(index, position)` pairs holding **only** what changed, because every pair
/// costs a request and a full round trip to the CalDAV server. The first move
/// on a fresh account writes every row, since "no position at all" and
/// "position 0" are different states; every move after it writes two.
pub fn reorder(positions: &[Option<i64>], from: usize, to: usize) -> Vec<(usize, i64)> {
if from >= positions.len() || to >= positions.len() || from == to {
return Vec::new();
}
let mut order: Vec<usize> = (0..positions.len()).collect();
let moved = order.remove(from);
order.insert(to, moved);
order
.into_iter()
.enumerate()
.filter(|&(rank, index)| positions[index] != Some(rank as i64))
.map(|(rank, index)| (index, rank as i64))
.collect()
}
/// A name to offer for a feed somebody has just pasted a link to.
///
/// The host, because the last path segment of a real subscription link is
/// `basic.ics` or a base64 blob, and a suggestion that has to be deleted every
/// time is worse than an empty field. Only ever a suggestion: it fills a name
/// nobody has typed and never replaces one.
pub fn name_from_url(url: &str) -> String {
let after_scheme = url
.trim()
.split_once("://")
.map_or(url.trim(), |(_, rest)| rest);
let host = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
// Credentials in the authority, which a subscription link can carry.
.rsplit('@')
.next()
.unwrap_or_default()
// A port says nothing about what the calendar is.
.split(':')
.next()
.unwrap_or_default();
host.strip_prefix("www.").unwrap_or(host).to_owned()
}
/// Why a name cannot be sent, if it cannot.
///
/// The server refuses an empty one too. Saying so here means the refusal
/// arrives without a round trip, not that the check lives in only one place.
pub fn check_name(name: &str) -> Option<&'static str> {
name.trim().is_empty().then_some("give it a name")
}
/// Why a subscription link cannot be sent, if it cannot.
///
/// `webcal://` is accepted because it is what a calendar's "subscribe" button
/// hands out; the server rewrites it to `https://` before fetching.
pub fn check_url(url: &str) -> Option<&'static str> {
let url = url.trim();
if url.is_empty() {
return Some("paste the link to subscribe to");
}
let known = ["https://", "http://", "webcal://"]
.iter()
.any(|scheme| url.len() > scheme.len() && url.to_ascii_lowercase().starts_with(scheme));
(!known).then_some("a subscription link starts with https:// or webcal://")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_six_digit_colour_is_taken_as_it_is() {
assert_eq!(swatch(Some("#0CCE6B")), "#0cce6b");
}
#[test]
fn the_alpha_channel_a_control_cannot_set_is_dropped() {
// Apple's calendar-color, which is what a real server sends.
assert_eq!(swatch(Some("#0CCE6BFF")), "#0cce6b");
assert_eq!(swatch(Some("#0cce6b80")), "#0cce6b");
}
#[test]
fn a_short_colour_is_expanded_rather_than_read_as_a_prefix() {
assert_eq!(swatch(Some("#f0a")), "#ff00aa");
}
#[test]
fn anything_a_colour_input_cannot_show_falls_back_rather_than_going_black() {
for odd in ["", " ", "rebeccapurple", "rgb(1,2,3)", "#12345", "#zzzzzz"] {
assert_eq!(swatch(Some(odd)), NO_COLOR, "{odd:?}");
}
assert_eq!(swatch(None), NO_COLOR);
}
#[test]
fn the_first_move_on_a_fresh_account_writes_every_row() {
let unarranged = vec![None, None, None];
// Moving the last one to the front. Nothing has a position yet, so
// every row differs from where it now needs to be.
assert_eq!(reorder(&unarranged, 2, 0), vec![(2, 0), (0, 1), (1, 2)]);
}
#[test]
fn a_move_within_an_arranged_list_writes_only_what_moved() {
let arranged = vec![Some(0), Some(1), Some(2), Some(3)];
assert_eq!(
reorder(&arranged, 1, 2),
vec![(2, 1), (1, 2)],
"swapping two neighbours leaves the rest where they are",
);
}
#[test]
fn moving_something_further_writes_everything_it_passed() {
let arranged = vec![Some(0), Some(1), Some(2), Some(3)];
assert_eq!(reorder(&arranged, 3, 0), vec![(3, 0), (0, 1), (1, 2), (2, 3)]);
}
#[test]
fn a_move_that_goes_nowhere_sends_nothing() {
let arranged = vec![Some(0), Some(1)];
assert!(reorder(&arranged, 1, 1).is_empty());
assert!(reorder(&arranged, 0, 5).is_empty(), "off the end");
assert!(reorder(&[], 0, 0).is_empty());
}
#[test]
fn a_suggested_name_is_the_host_and_not_the_file() {
assert_eq!(
name_from_url(
"https://calendar.google.com/calendar/ical/x%40group.calendar.google.com/public/basic.ics",
),
"calendar.google.com",
);
assert_eq!(name_from_url("webcal://www.officeholidays.com/ics/uk"), "officeholidays.com");
assert_eq!(name_from_url("https://user:pw@dav.example.org:8443/f.ics"), "dav.example.org");
assert_eq!(name_from_url(" https://example.org "), "example.org");
}
#[test]
fn a_link_is_checked_before_it_costs_a_round_trip() {
assert_eq!(check_url("https://example.org/f.ics"), None);
assert_eq!(check_url("WEBCAL://example.org/f.ics"), None);
assert!(check_url("").is_some());
assert!(check_url("example.org/f.ics").is_some(), "no scheme");
assert!(check_url("ftp://example.org/f.ics").is_some());
assert!(check_url("https://").is_some(), "a scheme and nothing else");
}
#[test]
fn a_name_of_spaces_is_no_name() {
assert_eq!(check_name("Work"), None);
assert!(check_name(" ").is_some());
}
}
+100
View File
@@ -347,6 +347,78 @@ impl CalendarsStore {
}
});
}
/// Makes a calendar on the CalDAV server.
///
/// Awaited rather than fired off, because the editor that asks for one has
/// somewhere to say "Saving…" and somewhere to put a refusal. Nothing is
/// applied locally first: unlike a checkbox, there is nothing to show until
/// the server has agreed, and a calendar that appears and then vanishes is
/// worse than one that takes a moment.
pub async fn create(self, request: api::CreateCalendar) -> Result<(), ApiError> {
api::create_calendar(&request).await?;
// Creating answers with a path rather than a list, because the new
// calendar has to be discovered to be described.
self.adopt(api::calendars().await?);
Ok(())
}
/// Sends one change and adopts the list that comes back.
pub async fn apply(self, request: api::UpdateCalendar) -> Result<(), ApiError> {
self.adopt(api::update_calendar(&request).await?);
Ok(())
}
/// Deletes a calendar and everything in it. There is no undo behind this.
pub async fn remove(self, href: String) -> Result<(), ApiError> {
self.adopt(api::delete_calendar(&href).await?);
Ok(())
}
/// Moves one calendar up or down the sidebar, for this person only.
///
/// The arithmetic is in [`crate::sources::reorder`] and sends only the
/// rows that actually move, because each one is a request and a round trip
/// to the CalDAV server. Fired off rather than awaited: this is asked for
/// from a menu with nowhere to report to, so a refusal lands on the
/// sidebar's own error line.
pub fn move_by(self, href: &str, delta: isize) {
let items = self.items.get_untracked();
let Some(from) = items.iter().position(|calendar| calendar.href == href) else {
return;
};
let last = items.len().saturating_sub(1);
let to = from.saturating_add_signed(delta).min(last);
let positions: Vec<Option<i64>> = items.iter().map(|calendar| calendar.position).collect();
let moves = crate::sources::reorder(&positions, from, to);
if moves.is_empty() {
return;
}
leptos::task::spawn_local(async move {
for (index, position) in moves {
let request = api::UpdateCalendar {
position: Some(position),
..api::UpdateCalendar::to(&items[index].href)
};
match api::update_calendar(&request).await {
// Adopted as each one lands, so an order half-written by a
// failure is still the order the sidebar shows.
Ok(updated) => self.adopt(updated),
Err(failed) => {
self.error.set(Some(failed));
return;
}
}
}
});
}
/// Takes a list the server has confirmed.
fn adopt(self, calendars: Vec<api::Calendar>) {
self.items.set(calendars);
self.error.set(None);
}
}
impl Default for CalendarsStore {
@@ -416,6 +488,34 @@ impl FeedsStore {
}
});
}
/// Subscribes to somebody else's calendar.
///
/// Slow on purpose: the server fetches the link once before storing it, so
/// a bad address is refused while the person still has it to hand rather
/// than becoming a subscription that silently never worked.
pub async fn subscribe(self, request: api::SubscribeFeed) -> Result<(), ApiError> {
api::subscribe_feed(&request).await?;
// Subscribing answers with the one feed; the list is re-read rather
// than appended to, so where it lands is the server's business.
self.items.set(api::feeds().await?);
self.error.set(None);
Ok(())
}
/// Sends one change and adopts the list that comes back.
pub async fn apply(self, request: api::UpdateFeed) -> Result<(), ApiError> {
self.items.set(api::update_feed(&request).await?);
self.error.set(None);
Ok(())
}
/// Stops following a calendar. Nothing of somebody else's is deleted.
pub async fn unsubscribe(self, id: String) -> Result<(), ApiError> {
self.items.set(api::unsubscribe_feed(&id).await?);
self.error.set(None);
Ok(())
}
}
impl Default for FeedsStore {
+104
View File
@@ -1649,6 +1649,110 @@ await shoot('shell-calendar-hidden');
await work.check();
await page.waitForTimeout(1200);
// Making a calendar, changing it and deleting it again, through the interface
// rather than through the API underneath it. Every one of these calls existed
// in the typed client from M9 and had nothing wired to it, so what is being
// checked here is the wiring: that the sidebar can add a calendar to a real
// CalDAV server, rename it there, and take it away again -- leaving the server
// exactly as it was found, so a second run of this file starts where this one
// did.
async function colour(testid, value) {
await page.locator(`[data-testid="${testid}"]`).evaluate((element, chosen) => {
element.value = chosen;
element.dispatchEvent(new Event('input', { bubbles: true }));
}, value);
}
await page.click('[data-testid="add-calendar"]');
await page.waitForSelector('[data-testid="source-editor"]', { timeout: 5000 });
await page.fill('[data-testid="source-name"]', 'Shoot Test');
await colour('source-color', '#c2410c');
await shoot('shell-source-new');
await page.click('[data-testid="source-save"]');
// MKCALENDAR, then a fresh PROPFIND to describe what was created.
const created = await page
.waitForSelector('[data-testid="calendar-shoot-test"]', { timeout: 20000 })
.then(() => true)
.catch(() => false);
if (!created) {
problems.push('creating a calendar from the sidebar never produced one');
}
if (created) {
// Its colour has to be the one that was picked, read back off the swatch
// rather than off the form that sent it.
const swatch = await page.evaluate(() => {
const box = document.querySelector('[data-testid="calendar-shoot-test"]')
?.closest('div')
?.querySelector('span[aria-hidden="true"]');
return box ? getComputedStyle(box).backgroundColor : null;
});
if (swatch !== 'rgb(194, 65, 12)') {
problems.push(`a new calendar was given #c2410c and came back as ${swatch}`);
}
await page.click('[data-testid="calendar-menu-shoot-test"]');
await page.waitForSelector('[data-testid="source-menu"]', { timeout: 5000 });
await shoot('shell-source-menu');
await page.click('[data-testid="menu-source-edit"]');
await page.waitForSelector('[data-testid="source-editor"]', { timeout: 5000 });
// The colour control has to be holding the calendar's own colour and not
// black, which is what an eight-digit CalDAV colour reads as if nobody
// trims the alpha off it.
const held = await page.inputValue('[data-testid="source-color"]');
if (held !== '#c2410c') {
problems.push(`the editor opened on a calendar coloured #c2410c holding ${held}`);
}
await page.fill('[data-testid="source-name"]', 'Shoot Renamed');
await shoot('shell-source-edit');
await page.click('[data-testid="source-save"]');
const renamed = await page
.waitForSelector('[data-testid="calendar-shoot-renamed"]', { timeout: 20000 })
.then(() => true)
.catch(() => false);
if (!renamed) {
problems.push('renaming a calendar from the sidebar did not reach the server');
}
// And away again. Two presses, because there is no undo behind it.
const handle = renamed ? 'shoot-renamed' : 'shoot-test';
await page.click(`[data-testid="calendar-menu-${handle}"]`);
await page.waitForSelector('[data-testid="source-menu"]', { timeout: 5000 });
await page.click('[data-testid="menu-source-delete"]');
await page.waitForSelector('[data-testid="source-delete-confirm"]', { timeout: 5000 });
await page.click('[data-testid="source-delete-confirm"]');
const gone = await page
.waitForSelector(`[data-testid="calendar-${handle}"]`, { state: 'detached', timeout: 20000 })
.then(() => true)
.catch(() => false);
if (!gone) {
problems.push('a deleted calendar was still listed');
}
console.log(` calendar round trip: created ${created}, renamed ${renamed}, deleted ${gone}`);
}
// A subscription link that cannot work is refused before it costs a round
// trip, and the panel stays open holding what was typed rather than closing
// over the mistake.
await page.click('[data-testid="add-feed"]');
await page.waitForSelector('[data-testid="source-editor"]', { timeout: 5000 });
await page.fill('[data-testid="source-url"]', 'not-a-link');
await shoot('shell-source-subscribe');
await page.click('[data-testid="source-save"]');
await page.waitForTimeout(300);
const refused = await page.locator('[data-testid="source-error"]').count();
if (refused !== 1) {
problems.push('subscribing to something that is not a link was not refused');
}
if (await page.locator('[data-testid="source-editor"]').count() !== 1) {
problems.push('a refused subscription closed the panel and lost what was typed');
}
await page.click('[data-testid="source-cancel"]');
await page.waitForTimeout(300);
// The year view answers two questions -- when was the year busy, and take me
// there -- so both are checked. Nothing here reads a title: at this size there
// is no title to read.