Remove option component
[ProtonMail-WebClient.git] / packages / components / containers / calendar / calendarModal / holidaysCalendarModal / HolidaysCalendarModalWithDirectory.tsx
blobb2fe2a48f92be6ac05dcfd26e2d94b751763b71f
1 import { useCallback, useMemo, useState } from 'react';
3 import { c } from 'ttag';
5 import { Button } from '@proton/atoms';
6 import Form from '@proton/components/components/form/Form';
7 import ColorPicker from '@proton/components/components/input/ColorPicker';
8 import Option from '@proton/components/components/option/Option';
9 import { getCalendarModalSize } from '@proton/components/containers/calendar/calendarModal/helpers';
10 import { CALENDAR_MODAL_TYPE } from '@proton/components/containers/calendar/calendarModal/interface';
11 import { useLoading } from '@proton/hooks';
12 import { removeHolidaysCalendar, updateCalendarSettings } from '@proton/shared/lib/api/calendars';
13 import { dedupeNotifications, sortNotificationsByAscendingTrigger } from '@proton/shared/lib/calendar/alarms';
14 import { modelToNotifications } from '@proton/shared/lib/calendar/alarms/modelToNotifications';
15 import { notificationsToModel } from '@proton/shared/lib/calendar/alarms/notificationsToModel';
16 import type { EditableCalendarSettings } from '@proton/shared/lib/calendar/calendar';
17 import { updateCalendar } from '@proton/shared/lib/calendar/calendar';
18 import {
19     CALENDAR_TYPE,
20     DEFAULT_EVENT_DURATION,
21     MAX_DEFAULT_NOTIFICATIONS,
22 } from '@proton/shared/lib/calendar/constants';
23 import setupHolidaysCalendarHelper from '@proton/shared/lib/calendar/crypto/keys/setupHolidaysCalendarHelper';
24 import {
25     findHolidaysCalendarByCountryCodeAndLanguageTag,
26     getHolidaysCalendarsFromCountryCode,
27     getSuggestedHolidaysCalendar,
28 } from '@proton/shared/lib/calendar/holidaysCalendar/holidaysCalendar';
29 import { getRandomAccentColor } from '@proton/shared/lib/colors';
30 import { languageCode } from '@proton/shared/lib/i18n';
31 import { getBrowserLanguageTags } from '@proton/shared/lib/i18n/helper';
32 import type {
33     CalendarBootstrap,
34     CalendarCreateData,
35     HolidaysDirectoryCalendar,
36     NotificationModel,
37     VisualCalendar,
38 } from '@proton/shared/lib/interfaces/calendar';
39 import uniqueBy from '@proton/utils/uniqueBy';
41 import type { ModalProps } from '../../../../components';
42 import {
43     InputFieldTwo as InputField,
44     ModalTwo,
45     ModalTwoContent,
46     ModalTwoFooter,
47     ModalTwoHeader,
48     SelectTwo as Select,
49     useFormErrors,
50 } from '../../../../components';
51 import CountrySelect from '../../../../components/country/CountrySelect';
52 import {
53     useApi,
54     useCalendarUserSettings,
55     useEventManager,
56     useGetAddressKeys,
57     useGetAddresses,
58     useNotifications,
59     useReadCalendarBootstrap,
60 } from '../../../../hooks';
61 import { useCalendarModelEventManager } from '../../../eventManager';
62 import Notifications from '../../notifications/Notifications';
63 import BusySlotsCheckbox from '../BusySlotsCheckbox';
64 import { getDefaultModel } from '../personalCalendarModal/calendarModalState';
66 const getInitialCalendarNotifications = (bootstrap?: CalendarBootstrap) => {
67     if (!bootstrap) {
68         return [];
69     }
70     const { CalendarSettings } = bootstrap;
72     return notificationsToModel(CalendarSettings.DefaultFullDayNotifications, true);
75 const getHasAlreadyJoinedCalendar = (
76     holidaysCalendars: VisualCalendar[],
77     calendar?: HolidaysDirectoryCalendar,
78     inputCalendar?: VisualCalendar
79 ) => {
80     if (!calendar) {
81         return false;
82     }
83     const { CalendarID } = calendar;
84     const holidaysCalendar = holidaysCalendars.find(({ ID }) => ID === CalendarID);
86     return !!holidaysCalendar && holidaysCalendar.ID !== inputCalendar?.ID;
89 const getModalTitle = (isEdit: boolean) => {
90     if (isEdit) {
91         return c('Modal title').t`Edit calendar`;
92     }
94     // translator: A holidays calendar includes bank holidays and observances
95     return c('Modal title').t`Add public holidays`;
98 const getModalSubline = (isEdit: boolean) => {
99     if (isEdit) {
100         return;
101     }
103     // translator: A holidays calendar includes bank holidays and observances
104     return c('Modal title').t`Get a country's official public holidays calendar.`;
107 interface Props extends ModalProps {
108     /**
109      * Calendar the user wants to update
110      */
111     calendar?: VisualCalendar;
112     calendarBootstrap?: CalendarBootstrap;
113     directory: HolidaysDirectoryCalendar[];
114     /**
115      * Holidays calendars the user has already joined
116      */
117     holidaysCalendars: VisualCalendar[];
118     type: CALENDAR_MODAL_TYPE;
119     onEditCalendar?: () => void;
122 const HolidaysCalendarModalWithDirectory = ({
123     calendar: inputHolidaysCalendar,
124     calendarBootstrap,
125     directory,
126     holidaysCalendars,
127     type,
128     onEditCalendar,
129     ...rest
130 }: Props) => {
131     const getAddresses = useGetAddresses();
132     const [calendarUserSettings] = useCalendarUserSettings();
133     const PrimaryTimezone = calendarUserSettings?.PrimaryTimezone!;
134     const { call } = useEventManager();
135     const { call: calendarCall } = useCalendarModelEventManager();
136     const api = useApi();
137     const getAddressKeys = useGetAddressKeys();
138     const { validator, onFormSubmit } = useFormErrors();
139     const [loading, withLoading] = useLoading();
140     const { createNotification } = useNotifications();
141     const readCalendarBootstrap = useReadCalendarBootstrap();
142     const defaultModel = getDefaultModel(CALENDAR_TYPE.HOLIDAYS);
143     const [makesUserBusy, setMakesUserBusy] = useState(
144         calendarBootstrap?.CalendarSettings?.MakesUserBusy ?? defaultModel.shareBusySlots
145     );
147     const memoizedHolidaysCalendars = useMemo(() => {
148         // Prevent the list of user holidays calendars from changing (via event loop) once the modal opened.
149         // This avoids possible UI jumps and glitches
150         return holidaysCalendars;
151     }, []);
153     const visibleDirectory = useMemo(() => {
154         const inputHolidaysCalendarID = inputHolidaysCalendar?.ID;
156         return directory.filter((calendar) => {
157             // a directory calendar is displayed if it's not hidden, or we're editing it
158             return !calendar.Hidden || calendar.CalendarID === inputHolidaysCalendarID;
159         });
160     }, [directory]);
162     const { directoryCalendarFromInput, suggestedCalendar } = useMemo(() => {
163         // Directory calendar that we want to edit (when we get an input calendar)
164         const directoryCalendarFromInput = visibleDirectory.find(
165             ({ CalendarID }) => CalendarID === inputHolidaysCalendar?.ID
166         );
168         // Default holidays calendar found based on the user time zone and language
169         const suggestedCalendar = getSuggestedHolidaysCalendar(
170             visibleDirectory,
171             PrimaryTimezone,
172             languageCode,
173             getBrowserLanguageTags()
174         );
176         return { directoryCalendarFromInput, suggestedCalendar };
177     }, [inputHolidaysCalendar, visibleDirectory, PrimaryTimezone, languageCode]);
179     // Check if the user has already joined the default holidays directory calendar.
180     // If so, we don't want to pre-select that default calendar
181     const hasAlreadyJoinedSuggestedCalendar = getHasAlreadyJoinedCalendar(
182         memoizedHolidaysCalendars,
183         suggestedCalendar,
184         inputHolidaysCalendar
185     );
187     /**
188      * We won't have preselection if we are in one of the following cases
189      *  - user is editing an existing holidays calendar
190      *  - user doesn't have a suggested calendar
191      *  - user has already added the suggested calendar
192      */
193     const canPreselect = !directoryCalendarFromInput && !!suggestedCalendar && !hasAlreadyJoinedSuggestedCalendar;
195     const isEdit = !!inputHolidaysCalendar;
197     // Currently selected option in the modal
198     const [selectedCalendar, setSelectedCalendar] = useState<HolidaysDirectoryCalendar | undefined>(
199         directoryCalendarFromInput
200     );
202     // Calendar that is either the selected one or the default one if preselect possible
203     // Because in some case, we do have a default calendar (suggested one) to use, but we want to act CountrySelect as if we don't (focus on suggested option)
204     // we need to separate calendar that has been explicitly selected by user from the one that is being used (selected OR suggested)
205     const computedCalendar = selectedCalendar || (canPreselect ? suggestedCalendar : undefined);
207     // Check if currently selected holidays calendar has already been joined by the user
208     // If already joined, we don't want the user to be able to "save" again, or he will get an error
209     const hasAlreadyJoinedSelectedCalendar = getHasAlreadyJoinedCalendar(
210         memoizedHolidaysCalendars,
211         computedCalendar,
212         inputHolidaysCalendar
213     );
215     const [color, setColor] = useState(inputHolidaysCalendar?.Color || getRandomAccentColor());
216     const [notifications, setNotifications] = useState<NotificationModel[]>(
217         getInitialCalendarNotifications(calendarBootstrap)
218     ); // Note that we don't need to fill this state on holidays calendar edition since this field will not be displayed
220     // Preselection hint is needed only if
221     // - user can have a preselected calendar
222     // - suggested calendar matches computed one
223     const canShowHint = canPreselect && suggestedCalendar === computedCalendar;
225     const countries: Pick<HolidaysDirectoryCalendar, 'Country' | 'CountryCode'>[] = useMemo(() => {
226         return uniqueBy(visibleDirectory, ({ CountryCode }) => CountryCode)
227             .map(({ Country, CountryCode }) => ({ Country, CountryCode }))
228             .sort((a, b) => a.Country.localeCompare(b.Country));
229     }, [visibleDirectory]);
231     // We might have several calendars for a specific country, with different languages
232     const filteredLanguageOptions: HolidaysDirectoryCalendar[] = useMemo(() => {
233         return getHolidaysCalendarsFromCountryCode(visibleDirectory, computedCalendar?.CountryCode || '');
234     }, [computedCalendar, visibleDirectory]);
236     const preselectedOption = useMemo(() => {
237         return canPreselect
238             ? {
239                   countryName: suggestedCalendar.Country,
240                   countryCode: suggestedCalendar.CountryCode,
241               }
242             : undefined;
243     }, [canPreselect, suggestedCalendar]);
245     const value = useMemo(() => {
246         return selectedCalendar
247             ? {
248                   countryName: selectedCalendar.Country,
249                   countryCode: selectedCalendar.CountryCode,
250               }
251             : undefined;
252     }, [selectedCalendar]);
254     const handleSubmit = async () => {
255         try {
256             if (!onFormSubmit() || hasAlreadyJoinedSelectedCalendar) {
257                 return;
258             }
260             if (computedCalendar) {
261                 const formattedNotifications = modelToNotifications(
262                     sortNotificationsByAscendingTrigger(dedupeNotifications(notifications))
263                 );
264                 /**
265                  * Based on the inputHolidaysCalendar, we have several cases to cover:
266                  * 1 - The user is updating colors or notifications of his holidays calendar
267                  *      => We perform a classic calendar update
268                  * 2 - The user is updating the country or the language of his holidays calendar
269                  *      => We need to leave the old holidays calendar and then join a new one
270                  * 3 - The user is joining a holidays calendar
271                  *      => We just want to join a holidays calendar
272                  */
273                 const addresses = await getAddresses();
274                 if (inputHolidaysCalendar) {
275                     // 1 - Classic update: staying in the same holidays calendar
276                     if (computedCalendar.CalendarID === directoryCalendarFromInput?.CalendarID) {
277                         const calendarPayload: CalendarCreateData = {
278                             Name: inputHolidaysCalendar.Name,
279                             Description: inputHolidaysCalendar.Description,
280                             Color: color,
281                             Display: inputHolidaysCalendar.Display,
282                         };
283                         const calendarSettingsPayload: Required<EditableCalendarSettings> = {
284                             DefaultEventDuration: DEFAULT_EVENT_DURATION,
285                             DefaultFullDayNotifications: formattedNotifications,
286                             DefaultPartDayNotifications: [],
287                             MakesUserBusy: makesUserBusy,
288                         };
289                         await updateCalendar(
290                             inputHolidaysCalendar,
291                             calendarPayload,
292                             calendarSettingsPayload,
293                             readCalendarBootstrap,
294                             getAddresses,
295                             api
296                         );
297                         await call();
298                         await calendarCall([directoryCalendarFromInput.CalendarID]);
299                         onEditCalendar?.();
300                     } else {
301                         // 2 - Leave old holidays calendar and join a new one
302                         // 2bis - If input holidays calendar doesn't exist anymore, we remove it and join new one
304                         // Use route which does not need password confirmation to remove the calendar
305                         await api(removeHolidaysCalendar(inputHolidaysCalendar.ID));
307                         const calendar = await setupHolidaysCalendarHelper({
308                             holidaysCalendar: computedCalendar,
309                             addresses,
310                             getAddressKeys,
311                             color,
312                             notifications: formattedNotifications,
313                             priority: inputHolidaysCalendar.Priority,
314                             api,
315                         }).catch(() => {
316                             createNotification({
317                                 type: 'error',
318                                 // translator: A holidays calendar includes bank holidays and observances
319                                 text: c('Notification in holidays calendar modal').t`Adding holidays calendar failed`,
320                             });
321                         });
323                         // Make busyness update afterward via setting call
324                         if (calendar && makesUserBusy !== calendar.CalendarSettings.MakesUserBusy) {
325                             await api(updateCalendarSettings(calendar.Calendar.ID, { MakesUserBusy: makesUserBusy }));
326                         }
328                         await call();
329                     }
330                     createNotification({
331                         type: 'success',
332                         text: c('Notification in holidays calendar modal').t`Calendar updated`,
333                     });
334                 } else {
335                     // 3 - Joining a holidays calendar
336                     const calendar = await setupHolidaysCalendarHelper({
337                         holidaysCalendar: computedCalendar,
338                         addresses,
339                         getAddressKeys,
340                         color,
341                         notifications: formattedNotifications,
342                         api,
343                     });
345                     // Make busyness update afterward via setting call
346                     if (makesUserBusy !== calendar.CalendarSettings.MakesUserBusy) {
347                         await api(updateCalendarSettings(calendar.Calendar.ID, { MakesUserBusy: makesUserBusy }));
348                     }
349                     await call();
351                     createNotification({
352                         type: 'success',
353                         text: c('Notification in holidays calendar modal').t`Calendar added`,
354                     });
355                 }
357                 rest.onClose?.();
358             }
359         } catch (error) {
360             console.error(error);
361             rest.onClose?.();
362         }
363     };
365     const handleSelectCountry = useCallback(
366         (countryCode: string) => {
367             const newSelected = findHolidaysCalendarByCountryCodeAndLanguageTag(visibleDirectory, countryCode, [
368                 languageCode,
369                 ...getBrowserLanguageTags(),
370             ]);
372             if (newSelected) {
373                 setSelectedCalendar(newSelected);
374             }
375         },
376         [visibleDirectory, setSelectedCalendar]
377     );
379     const handleSelectLanguage = ({ value }: { value: any }) => {
380         const calendarsFromCountry = filteredLanguageOptions.find((calendar) => calendar.Language === value);
381         setSelectedCalendar(calendarsFromCountry);
382     };
384     const getErrorText = () => {
385         if (!rest.open) {
386             // Avoid displaying the error during the exit animation
387             return '';
388         }
389         if (hasAlreadyJoinedSelectedCalendar) {
390             // translator: A holidays calendar includes bank holidays and observances
391             return c('Error').t`You already added this holidays calendar`;
392         }
394         return '';
395     };
397     // translator: Hint text about the pre-selected country option in the holidays calendar modal
398     const hintText = c('Info').t`Based on your time zone`;
399     const isComplete = type === CALENDAR_MODAL_TYPE.COMPLETE;
401     const countryOptions = useMemo(
402         () =>
403             countries.map((calendar) => ({
404                 countryName: calendar.Country,
405                 countryCode: calendar.CountryCode,
406             })),
407         [countries]
408     );
410     return (
411         <ModalTwo
412             as={Form}
413             fullscreenOnMobile
414             onSubmit={() => withLoading(handleSubmit())}
415             size={getCalendarModalSize(type)}
416             className="w-full"
417             {...rest}
418         >
419             <ModalTwoHeader title={getModalTitle(isEdit)} subline={getModalSubline(isEdit)} />
420             <ModalTwoContent className="holidays-calendar-modal-content">
421                 {isComplete && (
422                     <CountrySelect
423                         options={countryOptions}
424                         preSelectedOption={preselectedOption}
425                         value={value}
426                         preSelectedOptionDivider={hintText}
427                         onSelectCountry={handleSelectCountry}
428                         error={validator([getErrorText()])}
429                         hint={canShowHint ? hintText : undefined}
430                     />
431                 )}
432                 {computedCalendar && filteredLanguageOptions.length > 1 && isComplete && (
433                     <InputField
434                         id="languageSelect"
435                         as={Select}
436                         label={c('Label').t`Language`}
437                         value={computedCalendar.Language}
438                         onChange={handleSelectLanguage}
439                         aria-describedby="label-languageSelect"
440                         data-testid="holidays-calendar-modal:language-select"
441                     >
442                         {filteredLanguageOptions.map((option) => (
443                             <Option key={option.Language} value={option.Language} title={option.Language} />
444                         ))}
445                     </InputField>
446                 )}
448                 <InputField
449                     id="colorSelect"
450                     as={ColorPicker}
451                     label={c('Label').t`Color`}
452                     color={color}
453                     onChange={(color: string) => setColor(color)}
454                     data-testid="holidays-calendar-modal:color-select"
455                 />
457                 {isComplete && (
458                     <>
459                         <InputField
460                             id="default-full-day-notification"
461                             as={Notifications}
462                             label={c('Label').t`Notifications`}
463                             hasType
464                             notifications={notifications}
465                             defaultNotification={defaultModel.defaultFullDayNotification}
466                             canAdd={notifications.length < MAX_DEFAULT_NOTIFICATIONS}
467                             onChange={(notifications: NotificationModel[]) => {
468                                 setNotifications(notifications);
469                             }}
470                         />
471                         <BusySlotsCheckbox
472                             onChange={(nextMakesUserBusy) => {
473                                 setMakesUserBusy(nextMakesUserBusy);
474                             }}
475                             value={makesUserBusy}
476                         />
477                     </>
478                 )}
479             </ModalTwoContent>
480             <ModalTwoFooter>
481                 <>
482                     <Button onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
483                     <Button
484                         loading={loading}
485                         disabled={!computedCalendar}
486                         type="submit"
487                         color="norm"
488                         data-testid="holidays-calendar-modal:submit"
489                     >
490                         {isEdit ? c('Action').t`Save` : c('Action').t`Add`}
491                     </Button>
492                 </>
493             </ModalTwoFooter>
494         </ModalTwo>
495     );
498 export default HolidaysCalendarModalWithDirectory;