1 import type { ChangeEvent } from 'react';
2 import { useMemo, useState } from 'react';
4 import { c } from 'ttag';
6 import { Button } from '@proton/atoms';
7 import Form from '@proton/components/components/form/Form';
8 import ColorPicker from '@proton/components/components/input/ColorPicker';
9 import TextArea from '@proton/components/components/input/TextArea';
10 import Loader from '@proton/components/components/loader/Loader';
11 import Option from '@proton/components/components/option/Option';
12 import { getCalendarModalSize } from '@proton/components/containers/calendar/calendarModal/helpers';
13 import { CALENDAR_MODAL_TYPE } from '@proton/components/containers/calendar/calendarModal/interface';
14 import { useContactEmailsCache } from '@proton/components/containers/contacts/ContactEmailsProvider';
15 import { useLoading } from '@proton/hooks';
16 import { dedupeNotifications, sortNotificationsByAscendingTrigger } from '@proton/shared/lib/calendar/alarms';
17 import { getIsCalendarWritable, getIsSubscribedCalendar, getShowDuration } from '@proton/shared/lib/calendar/calendar';
18 import { MAX_CHARS_API, MAX_DEFAULT_NOTIFICATIONS } from '@proton/shared/lib/calendar/constants';
19 import { getSharedCalendarSubHeaderText } from '@proton/shared/lib/calendar/sharing/shareProton/shareProton';
20 import type { Nullable } from '@proton/shared/lib/interfaces';
21 import type { NotificationModel, SubscribedCalendar, VisualCalendar } from '@proton/shared/lib/interfaces/calendar';
30 } from '../../../../components';
31 import type { SelectChangeEvent } from '../../../../components/selectTwo/select';
32 import { TruncatedText } from '../../../../components/truncatedText';
33 import GenericError from '../../../error/GenericError';
34 import useGetCalendarActions from '../../hooks/useGetCalendarActions';
35 import useGetCalendarSetup from '../../hooks/useGetCalendarSetup';
36 import Notifications from '../../notifications/Notifications';
37 import BusySlotsCheckbox from '../BusySlotsCheckbox';
38 import { getCalendarPayload, getCalendarSettingsPayload, getDefaultModel, validate } from './calendarModalState';
40 import './PersonalCalendarModal.scss';
42 const URL_MAX_DISPLAY_LENGTH = 100;
44 const { COMPLETE, VISUAL, SHARED } = CALENDAR_MODAL_TYPE;
46 export interface CalendarModalProps {
47 calendar?: VisualCalendar | SubscribedCalendar;
48 calendars?: VisualCalendar[];
49 defaultCalendarID?: Nullable<string>;
52 onCreateCalendar?: (id: string) => void;
53 onEditCalendar?: () => void;
55 type?: CALENDAR_MODAL_TYPE;
58 export const PersonalCalendarModal = ({
59 calendar: initialCalendar,
61 defaultCalendarID = '',
68 }: CalendarModalProps) => {
69 const [loadingAction, withLoadingAction] = useLoading();
70 const { contactEmailsMap } = useContactEmailsCache() || {};
72 const [isSubmitted, setIsSubmitted] = useState(false);
73 const [error, setError] = useState(false);
74 const [calendar, setCalendar] = useState(initialCalendar);
76 const [model, setModel] = useState(() => getDefaultModel());
78 const addressText = useMemo(() => {
79 const option = model.addressOptions.find(({ value: ID }) => ID === model.addressID);
80 return (option && option.text) || '';
81 }, [model.addressID, model.addressOptions]);
83 const showDuration = initialCalendar ? getShowDuration(initialCalendar) : true;
85 initialCalendar && getIsSubscribedCalendar(initialCalendar)
86 ? initialCalendar.SubscriptionParameters.URL
89 const { error: setupError, loading: loadingSetup } = useGetCalendarSetup({ calendar: initialCalendar, setModel });
90 const { handleCreateCalendar, handleUpdateCalendar } = useGetCalendarActions({
101 const formattedModel = {
103 name: model.name.trim(),
104 description: model.description.trim(),
107 const errors = validate(formattedModel);
109 const handleProcessCalendar = async () => {
110 const formattedModelWithFormattedNotifications = {
112 partDayNotifications: sortNotificationsByAscendingTrigger(dedupeNotifications(model.partDayNotifications)),
113 fullDayNotifications: sortNotificationsByAscendingTrigger(dedupeNotifications(model.fullDayNotifications)),
115 const calendarPayload = getCalendarPayload(formattedModelWithFormattedNotifications);
116 const calendarSettingsPayload = getCalendarSettingsPayload(formattedModelWithFormattedNotifications);
118 return handleUpdateCalendar(calendar, calendarPayload, calendarSettingsPayload);
120 return handleCreateCalendar(
121 formattedModelWithFormattedNotifications.addressID,
123 calendarSettingsPayload
127 const hasError = error || setupError;
129 const getTitle = (type: CALENDAR_MODAL_TYPE) => {
130 const editCalendarText = c('Title; edit calendar modal').t`Edit calendar`;
132 return c('Title').t`Error`;
135 if (type === VISUAL) {
136 return editCalendarText;
139 return initialCalendar ? editCalendarText : c('Title; create calendar modal').t`Create calendar`;
142 const getSubline = () => {
143 if (type !== CALENDAR_MODAL_TYPE.SHARED || !calendar || !contactEmailsMap) {
147 const subHeaderText = getSharedCalendarSubHeaderText(calendar, contactEmailsMap);
152 <div className="text-rg text-break mb-1 color-norm">{subHeaderText}</div>
153 {!getIsCalendarWritable(calendar) && (
154 <div className="text-rg text-ellipsis color-weak">
155 {c('Info; access rights for shared calendar').t`View only`}
163 const handleSubmit = () => {
165 window.location.reload();
169 setIsSubmitted(true);
171 if (Object.keys(errors).length > 0) {
175 void withLoadingAction(handleProcessCalendar());
178 const getFakeLabel = (labelText: string) => (
179 <span className="flex field-two-label-container justify-space-between flex-nowrap items-end">
180 <span className="field-two-label">{labelText}</span>
184 const getFakeInputTwo = ({ content, label }: { content: React.ReactNode; label: string }) => {
185 // classes taken from InputFieldTwo
187 <div className="field-two-container w-full">
188 {getFakeLabel(label)}
189 <div className="field-two-field-container relative">{content}</div>
194 const calendarNameRow = (
196 id="calendar-name-input"
197 label={c('Label').t`Name`}
199 error={isSubmitted && errors.name}
200 maxLength={MAX_CHARS_API.CALENDAR_NAME}
201 disableChange={loadingAction}
202 placeholder={c('Placeholder').t`Add a calendar name`}
203 onChange={({ target }: ChangeEvent<HTMLInputElement>) => setModel({ ...model, name: target.value })}
211 label={c('Label').t`Color`}
214 onChange={(color: string) => setModel({ ...model, color })}
218 const descriptionRow = (
221 label={c('Label').t`Description`}
223 id="calendar-description-textarea"
224 value={model.description}
225 placeholder={c('Placeholder').t`Add a calendar description`}
226 onChange={({ target }: ChangeEvent<HTMLTextAreaElement>) =>
227 setModel({ ...model, description: target.value })
229 maxLength={MAX_CHARS_API.CALENDAR_DESCRIPTION}
230 isSubmitted={isSubmitted}
231 error={errors.description}
235 const addressRow = model.calendarID ? (
236 getFakeInputTwo({ content: addressText, label: c('Label').t`Email address` })
240 id="calendar-address-select"
241 value={model.addressID}
243 onChange={({ value }: SelectChangeEvent<string>) => setModel({ ...model, addressID: value })}
244 label={c('Label').t`Email address`}
246 {model.addressOptions.map(({ value, text }) => (
247 <Option key={value} value={value} title={text} />
252 const defaultEventDurationRow = showDuration ? (
255 label={c('Label').t`Event duration`}
257 data-testid="create-calendar/event-settings:event-duration"
258 value={model.duration}
260 onChange={({ value }: SelectChangeEvent<string>) => setModel({ ...model, duration: +value })}
263 { text: c('Duration').t`30 minutes`, value: 30 },
264 { text: c('Duration').t`60 minutes`, value: 60 },
265 { text: c('Duration').t`90 minutes`, value: 90 },
266 { text: c('Duration').t`120 minutes`, value: 120 },
267 ].map(({ value, text }) => (
268 <Option key={value} value={value} title={text} />
273 const defaultNotificationsRow = (
276 id="default-notification"
278 label={c('Label').t`Event notifications`}
279 data-testid="create-calendar/event-settings:default-notification"
281 notifications={model.partDayNotifications}
282 canAdd={model.partDayNotifications.length < MAX_DEFAULT_NOTIFICATIONS}
283 defaultNotification={model.defaultPartDayNotification}
284 onChange={(notifications: NotificationModel[]) => {
287 partDayNotifications: notifications,
293 id="default-full-day-notification"
295 label={c('Label').t`All-day event notifications`}
296 data-testid="create-calendar/event-settings:default-full-day-notification"
298 notifications={model.fullDayNotifications}
299 canAdd={model.fullDayNotifications.length < MAX_DEFAULT_NOTIFICATIONS}
300 defaultNotification={model.defaultFullDayNotification}
301 onChange={(notifications: NotificationModel[]) => {
304 fullDayNotifications: notifications,
311 const busySlotsCheckbox = (
313 value={model.shareBusySlots}
314 onChange={(shareBusySlots) => setModel({ ...model, shareBusySlots })}
318 const subscribeURLRow =
322 <span className="text-break-all">
323 <TruncatedText maxChars={URL_MAX_DISPLAY_LENGTH}>{subscribeURL}</TruncatedText>
326 label: c('Label').t`URL`,
329 const getContentRows = (type: CALENDAR_MODAL_TYPE) => {
330 if (type === VISUAL) {
340 if (type === SHARED) {
347 {defaultNotificationsRow}
359 {defaultEventDurationRow}
360 {defaultNotificationsRow}
369 size={getCalendarModalSize(type)}
377 if (!loadingAction) {
387 <ModalTwoHeader title={getTitle(type)} subline={getSubline()} />
388 <ModalTwoContent className="calendar-modal-content">
389 {hasError ? <GenericError /> : getContentRows(type)}
393 <Button onClick={() => window.location.reload()} className="ml-auto" color="norm">
394 {c('Action').t`Close`}
398 <Button onClick={onClose} disabled={loadingAction}>
399 {c('Action').t`Cancel`}
401 <Button loading={loadingAction} type="submit" color="norm">
402 {c('Action').t`Save`}
413 export default PersonalCalendarModal;