@remit/web-client 0.0.230 → 0.0.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/calendar/CalendarComposePane.stories.tsx +115 -0
- package/src/components/calendar/CalendarComposePane.tsx +94 -0
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
- package/src/components/calendar/CalendarEventPane.tsx +90 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
- package/src/components/calendar/CalendarWorkspace.tsx +192 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +59 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +124 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +163 -0
- package/src/hooks/useCalendarData.ts +120 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +187 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +199 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +128 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +150 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/test-support/http.ts +8 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { EventDraft } from "@remit/ui";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
|
|
4
|
+
import { useCalendarComposeSeed } from "@/components/calendar/CalendarComposeSeed";
|
|
5
|
+
import {
|
|
6
|
+
createInputFromDraft,
|
|
7
|
+
emptyDraft,
|
|
8
|
+
UNZONED_CALENDAR,
|
|
9
|
+
useCalendars,
|
|
10
|
+
useCalendarWrites,
|
|
11
|
+
} from "@/hooks/calendar";
|
|
12
|
+
import { useCalendarAddress } from "@/routing";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Writing a new event.
|
|
16
|
+
*
|
|
17
|
+
* The draft starts on the slot the reader dragged out of the grid, or on the
|
|
18
|
+
* day the calendar is showing when they opened the composer some other way. The
|
|
19
|
+
* write goes through the same service function every other path uses, so an
|
|
20
|
+
* event typed here, one accepted from an invitation and one written over CalDAV
|
|
21
|
+
* are the same bytes stored the same way.
|
|
22
|
+
*/
|
|
23
|
+
export interface WriteCalendarEventProps {
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
|
|
28
|
+
const { date } = useCalendarAddress();
|
|
29
|
+
const seed = useCalendarComposeSeed();
|
|
30
|
+
const { calendars, timeZoneByCalendarId } = useCalendars();
|
|
31
|
+
const { createEvent, isWriting } = useCalendarWrites();
|
|
32
|
+
const [problem, setProblem] = useState("");
|
|
33
|
+
const [draft, setDraft] = useState<EventDraft>(() => {
|
|
34
|
+
const blank = emptyDraft(seed?.date ?? date, "");
|
|
35
|
+
if (!seed) return blank;
|
|
36
|
+
return {
|
|
37
|
+
...blank,
|
|
38
|
+
allDay: seed.allDay,
|
|
39
|
+
startTime: seed.allDay ? "" : seed.startTime,
|
|
40
|
+
endTime: seed.allDay ? "" : seed.endTime,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The default calendar is provisioned by the listing itself, so the picker
|
|
45
|
+
// has one the moment the calendars arrive rather than making the reader
|
|
46
|
+
// choose before they can type anything.
|
|
47
|
+
const firstCalendarId = calendars[0]?.id ?? "";
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (firstCalendarId === "") return;
|
|
50
|
+
setDraft((current) =>
|
|
51
|
+
current.calendarId === ""
|
|
52
|
+
? { ...current, calendarId: firstCalendarId }
|
|
53
|
+
: current,
|
|
54
|
+
);
|
|
55
|
+
}, [firstCalendarId]);
|
|
56
|
+
|
|
57
|
+
const save = () => {
|
|
58
|
+
const built = createInputFromDraft(
|
|
59
|
+
draft,
|
|
60
|
+
timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
|
|
61
|
+
);
|
|
62
|
+
if (!built.ok) {
|
|
63
|
+
setProblem(built.problem);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
void createEvent(built.input).then((outcome) => {
|
|
67
|
+
if (outcome.kind === "written") {
|
|
68
|
+
setProblem("");
|
|
69
|
+
onClose();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setProblem(
|
|
73
|
+
outcome.kind === "conflict"
|
|
74
|
+
? "This calendar changed while you were writing. Close the form and try again."
|
|
75
|
+
: outcome.message,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<CalendarComposePane
|
|
82
|
+
title="New event"
|
|
83
|
+
subtitle={draft.date}
|
|
84
|
+
calendars={calendars}
|
|
85
|
+
draft={draft}
|
|
86
|
+
onChange={setDraft}
|
|
87
|
+
problem={problem}
|
|
88
|
+
saveLabel="Add"
|
|
89
|
+
isSaving={isWriting}
|
|
90
|
+
onSave={save}
|
|
91
|
+
onCancel={onClose}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each zoom puts on screen.
|
|
3
|
+
*
|
|
4
|
+
* The route is one route for all five views, so "the view mounts its own
|
|
5
|
+
* component" is a decision this pane makes and the place to assert it. A zoom
|
|
6
|
+
* that is not drawn yet says so out loud: an empty surface would be
|
|
7
|
+
* indistinguishable from a week with nothing in it, which is the dead route
|
|
8
|
+
* this stage exists to avoid.
|
|
9
|
+
*/
|
|
10
|
+
import "@remit/test-dom";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type { CalendarColorId, CalendarEventData } from "@remit/ui";
|
|
14
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
15
|
+
import { createElement, type ReactElement } from "react";
|
|
16
|
+
import { renderToString } from "react-dom/server";
|
|
17
|
+
import {
|
|
18
|
+
CalendarWorkspace,
|
|
19
|
+
type CalendarWorkspaceProps,
|
|
20
|
+
} from "./CalendarWorkspace.js";
|
|
21
|
+
|
|
22
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
|
+
const DATE = "2026-06-10";
|
|
24
|
+
const NOW = `${DATE}T09:30:00+02:00`;
|
|
25
|
+
const CALENDAR = "cal_work";
|
|
26
|
+
|
|
27
|
+
const colorByCalendarId: Record<string, CalendarColorId> = {
|
|
28
|
+
[CALENDAR]: "cal-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const roadmap: CalendarEventData = {
|
|
32
|
+
id: "roadmap",
|
|
33
|
+
calendarId: CALENDAR,
|
|
34
|
+
title: "Roadmap review",
|
|
35
|
+
start: `${DATE}T10:00:00+02:00`,
|
|
36
|
+
end: `${DATE}T11:00:00+02:00`,
|
|
37
|
+
allDay: false,
|
|
38
|
+
location: "",
|
|
39
|
+
notes: "",
|
|
40
|
+
attendees: [],
|
|
41
|
+
myRsvp: "accepted",
|
|
42
|
+
threadId: "",
|
|
43
|
+
threadSubject: "",
|
|
44
|
+
timeZone: TIME_ZONE,
|
|
45
|
+
zoneCertainty: "explicit",
|
|
46
|
+
recurrenceRule: "",
|
|
47
|
+
seriesId: "",
|
|
48
|
+
seriesException: false,
|
|
49
|
+
status: "confirmed",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const noop = () => {};
|
|
53
|
+
|
|
54
|
+
const workspaceProps = (
|
|
55
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
56
|
+
events: CalendarEventData[] = [roadmap],
|
|
57
|
+
): CalendarWorkspaceProps => ({
|
|
58
|
+
view,
|
|
59
|
+
date: DATE,
|
|
60
|
+
events,
|
|
61
|
+
colorByCalendarId,
|
|
62
|
+
density: "comfortable",
|
|
63
|
+
selectedEventId: "",
|
|
64
|
+
timeZone: TIME_ZONE,
|
|
65
|
+
now: NOW,
|
|
66
|
+
onChangeView: noop,
|
|
67
|
+
onToday: noop,
|
|
68
|
+
onStep: noop,
|
|
69
|
+
onChangeDensity: noop,
|
|
70
|
+
onSelectEvent: noop,
|
|
71
|
+
onPickSlot: noop,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The calendar engine schedules browser timers while it renders. Nothing mounts
|
|
76
|
+
* here, so nothing ever clears them and the suite would sit on a live event
|
|
77
|
+
* loop after its last assertion. Take them back.
|
|
78
|
+
*/
|
|
79
|
+
function renderQuietly(element: ReactElement): string {
|
|
80
|
+
const scheduled: ReturnType<typeof setTimeout>[] = [];
|
|
81
|
+
const native = globalThis.setTimeout;
|
|
82
|
+
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
|
|
83
|
+
const handle = native(...args);
|
|
84
|
+
scheduled.push(handle);
|
|
85
|
+
return handle;
|
|
86
|
+
}) as typeof globalThis.setTimeout;
|
|
87
|
+
try {
|
|
88
|
+
return renderToString(element);
|
|
89
|
+
} finally {
|
|
90
|
+
globalThis.setTimeout = native;
|
|
91
|
+
for (const handle of scheduled) clearTimeout(handle);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const render = (
|
|
96
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
97
|
+
events: CalendarEventData[] = [roadmap],
|
|
98
|
+
): string =>
|
|
99
|
+
renderQuietly(createElement(CalendarWorkspace, workspaceProps(view, events)));
|
|
100
|
+
|
|
101
|
+
describe("the zoom the address names", () => {
|
|
102
|
+
it("draws the grid for the week and the day", () => {
|
|
103
|
+
for (const view of ["week", "day"] as const) {
|
|
104
|
+
const html = render(view);
|
|
105
|
+
assert.ok(
|
|
106
|
+
html.includes("Roadmap review"),
|
|
107
|
+
`${view} did not draw the events it was given`,
|
|
108
|
+
);
|
|
109
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("says so for a zoom that is not drawn yet", () => {
|
|
114
|
+
for (const view of ["year", "month", "agenda"] as const) {
|
|
115
|
+
const html = render(view);
|
|
116
|
+
assert.ok(
|
|
117
|
+
html.includes(`calendar-placeholder-${view}`),
|
|
118
|
+
`${view} rendered no placeholder`,
|
|
119
|
+
);
|
|
120
|
+
assert.ok(html.includes("Not built yet"));
|
|
121
|
+
assert.ok(html.includes("Week and Day work now."));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("names the agenda's own stage rather than a generic one", () => {
|
|
126
|
+
assert.ok(render("agenda").includes("scrolling day strip"));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("the toolbar", () => {
|
|
131
|
+
it("offers every zoom, whichever one is on", () => {
|
|
132
|
+
const html = render("year");
|
|
133
|
+
for (const label of ["Year", "Month", "Week", "Day", "Agenda"]) {
|
|
134
|
+
assert.ok(html.includes(`>${label}<`), `no way to reach ${label}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("offers back, forward and today at a zoom that draws no grid", () => {
|
|
139
|
+
const html = render("month");
|
|
140
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
141
|
+
assert.ok(html.includes('aria-label="Next"'));
|
|
142
|
+
assert.ok(html.includes(">Today<"));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("names the range before the grid has measured one", () => {
|
|
146
|
+
// The words are the device's locale's; the year is the part that is this
|
|
147
|
+
// component's answer rather than `Intl`'s.
|
|
148
|
+
assert.ok(render("month").includes("2026"));
|
|
149
|
+
assert.ok(render("year").includes("2026"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("carries the density control, which the address never names", () => {
|
|
153
|
+
const html = render("week");
|
|
154
|
+
assert.ok(html.includes("Calendar density"));
|
|
155
|
+
assert.ok(html.includes(">Detail<"));
|
|
156
|
+
assert.ok(html.includes(">Glance<"));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("a week with nothing in it", () => {
|
|
161
|
+
it("draws the grid rather than an empty surface", () => {
|
|
162
|
+
const html = render("week", []);
|
|
163
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
164
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The way back to the mail, at a width where the nav is not a pane.
|
|
170
|
+
*
|
|
171
|
+
* The shell renders the sidebar as a slide-over below 1024px and nothing opens
|
|
172
|
+
* a slide-over on its own, so a surface that is the whole width of the app has
|
|
173
|
+
* to carry the control itself — mail's own list headers do, and the calendar is
|
|
174
|
+
* reached from the same sidebar it would otherwise strand a reader away from.
|
|
175
|
+
*/
|
|
176
|
+
describe("the folders control", () => {
|
|
177
|
+
const shell = (width: number): string =>
|
|
178
|
+
renderQuietly(
|
|
179
|
+
createElement(AppShellSlotted, {
|
|
180
|
+
initialWidth: width,
|
|
181
|
+
nav: createElement("nav", null, "Folders"),
|
|
182
|
+
list: createElement(CalendarWorkspace, workspaceProps("week")),
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it("is in the calendar's header where the nav is a slide-over", () => {
|
|
187
|
+
assert.match(shell(900), /aria-label="Open folders"/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is absent where the nav is a pane of its own", () => {
|
|
191
|
+
assert.doesNotMatch(shell(1440), /aria-label="Open folders"/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing an event and deleting one, through the addresses that carry them.
|
|
3
|
+
*
|
|
4
|
+
* The addresses are the real ones — `/calendar/{view}/{date}/new` and the event
|
|
5
|
+
* segment under the view — and so are the components each mounts. What is
|
|
6
|
+
* asserted is the request that left and the address the reader ends up at,
|
|
7
|
+
* because a form that posts and then leaves the composer up looks exactly like
|
|
8
|
+
* one that posted nothing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type {
|
|
14
|
+
RemitImapCalendarEventInstance,
|
|
15
|
+
RemitImapCalendarEventResponse,
|
|
16
|
+
RemitImapCalendarResponse,
|
|
17
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
18
|
+
import {
|
|
19
|
+
type AnyRouter,
|
|
20
|
+
createMemoryHistory,
|
|
21
|
+
createRootRoute,
|
|
22
|
+
createRoute,
|
|
23
|
+
createRouter,
|
|
24
|
+
Outlet,
|
|
25
|
+
type RouteComponent,
|
|
26
|
+
RouterProvider,
|
|
27
|
+
} from "@tanstack/react-router";
|
|
28
|
+
import { createElement } from "react";
|
|
29
|
+
import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
|
|
30
|
+
import { calendarSearchSchema } from "@/lib/calendar-route";
|
|
31
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
32
|
+
import { Route as OccurrenceRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.js";
|
|
33
|
+
import { Route as EventIndexRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/index.js";
|
|
34
|
+
import { Route as EventRoute } from "../../routes/calendar/$view.$date/$calendarObjectId.js";
|
|
35
|
+
import { Route as ComposeRoute } from "../../routes/calendar/$view.$date/new.js";
|
|
36
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
37
|
+
import {
|
|
38
|
+
type HttpCall,
|
|
39
|
+
type HttpMock,
|
|
40
|
+
mockFetch,
|
|
41
|
+
} from "../../test-support/http";
|
|
42
|
+
|
|
43
|
+
// The router reads `self` at construction; the shared globals stop at `window`.
|
|
44
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
45
|
+
|
|
46
|
+
const WORK = "11111111-1111-4111-8111-111111111111";
|
|
47
|
+
const OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
48
|
+
const WEEK = "/calendar/week/2026-06-10";
|
|
49
|
+
|
|
50
|
+
let harness: DomHarness | undefined;
|
|
51
|
+
let http: HttpMock | undefined;
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
harness?.close();
|
|
55
|
+
harness = undefined;
|
|
56
|
+
http?.restore();
|
|
57
|
+
http = undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The component a file route mounts, lifted off the route itself.
|
|
62
|
+
*
|
|
63
|
+
* The route objects are module singletons and re-parenting one mutates it for
|
|
64
|
+
* every other test in the process, so the tree below is built fresh per case
|
|
65
|
+
* out of the real components rather than out of the real routes. Which routes
|
|
66
|
+
* an address mounts is `routes/calendar/-calendar-tree.test.ts`'s subject; this
|
|
67
|
+
* one is about what those components then do.
|
|
68
|
+
*/
|
|
69
|
+
const componentOf = (route: unknown): RouteComponent => {
|
|
70
|
+
const { component } = (route as { options: { component?: RouteComponent } })
|
|
71
|
+
.options;
|
|
72
|
+
if (!component) throw new Error("the route mounts no component");
|
|
73
|
+
return component;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const calendars: RemitImapCalendarResponse[] = [
|
|
77
|
+
{
|
|
78
|
+
calendarId: WORK,
|
|
79
|
+
accountConfigId: "cfg-1",
|
|
80
|
+
urlSegment: "work",
|
|
81
|
+
displayName: "Work",
|
|
82
|
+
color: "Cal1",
|
|
83
|
+
componentSet: "VeventOnly",
|
|
84
|
+
source: "UserCreated",
|
|
85
|
+
timezone: "Europe/Amsterdam",
|
|
86
|
+
syncSequence: 1,
|
|
87
|
+
createdAt: 0,
|
|
88
|
+
updatedAt: 0,
|
|
89
|
+
} as RemitImapCalendarResponse,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const instance: RemitImapCalendarEventInstance = {
|
|
93
|
+
calendarId: WORK,
|
|
94
|
+
calendarObjectId: OBJECT,
|
|
95
|
+
recurrenceId: "",
|
|
96
|
+
icalUid: "uid-1",
|
|
97
|
+
summary: "Roadmap review",
|
|
98
|
+
start: "2026-06-10T10:00:00+02:00",
|
|
99
|
+
end: "2026-06-10T11:30:00+02:00",
|
|
100
|
+
allDay: false,
|
|
101
|
+
status: "Confirmed",
|
|
102
|
+
transparency: "Opaque",
|
|
103
|
+
zoneCertainty: "Explicit",
|
|
104
|
+
etag: "etag-1",
|
|
105
|
+
hasRecurrence: false,
|
|
106
|
+
} as RemitImapCalendarEventInstance;
|
|
107
|
+
|
|
108
|
+
const resource = {
|
|
109
|
+
calendarObjectId: OBJECT,
|
|
110
|
+
calendarId: WORK,
|
|
111
|
+
resourceName: "roadmap.ics",
|
|
112
|
+
icalUid: "uid-1",
|
|
113
|
+
icalData: [
|
|
114
|
+
"BEGIN:VCALENDAR",
|
|
115
|
+
"BEGIN:VEVENT",
|
|
116
|
+
"SUMMARY:Roadmap review",
|
|
117
|
+
"LOCATION:Room Zuid",
|
|
118
|
+
"END:VEVENT",
|
|
119
|
+
"END:VCALENDAR",
|
|
120
|
+
].join("\r\n"),
|
|
121
|
+
etag: "etag-1",
|
|
122
|
+
} as RemitImapCalendarEventResponse;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The calendar's addresses, at the shape the generated tree gives them: the
|
|
126
|
+
* view, the composer beside the open event, and one occurrence under the series.
|
|
127
|
+
* The grid is left off — nothing here is about how a week is drawn, and the
|
|
128
|
+
* pane routes do not need one mounted beside them to be reached.
|
|
129
|
+
*/
|
|
130
|
+
const testRouter = (entry: string): AnyRouter => {
|
|
131
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
132
|
+
const viewRoute = createRoute({
|
|
133
|
+
getParentRoute: () => rootRoute,
|
|
134
|
+
path: "/calendar/$view/$date",
|
|
135
|
+
validateSearch: calendarSearchSchema,
|
|
136
|
+
component: () =>
|
|
137
|
+
createElement(
|
|
138
|
+
CalendarComposeSeedProvider,
|
|
139
|
+
{ pick: undefined },
|
|
140
|
+
createElement(Outlet),
|
|
141
|
+
),
|
|
142
|
+
});
|
|
143
|
+
const eventRoute = createRoute({
|
|
144
|
+
getParentRoute: () => viewRoute,
|
|
145
|
+
path: "$calendarObjectId",
|
|
146
|
+
component: componentOf(EventRoute),
|
|
147
|
+
});
|
|
148
|
+
const routeTree = rootRoute.addChildren([
|
|
149
|
+
viewRoute.addChildren([
|
|
150
|
+
createRoute({
|
|
151
|
+
getParentRoute: () => viewRoute,
|
|
152
|
+
path: "new",
|
|
153
|
+
component: componentOf(ComposeRoute),
|
|
154
|
+
}),
|
|
155
|
+
eventRoute.addChildren([
|
|
156
|
+
createRoute({
|
|
157
|
+
getParentRoute: () => eventRoute,
|
|
158
|
+
path: "/",
|
|
159
|
+
component: componentOf(EventIndexRoute),
|
|
160
|
+
}),
|
|
161
|
+
createRoute({
|
|
162
|
+
getParentRoute: () => eventRoute,
|
|
163
|
+
path: "$recurrenceId",
|
|
164
|
+
component: componentOf(OccurrenceRoute),
|
|
165
|
+
}),
|
|
166
|
+
]),
|
|
167
|
+
]),
|
|
168
|
+
]);
|
|
169
|
+
return createRouter({
|
|
170
|
+
routeTree,
|
|
171
|
+
stringifySearch,
|
|
172
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
173
|
+
}) as unknown as AnyRouter;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const mount = async (entry: string, respond: (call: HttpCall) => unknown) => {
|
|
177
|
+
http = mockFetch(respond);
|
|
178
|
+
const router = testRouter(entry);
|
|
179
|
+
await router.load();
|
|
180
|
+
harness = createDomHarness();
|
|
181
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
182
|
+
await settle();
|
|
183
|
+
return router;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const settle = async () => {
|
|
187
|
+
await harness?.flush();
|
|
188
|
+
await harness?.wait(30);
|
|
189
|
+
await harness?.flush();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const answering =
|
|
193
|
+
(
|
|
194
|
+
respond: (call: HttpCall) => unknown,
|
|
195
|
+
items: RemitImapCalendarEventInstance[] = [instance],
|
|
196
|
+
stored: RemitImapCalendarEventResponse = resource,
|
|
197
|
+
) =>
|
|
198
|
+
(call: HttpCall) => {
|
|
199
|
+
if (call.path.endsWith("/calendars") && call.method === "GET")
|
|
200
|
+
return { items: calendars };
|
|
201
|
+
if (call.path.endsWith("/calendar-events") && call.method === "GET")
|
|
202
|
+
return { items };
|
|
203
|
+
if (call.path.includes("/calendar-events/") && call.method === "GET")
|
|
204
|
+
return stored;
|
|
205
|
+
return respond(call);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const RECURRENCE = "2026-06-10T07:15:00Z";
|
|
209
|
+
|
|
210
|
+
const occurrence = {
|
|
211
|
+
...instance,
|
|
212
|
+
recurrenceId: RECURRENCE,
|
|
213
|
+
summary: "Standup",
|
|
214
|
+
start: "2026-06-10T09:15:00+02:00",
|
|
215
|
+
end: "2026-06-10T09:30:00+02:00",
|
|
216
|
+
hasRecurrence: true,
|
|
217
|
+
} as RemitImapCalendarEventInstance;
|
|
218
|
+
|
|
219
|
+
const series = {
|
|
220
|
+
...resource,
|
|
221
|
+
icalData: [
|
|
222
|
+
"BEGIN:VCALENDAR",
|
|
223
|
+
"BEGIN:VEVENT",
|
|
224
|
+
"SUMMARY:Standup",
|
|
225
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
226
|
+
"END:VEVENT",
|
|
227
|
+
"END:VCALENDAR",
|
|
228
|
+
].join("\r\n"),
|
|
229
|
+
} as RemitImapCalendarEventResponse;
|
|
230
|
+
|
|
231
|
+
describe("writing an event from the composer route", () => {
|
|
232
|
+
it("posts what was typed and puts the reader back on the calendar", async () => {
|
|
233
|
+
const router = await mount(
|
|
234
|
+
`${WEEK}/new`,
|
|
235
|
+
answering(() => resource),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
harness?.type(harness.byLabel("Title"), "Supplier call");
|
|
239
|
+
await settle();
|
|
240
|
+
harness?.click(harness.byText("button", "Add"));
|
|
241
|
+
await settle();
|
|
242
|
+
|
|
243
|
+
const posted = (http?.calls ?? []).filter(
|
|
244
|
+
(call) =>
|
|
245
|
+
call.method === "POST" && call.path.endsWith("/calendar-events"),
|
|
246
|
+
);
|
|
247
|
+
assert.equal(posted.length, 1, "the event was written once");
|
|
248
|
+
assert.equal(posted[0].body?.summary, "Supplier call");
|
|
249
|
+
assert.equal(posted[0].body?.calendarId, WORK);
|
|
250
|
+
assert.equal(
|
|
251
|
+
router.state.location.pathname,
|
|
252
|
+
WEEK,
|
|
253
|
+
"a saved event closes the composer rather than leaving it up",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("says what is missing rather than posting a nameless event", async () => {
|
|
258
|
+
await mount(
|
|
259
|
+
`${WEEK}/new`,
|
|
260
|
+
answering(() => resource),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
harness?.click(harness.byText("button", "Add"));
|
|
264
|
+
await settle();
|
|
265
|
+
|
|
266
|
+
assert.equal(
|
|
267
|
+
(http?.calls ?? []).filter((call) => call.method === "POST").length,
|
|
268
|
+
0,
|
|
269
|
+
);
|
|
270
|
+
assert.match(harness?.text() ?? "", /title/i);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("deleting an event from the event route", () => {
|
|
275
|
+
it("sends the delete with the version on screen and closes the pane", async () => {
|
|
276
|
+
const router = await mount(
|
|
277
|
+
`${WEEK}/${OBJECT}`,
|
|
278
|
+
answering(() => ({})),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
assert.match(harness?.text() ?? "", /Roadmap review/);
|
|
282
|
+
harness?.click(harness.byText("button", "Delete"));
|
|
283
|
+
await settle();
|
|
284
|
+
|
|
285
|
+
const deleted = (http?.calls ?? []).filter(
|
|
286
|
+
(call) => call.method === "DELETE",
|
|
287
|
+
);
|
|
288
|
+
assert.equal(deleted.length, 1);
|
|
289
|
+
assert.ok(deleted[0].url.includes(`calendarId=${WORK}`), deleted[0].url);
|
|
290
|
+
assert.equal(
|
|
291
|
+
deleted[0].headers["if-match"],
|
|
292
|
+
"etag-1",
|
|
293
|
+
"a delete built on a version nobody stated can remove somebody else's event",
|
|
294
|
+
);
|
|
295
|
+
assert.equal(router.state.location.pathname, WEEK);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("editing one morning of a repeating event", () => {
|
|
300
|
+
it("asks what the change applies to before opening the form", async () => {
|
|
301
|
+
await mount(
|
|
302
|
+
`${WEEK}/${OBJECT}/${RECURRENCE}`,
|
|
303
|
+
answering(() => ({}), [occurrence], series),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
assert.doesNotMatch(
|
|
307
|
+
harness?.text() ?? "",
|
|
308
|
+
/What should the change apply to/,
|
|
309
|
+
"the question is asked when Edit is pressed, not before",
|
|
310
|
+
);
|
|
311
|
+
harness?.click(harness.byText("button", "Edit"));
|
|
312
|
+
await settle();
|
|
313
|
+
|
|
314
|
+
const asked = harness?.text() ?? "";
|
|
315
|
+
assert.match(asked, /Standup repeats/);
|
|
316
|
+
assert.match(asked, /What should the change apply to/);
|
|
317
|
+
assert.match(
|
|
318
|
+
asked,
|
|
319
|
+
/Every weekday, 09:15/,
|
|
320
|
+
"the rule is read back in words, not as an RRULE",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("sends the answer with the edit, naming the occurrence it meant", async () => {
|
|
325
|
+
await mount(
|
|
326
|
+
`${WEEK}/${OBJECT}/${RECURRENCE}`,
|
|
327
|
+
answering(() => ({}), [occurrence], series),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
harness?.click(harness.byText("button", "Edit"));
|
|
331
|
+
await settle();
|
|
332
|
+
harness?.click(harness.byText("button", "This event"));
|
|
333
|
+
await settle();
|
|
334
|
+
|
|
335
|
+
harness?.type(harness.byLabel("Title"), "Standup, short");
|
|
336
|
+
await settle();
|
|
337
|
+
harness?.click(harness.byText("button", "Save"));
|
|
338
|
+
await settle();
|
|
339
|
+
|
|
340
|
+
const patched = (http?.calls ?? []).filter(
|
|
341
|
+
(call) => call.method === "PATCH",
|
|
342
|
+
);
|
|
343
|
+
assert.equal(patched.length, 1);
|
|
344
|
+
assert.equal(patched[0].body?.summary, "Standup, short");
|
|
345
|
+
assert.equal(
|
|
346
|
+
patched[0].headers["if-match"],
|
|
347
|
+
"etag-1",
|
|
348
|
+
"the write is conditional on the version the form was opened from",
|
|
349
|
+
);
|
|
350
|
+
assert.ok(patched[0].url.includes("scope=This"), patched[0].url);
|
|
351
|
+
assert.ok(
|
|
352
|
+
patched[0].url.includes(encodeURIComponent(RECURRENCE)),
|
|
353
|
+
patched[0].url,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -219,6 +219,22 @@ export function MailSidebarAdapter({
|
|
|
219
219
|
</NavLink>
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
|
+
if (navId === "calendar") {
|
|
223
|
+
// No list segment to name: the calendar's index route sends the reader
|
|
224
|
+
// to the week they are in, so a bookmark of the nav entry keeps working
|
|
225
|
+
// tomorrow.
|
|
226
|
+
return (
|
|
227
|
+
<NavLink
|
|
228
|
+
to="/calendar"
|
|
229
|
+
onClick={() => onClick?.()}
|
|
230
|
+
className={className}
|
|
231
|
+
aria-label={ariaLabel}
|
|
232
|
+
title={title}
|
|
233
|
+
>
|
|
234
|
+
{children}
|
|
235
|
+
</NavLink>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
222
238
|
if (navId === "outbox") {
|
|
223
239
|
return (
|
|
224
240
|
<NavLink
|
|
@@ -332,6 +348,7 @@ export function MailSidebarAdapter({
|
|
|
332
348
|
selectedNavId={selectedNavId}
|
|
333
349
|
onSelectNav={handleSelectNav}
|
|
334
350
|
linkComponent={linkComponent}
|
|
351
|
+
calendarNav="shown"
|
|
335
352
|
variant={variant}
|
|
336
353
|
savedSearches={savedSearches}
|
|
337
354
|
saveableQuery={saveableQuery}
|