@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,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/** What the New event popover can create. */
|
|
3
|
+
export type QuickTab = "event" | "task" | "appointment";
|
|
4
|
+
/** The DOM ids that tie each tab to its panel (`aria-controls` / `aria-labelledby`). */
|
|
5
|
+
export declare function quickTabId(tab: QuickTab): string;
|
|
6
|
+
export declare function quickPanelId(tab: QuickTab): string;
|
|
7
|
+
/** The attributes of the element holding the selected tab's content. */
|
|
8
|
+
export declare function quickPanelProps(tab: QuickTab): {
|
|
9
|
+
role: "tabpanel";
|
|
10
|
+
id: string;
|
|
11
|
+
"aria-labelledby": string;
|
|
12
|
+
};
|
|
13
|
+
export interface QuickCreateTabsProps {
|
|
14
|
+
tab: QuickTab;
|
|
15
|
+
onChange: (tab: QuickTab) => void;
|
|
16
|
+
/** Whether the booking plugin is running: without it there is no Appointment schedule tab. */
|
|
17
|
+
showAppointment: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The segmented control across the top of the New event popover: Event, Task and (when the booking plugin is running) Appointment schedule.
|
|
21
|
+
* A WAI-ARIA tab list with automatic activation: only the selected tab is in the Tab order, and the arrow keys (wrapping), Home and End
|
|
22
|
+
* move to and select another.
|
|
23
|
+
*/
|
|
24
|
+
export default function QuickCreateTabs({ tab, onChange, showAppointment }: QuickCreateTabsProps): React.JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
const TAB_LABELS = { event: "Event", task: "Task", appointment: "Appointment schedule" };
|
|
3
|
+
/** The DOM ids that tie each tab to its panel (`aria-controls` / `aria-labelledby`). */
|
|
4
|
+
export function quickTabId(tab) {
|
|
5
|
+
return `quick-create-tab-${tab}`;
|
|
6
|
+
}
|
|
7
|
+
export function quickPanelId(tab) {
|
|
8
|
+
return `quick-create-panel-${tab}`;
|
|
9
|
+
}
|
|
10
|
+
/** The attributes of the element holding the selected tab's content. */
|
|
11
|
+
export function quickPanelProps(tab) {
|
|
12
|
+
return { role: "tabpanel", id: quickPanelId(tab), "aria-labelledby": quickTabId(tab) };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The segmented control across the top of the New event popover: Event, Task and (when the booking plugin is running) Appointment schedule.
|
|
16
|
+
* A WAI-ARIA tab list with automatic activation: only the selected tab is in the Tab order, and the arrow keys (wrapping), Home and End
|
|
17
|
+
* move to and select another.
|
|
18
|
+
*/
|
|
19
|
+
export default function QuickCreateTabs({ tab, onChange, showAppointment }) {
|
|
20
|
+
const tabs = showAppointment ? ["event", "task", "appointment"] : ["event", "task"];
|
|
21
|
+
function handleKeyDown(event) {
|
|
22
|
+
const index = tabs.indexOf(tab);
|
|
23
|
+
let next;
|
|
24
|
+
if (event.key === "ArrowRight") {
|
|
25
|
+
next = tabs[(index + 1) % tabs.length];
|
|
26
|
+
}
|
|
27
|
+
else if (event.key === "ArrowLeft") {
|
|
28
|
+
next = tabs[(index + tabs.length - 1) % tabs.length];
|
|
29
|
+
}
|
|
30
|
+
else if (event.key === "Home") {
|
|
31
|
+
next = tabs[0];
|
|
32
|
+
}
|
|
33
|
+
else if (event.key === "End") {
|
|
34
|
+
next = tabs[tabs.length - 1];
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
onChange(next);
|
|
41
|
+
}
|
|
42
|
+
return (_jsx("div", { role: "tablist", "aria-label": "What to create", className: "mx-5 mb-3 flex gap-1 rounded-lg bg-surface-alt p-1", children: tabs.map((id) => (_jsx("button", { type: "button", role: "tab", id: quickTabId(id), "aria-selected": id === tab, "aria-controls": quickPanelId(id), tabIndex: id === tab ? 0 : -1, onClick: () => onChange(id), onKeyDown: handleKeyDown, className: [
|
|
43
|
+
"flex-1 min-w-0 rounded-md px-2 py-1.5 text-sm font-medium truncate focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
44
|
+
id === tab ? "bg-surface text-text shadow-sm" : "text-text-muted hover:text-text",
|
|
45
|
+
].join(" "), children: TAB_LABELS[id] }, id))) }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React, { FormEvent, ReactNode } from "react";
|
|
2
|
+
import { EventFormController } from "./eventForm.js";
|
|
3
|
+
import { QuickTab } from "./QuickCreateTabs.js";
|
|
4
|
+
export interface QuickFaceFrameProps {
|
|
5
|
+
c: EventFormController;
|
|
6
|
+
/** The `QuickCreateTabs` strip. */
|
|
7
|
+
tabs: ReactNode;
|
|
8
|
+
/** The tab this face is the panel of. */
|
|
9
|
+
tab: QuickTab;
|
|
10
|
+
error: string | null;
|
|
11
|
+
/** Leave the title out (the Appointment schedule tab once it has saved). */
|
|
12
|
+
hideTitle?: boolean;
|
|
13
|
+
/** Enter in a field, or the Save button. */
|
|
14
|
+
onSubmit: (event: FormEvent) => void;
|
|
15
|
+
/** What sits in the tab's panel under the title. */
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
/** The buttons along the bottom (More options, Save). */
|
|
18
|
+
footer: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The frame the Task and Appointment schedule faces share, drawn the way `EventQuickForm` draws the Event face: a grip to drag the popover
|
|
22
|
+
* and a close button, the tab strip, then the panel - an error, the title (the one the Event tab holds, so switching tabs keeps it) and the
|
|
23
|
+
* face's own rows - and the buttons.
|
|
24
|
+
*/
|
|
25
|
+
export default function QuickFaceFrame({ c, tabs, tab, error, hideTitle, onSubmit, children, footer }: QuickFaceFrameProps): React.JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useContext } from "react";
|
|
7
|
+
import { HiOutlineBars2, HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import { EventShellContext } from "./EventShell.js";
|
|
10
|
+
import { quickPanelProps } from "./QuickCreateTabs.js";
|
|
11
|
+
/**
|
|
12
|
+
* The frame the Task and Appointment schedule faces share, drawn the way `EventQuickForm` draws the Event face: a grip to drag the popover
|
|
13
|
+
* and a close button, the tab strip, then the panel - an error, the title (the one the Event tab holds, so switching tabs keeps it) and the
|
|
14
|
+
* face's own rows - and the buttons.
|
|
15
|
+
*/
|
|
16
|
+
export default function QuickFaceFrame({ c, tabs, tab, error, hideTitle, onSubmit, children, footer }) {
|
|
17
|
+
const { dragHandleProps } = useContext(EventShellContext);
|
|
18
|
+
return (_jsxs("form", { onSubmit: onSubmit, className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-2 pt-2", children: [dragHandleProps ? (_jsx("div", { ...dragHandleProps, "aria-hidden": "true", className: "flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none", children: _jsx(HiOutlineBars2, { size: 20 }) })) : (_jsx("div", { className: "flex-1" })), _jsx("button", { type: "button", "aria-label": "Close", onClick: c.onCancel, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), tabs, _jsxs("div", { ...quickPanelProps(tab), className: "flex flex-col gap-3 px-5 pb-3", children: [error && _jsx(Alert, { children: error }), !hideTitle && (_jsx("div", { className: "pl-8", children: _jsx("input", { type: "text", "aria-label": "Title", placeholder: "Add title", "data-autofocus": true, className: "w-full text-xl bg-transparent text-text border-0 border-b-2 border-primary/50 focus:border-primary focus:outline-none pb-1 placeholder:text-text-muted", value: c.values.title, onChange: (e) => c.update({ title: e.target.value }) }) })), children] }), _jsx("div", { className: "flex items-center justify-end gap-2 px-4 py-3", children: footer })] }));
|
|
19
|
+
}
|
|
@@ -10,6 +10,9 @@ export interface RecurrenceEditorProps {
|
|
|
10
10
|
/** The weekday the event starts on (in the frame its series expands in - see `allDay.ts`'s
|
|
11
11
|
* `startWeekdayCode()`): the day a new weekly rule, or one switched to Weekly, repeats on. Defaults to Monday. */
|
|
12
12
|
startWeekday?: WeekdayCode;
|
|
13
|
+
/** Leaves out the "Repeats" checkbox, for a caller that already turns repeating on and off itself (the event form's
|
|
14
|
+
* "Does not repeat" menu): what remains is the rule's details, shown only while there is a rule. */
|
|
15
|
+
hideToggle?: boolean;
|
|
13
16
|
}
|
|
14
17
|
/**
|
|
15
18
|
* A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
|
|
@@ -17,4 +20,4 @@ export interface RecurrenceEditorProps {
|
|
|
17
20
|
* live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
|
|
18
21
|
* `.toText()`), so it never drifts out of sync with what will actually be submitted.
|
|
19
22
|
*/
|
|
20
|
-
export default function RecurrenceEditor({ value, onChange, allDay, startWeekday }: RecurrenceEditorProps): React.JSX.Element;
|
|
23
|
+
export default function RecurrenceEditor({ value, onChange, allDay, startWeekday, hideToggle }: RecurrenceEditorProps): React.JSX.Element;
|
|
@@ -23,7 +23,7 @@ function endConditionOf(rule) {
|
|
|
23
23
|
* live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
|
|
24
24
|
* `.toText()`), so it never drifts out of sync with what will actually be submitted.
|
|
25
25
|
*/
|
|
26
|
-
export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO" }) {
|
|
26
|
+
export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO", hideToggle = false }) {
|
|
27
27
|
function handleEnable(enabled) {
|
|
28
28
|
onChange(enabled ? { freq: "weekly", interval: 1, byDay: [startWeekday], exceptions: [] } : null);
|
|
29
29
|
}
|
|
@@ -81,7 +81,7 @@ export default function RecurrenceEditor({ value, onChange, allDay = false, star
|
|
|
81
81
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw))
|
|
82
82
|
update({ until: recurrenceUntilInstant(raw, allDay) });
|
|
83
83
|
}
|
|
84
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: value !== null, onChange: (e) => handleEnable(e.target.checked) }), "Repeats"] }), value && (_jsxs("div", { className: "flex flex-col gap-3 pl-6 border-l-2 border-border", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("span", { children: "Every" }), _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.interval, onChange: (e) => handleInterval(e.target.value), "aria-label": "Recurrence interval" }), _jsxs("select", { className: SELECT_CLASS, value: value.freq, onChange: (e) => handleFrequency(e.target.value), "aria-label": "Recurrence frequency", children: [_jsx("option", { value: "daily", children: value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural }), _jsx("option", { value: "weekly", children: value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural }), _jsx("option", { value: "monthly", children: value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural }), _jsx("option", { value: "yearly", children: value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural })] })] }), value.freq === "weekly" && (_jsx("div", { className: "flex gap-1", children: WEEKDAY_CODES.map((day) => (_jsx("button", { type: "button", onClick: () => toggleDay(day), "aria-pressed": (value.byDay ?? []).includes(day), "aria-label": WEEKDAY_LABELS[day], className: [
|
|
84
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [!hideToggle && (_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: value !== null, onChange: (e) => handleEnable(e.target.checked) }), "Repeats"] })), value && (_jsxs("div", { className: "flex flex-col gap-3 pl-6 border-l-2 border-border", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("span", { children: "Every" }), _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.interval, onChange: (e) => handleInterval(e.target.value), "aria-label": "Recurrence interval" }), _jsxs("select", { className: SELECT_CLASS, value: value.freq, onChange: (e) => handleFrequency(e.target.value), "aria-label": "Recurrence frequency", children: [_jsx("option", { value: "daily", children: value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural }), _jsx("option", { value: "weekly", children: value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural }), _jsx("option", { value: "monthly", children: value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural }), _jsx("option", { value: "yearly", children: value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural })] })] }), value.freq === "weekly" && (_jsx("div", { className: "flex gap-1", children: WEEKDAY_CODES.map((day) => (_jsx("button", { type: "button", onClick: () => toggleDay(day), "aria-pressed": (value.byDay ?? []).includes(day), "aria-label": WEEKDAY_LABELS[day], className: [
|
|
85
85
|
"w-9 h-9 rounded-full text-xs font-semibold border",
|
|
86
86
|
(value.byDay ?? []).includes(day)
|
|
87
87
|
? "bg-primary text-white border-primary"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/** The event a guest asks about: what it says now (the form starts from it and sends only what differs) and which row of it the request is for. */
|
|
3
|
+
export interface ChangeSubject {
|
|
4
|
+
/** The event's uid on this mailbox (`CalendarEvent.uid`, or `MessageInvite.calendarEventUid`): the row the organizer's answer will come back to. */
|
|
5
|
+
uid: string;
|
|
6
|
+
title: string;
|
|
7
|
+
location?: string | null;
|
|
8
|
+
/** ISO 8601 instants; absent for an event whose time is not known. */
|
|
9
|
+
startDate?: string;
|
|
10
|
+
endDate?: string;
|
|
11
|
+
allDay: boolean;
|
|
12
|
+
/** Part of a series: a request changes the whole series (its own row), so its time - which is one occurrence's - is not offered. */
|
|
13
|
+
recurring: boolean;
|
|
14
|
+
description?: string | null;
|
|
15
|
+
descriptionHtml?: string | null;
|
|
16
|
+
/** Everyone already invited, whatever the case, so a guest is not added twice. */
|
|
17
|
+
guestAddresses: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface RequestChangeFormProps {
|
|
20
|
+
subject: ChangeSubject;
|
|
21
|
+
/** The organizer lets guests change the title, location, description and time (`guestsCanModify`). */
|
|
22
|
+
canChange: boolean;
|
|
23
|
+
/** The organizer lets guests add guests (`guestsCanInviteOthers`). */
|
|
24
|
+
canInvite: boolean;
|
|
25
|
+
/** The request was sent. */
|
|
26
|
+
onSent: () => void;
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A guest's request to the organizer, for an event whose organizer lets guests change it and/or invite others (`POST /calendar-events/:id/request-change`).
|
|
31
|
+
* Starts from the event as it is and sends only what the guest changed: the title, location, description and time (with `canChange`) and guests to add (with
|
|
32
|
+
* `canInvite`). Nothing changes on the guest's own calendar - the organizer's copy is updated (automatically, when the flags allow it) and the change
|
|
33
|
+
* comes back as the organizer's next update - so the form says so, and afterwards a "Your change was sent to the organizer" notification confirms it.
|
|
34
|
+
* A title or location can be changed, not cleared (the server refuses that), and the time of a series or an all-day event is not offered.
|
|
35
|
+
*/
|
|
36
|
+
export default function RequestChangeForm({ subject, canChange, canInvite, onSent, onCancel }: RequestChangeFormProps): React.JSX.Element;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import { requestEventChange } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
9
|
+
import { htmlToPlainText, sanitizeEventDescriptionHtml } from "@rapidmx/react-shared/calendar/eventDescription.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
13
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
14
|
+
import { notify } from "../../notifications/store.js";
|
|
15
|
+
import { INPUT_CLASS } from "./EventFormParts.js";
|
|
16
|
+
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
17
|
+
import { descriptionProblem, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
18
|
+
import { mergeGuests } from "./eventFormat.js";
|
|
19
|
+
const MINUTE_MS = 60000;
|
|
20
|
+
/** Whether two instants (ISO, or a form's local time) name the same minute. */
|
|
21
|
+
function sameMinute(a, b) {
|
|
22
|
+
return Math.round(new Date(a).getTime() / MINUTE_MS) === Math.round(new Date(b).getTime() / MINUTE_MS);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A guest's request to the organizer, for an event whose organizer lets guests change it and/or invite others (`POST /calendar-events/:id/request-change`).
|
|
26
|
+
* Starts from the event as it is and sends only what the guest changed: the title, location, description and time (with `canChange`) and guests to add (with
|
|
27
|
+
* `canInvite`). Nothing changes on the guest's own calendar - the organizer's copy is updated (automatically, when the flags allow it) and the change
|
|
28
|
+
* comes back as the organizer's next update - so the form says so, and afterwards a "Your change was sent to the organizer" notification confirms it.
|
|
29
|
+
* A title or location can be changed, not cleared (the server refuses that), and the time of a series or an all-day event is not offered.
|
|
30
|
+
*/
|
|
31
|
+
export default function RequestChangeForm({ subject, canChange, canInvite, onSent, onCancel }) {
|
|
32
|
+
const canChangeTime = canChange && !subject.allDay && !subject.recurring && !!subject.startDate && !!subject.endDate;
|
|
33
|
+
const [title, setTitle] = useState(subject.title);
|
|
34
|
+
const [location, setLocation] = useState(subject.location ?? "");
|
|
35
|
+
const [start, setStart] = useState(subject.startDate ? toDatetimeLocal(subject.startDate) : "");
|
|
36
|
+
const [end, setEnd] = useState(subject.endDate ? toDatetimeLocal(subject.endDate) : "");
|
|
37
|
+
const [descriptionHtml, setDescriptionHtml] = useState(() => initialDescriptionHtml(subject));
|
|
38
|
+
const [added, setAdded] = useState([]);
|
|
39
|
+
const [guestDraft, setGuestDraft] = useState("");
|
|
40
|
+
const [error, setError] = useState(null);
|
|
41
|
+
const [sending, setSending] = useState(false);
|
|
42
|
+
const heading = canChange ? "Request a change" : "Add guests";
|
|
43
|
+
/** Adds what was typed into the guests box, returning the guests as they now stand; a mistyped address stays in the box and is said. */
|
|
44
|
+
function addTypedGuests() {
|
|
45
|
+
const invited = [...subject.guestAddresses, ...added.map((guest) => guest.address)].map((address) => ({ address }));
|
|
46
|
+
const merged = mergeGuests(invited.map((guest) => ({ ...guest, role: "required", responseStatus: "needsAction", isOrganizer: false })), guestDraft);
|
|
47
|
+
if (merged.invalid.length > 0) {
|
|
48
|
+
setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
|
|
49
|
+
setGuestDraft(merged.invalid.join(" "));
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
setGuestDraft("");
|
|
53
|
+
return merged.attendees.slice(invited.length).map((guest) => ({ address: guest.address }));
|
|
54
|
+
}
|
|
55
|
+
function handleGuestKey(event) {
|
|
56
|
+
if (event.key === "Enter" || event.key === "," || event.key === ";") {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
setError(null);
|
|
59
|
+
const more = addTypedGuests();
|
|
60
|
+
if (more) {
|
|
61
|
+
setAdded((current) => [...current, ...more]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function handleSubmit(event) {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
setError(null);
|
|
68
|
+
const typed = canInvite ? addTypedGuests() : [];
|
|
69
|
+
if (!typed) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const guests = [...added, ...typed];
|
|
73
|
+
const request = {};
|
|
74
|
+
if (canChange) {
|
|
75
|
+
if (title.trim() !== subject.title.trim()) {
|
|
76
|
+
if (!title.trim()) {
|
|
77
|
+
setError("Enter a title, or leave it as it is.");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
request.title = title.trim();
|
|
81
|
+
}
|
|
82
|
+
if (location.trim() !== (subject.location ?? "").trim()) {
|
|
83
|
+
if (!location.trim()) {
|
|
84
|
+
setError("Enter a location, or leave it as it is.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
request.location = location.trim();
|
|
88
|
+
}
|
|
89
|
+
if (canChangeTime && (!sameMinute(start, subject.startDate) || !sameMinute(end, subject.endDate))) {
|
|
90
|
+
const startMs = new Date(start).getTime();
|
|
91
|
+
const endMs = new Date(end).getTime();
|
|
92
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) {
|
|
93
|
+
setError("The end time must be after the start time.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
request.startDate = new Date(startMs).toISOString();
|
|
97
|
+
request.endDate = new Date(endMs).toISOString();
|
|
98
|
+
}
|
|
99
|
+
const html = sanitizeEventDescriptionHtml(descriptionHtml);
|
|
100
|
+
if (html !== sanitizeEventDescriptionHtml(initialDescriptionHtml(subject)) && descriptionHtml !== initialDescriptionHtml(subject)) {
|
|
101
|
+
const plain = htmlToPlainText(html);
|
|
102
|
+
if (!plain) {
|
|
103
|
+
setError("Enter a description, or leave it as it is.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const tooLong = descriptionProblem(html);
|
|
107
|
+
if (tooLong) {
|
|
108
|
+
setError(tooLong);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
request.descriptionHtml = html;
|
|
112
|
+
request.description = plain;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (guests.length > 0) {
|
|
116
|
+
request.addAttendees = guests;
|
|
117
|
+
}
|
|
118
|
+
if (Object.keys(request).length === 0) {
|
|
119
|
+
setError("Change something first.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setSending(true);
|
|
123
|
+
try {
|
|
124
|
+
await requestEventChange(subject.uid, request);
|
|
125
|
+
notify({
|
|
126
|
+
kind: "success",
|
|
127
|
+
title: "Your change was sent to the organizer",
|
|
128
|
+
message: "The event on your calendar updates when the organizer's update comes back.",
|
|
129
|
+
});
|
|
130
|
+
onSent();
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
notifyApiError(err, "Couldn't send your change");
|
|
134
|
+
setSending(false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return (_jsxs("form", { onSubmit: (event) => void handleSubmit(event), "aria-label": heading, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: heading }), _jsx("button", { type: "button", "aria-label": "Cancel request", onClick: onCancel, className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Your request goes to the organizer. Nothing on your calendar changes until their update arrives; when the organizer allows it, it is applied for you." }), error && _jsx(Alert, { children: error }), canChange && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Title", _jsx("input", { type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Location", _jsx("input", { type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) })] }), canChangeTime && (_jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Starts", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: start, onChange: (e) => setStart(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Ends", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: end, onChange: (e) => setEnd(e.target.value) })] })] })), !canChangeTime && (_jsx("p", { className: "text-xs text-text-muted", children: subject.recurring ? "A change here applies to the whole series; its times can only be changed by the organizer." : "The time of this event can only be changed by the organizer." })), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-xs font-medium", children: "Description" }), _jsx(LazyDescriptionEditor, { value: descriptionHtml, onChange: setDescriptionHtml })] })] })), canInvite && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Guests to add", _jsx("input", { type: "text", className: INPUT_CLASS, placeholder: "name@example.com", value: guestDraft, onChange: (e) => setGuestDraft(e.target.value), onKeyDown: handleGuestKey })] }), added.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-1.5", children: added.map((guest, index) => (_jsxs("li", { className: "flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5", children: [_jsx("span", { className: "truncate max-w-[16rem]", children: guest.address }), _jsx("button", { type: "button", "aria-label": `Remove ${guest.address}`, onClick: () => setAdded((current) => current.filter((_, i) => i !== index)), className: "w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, guest.address))) }))] })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "submit", loading: sending, disabled: sending, className: "!w-auto", children: "Send request" }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: sending, onClick: onCancel, children: "Cancel" })] })] }));
|
|
138
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
3
|
+
import { EventAnchor } from "./EventShell.js";
|
|
3
4
|
export interface SplitDayColumn {
|
|
4
5
|
folderUid: string;
|
|
5
6
|
name: string;
|
|
@@ -12,8 +13,8 @@ export interface SplitDayViewProps {
|
|
|
12
13
|
* calendar; this component splits them into columns itself by `occurrence.folderUid`. */
|
|
13
14
|
occurrences: CalendarOccurrence[];
|
|
14
15
|
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
15
|
-
/** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid
|
|
16
|
-
onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
|
|
16
|
+
/** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid`; `anchor` is the slot. */
|
|
17
|
+
onSelectSlot: (start: Date, end: Date, folderUid: string, anchor: EventAnchor) => void;
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
20
|
* Outlook's "Split" view: one column per checked calendar, all showing the same single day, so
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { addDays, addMinutes, format, startOfDay } from "date-fns";
|
|
3
3
|
import { occursOnDay } from "./allDay.js";
|
|
4
|
+
import { anchorOf } from "./EventShell.js";
|
|
4
5
|
const HOUR_HEIGHT_PX = 48;
|
|
5
6
|
const SLOT_MINUTES = 30;
|
|
6
7
|
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
@@ -18,12 +19,12 @@ const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
|
18
19
|
export default function SplitDayView({ day, columns, occurrences, onSelectEvent, onSelectSlot }) {
|
|
19
20
|
const dayOccurrences = occurrences.filter((occ) => occursOnDay(occ, day));
|
|
20
21
|
const dayStart = startOfDay(day);
|
|
21
|
-
return (_jsxs("div", { role: "region", "aria-label": "Split view", className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), columns.map((col) => (_jsxs("div", { className: "flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border", children: [_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: col.color } }), _jsx("span", { className: "text-xs font-semibold truncate", children: col.name })] }, col.folderUid)))] }), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), columns.map((col) => (_jsx(SplitColumn, { column: col, dayStart: dayStart, occurrences: dayOccurrences.filter((occ) => occ.folderUid === col.folderUid), onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, col.folderUid)))] })] }));
|
|
22
|
+
return (_jsxs("div", { role: "region", "aria-label": "Split view", "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), columns.map((col) => (_jsxs("div", { className: "flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border", children: [_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: col.color } }), _jsx("span", { className: "text-xs font-semibold truncate", children: col.name })] }, col.folderUid)))] }), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), columns.map((col) => (_jsx(SplitColumn, { column: col, dayStart: dayStart, occurrences: dayOccurrences.filter((occ) => occ.folderUid === col.folderUid), onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, col.folderUid)))] })] }));
|
|
22
23
|
}
|
|
23
24
|
function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlot, }) {
|
|
24
25
|
return (_jsxs("div", { className: "flex-1 min-w-0 relative border-l border-border", children: [Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
25
26
|
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
26
|
-
return (_jsx("button", { type: "button", onClick: () => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid), style: { height: HOUR_HEIGHT_PX / 2 }, className: "block w-full border-b border-border/50 text-left", "aria-label": `New event at ${format(slotStart, "h:mm a")} in ${column.name}` }, i));
|
|
27
|
+
return (_jsx("button", { type: "button", onClick: (e) => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid, anchorOf(e.currentTarget)), style: { height: HOUR_HEIGHT_PX / 2 }, className: "block w-full border-b border-border/50 text-left", "aria-label": `New event at ${format(slotStart, "h:mm a")} in ${column.name}` }, i));
|
|
27
28
|
}), occurrences.map((occurrence) => {
|
|
28
29
|
const start = new Date(occurrence.startDate);
|
|
29
30
|
const end = new Date(occurrence.endDate);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { ReactNode } from "react";
|
|
2
|
+
import { TaskPriority } from "@rapidmx/react-shared/tasks/tasksApi.js";
|
|
3
|
+
import { EventFormController } from "./eventForm.js";
|
|
4
|
+
/**
|
|
5
|
+
* What the Task tab holds besides the title (which is the Event tab's): kept by `QuickCreateFaces`, so it survives a trip to another tab.
|
|
6
|
+
* `dueDate` and `reminder` are on the device's clock, as the Tasks app's own form reads them.
|
|
7
|
+
*/
|
|
8
|
+
export interface TaskDraft {
|
|
9
|
+
/** `yyyy-MM-dd`, or `""` for a task with no due date. */
|
|
10
|
+
dueDate: string;
|
|
11
|
+
/** A date-only due date (local midnight, what the Tasks app writes) rather than a time on it. */
|
|
12
|
+
allDay: boolean;
|
|
13
|
+
/** `HH:mm`, used when `allDay` is off. */
|
|
14
|
+
dueTime: string;
|
|
15
|
+
/** The `TaskList` chosen; `""` is the default "Tasks" list. */
|
|
16
|
+
listUid: string;
|
|
17
|
+
body: string;
|
|
18
|
+
priority: TaskPriority;
|
|
19
|
+
/** `yyyy-MM-ddTHH:mm`, or `""` for no reminder. */
|
|
20
|
+
reminder: string;
|
|
21
|
+
myDay: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** A new task's starting values: due on the day of the slot or day that was clicked, all day, with the slot's time ready if a time is wanted. */
|
|
24
|
+
export declare function initialTaskDraft(start: string, allDayClick: boolean): TaskDraft;
|
|
25
|
+
export interface TaskQuickFormProps {
|
|
26
|
+
c: EventFormController;
|
|
27
|
+
draft: TaskDraft;
|
|
28
|
+
onDraftChange: (patch: Partial<TaskDraft>) => void;
|
|
29
|
+
tabs: ReactNode;
|
|
30
|
+
/** The card: notes, priority, a reminder and My Day as well. */
|
|
31
|
+
expanded: boolean;
|
|
32
|
+
onExpand: () => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The Task tab of the New event popover (and, with More options, its card): creates a task in the chosen mailbox's Tasks folder with
|
|
36
|
+
* `POST /api/mail/tasks`. Title, due date (all day, or a time on it), which mailbox and - when the mailbox has any - which task list; the
|
|
37
|
+
* card adds notes, priority, a reminder and My Day - every field a `Task` has that a person can fill in (an assignee is chosen in the Tasks app).
|
|
38
|
+
* The calendar does not show tasks, so a save closes the popover with a "Task added" notification instead of reloading anything.
|
|
39
|
+
*/
|
|
40
|
+
export default function TaskQuickForm({ c, draft, onDraftChange, tabs, expanded, onExpand }: TaskQuickFormProps): React.JSX.Element;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { parseISO } from "date-fns";
|
|
8
|
+
import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineClock, HiOutlineFlag, HiOutlineFolder, HiOutlineListBullet, HiOutlineSun, HiOutlineXMark, } from "react-icons/hi2";
|
|
9
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
10
|
+
import { createTask, listTaskLists } from "@rapidmx/react-shared/tasks/tasksApi.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
13
|
+
import { notify } from "../../notifications/store.js";
|
|
14
|
+
import { INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
|
|
15
|
+
import QuickFaceFrame from "./QuickFaceFrame.js";
|
|
16
|
+
/** A new task's starting values: due on the day of the slot or day that was clicked, all day, with the slot's time ready if a time is wanted. */
|
|
17
|
+
export function initialTaskDraft(start, allDayClick) {
|
|
18
|
+
return {
|
|
19
|
+
dueDate: start.slice(0, 10),
|
|
20
|
+
allDay: true,
|
|
21
|
+
dueTime: allDayClick ? "09:00" : start.slice(11, 16),
|
|
22
|
+
listUid: "",
|
|
23
|
+
body: "",
|
|
24
|
+
priority: "normal",
|
|
25
|
+
reminder: "",
|
|
26
|
+
myDay: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The Task tab of the New event popover (and, with More options, its card): creates a task in the chosen mailbox's Tasks folder with
|
|
31
|
+
* `POST /api/mail/tasks`. Title, due date (all day, or a time on it), which mailbox and - when the mailbox has any - which task list; the
|
|
32
|
+
* card adds notes, priority, a reminder and My Day - every field a `Task` has that a person can fill in (an assignee is chosen in the Tasks app).
|
|
33
|
+
* The calendar does not show tasks, so a save closes the popover with a "Task added" notification instead of reloading anything.
|
|
34
|
+
*/
|
|
35
|
+
export default function TaskQuickForm({ c, draft, onDraftChange, tabs, expanded, onExpand }) {
|
|
36
|
+
const { values } = c;
|
|
37
|
+
const [lists, setLists] = useState([]);
|
|
38
|
+
const [error, setError] = useState(null);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
// The chosen mailbox's task lists (a mailbox with none has just the default one, and no choice is shown).
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
setLists([]);
|
|
44
|
+
void listTaskLists(values.targetMailboxUid)
|
|
45
|
+
.catch(() => [])
|
|
46
|
+
.then((result) => {
|
|
47
|
+
if (!cancelled) {
|
|
48
|
+
setLists(result);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [values.targetMailboxUid]);
|
|
55
|
+
const mailboxChoices = c.mailboxOptions && c.mailboxOptions.length > 1 ? c.mailboxOptions : undefined;
|
|
56
|
+
// A list of another mailbox that was chosen before the mailbox changed is no longer one.
|
|
57
|
+
const listUid = lists.some((list) => list.uid === draft.listUid) ? draft.listUid : "";
|
|
58
|
+
async function handleSubmit(event) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
setError(null);
|
|
61
|
+
const title = values.title.trim();
|
|
62
|
+
if (!title) {
|
|
63
|
+
setError("A title is required.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (draft.dueDate && !draft.allDay && !draft.dueTime) {
|
|
67
|
+
setError("Pick a time, or choose All day.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setSaving(true);
|
|
71
|
+
try {
|
|
72
|
+
const mailboxUid = values.targetMailboxUid;
|
|
73
|
+
const folderUid = await findWellKnownFolderUid(mailboxUid, "tasks");
|
|
74
|
+
if (!folderUid) {
|
|
75
|
+
setError("That mailbox has no Tasks folder.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await createTask({
|
|
79
|
+
mailboxUid,
|
|
80
|
+
folderUid,
|
|
81
|
+
title,
|
|
82
|
+
body: draft.body.trim() || undefined,
|
|
83
|
+
// A date-only due date is the local day's midnight, the way the Tasks app writes one.
|
|
84
|
+
dueDate: draft.dueDate ? parseISO(draft.allDay ? draft.dueDate : `${draft.dueDate}T${draft.dueTime}`).toISOString() : undefined,
|
|
85
|
+
priority: draft.priority,
|
|
86
|
+
reminderDate: draft.reminder ? parseISO(draft.reminder).toISOString() : undefined,
|
|
87
|
+
taskListUid: listUid || undefined,
|
|
88
|
+
myDay: draft.myDay || undefined,
|
|
89
|
+
});
|
|
90
|
+
notify({ kind: "success", title: "Task added", message: `“${title}” was added to Tasks.` });
|
|
91
|
+
c.onCancel();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create this task.");
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
setSaving(false);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return (_jsxs(QuickFaceFrame, { c: c, tabs: tabs, tab: "task", error: error, onSubmit: (event) => void handleSubmit(event), footer: _jsxs(_Fragment, { children: [!expanded && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: onExpand, children: "More options" })), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })] }), children: [_jsx(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: _jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 text-sm", children: [_jsx("input", { type: "date", "aria-label": "Due date", className: `${SELECT_CLASS} min-w-0`, value: draft.dueDate, onChange: (e) => onDraftChange({ dueDate: e.target.value }) }), draft.dueDate && !draft.allDay && (_jsx("input", { type: "time", "aria-label": "Due time", className: `${SELECT_CLASS} min-w-0`, value: draft.dueTime, onChange: (e) => onDraftChange({ dueTime: e.target.value }) })), draft.dueDate && (_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: draft.allDay, onChange: (e) => onDraftChange({ allDay: e.target.checked }) }), "All day"] }))] }) }), mailboxChoices && (_jsx(IconRow, { icon: _jsx(HiOutlineFolder, { size: 20 }), children: _jsx("select", { "aria-label": "Mailbox", className: `${SELECT_CLASS} max-w-full`, value: values.targetMailboxUid, onChange: (e) => c.onMailboxChange(e.target.value), children: mailboxChoices.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), lists.length > 0 && (_jsx(IconRow, { icon: _jsx(HiOutlineListBullet, { size: 20 }), children: _jsxs("select", { "aria-label": "Task list", className: `${SELECT_CLASS} max-w-full`, value: listUid, onChange: (e) => onDraftChange({ listUid: e.target.value }), children: [_jsx("option", { value: "", children: "Tasks" }), lists.map((list) => (_jsx("option", { value: list.uid, children: list.name }, list.uid)))] }) })), expanded && (_jsxs(_Fragment, { children: [_jsx(IconRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 20 }), children: _jsx("textarea", { "aria-label": "Notes", rows: 3, placeholder: "Add notes", className: INPUT_CLASS, value: draft.body, onChange: (e) => onDraftChange({ body: e.target.value }) }) }), _jsx(IconRow, { icon: _jsx(HiOutlineFlag, { size: 20 }), children: _jsxs("select", { "aria-label": "Priority", className: `${SELECT_CLASS} max-w-full`, value: draft.priority, onChange: (e) => onDraftChange({ priority: e.target.value }), children: [_jsx("option", { value: "low", children: "Low priority" }), _jsx("option", { value: "normal", children: "Normal priority" }), _jsx("option", { value: "high", children: "High priority" })] }) }), _jsx(IconRow, { icon: _jsx(HiOutlineBell, { size: 20 }), children: draft.reminder ? (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "datetime-local", "aria-label": "Reminder", className: `${SELECT_CLASS} min-w-0`, value: draft.reminder, onChange: (e) => onDraftChange({ reminder: e.target.value }) }), _jsx("button", { type: "button", "aria-label": "Remove reminder", className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", onClick: () => onDraftChange({ reminder: "" }), children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] })) : (_jsx("button", { type: "button", className: "text-sm text-text-muted hover:text-text py-1.5", onClick: () => onDraftChange({ reminder: `${draft.dueDate || values.start.slice(0, 10)}T09:00` }), children: "Add reminder" })) }), _jsx(IconRow, { icon: _jsx(HiOutlineSun, { size: 20 }), children: _jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: draft.myDay, onChange: (e) => onDraftChange({ myDay: e.target.checked }) }), "Add to My Day"] }) })] }))] }));
|
|
101
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
3
|
+
import { EventAnchor } from "./EventShell.js";
|
|
3
4
|
export interface TimeGridViewProps {
|
|
4
5
|
/** One day for the Day view, seven for the Week view, five for the Work Week view. */
|
|
5
6
|
days: Date[];
|
|
@@ -8,8 +9,9 @@ export interface TimeGridViewProps {
|
|
|
8
9
|
/** Each occurrence's own calendar color, keyed by `folderUid` — see `calendarColors.ts`. */
|
|
9
10
|
folderColors: Record<string, string>;
|
|
10
11
|
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
11
|
-
/** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow.
|
|
12
|
-
|
|
12
|
+
/** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow. `anchor` is the slot, for the
|
|
13
|
+
* quick-create popover to open beside. */
|
|
14
|
+
onSelectSlot: (start: Date, end: Date, anchor: EventAnchor) => void;
|
|
13
15
|
}
|
|
14
16
|
/** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
|
|
15
17
|
* differ only in how many day columns are shown), with a day-header row and all-day events in a
|
|
@@ -3,6 +3,7 @@ import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
|
3
3
|
import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
|
|
4
4
|
import { eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
5
5
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
6
|
+
import { anchorOf } from "./EventShell.js";
|
|
6
7
|
const HOUR_HEIGHT_PX = 48;
|
|
7
8
|
const SLOT_MINUTES = 30;
|
|
8
9
|
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
@@ -13,7 +14,7 @@ const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
|
13
14
|
export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
|
|
14
15
|
const allDayEvents = occurrences.filter((occ) => occ.allDay);
|
|
15
16
|
const timedEvents = occurrences.filter((occ) => !occ.allDay);
|
|
16
|
-
return (_jsxs("div", { className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
|
|
17
|
+
return (_jsxs("div", { "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
|
|
17
18
|
.filter((occ) => occursOnDay(occ, day))
|
|
18
19
|
.map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
|
|
19
20
|
}
|
|
@@ -26,7 +27,7 @@ function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot
|
|
|
26
27
|
}
|
|
27
28
|
function TimeSlot({ start, onSelectSlot }) {
|
|
28
29
|
const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
|
|
29
|
-
return (_jsx("button", { ref: setNodeRef, type: "button", onClick: () => onSelectSlot(start, addMinutes(start, SLOT_MINUTES)), style: { height: HOUR_HEIGHT_PX / 2 }, className: ["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" "), "aria-label": `New event at ${format(start, "h:mm a, MMM d")}` }));
|
|
30
|
+
return (_jsx("button", { ref: setNodeRef, type: "button", onClick: (e) => onSelectSlot(start, addMinutes(start, SLOT_MINUTES), anchorOf(e.currentTarget)), style: { height: HOUR_HEIGHT_PX / 2 }, className: ["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" "), "aria-label": `New event at ${format(start, "h:mm a, MMM d")}` }));
|
|
30
31
|
}
|
|
31
32
|
/** A block's position within its day column. A multi-day event is drawn in every day column it
|
|
32
33
|
* overlaps, clipped to that day. */
|