@remit/web-client 0.0.304 → 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/onboarding/OnboardingWizard.flow.test.ts +222 -2
- package/src/components/onboarding/OnboardingWizard.tsx +262 -15
- package/src/components/onboarding/onboarding.stories.tsx +90 -0
- package/src/components/settings/AccountServices.render.test.ts +4 -1
- 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/components/settings/accounts.stories.tsx +5 -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/-accounts.test.ts +70 -2
- package/src/routes/settings/accounts.tsx +87 -7
- package/src/routes/settings/calendars.tsx +49 -10
|
@@ -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
|
+
}
|
|
@@ -199,6 +199,11 @@ export const AccountsOauthSuccess: Story = {
|
|
|
199
199
|
args: { url: "/settings/accounts?connected=acc-personal" },
|
|
200
200
|
play: async ({ canvasElement }) => {
|
|
201
201
|
const canvas = within(canvasElement);
|
|
202
|
+
await expect(await canvas.findByText(/^Connected /)).toBeVisible();
|
|
203
|
+
await expect(canvas.getByText(/Access granted/)).toBeVisible();
|
|
204
|
+
await userEvent.click(
|
|
205
|
+
canvas.getByRole("button", { name: /Go to inbox|Continue/ }),
|
|
206
|
+
);
|
|
202
207
|
await expect(
|
|
203
208
|
await canvas.findByText("Account connected successfully."),
|
|
204
209
|
).toBeVisible();
|
|
@@ -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.`;
|
|
@@ -144,7 +144,9 @@ const pageShow = (persisted: boolean): Event => {
|
|
|
144
144
|
};
|
|
145
145
|
|
|
146
146
|
/** The real route, mounted the way the generated tree mounts it. */
|
|
147
|
-
const mountAccounts = async (
|
|
147
|
+
const mountAccounts = async (
|
|
148
|
+
entry = "/settings/accounts",
|
|
149
|
+
): Promise<DomHarness> => {
|
|
148
150
|
http = mockFetch((call) => {
|
|
149
151
|
if (call.path.endsWith("/oauth/microsoft/start")) {
|
|
150
152
|
return { authorizationUrl: CONSENT_URL };
|
|
@@ -168,7 +170,7 @@ const mountAccounts = async (): Promise<DomHarness> => {
|
|
|
168
170
|
]) as unknown as AnyRoute;
|
|
169
171
|
const router = createRouter({
|
|
170
172
|
routeTree,
|
|
171
|
-
history: createMemoryHistory({ initialEntries: [
|
|
173
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
172
174
|
}) as unknown as AnyRouter;
|
|
173
175
|
await router.load();
|
|
174
176
|
|
|
@@ -275,3 +277,69 @@ describe("the Reconnect button and the redirect it starts", () => {
|
|
|
275
277
|
assert.ok(dom.byText("button", "Reconnect"));
|
|
276
278
|
});
|
|
277
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("the Microsoft callback landing on the accounts page (#1183)", () => {
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
harness?.close();
|
|
284
|
+
harness = undefined;
|
|
285
|
+
http?.restore();
|
|
286
|
+
http = undefined;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a reconnect that comes back connected ends on the granted screen", async () => {
|
|
290
|
+
const dom = await mountAccounts("/settings/accounts?connected=acc-1");
|
|
291
|
+
await dom.waitFor(
|
|
292
|
+
() => dom.text().includes("Connected matthijs@ischen.nl"),
|
|
293
|
+
"the granted screen",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
assert.match(dom.text(), /Access granted/);
|
|
297
|
+
dom.click(dom.byText("button", "Go to inbox"));
|
|
298
|
+
await settle(dom);
|
|
299
|
+
assert.match(dom.text(), /Account connected successfully\./);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a reconnect Microsoft refused a service reopens that account, address filled", async () => {
|
|
303
|
+
const dom = await mountAccounts(
|
|
304
|
+
"/settings/accounts?oauthError=scope_not_granted&oauthEmail=matthijs%40ischen.nl&missingServices=Calendar",
|
|
305
|
+
);
|
|
306
|
+
await dom.waitFor(
|
|
307
|
+
() => dom.text().includes("Reconnect matthijs@ischen.nl"),
|
|
308
|
+
"the reconnect sign-in",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
assert.match(dom.text(), /Microsoft did not grant access to Calendar\./);
|
|
312
|
+
assert.equal(
|
|
313
|
+
dom.query<HTMLInputElement>("#microsoft-email")?.value,
|
|
314
|
+
"matthijs@ischen.nl",
|
|
315
|
+
);
|
|
316
|
+
const choice = dom
|
|
317
|
+
.queryAll("fieldset")
|
|
318
|
+
.find((fieldset) =>
|
|
319
|
+
fieldset.textContent?.includes("What should Remit sync?"),
|
|
320
|
+
);
|
|
321
|
+
assert.ok(choice, "expected the service choice");
|
|
322
|
+
assert.deepEqual(
|
|
323
|
+
[
|
|
324
|
+
...choice.querySelectorAll<HTMLInputElement>("input[type=checkbox]"),
|
|
325
|
+
].map((box) => box.checked),
|
|
326
|
+
[true, true],
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a new sign-in Microsoft refused a service reopens the add wizard", async () => {
|
|
331
|
+
const dom = await mountAccounts(
|
|
332
|
+
"/settings/accounts?oauthError=scope_not_granted&oauthEmail=new%40outlook.com&missingServices=Mail%2CCalendar",
|
|
333
|
+
);
|
|
334
|
+
await dom.waitFor(
|
|
335
|
+
() => dom.text().includes("did not grant access to Mail and Calendar"),
|
|
336
|
+
"the refused sign-in",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
assert.doesNotMatch(dom.text(), /Reconnect new@outlook\.com/);
|
|
340
|
+
assert.equal(
|
|
341
|
+
dom.query<HTMLInputElement>("#microsoft-email")?.value,
|
|
342
|
+
"new@outlook.com",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
});
|