From a38845e523ccdd41be004a72d1de187b336f3588 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Wed, 11 Feb 2026 18:36:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(sharing)=20use=20UI=20Kit=20native=20?= =?UTF-8?q?sharing=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar-list/CalendarItemMenu.tsx | 11 +- .../calendar-list/CalendarList.scss | 35 +-- .../components/calendar-list/CalendarList.tsx | 15 +- .../calendar-list/CalendarListItem.tsx | 4 + .../calendar-list/CalendarModal.tsx | 92 +------- .../calendar-list/CalendarShareModal.tsx | 218 ++++++++++++++++++ .../hooks/useCalendarListState.ts | 35 +-- .../components/calendar-list/types.ts | 11 +- .../src/features/i18n/translations.json | 3 + 9 files changed, 283 insertions(+), 141 deletions(-) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarShareModal.tsx diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx index 726954f..0219434 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx @@ -15,6 +15,7 @@ export const CalendarItemMenu = ({ onOpenChange, onEdit, onDelete, + onShare, onImport, onSubscription, }: CalendarItemMenuProps) => { @@ -29,6 +30,14 @@ export const CalendarItemMenu = ({ }, ]; + if (onShare) { + items.push({ + label: t("calendar.list.share"), + icon: person_add, + callback: onShare, + }); + } + if (onImport) { items.push({ label: t("calendar.list.import"), @@ -52,7 +61,7 @@ export const CalendarItemMenu = ({ }); return items; - }, [t, onEdit, onDelete, onImport, onSubscription]); + }, [t, onEdit, onDelete, onShare, onImport, onSubscription]); return ( diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss index 6bdc081..8422528 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss @@ -279,36 +279,15 @@ } } - &__share-section { - margin-top: 0.5rem; - } +} - &__share-divider { - height: 1px; - background-color: var(--c--theme--colors--greyscale-200); - margin-bottom: 1rem; - } +// ============================================================================ +// Share Modal Styles (close button position fix) +// ============================================================================ - &__share-input-row { - display: flex; - gap: 0.5rem; - align-items: flex-start; - - > div:first-child { - flex: 1; - } - - > button { - flex-shrink: 0; - margin-top: 0.25rem; - } - } - - &__share-hint { - margin-top: 0.5rem; - font-size: 0.75rem; - color: var(--c--theme--colors--greyscale-500); - } +.c__modal__scroller:has(.c__share-modal) .c__modal__close .c__button { + top: 0.75rem !important; + right: 0.75rem !important; } // ============================================================================ diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx index 8d6639f..40f010d 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { useCalendarContext } from "../../contexts"; import { CalendarModal } from "./CalendarModal"; +import { CalendarShareModal } from "./CalendarShareModal"; import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { ImportEventsModal } from "./ImportEventsModal"; import { SubscriptionUrlModal } from "./SubscriptionUrlModal"; @@ -25,20 +26,21 @@ export const CalendarList = () => { createCalendar, updateCalendar, deleteCalendar, - shareCalendar, calendarRef, } = useCalendarContext(); const { modalState, deleteState, + shareModalState, isMyCalendarsExpanded, openMenuUrl, handleOpenCreateModal, handleOpenEditModal, handleCloseModal, handleSaveCalendar, - handleShareCalendar, + handleOpenShareModal, + handleCloseShareModal, handleOpenDeleteModal, handleCloseDeleteModal, handleConfirmDelete, @@ -49,7 +51,6 @@ export const CalendarList = () => { createCalendar, updateCalendar, deleteCalendar, - shareCalendar, }); // Subscription modal state @@ -147,6 +148,7 @@ export const CalendarList = () => { onMenuToggle={handleMenuToggle} onEdit={handleOpenEditModal} onDelete={handleOpenDeleteModal} + onShare={handleOpenShareModal} onImport={handleOpenImportModal} onSubscription={handleOpenSubscriptionModal} onCloseMenu={handleCloseMenu} @@ -163,7 +165,12 @@ export const CalendarList = () => { calendar={modalState.calendar} onClose={handleCloseModal} onSave={handleSaveCalendar} - onShare={handleShareCalendar} + /> + + onEdit(calendar)} onDelete={() => onDelete(calendar)} + onShare={ + onShare ? () => onShare(calendar) : undefined + } onImport={ onImport ? () => onImport(calendar) : undefined } diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx index 62f17d4..93c839f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarModal.tsx @@ -1,6 +1,6 @@ /** * CalendarModal component. - * Handles creation and editing of calendars, including sharing. + * Handles creation and editing of calendars. */ import { useState, useEffect } from "react"; @@ -22,7 +22,6 @@ export const CalendarModal = ({ calendar, onClose, onSave, - onShare, }: CalendarModalProps) => { const { t } = useTranslation(); const [name, setName] = useState(""); @@ -31,12 +30,6 @@ export const CalendarModal = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // Share state - const [shareEmail, setShareEmail] = useState(""); - const [isSharing, setIsSharing] = useState(false); - const [shareSuccess, setShareSuccess] = useState(null); - const [shareError, setShareError] = useState(null); - // Reset form when modal opens or calendar changes useEffect(() => { if (isOpen) { @@ -50,9 +43,6 @@ export const CalendarModal = ({ setDescription(""); } setError(null); - setShareEmail(""); - setShareSuccess(null); - setShareError(null); } }, [isOpen, mode, calendar]); @@ -74,47 +64,11 @@ export const CalendarModal = ({ } }; - const handleShare = async () => { - if (!shareEmail.trim() || !onShare) return; - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(shareEmail.trim())) { - setShareError(t('calendar.shareCalendar.invalidEmail')); - return; - } - - setIsSharing(true); - setShareError(null); - setShareSuccess(null); - - try { - const result = await onShare(shareEmail.trim()); - if (result.success) { - setShareSuccess( - t('calendar.shareCalendar.success', { email: shareEmail.trim() }) - ); - setShareEmail(""); - } else { - setShareError(result.error || t('calendar.shareCalendar.error')); - } - } catch (err) { - setShareError( - err instanceof Error ? err.message : t('calendar.shareCalendar.error') - ); - } finally { - setIsSharing(false); - } - }; - const handleClose = () => { setName(""); setColor(DEFAULT_COLORS[0]); setDescription(""); setError(null); - setShareEmail(""); - setShareSuccess(null); - setShareError(null); onClose(); }; @@ -186,50 +140,6 @@ export const CalendarModal = ({ rows={2} fullWidth /> - - {/* Share section - only visible in edit mode */} - {mode === "edit" && onShare && ( -
-
- - - {shareSuccess && ( -
{shareSuccess}
- )} - {shareError && ( -
{shareError}
- )} - -
- setShareEmail(e.target.value)} - placeholder={t('calendar.shareCalendar.emailPlaceholder')} - fullWidth - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleShare(); - } - }} - /> - -
-

- {t('calendar.shareCalendar.hint')} -

-
- )}
); diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarShareModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarShareModal.tsx new file mode 100644 index 0000000..2d22072 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarShareModal.tsx @@ -0,0 +1,218 @@ +/** + * CalendarShareModal component. + * Wraps the UI Kit ShareModal for managing calendar sharing via CalDAV. + */ + +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ShareModal } from "@gouvfr-lasuite/ui-kit"; + +import { useCalendarContext } from "../../contexts"; +import { useAuth } from "../../../auth/Auth"; +import { + addToast, + ToasterItem, +} from "../../../ui/components/toaster/Toaster"; +import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; + +interface CalendarShareModalProps { + isOpen: boolean; + calendar: CalDavCalendar | null; + onClose: () => void; +} + +type ShareUser = { + id: string; + full_name: string; + email: string; +}; + +type ShareAccess = { + id: string; + role: string; + user: ShareUser; + can_delete?: boolean; +}; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const CalendarShareModal = ({ + isOpen, + calendar, + onClose, +}: CalendarShareModalProps) => { + const { t } = useTranslation(); + const { caldavService, shareCalendar } = useCalendarContext(); + const { user } = useAuth(); + const [accesses, setAccesses] = useState([]); + const [searchResults, setSearchResults] = useState([]); + const [loading, setLoading] = useState(false); + + const buildAccesses = useCallback( + (sharees: ShareAccess[]) => { + const ownerAccess: ShareAccess | null = user + ? { + id: "owner", + role: "owner", + can_delete: false, + user: { + id: user.id, + full_name: user.email, + email: user.email, + }, + } + : null; + return ownerAccess ? [ownerAccess, ...sharees] : sharees; + }, + [user], + ); + + const fetchSharees = useCallback(async () => { + if (!calendar) return; + + const result = await caldavService.getCalendarSharees(calendar.url); + if (result.success && result.data) { + const shareeAccesses = result.data.map((sharee) => { + const email = sharee.href.replace(/^mailto:/, ""); + return { + id: sharee.href, + role: "read-write", + user: { + id: sharee.href, + full_name: sharee.displayName || email, + email, + }, + }; + }); + setAccesses(buildAccesses(shareeAccesses)); + } else { + setAccesses(buildAccesses([])); + } + }, [calendar, caldavService, buildAccesses]); + + useEffect(() => { + if (isOpen && calendar) { + fetchSharees(); + } + if (!isOpen) { + setAccesses([]); + setSearchResults([]); + } + }, [isOpen, calendar, fetchSharees]); + + const handleSearchUsers = useCallback((query: string) => { + if (EMAIL_REGEX.test(query.trim())) { + const email = query.trim(); + setSearchResults([ + { id: email, email, full_name: email }, + ]); + } else { + setSearchResults([]); + } + }, []); + + const handleInviteUser = useCallback( + async (users: ShareUser[]) => { + if (!calendar || users.length === 0) return; + + setLoading(true); + try { + const user = users[0]; + const result = await shareCalendar(calendar.url, user.email); + if (result.success) { + addToast( + + {t("calendar.shareCalendar.success", { + email: user.email, + })} + , + ); + await fetchSharees(); + } else { + addToast( + + {result.error || t("calendar.shareCalendar.error")} + , + ); + } + } catch { + addToast( + + {t("calendar.shareCalendar.error")} + , + ); + } finally { + setLoading(false); + setSearchResults([]); + } + }, + [calendar, shareCalendar, fetchSharees, t], + ); + + const handleDeleteAccess = useCallback( + async (access: ShareAccess) => { + if (!calendar) return; + + setLoading(true); + try { + const shareeHref = access.id.startsWith("mailto:") + ? access.id + : `mailto:${access.user.email}`; + const result = await caldavService.unshareCalendar( + calendar.url, + shareeHref, + ); + if (result.success) { + await fetchSharees(); + } else { + addToast( + + {result.error || t("calendar.shareCalendar.error")} + , + ); + } + } catch { + addToast( + + {t("calendar.shareCalendar.error")} + , + ); + } finally { + setLoading(false); + } + }, + [calendar, caldavService, fetchSharees, t], + ); + + const invitationRoles = [ + { label: t("roles.editor"), value: "read-write" }, + ]; + + const getAccessRoles = useCallback( + (access: ShareAccess) => { + if (access.role === "owner") { + return [{ label: t("roles.owner"), value: "owner" }]; + } + return [{ label: t("roles.editor"), value: "read-write" }]; + }, + [t], + ); + + return ( + + ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts index a4e840f..f53adf0 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts @@ -10,7 +10,7 @@ import type { CalDavCalendarCreate, CalDavCalendarUpdate, } from "../../../services/dav/types/caldav-service"; -import type { CalendarModalState, DeleteState } from "../types"; +import type { CalendarModalState, DeleteState, ShareModalState } from "../types"; interface UseCalendarListStateProps { createCalendar: ( @@ -21,17 +21,12 @@ interface UseCalendarListStateProps { options: CalDavCalendarUpdate ) => Promise<{ success: boolean; error?: string }>; deleteCalendar: (url: string) => Promise<{ success: boolean; error?: string }>; - shareCalendar: ( - url: string, - email: string - ) => Promise<{ success: boolean; error?: string }>; } export const useCalendarListState = ({ createCalendar, updateCalendar, deleteCalendar, - shareCalendar, }: UseCalendarListStateProps) => { // Modal states const [modalState, setModalState] = useState({ @@ -46,6 +41,11 @@ export const useCalendarListState = ({ isLoading: false, }); + const [shareModalState, setShareModalState] = useState({ + isOpen: false, + calendar: null, + }); + const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true); const [openMenuUrl, setOpenMenuUrl] = useState(null); @@ -100,15 +100,14 @@ export const useCalendarListState = ({ [modalState, createCalendar, updateCalendar] ); - const handleShareCalendar = useCallback( - async (email: string): Promise<{ success: boolean; error?: string }> => { - if (!modalState.calendar) { - return { success: false, error: 'No calendar selected' }; - } - return shareCalendar(modalState.calendar.url, email); - }, - [modalState.calendar, shareCalendar] - ); + // Share modal handlers + const handleOpenShareModal = useCallback((calendar: CalDavCalendar) => { + setShareModalState({ isOpen: true, calendar }); + }, []); + + const handleCloseShareModal = useCallback(() => { + setShareModalState({ isOpen: false, calendar: null }); + }, []); // Delete handlers const handleOpenDeleteModal = useCallback((calendar: CalDavCalendar) => { @@ -164,6 +163,7 @@ export const useCalendarListState = ({ // Modal state modalState, deleteState, + shareModalState, // Expansion state isMyCalendarsExpanded, @@ -174,7 +174,10 @@ export const useCalendarListState = ({ handleOpenEditModal, handleCloseModal, handleSaveCalendar, - handleShareCalendar, + + // Share modal handlers + handleOpenShareModal, + handleCloseShareModal, // Delete handlers handleOpenDeleteModal, diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts index 03ec398..275a78b 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts @@ -13,7 +13,6 @@ export interface CalendarModalProps { calendar?: CalDavCalendar | null; onClose: () => void; onSave: (name: string, color: string, description?: string) => Promise; - onShare?: (email: string) => Promise<{ success: boolean; error?: string }>; } /** @@ -24,6 +23,7 @@ export interface CalendarItemMenuProps { onOpenChange: (isOpen: boolean) => void; onEdit: () => void; onDelete: () => void; + onShare?: () => void; onImport?: () => void; onSubscription?: () => void; } @@ -50,6 +50,7 @@ export interface CalendarListItemProps { onMenuToggle: (url: string) => void; onEdit: (calendar: CalDavCalendar) => void; onDelete: (calendar: CalDavCalendar) => void; + onShare?: (calendar: CalDavCalendar) => void; onImport?: (calendar: CalDavCalendar) => void; onSubscription?: (calendar: CalDavCalendar) => void; onCloseMenu: () => void; @@ -64,6 +65,14 @@ export interface CalendarModalState { calendar: CalDavCalendar | null; } +/** + * State for the share modal. + */ +export interface ShareModalState { + isOpen: boolean; + calendar: CalDavCalendar | null; +} + /** * State for the delete confirmation. */ diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 5fe2471..d0b8d8e 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -183,6 +183,7 @@ "showCalendar": "Show calendar", "edit": "Edit", "delete": "Delete", + "share": "Share", "import": "Import events", "subscription": "Subscription URL", "options": "Options" @@ -809,6 +810,7 @@ "showCalendar": "Afficher le calendrier", "edit": "Modifier", "delete": "Supprimer", + "share": "Partager", "import": "Importer des événements", "subscription": "URL d'abonnement", "options": "Options" @@ -1182,6 +1184,7 @@ "showCalendar": "Agenda tonen", "edit": "Bewerken", "delete": "Verwijderen", + "share": "Delen", "import": "Evenementen importeren", "subscription": "Abonnements-URL", "options": "Opties"