@remit/web-client 0.0.305 → 0.0.306
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/CalendarEventPane.stories.tsx +23 -0
- package/src/components/calendar/CalendarEventPane.tsx +13 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +13 -2
- package/src/components/calendar/WriteCalendarEvent.tsx +2 -1
- package/src/components/calendar/pending-suggestions.render.test.ts +5 -0
- 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/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.306",
|
|
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,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"
|
|
@@ -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>(() => {
|
|
@@ -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
|
},
|
|
@@ -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.`;
|
|
@@ -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}
|