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';
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';
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';
35 HolidaysDirectoryCalendar,
38 } from '@proton/shared/lib/interfaces/calendar';
39 import uniqueBy from '@proton/utils/uniqueBy';
41 import type { ModalProps } from '../../../../components';
43 InputFieldTwo as InputField,
50 } from '../../../../components';
51 import CountrySelect from '../../../../components/country/CountrySelect';
54 useCalendarUserSettings,
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) => {
70 const { CalendarSettings } = bootstrap;
72 return notificationsToModel(CalendarSettings.DefaultFullDayNotifications, true);
75 const getHasAlreadyJoinedCalendar = (
76 holidaysCalendars: VisualCalendar[],
77 calendar?: HolidaysDirectoryCalendar,
78 inputCalendar?: VisualCalendar
83 const { CalendarID } = calendar;
84 const holidaysCalendar = holidaysCalendars.find(({ ID }) => ID === CalendarID);
86 return !!holidaysCalendar && holidaysCalendar.ID !== inputCalendar?.ID;
89 const getModalTitle = (isEdit: boolean) => {
91 return c('Modal title').t`Edit calendar`;
94 // translator: A holidays calendar includes bank holidays and observances
95 return c('Modal title').t`Add public holidays`;
98 const getModalSubline = (isEdit: boolean) => {
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 {
109 * Calendar the user wants to update
111 calendar?: VisualCalendar;
112 calendarBootstrap?: CalendarBootstrap;
113 directory: HolidaysDirectoryCalendar[];
115 * Holidays calendars the user has already joined
117 holidaysCalendars: VisualCalendar[];
118 type: CALENDAR_MODAL_TYPE;
119 onEditCalendar?: () => void;
122 const HolidaysCalendarModalWithDirectory = ({
123 calendar: inputHolidaysCalendar,
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
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;
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;
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
168 // Default holidays calendar found based on the user time zone and language
169 const suggestedCalendar = getSuggestedHolidaysCalendar(
173 getBrowserLanguageTags()
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,
184 inputHolidaysCalendar
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
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
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,
212 inputHolidaysCalendar
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(() => {
239 countryName: suggestedCalendar.Country,
240 countryCode: suggestedCalendar.CountryCode,
243 }, [canPreselect, suggestedCalendar]);
245 const value = useMemo(() => {
246 return selectedCalendar
248 countryName: selectedCalendar.Country,
249 countryCode: selectedCalendar.CountryCode,
252 }, [selectedCalendar]);
254 const handleSubmit = async () => {
256 if (!onFormSubmit() || hasAlreadyJoinedSelectedCalendar) {
260 if (computedCalendar) {
261 const formattedNotifications = modelToNotifications(
262 sortNotificationsByAscendingTrigger(dedupeNotifications(notifications))
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
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,
281 Display: inputHolidaysCalendar.Display,
283 const calendarSettingsPayload: Required<EditableCalendarSettings> = {
284 DefaultEventDuration: DEFAULT_EVENT_DURATION,
285 DefaultFullDayNotifications: formattedNotifications,
286 DefaultPartDayNotifications: [],
287 MakesUserBusy: makesUserBusy,
289 await updateCalendar(
290 inputHolidaysCalendar,
292 calendarSettingsPayload,
293 readCalendarBootstrap,
298 await calendarCall([directoryCalendarFromInput.CalendarID]);
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,
312 notifications: formattedNotifications,
313 priority: inputHolidaysCalendar.Priority,
318 // translator: A holidays calendar includes bank holidays and observances
319 text: c('Notification in holidays calendar modal').t`Adding holidays calendar failed`,
323 // Make busyness update afterward via setting call
324 if (calendar && makesUserBusy !== calendar.CalendarSettings.MakesUserBusy) {
325 await api(updateCalendarSettings(calendar.Calendar.ID, { MakesUserBusy: makesUserBusy }));
332 text: c('Notification in holidays calendar modal').t`Calendar updated`,
335 // 3 - Joining a holidays calendar
336 const calendar = await setupHolidaysCalendarHelper({
337 holidaysCalendar: computedCalendar,
341 notifications: formattedNotifications,
345 // Make busyness update afterward via setting call
346 if (makesUserBusy !== calendar.CalendarSettings.MakesUserBusy) {
347 await api(updateCalendarSettings(calendar.Calendar.ID, { MakesUserBusy: makesUserBusy }));
353 text: c('Notification in holidays calendar modal').t`Calendar added`,
360 console.error(error);
365 const handleSelectCountry = useCallback(
366 (countryCode: string) => {
367 const newSelected = findHolidaysCalendarByCountryCodeAndLanguageTag(visibleDirectory, countryCode, [
369 ...getBrowserLanguageTags(),
373 setSelectedCalendar(newSelected);
376 [visibleDirectory, setSelectedCalendar]
379 const handleSelectLanguage = ({ value }: { value: any }) => {
380 const calendarsFromCountry = filteredLanguageOptions.find((calendar) => calendar.Language === value);
381 setSelectedCalendar(calendarsFromCountry);
384 const getErrorText = () => {
386 // Avoid displaying the error during the exit animation
389 if (hasAlreadyJoinedSelectedCalendar) {
390 // translator: A holidays calendar includes bank holidays and observances
391 return c('Error').t`You already added this holidays calendar`;
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(
403 countries.map((calendar) => ({
404 countryName: calendar.Country,
405 countryCode: calendar.CountryCode,
414 onSubmit={() => withLoading(handleSubmit())}
415 size={getCalendarModalSize(type)}
419 <ModalTwoHeader title={getModalTitle(isEdit)} subline={getModalSubline(isEdit)} />
420 <ModalTwoContent className="holidays-calendar-modal-content">
423 options={countryOptions}
424 preSelectedOption={preselectedOption}
426 preSelectedOptionDivider={hintText}
427 onSelectCountry={handleSelectCountry}
428 error={validator([getErrorText()])}
429 hint={canShowHint ? hintText : undefined}
432 {computedCalendar && filteredLanguageOptions.length > 1 && isComplete && (
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"
442 {filteredLanguageOptions.map((option) => (
443 <Option key={option.Language} value={option.Language} title={option.Language} />
451 label={c('Label').t`Color`}
453 onChange={(color: string) => setColor(color)}
454 data-testid="holidays-calendar-modal:color-select"
460 id="default-full-day-notification"
462 label={c('Label').t`Notifications`}
464 notifications={notifications}
465 defaultNotification={defaultModel.defaultFullDayNotification}
466 canAdd={notifications.length < MAX_DEFAULT_NOTIFICATIONS}
467 onChange={(notifications: NotificationModel[]) => {
468 setNotifications(notifications);
472 onChange={(nextMakesUserBusy) => {
473 setMakesUserBusy(nextMakesUserBusy);
475 value={makesUserBusy}
482 <Button onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
485 disabled={!computedCalendar}
488 data-testid="holidays-calendar-modal:submit"
490 {isEdit ? c('Action').t`Save` : c('Action').t`Add`}
498 export default HolidaysCalendarModalWithDirectory;