@remit/web-client 0.0.291 → 0.0.293
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/calendar/CalendarComposePane.stories.tsx +19 -0
- package/src/components/calendar/CalendarComposePane.tsx +30 -10
- package/src/components/calendar/OpenCalendarEvent.tsx +7 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +4 -0
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +45 -0
- package/src/components/onboarding/OnboardingWizard.tsx +2 -8
- package/src/components/onboarding/onboarding-servers.stories.tsx +49 -0
- package/src/components/settings/CalendarCreateCard.stories.tsx +33 -0
- package/src/components/settings/CalendarCreateCard.tsx +149 -0
- package/src/components/settings/CalendarDetailsForm.stories.tsx +57 -0
- package/src/components/settings/CalendarDetailsForm.tsx +136 -0
- package/src/components/settings/CalendarFeedCard.stories.tsx +18 -0
- package/src/components/settings/CalendarFeedCard.tsx +7 -1
- package/src/components/settings/CalendarFeedPanel.tsx +4 -0
- package/src/components/settings/CalendarSettingsPanel.tsx +54 -0
- package/src/components/settings/TimeZoneSelect.tsx +43 -0
- package/src/components/settings/calendar-collection-cards.render.test.ts +149 -0
- package/src/hooks/calendar/calendar-collections.render.test.ts +259 -0
- package/src/hooks/calendar/index.ts +6 -0
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +130 -0
- package/src/hooks/calendar/useDraftClashes.ts +91 -0
- package/src/lib/calendar-collection-copy.test.ts +23 -0
- package/src/lib/calendar-collection-copy.ts +21 -0
- package/src/lib/provider-presets.test.ts +33 -2
- package/src/lib/provider-presets.ts +14 -1
- package/src/routes/settings/calendars.tsx +28 -6
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating, renaming, rezoning and deleting a calendar collection.
|
|
3
|
+
*
|
|
4
|
+
* A refusal comes back as an outcome carrying the server's own words — a url
|
|
5
|
+
* segment already taken, a zone it cannot resolve, the default calendar that
|
|
6
|
+
* cannot be removed — so the settings card states it where the reader acted.
|
|
7
|
+
* A lapsed session is not among the statuses owned here and escalates.
|
|
8
|
+
*
|
|
9
|
+
* Every write invalidates the listing, which is what the settings page, the
|
|
10
|
+
* editor's picker and the address filter all read. A delete or a rezone also
|
|
11
|
+
* moves or removes occurrences, so the event reads are invalidated with it.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
calendarDetailOperationsDeleteCalendarMutation,
|
|
15
|
+
calendarDetailOperationsUpdateCalendarMutation,
|
|
16
|
+
calendarOperationsCreateCalendarMutation,
|
|
17
|
+
calendarOperationsListCalendarsQueryKey,
|
|
18
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
|
+
import type {
|
|
20
|
+
RemitImapCreateCalendarInput,
|
|
21
|
+
RemitImapUpdateCalendarInput,
|
|
22
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
23
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
24
|
+
import { useCallback, useMemo } from "react";
|
|
25
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
26
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
27
|
+
import { useInvalidateCalendarReads } from "./useCalendarWrites";
|
|
28
|
+
|
|
29
|
+
const COLLECTION_WRITE_META = softErrorStatuses(400, 404);
|
|
30
|
+
|
|
31
|
+
export type CalendarCollectionOutcome =
|
|
32
|
+
| { kind: "written" }
|
|
33
|
+
| { kind: "refused"; message: string };
|
|
34
|
+
|
|
35
|
+
const WRITTEN: CalendarCollectionOutcome = { kind: "written" };
|
|
36
|
+
|
|
37
|
+
const refused = (error: unknown): CalendarCollectionOutcome => ({
|
|
38
|
+
kind: "refused",
|
|
39
|
+
message: formatErrorMessage(error),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface CalendarCollectionWrites {
|
|
43
|
+
createCalendar: (
|
|
44
|
+
input: RemitImapCreateCalendarInput,
|
|
45
|
+
) => Promise<CalendarCollectionOutcome>;
|
|
46
|
+
updateCalendar: (
|
|
47
|
+
calendarId: string,
|
|
48
|
+
patch: RemitImapUpdateCalendarInput,
|
|
49
|
+
) => Promise<CalendarCollectionOutcome>;
|
|
50
|
+
deleteCalendar: (calendarId: string) => Promise<CalendarCollectionOutcome>;
|
|
51
|
+
isWriting: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useCalendarCollectionWrites(): CalendarCollectionWrites {
|
|
55
|
+
const queryClient = useQueryClient();
|
|
56
|
+
const invalidateReads = useInvalidateCalendarReads();
|
|
57
|
+
|
|
58
|
+
const invalidateListing = useCallback(
|
|
59
|
+
() =>
|
|
60
|
+
queryClient.invalidateQueries({
|
|
61
|
+
queryKey: calendarOperationsListCalendarsQueryKey(),
|
|
62
|
+
}),
|
|
63
|
+
[queryClient],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const invalidateAll = useCallback(() => {
|
|
67
|
+
invalidateReads();
|
|
68
|
+
return invalidateListing();
|
|
69
|
+
}, [invalidateReads, invalidateListing]);
|
|
70
|
+
|
|
71
|
+
const create = useMutation({
|
|
72
|
+
...calendarOperationsCreateCalendarMutation(),
|
|
73
|
+
meta: COLLECTION_WRITE_META,
|
|
74
|
+
onSuccess: invalidateListing,
|
|
75
|
+
});
|
|
76
|
+
const update = useMutation({
|
|
77
|
+
...calendarDetailOperationsUpdateCalendarMutation(),
|
|
78
|
+
meta: COLLECTION_WRITE_META,
|
|
79
|
+
onSuccess: invalidateAll,
|
|
80
|
+
});
|
|
81
|
+
const remove = useMutation({
|
|
82
|
+
...calendarDetailOperationsDeleteCalendarMutation(),
|
|
83
|
+
meta: COLLECTION_WRITE_META,
|
|
84
|
+
onSuccess: invalidateAll,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const createCalendar = useCallback(
|
|
88
|
+
(input: RemitImapCreateCalendarInput) =>
|
|
89
|
+
create
|
|
90
|
+
.mutateAsync({ body: input })
|
|
91
|
+
.then(() => WRITTEN)
|
|
92
|
+
.catch(refused),
|
|
93
|
+
[create],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const updateCalendar = useCallback(
|
|
97
|
+
(calendarId: string, patch: RemitImapUpdateCalendarInput) =>
|
|
98
|
+
update
|
|
99
|
+
.mutateAsync({ path: { calendarId }, body: patch })
|
|
100
|
+
.then(() => WRITTEN)
|
|
101
|
+
.catch(refused),
|
|
102
|
+
[update],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const deleteCalendar = useCallback(
|
|
106
|
+
(calendarId: string) =>
|
|
107
|
+
remove
|
|
108
|
+
.mutateAsync({ path: { calendarId } })
|
|
109
|
+
.then(() => WRITTEN)
|
|
110
|
+
.catch(refused),
|
|
111
|
+
[remove],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return useMemo(
|
|
115
|
+
() => ({
|
|
116
|
+
createCalendar,
|
|
117
|
+
updateCalendar,
|
|
118
|
+
deleteCalendar,
|
|
119
|
+
isWriting: create.isPending || update.isPending || remove.isPending,
|
|
120
|
+
}),
|
|
121
|
+
[
|
|
122
|
+
createCalendar,
|
|
123
|
+
updateCalendar,
|
|
124
|
+
deleteCalendar,
|
|
125
|
+
create.isPending,
|
|
126
|
+
update.isPending,
|
|
127
|
+
remove.isPending,
|
|
128
|
+
],
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the span a draft names runs into, across every calendar the reader
|
|
3
|
+
* holds — so the editor says so before Save rather than the grid after it.
|
|
4
|
+
*
|
|
5
|
+
* Read on the weeks from the draft's first day through its last, which for a
|
|
6
|
+
* single-day draft is the grid's own cache entry, and measured by the same rule the invitation card uses. Undefined
|
|
7
|
+
* while there is no span to check (all day, a time missing, an end not after
|
|
8
|
+
* the start) or no answer to
|
|
9
|
+
* check it against: drawing "nothing else is booked" over a read that has not
|
|
10
|
+
* landed is a claim, not a delay.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
type CalendarClash,
|
|
14
|
+
type CalendarEventData,
|
|
15
|
+
clashesWith,
|
|
16
|
+
type EventDraft,
|
|
17
|
+
} from "@remit/ui";
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { formatEventWhen } from "@/lib/calendar-format";
|
|
20
|
+
import {
|
|
21
|
+
isDrawnInstance,
|
|
22
|
+
toCalendarEventData,
|
|
23
|
+
UNZONED_CALENDAR,
|
|
24
|
+
} from "./instance";
|
|
25
|
+
import { useCalendarEventWindow } from "./useCalendarEvents";
|
|
26
|
+
import { useCalendars } from "./useCalendars";
|
|
27
|
+
import { type CalendarWindow, calendarWindow, isoAtInZone } from "./window";
|
|
28
|
+
|
|
29
|
+
const EVERY_CALENDAR: readonly string[] = [];
|
|
30
|
+
const NO_WINDOW: CalendarWindow = { from: "", to: "" };
|
|
31
|
+
|
|
32
|
+
export function useDraftClashes(
|
|
33
|
+
draft: EventDraft,
|
|
34
|
+
/** The resource being edited, which never clashes with itself. */
|
|
35
|
+
calendarObjectId = "",
|
|
36
|
+
): CalendarClash[] | undefined {
|
|
37
|
+
const { timeZoneByCalendarId } = useCalendars();
|
|
38
|
+
const checkable =
|
|
39
|
+
draft.date !== "" &&
|
|
40
|
+
!draft.allDay &&
|
|
41
|
+
draft.endDate !== "" &&
|
|
42
|
+
draft.startTime !== "" &&
|
|
43
|
+
draft.endTime !== "" &&
|
|
44
|
+
`${draft.endDate}T${draft.endTime}` > `${draft.date}T${draft.startTime}`;
|
|
45
|
+
const { instances, isLoading, error } = useCalendarEventWindow({
|
|
46
|
+
...(checkable
|
|
47
|
+
? {
|
|
48
|
+
from: calendarWindow("week", draft.date).from,
|
|
49
|
+
to: calendarWindow("week", draft.endDate).to,
|
|
50
|
+
}
|
|
51
|
+
: NO_WINDOW),
|
|
52
|
+
calendarIds: EVERY_CALENDAR,
|
|
53
|
+
enabled: checkable,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const zone = timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR;
|
|
57
|
+
|
|
58
|
+
return useMemo(() => {
|
|
59
|
+
if (!checkable || isLoading || error) return undefined;
|
|
60
|
+
const start = isoAtInZone(draft.date, draft.startTime, zone);
|
|
61
|
+
const end = isoAtInZone(draft.endDate, draft.endTime, zone);
|
|
62
|
+
const others: CalendarEventData[] = instances
|
|
63
|
+
.filter(
|
|
64
|
+
(instance) =>
|
|
65
|
+
isDrawnInstance(instance) &&
|
|
66
|
+
instance.calendarObjectId !== calendarObjectId,
|
|
67
|
+
)
|
|
68
|
+
.map((instance) =>
|
|
69
|
+
toCalendarEventData(
|
|
70
|
+
instance,
|
|
71
|
+
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return clashesWith({ start, end }, others).map((event) => ({
|
|
75
|
+
id: event.id,
|
|
76
|
+
label: `${event.title}, ${formatEventWhen(event)}`,
|
|
77
|
+
}));
|
|
78
|
+
}, [
|
|
79
|
+
checkable,
|
|
80
|
+
isLoading,
|
|
81
|
+
error,
|
|
82
|
+
draft.date,
|
|
83
|
+
draft.endDate,
|
|
84
|
+
draft.startTime,
|
|
85
|
+
draft.endTime,
|
|
86
|
+
zone,
|
|
87
|
+
instances,
|
|
88
|
+
calendarObjectId,
|
|
89
|
+
timeZoneByCalendarId,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { calendarUrlSegmentFor } from "./calendar-collection-copy.js";
|
|
4
|
+
|
|
5
|
+
describe("calendarUrlSegmentFor", () => {
|
|
6
|
+
test("folds a name into a path segment", () => {
|
|
7
|
+
assert.strictEqual(
|
|
8
|
+
calendarUrlSegmentFor("Harbour Projects"),
|
|
9
|
+
"harbour-projects",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("drops accents and punctuation at either end", () => {
|
|
14
|
+
assert.strictEqual(
|
|
15
|
+
calendarUrlSegmentFor(" Café & Crèche! "),
|
|
16
|
+
"cafe-creche",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("stays within the 64 characters the API accepts", () => {
|
|
21
|
+
assert.strictEqual(calendarUrlSegmentFor("a".repeat(80)).length, 64);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CollectionConfirmCopy {
|
|
2
|
+
title: string;
|
|
3
|
+
description: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const deleteCalendarConfirmCopy = (
|
|
7
|
+
calendarName: string,
|
|
8
|
+
): CollectionConfirmCopy => ({
|
|
9
|
+
title: `Delete ${calendarName}?`,
|
|
10
|
+
description: `This removes ${calendarName} and every event in it, and any subscription address stops working. It cannot be undone.`,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** A starting address for a new calendar, from the name typed so far. */
|
|
14
|
+
export const calendarUrlSegmentFor = (displayName: string): string =>
|
|
15
|
+
displayName
|
|
16
|
+
.normalize("NFKD")
|
|
17
|
+
.replace(/[̀-ͯ]/g, "")
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
.slice(0, 64);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { APP_PASSWORD_URLS, discoverSettings } from "./autodiscovery.js";
|
|
3
4
|
import {
|
|
4
5
|
getPresetById,
|
|
5
6
|
PROVIDER_PRESETS,
|
|
@@ -29,12 +30,16 @@ const EXPECTED: Record<string, ExpectedSettings> = {
|
|
|
29
30
|
imap: { host: "imap.fastmail.com", port: 993, security: "tls" },
|
|
30
31
|
smtp: { host: "smtp.fastmail.com", port: 465, security: "tls" },
|
|
31
32
|
},
|
|
33
|
+
gmail: {
|
|
34
|
+
imap: { host: "imap.gmail.com", port: 993, security: "tls" },
|
|
35
|
+
smtp: { host: "smtp.gmail.com", port: 587, security: "starttls" },
|
|
36
|
+
},
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
describe("provider presets", () => {
|
|
35
|
-
it("exposes exactly the
|
|
40
|
+
it("exposes exactly the five documented providers", () => {
|
|
36
41
|
const ids = PROVIDER_PRESETS.map((p) => p.id).sort();
|
|
37
|
-
assert.deepEqual(ids, ["aol", "fastmail", "icloud", "yahoo"]);
|
|
42
|
+
assert.deepEqual(ids, ["aol", "fastmail", "gmail", "icloud", "yahoo"]);
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
for (const preset of PROVIDER_PRESETS) {
|
|
@@ -71,6 +76,18 @@ describe("provider presets", () => {
|
|
|
71
76
|
assert.equal(preset.label, "iCloud");
|
|
72
77
|
});
|
|
73
78
|
|
|
79
|
+
it("tells a Gmail user to turn on 2-Step Verification before creating an app password", () => {
|
|
80
|
+
const gmail = getPresetById("gmail");
|
|
81
|
+
assert.ok(gmail);
|
|
82
|
+
const { text, url } = gmail.passwordHelp;
|
|
83
|
+
const twoStep = text.search(/2-Step Verification/);
|
|
84
|
+
const appPassword = text.search(/create an app password/i);
|
|
85
|
+
assert.ok(twoStep >= 0, "names 2-Step Verification");
|
|
86
|
+
assert.ok(appPassword > twoStep, "2-Step Verification comes first");
|
|
87
|
+
assert.equal(url, APP_PASSWORD_URLS["gmail.com"]);
|
|
88
|
+
assert.equal(url, APP_PASSWORD_URLS["googlemail.com"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
74
91
|
it("returns undefined for an unknown id", () => {
|
|
75
92
|
assert.equal(getPresetById("custom"), undefined);
|
|
76
93
|
});
|
|
@@ -82,6 +99,20 @@ describe("provider presets", () => {
|
|
|
82
99
|
assert.equal(presetIdForEmail("carol@yahoo.com"), "yahoo");
|
|
83
100
|
assert.equal(presetIdForEmail("dave@aol.com"), "aol");
|
|
84
101
|
assert.equal(presetIdForEmail("erin@fastmail.com"), "fastmail");
|
|
102
|
+
assert.equal(presetIdForEmail("frank@gmail.com"), "gmail");
|
|
103
|
+
assert.equal(presetIdForEmail("grace@googlemail.com"), "gmail");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("resolves a Gmail address to the preset its server lookup agrees with", async () => {
|
|
107
|
+
for (const email of ["user@gmail.com", "User@GoogleMail.com"]) {
|
|
108
|
+
const preset = getPresetById(presetIdForEmail(email));
|
|
109
|
+
assert.ok(preset, email);
|
|
110
|
+
assert.equal(preset.id, "gmail");
|
|
111
|
+
const discovered = await discoverSettings(email);
|
|
112
|
+
assert.ok(discovered, email);
|
|
113
|
+
assert.deepEqual(preset.imap, discovered.imap);
|
|
114
|
+
assert.deepEqual(preset.smtp, discovered.smtp);
|
|
115
|
+
}
|
|
85
116
|
});
|
|
86
117
|
|
|
87
118
|
it("is case-insensitive on the domain", () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { APP_PASSWORD_URLS, type ServerSettings } from "./autodiscovery.js";
|
|
2
2
|
|
|
3
3
|
export interface ProviderPreset {
|
|
4
4
|
id: string;
|
|
@@ -54,6 +54,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|
|
54
54
|
url: "https://www.fastmail.help/hc/en-us/articles/360058752854",
|
|
55
55
|
},
|
|
56
56
|
},
|
|
57
|
+
{
|
|
58
|
+
id: "gmail",
|
|
59
|
+
label: "Gmail",
|
|
60
|
+
username: "full-email",
|
|
61
|
+
imap: { host: "imap.gmail.com", port: 993, security: "tls" },
|
|
62
|
+
smtp: { host: "smtp.gmail.com", port: 587, security: "starttls" },
|
|
63
|
+
passwordHelp: {
|
|
64
|
+
text: "Gmail requires an app password, not your Google password. First turn on 2-Step Verification for your Google Account, then create an app password under Security → 2-Step Verification → App passwords.",
|
|
65
|
+
url: APP_PASSWORD_URLS["gmail.com"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
57
68
|
];
|
|
58
69
|
|
|
59
70
|
export function getPresetById(id: string): ProviderPreset | undefined {
|
|
@@ -70,6 +81,8 @@ const DOMAIN_TO_PRESET: Record<string, string> = {
|
|
|
70
81
|
"aol.com": "aol",
|
|
71
82
|
"fastmail.com": "fastmail",
|
|
72
83
|
"fastmail.fm": "fastmail",
|
|
84
|
+
"gmail.com": "gmail",
|
|
85
|
+
"googlemail.com": "gmail",
|
|
73
86
|
};
|
|
74
87
|
|
|
75
88
|
/** Pre-select a provider preset from a known email domain; "" for Custom. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { SettingsShell } from "@remit/ui";
|
|
2
2
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
|
|
5
|
+
import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
|
|
6
|
+
import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
|
|
5
7
|
import { useCalendars } from "@/hooks/calendar/useCalendars";
|
|
6
8
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
7
9
|
|
|
@@ -11,6 +13,13 @@ export const Route = createFileRoute("/settings/calendars")({
|
|
|
11
13
|
|
|
12
14
|
const calendarsHelp = (
|
|
13
15
|
<div className="space-y-3">
|
|
16
|
+
<p>
|
|
17
|
+
Each calendar has a name, a time zone its floating times are read in, and
|
|
18
|
+
an address CalDAV clients reach it by. The address is fixed when the
|
|
19
|
+
calendar is added; the name and zone can change. Deleting a calendar
|
|
20
|
+
deletes every event in it. The default calendar is where accepted
|
|
21
|
+
invitations land, so it cannot be deleted.
|
|
22
|
+
</p>
|
|
14
23
|
<p>
|
|
15
24
|
A subscription address lets Apple Calendar, Google Calendar, Outlook and
|
|
16
25
|
Thunderbird show a calendar from here, read-only and kept up to date.
|
|
@@ -31,14 +40,16 @@ const calendarsHelp = (
|
|
|
31
40
|
function CalendarsSettings() {
|
|
32
41
|
const navigate = useNavigate();
|
|
33
42
|
const [helpOpen, setHelpOpen] = useState(true);
|
|
34
|
-
const { calendars, isLoading } = useCalendars();
|
|
43
|
+
const { calendars, timeZoneByCalendarId, isLoading } = useCalendars();
|
|
44
|
+
const { createCalendar, isWriting } = useCalendarCollectionWrites();
|
|
45
|
+
const [createProblem, setCreateProblem] = useState("");
|
|
35
46
|
|
|
36
47
|
return (
|
|
37
48
|
<SettingsShell
|
|
38
49
|
items={SETTINGS_NAV_ITEMS}
|
|
39
50
|
activeId="calendars"
|
|
40
51
|
title="Calendars"
|
|
41
|
-
description="
|
|
52
|
+
description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
|
|
42
53
|
help={calendarsHelp}
|
|
43
54
|
helpOpen={helpOpen}
|
|
44
55
|
onToggleHelp={() => setHelpOpen((open) => !open)}
|
|
@@ -55,17 +66,28 @@ function CalendarsSettings() {
|
|
|
55
66
|
aria-busy="true"
|
|
56
67
|
aria-label="Loading calendars"
|
|
57
68
|
/>
|
|
58
|
-
) : calendars.length === 0 ? (
|
|
59
|
-
<p className="py-12 text-sm text-fg-muted">No calendars yet.</p>
|
|
60
69
|
) : (
|
|
61
70
|
<div className="space-y-4">
|
|
62
71
|
{calendars.map((calendar) => (
|
|
63
|
-
<
|
|
72
|
+
<CalendarSettingsPanel
|
|
64
73
|
key={calendar.id}
|
|
65
74
|
calendarId={calendar.id}
|
|
66
75
|
calendarName={calendar.name}
|
|
76
|
+
timezone={timeZoneByCalendarId[calendar.id] ?? ""}
|
|
67
77
|
/>
|
|
68
78
|
))}
|
|
79
|
+
<CalendarCreateCard
|
|
80
|
+
isBusy={isWriting}
|
|
81
|
+
problem={createProblem}
|
|
82
|
+
onCreate={(calendar) => {
|
|
83
|
+
setCreateProblem("");
|
|
84
|
+
return createCalendar(calendar).then((outcome) => {
|
|
85
|
+
if (outcome.kind === "written") return true;
|
|
86
|
+
setCreateProblem(outcome.message);
|
|
87
|
+
return false;
|
|
88
|
+
});
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
69
91
|
</div>
|
|
70
92
|
)}
|
|
71
93
|
</SettingsShell>
|