@remit/web-client 0.0.290 → 0.0.292
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 +19 -0
- package/src/components/calendar/CalendarComposePane.tsx +30 -10
- package/src/components/calendar/OpenCalendarEvent.tsx +7 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +4 -0
- package/src/components/settings/CalendarCreateCard.stories.tsx +33 -0
- package/src/components/settings/CalendarCreateCard.tsx +149 -0
- package/src/components/settings/CalendarDetailsForm.stories.tsx +57 -0
- package/src/components/settings/CalendarDetailsForm.tsx +136 -0
- package/src/components/settings/CalendarFeedCard.stories.tsx +18 -0
- package/src/components/settings/CalendarFeedCard.tsx +7 -1
- package/src/components/settings/CalendarFeedPanel.tsx +4 -0
- package/src/components/settings/CalendarSettingsPanel.tsx +54 -0
- package/src/components/settings/TimeZoneSelect.tsx +43 -0
- package/src/components/settings/calendar-collection-cards.render.test.ts +149 -0
- package/src/hooks/calendar/calendar-collections.render.test.ts +259 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +21 -0
- package/src/hooks/calendar/index.ts +6 -0
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +130 -0
- package/src/hooks/calendar/useCalendarWrites.ts +5 -3
- package/src/hooks/calendar/useDraftClashes.ts +91 -0
- package/src/lib/calendar-collection-copy.test.ts +23 -0
- package/src/lib/calendar-collection-copy.ts +21 -0
- package/src/routes/settings/calendars.tsx +28 -6
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Select } from "@remit/ui";
|
|
2
|
+
import { UNZONED_CALENDAR } from "@/hooks/calendar";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The zone a calendar's floating times are read in. The empty choice is how
|
|
6
|
+
* the API spells "no zone", which reads as UTC — there is no IANA name for UTC
|
|
7
|
+
* the server accepts, so it is offered as its own option rather than as a zone.
|
|
8
|
+
*/
|
|
9
|
+
export interface TimeZoneSelectProps {
|
|
10
|
+
id: string;
|
|
11
|
+
value: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onChange: (timeZone: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ZONES = Intl.supportedValuesOf("timeZone");
|
|
17
|
+
|
|
18
|
+
export function TimeZoneSelect({
|
|
19
|
+
id,
|
|
20
|
+
value,
|
|
21
|
+
disabled,
|
|
22
|
+
onChange,
|
|
23
|
+
}: TimeZoneSelectProps) {
|
|
24
|
+
const zones =
|
|
25
|
+
value === UNZONED_CALENDAR || ZONES.includes(value)
|
|
26
|
+
? ZONES
|
|
27
|
+
: [value, ...ZONES];
|
|
28
|
+
return (
|
|
29
|
+
<Select
|
|
30
|
+
id={id}
|
|
31
|
+
value={value}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
onChange={(event) => onChange(event.target.value)}
|
|
34
|
+
>
|
|
35
|
+
<option value={UNZONED_CALENDAR}>None (read as UTC)</option>
|
|
36
|
+
{zones.map((zone) => (
|
|
37
|
+
<option key={zone} value={zone}>
|
|
38
|
+
{zone}
|
|
39
|
+
</option>
|
|
40
|
+
))}
|
|
41
|
+
</Select>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarClash, EventDraft } from "@remit/ui";
|
|
4
|
+
import React, { createElement } from "react";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
|
|
7
|
+
import { CalendarCreateCard } from "./CalendarCreateCard";
|
|
8
|
+
import { CalendarDetailsForm } from "./CalendarDetailsForm";
|
|
9
|
+
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
10
|
+
|
|
11
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
12
|
+
// runtime, which references a global `React`.
|
|
13
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
14
|
+
|
|
15
|
+
const details = (problem = "", timezone = "Europe/Amsterdam") =>
|
|
16
|
+
renderToString(
|
|
17
|
+
createElement(CalendarDetailsForm, {
|
|
18
|
+
calendarName: "Work",
|
|
19
|
+
timezone,
|
|
20
|
+
isBusy: false,
|
|
21
|
+
problem,
|
|
22
|
+
onSave: () => undefined,
|
|
23
|
+
onDelete: () => undefined,
|
|
24
|
+
}) as never,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
describe("CalendarDetailsForm", () => {
|
|
28
|
+
it("offers the name, the zone, a save and a delete", () => {
|
|
29
|
+
const html = details();
|
|
30
|
+
assert.match(html, /value="Work"/);
|
|
31
|
+
assert.match(html, /Europe\/Amsterdam/);
|
|
32
|
+
assert.match(html, /Save changes/);
|
|
33
|
+
assert.match(html, /Delete calendar/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("offers no zone as its own choice, read as UTC", () => {
|
|
37
|
+
assert.match(details("", ""), /None \(read as UTC\)/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps a zone the list does not hold rather than dropping it", () => {
|
|
41
|
+
assert.match(details("", "Mars/Olympus"), /Mars\/Olympus/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("states a refusal in the server's words", () => {
|
|
45
|
+
const html = details("cannot be removed");
|
|
46
|
+
assert.match(html, /role="alert"/);
|
|
47
|
+
assert.match(html.replaceAll("<!-- -->", ""), /Work was not changed/);
|
|
48
|
+
assert.match(html, /cannot be removed/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("CalendarCreateCard", () => {
|
|
53
|
+
const create = (problem = "") =>
|
|
54
|
+
renderToString(
|
|
55
|
+
createElement(CalendarCreateCard, {
|
|
56
|
+
isBusy: false,
|
|
57
|
+
problem,
|
|
58
|
+
onCreate: () => Promise.resolve(true),
|
|
59
|
+
}) as never,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it("asks for a name, an address and a zone", () => {
|
|
63
|
+
const html = create();
|
|
64
|
+
assert.match(html, /New calendar/);
|
|
65
|
+
assert.match(html, />Name</);
|
|
66
|
+
assert.match(html, />Address</);
|
|
67
|
+
assert.match(html, />Time zone</);
|
|
68
|
+
assert.match(html, /Add calendar/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("states a refused create where the button is", () => {
|
|
72
|
+
const html = create("already addresses a calendar");
|
|
73
|
+
assert.match(html, /The calendar was not added/);
|
|
74
|
+
assert.match(html, /already addresses a calendar/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("CalendarFeedCard with details", () => {
|
|
79
|
+
it("draws the calendar's settings above its address", () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
createElement(CalendarFeedCard, {
|
|
82
|
+
calendarName: "Work",
|
|
83
|
+
details: createElement("p", null, "calendar details here"),
|
|
84
|
+
state: { status: "absent" },
|
|
85
|
+
mintedUrl: "",
|
|
86
|
+
isBusy: false,
|
|
87
|
+
actionError: undefined,
|
|
88
|
+
onMint: () => undefined,
|
|
89
|
+
onRevoke: () => undefined,
|
|
90
|
+
onDismissMinted: () => undefined,
|
|
91
|
+
onRetry: () => undefined,
|
|
92
|
+
}) as never,
|
|
93
|
+
);
|
|
94
|
+
assert.ok(
|
|
95
|
+
html.indexOf("calendar details here") <
|
|
96
|
+
html.indexOf("Create subscription address"),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("CalendarComposePane clashes", () => {
|
|
102
|
+
const draft: EventDraft = {
|
|
103
|
+
title: "Roadmap review",
|
|
104
|
+
date: "2026-06-10",
|
|
105
|
+
endDate: "2026-06-10",
|
|
106
|
+
startTime: "10:00",
|
|
107
|
+
endTime: "11:00",
|
|
108
|
+
allDay: false,
|
|
109
|
+
calendarId: "cal_work",
|
|
110
|
+
location: "",
|
|
111
|
+
guests: "",
|
|
112
|
+
notes: "",
|
|
113
|
+
repeat: "",
|
|
114
|
+
};
|
|
115
|
+
const pane = (clashes: CalendarClash[] | undefined) =>
|
|
116
|
+
renderToString(
|
|
117
|
+
createElement(CalendarComposePane, {
|
|
118
|
+
title: "New event",
|
|
119
|
+
calendars: [],
|
|
120
|
+
draft,
|
|
121
|
+
onChange: () => undefined,
|
|
122
|
+
problem: "",
|
|
123
|
+
saveLabel: "Add",
|
|
124
|
+
isSaving: false,
|
|
125
|
+
clashes,
|
|
126
|
+
onSave: () => undefined,
|
|
127
|
+
onCancel: () => undefined,
|
|
128
|
+
}) as never,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it("names what the span runs into", () => {
|
|
132
|
+
const html = pane([{ id: "a", label: "Board prep, Wed 10 June" }]);
|
|
133
|
+
assert.match(
|
|
134
|
+
html,
|
|
135
|
+
/This clashes with something you have already agreed to/,
|
|
136
|
+
);
|
|
137
|
+
assert.match(html, /Board prep, Wed 10 June/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("says a checked span is clear", () => {
|
|
141
|
+
assert.match(pane([]), /Nothing else is booked at this time/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("draws nothing before there is an answer", () => {
|
|
145
|
+
const html = pane(undefined);
|
|
146
|
+
assert.doesNotMatch(html, /Nothing else is booked/);
|
|
147
|
+
assert.doesNotMatch(html, /This clashes/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapCalendarEventInstance,
|
|
5
|
+
RemitImapCalendarResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import type { CalendarClash, EventDraft } from "@remit/ui";
|
|
8
|
+
import { act, createElement } from "react";
|
|
9
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
10
|
+
import {
|
|
11
|
+
type HttpCall,
|
|
12
|
+
type HttpMock,
|
|
13
|
+
httpError,
|
|
14
|
+
mockFetch,
|
|
15
|
+
} from "../../test-support/http";
|
|
16
|
+
import {
|
|
17
|
+
type CalendarCollectionOutcome,
|
|
18
|
+
type CalendarCollectionWrites,
|
|
19
|
+
useCalendarCollectionWrites,
|
|
20
|
+
} from "./useCalendarCollectionWrites";
|
|
21
|
+
import { useCalendars } from "./useCalendars";
|
|
22
|
+
import { useDraftClashes } from "./useDraftClashes";
|
|
23
|
+
|
|
24
|
+
const WORK = "11111111-1111-4111-8111-111111111111";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock | undefined;
|
|
28
|
+
let writes: CalendarCollectionWrites | undefined;
|
|
29
|
+
let clashes: CalendarClash[] | undefined;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness?.close();
|
|
33
|
+
harness = undefined;
|
|
34
|
+
http?.restore();
|
|
35
|
+
http = undefined;
|
|
36
|
+
writes = undefined;
|
|
37
|
+
clashes = undefined;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const calendars: RemitImapCalendarResponse[] = [
|
|
41
|
+
{
|
|
42
|
+
calendarId: WORK,
|
|
43
|
+
accountConfigId: "cfg-1",
|
|
44
|
+
urlSegment: "work",
|
|
45
|
+
displayName: "Work",
|
|
46
|
+
color: "Cal1",
|
|
47
|
+
componentSet: "VeventOnly",
|
|
48
|
+
source: "UserCreated",
|
|
49
|
+
timezone: "",
|
|
50
|
+
syncSequence: 1,
|
|
51
|
+
createdAt: 0,
|
|
52
|
+
updatedAt: 0,
|
|
53
|
+
} as RemitImapCalendarResponse,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const instance = (
|
|
57
|
+
over: Partial<RemitImapCalendarEventInstance>,
|
|
58
|
+
): RemitImapCalendarEventInstance =>
|
|
59
|
+
({
|
|
60
|
+
calendarId: WORK,
|
|
61
|
+
calendarObjectId: "obj-1",
|
|
62
|
+
recurrenceId: "",
|
|
63
|
+
icalUid: "uid-1",
|
|
64
|
+
summary: "Board prep",
|
|
65
|
+
start: "2026-06-10T10:30:00+00:00",
|
|
66
|
+
end: "2026-06-10T11:00:00+00:00",
|
|
67
|
+
allDay: false,
|
|
68
|
+
status: "Confirmed",
|
|
69
|
+
transparency: "Opaque",
|
|
70
|
+
zoneCertainty: "Explicit",
|
|
71
|
+
etag: "etag-1",
|
|
72
|
+
hasRecurrence: false,
|
|
73
|
+
...over,
|
|
74
|
+
}) as RemitImapCalendarEventInstance;
|
|
75
|
+
|
|
76
|
+
const draft = (over: Partial<EventDraft>): EventDraft => ({
|
|
77
|
+
title: "Roadmap review",
|
|
78
|
+
date: "2026-06-10",
|
|
79
|
+
endDate: "2026-06-10",
|
|
80
|
+
startTime: "10:00",
|
|
81
|
+
endTime: "11:00",
|
|
82
|
+
allDay: false,
|
|
83
|
+
calendarId: WORK,
|
|
84
|
+
location: "",
|
|
85
|
+
guests: "",
|
|
86
|
+
notes: "",
|
|
87
|
+
repeat: "",
|
|
88
|
+
...over,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const settle = async () => {
|
|
92
|
+
await harness?.flush();
|
|
93
|
+
await harness?.wait(20);
|
|
94
|
+
await harness?.flush();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const serve = (respond: (call: HttpCall) => unknown) => {
|
|
98
|
+
http = mockFetch((call) =>
|
|
99
|
+
call.path.endsWith("/calendars") && call.method === "GET"
|
|
100
|
+
? { items: calendars }
|
|
101
|
+
: respond(call),
|
|
102
|
+
);
|
|
103
|
+
harness = createDomHarness();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function WritesProbe() {
|
|
107
|
+
useCalendars();
|
|
108
|
+
writes = useCalendarCollectionWrites();
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const write = async (
|
|
113
|
+
run: (writes: CalendarCollectionWrites) => Promise<CalendarCollectionOutcome>,
|
|
114
|
+
): Promise<CalendarCollectionOutcome> => {
|
|
115
|
+
const held = writes;
|
|
116
|
+
if (!held) throw new Error("the writes are not mounted");
|
|
117
|
+
let outcome: CalendarCollectionOutcome | undefined;
|
|
118
|
+
await act(async () => {
|
|
119
|
+
outcome = await run(held);
|
|
120
|
+
});
|
|
121
|
+
await settle();
|
|
122
|
+
if (!outcome) throw new Error("the write returned nothing");
|
|
123
|
+
return outcome;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const listingReads = (): number =>
|
|
127
|
+
(http?.calls ?? []).filter(
|
|
128
|
+
(call) => call.method === "GET" && call.path.endsWith("/calendars"),
|
|
129
|
+
).length;
|
|
130
|
+
|
|
131
|
+
describe("managing a calendar collection", () => {
|
|
132
|
+
it("creates one and reads the listing back", async () => {
|
|
133
|
+
serve(() => calendars[0]);
|
|
134
|
+
harness?.renderApp(createElement(WritesProbe));
|
|
135
|
+
await settle();
|
|
136
|
+
const before = listingReads();
|
|
137
|
+
|
|
138
|
+
const outcome = await write((writes) =>
|
|
139
|
+
writes.createCalendar({ displayName: "Harbour", urlSegment: "harbour" }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(outcome, { kind: "written" });
|
|
143
|
+
const posted = (http?.calls ?? []).find((call) => call.method === "POST");
|
|
144
|
+
assert.equal(posted?.body?.urlSegment, "harbour");
|
|
145
|
+
assert.ok(listingReads() > before, "the listing has to be read again");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("renames and rezones one through a patch", async () => {
|
|
149
|
+
serve(() => calendars[0]);
|
|
150
|
+
harness?.renderApp(createElement(WritesProbe));
|
|
151
|
+
await settle();
|
|
152
|
+
|
|
153
|
+
await write((writes) =>
|
|
154
|
+
writes.updateCalendar(WORK, {
|
|
155
|
+
displayName: "Works",
|
|
156
|
+
timezone: "Europe/Lisbon",
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const patched = (http?.calls ?? []).find((call) => call.method === "PATCH");
|
|
161
|
+
assert.equal(patched?.path.endsWith(`/calendars/${WORK}`), true);
|
|
162
|
+
assert.equal(patched?.body?.timezone, "Europe/Lisbon");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("hands back the server's words when it refuses a delete", async () => {
|
|
166
|
+
serve((call) =>
|
|
167
|
+
call.method === "DELETE"
|
|
168
|
+
? httpError(400, '"default" cannot be removed')
|
|
169
|
+
: calendars[0],
|
|
170
|
+
);
|
|
171
|
+
harness?.renderApp(createElement(WritesProbe));
|
|
172
|
+
await settle();
|
|
173
|
+
|
|
174
|
+
const outcome = await write((writes) => writes.deleteCalendar(WORK));
|
|
175
|
+
assert.equal(outcome.kind, "refused");
|
|
176
|
+
assert.match(
|
|
177
|
+
outcome.kind === "refused" ? outcome.message : "",
|
|
178
|
+
/cannot be removed/,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function ClashProbe({
|
|
184
|
+
of,
|
|
185
|
+
editing = "",
|
|
186
|
+
}: {
|
|
187
|
+
of: EventDraft;
|
|
188
|
+
editing?: string;
|
|
189
|
+
}) {
|
|
190
|
+
clashes = useDraftClashes(of, editing);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const clashesFor = async (
|
|
195
|
+
of: EventDraft,
|
|
196
|
+
items: RemitImapCalendarEventInstance[],
|
|
197
|
+
editing = "",
|
|
198
|
+
): Promise<CalendarClash[] | undefined> => {
|
|
199
|
+
serve(() => ({ items }));
|
|
200
|
+
harness?.renderApp(createElement(ClashProbe, { of, editing }));
|
|
201
|
+
await settle();
|
|
202
|
+
return clashes;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
describe("the clashes a draft runs into", () => {
|
|
206
|
+
it("names an event the span overlaps", async () => {
|
|
207
|
+
const found = await clashesFor(draft({}), [instance({})]);
|
|
208
|
+
assert.equal(found?.length, 1);
|
|
209
|
+
assert.match(found?.[0]?.label ?? "", /^Board prep, /);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("finds none for a span in a free slot", async () => {
|
|
213
|
+
const found = await clashesFor(
|
|
214
|
+
draft({ startTime: "14:00", endTime: "15:00" }),
|
|
215
|
+
[instance({})],
|
|
216
|
+
);
|
|
217
|
+
assert.deepEqual(found, []);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("never counts the event being edited against itself", async () => {
|
|
221
|
+
const found = await clashesFor(draft({}), [instance({})], "obj-1");
|
|
222
|
+
assert.deepEqual(found, []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("checks a multi-day span through its last day", async () => {
|
|
226
|
+
const found = await clashesFor(
|
|
227
|
+
draft({
|
|
228
|
+
endDate: "2026-06-16",
|
|
229
|
+
startTime: "09:00",
|
|
230
|
+
endTime: "17:00",
|
|
231
|
+
}),
|
|
232
|
+
[
|
|
233
|
+
instance({
|
|
234
|
+
start: "2026-06-15T12:00:00+00:00",
|
|
235
|
+
end: "2026-06-15T13:00:00+00:00",
|
|
236
|
+
}),
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
assert.equal(found?.length, 1);
|
|
240
|
+
const read = http?.to("/calendar-events")[0]?.url ?? "";
|
|
241
|
+
assert.ok(
|
|
242
|
+
read.includes(`to=${encodeURIComponent("2026-06-22")}`),
|
|
243
|
+
"the read has to cover the week of the last day",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("checks nothing when the end is not after the start", async () => {
|
|
248
|
+
const found = await clashesFor(
|
|
249
|
+
draft({ startTime: "10:00", endTime: "10:00" }),
|
|
250
|
+
[instance({})],
|
|
251
|
+
);
|
|
252
|
+
assert.equal(found, undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("checks nothing for an all-day draft", async () => {
|
|
256
|
+
const found = await clashesFor(draft({ allDay: true }), [instance({})]);
|
|
257
|
+
assert.equal(found, undefined);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -191,6 +191,27 @@ describe("editing one occurrence of a series", () => {
|
|
|
191
191
|
);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
it("names the occurrence a whole-series edit was made from", async () => {
|
|
195
|
+
await mount(() => ({ items: [] }));
|
|
196
|
+
await write((writes) =>
|
|
197
|
+
writes.updateEvent(
|
|
198
|
+
{ ...scopedWrite, scope: "all" },
|
|
199
|
+
{
|
|
200
|
+
start: "2026-06-11T10:00:00+02:00",
|
|
201
|
+
end: "2026-06-11T11:00:00+02:00",
|
|
202
|
+
},
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const patched = (http?.calls ?? []).find((call) => call.method === "PATCH");
|
|
207
|
+
assert.ok(patched, "the edit was sent");
|
|
208
|
+
assert.ok(patched.url.includes("scope=All"), patched.url);
|
|
209
|
+
assert.ok(
|
|
210
|
+
patched.url.includes(encodeURIComponent("2026-06-11T07:15:00Z")),
|
|
211
|
+
"the server moves the series by what changed on this occurrence",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
194
215
|
it("makes the delete conditional on the same version", async () => {
|
|
195
216
|
await mount(() => ({ items: [] }));
|
|
196
217
|
await write((writes) => writes.deleteEvent(scopedWrite));
|
|
@@ -57,6 +57,11 @@ export {
|
|
|
57
57
|
useCalendarSelection,
|
|
58
58
|
useDrawnEvents,
|
|
59
59
|
} from "./selection";
|
|
60
|
+
export {
|
|
61
|
+
type CalendarCollectionOutcome,
|
|
62
|
+
type CalendarCollectionWrites,
|
|
63
|
+
useCalendarCollectionWrites,
|
|
64
|
+
} from "./useCalendarCollectionWrites";
|
|
60
65
|
export {
|
|
61
66
|
type CalendarEventResource,
|
|
62
67
|
useCalendarEvent,
|
|
@@ -82,6 +87,7 @@ export {
|
|
|
82
87
|
useCalendarWrites,
|
|
83
88
|
useInvalidateCalendarReads,
|
|
84
89
|
} from "./useCalendarWrites";
|
|
90
|
+
export { useDraftClashes } from "./useDraftClashes";
|
|
85
91
|
export {
|
|
86
92
|
addDays,
|
|
87
93
|
type CalendarWindow,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating, renaming, rezoning and deleting a calendar collection.
|
|
3
|
+
*
|
|
4
|
+
* A refusal comes back as an outcome carrying the server's own words — a url
|
|
5
|
+
* segment already taken, a zone it cannot resolve, the default calendar that
|
|
6
|
+
* cannot be removed — so the settings card states it where the reader acted.
|
|
7
|
+
* A lapsed session is not among the statuses owned here and escalates.
|
|
8
|
+
*
|
|
9
|
+
* Every write invalidates the listing, which is what the settings page, the
|
|
10
|
+
* editor's picker and the address filter all read. A delete or a rezone also
|
|
11
|
+
* moves or removes occurrences, so the event reads are invalidated with it.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
calendarDetailOperationsDeleteCalendarMutation,
|
|
15
|
+
calendarDetailOperationsUpdateCalendarMutation,
|
|
16
|
+
calendarOperationsCreateCalendarMutation,
|
|
17
|
+
calendarOperationsListCalendarsQueryKey,
|
|
18
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
|
+
import type {
|
|
20
|
+
RemitImapCreateCalendarInput,
|
|
21
|
+
RemitImapUpdateCalendarInput,
|
|
22
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
23
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
24
|
+
import { useCallback, useMemo } from "react";
|
|
25
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
26
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
27
|
+
import { useInvalidateCalendarReads } from "./useCalendarWrites";
|
|
28
|
+
|
|
29
|
+
const COLLECTION_WRITE_META = softErrorStatuses(400, 404);
|
|
30
|
+
|
|
31
|
+
export type CalendarCollectionOutcome =
|
|
32
|
+
| { kind: "written" }
|
|
33
|
+
| { kind: "refused"; message: string };
|
|
34
|
+
|
|
35
|
+
const WRITTEN: CalendarCollectionOutcome = { kind: "written" };
|
|
36
|
+
|
|
37
|
+
const refused = (error: unknown): CalendarCollectionOutcome => ({
|
|
38
|
+
kind: "refused",
|
|
39
|
+
message: formatErrorMessage(error),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface CalendarCollectionWrites {
|
|
43
|
+
createCalendar: (
|
|
44
|
+
input: RemitImapCreateCalendarInput,
|
|
45
|
+
) => Promise<CalendarCollectionOutcome>;
|
|
46
|
+
updateCalendar: (
|
|
47
|
+
calendarId: string,
|
|
48
|
+
patch: RemitImapUpdateCalendarInput,
|
|
49
|
+
) => Promise<CalendarCollectionOutcome>;
|
|
50
|
+
deleteCalendar: (calendarId: string) => Promise<CalendarCollectionOutcome>;
|
|
51
|
+
isWriting: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useCalendarCollectionWrites(): CalendarCollectionWrites {
|
|
55
|
+
const queryClient = useQueryClient();
|
|
56
|
+
const invalidateReads = useInvalidateCalendarReads();
|
|
57
|
+
|
|
58
|
+
const invalidateListing = useCallback(
|
|
59
|
+
() =>
|
|
60
|
+
queryClient.invalidateQueries({
|
|
61
|
+
queryKey: calendarOperationsListCalendarsQueryKey(),
|
|
62
|
+
}),
|
|
63
|
+
[queryClient],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const invalidateAll = useCallback(() => {
|
|
67
|
+
invalidateReads();
|
|
68
|
+
return invalidateListing();
|
|
69
|
+
}, [invalidateReads, invalidateListing]);
|
|
70
|
+
|
|
71
|
+
const create = useMutation({
|
|
72
|
+
...calendarOperationsCreateCalendarMutation(),
|
|
73
|
+
meta: COLLECTION_WRITE_META,
|
|
74
|
+
onSuccess: invalidateListing,
|
|
75
|
+
});
|
|
76
|
+
const update = useMutation({
|
|
77
|
+
...calendarDetailOperationsUpdateCalendarMutation(),
|
|
78
|
+
meta: COLLECTION_WRITE_META,
|
|
79
|
+
onSuccess: invalidateAll,
|
|
80
|
+
});
|
|
81
|
+
const remove = useMutation({
|
|
82
|
+
...calendarDetailOperationsDeleteCalendarMutation(),
|
|
83
|
+
meta: COLLECTION_WRITE_META,
|
|
84
|
+
onSuccess: invalidateAll,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const createCalendar = useCallback(
|
|
88
|
+
(input: RemitImapCreateCalendarInput) =>
|
|
89
|
+
create
|
|
90
|
+
.mutateAsync({ body: input })
|
|
91
|
+
.then(() => WRITTEN)
|
|
92
|
+
.catch(refused),
|
|
93
|
+
[create],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const updateCalendar = useCallback(
|
|
97
|
+
(calendarId: string, patch: RemitImapUpdateCalendarInput) =>
|
|
98
|
+
update
|
|
99
|
+
.mutateAsync({ path: { calendarId }, body: patch })
|
|
100
|
+
.then(() => WRITTEN)
|
|
101
|
+
.catch(refused),
|
|
102
|
+
[update],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const deleteCalendar = useCallback(
|
|
106
|
+
(calendarId: string) =>
|
|
107
|
+
remove
|
|
108
|
+
.mutateAsync({ path: { calendarId } })
|
|
109
|
+
.then(() => WRITTEN)
|
|
110
|
+
.catch(refused),
|
|
111
|
+
[remove],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return useMemo(
|
|
115
|
+
() => ({
|
|
116
|
+
createCalendar,
|
|
117
|
+
updateCalendar,
|
|
118
|
+
deleteCalendar,
|
|
119
|
+
isWriting: create.isPending || update.isPending || remove.isPending,
|
|
120
|
+
}),
|
|
121
|
+
[
|
|
122
|
+
createCalendar,
|
|
123
|
+
updateCalendar,
|
|
124
|
+
deleteCalendar,
|
|
125
|
+
create.isPending,
|
|
126
|
+
update.isPending,
|
|
127
|
+
remove.isPending,
|
|
128
|
+
],
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -95,10 +95,12 @@ export function useInvalidateCalendarReads(): () => void {
|
|
|
95
95
|
}, [queryClient]);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The occurrence rides along under `All` too: an edit's times are the times of
|
|
100
|
+
* the occurrence it was made from, and the server moves the series by as much.
|
|
101
|
+
*/
|
|
98
102
|
const scopeQuery = ({ scope, recurrenceId }: ScopedWrite) =>
|
|
99
|
-
scope === undefined
|
|
100
|
-
? {}
|
|
101
|
-
: { scope: SCOPES[scope], recurrenceId };
|
|
103
|
+
scope === undefined ? {} : { scope: SCOPES[scope], recurrenceId };
|
|
102
104
|
|
|
103
105
|
export function useCalendarWrites(): CalendarWrites {
|
|
104
106
|
const invalidate = useInvalidateCalendarReads();
|