@remit/web-client 0.0.285 → 0.0.287
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 +17 -0
- package/src/components/calendar/WriteCalendarEvent.stories.tsx +1 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +1 -0
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +3 -1
- package/src/hooks/calendar/draft.test.ts +152 -5
- package/src/hooks/calendar/draft.ts +15 -5
- package/src/hooks/escalated-run-refusal.render.test.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.287",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -34,6 +34,7 @@ const draft: EventDraft = {
|
|
|
34
34
|
title: "Roadmap review",
|
|
35
35
|
date: "2026-06-10",
|
|
36
36
|
startTime: "10:00",
|
|
37
|
+
endDate: "2026-06-10",
|
|
37
38
|
endTime: "11:30",
|
|
38
39
|
allDay: false,
|
|
39
40
|
calendarId: "cal_work",
|
|
@@ -104,6 +105,22 @@ export const EditingOneOccurrence: Story = {
|
|
|
104
105
|
},
|
|
105
106
|
};
|
|
106
107
|
|
|
108
|
+
/** A night shift opens as one night, ending on the next day. */
|
|
109
|
+
export const EditingOvernight: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
title: "Edit event",
|
|
112
|
+
subtitle: "2026-06-10",
|
|
113
|
+
saveLabel: "Save",
|
|
114
|
+
draft: {
|
|
115
|
+
...draft,
|
|
116
|
+
title: "Night shift handover",
|
|
117
|
+
startTime: "22:00",
|
|
118
|
+
endDate: "2026-06-11",
|
|
119
|
+
endTime: "01:00",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
107
124
|
/** The version on screen was replaced elsewhere. Nothing is overwritten. */
|
|
108
125
|
export const Conflicted: Story = {
|
|
109
126
|
args: {
|
|
@@ -214,7 +214,9 @@ describe("the appointment ceremony", () => {
|
|
|
214
214
|
message: "not settled",
|
|
215
215
|
details: { mailboxId: "mbx-trash", syncStatus: "pending" },
|
|
216
216
|
}),
|
|
217
|
-
|
|
217
|
+
// 422: the folder being appointed has not settled, which is a
|
|
218
|
+
// different answer from a 409's "somebody else is changing it".
|
|
219
|
+
{ status: 422, headers: { "content-type": "application/json" } },
|
|
218
220
|
);
|
|
219
221
|
});
|
|
220
222
|
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import { describe, it } from "node:test";
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
type CalendarEventData,
|
|
14
|
+
type EventDraft,
|
|
15
|
+
withAllDay,
|
|
16
|
+
withStartDate,
|
|
17
|
+
} from "@remit/ui";
|
|
13
18
|
import {
|
|
14
19
|
type CreateInput,
|
|
15
20
|
createInputFromDraft,
|
|
21
|
+
draftFromEvent,
|
|
16
22
|
emptyDraft,
|
|
17
23
|
patchFromDrafts,
|
|
18
24
|
type UpdatePatch,
|
|
@@ -28,6 +34,30 @@ const draft = (over: Partial<EventDraft> = {}): EventDraft => ({
|
|
|
28
34
|
...over,
|
|
29
35
|
});
|
|
30
36
|
|
|
37
|
+
const STORED = { repeat: "", location: "", notes: "" };
|
|
38
|
+
|
|
39
|
+
const event = (over: Partial<CalendarEventData>): CalendarEventData => ({
|
|
40
|
+
id: "evt_1",
|
|
41
|
+
calendarId: CALENDAR,
|
|
42
|
+
title: "Roadmap review",
|
|
43
|
+
start: "2026-06-10T09:00:00+02:00",
|
|
44
|
+
end: "2026-06-10T10:00:00+02:00",
|
|
45
|
+
allDay: false,
|
|
46
|
+
location: "",
|
|
47
|
+
notes: "",
|
|
48
|
+
attendees: [],
|
|
49
|
+
myRsvp: "accepted",
|
|
50
|
+
threadId: "",
|
|
51
|
+
threadSubject: "",
|
|
52
|
+
timeZone: AMSTERDAM,
|
|
53
|
+
zoneCertainty: "explicit",
|
|
54
|
+
recurrenceRule: "",
|
|
55
|
+
seriesId: "",
|
|
56
|
+
seriesException: false,
|
|
57
|
+
status: "confirmed",
|
|
58
|
+
...over,
|
|
59
|
+
});
|
|
60
|
+
|
|
31
61
|
const refusal = (result: CreateInput | UpdatePatch): string => {
|
|
32
62
|
assert.ok(
|
|
33
63
|
!result.ok,
|
|
@@ -57,6 +87,31 @@ describe("creating an event", () => {
|
|
|
57
87
|
assert.equal(built.input.end, "2026-06-11");
|
|
58
88
|
});
|
|
59
89
|
|
|
90
|
+
it("ends a night that runs past midnight on the next day", () => {
|
|
91
|
+
const built = createInputFromDraft(
|
|
92
|
+
draft({ startTime: "22:00", endDate: "2026-06-11", endTime: "01:00" }),
|
|
93
|
+
AMSTERDAM,
|
|
94
|
+
);
|
|
95
|
+
assert.ok(built.ok);
|
|
96
|
+
assert.equal(built.input.start, "2026-06-10T22:00:00+02:00");
|
|
97
|
+
assert.equal(built.input.end, "2026-06-11T01:00:00+02:00");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ends an all-day event the day after the last day it covers", () => {
|
|
101
|
+
const built = createInputFromDraft(
|
|
102
|
+
draft({
|
|
103
|
+
allDay: true,
|
|
104
|
+
startTime: "",
|
|
105
|
+
endTime: "",
|
|
106
|
+
endDate: "2026-06-12",
|
|
107
|
+
}),
|
|
108
|
+
AMSTERDAM,
|
|
109
|
+
);
|
|
110
|
+
assert.ok(built.ok);
|
|
111
|
+
assert.equal(built.input.start, "2026-06-10");
|
|
112
|
+
assert.equal(built.input.end, "2026-06-13");
|
|
113
|
+
});
|
|
114
|
+
|
|
60
115
|
it("turns a picked repeat sentence into the rule it means", () => {
|
|
61
116
|
const built = createInputFromDraft(
|
|
62
117
|
draft({ repeat: "Every weekday, 09:00" }),
|
|
@@ -86,7 +141,24 @@ describe("a refusal", () => {
|
|
|
86
141
|
AMSTERDAM,
|
|
87
142
|
),
|
|
88
143
|
),
|
|
89
|
-
/end
|
|
144
|
+
/end is not after the start/i,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("names a last day before the first on an all-day event", () => {
|
|
149
|
+
assert.match(
|
|
150
|
+
refusal(
|
|
151
|
+
createInputFromDraft(
|
|
152
|
+
draft({
|
|
153
|
+
allDay: true,
|
|
154
|
+
startTime: "",
|
|
155
|
+
endTime: "",
|
|
156
|
+
endDate: "2026-06-09",
|
|
157
|
+
}),
|
|
158
|
+
AMSTERDAM,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
/last day is before the first/i,
|
|
90
162
|
);
|
|
91
163
|
});
|
|
92
164
|
|
|
@@ -153,6 +225,41 @@ describe("editing an event", () => {
|
|
|
153
225
|
assert.equal(patch.patch.recurrenceRule, "");
|
|
154
226
|
});
|
|
155
227
|
|
|
228
|
+
it("saves another field of a night that runs past midnight", () => {
|
|
229
|
+
const before = draftFromEvent(
|
|
230
|
+
event({
|
|
231
|
+
start: "2026-06-10T22:00:00+02:00",
|
|
232
|
+
end: "2026-06-11T01:00:00+02:00",
|
|
233
|
+
}),
|
|
234
|
+
STORED,
|
|
235
|
+
);
|
|
236
|
+
assert.equal(before.endDate, "2026-06-11");
|
|
237
|
+
const patch = patchFromDrafts(
|
|
238
|
+
before,
|
|
239
|
+
{ ...before, title: "Night shift" },
|
|
240
|
+
AMSTERDAM,
|
|
241
|
+
);
|
|
242
|
+
assert.ok(patch.ok);
|
|
243
|
+
assert.deepEqual(patch.patch, { summary: "Night shift" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps every day of an all-day event it opens", () => {
|
|
247
|
+
const before = draftFromEvent(
|
|
248
|
+
event({ allDay: true, start: "2026-06-10", end: "2026-06-13" }),
|
|
249
|
+
STORED,
|
|
250
|
+
);
|
|
251
|
+
assert.equal(before.date, "2026-06-10");
|
|
252
|
+
assert.equal(before.endDate, "2026-06-12");
|
|
253
|
+
const patch = patchFromDrafts(
|
|
254
|
+
before,
|
|
255
|
+
{ ...before, date: "2026-06-17", endDate: "2026-06-19" },
|
|
256
|
+
AMSTERDAM,
|
|
257
|
+
);
|
|
258
|
+
assert.ok(patch.ok);
|
|
259
|
+
assert.equal(patch.patch.start, "2026-06-17");
|
|
260
|
+
assert.equal(patch.patch.end, "2026-06-20");
|
|
261
|
+
});
|
|
262
|
+
|
|
156
263
|
it("changes nothing when nothing was touched", () => {
|
|
157
264
|
const before = draft();
|
|
158
265
|
const patch = patchFromDrafts(before, { ...before }, AMSTERDAM);
|
|
@@ -185,7 +292,7 @@ describe("an event anchored somewhere other than the device", () => {
|
|
|
185
292
|
const before = draft();
|
|
186
293
|
const patch = patchFromDrafts(
|
|
187
294
|
before,
|
|
188
|
-
{ ...before, date: "2026-06-17" },
|
|
295
|
+
{ ...before, date: "2026-06-17", endDate: "2026-06-17" },
|
|
189
296
|
AMSTERDAM,
|
|
190
297
|
);
|
|
191
298
|
assert.ok(patch.ok);
|
|
@@ -200,7 +307,7 @@ describe("an event anchored somewhere other than the device", () => {
|
|
|
200
307
|
|
|
201
308
|
it("reads the zone's own winter offset rather than one it saw in June", () => {
|
|
202
309
|
const built = createInputFromDraft(
|
|
203
|
-
draft({ date: "2026-01-14" }),
|
|
310
|
+
draft({ date: "2026-01-14", endDate: "2026-01-14" }),
|
|
204
311
|
AMSTERDAM,
|
|
205
312
|
);
|
|
206
313
|
assert.ok(built.ok);
|
|
@@ -231,7 +338,7 @@ describe("an event anchored somewhere other than the device", () => {
|
|
|
231
338
|
const before = draft();
|
|
232
339
|
const patch = patchFromDrafts(
|
|
233
340
|
before,
|
|
234
|
-
{ ...before, date: "2026-06-17" },
|
|
341
|
+
{ ...before, date: "2026-06-17", endDate: "2026-06-17" },
|
|
235
342
|
"",
|
|
236
343
|
);
|
|
237
344
|
assert.ok(patch.ok);
|
|
@@ -239,3 +346,43 @@ describe("an event anchored somewhere other than the device", () => {
|
|
|
239
346
|
assert.equal(patch.patch.start, "2026-06-17T09:00:00+00:00");
|
|
240
347
|
});
|
|
241
348
|
});
|
|
349
|
+
|
|
350
|
+
describe("the form moving its own dates", () => {
|
|
351
|
+
const weekend = draft({
|
|
352
|
+
allDay: true,
|
|
353
|
+
startTime: "",
|
|
354
|
+
endTime: "",
|
|
355
|
+
date: "2026-06-12",
|
|
356
|
+
endDate: "2026-06-14",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("carries the last day along when the first day moves", () => {
|
|
360
|
+
const moved = withStartDate(weekend, "2026-06-19");
|
|
361
|
+
assert.equal(moved.date, "2026-06-19");
|
|
362
|
+
assert.equal(moved.endDate, "2026-06-21");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("keeps the last day while the start date is cleared and retyped", () => {
|
|
366
|
+
const cleared = withStartDate(weekend, "");
|
|
367
|
+
assert.equal(cleared.endDate, "2026-06-14");
|
|
368
|
+
const retyped = withStartDate(cleared, "2026-06-12");
|
|
369
|
+
assert.equal(retyped.date, "2026-06-12");
|
|
370
|
+
assert.equal(retyped.endDate, "2026-06-14");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("makes a night that runs past midnight one all-day date", () => {
|
|
374
|
+
const night = draft({
|
|
375
|
+
startTime: "22:00",
|
|
376
|
+
endDate: "2026-06-11",
|
|
377
|
+
endTime: "01:00",
|
|
378
|
+
});
|
|
379
|
+
const allDay = withAllDay(night, true);
|
|
380
|
+
assert.equal(allDay.allDay, true);
|
|
381
|
+
assert.equal(allDay.endDate, "2026-06-10");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("keeps the days of a timed event that spans them when made all day", () => {
|
|
385
|
+
const span = draft({ endDate: "2026-06-12", endTime: "17:00" });
|
|
386
|
+
assert.equal(withAllDay(span, true).endDate, "2026-06-12");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
RemitImapCreateCalendarEventInput,
|
|
14
14
|
RemitImapUpdateCalendarEventInput,
|
|
15
15
|
} from "@remit/api-http-client/types.gen.ts";
|
|
16
|
-
import type
|
|
16
|
+
import { type CalendarEventData, type EventDraft, lastDayOf } from "@remit/ui";
|
|
17
17
|
import { rruleFromText } from "./recurrence-rule";
|
|
18
18
|
import { addDays, isoAtInZone } from "./window";
|
|
19
19
|
|
|
@@ -33,6 +33,7 @@ export function emptyDraft(date: string, calendarId: string): EventDraft {
|
|
|
33
33
|
title: "",
|
|
34
34
|
date,
|
|
35
35
|
startTime: "09:00",
|
|
36
|
+
endDate: date,
|
|
36
37
|
endTime: "10:00",
|
|
37
38
|
allDay: false,
|
|
38
39
|
calendarId,
|
|
@@ -56,6 +57,7 @@ export function draftFromEvent(
|
|
|
56
57
|
title: event.title,
|
|
57
58
|
date: event.start.slice(0, 10),
|
|
58
59
|
startTime: event.allDay ? "" : event.start.slice(11, 16),
|
|
60
|
+
endDate: lastDayOf(event),
|
|
59
61
|
endTime: event.allDay ? "" : event.end.slice(11, 16),
|
|
60
62
|
allDay: event.allDay,
|
|
61
63
|
calendarId: event.calendarId,
|
|
@@ -79,10 +81,11 @@ function timesFor(
|
|
|
79
81
|
draft: EventDraft,
|
|
80
82
|
timeZone: string,
|
|
81
83
|
): { start: string; end: string } {
|
|
82
|
-
if (draft.allDay)
|
|
84
|
+
if (draft.allDay)
|
|
85
|
+
return { start: draft.date, end: addDays(draft.endDate, 1) };
|
|
83
86
|
return {
|
|
84
87
|
start: isoAtInZone(draft.date, draft.startTime, timeZone),
|
|
85
|
-
end: isoAtInZone(draft.
|
|
88
|
+
end: isoAtInZone(draft.endDate, draft.endTime, timeZone),
|
|
86
89
|
};
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -95,11 +98,17 @@ function refuse(draft: EventDraft, checkRepeat: boolean): string {
|
|
|
95
98
|
if (draft.title.trim() === "")
|
|
96
99
|
return "Give the event a title before saving it.";
|
|
97
100
|
if (draft.date === "") return "Pick the day the event is on.";
|
|
101
|
+
if (draft.endDate === "") return "Pick the day the event ends on.";
|
|
98
102
|
if (draft.calendarId === "") return "Pick the calendar to save the event in.";
|
|
99
103
|
if (!draft.allDay && (draft.startTime === "" || draft.endTime === ""))
|
|
100
104
|
return "Set a start and an end time, or mark the event all day.";
|
|
101
|
-
if (
|
|
102
|
-
return "The
|
|
105
|
+
if (draft.allDay && draft.endDate < draft.date)
|
|
106
|
+
return "The last day is before the first. Move one of them.";
|
|
107
|
+
if (
|
|
108
|
+
!draft.allDay &&
|
|
109
|
+
`${draft.endDate}T${draft.endTime}` <= `${draft.date}T${draft.startTime}`
|
|
110
|
+
)
|
|
111
|
+
return "The end is not after the start. Move one of them.";
|
|
103
112
|
if (checkRepeat && rruleFromText(draft.repeat) === undefined)
|
|
104
113
|
return "This calendar can't store that repeat rule. Pick one of the offered rules, or turn the repeat off.";
|
|
105
114
|
return "";
|
|
@@ -149,6 +158,7 @@ export function patchFromDrafts(
|
|
|
149
158
|
const moved =
|
|
150
159
|
after.date !== before.date ||
|
|
151
160
|
after.startTime !== before.startTime ||
|
|
161
|
+
after.endDate !== before.endDate ||
|
|
152
162
|
after.endTime !== before.endTime ||
|
|
153
163
|
after.allDay !== before.allDay;
|
|
154
164
|
if (moved) {
|
|
@@ -165,11 +165,14 @@ describe("a bulk delete refused for its folder role", () => {
|
|
|
165
165
|
await mount((path) => {
|
|
166
166
|
if (path.endsWith("/config")) return CONFIG;
|
|
167
167
|
if (path.endsWith("/messages/delete"))
|
|
168
|
+
// A conflict with no code this prompt knows: the status class's own,
|
|
169
|
+
// which is what every refusal that names nothing more specific
|
|
170
|
+
// answers with.
|
|
168
171
|
return new Response(
|
|
169
172
|
JSON.stringify({
|
|
170
173
|
status: 409,
|
|
171
174
|
message: "boom",
|
|
172
|
-
code: "
|
|
175
|
+
code: "conflict",
|
|
173
176
|
}),
|
|
174
177
|
{ status: 409, headers: { "content-type": "application/json" } },
|
|
175
178
|
);
|