@remit/web-client 0.0.305 → 0.0.307
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/AgendaView.stories.tsx +2 -6
- package/src/components/calendar/CalendarEventPane.stories.tsx +23 -0
- package/src/components/calendar/CalendarEventPane.tsx +13 -1
- package/src/components/calendar/OpenCalendarEvent.stories.tsx +1 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +13 -2
- package/src/components/calendar/WriteCalendarEvent.tsx +2 -1
- package/src/components/calendar/calendar-app.stories.tsx +119 -0
- package/src/components/calendar/calendar-story-fixtures.ts +0 -20
- package/src/components/calendar/pending-suggestions.render.test.ts +5 -0
- package/src/components/mail/DraftsView.tsx +3 -7
- package/src/components/mail/OutboxPane.tsx +14 -22
- package/src/components/settings/CalendarSettingsPanel.tsx +22 -16
- package/src/components/settings/CalendarSubscribeCard.stories.tsx +89 -0
- package/src/components/settings/CalendarSubscribeCard.tsx +115 -0
- package/src/components/settings/CalendarSubscriptionStatus.stories.tsx +77 -0
- package/src/components/settings/CalendarSubscriptionStatus.tsx +88 -0
- package/src/hooks/calendar/instance.ts +7 -0
- package/src/hooks/calendar/useCalendarSubscriptions.ts +103 -0
- package/src/hooks/calendar/useCalendars.ts +4 -0
- package/src/hooks/intelligence-calendar.render.test.ts +5 -0
- package/src/lib/calendar-subscription.ts +11 -0
- package/src/mocks/story-frame/calendar-window.ts +21 -0
- package/src/mocks/story-frame/mail-handlers.ts +21 -3
- package/src/mocks/story-frame/mail-world.ts +9 -0
- package/src/routes/settings/calendars.tsx +49 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.307",
|
|
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": {
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import type { Density } from "@remit/ui";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { expect, within } from "storybook/test";
|
|
4
|
+
import { instancesWithin } from "@/mocks/story-frame/calendar-window";
|
|
4
5
|
import { AgendaView } from "./AgendaView";
|
|
5
|
-
import {
|
|
6
|
-
calendars,
|
|
7
|
-
fortnight,
|
|
8
|
-
instancesWithin,
|
|
9
|
-
STORY_DATE,
|
|
10
|
-
} from "./calendar-story-fixtures";
|
|
6
|
+
import { calendars, fortnight, STORY_DATE } from "./calendar-story-fixtures";
|
|
11
7
|
import {
|
|
12
8
|
type CalendarServer,
|
|
13
9
|
CalendarStory,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
3
4
|
import { CalendarEventPane } from "./CalendarEventPane";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -175,3 +176,25 @@ export const ChangedElsewhere: Story = {
|
|
|
175
176
|
export const NotWritableYet: Story = {
|
|
176
177
|
args: { onEdit: undefined, onDelete: undefined },
|
|
177
178
|
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* An event from a subscribed feed. The controls are gone because the feed
|
|
182
|
+
* owns the event, and the note says so where they would have been.
|
|
183
|
+
*/
|
|
184
|
+
export const FromSubscription: Story = {
|
|
185
|
+
args: {
|
|
186
|
+
onEdit: undefined,
|
|
187
|
+
onDelete: undefined,
|
|
188
|
+
readOnlyNote:
|
|
189
|
+
"Read-only: from calendar.google.com. Change it where the feed comes from.",
|
|
190
|
+
},
|
|
191
|
+
play: async ({ canvasElement }) => {
|
|
192
|
+
const canvas = within(canvasElement);
|
|
193
|
+
await expect(
|
|
194
|
+
canvas.getByText(
|
|
195
|
+
"Read-only: from calendar.google.com. Change it where the feed comes from.",
|
|
196
|
+
),
|
|
197
|
+
).toBeVisible();
|
|
198
|
+
await expect(canvas.queryByRole("button", { name: "Edit" })).toBeNull();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
|
|
2
2
|
import { EventDetail } from "@remit/ui";
|
|
3
|
-
import { AlertCircle, CalendarDays } from "lucide-react";
|
|
3
|
+
import { AlertCircle, CalendarDays, Lock } from "lucide-react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { formatEventWhen } from "@/lib/calendar-format";
|
|
6
6
|
|
|
@@ -76,6 +76,11 @@ export interface CalendarEventPaneProps {
|
|
|
76
76
|
* when the last one did.
|
|
77
77
|
*/
|
|
78
78
|
problem?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Why this event cannot be changed here, stated where Edit and Delete would
|
|
81
|
+
* be. Empty for an event the reader can change.
|
|
82
|
+
*/
|
|
83
|
+
readOnlyNote?: string;
|
|
79
84
|
onEdit?: () => void;
|
|
80
85
|
onDelete?: () => void;
|
|
81
86
|
onClose: () => void;
|
|
@@ -87,6 +92,7 @@ export function CalendarEventPane({
|
|
|
87
92
|
isOccurrence,
|
|
88
93
|
absence = NOT_IN_VIEW,
|
|
89
94
|
problem = "",
|
|
95
|
+
readOnlyNote = "",
|
|
90
96
|
onEdit,
|
|
91
97
|
onDelete,
|
|
92
98
|
onClose,
|
|
@@ -129,6 +135,12 @@ export function CalendarEventPane({
|
|
|
129
135
|
One occurrence of a repeating event
|
|
130
136
|
</p>
|
|
131
137
|
)}
|
|
138
|
+
{readOnlyNote !== "" && (
|
|
139
|
+
<p className="flex shrink-0 items-start gap-2 border-b border-line bg-surface-sunken px-row-inset py-2 text-sm text-fg-muted">
|
|
140
|
+
<Lock className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
|
141
|
+
<span className="min-w-0 flex-1 break-words">{readOnlyNote}</span>
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
132
144
|
{problem !== "" && (
|
|
133
145
|
<div
|
|
134
146
|
role="alert"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { instancesWithin } from "@/mocks/story-frame/calendar-window";
|
|
3
4
|
import { useOpenCalendarEvent } from "@/routing";
|
|
4
5
|
import {
|
|
5
6
|
calendars,
|
|
6
7
|
fortnight,
|
|
7
|
-
instancesWithin,
|
|
8
8
|
ROADMAP_OBJECT,
|
|
9
9
|
roadmapResource,
|
|
10
10
|
STANDUP_OBJECT,
|
|
@@ -38,6 +38,7 @@ import { useCalendarData } from "@/hooks/useCalendarData";
|
|
|
38
38
|
import { formatEventWhen } from "@/lib/calendar-format";
|
|
39
39
|
import { calendarEventReportHref } from "@/lib/calendar-report";
|
|
40
40
|
import type { CalendarSearch } from "@/lib/calendar-route";
|
|
41
|
+
import { subscriptionReadOnlyNote } from "@/lib/calendar-subscription";
|
|
41
42
|
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -113,7 +114,7 @@ export function OpenCalendarEvent({
|
|
|
113
114
|
}: OpenCalendarEventProps) {
|
|
114
115
|
const { view, date, calendarIds } = useCalendarAddress();
|
|
115
116
|
const { events, isLoading } = useCalendarData({ view, date, calendarIds });
|
|
116
|
-
const { calendars, timeZoneByCalendarId } = useCalendars();
|
|
117
|
+
const { calendars, collections, timeZoneByCalendarId } = useCalendars();
|
|
117
118
|
const { closeEvent } = useCalendarNavigation();
|
|
118
119
|
const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
|
|
119
120
|
|
|
@@ -178,7 +179,8 @@ export function OpenCalendarEvent({
|
|
|
178
179
|
recurrenceId !== "";
|
|
179
180
|
// Until the resource is read there is no etag, and a write with no etag is a
|
|
180
181
|
// write that can silently overwrite somebody.
|
|
181
|
-
const writable =
|
|
182
|
+
const writable =
|
|
183
|
+
event !== undefined && resource !== undefined && !calendar?.readOnly;
|
|
182
184
|
|
|
183
185
|
const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
|
|
184
186
|
if (outcome.kind === "written") {
|
|
@@ -340,6 +342,15 @@ export function OpenCalendarEvent({
|
|
|
340
342
|
}
|
|
341
343
|
}
|
|
342
344
|
calendar={calendar}
|
|
345
|
+
readOnlyNote={
|
|
346
|
+
calendar?.readOnly
|
|
347
|
+
? subscriptionReadOnlyNote(
|
|
348
|
+
collections.find(
|
|
349
|
+
(collection) => collection.calendarId === calendar.id,
|
|
350
|
+
)?.subscriptionUrl ?? "",
|
|
351
|
+
)
|
|
352
|
+
: ""
|
|
353
|
+
}
|
|
343
354
|
isOccurrence={recurrenceId !== undefined}
|
|
344
355
|
absence={absenceOf(search, seriesOnly && isLoading)}
|
|
345
356
|
problem={problem}
|
|
@@ -30,7 +30,8 @@ export interface WriteCalendarEventProps {
|
|
|
30
30
|
export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
|
|
31
31
|
const { date } = useCalendarAddress();
|
|
32
32
|
const seed = useCalendarComposeSeed();
|
|
33
|
-
const { calendars, timeZoneByCalendarId } = useCalendars();
|
|
33
|
+
const { calendars: held, timeZoneByCalendarId } = useCalendars();
|
|
34
|
+
const calendars = held.filter((calendar) => !calendar.readOnly);
|
|
34
35
|
const { createEvent, isWriting } = useCalendarWrites();
|
|
35
36
|
const [problem, setProblem] = useState("");
|
|
36
37
|
const [draft, setDraft] = useState<EventDraft>(() => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
6
|
+
import {
|
|
7
|
+
calendars,
|
|
8
|
+
fortnight,
|
|
9
|
+
ROADMAP_OBJECT,
|
|
10
|
+
roadmapResource,
|
|
11
|
+
STANDUP_OBJECT,
|
|
12
|
+
STANDUP_RECURRENCE,
|
|
13
|
+
STORY_DATE,
|
|
14
|
+
STORY_WEEK,
|
|
15
|
+
standupResource,
|
|
16
|
+
} from "./calendar-story-fixtures";
|
|
17
|
+
|
|
18
|
+
const world = mailWorld({
|
|
19
|
+
calendars,
|
|
20
|
+
calendarEvents: fortnight,
|
|
21
|
+
calendarResources: [roadmapResource, standupResource],
|
|
22
|
+
});
|
|
23
|
+
const desktop = { viewport: { value: "desktop", isRotated: false } };
|
|
24
|
+
const phone = { viewport: { value: "mobile", isRotated: false } };
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Playground/Shipped/Calendar/App",
|
|
28
|
+
component: AppStory,
|
|
29
|
+
args: { url: STORY_WEEK },
|
|
30
|
+
parameters: {
|
|
31
|
+
layout: "fullscreen",
|
|
32
|
+
msw: { handlers: mailHandlers(world) },
|
|
33
|
+
},
|
|
34
|
+
globals: desktop,
|
|
35
|
+
} satisfies Meta<typeof AppStory>;
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>;
|
|
39
|
+
|
|
40
|
+
const page = () => within(document.body);
|
|
41
|
+
|
|
42
|
+
export const Week: Story = {
|
|
43
|
+
play: async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
(await page().findAllByText("Roadmap review"))[0],
|
|
46
|
+
).toBeVisible();
|
|
47
|
+
await expect(
|
|
48
|
+
(await page().findAllByRole("link", { name: "Calendar" }))[0],
|
|
49
|
+
).toBeVisible();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Empty: Story = {
|
|
54
|
+
parameters: { msw: { handlers: mailHandlers(mailWorld()) } },
|
|
55
|
+
play: async () => {
|
|
56
|
+
await expect(
|
|
57
|
+
(await page().findAllByRole("link", { name: "Calendar" }))[0],
|
|
58
|
+
).toBeVisible();
|
|
59
|
+
await expect(page().queryByText("Roadmap review")).toBeNull();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const OpenEvent: Story = {
|
|
64
|
+
args: { url: `${STORY_WEEK}/${ROADMAP_OBJECT}` },
|
|
65
|
+
play: async () => {
|
|
66
|
+
await expect(await page().findByText("Room Zuid")).toBeVisible();
|
|
67
|
+
await expect(
|
|
68
|
+
await page().findByRole("button", { name: "Edit" }),
|
|
69
|
+
).toBeVisible();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const RecurrenceScope: Story = {
|
|
74
|
+
args: { url: `${STORY_WEEK}/${STANDUP_OBJECT}/${STANDUP_RECURRENCE}` },
|
|
75
|
+
play: async () => {
|
|
76
|
+
await userEvent.click(await page().findByRole("button", { name: "Edit" }));
|
|
77
|
+
await expect(
|
|
78
|
+
await page().findByText("What should the change apply to?"),
|
|
79
|
+
).toBeVisible();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const NewEvent: Story = {
|
|
84
|
+
args: { url: `${STORY_WEEK}/new` },
|
|
85
|
+
play: async () => {
|
|
86
|
+
await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
|
|
87
|
+
await expect(page().getByLabelText("Title")).toHaveValue("");
|
|
88
|
+
await expect(
|
|
89
|
+
await page().findByRole("radio", { name: "Northwind" }),
|
|
90
|
+
).toBeChecked();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const PhoneNewEvent: Story = {
|
|
95
|
+
args: { url: `/calendar/day/${STORY_DATE}/new` },
|
|
96
|
+
globals: phone,
|
|
97
|
+
play: async () => {
|
|
98
|
+
await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
|
|
99
|
+
await expect(page().getByLabelText("Title")).toHaveValue("");
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const PhoneDay: Story = {
|
|
104
|
+
args: { url: `/calendar/day/${STORY_DATE}` },
|
|
105
|
+
globals: phone,
|
|
106
|
+
play: async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
(await page().findAllByText("Roadmap review"))[0],
|
|
109
|
+
).toBeVisible();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const PhoneEvent: Story = {
|
|
114
|
+
args: { url: `/calendar/day/${STORY_DATE}/${ROADMAP_OBJECT}` },
|
|
115
|
+
globals: phone,
|
|
116
|
+
play: async () => {
|
|
117
|
+
await expect(await page().findByText("Room Zuid")).toBeVisible();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -116,23 +116,3 @@ export const standupResource = {
|
|
|
116
116
|
"END:VCALENDAR",
|
|
117
117
|
].join("\r\n"),
|
|
118
118
|
} as RemitImapCalendarEventResponse;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The occurrences inside the window a request asked for.
|
|
122
|
-
*
|
|
123
|
-
* The panes read a week at a time and hold several weeks at once, so a server
|
|
124
|
-
* that answered every window with the same list would draw each day's events
|
|
125
|
-
* once per window it appears in.
|
|
126
|
-
*/
|
|
127
|
-
export const instancesWithin = (
|
|
128
|
-
url: URL,
|
|
129
|
-
instances: readonly RemitImapCalendarEventInstance[],
|
|
130
|
-
): RemitImapCalendarEventInstance[] => {
|
|
131
|
-
const from = Date.parse(url.searchParams.get("from") ?? "");
|
|
132
|
-
const to = Date.parse(url.searchParams.get("to") ?? "");
|
|
133
|
-
if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
|
|
134
|
-
return instances.filter((instance) => {
|
|
135
|
-
const start = Date.parse(instance.start);
|
|
136
|
-
return start >= from && start < to;
|
|
137
|
-
});
|
|
138
|
-
};
|
|
@@ -35,6 +35,11 @@ const calendars = [
|
|
|
35
35
|
source: "Default",
|
|
36
36
|
timezone: "Europe/Amsterdam",
|
|
37
37
|
syncSequence: 1,
|
|
38
|
+
subscriptionUrl: "",
|
|
39
|
+
subscriptionEnabled: false,
|
|
40
|
+
subscriptionCheckedAt: 0,
|
|
41
|
+
subscriptionFetchedAt: 0,
|
|
42
|
+
subscriptionError: "",
|
|
38
43
|
createdAt: 0,
|
|
39
44
|
updatedAt: 0,
|
|
40
45
|
},
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
ComfortableRowTextContent,
|
|
33
33
|
cn,
|
|
34
34
|
comfortableRowClass,
|
|
35
|
+
PaneHeader,
|
|
35
36
|
type ThreadRowData,
|
|
36
37
|
type ThreadSection,
|
|
37
38
|
} from "@remit/ui";
|
|
@@ -194,18 +195,13 @@ export function DraftsView({
|
|
|
194
195
|
|
|
195
196
|
return (
|
|
196
197
|
<section className="flex h-full w-full flex-col bg-surface">
|
|
197
|
-
{
|
|
198
|
-
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
199
|
-
<NavMenuButton />
|
|
200
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
201
|
-
{title}
|
|
202
|
-
</h1>
|
|
198
|
+
<PaneHeader title={title} leading={<NavMenuButton />}>
|
|
203
199
|
{(unreadCount ?? 0) > 0 && (
|
|
204
200
|
<span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
|
|
205
201
|
{unreadCount} unread
|
|
206
202
|
</span>
|
|
207
203
|
)}
|
|
208
|
-
</
|
|
204
|
+
</PaneHeader>
|
|
209
205
|
|
|
210
206
|
{/* Scrollable body */}
|
|
211
207
|
<div className="flex-1 overflow-y-auto">
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
cn,
|
|
27
27
|
LIST_ROW_SELECTOR,
|
|
28
28
|
OutboxRow,
|
|
29
|
+
PaneHeader,
|
|
29
30
|
ReadingPaneEmpty,
|
|
30
31
|
useRovingFocus,
|
|
31
32
|
} from "@remit/ui";
|
|
@@ -458,18 +459,7 @@ function OutboxList() {
|
|
|
458
459
|
|
|
459
460
|
return (
|
|
460
461
|
<div className="h-full flex flex-col bg-canvas">
|
|
461
|
-
{
|
|
462
|
-
the bottom hairline on the same y as the mailbox view so the grid
|
|
463
|
-
line stays continuous across nav → outbox (no staircase, #422). */}
|
|
464
|
-
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
465
|
-
<NavMenuButton />
|
|
466
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
467
|
-
Outbox
|
|
468
|
-
</h1>
|
|
469
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
470
|
-
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
471
|
-
</span>
|
|
472
|
-
</header>
|
|
462
|
+
<OutboxListHeader count={messages.length} />
|
|
473
463
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
474
464
|
{messages.map((message) => (
|
|
475
465
|
<OutboxMessageRow
|
|
@@ -484,6 +474,16 @@ function OutboxList() {
|
|
|
484
474
|
);
|
|
485
475
|
}
|
|
486
476
|
|
|
477
|
+
function OutboxListHeader({ count }: { count: number }) {
|
|
478
|
+
return (
|
|
479
|
+
<PaneHeader title="Outbox" leading={<NavMenuButton />}>
|
|
480
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
481
|
+
{count} {count === 1 ? "message" : "messages"}
|
|
482
|
+
</span>
|
|
483
|
+
</PaneHeader>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
487
|
/**
|
|
488
488
|
* Outbox reading pane. Mount in the `reading` slot of `AppShellSlotted`.
|
|
489
489
|
* Only rendered ≥ 1024px.
|
|
@@ -496,7 +496,7 @@ function OutboxReading() {
|
|
|
496
496
|
{/* Detail datum bar (40px): outbox has no message-action toolbar,
|
|
497
497
|
but the datum bar must be present so its bottom hairline lines
|
|
498
498
|
up with the list pane's, keeping the grid continuous (#422). */}
|
|
499
|
-
<
|
|
499
|
+
<PaneHeader />
|
|
500
500
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
501
501
|
{selectedMessage ? (
|
|
502
502
|
<OutboxMessageDetail message={selectedMessage} />
|
|
@@ -556,15 +556,7 @@ function OutboxPhone() {
|
|
|
556
556
|
|
|
557
557
|
return (
|
|
558
558
|
<div className="h-full flex flex-col bg-canvas">
|
|
559
|
-
<
|
|
560
|
-
<NavMenuButton />
|
|
561
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
562
|
-
Outbox
|
|
563
|
-
</h1>
|
|
564
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
565
|
-
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
566
|
-
</span>
|
|
567
|
-
</header>
|
|
559
|
+
<OutboxListHeader count={messages.length} />
|
|
568
560
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
569
561
|
{messages.map((message) => (
|
|
570
562
|
<OutboxMessageRow
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* One calendar on the settings page: its name and zone, deleting it, and its
|
|
3
3
|
* subscription address, wired to the server.
|
|
4
4
|
*/
|
|
5
|
-
import { useState } from "react";
|
|
5
|
+
import { type ReactNode, useState } from "react";
|
|
6
6
|
import { CalendarDetailsForm } from "@/components/settings/CalendarDetailsForm";
|
|
7
7
|
import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
|
|
8
8
|
import {
|
|
@@ -14,12 +14,15 @@ export interface CalendarSettingsPanelProps {
|
|
|
14
14
|
calendarId: string;
|
|
15
15
|
calendarName: string;
|
|
16
16
|
timezone: string;
|
|
17
|
+
/** Where a subscribed calendar comes from, drawn above its details. */
|
|
18
|
+
subscription?: ReactNode;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function CalendarSettingsPanel({
|
|
20
22
|
calendarId,
|
|
21
23
|
calendarName,
|
|
22
24
|
timezone,
|
|
25
|
+
subscription,
|
|
23
26
|
}: CalendarSettingsPanelProps) {
|
|
24
27
|
const { updateCalendar, deleteCalendar, isWriting } =
|
|
25
28
|
useCalendarCollectionWrites();
|
|
@@ -33,21 +36,24 @@ export function CalendarSettingsPanel({
|
|
|
33
36
|
calendarId={calendarId}
|
|
34
37
|
calendarName={calendarName}
|
|
35
38
|
details={
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
<>
|
|
40
|
+
{subscription}
|
|
41
|
+
<CalendarDetailsForm
|
|
42
|
+
key={`${calendarName}\u0000${timezone}`}
|
|
43
|
+
calendarName={calendarName}
|
|
44
|
+
timezone={timezone}
|
|
45
|
+
isBusy={isWriting}
|
|
46
|
+
problem={problem}
|
|
47
|
+
onSave={(details) => {
|
|
48
|
+
setProblem("");
|
|
49
|
+
void updateCalendar(calendarId, details).then(settle);
|
|
50
|
+
}}
|
|
51
|
+
onDelete={() => {
|
|
52
|
+
setProblem("");
|
|
53
|
+
void deleteCalendar(calendarId).then(settle);
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</>
|
|
51
57
|
}
|
|
52
58
|
/>
|
|
53
59
|
);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, fn, userEvent, within } from "storybook/test";
|
|
3
|
+
import { CalendarSubscribeCard } from "./CalendarSubscribeCard";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Settings › Calendars: subscribing to another calendar's read-only iCal
|
|
7
|
+
* address (#1261). The server reads the feed before creating anything, so a
|
|
8
|
+
* refusal means nothing was added.
|
|
9
|
+
*/
|
|
10
|
+
const meta: Meta<typeof CalendarSubscribeCard> = {
|
|
11
|
+
title: "Playground/Shipped/Settings/Calendars/Subscribe to a calendar",
|
|
12
|
+
component: CalendarSubscribeCard,
|
|
13
|
+
parameters: { layout: "padded" },
|
|
14
|
+
args: {
|
|
15
|
+
isBusy: false,
|
|
16
|
+
error: undefined,
|
|
17
|
+
onSubscribe: fn(),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof CalendarSubscribeCard>;
|
|
23
|
+
|
|
24
|
+
/** Empty: the button waits for a name and an address. */
|
|
25
|
+
export const Empty: Story = {
|
|
26
|
+
play: async ({ canvasElement }) => {
|
|
27
|
+
const canvas = within(canvasElement);
|
|
28
|
+
await expect(
|
|
29
|
+
canvas.getByRole("button", { name: "Subscribe" }),
|
|
30
|
+
).toBeDisabled();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Filled in and sent. */
|
|
35
|
+
export const Filled: Story = {
|
|
36
|
+
play: async ({ canvasElement, args }) => {
|
|
37
|
+
const canvas = within(canvasElement);
|
|
38
|
+
await userEvent.type(
|
|
39
|
+
canvas.getByLabelText("Calendar name"),
|
|
40
|
+
"Harbour rota",
|
|
41
|
+
);
|
|
42
|
+
await userEvent.type(
|
|
43
|
+
canvas.getByLabelText("Calendar address"),
|
|
44
|
+
"webcal://calendar.example/harbour/basic.ics",
|
|
45
|
+
);
|
|
46
|
+
await userEvent.click(canvas.getByRole("button", { name: "Subscribe" }));
|
|
47
|
+
await expect(args.onSubscribe).toHaveBeenCalledWith(
|
|
48
|
+
{
|
|
49
|
+
displayName: "Harbour rota",
|
|
50
|
+
url: "webcal://calendar.example/harbour/basic.ics",
|
|
51
|
+
},
|
|
52
|
+
expect.any(Function),
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The feed is being read. */
|
|
58
|
+
export const Subscribing: Story = {
|
|
59
|
+
args: { isBusy: true },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The feed could not be read, so no calendar was made. */
|
|
63
|
+
export const Refused: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
error: {
|
|
66
|
+
code: "subscription_unreachable",
|
|
67
|
+
message: "the calendar was not created: the feed answered HTTP 404",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
play: async ({ canvasElement }) => {
|
|
71
|
+
const canvas = within(canvasElement);
|
|
72
|
+
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
|
73
|
+
"The calendar was not added.",
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Refused, on the dark theme. */
|
|
79
|
+
export const RefusedDark: Story = {
|
|
80
|
+
name: "Refused (dark)",
|
|
81
|
+
parameters: { theme: "dark" },
|
|
82
|
+
args: {
|
|
83
|
+
error: {
|
|
84
|
+
code: "subscription_unreachable",
|
|
85
|
+
message:
|
|
86
|
+
"the calendar was not created: the feed could not be reached (ENOTFOUND)",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Calendars: subscribe a new calendar to a read-only iCalendar
|
|
3
|
+
* address (issue #1261).
|
|
4
|
+
*
|
|
5
|
+
* Presentational. The server reads the feed before it creates anything, so a
|
|
6
|
+
* refusal here means nothing was added, and the banner says so in those words.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
Banner,
|
|
10
|
+
Button,
|
|
11
|
+
Card,
|
|
12
|
+
CardBody,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
Input,
|
|
16
|
+
} from "@remit/ui";
|
|
17
|
+
import { CalendarPlus } from "lucide-react";
|
|
18
|
+
import { type FormEvent, useId, useState } from "react";
|
|
19
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
20
|
+
|
|
21
|
+
export interface CalendarSubscribeCardProps {
|
|
22
|
+
isBusy: boolean;
|
|
23
|
+
/** The subscribe the server turned down. */
|
|
24
|
+
error: unknown;
|
|
25
|
+
onSubscribe: (
|
|
26
|
+
request: { displayName: string; url: string },
|
|
27
|
+
onDone: () => void,
|
|
28
|
+
) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SUBSCRIBE_TITLE = "Subscribe to a calendar";
|
|
32
|
+
|
|
33
|
+
export function CalendarSubscribeCard({
|
|
34
|
+
isBusy,
|
|
35
|
+
error,
|
|
36
|
+
onSubscribe,
|
|
37
|
+
}: CalendarSubscribeCardProps) {
|
|
38
|
+
const titleId = useId();
|
|
39
|
+
const nameId = useId();
|
|
40
|
+
const urlId = useId();
|
|
41
|
+
const [displayName, setDisplayName] = useState("");
|
|
42
|
+
const [url, setUrl] = useState("");
|
|
43
|
+
const ready = displayName.trim() !== "" && url.trim() !== "";
|
|
44
|
+
|
|
45
|
+
const submit = (event: FormEvent) => {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
if (!ready) return;
|
|
48
|
+
onSubscribe({ displayName, url }, () => {
|
|
49
|
+
setDisplayName("");
|
|
50
|
+
setUrl("");
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Card className="max-w-xl" role="region" aria-labelledby={titleId}>
|
|
56
|
+
<CardHeader>
|
|
57
|
+
<CardTitle id={titleId}>{SUBSCRIBE_TITLE}</CardTitle>
|
|
58
|
+
</CardHeader>
|
|
59
|
+
<CardBody>
|
|
60
|
+
<form className="space-y-3" onSubmit={submit}>
|
|
61
|
+
<p className="text-sm text-fg-muted">
|
|
62
|
+
Paste the secret iCal address from Google Calendar, a published
|
|
63
|
+
Outlook calendar or any other feed. It shows here read-only and
|
|
64
|
+
refreshes on its own.
|
|
65
|
+
</p>
|
|
66
|
+
<div className="space-y-1">
|
|
67
|
+
<label htmlFor={nameId} className="text-sm font-medium text-fg">
|
|
68
|
+
Calendar name
|
|
69
|
+
</label>
|
|
70
|
+
<Input
|
|
71
|
+
id={nameId}
|
|
72
|
+
value={displayName}
|
|
73
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
74
|
+
placeholder="Team rota"
|
|
75
|
+
autoComplete="off"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="space-y-1">
|
|
79
|
+
<label htmlFor={urlId} className="text-sm font-medium text-fg">
|
|
80
|
+
Calendar address
|
|
81
|
+
</label>
|
|
82
|
+
<Input
|
|
83
|
+
id={urlId}
|
|
84
|
+
value={url}
|
|
85
|
+
onChange={(event) => setUrl(event.target.value)}
|
|
86
|
+
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
|
|
87
|
+
autoComplete="off"
|
|
88
|
+
spellCheck={false}
|
|
89
|
+
inputMode="url"
|
|
90
|
+
/>
|
|
91
|
+
<p className="text-xs text-fg-muted">
|
|
92
|
+
For Google Calendar the address is the password to the calendar.
|
|
93
|
+
Reader never shows it in full again.
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
<Button
|
|
97
|
+
type="submit"
|
|
98
|
+
variant="primary"
|
|
99
|
+
size="sm"
|
|
100
|
+
disabled={!ready || isBusy}
|
|
101
|
+
icon={<CalendarPlus className="size-3.5" />}
|
|
102
|
+
>
|
|
103
|
+
{isBusy ? "Subscribing…" : "Subscribe"}
|
|
104
|
+
</Button>
|
|
105
|
+
{error !== undefined && error !== null && (
|
|
106
|
+
<Banner tone="danger" variant="soft">
|
|
107
|
+
<p className="font-medium">The calendar was not added.</p>
|
|
108
|
+
<p className="mt-0.5 break-words">{formatErrorMessage(error)}</p>
|
|
109
|
+
</Banner>
|
|
110
|
+
)}
|
|
111
|
+
</form>
|
|
112
|
+
</CardBody>
|
|
113
|
+
</Card>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Card, CardBody } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, fn, within } from "storybook/test";
|
|
4
|
+
import { CalendarSubscriptionStatus } from "./CalendarSubscriptionStatus";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Settings › Calendars: a calendar subscribed to a feed (#1261). The address is
|
|
8
|
+
* never drawn in full, since for Google it is the whole credential; its host
|
|
9
|
+
* is enough to recognise it.
|
|
10
|
+
*/
|
|
11
|
+
const meta: Meta<typeof CalendarSubscriptionStatus> = {
|
|
12
|
+
title: "Playground/Shipped/Settings/Calendars/Subscribed calendar",
|
|
13
|
+
component: CalendarSubscriptionStatus,
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
decorators: [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<Card className="max-w-xl">
|
|
18
|
+
<CardBody>
|
|
19
|
+
<Story />
|
|
20
|
+
</CardBody>
|
|
21
|
+
</Card>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
args: {
|
|
25
|
+
calendarName: "Harbour rota",
|
|
26
|
+
subscriptionUrl:
|
|
27
|
+
"https://calendar.google.com/calendar/ical/harbour%40example.com/private-5f1c0ffee/basic.ics",
|
|
28
|
+
enabled: true,
|
|
29
|
+
fetchedAt: Date.parse("2026-09-25T08:15:00Z"),
|
|
30
|
+
error: "",
|
|
31
|
+
isBusy: false,
|
|
32
|
+
actionError: undefined,
|
|
33
|
+
onSetEnabled: fn(),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof CalendarSubscriptionStatus>;
|
|
39
|
+
|
|
40
|
+
/** Refreshing on schedule. Only the host is named. */
|
|
41
|
+
export const Live: Story = {
|
|
42
|
+
play: async ({ canvasElement }) => {
|
|
43
|
+
const canvas = within(canvasElement);
|
|
44
|
+
await expect(
|
|
45
|
+
canvas.getByText(/Read-only, from calendar\.google\.com/),
|
|
46
|
+
).toBeVisible();
|
|
47
|
+
await expect(canvasElement.textContent).not.toContain("private-5f1c0ffee");
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The last refresh failed. The events already stored stay. */
|
|
52
|
+
export const Failing: Story = {
|
|
53
|
+
args: { error: "the feed answered HTTP 404" },
|
|
54
|
+
play: async ({ canvasElement }) => {
|
|
55
|
+
const canvas = within(canvasElement);
|
|
56
|
+
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
|
57
|
+
"the feed answered HTTP 404",
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Updates paused. Nothing was removed. */
|
|
63
|
+
export const Paused: Story = {
|
|
64
|
+
args: { enabled: false },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Paused, on the dark theme. */
|
|
68
|
+
export const PausedDark: Story = {
|
|
69
|
+
name: "Paused (dark)",
|
|
70
|
+
parameters: { theme: "dark" },
|
|
71
|
+
args: { enabled: false, error: "the feed answered HTTP 410" },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Never fetched successfully yet. */
|
|
75
|
+
export const NeverFetched: Story = {
|
|
76
|
+
args: { fetchedAt: 0 },
|
|
77
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Calendars: where a subscribed calendar comes from, when it was
|
|
3
|
+
* last read, why it was not, and the switch that stops its refresh
|
|
4
|
+
* (issue #1261).
|
|
5
|
+
*
|
|
6
|
+
* The address itself is not drawn. For a Google feed it is the whole
|
|
7
|
+
* credential, so only its host is named.
|
|
8
|
+
*/
|
|
9
|
+
import { Badge, Banner, Button } from "@remit/ui";
|
|
10
|
+
import { Pause, Play } from "lucide-react";
|
|
11
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
12
|
+
import { subscriptionHostOf } from "@/lib/calendar-subscription";
|
|
13
|
+
import { formatDatePreset } from "@/lib/format";
|
|
14
|
+
|
|
15
|
+
export interface CalendarSubscriptionStatusProps {
|
|
16
|
+
calendarName: string;
|
|
17
|
+
subscriptionUrl: string;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
fetchedAt: number;
|
|
20
|
+
error: string;
|
|
21
|
+
isBusy: boolean;
|
|
22
|
+
/** A pause or resume the server turned down. */
|
|
23
|
+
actionError: unknown;
|
|
24
|
+
onSetEnabled: (enabled: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CalendarSubscriptionStatus({
|
|
28
|
+
calendarName,
|
|
29
|
+
subscriptionUrl,
|
|
30
|
+
enabled,
|
|
31
|
+
fetchedAt,
|
|
32
|
+
error,
|
|
33
|
+
isBusy,
|
|
34
|
+
actionError,
|
|
35
|
+
onSetEnabled,
|
|
36
|
+
}: CalendarSubscriptionStatusProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="mb-4 space-y-2">
|
|
39
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
40
|
+
<Badge tone={enabled ? "positive" : "neutral"}>
|
|
41
|
+
{enabled ? "subscribed" : "paused"}
|
|
42
|
+
</Badge>
|
|
43
|
+
<span className="text-xs text-fg-muted">
|
|
44
|
+
Read-only, from {subscriptionHostOf(subscriptionUrl)}
|
|
45
|
+
{fetchedAt > 0
|
|
46
|
+
? `. Last updated ${formatDatePreset(fetchedAt, "medium")}`
|
|
47
|
+
: ""}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
{error !== "" && (
|
|
51
|
+
<Banner tone="warning" variant="soft">
|
|
52
|
+
<p className="font-medium">{calendarName} was not refreshed.</p>
|
|
53
|
+
<p className="mt-0.5 break-words">{error}</p>
|
|
54
|
+
</Banner>
|
|
55
|
+
)}
|
|
56
|
+
<p className="text-sm text-fg-muted">
|
|
57
|
+
{enabled
|
|
58
|
+
? "Events are read from the feed on a schedule and cannot be edited here."
|
|
59
|
+
: "Updates are paused. The events already here stay until you resume or remove the calendar."}
|
|
60
|
+
</p>
|
|
61
|
+
<Button
|
|
62
|
+
variant="secondary"
|
|
63
|
+
size="sm"
|
|
64
|
+
disabled={isBusy}
|
|
65
|
+
icon={
|
|
66
|
+
enabled ? (
|
|
67
|
+
<Pause className="size-3.5" />
|
|
68
|
+
) : (
|
|
69
|
+
<Play className="size-3.5" />
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
onClick={() => onSetEnabled(!enabled)}
|
|
73
|
+
>
|
|
74
|
+
{enabled ? "Pause updates" : "Resume updates"}
|
|
75
|
+
</Button>
|
|
76
|
+
{actionError !== undefined && actionError !== null && (
|
|
77
|
+
<Banner tone="danger" variant="soft">
|
|
78
|
+
<p className="font-medium">
|
|
79
|
+
Updates for {calendarName} were not changed.
|
|
80
|
+
</p>
|
|
81
|
+
<p className="mt-0.5 break-words">
|
|
82
|
+
{formatErrorMessage(actionError)}
|
|
83
|
+
</p>
|
|
84
|
+
</Banner>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -163,6 +163,13 @@ export function toCalendarDescriptor(
|
|
|
163
163
|
accountLabel: "",
|
|
164
164
|
name: calendar.displayName,
|
|
165
165
|
color: COLORS[calendar.color] ?? "cal-1",
|
|
166
|
+
...(calendar.source === "Subscribed"
|
|
167
|
+
? {
|
|
168
|
+
readOnly: true,
|
|
169
|
+
sync: calendar.subscriptionEnabled ? "live" : "paused",
|
|
170
|
+
syncError: calendar.subscriptionError,
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
166
173
|
};
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribing a new calendar to a read-only iCalendar URL, and turning an
|
|
3
|
+
* existing subscription's refresh off and on (issue #1261).
|
|
4
|
+
*
|
|
5
|
+
* The server fetches the feed before it creates anything, so a refused
|
|
6
|
+
* subscribe leaves no calendar behind and its reason is what the form states.
|
|
7
|
+
* A 401 is not among the statuses owned here: a lapsed session escalates the
|
|
8
|
+
* way it does everywhere else.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
calendarDetailOperationsUpdateCalendarMutation,
|
|
12
|
+
calendarOperationsCreateCalendarMutation,
|
|
13
|
+
calendarOperationsListCalendarsQueryKey,
|
|
14
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
16
|
+
import { useCallback } from "react";
|
|
17
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
18
|
+
import { useInvalidateCalendarReads } from "./useCalendarWrites";
|
|
19
|
+
|
|
20
|
+
const SUBSCRIPTION_WRITE_META = softErrorStatuses(400, 404);
|
|
21
|
+
|
|
22
|
+
export interface CalendarSubscribeRequest {
|
|
23
|
+
displayName: string;
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CalendarSubscriptionControls {
|
|
28
|
+
subscribe: (request: CalendarSubscribeRequest, onDone: () => void) => void;
|
|
29
|
+
isSubscribing: boolean;
|
|
30
|
+
subscribeError: unknown;
|
|
31
|
+
setEnabled: (calendarId: string, enabled: boolean) => void;
|
|
32
|
+
togglingCalendarId: string;
|
|
33
|
+
/** The last pause or resume the server turned down, and whose it was. */
|
|
34
|
+
toggleFailure: { calendarId: string; error: unknown };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const urlSegmentFor = (): string =>
|
|
38
|
+
`subscribed-${crypto.randomUUID().slice(0, 8)}`;
|
|
39
|
+
|
|
40
|
+
export function useCalendarSubscriptions(): CalendarSubscriptionControls {
|
|
41
|
+
const queryClient = useQueryClient();
|
|
42
|
+
const invalidateReads = useInvalidateCalendarReads();
|
|
43
|
+
const invalidate = () => {
|
|
44
|
+
void queryClient.invalidateQueries({
|
|
45
|
+
queryKey: calendarOperationsListCalendarsQueryKey(),
|
|
46
|
+
});
|
|
47
|
+
invalidateReads();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const create = useMutation({
|
|
51
|
+
...calendarOperationsCreateCalendarMutation(),
|
|
52
|
+
meta: SUBSCRIPTION_WRITE_META,
|
|
53
|
+
onSuccess: invalidate,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const toggle = useMutation({
|
|
57
|
+
...calendarDetailOperationsUpdateCalendarMutation(),
|
|
58
|
+
meta: SUBSCRIPTION_WRITE_META,
|
|
59
|
+
onSuccess: invalidate,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { mutate: createCalendar } = create;
|
|
63
|
+
const subscribe = useCallback(
|
|
64
|
+
(request: CalendarSubscribeRequest, onDone: () => void) => {
|
|
65
|
+
createCalendar(
|
|
66
|
+
{
|
|
67
|
+
body: {
|
|
68
|
+
urlSegment: urlSegmentFor(),
|
|
69
|
+
displayName: request.displayName.trim(),
|
|
70
|
+
subscriptionUrl: request.url.trim(),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{ onSuccess: onDone },
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
[createCalendar],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const { mutate: updateCalendar } = toggle;
|
|
80
|
+
const setEnabled = useCallback(
|
|
81
|
+
(calendarId: string, enabled: boolean) => {
|
|
82
|
+
updateCalendar({
|
|
83
|
+
path: { calendarId },
|
|
84
|
+
body: { subscriptionEnabled: enabled },
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
[updateCalendar],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
subscribe,
|
|
92
|
+
isSubscribing: create.isPending,
|
|
93
|
+
subscribeError: create.error,
|
|
94
|
+
setEnabled,
|
|
95
|
+
togglingCalendarId: toggle.isPending
|
|
96
|
+
? (toggle.variables?.path.calendarId ?? "")
|
|
97
|
+
: "",
|
|
98
|
+
toggleFailure: {
|
|
99
|
+
calendarId: toggle.variables?.path.calendarId ?? "",
|
|
100
|
+
error: toggle.error,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* indistinguishable from a week with nothing in it.
|
|
9
9
|
*/
|
|
10
10
|
import { calendarOperationsListCalendarsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
+
import type { RemitImapCalendarResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
12
|
import type { CalendarColorId, CalendarDescriptor } from "@remit/ui";
|
|
12
13
|
import { useQuery } from "@tanstack/react-query";
|
|
13
14
|
import { useMemo } from "react";
|
|
@@ -15,6 +16,8 @@ import { toCalendarDescriptor } from "./instance";
|
|
|
15
16
|
|
|
16
17
|
export interface CalendarsResult {
|
|
17
18
|
calendars: CalendarDescriptor[];
|
|
19
|
+
/** The collections as the API returned them, for a surface that manages them. */
|
|
20
|
+
collections: RemitImapCalendarResponse[];
|
|
18
21
|
colorByCalendarId: Record<string, CalendarColorId>;
|
|
19
22
|
/** The zone each collection's floating times are read in. */
|
|
20
23
|
timeZoneByCalendarId: Record<string, string>;
|
|
@@ -39,6 +42,7 @@ export function useCalendars(): CalendarsResult {
|
|
|
39
42
|
const calendars = items.map(toCalendarDescriptor);
|
|
40
43
|
return {
|
|
41
44
|
calendars,
|
|
45
|
+
collections: items,
|
|
42
46
|
colorByCalendarId: Object.fromEntries(
|
|
43
47
|
calendars.map((calendar) => [calendar.id, calendar.color]),
|
|
44
48
|
),
|
|
@@ -57,6 +57,11 @@ const calendars = [
|
|
|
57
57
|
source: "Default",
|
|
58
58
|
timezone: "Europe/Amsterdam",
|
|
59
59
|
syncSequence: 1,
|
|
60
|
+
subscriptionUrl: "",
|
|
61
|
+
subscriptionEnabled: false,
|
|
62
|
+
subscriptionCheckedAt: 0,
|
|
63
|
+
subscriptionFetchedAt: 0,
|
|
64
|
+
subscriptionError: "",
|
|
60
65
|
createdAt: 0,
|
|
61
66
|
updatedAt: 0,
|
|
62
67
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a subscribed calendar's events come from, named by host alone. For a
|
|
3
|
+
* Google feed the full address is the credential, so no surface draws it.
|
|
4
|
+
*/
|
|
5
|
+
export const subscriptionHostOf = (subscriptionUrl: string): string =>
|
|
6
|
+
URL.canParse(subscriptionUrl)
|
|
7
|
+
? new URL(subscriptionUrl).host
|
|
8
|
+
: "an iCalendar feed";
|
|
9
|
+
|
|
10
|
+
export const subscriptionReadOnlyNote = (subscriptionUrl: string): string =>
|
|
11
|
+
`Read-only: from ${subscriptionHostOf(subscriptionUrl)}. Change it where the feed comes from.`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The occurrences inside the window a request asked for.
|
|
5
|
+
*
|
|
6
|
+
* The panes read a week at a time and hold several weeks at once, so a server
|
|
7
|
+
* that answered every window with the same list would draw each day's events
|
|
8
|
+
* once per window it appears in.
|
|
9
|
+
*/
|
|
10
|
+
export const instancesWithin = (
|
|
11
|
+
url: URL,
|
|
12
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
13
|
+
): RemitImapCalendarEventInstance[] => {
|
|
14
|
+
const from = Date.parse(url.searchParams.get("from") ?? "");
|
|
15
|
+
const to = Date.parse(url.searchParams.get("to") ?? "");
|
|
16
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
|
|
17
|
+
return instances.filter((instance) => {
|
|
18
|
+
const start = Date.parse(instance.start);
|
|
19
|
+
return start >= from && start < to;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AccountDetailOperationsUpdateAccountResponse,
|
|
3
3
|
AddressDetailOperationsUpdateAddressResponse,
|
|
4
4
|
AddressOperationsSearchAddressesResponse,
|
|
5
|
+
CalendarEventDetailOperationsGetCalendarEventResponse,
|
|
5
6
|
CalendarEventOperationsListCalendarEventsResponse,
|
|
6
7
|
CalendarOperationsListCalendarsResponse,
|
|
7
8
|
CalendarSuggestionOperationsListCalendarSuggestionsResponse,
|
|
@@ -35,6 +36,7 @@ import type {
|
|
|
35
36
|
} from "@remit/api-http-client/types.gen.ts";
|
|
36
37
|
import { type HttpHandler, HttpResponse, http } from "msw";
|
|
37
38
|
import { makeConfig } from "@/test-support/fixtures";
|
|
39
|
+
import { instancesWithin } from "./calendar-window";
|
|
38
40
|
import type { MailWorld } from "./mail-world";
|
|
39
41
|
|
|
40
42
|
const API = "/api";
|
|
@@ -487,13 +489,29 @@ export const mailHandlers = (
|
|
|
487
489
|
return new HttpResponse(null, { status: 204 });
|
|
488
490
|
}),
|
|
489
491
|
http.get(`${API}/calendars`, () =>
|
|
490
|
-
HttpResponse.json<CalendarOperationsListCalendarsResponse>({
|
|
492
|
+
HttpResponse.json<CalendarOperationsListCalendarsResponse>({
|
|
493
|
+
items: world.calendars,
|
|
494
|
+
}),
|
|
491
495
|
),
|
|
492
|
-
http.get(`${API}/calendar-events`, () =>
|
|
496
|
+
http.get(`${API}/calendar-events`, ({ request }) =>
|
|
493
497
|
HttpResponse.json<CalendarEventOperationsListCalendarEventsResponse>({
|
|
494
|
-
items:
|
|
498
|
+
items: instancesWithin(new URL(request.url), world.calendarEvents),
|
|
495
499
|
}),
|
|
496
500
|
),
|
|
501
|
+
http.get(`${API}/calendar-events/:calendarObjectId`, ({ params }) => {
|
|
502
|
+
const resource = world.calendarResources.find(
|
|
503
|
+
(candidate) =>
|
|
504
|
+
candidate.calendarObjectId === String(params.calendarObjectId),
|
|
505
|
+
);
|
|
506
|
+
if (!resource)
|
|
507
|
+
return HttpResponse.json(
|
|
508
|
+
{ status: 404, message: "Calendar event not found" },
|
|
509
|
+
{ status: 404 },
|
|
510
|
+
);
|
|
511
|
+
return HttpResponse.json<CalendarEventDetailOperationsGetCalendarEventResponse>(
|
|
512
|
+
resource,
|
|
513
|
+
);
|
|
514
|
+
}),
|
|
497
515
|
http.get(`${API}/calendar-suggestions`, () =>
|
|
498
516
|
HttpResponse.json<CalendarSuggestionOperationsListCalendarSuggestionsResponse>(
|
|
499
517
|
{ items: [] },
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
RemitImapAccountResponse,
|
|
3
3
|
RemitImapAddressResponse,
|
|
4
|
+
RemitImapCalendarEventInstance,
|
|
5
|
+
RemitImapCalendarEventResponse,
|
|
6
|
+
RemitImapCalendarResponse,
|
|
4
7
|
RemitImapCanonicalMailboxRole,
|
|
5
8
|
RemitImapMailboxResponse,
|
|
6
9
|
RemitImapMessageCategory,
|
|
@@ -27,6 +30,9 @@ export interface MailWorld {
|
|
|
27
30
|
addresses: RemitImapAddressResponse[];
|
|
28
31
|
systemUpdate: RemitImapSystemUpdateResponse;
|
|
29
32
|
bodies: Record<string, string>;
|
|
33
|
+
calendars: RemitImapCalendarResponse[];
|
|
34
|
+
calendarEvents: RemitImapCalendarEventInstance[];
|
|
35
|
+
calendarResources: RemitImapCalendarEventResponse[];
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export const MINUTE = 60_000;
|
|
@@ -429,5 +435,8 @@ export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
|
|
|
429
435
|
run: null,
|
|
430
436
|
},
|
|
431
437
|
bodies: {},
|
|
438
|
+
calendars: [],
|
|
439
|
+
calendarEvents: [],
|
|
440
|
+
calendarResources: [],
|
|
432
441
|
...overrides,
|
|
433
442
|
});
|
|
@@ -3,7 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
|
|
5
5
|
import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
|
|
6
|
+
import { CalendarSubscribeCard } from "@/components/settings/CalendarSubscribeCard";
|
|
7
|
+
import { CalendarSubscriptionStatus } from "@/components/settings/CalendarSubscriptionStatus";
|
|
6
8
|
import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
|
|
9
|
+
import { useCalendarSubscriptions } from "@/hooks/calendar/useCalendarSubscriptions";
|
|
7
10
|
import { useCalendars } from "@/hooks/calendar/useCalendars";
|
|
8
11
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
9
12
|
|
|
@@ -34,13 +37,19 @@ const calendarsHelp = (
|
|
|
34
37
|
looked up later. Replacing it breaks every subscription built on the old
|
|
35
38
|
one, which is how a shared address is taken back.
|
|
36
39
|
</p>
|
|
40
|
+
<p>
|
|
41
|
+
Subscribing works the other way round: paste another calendar's iCal
|
|
42
|
+
address and its events show here, read-only, refreshed on a schedule.
|
|
43
|
+
</p>
|
|
37
44
|
</div>
|
|
38
45
|
);
|
|
39
46
|
|
|
40
47
|
function CalendarsSettings() {
|
|
41
48
|
const navigate = useNavigate();
|
|
42
49
|
const [helpOpen, setHelpOpen] = useState(true);
|
|
43
|
-
const { calendars, timeZoneByCalendarId, isLoading } =
|
|
50
|
+
const { calendars, collections, timeZoneByCalendarId, isLoading } =
|
|
51
|
+
useCalendars();
|
|
52
|
+
const subscriptions = useCalendarSubscriptions();
|
|
44
53
|
const { createCalendar, isWriting } = useCalendarCollectionWrites();
|
|
45
54
|
const [createProblem, setCreateProblem] = useState("");
|
|
46
55
|
|
|
@@ -49,7 +58,7 @@ function CalendarsSettings() {
|
|
|
49
58
|
items={SETTINGS_NAV_ITEMS}
|
|
50
59
|
activeId="calendars"
|
|
51
60
|
title="Calendars"
|
|
52
|
-
description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
|
|
61
|
+
description="Add, rename and delete calendars, subscribe to a calendar feed, and share one with any client that can subscribe to a URL."
|
|
53
62
|
help={calendarsHelp}
|
|
54
63
|
helpOpen={helpOpen}
|
|
55
64
|
onToggleHelp={() => setHelpOpen((open) => !open)}
|
|
@@ -68,14 +77,44 @@ function CalendarsSettings() {
|
|
|
68
77
|
/>
|
|
69
78
|
) : (
|
|
70
79
|
<div className="space-y-4">
|
|
71
|
-
{calendars.map((calendar) =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
{calendars.map((calendar) => {
|
|
81
|
+
const collection = collections.find(
|
|
82
|
+
(candidate) => candidate.calendarId === calendar.id,
|
|
83
|
+
);
|
|
84
|
+
return (
|
|
85
|
+
<CalendarSettingsPanel
|
|
86
|
+
key={calendar.id}
|
|
87
|
+
calendarId={calendar.id}
|
|
88
|
+
calendarName={calendar.name}
|
|
89
|
+
timezone={timeZoneByCalendarId[calendar.id] ?? ""}
|
|
90
|
+
subscription={
|
|
91
|
+
collection?.source === "Subscribed" && (
|
|
92
|
+
<CalendarSubscriptionStatus
|
|
93
|
+
calendarName={collection.displayName}
|
|
94
|
+
subscriptionUrl={collection.subscriptionUrl}
|
|
95
|
+
enabled={collection.subscriptionEnabled}
|
|
96
|
+
fetchedAt={collection.subscriptionFetchedAt}
|
|
97
|
+
error={collection.subscriptionError}
|
|
98
|
+
isBusy={subscriptions.togglingCalendarId === calendar.id}
|
|
99
|
+
actionError={
|
|
100
|
+
subscriptions.toggleFailure.calendarId === calendar.id
|
|
101
|
+
? subscriptions.toggleFailure.error
|
|
102
|
+
: undefined
|
|
103
|
+
}
|
|
104
|
+
onSetEnabled={(enabled) =>
|
|
105
|
+
subscriptions.setEnabled(calendar.id, enabled)
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
<CalendarSubscribeCard
|
|
114
|
+
isBusy={subscriptions.isSubscribing}
|
|
115
|
+
error={subscriptions.subscribeError}
|
|
116
|
+
onSubscribe={subscriptions.subscribe}
|
|
117
|
+
/>
|
|
79
118
|
<CalendarCreateCard
|
|
80
119
|
isBusy={isWriting}
|
|
81
120
|
problem={createProblem}
|