@remit/web-client 0.0.290 → 0.0.292
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/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/calendar-writes.render.test.ts +21 -0
- package/src/hooks/calendar/index.ts +6 -0
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +130 -0
- package/src/hooks/calendar/useCalendarWrites.ts +5 -3
- 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/routes/settings/calendars.tsx +28 -6
|
@@ -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,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>
|