@rapidmx/web-client 0.14.1 → 0.15.1
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/apps/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -866
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { MouseEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { format } from "date-fns";
|
|
7
|
+
import {
|
|
8
|
+
AvailabilityState,
|
|
9
|
+
FREE_BUSY_MAX_ADDRESSES,
|
|
10
|
+
PersonAvailability,
|
|
11
|
+
availabilityDuring,
|
|
12
|
+
availabilityOf,
|
|
13
|
+
getFreeBusy,
|
|
14
|
+
suggestTimes,
|
|
15
|
+
summarizeAvailability,
|
|
16
|
+
withoutOwnBlock,
|
|
17
|
+
} from "@rapidmx/react-shared/calendar/freeBusyApi.js";
|
|
18
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
19
|
+
import { addDaysToKey } from "./allDay.js";
|
|
20
|
+
import { EventFormController } from "./eventForm.js";
|
|
21
|
+
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
22
|
+
import {
|
|
23
|
+
SEARCH_DAYS,
|
|
24
|
+
WORK_END_HOUR,
|
|
25
|
+
WORK_START_HOUR,
|
|
26
|
+
clockLabel,
|
|
27
|
+
dayStartMs,
|
|
28
|
+
freeBusyErrorMessage,
|
|
29
|
+
isDayKey,
|
|
30
|
+
minuteOfDay,
|
|
31
|
+
summaryLine,
|
|
32
|
+
workingWindows,
|
|
33
|
+
} from "./findTimeGrid.js";
|
|
34
|
+
|
|
35
|
+
/** How long the grid waits after the last change of day or guests before asking the server, so typing a list of guests is one look-up. */
|
|
36
|
+
export const FIND_TIME_DEBOUNCE_MS = 300;
|
|
37
|
+
|
|
38
|
+
const HOUR_WIDTH = 52;
|
|
39
|
+
const NAME_WIDTH = 136;
|
|
40
|
+
const HEADER_HEIGHT = 28;
|
|
41
|
+
const ROW_HEIGHT = 44;
|
|
42
|
+
const TIMELINE_WIDTH = HOUR_WIDTH * 24;
|
|
43
|
+
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
|
|
44
|
+
const GRID_LINES = `repeating-linear-gradient(to right, transparent 0, transparent ${HOUR_WIDTH - 1}px, var(--color-border) ${HOUR_WIDTH - 1}px, var(--color-border) ${HOUR_WIDTH}px)`;
|
|
45
|
+
const HATCH = "repeating-linear-gradient(45deg, transparent 0, transparent 4px, var(--color-text-muted) 4px, var(--color-text-muted) 5px)";
|
|
46
|
+
const TENTATIVE_HATCH =
|
|
47
|
+
"repeating-linear-gradient(45deg, var(--color-danger-bg) 0, var(--color-danger-bg) 4px, var(--color-danger) 4px, var(--color-danger) 6px)";
|
|
48
|
+
|
|
49
|
+
/** "12am", "9am", "12pm" for the header cells and their labels. */
|
|
50
|
+
function hourLabel(hour: number): string {
|
|
51
|
+
return format(new Date(2000, 0, 1, hour), "haaa");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Row {
|
|
55
|
+
address: string;
|
|
56
|
+
name: string;
|
|
57
|
+
you: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type LookUp = { status: "loading" | "ready"; people: PersonAvailability[] } | { status: "error"; people: PersonAvailability[]; message: string };
|
|
61
|
+
|
|
62
|
+
/** What a row says about the proposed time, under the person's name. */
|
|
63
|
+
function stateText(state: AvailabilityState | undefined, status: PersonAvailability["status"] | undefined): string {
|
|
64
|
+
if (status === "restricted") {
|
|
65
|
+
return "Hides their calendar";
|
|
66
|
+
}
|
|
67
|
+
if (status === "unknown") {
|
|
68
|
+
return "Availability unknown";
|
|
69
|
+
}
|
|
70
|
+
if (status === undefined) {
|
|
71
|
+
return "Checking…";
|
|
72
|
+
}
|
|
73
|
+
// Their calendar was read, but the proposed time is not on the days it covers.
|
|
74
|
+
if (state === undefined) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
return state === "free" ? "Free" : state === "busy" ? "Busy at this time" : "Maybe busy at this time";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The event dialog's "Find a time" tab: a day of the guests' calendars side by side, as Google Calendar draws it. A row for you and one for each guest,
|
|
82
|
+
* the hours of the day across (working hours in view first), each person's busy time as a block (a tentative one hatched) and the event's proposed time as a
|
|
83
|
+
* highlight across every row; pressing an hour, or a place in a row, moves the event's start there (its length is kept). Under the grid: whether the proposed
|
|
84
|
+
* time suits everyone, and up to five suggested times - the next slots of the event's length, 8:00am to 6:00pm, when everyone who can be checked is free.
|
|
85
|
+
*
|
|
86
|
+
* Who can be checked: `POST /calendar-events/free-busy` tells a guest's busy time only for a mailbox on this server whose owner shares it with you. A guest it
|
|
87
|
+
* cannot see - an external address, or somebody who hides their calendar - is shown as such, in the grid and in the summary, and is never counted as free;
|
|
88
|
+
* suggestions ignore them (and say so). The look-up is made after a short pause (`FIND_TIME_DEBOUNCE_MS`) whenever the day or the guests change, one answer at
|
|
89
|
+
* a time (an answer for a look-up that has since been replaced is dropped), and a failure - too many look-ups, or no connection - says so with a retry.
|
|
90
|
+
*/
|
|
91
|
+
export default function FindATime({ c }: { c: EventFormController }) {
|
|
92
|
+
const { values, deviceZone, organizerAddress, occurrence } = c;
|
|
93
|
+
const { start, end, allDay, formZone } = values;
|
|
94
|
+
const startDay = start.slice(0, 10);
|
|
95
|
+
const [dayKey, setDayKey] = useState(() => (isDayKey(startDay) ? startDay : format(new Date(), "yyyy-MM-dd")));
|
|
96
|
+
const [retry, setRetry] = useState(0);
|
|
97
|
+
// Picking another date in the form moves the grid to it.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (isDayKey(startDay)) {
|
|
100
|
+
setDayKey(startDay);
|
|
101
|
+
}
|
|
102
|
+
}, [startDay]);
|
|
103
|
+
|
|
104
|
+
// You, then each guest once (case-insensitively), at most as many as one look-up may ask about.
|
|
105
|
+
const rows = useMemo<Row[]>(() => {
|
|
106
|
+
const seen = new Set([organizerAddress.toLowerCase()]);
|
|
107
|
+
const guests: Row[] = [];
|
|
108
|
+
for (const attendee of values.attendees) {
|
|
109
|
+
const key = attendee.address.toLowerCase();
|
|
110
|
+
if (!seen.has(key)) {
|
|
111
|
+
seen.add(key);
|
|
112
|
+
guests.push({ address: attendee.address, name: attendee.displayName || attendee.address, you: false });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return [{ address: organizerAddress, name: "You", you: true }, ...guests].slice(0, FREE_BUSY_MAX_ADDRESSES);
|
|
116
|
+
}, [values.attendees, organizerAddress]);
|
|
117
|
+
const guestCount = rows.length - 1;
|
|
118
|
+
const addresses = rows.map((row) => row.address);
|
|
119
|
+
const addressesKey = addresses.map((address) => address.toLowerCase()).join(",");
|
|
120
|
+
|
|
121
|
+
const rangeStart = dayStartMs(dayKey, formZone, deviceZone);
|
|
122
|
+
const rangeEnd = dayStartMs(addDaysToKey(dayKey, SEARCH_DAYS), formZone, deviceZone);
|
|
123
|
+
const eventStart = allDay ? Number.NaN : wallStringToMs(start, formZone, deviceZone);
|
|
124
|
+
const eventEnd = allDay ? Number.NaN : wallStringToMs(end, formZone, deviceZone);
|
|
125
|
+
const timeIsValid = eventEnd > eventStart;
|
|
126
|
+
|
|
127
|
+
const [lookUp, setLookUp] = useState<LookUp>({ status: "loading", people: [] });
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (allDay || guestCount === 0 || Number.isNaN(rangeStart) || Number.isNaN(rangeEnd)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Each run of this effect owns its answer: once the day or the guests change (or this unmounts) a slower earlier look-up is ignored.
|
|
133
|
+
let current = true;
|
|
134
|
+
setLookUp((previous) => ({ status: "loading", people: previous.people }));
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
getFreeBusy(addresses, new Date(rangeStart), new Date(rangeEnd)).then(
|
|
137
|
+
(response) => current && setLookUp({ status: "ready", people: availabilityOf(response) }),
|
|
138
|
+
(err: unknown) => current && setLookUp({ status: "error", people: [], message: freeBusyErrorMessage(err) }),
|
|
139
|
+
);
|
|
140
|
+
}, FIND_TIME_DEBOUNCE_MS);
|
|
141
|
+
return () => {
|
|
142
|
+
current = false;
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
};
|
|
145
|
+
// `addresses` is what `addressesKey` says.
|
|
146
|
+
}, [addressesKey, rangeStart, rangeEnd, allDay, retry]);
|
|
147
|
+
|
|
148
|
+
// The event's own time, when it is being rescheduled, is on the calendars of the people already invited: not their conflict with themselves.
|
|
149
|
+
const people = useMemo(() => {
|
|
150
|
+
if (!occurrence || occurrence.allDay) {
|
|
151
|
+
return lookUp.people;
|
|
152
|
+
}
|
|
153
|
+
const invited = new Set([occurrence.organizer.address, ...occurrence.attendees.map((attendee) => attendee.address), organizerAddress].map((a) => a.toLowerCase()));
|
|
154
|
+
return withoutOwnBlock(lookUp.people, invited, Date.parse(occurrence.startDate), Date.parse(occurrence.endDate));
|
|
155
|
+
}, [lookUp.people, occurrence, organizerAddress]);
|
|
156
|
+
|
|
157
|
+
// Working hours scrolled into view, or the event's own start when that lies outside them.
|
|
158
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
159
|
+
useLayoutEffect(() => {
|
|
160
|
+
if (!scroller.current) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const startHour = timeIsValid && msToWallString(eventStart, formZone, deviceZone).slice(0, 10) === dayKey ? Number(msToWallString(eventStart, formZone, deviceZone).slice(11, 13)) : undefined;
|
|
164
|
+
const firstHour = startHour !== undefined && (startHour < WORK_START_HOUR || startHour >= WORK_END_HOUR - 3) ? Math.max(0, startHour - 1) : WORK_START_HOUR;
|
|
165
|
+
scroller.current.scrollLeft = firstHour * HOUR_WIDTH;
|
|
166
|
+
// Only a change of day moves the view: moving the event within it must not.
|
|
167
|
+
}, [dayKey, guestCount > 0 && !allDay]);
|
|
168
|
+
|
|
169
|
+
function startAt(minutes: number) {
|
|
170
|
+
c.setStart(`${dayKey}T${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function handleTimelineClick(event: MouseEvent<HTMLDivElement>) {
|
|
174
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
175
|
+
if (box.width > 0) {
|
|
176
|
+
// Half hours: the smallest step of the form's own time inputs is a minute, but a row is only 52px an hour wide.
|
|
177
|
+
startAt(Math.min(47, Math.max(0, Math.floor(((event.clientX - box.left) / box.width) * 48))) * 30);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (allDay) {
|
|
182
|
+
return <p className="text-sm text-text-muted">Finding a time works for an event with a start and end time. Uncheck All day to see when your guests are free.</p>;
|
|
183
|
+
}
|
|
184
|
+
if (guestCount === 0) {
|
|
185
|
+
return <p className="text-sm text-text-muted">Add guests to see when they are free.</p>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const dayLabel = format(localDateFromKey(dayKey), "EEEE, MMMM d");
|
|
189
|
+
const proposedFrom = timeIsValid ? minuteOfDay(eventStart, dayKey, formZone, deviceZone) : 0;
|
|
190
|
+
const proposedTo = timeIsValid ? minuteOfDay(eventEnd, dayKey, formZone, deviceZone) : 0;
|
|
191
|
+
const showProposed = timeIsValid && proposedTo > proposedFrom;
|
|
192
|
+
const covered = timeIsValid && eventStart >= rangeStart && eventEnd <= rangeEnd;
|
|
193
|
+
const answered = lookUp.status === "ready";
|
|
194
|
+
const byAddress = new Map(people.map((person) => [person.address.toLowerCase(), person]));
|
|
195
|
+
|
|
196
|
+
const summary = answered && covered ? summarizeAvailability(people, eventStart, eventEnd) : undefined;
|
|
197
|
+
const suggestions =
|
|
198
|
+
answered && timeIsValid
|
|
199
|
+
? suggestTimes(people, {
|
|
200
|
+
windows: workingWindows(dayKey, formZone, deviceZone),
|
|
201
|
+
durationMs: eventEnd - eventStart,
|
|
202
|
+
notBeforeMs: Date.now(),
|
|
203
|
+
})
|
|
204
|
+
: [];
|
|
205
|
+
const seen = people.filter((person) => person.status === "available").length;
|
|
206
|
+
const unseen = people.length - seen;
|
|
207
|
+
|
|
208
|
+
/** What a screen reader hears of a person whose calendar was read: their busy times on the day shown. */
|
|
209
|
+
function busyDescription(person: PersonAvailability): string {
|
|
210
|
+
const dayEnd = dayStartMs(addDaysToKey(dayKey, 1), formZone, deviceZone);
|
|
211
|
+
const blocks = person.busy.filter((block) => block.startMs < dayEnd && block.endMs > rangeStart);
|
|
212
|
+
return blocks.length === 0
|
|
213
|
+
? "Free all day."
|
|
214
|
+
: blocks
|
|
215
|
+
.map((block) => `${block.tentative ? "Tentatively busy" : "Busy"} ${clockLabel(block.startMs, formZone, deviceZone)} to ${clockLabel(block.endMs, formZone, deviceZone)}`)
|
|
216
|
+
.join(", ");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div className="flex flex-col gap-3 min-w-0">
|
|
221
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
aria-label="Previous day"
|
|
225
|
+
onClick={() => setDayKey((key) => addDaysToKey(key, -1))}
|
|
226
|
+
className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
227
|
+
>
|
|
228
|
+
‹
|
|
229
|
+
</button>
|
|
230
|
+
<span className="text-sm font-medium min-w-[9rem] text-center">{dayLabel}</span>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
aria-label="Next day"
|
|
234
|
+
onClick={() => setDayKey((key) => addDaysToKey(key, 1))}
|
|
235
|
+
className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
236
|
+
>
|
|
237
|
+
›
|
|
238
|
+
</button>
|
|
239
|
+
<span className="text-xs text-text-muted">{formZone.replace(/_/g, " ")}</span>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{lookUp.status === "error" && (
|
|
243
|
+
<div role="alert" className="flex flex-wrap items-center gap-3 text-sm text-danger">
|
|
244
|
+
<span>{lookUp.message}</span>
|
|
245
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setRetry((count) => count + 1)}>
|
|
246
|
+
Try again
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
<div ref={scroller} role="group" aria-label="Guests' availability" aria-busy={lookUp.status === "loading"} className="overflow-x-auto border border-border rounded-md">
|
|
252
|
+
<div className="relative" style={{ width: NAME_WIDTH + TIMELINE_WIDTH }}>
|
|
253
|
+
<div className="flex bg-surface border-b border-border" style={{ height: HEADER_HEIGHT }}>
|
|
254
|
+
<div className="sticky left-0 z-20 shrink-0 bg-surface border-r border-border" style={{ width: NAME_WIDTH }} />
|
|
255
|
+
{HOURS.map((hour) => (
|
|
256
|
+
<button
|
|
257
|
+
key={hour}
|
|
258
|
+
type="button"
|
|
259
|
+
aria-label={`Start at ${hourLabel(hour)}`}
|
|
260
|
+
title={`Start at ${hourLabel(hour)}`}
|
|
261
|
+
onClick={() => startAt(hour * 60)}
|
|
262
|
+
className="shrink-0 text-[10px] text-text-muted hover:bg-surface-alt hover:text-text text-left pl-1"
|
|
263
|
+
style={{ width: HOUR_WIDTH }}
|
|
264
|
+
>
|
|
265
|
+
{hourLabel(hour)}
|
|
266
|
+
</button>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{rows.map((row) => {
|
|
271
|
+
const person = byAddress.get(row.address.toLowerCase());
|
|
272
|
+
const state = person && timeIsValid && covered ? availabilityDuring(person, eventStart, eventEnd) : undefined;
|
|
273
|
+
const hidden = person && person.status !== "available" ? person : undefined;
|
|
274
|
+
return (
|
|
275
|
+
<div key={row.address.toLowerCase()} className="flex border-b border-border last:border-b-0" style={{ height: ROW_HEIGHT }}>
|
|
276
|
+
<div className="sticky left-0 z-10 shrink-0 flex items-center gap-2 px-2 bg-surface border-r border-border" style={{ width: NAME_WIDTH }}>
|
|
277
|
+
<span aria-hidden="true" className="w-6 h-6 shrink-0 rounded-full bg-primary/15 text-primary-dark text-[11px] font-semibold flex items-center justify-center uppercase">
|
|
278
|
+
{row.name.charAt(0)}
|
|
279
|
+
</span>
|
|
280
|
+
<div className="min-w-0">
|
|
281
|
+
<div className="text-xs font-medium truncate">{row.name}</div>
|
|
282
|
+
<div className="text-[10px] text-text-muted truncate">{stateText(state, person?.status)}</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
<div
|
|
286
|
+
role="img"
|
|
287
|
+
aria-label={`${row.name}: ${hidden ? stateText(undefined, hidden.status) : person ? busyDescription(person) : "Checking availability"}`}
|
|
288
|
+
onClick={handleTimelineClick}
|
|
289
|
+
className={["relative shrink-0 cursor-pointer", !person && lookUp.status === "loading" ? "animate-pulse bg-surface-alt" : ""].join(" ")}
|
|
290
|
+
style={{ width: TIMELINE_WIDTH, backgroundImage: GRID_LINES }}
|
|
291
|
+
>
|
|
292
|
+
<div aria-hidden="true" className="absolute inset-y-0 left-0 bg-surface-alt/70" style={{ width: WORK_START_HOUR * HOUR_WIDTH }} />
|
|
293
|
+
<div aria-hidden="true" className="absolute inset-y-0 right-0 bg-surface-alt/70" style={{ width: (24 - WORK_END_HOUR) * HOUR_WIDTH }} />
|
|
294
|
+
{hidden && (
|
|
295
|
+
<>
|
|
296
|
+
<div aria-hidden="true" className="absolute inset-0 opacity-30" style={{ backgroundImage: HATCH }} />
|
|
297
|
+
<span aria-hidden="true" className="sticky left-2 inline-block px-2 mt-3 text-[11px] text-text bg-surface rounded-sm">
|
|
298
|
+
{stateText(undefined, hidden.status)}
|
|
299
|
+
</span>
|
|
300
|
+
</>
|
|
301
|
+
)}
|
|
302
|
+
{person?.status === "available" &&
|
|
303
|
+
person.busy.map((block, index) => {
|
|
304
|
+
const from = minuteOfDay(block.startMs, dayKey, formZone, deviceZone);
|
|
305
|
+
const to = minuteOfDay(block.endMs, dayKey, formZone, deviceZone);
|
|
306
|
+
return to > from ? (
|
|
307
|
+
<div
|
|
308
|
+
key={index}
|
|
309
|
+
aria-hidden="true"
|
|
310
|
+
data-busy={block.tentative ? "tentative" : "busy"}
|
|
311
|
+
className={["absolute top-2 bottom-2 rounded-sm border border-danger", block.tentative ? "" : "bg-danger/60"].join(" ")}
|
|
312
|
+
style={{ left: (from / 60) * HOUR_WIDTH, width: ((to - from) / 60) * HOUR_WIDTH, backgroundImage: block.tentative ? TENTATIVE_HATCH : undefined }}
|
|
313
|
+
/>
|
|
314
|
+
) : null;
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
})}
|
|
320
|
+
|
|
321
|
+
{showProposed && (
|
|
322
|
+
<div
|
|
323
|
+
aria-hidden="true"
|
|
324
|
+
data-proposed-time=""
|
|
325
|
+
className="absolute border-x-2 border-primary bg-primary/10 pointer-events-none z-[5]"
|
|
326
|
+
style={{
|
|
327
|
+
top: HEADER_HEIGHT,
|
|
328
|
+
bottom: 0,
|
|
329
|
+
left: NAME_WIDTH + (proposedFrom / 60) * HOUR_WIDTH,
|
|
330
|
+
width: ((proposedTo - proposedFrom) / 60) * HOUR_WIDTH,
|
|
331
|
+
}}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div role="status" aria-live="polite" className="text-sm">
|
|
338
|
+
{lookUp.status === "loading" && "Checking availability…"}
|
|
339
|
+
{lookUp.status === "ready" && !timeIsValid && "Set a start and an end time to see who is free."}
|
|
340
|
+
{lookUp.status === "ready" && timeIsValid && !covered && "Pick a time within this week to see who is free."}
|
|
341
|
+
{summary && <span className={summary.conflicts > 0 ? "font-medium text-danger" : "font-medium"}>{summaryLine(summary)}</span>}
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{answered && timeIsValid && (
|
|
345
|
+
<section aria-labelledby="find-time-suggestions" className="flex flex-col gap-1.5">
|
|
346
|
+
<h4 id="find-time-suggestions" className="text-xs font-bold uppercase tracking-wide text-text-muted">
|
|
347
|
+
Suggested times
|
|
348
|
+
</h4>
|
|
349
|
+
{suggestions.length > 0 ? (
|
|
350
|
+
<ul className="flex flex-col gap-1">
|
|
351
|
+
{suggestions.map((ms) => {
|
|
352
|
+
const startWall = msToWallString(ms, formZone, deviceZone);
|
|
353
|
+
const endWall = msToWallString(ms + (eventEnd - eventStart), formZone, deviceZone);
|
|
354
|
+
return (
|
|
355
|
+
<li key={ms}>
|
|
356
|
+
<button type="button" onClick={() => c.setStart(startWall)} className="text-sm text-primary-dark hover:underline text-left">
|
|
357
|
+
{formatFormWhen(startWall, endWall, false)}
|
|
358
|
+
</button>
|
|
359
|
+
</li>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</ul>
|
|
363
|
+
) : (
|
|
364
|
+
<p className="text-sm text-text-muted">
|
|
365
|
+
{seen === 0
|
|
366
|
+
? "There is no calendar to check, so no time can be suggested."
|
|
367
|
+
: `No time this week has everyone free between ${hourLabel(WORK_START_HOUR)} and ${hourLabel(WORK_END_HOUR)}.`}
|
|
368
|
+
</p>
|
|
369
|
+
)}
|
|
370
|
+
{unseen > 0 && (
|
|
371
|
+
<p className="text-xs text-text-muted">
|
|
372
|
+
Suggestions leave out {unseen} {unseen === 1 ? "person whose calendar" : "people whose calendars"} can’t be seen.
|
|
373
|
+
</p>
|
|
374
|
+
)}
|
|
375
|
+
</section>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ComponentType, useEffect, useState } from "react";
|
|
6
|
+
import type { DescriptionEditorProps } from "./DescriptionEditor.js";
|
|
7
|
+
|
|
8
|
+
let loading: Promise<ComponentType<DescriptionEditorProps>> | undefined;
|
|
9
|
+
/** The editor once its chunk has arrived, so an editor drawn again (the quick popover growing into the card) starts as the editor, not as the placeholder. */
|
|
10
|
+
let loaded: ComponentType<DescriptionEditorProps> | undefined;
|
|
11
|
+
|
|
12
|
+
/** Fetches the editor's chunk once. A failed download is forgotten, so asking again tries again. */
|
|
13
|
+
function loadDescriptionEditor(): Promise<ComponentType<DescriptionEditorProps>> {
|
|
14
|
+
loading ??= import("./DescriptionEditor.js").then(
|
|
15
|
+
(module) => (loaded = module.default),
|
|
16
|
+
(err: unknown) => {
|
|
17
|
+
loading = undefined;
|
|
18
|
+
throw err;
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
return loading;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `DescriptionEditor`, loaded when it is first drawn. The editor is TipTap and ProseMirror - a good deal of code that the calendar, and the reading pane's
|
|
26
|
+
* invitation card, only need once somebody opens a description - so it is fetched then rather than with the page. Not `React.lazy()`: a chunk that fails to
|
|
27
|
+
* download (offline, or a deploy that replaced it) is said so here with a retry, instead of throwing through the tree above.
|
|
28
|
+
*/
|
|
29
|
+
export default function LazyDescriptionEditor(props: DescriptionEditorProps) {
|
|
30
|
+
const [Editor, setEditor] = useState<ComponentType<DescriptionEditorProps> | null>(() => loaded ?? null);
|
|
31
|
+
const [failed, setFailed] = useState(false);
|
|
32
|
+
const [attempt, setAttempt] = useState(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let current = true;
|
|
36
|
+
loadDescriptionEditor().then(
|
|
37
|
+
(component) => current && setEditor(() => component),
|
|
38
|
+
() => current && setFailed(true),
|
|
39
|
+
);
|
|
40
|
+
return () => {
|
|
41
|
+
current = false;
|
|
42
|
+
};
|
|
43
|
+
}, [attempt]);
|
|
44
|
+
|
|
45
|
+
if (Editor) {
|
|
46
|
+
return <Editor {...props} />;
|
|
47
|
+
}
|
|
48
|
+
if (failed) {
|
|
49
|
+
return (
|
|
50
|
+
<p role="alert" className="text-sm text-danger">
|
|
51
|
+
The description editor couldn’t be loaded.{" "}
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="underline"
|
|
55
|
+
onClick={() => {
|
|
56
|
+
setFailed(false);
|
|
57
|
+
setAttempt((count) => count + 1);
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
Try again
|
|
61
|
+
</button>
|
|
62
|
+
</p>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<div aria-busy="true" className="min-h-[7rem] rounded-md border border-border bg-surface-alt text-sm text-text-muted px-3 py-2">
|
|
67
|
+
Loading the editor…
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
7
|
-
import { eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
|
|
7
|
+
import { addDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
|
|
8
8
|
import { dayDropId, eventDragId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
9
9
|
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
10
10
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
11
|
+
import { EventAnchor, anchorOf } from "./EventShell.js";
|
|
11
12
|
|
|
12
13
|
const MAX_CHIPS_PER_DAY = 3;
|
|
13
14
|
|
|
@@ -20,11 +21,14 @@ export interface MonthViewProps {
|
|
|
20
21
|
folderColors: Record<string, string>;
|
|
21
22
|
onSelectDay: (date: Date) => void;
|
|
22
23
|
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
24
|
+
/** Click on the empty part of a day: the day (from midnight to the next midnight) and the cell, for a new all-day event's quick-create
|
|
25
|
+
* popover to open beside. */
|
|
26
|
+
onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
|
|
26
30
|
* events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
|
|
27
|
-
export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent }: MonthViewProps) {
|
|
31
|
+
export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps) {
|
|
28
32
|
const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
|
|
29
33
|
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
|
|
30
34
|
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
@@ -40,6 +44,7 @@ export default function MonthView({ viewDate, occurrences, folderColors, onSelec
|
|
|
40
44
|
folderColors={folderColors}
|
|
41
45
|
onSelectDay={onSelectDay}
|
|
42
46
|
onSelectEvent={onSelectEvent}
|
|
47
|
+
onSelectSlot={onSelectSlot}
|
|
43
48
|
/>
|
|
44
49
|
))}
|
|
45
50
|
</div>
|
|
@@ -53,9 +58,10 @@ interface DayCellProps {
|
|
|
53
58
|
folderColors: Record<string, string>;
|
|
54
59
|
onSelectDay: (date: Date) => void;
|
|
55
60
|
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
61
|
+
onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent }: DayCellProps) {
|
|
64
|
+
function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: DayCellProps) {
|
|
59
65
|
const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
|
|
60
66
|
const visible = occurrences.slice(0, MAX_CHIPS_PER_DAY);
|
|
61
67
|
const overflowCount = occurrences.length - visible.length;
|
|
@@ -63,6 +69,12 @@ function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay,
|
|
|
63
69
|
return (
|
|
64
70
|
<div
|
|
65
71
|
ref={setNodeRef}
|
|
72
|
+
onClick={(e) => {
|
|
73
|
+
// Only the empty part of the cell: the day number, the chips and "+N more" have their own clicks.
|
|
74
|
+
if (onSelectSlot && !(e.target as Element).closest("button")) {
|
|
75
|
+
onSelectSlot(day, addDays(day, 1), anchorOf(e.currentTarget));
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
66
78
|
className={[
|
|
67
79
|
"border-b border-r border-border p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden",
|
|
68
80
|
isOver ? "bg-primary/5" : "",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
|
|
7
|
+
import AppointmentQuickForm, { initialAppointmentDraft } from "./AppointmentQuickForm.js";
|
|
8
|
+
import EventExpandedForm from "./EventExpandedForm.js";
|
|
9
|
+
import EventQuickForm from "./EventQuickForm.js";
|
|
10
|
+
import { EventFormController } from "./eventForm.js";
|
|
11
|
+
import QuickCreateTabs, { QuickTab, quickPanelProps, quickTabId } from "./QuickCreateTabs.js";
|
|
12
|
+
import TaskQuickForm, { initialTaskDraft } from "./TaskQuickForm.js";
|
|
13
|
+
|
|
14
|
+
/** What `EventModal` tells the editor about the tabs of a new event's popover. */
|
|
15
|
+
export interface QuickCreateConfig {
|
|
16
|
+
tab: QuickTab;
|
|
17
|
+
onTabChange: (tab: QuickTab) => void;
|
|
18
|
+
/** Where the booking plugin's Settings pages are, or `undefined` when the plugin is not running (and there is no Appointment schedule tab). */
|
|
19
|
+
bookingHref?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QuickCreateFacesProps {
|
|
23
|
+
/** The Event tab's form, which owns the title and the mailbox and calendar the other tabs share. */
|
|
24
|
+
c: EventFormController;
|
|
25
|
+
layout: "quick" | "expanded";
|
|
26
|
+
onExpand: () => void;
|
|
27
|
+
config: QuickCreateConfig;
|
|
28
|
+
dirtyRef: MutableRefObject<boolean>;
|
|
29
|
+
/** The signed-in mailbox's address: the host's name on booking pages when its display name is not known. */
|
|
30
|
+
organizerAddress: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The faces of a new event, one for each tab: Event (the quick popover or, after More options, the full card - unchanged), Task and
|
|
35
|
+
* Appointment schedule. Stays mounted while the tab changes, so it is what keeps the other tabs' fields (the Event tab's live in
|
|
36
|
+
* `EventEditor`) when the user looks at another one and comes back. The title, the mailbox and the calendar are the Event tab's, so they
|
|
37
|
+
* are shared by all three.
|
|
38
|
+
*
|
|
39
|
+
* The tab strip is redrawn by whichever face is showing, so a tab change moves keyboard focus back to the selected tab here - what an
|
|
40
|
+
* arrow-key walk along the strip needs.
|
|
41
|
+
*/
|
|
42
|
+
export default function QuickCreateFaces({ c, layout, onExpand, config, dirtyRef, organizerAddress }: QuickCreateFacesProps) {
|
|
43
|
+
const { tab, onTabChange, bookingHref } = config;
|
|
44
|
+
const [task, setTask] = useState(() => initialTaskDraft(c.values.start, c.values.allDay));
|
|
45
|
+
const [appointment, setAppointment] = useState(initialAppointmentDraft);
|
|
46
|
+
|
|
47
|
+
const shownTab = useRef(tab);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (shownTab.current !== tab) {
|
|
50
|
+
shownTab.current = tab;
|
|
51
|
+
document.getElementById(quickTabId(tab))?.focus();
|
|
52
|
+
}
|
|
53
|
+
}, [tab]);
|
|
54
|
+
|
|
55
|
+
// Notes typed on the Task tab are worth a second thought before a stray click closes the popover, like a title.
|
|
56
|
+
dirtyRef.current = dirtyRef.current || task.body.trim() !== "";
|
|
57
|
+
|
|
58
|
+
const tabs = <QuickCreateTabs tab={tab} onChange={onTabChange} showAppointment={!!bookingHref} />;
|
|
59
|
+
|
|
60
|
+
if (tab === "task") {
|
|
61
|
+
return (
|
|
62
|
+
<TaskQuickForm
|
|
63
|
+
c={c}
|
|
64
|
+
draft={task}
|
|
65
|
+
onDraftChange={(patch) => setTask((prev) => ({ ...prev, ...patch }))}
|
|
66
|
+
tabs={tabs}
|
|
67
|
+
expanded={layout === "expanded"}
|
|
68
|
+
onExpand={onExpand}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (tab === "appointment") {
|
|
73
|
+
const hostMailbox = c.mailboxOptions?.find((option) => option.mailbox.uid === c.values.targetMailboxUid)?.mailbox;
|
|
74
|
+
return (
|
|
75
|
+
<AppointmentQuickForm
|
|
76
|
+
c={c}
|
|
77
|
+
draft={appointment}
|
|
78
|
+
onDraftChange={(patch) => setAppointment((prev) => ({ ...prev, ...patch }))}
|
|
79
|
+
tabs={tabs}
|
|
80
|
+
settingsHref={bookingHref!}
|
|
81
|
+
hostName={hostMailbox?.displayName ?? organizerAddress}
|
|
82
|
+
timeZone={hostMailbox?.timezone ?? deviceTimeZone()}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return layout === "quick" ? <EventQuickForm c={c} onExpand={onExpand} tabs={tabs} tabPanelProps={quickPanelProps("event")} /> : <EventExpandedForm c={c} />;
|
|
87
|
+
}
|