@remit/web-client 0.0.231 → 0.0.233
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/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
- package/src/components/calendar/CalendarComposePane.tsx +83 -23
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
- package/src/components/calendar/CalendarEventPane.tsx +28 -5
- package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
- package/src/components/calendar/CalendarWorkspace.tsx +71 -23
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -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 +88 -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/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +195 -0
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -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 +168 -0
- package/src/hooks/useCalendarData.ts +62 -40
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +21 -7
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +51 -15
- package/src/routing/calendar.ts +32 -4
- package/src/test-support/http.ts +8 -0
- package/src/lib/calendar-fixtures.ts +0 -207
|
@@ -1,13 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EventDraft,
|
|
3
|
+
type RecurrenceScope,
|
|
4
|
+
RecurrenceScopePrompt,
|
|
5
|
+
} from "@remit/ui";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
|
|
1
8
|
import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
|
|
9
|
+
import {
|
|
10
|
+
type CalendarWriteOutcome,
|
|
11
|
+
calendarInstanceId,
|
|
12
|
+
draftFromEvent,
|
|
13
|
+
patchFromDrafts,
|
|
14
|
+
rruleFromIcalData,
|
|
15
|
+
type ScopedWrite,
|
|
16
|
+
textFromIcalData,
|
|
17
|
+
textFromRrule,
|
|
18
|
+
UNZONED_CALENDAR,
|
|
19
|
+
useCalendarEvent,
|
|
20
|
+
useCalendars,
|
|
21
|
+
useCalendarWrites,
|
|
22
|
+
} from "@/hooks/calendar";
|
|
2
23
|
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
24
|
+
import { formatEventWhen } from "@/lib/calendar-format";
|
|
3
25
|
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
4
26
|
|
|
5
27
|
/**
|
|
6
|
-
* The event the address names, resolved.
|
|
28
|
+
* The event the address names, resolved — and everything a reader can do to it.
|
|
7
29
|
*
|
|
8
|
-
* The two event routes
|
|
9
|
-
*
|
|
10
|
-
*
|
|
30
|
+
* The two event routes differ only in what the address says, so the lookup, the
|
|
31
|
+
* scope question and the way back live here once rather than in each of them.
|
|
32
|
+
*
|
|
33
|
+
* An edit or a delete on a repeating event asks which occurrences it means
|
|
34
|
+
* before the form opens, not on the way out: which instances it reaches changes
|
|
35
|
+
* what the change is, so it is settled while it can still be answered.
|
|
36
|
+
*
|
|
37
|
+
* Every write carries the etag of the version on screen. A 412 means somebody
|
|
38
|
+
* replaced the event in between, and it is said out loud rather than resolved
|
|
39
|
+
* by overwriting them.
|
|
11
40
|
*/
|
|
12
41
|
export interface OpenCalendarEventProps {
|
|
13
42
|
calendarObjectId: string;
|
|
@@ -15,24 +44,177 @@ export interface OpenCalendarEventProps {
|
|
|
15
44
|
recurrenceId?: string;
|
|
16
45
|
}
|
|
17
46
|
|
|
47
|
+
const CONFLICT =
|
|
48
|
+
"This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.";
|
|
49
|
+
|
|
50
|
+
type Intent = "edit" | "delete";
|
|
51
|
+
|
|
18
52
|
export function OpenCalendarEvent({
|
|
19
53
|
calendarObjectId,
|
|
20
54
|
recurrenceId,
|
|
21
55
|
}: OpenCalendarEventProps) {
|
|
22
56
|
const { view, date, calendarIds } = useCalendarAddress();
|
|
23
|
-
const { events
|
|
57
|
+
const { events } = useCalendarData({ view, date, calendarIds });
|
|
58
|
+
const { calendars, timeZoneByCalendarId } = useCalendars();
|
|
24
59
|
const { closeEvent } = useCalendarNavigation();
|
|
60
|
+
const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
|
|
25
61
|
|
|
26
|
-
const event = events.find(
|
|
62
|
+
const event = events.find(
|
|
63
|
+
(candidate) =>
|
|
64
|
+
candidate.id === calendarInstanceId(calendarObjectId, recurrenceId ?? ""),
|
|
65
|
+
);
|
|
27
66
|
const calendar = calendars.find(
|
|
28
67
|
(candidate) => candidate.id === event?.calendarId,
|
|
29
68
|
);
|
|
69
|
+
const { resource } = useCalendarEvent(
|
|
70
|
+
calendarObjectId,
|
|
71
|
+
event?.calendarId ?? "",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const [asking, setAsking] = useState<Intent | undefined>(undefined);
|
|
75
|
+
const [editing, setEditing] = useState<
|
|
76
|
+
{ before: EventDraft; scope: RecurrenceScope | undefined } | undefined
|
|
77
|
+
>(undefined);
|
|
78
|
+
const [draft, setDraft] = useState<EventDraft | undefined>(undefined);
|
|
79
|
+
const [problem, setProblem] = useState("");
|
|
80
|
+
|
|
81
|
+
const stored = resource ? textFromIcalData(resource.icalData) : undefined;
|
|
82
|
+
const rrule = resource ? rruleFromIcalData(resource.icalData) : "";
|
|
83
|
+
const startTime = event && !event.allDay ? event.start.slice(11, 16) : "";
|
|
84
|
+
const repeat =
|
|
85
|
+
rrule === "" ? "" : (textFromRrule(rrule, startTime) ?? "Repeats");
|
|
86
|
+
|
|
87
|
+
// Scoping needs the occurrence to anchor at, so it is offered only where the
|
|
88
|
+
// address names one. A series opened by itself has one honest answer, which
|
|
89
|
+
// is the whole of it.
|
|
90
|
+
const canScope =
|
|
91
|
+
event !== undefined &&
|
|
92
|
+
event.seriesId !== "" &&
|
|
93
|
+
recurrenceId !== undefined &&
|
|
94
|
+
recurrenceId !== "";
|
|
95
|
+
// Until the resource is read there is no etag, and a write with no etag is a
|
|
96
|
+
// write that can silently overwrite somebody.
|
|
97
|
+
const writable = event !== undefined && resource !== undefined;
|
|
98
|
+
|
|
99
|
+
const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
|
|
100
|
+
if (outcome.kind === "written") {
|
|
101
|
+
setProblem("");
|
|
102
|
+
done();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setProblem(outcome.kind === "conflict" ? CONFLICT : outcome.message);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const write = (scope: RecurrenceScope | undefined): ScopedWrite => ({
|
|
109
|
+
calendarObjectId,
|
|
110
|
+
calendarId: event?.calendarId ?? "",
|
|
111
|
+
recurrenceId: recurrenceId ?? "",
|
|
112
|
+
scope,
|
|
113
|
+
etag: resource?.etag ?? "",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const startEdit = (scope: RecurrenceScope | undefined) => {
|
|
117
|
+
if (!event) return;
|
|
118
|
+
const before = draftFromEvent(event, {
|
|
119
|
+
repeat,
|
|
120
|
+
location: stored?.location ?? "",
|
|
121
|
+
notes: stored?.description ?? "",
|
|
122
|
+
});
|
|
123
|
+
setAsking(undefined);
|
|
124
|
+
setEditing({ before, scope });
|
|
125
|
+
setDraft(before);
|
|
126
|
+
setProblem("");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const removeEvent = (scope: RecurrenceScope | undefined) => {
|
|
130
|
+
setAsking(undefined);
|
|
131
|
+
void deleteEvent(write(scope)).then((outcome) =>
|
|
132
|
+
settle(outcome, closeEvent),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const saveEdit = () => {
|
|
137
|
+
if (!editing || !draft) return;
|
|
138
|
+
const patch = patchFromDrafts(
|
|
139
|
+
editing.before,
|
|
140
|
+
draft,
|
|
141
|
+
timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
|
|
142
|
+
);
|
|
143
|
+
if (!patch.ok) {
|
|
144
|
+
setProblem(patch.problem);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
void updateEvent(write(editing.scope), patch.patch).then((outcome) =>
|
|
148
|
+
settle(outcome, () => {
|
|
149
|
+
setEditing(undefined);
|
|
150
|
+
setDraft(undefined);
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (asking !== undefined && event) {
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex h-full flex-col justify-center bg-surface px-row-inset">
|
|
158
|
+
<RecurrenceScopePrompt
|
|
159
|
+
className="mx-auto w-full max-w-md"
|
|
160
|
+
title={event.title}
|
|
161
|
+
ruleText={repeat === "" ? "It repeats" : repeat}
|
|
162
|
+
instanceText={formatEventWhen(event)}
|
|
163
|
+
onChoose={asking === "edit" ? startEdit : removeEvent}
|
|
164
|
+
onCancel={() => setAsking(undefined)}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (editing && draft) {
|
|
171
|
+
return (
|
|
172
|
+
<CalendarComposePane
|
|
173
|
+
title="Edit event"
|
|
174
|
+
subtitle={
|
|
175
|
+
editing.scope === "this" ? "This occurrence only" : draft.date
|
|
176
|
+
}
|
|
177
|
+
calendars={calendars}
|
|
178
|
+
draft={draft}
|
|
179
|
+
onChange={setDraft}
|
|
180
|
+
problem={problem}
|
|
181
|
+
saveLabel="Save"
|
|
182
|
+
isSaving={isWriting}
|
|
183
|
+
repeatEditable={editing.scope === undefined || editing.scope === "all"}
|
|
184
|
+
calendarEditable={false}
|
|
185
|
+
onSave={saveEdit}
|
|
186
|
+
onCancel={() => {
|
|
187
|
+
setEditing(undefined);
|
|
188
|
+
setDraft(undefined);
|
|
189
|
+
setProblem("");
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
30
194
|
|
|
31
195
|
return (
|
|
32
196
|
<CalendarEventPane
|
|
33
|
-
event={
|
|
197
|
+
event={
|
|
198
|
+
event && {
|
|
199
|
+
...event,
|
|
200
|
+
location: stored?.location ?? "",
|
|
201
|
+
notes: stored?.description ?? "",
|
|
202
|
+
recurrenceRule: repeat,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
34
205
|
calendar={calendar}
|
|
35
206
|
isOccurrence={recurrenceId !== undefined}
|
|
207
|
+
problem={problem}
|
|
208
|
+
onEdit={
|
|
209
|
+
writable
|
|
210
|
+
? () => (canScope ? setAsking("edit") : startEdit(undefined))
|
|
211
|
+
: undefined
|
|
212
|
+
}
|
|
213
|
+
onDelete={
|
|
214
|
+
writable
|
|
215
|
+
? () => (canScope ? setAsking("delete") : removeEvent(undefined))
|
|
216
|
+
: undefined
|
|
217
|
+
}
|
|
36
218
|
onClose={closeEvent}
|
|
37
219
|
/>
|
|
38
220
|
);
|
|
@@ -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,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strip and the address, bound together.
|
|
3
|
+
*
|
|
4
|
+
* Three claims, and every one of them is about the URL rather than about
|
|
5
|
+
* pixels: the strip opens on the day the path names, reaching an end asks for
|
|
6
|
+
* more days rather than replacing the ones on screen, and the day the reader
|
|
7
|
+
* scrolls to is written by `replace` — scrolling is not somewhere they went, so
|
|
8
|
+
* Back belongs to the screen they arrived from and not to every row they
|
|
9
|
+
* passed. The last one is the reason this suite drives the real router instead
|
|
10
|
+
* of a callback: a `push` here would bury the way out under a fortnight of
|
|
11
|
+
* history entries.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import {
|
|
16
|
+
calendarEventOperationsListCalendarEventsQueryKey,
|
|
17
|
+
calendarFreeBusyOperationsListCalendarFreeBusyQueryKey,
|
|
18
|
+
calendarOperationsListCalendarsQueryKey,
|
|
19
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
20
|
+
import {
|
|
21
|
+
type AnyRouter,
|
|
22
|
+
createMemoryHistory,
|
|
23
|
+
createRootRoute,
|
|
24
|
+
createRoute,
|
|
25
|
+
createRouter,
|
|
26
|
+
Outlet,
|
|
27
|
+
RouterProvider,
|
|
28
|
+
} from "@tanstack/react-router";
|
|
29
|
+
import { act, createElement, type ReactNode } from "react";
|
|
30
|
+
import {
|
|
31
|
+
calendarWindow,
|
|
32
|
+
datesInRange,
|
|
33
|
+
extendRangeEnd,
|
|
34
|
+
extendRangeStart,
|
|
35
|
+
rangeAround,
|
|
36
|
+
weekWindowsOver,
|
|
37
|
+
} from "@/hooks/calendar";
|
|
38
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
39
|
+
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
40
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
41
|
+
import {
|
|
42
|
+
calendarSearchSchema,
|
|
43
|
+
calendarViewMountsAgenda,
|
|
44
|
+
} from "../../lib/calendar-route";
|
|
45
|
+
import { stringifySearch } from "../../lib/search-params";
|
|
46
|
+
import { AgendaView } from "./AgendaView";
|
|
47
|
+
|
|
48
|
+
const DATE = "2026-06-10";
|
|
49
|
+
const WORK = "cal_work";
|
|
50
|
+
const HOME = "cal_home";
|
|
51
|
+
|
|
52
|
+
const TO_WEEK = "to week";
|
|
53
|
+
|
|
54
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
55
|
+
// `window`.
|
|
56
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
57
|
+
|
|
58
|
+
let harness: DomHarness | undefined;
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
harness?.close();
|
|
62
|
+
harness = undefined;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The zoom switch's own binding, beside the strip the route mounts. */
|
|
66
|
+
function CalendarLayout() {
|
|
67
|
+
const { goToView } = useCalendarNavigation();
|
|
68
|
+
return createElement(
|
|
69
|
+
"div",
|
|
70
|
+
null,
|
|
71
|
+
createElement(
|
|
72
|
+
"button",
|
|
73
|
+
{
|
|
74
|
+
type: "button",
|
|
75
|
+
"aria-label": TO_WEEK,
|
|
76
|
+
onClick: () => goToView("week"),
|
|
77
|
+
},
|
|
78
|
+
"Week",
|
|
79
|
+
),
|
|
80
|
+
createElement(AgendaView, {
|
|
81
|
+
density: "comfortable",
|
|
82
|
+
onPickSlot: () => undefined,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const testRouter = (
|
|
88
|
+
path: string,
|
|
89
|
+
component: () => ReactNode = CalendarLayout,
|
|
90
|
+
): AnyRouter => {
|
|
91
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
92
|
+
const viewRoute = createRoute({
|
|
93
|
+
getParentRoute: () => rootRoute,
|
|
94
|
+
path: "/calendar/$view/$date",
|
|
95
|
+
validateSearch: calendarSearchSchema,
|
|
96
|
+
component,
|
|
97
|
+
});
|
|
98
|
+
// The strip reads whether an event is open, which is a child of this route.
|
|
99
|
+
const eventRoute = createRoute({
|
|
100
|
+
getParentRoute: () => viewRoute,
|
|
101
|
+
path: "$calendarObjectId",
|
|
102
|
+
component: Outlet,
|
|
103
|
+
});
|
|
104
|
+
const instanceRoute = createRoute({
|
|
105
|
+
getParentRoute: () => eventRoute,
|
|
106
|
+
path: "$recurrenceId",
|
|
107
|
+
component: Outlet,
|
|
108
|
+
});
|
|
109
|
+
const composerRoute = createRoute({
|
|
110
|
+
getParentRoute: () => viewRoute,
|
|
111
|
+
path: "new",
|
|
112
|
+
component: Outlet,
|
|
113
|
+
});
|
|
114
|
+
const routeTree = rootRoute.addChildren([
|
|
115
|
+
viewRoute.addChildren([
|
|
116
|
+
eventRoute.addChildren([instanceRoute]),
|
|
117
|
+
composerRoute,
|
|
118
|
+
]),
|
|
119
|
+
]);
|
|
120
|
+
return createRouter({
|
|
121
|
+
routeTree,
|
|
122
|
+
// A list of ticked calendars is repeated params, which is how the app
|
|
123
|
+
// spells one and what a hand-written link says.
|
|
124
|
+
stringifySearch,
|
|
125
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
126
|
+
}) as unknown as AnyRouter;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** How many times a case reaches an end, and so how far the strip can go. */
|
|
130
|
+
const REACHES = 8;
|
|
131
|
+
|
|
132
|
+
/** Every week the strip can reach, given that many pulls at either end. */
|
|
133
|
+
const reachableWeeks = () => {
|
|
134
|
+
let range = rangeAround(DATE);
|
|
135
|
+
for (let reach = 0; reach < REACHES; reach += 1) {
|
|
136
|
+
range = extendRangeEnd(extendRangeStart(range));
|
|
137
|
+
}
|
|
138
|
+
return weekWindowsOver(datesInRange(range));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every week the strip can ask for, answered from the cache. A week nobody
|
|
143
|
+
* seeded would fire a real request, and what these cases are about is which
|
|
144
|
+
* weeks get asked for, not what comes back.
|
|
145
|
+
*/
|
|
146
|
+
const seed = (dom: DomHarness) => {
|
|
147
|
+
dom.queryClient.setQueryData(calendarOperationsListCalendarsQueryKey(), {
|
|
148
|
+
items: [],
|
|
149
|
+
});
|
|
150
|
+
for (const window of reachableWeeks()) {
|
|
151
|
+
dom.queryClient.setQueryData(
|
|
152
|
+
calendarEventOperationsListCalendarEventsQueryKey({ query: window }),
|
|
153
|
+
{ items: [] },
|
|
154
|
+
);
|
|
155
|
+
dom.queryClient.setQueryData(
|
|
156
|
+
calendarFreeBusyOperationsListCalendarFreeBusyQueryKey({
|
|
157
|
+
query: window,
|
|
158
|
+
}),
|
|
159
|
+
{ items: [] },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
interface Mounted {
|
|
165
|
+
dom: DomHarness;
|
|
166
|
+
router: AnyRouter;
|
|
167
|
+
pushes: number;
|
|
168
|
+
replaces: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const mount = async (path = `/calendar/agenda/${DATE}`): Promise<Mounted> => {
|
|
172
|
+
const dom = createDomHarness();
|
|
173
|
+
harness = dom;
|
|
174
|
+
seed(dom);
|
|
175
|
+
|
|
176
|
+
const router = testRouter(path);
|
|
177
|
+
const counted = { pushes: 0, replaces: 0 };
|
|
178
|
+
const { push, replace } = router.history;
|
|
179
|
+
router.history.push = (...args: Parameters<typeof push>) => {
|
|
180
|
+
counted.pushes += 1;
|
|
181
|
+
return push(...args);
|
|
182
|
+
};
|
|
183
|
+
router.history.replace = (...args: Parameters<typeof replace>) => {
|
|
184
|
+
counted.replaces += 1;
|
|
185
|
+
return replace(...args);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await router.load();
|
|
189
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
190
|
+
await dom.flush();
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
dom,
|
|
194
|
+
router,
|
|
195
|
+
get pushes() {
|
|
196
|
+
return counted.pushes;
|
|
197
|
+
},
|
|
198
|
+
get replaces() {
|
|
199
|
+
return counted.replaces;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The one element the strip scrolls; jsdom has no layout, only the event. */
|
|
205
|
+
const scroller = (dom: DomHarness): Element => {
|
|
206
|
+
const found = dom.query(".overflow-y-auto");
|
|
207
|
+
if (!found) throw new Error("the strip rendered no scroller");
|
|
208
|
+
return found;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const scroll = (dom: DomHarness) => {
|
|
212
|
+
dom.dispatch(scroller(dom), new Event("scroll", { bubbles: false }));
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
describe("the day the address names", () => {
|
|
216
|
+
it("is the day the strip opens on", async () => {
|
|
217
|
+
const { dom } = await mount();
|
|
218
|
+
assert.match(dom.text(), /Wednesday/, "the header names another day");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("asks for the weeks around it, one request each", async () => {
|
|
222
|
+
const { dom } = await mount();
|
|
223
|
+
const asked = eventWindows(dom);
|
|
224
|
+
const expected = weekWindowsOver(datesInRange(rangeAround(DATE)));
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
asked.map((window) => window.from).sort(),
|
|
227
|
+
expected.map((window) => window.from).sort(),
|
|
228
|
+
);
|
|
229
|
+
for (const window of asked) {
|
|
230
|
+
const days =
|
|
231
|
+
(Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
|
|
232
|
+
assert.equal(days, 7, "a read covered more than one week");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("reaching an end of the strip", () => {
|
|
238
|
+
it("asks for more days without giving up the ones on screen", async () => {
|
|
239
|
+
const { dom } = await mount();
|
|
240
|
+
const before = dom.text();
|
|
241
|
+
assert.doesNotMatch(before, /17 May/);
|
|
242
|
+
|
|
243
|
+
scroll(dom);
|
|
244
|
+
await dom.flush();
|
|
245
|
+
|
|
246
|
+
const after = dom.text();
|
|
247
|
+
assert.match(after, /17 May/, "the days before the window did not arrive");
|
|
248
|
+
assert.match(after, /9 Jun/, "the days it opened with were thrown away");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("adds week keys rather than widening the one it holds", async () => {
|
|
252
|
+
const { dom } = await mount();
|
|
253
|
+
const opening = eventWindows(dom).map((window) => window.from);
|
|
254
|
+
|
|
255
|
+
scroll(dom);
|
|
256
|
+
await dom.flush();
|
|
257
|
+
|
|
258
|
+
const after = eventWindows(dom).map((window) => window.from);
|
|
259
|
+
for (const from of opening) {
|
|
260
|
+
assert.ok(after.includes(from), `${from} was given up to fetch more`);
|
|
261
|
+
}
|
|
262
|
+
assert.ok(after.length > opening.length, "no week was added");
|
|
263
|
+
assert.ok(
|
|
264
|
+
after.some((from) => from < opening[0]),
|
|
265
|
+
"nothing reached further back than the week it opened with",
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("never grows a single read past what the server will answer", async () => {
|
|
270
|
+
const { dom } = await mount();
|
|
271
|
+
for (let reach = 0; reach < 6; reach += 1) {
|
|
272
|
+
scroll(dom);
|
|
273
|
+
await dom.flush();
|
|
274
|
+
}
|
|
275
|
+
for (const window of eventWindows(dom)) {
|
|
276
|
+
const days =
|
|
277
|
+
(Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
|
|
278
|
+
assert.equal(days, 7, `a read covered ${days} days`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("the day the reader scrolled to", () => {
|
|
284
|
+
it("is written to the path, so a reload comes back to it", async () => {
|
|
285
|
+
const { dom, router } = await mount();
|
|
286
|
+
scroll(dom);
|
|
287
|
+
await dom.wait(600);
|
|
288
|
+
await dom.flush();
|
|
289
|
+
|
|
290
|
+
assert.match(router.state.location.pathname, /^\/calendar\/agenda\//);
|
|
291
|
+
assert.notEqual(
|
|
292
|
+
router.state.location.pathname,
|
|
293
|
+
`/calendar/agenda/${DATE}`,
|
|
294
|
+
"scrolling left the address on the day it opened",
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("replaces rather than pushes, so Back is still the way out", async () => {
|
|
299
|
+
const mounted = await mount();
|
|
300
|
+
scroll(mounted.dom);
|
|
301
|
+
await mounted.dom.wait(600);
|
|
302
|
+
await mounted.dom.flush();
|
|
303
|
+
|
|
304
|
+
assert.ok(mounted.replaces > 0, "the address was never written");
|
|
305
|
+
assert.equal(mounted.pushes, 0, "scrolling stacked up history entries");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("dropping into the grid and back out", () => {
|
|
310
|
+
it("keeps the day and the calendars the reader was looking at", async () => {
|
|
311
|
+
const { dom, router } = await mount();
|
|
312
|
+
await act(async () => {
|
|
313
|
+
await router.navigate({
|
|
314
|
+
to: "/calendar/$view/$date",
|
|
315
|
+
params: { view: "agenda", date: DATE },
|
|
316
|
+
search: { calendarId: [HOME, WORK] },
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
await dom.flush();
|
|
320
|
+
|
|
321
|
+
dom.click(dom.byLabel(TO_WEEK));
|
|
322
|
+
await dom.flush();
|
|
323
|
+
|
|
324
|
+
assert.equal(router.state.location.pathname, `/calendar/week/${DATE}`);
|
|
325
|
+
assert.deepEqual(router.state.location.search.calendarId, [HOME, WORK]);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The layout's own read, bound exactly as the route binds it. Mounted without
|
|
331
|
+
* the strip, so any window in the cache came from here.
|
|
332
|
+
*/
|
|
333
|
+
function LayoutOnly() {
|
|
334
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
335
|
+
useCalendarData({
|
|
336
|
+
view,
|
|
337
|
+
date,
|
|
338
|
+
calendarIds,
|
|
339
|
+
enabled: !calendarViewMountsAgenda(view),
|
|
340
|
+
});
|
|
341
|
+
return createElement("div", { "data-testid": "layout-only" });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
describe("the layout's own week, at a zoom that does not draw it", () => {
|
|
345
|
+
const mountLayout = async (view: string): Promise<DomHarness> => {
|
|
346
|
+
const dom = createDomHarness();
|
|
347
|
+
harness = dom;
|
|
348
|
+
seed(dom);
|
|
349
|
+
const router = testRouter(`/calendar/${view}/${DATE}`, LayoutOnly);
|
|
350
|
+
await router.load();
|
|
351
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
352
|
+
await dom.flush();
|
|
353
|
+
return dom;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
it("reads the week the grid draws", async () => {
|
|
357
|
+
const dom = await mountLayout("week");
|
|
358
|
+
assert.deepEqual(
|
|
359
|
+
eventWindows(dom).map((window) => window.from),
|
|
360
|
+
[calendarWindow("week", DATE).from],
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/*
|
|
365
|
+
* The strip fetches its own weeks, so this read renders nothing — and the
|
|
366
|
+
* address rewrites on every scroll, so leaving it on would ask again, with
|
|
367
|
+
* its two neighbours prefetched, all the way down the strip.
|
|
368
|
+
*/
|
|
369
|
+
it("asks for nothing at all at the agenda zoom", async () => {
|
|
370
|
+
const dom = await mountLayout("agenda");
|
|
371
|
+
assert.deepEqual(eventWindows(dom), []);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
/** The windows the strip currently has `/calendar-events` open on. */
|
|
376
|
+
const eventWindows = (dom: DomHarness): { from: string; to: string }[] =>
|
|
377
|
+
dom.queryClient
|
|
378
|
+
.getQueryCache()
|
|
379
|
+
.getAll()
|
|
380
|
+
.filter((query) => query.isActive())
|
|
381
|
+
.map(
|
|
382
|
+
(query) =>
|
|
383
|
+
query.queryKey[0] as {
|
|
384
|
+
_id?: string;
|
|
385
|
+
query?: { from: string; to: string };
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
.filter((key) => key?._id === "calendarEventOperationsListCalendarEvents")
|
|
389
|
+
.flatMap((key) => (key.query ? [key.query] : []));
|