(sharing) use UI Kit native sharing modal

This commit is contained in:
Sylvain Zimmer
2026-02-11 18:36:55 +01:00
parent ef1583b595
commit a38845e523
9 changed files with 283 additions and 141 deletions

View File

@@ -15,6 +15,7 @@ export const CalendarItemMenu = ({
onOpenChange, onOpenChange,
onEdit, onEdit,
onDelete, onDelete,
onShare,
onImport, onImport,
onSubscription, onSubscription,
}: CalendarItemMenuProps) => { }: CalendarItemMenuProps) => {
@@ -29,6 +30,14 @@ export const CalendarItemMenu = ({
}, },
]; ];
if (onShare) {
items.push({
label: t("calendar.list.share"),
icon: <span className="material-icons">person_add</span>,
callback: onShare,
});
}
if (onImport) { if (onImport) {
items.push({ items.push({
label: t("calendar.list.import"), label: t("calendar.list.import"),
@@ -52,7 +61,7 @@ export const CalendarItemMenu = ({
}); });
return items; return items;
}, [t, onEdit, onDelete, onImport, onSubscription]); }, [t, onEdit, onDelete, onShare, onImport, onSubscription]);
return ( return (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}> <DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>

View File

@@ -279,36 +279,15 @@
} }
} }
&__share-section { }
margin-top: 0.5rem;
}
&__share-divider { // ============================================================================
height: 1px; // Share Modal Styles (close button position fix)
background-color: var(--c--theme--colors--greyscale-200); // ============================================================================
margin-bottom: 1rem;
}
&__share-input-row { .c__modal__scroller:has(.c__share-modal) .c__modal__close .c__button {
display: flex; top: 0.75rem !important;
gap: 0.5rem; right: 0.75rem !important;
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);
}
} }
// ============================================================================ // ============================================================================

View File

@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useCalendarContext } from "../../contexts"; import { useCalendarContext } from "../../contexts";
import { CalendarModal } from "./CalendarModal"; import { CalendarModal } from "./CalendarModal";
import { CalendarShareModal } from "./CalendarShareModal";
import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { DeleteConfirmModal } from "./DeleteConfirmModal";
import { ImportEventsModal } from "./ImportEventsModal"; import { ImportEventsModal } from "./ImportEventsModal";
import { SubscriptionUrlModal } from "./SubscriptionUrlModal"; import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
@@ -25,20 +26,21 @@ export const CalendarList = () => {
createCalendar, createCalendar,
updateCalendar, updateCalendar,
deleteCalendar, deleteCalendar,
shareCalendar,
calendarRef, calendarRef,
} = useCalendarContext(); } = useCalendarContext();
const { const {
modalState, modalState,
deleteState, deleteState,
shareModalState,
isMyCalendarsExpanded, isMyCalendarsExpanded,
openMenuUrl, openMenuUrl,
handleOpenCreateModal, handleOpenCreateModal,
handleOpenEditModal, handleOpenEditModal,
handleCloseModal, handleCloseModal,
handleSaveCalendar, handleSaveCalendar,
handleShareCalendar, handleOpenShareModal,
handleCloseShareModal,
handleOpenDeleteModal, handleOpenDeleteModal,
handleCloseDeleteModal, handleCloseDeleteModal,
handleConfirmDelete, handleConfirmDelete,
@@ -49,7 +51,6 @@ export const CalendarList = () => {
createCalendar, createCalendar,
updateCalendar, updateCalendar,
deleteCalendar, deleteCalendar,
shareCalendar,
}); });
// Subscription modal state // Subscription modal state
@@ -147,6 +148,7 @@ export const CalendarList = () => {
onMenuToggle={handleMenuToggle} onMenuToggle={handleMenuToggle}
onEdit={handleOpenEditModal} onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteModal} onDelete={handleOpenDeleteModal}
onShare={handleOpenShareModal}
onImport={handleOpenImportModal} onImport={handleOpenImportModal}
onSubscription={handleOpenSubscriptionModal} onSubscription={handleOpenSubscriptionModal}
onCloseMenu={handleCloseMenu} onCloseMenu={handleCloseMenu}
@@ -163,7 +165,12 @@ export const CalendarList = () => {
calendar={modalState.calendar} calendar={modalState.calendar}
onClose={handleCloseModal} onClose={handleCloseModal}
onSave={handleSaveCalendar} onSave={handleSaveCalendar}
onShare={handleShareCalendar} />
<CalendarShareModal
isOpen={shareModalState.isOpen}
calendar={shareModalState.calendar}
onClose={handleCloseShareModal}
/> />
<DeleteConfirmModal <DeleteConfirmModal

View File

@@ -20,6 +20,7 @@ export const CalendarListItem = ({
onMenuToggle, onMenuToggle,
onEdit, onEdit,
onDelete, onDelete,
onShare,
onImport, onImport,
onSubscription, onSubscription,
onCloseMenu, onCloseMenu,
@@ -53,6 +54,9 @@ export const CalendarListItem = ({
} }
onEdit={() => onEdit(calendar)} onEdit={() => onEdit(calendar)}
onDelete={() => onDelete(calendar)} onDelete={() => onDelete(calendar)}
onShare={
onShare ? () => onShare(calendar) : undefined
}
onImport={ onImport={
onImport ? () => onImport(calendar) : undefined onImport ? () => onImport(calendar) : undefined
} }

View File

@@ -1,6 +1,6 @@
/** /**
* CalendarModal component. * CalendarModal component.
* Handles creation and editing of calendars, including sharing. * Handles creation and editing of calendars.
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
@@ -22,7 +22,6 @@ export const CalendarModal = ({
calendar, calendar,
onClose, onClose,
onSave, onSave,
onShare,
}: CalendarModalProps) => { }: CalendarModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -31,12 +30,6 @@ export const CalendarModal = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Share state
const [shareEmail, setShareEmail] = useState("");
const [isSharing, setIsSharing] = useState(false);
const [shareSuccess, setShareSuccess] = useState<string | null>(null);
const [shareError, setShareError] = useState<string | null>(null);
// Reset form when modal opens or calendar changes // Reset form when modal opens or calendar changes
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -50,9 +43,6 @@ export const CalendarModal = ({
setDescription(""); setDescription("");
} }
setError(null); setError(null);
setShareEmail("");
setShareSuccess(null);
setShareError(null);
} }
}, [isOpen, mode, calendar]); }, [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 = () => { const handleClose = () => {
setName(""); setName("");
setColor(DEFAULT_COLORS[0]); setColor(DEFAULT_COLORS[0]);
setDescription(""); setDescription("");
setError(null); setError(null);
setShareEmail("");
setShareSuccess(null);
setShareError(null);
onClose(); onClose();
}; };
@@ -186,50 +140,6 @@ export const CalendarModal = ({
rows={2} rows={2}
fullWidth fullWidth
/> />
{/* Share section - only visible in edit mode */}
{mode === "edit" && onShare && (
<div className="calendar-modal__share-section">
<div className="calendar-modal__share-divider" />
<label className="calendar-modal__label">
<span className="material-icons">person_add</span>
{t('calendar.shareCalendar.title')}
</label>
{shareSuccess && (
<div className="calendar-modal__success">{shareSuccess}</div>
)}
{shareError && (
<div className="calendar-modal__error">{shareError}</div>
)}
<div className="calendar-modal__share-input-row">
<Input
label=""
value={shareEmail}
onChange={(e) => setShareEmail(e.target.value)}
placeholder={t('calendar.shareCalendar.emailPlaceholder')}
fullWidth
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleShare();
}
}}
/>
<Button
color="brand"
onClick={handleShare}
disabled={isSharing || !shareEmail.trim()}
>
{isSharing ? "..." : t('calendar.shareCalendar.share')}
</Button>
</div>
<p className="calendar-modal__share-hint">
{t('calendar.shareCalendar.hint')}
</p>
</div>
)}
</div> </div>
</Modal> </Modal>
); );

View File

@@ -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<ShareAccess[]>([]);
const [searchResults, setSearchResults] = useState<ShareUser[]>([]);
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(
<ToasterItem>
{t("calendar.shareCalendar.success", {
email: user.email,
})}
</ToasterItem>,
);
await fetchSharees();
} else {
addToast(
<ToasterItem type="error">
{result.error || t("calendar.shareCalendar.error")}
</ToasterItem>,
);
}
} catch {
addToast(
<ToasterItem type="error">
{t("calendar.shareCalendar.error")}
</ToasterItem>,
);
} 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(
<ToasterItem type="error">
{result.error || t("calendar.shareCalendar.error")}
</ToasterItem>,
);
}
} catch {
addToast(
<ToasterItem type="error">
{t("calendar.shareCalendar.error")}
</ToasterItem>,
);
} 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 (
<ShareModal
isOpen={isOpen}
onClose={onClose}
modalTitle={t("calendar.shareCalendar.title")}
accesses={accesses}
getAccessRoles={getAccessRoles}
onDeleteAccess={handleDeleteAccess}
searchUsersResult={searchResults}
onSearchUsers={handleSearchUsers}
onInviteUser={handleInviteUser}
searchPlaceholder={t("calendar.shareCalendar.emailPlaceholder")}
invitationRoles={invitationRoles}
hideInvitations
loading={loading}
/>
);
};

View File

@@ -10,7 +10,7 @@ import type {
CalDavCalendarCreate, CalDavCalendarCreate,
CalDavCalendarUpdate, CalDavCalendarUpdate,
} from "../../../services/dav/types/caldav-service"; } from "../../../services/dav/types/caldav-service";
import type { CalendarModalState, DeleteState } from "../types"; import type { CalendarModalState, DeleteState, ShareModalState } from "../types";
interface UseCalendarListStateProps { interface UseCalendarListStateProps {
createCalendar: ( createCalendar: (
@@ -21,17 +21,12 @@ interface UseCalendarListStateProps {
options: CalDavCalendarUpdate options: CalDavCalendarUpdate
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
deleteCalendar: (url: string) => 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 = ({ export const useCalendarListState = ({
createCalendar, createCalendar,
updateCalendar, updateCalendar,
deleteCalendar, deleteCalendar,
shareCalendar,
}: UseCalendarListStateProps) => { }: UseCalendarListStateProps) => {
// Modal states // Modal states
const [modalState, setModalState] = useState<CalendarModalState>({ const [modalState, setModalState] = useState<CalendarModalState>({
@@ -46,6 +41,11 @@ export const useCalendarListState = ({
isLoading: false, isLoading: false,
}); });
const [shareModalState, setShareModalState] = useState<ShareModalState>({
isOpen: false,
calendar: null,
});
const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true); const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true);
const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(null); const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(null);
@@ -100,15 +100,14 @@ export const useCalendarListState = ({
[modalState, createCalendar, updateCalendar] [modalState, createCalendar, updateCalendar]
); );
const handleShareCalendar = useCallback( // Share modal handlers
async (email: string): Promise<{ success: boolean; error?: string }> => { const handleOpenShareModal = useCallback((calendar: CalDavCalendar) => {
if (!modalState.calendar) { setShareModalState({ isOpen: true, calendar });
return { success: false, error: 'No calendar selected' }; }, []);
}
return shareCalendar(modalState.calendar.url, email); const handleCloseShareModal = useCallback(() => {
}, setShareModalState({ isOpen: false, calendar: null });
[modalState.calendar, shareCalendar] }, []);
);
// Delete handlers // Delete handlers
const handleOpenDeleteModal = useCallback((calendar: CalDavCalendar) => { const handleOpenDeleteModal = useCallback((calendar: CalDavCalendar) => {
@@ -164,6 +163,7 @@ export const useCalendarListState = ({
// Modal state // Modal state
modalState, modalState,
deleteState, deleteState,
shareModalState,
// Expansion state // Expansion state
isMyCalendarsExpanded, isMyCalendarsExpanded,
@@ -174,7 +174,10 @@ export const useCalendarListState = ({
handleOpenEditModal, handleOpenEditModal,
handleCloseModal, handleCloseModal,
handleSaveCalendar, handleSaveCalendar,
handleShareCalendar,
// Share modal handlers
handleOpenShareModal,
handleCloseShareModal,
// Delete handlers // Delete handlers
handleOpenDeleteModal, handleOpenDeleteModal,

View File

@@ -13,7 +13,6 @@ export interface CalendarModalProps {
calendar?: CalDavCalendar | null; calendar?: CalDavCalendar | null;
onClose: () => void; onClose: () => void;
onSave: (name: string, color: string, description?: string) => Promise<void>; onSave: (name: string, color: string, description?: string) => Promise<void>;
onShare?: (email: string) => Promise<{ success: boolean; error?: string }>;
} }
/** /**
@@ -24,6 +23,7 @@ export interface CalendarItemMenuProps {
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onShare?: () => void;
onImport?: () => void; onImport?: () => void;
onSubscription?: () => void; onSubscription?: () => void;
} }
@@ -50,6 +50,7 @@ export interface CalendarListItemProps {
onMenuToggle: (url: string) => void; onMenuToggle: (url: string) => void;
onEdit: (calendar: CalDavCalendar) => void; onEdit: (calendar: CalDavCalendar) => void;
onDelete: (calendar: CalDavCalendar) => void; onDelete: (calendar: CalDavCalendar) => void;
onShare?: (calendar: CalDavCalendar) => void;
onImport?: (calendar: CalDavCalendar) => void; onImport?: (calendar: CalDavCalendar) => void;
onSubscription?: (calendar: CalDavCalendar) => void; onSubscription?: (calendar: CalDavCalendar) => void;
onCloseMenu: () => void; onCloseMenu: () => void;
@@ -64,6 +65,14 @@ export interface CalendarModalState {
calendar: CalDavCalendar | null; calendar: CalDavCalendar | null;
} }
/**
* State for the share modal.
*/
export interface ShareModalState {
isOpen: boolean;
calendar: CalDavCalendar | null;
}
/** /**
* State for the delete confirmation. * State for the delete confirmation.
*/ */

View File

@@ -183,6 +183,7 @@
"showCalendar": "Show calendar", "showCalendar": "Show calendar",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"share": "Share",
"import": "Import events", "import": "Import events",
"subscription": "Subscription URL", "subscription": "Subscription URL",
"options": "Options" "options": "Options"
@@ -809,6 +810,7 @@
"showCalendar": "Afficher le calendrier", "showCalendar": "Afficher le calendrier",
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
"share": "Partager",
"import": "Importer des événements", "import": "Importer des événements",
"subscription": "URL d'abonnement", "subscription": "URL d'abonnement",
"options": "Options" "options": "Options"
@@ -1182,6 +1184,7 @@
"showCalendar": "Agenda tonen", "showCalendar": "Agenda tonen",
"edit": "Bewerken", "edit": "Bewerken",
"delete": "Verwijderen", "delete": "Verwijderen",
"share": "Delen",
"import": "Evenementen importeren", "import": "Evenementen importeren",
"subscription": "Abonnements-URL", "subscription": "Abonnements-URL",
"options": "Opties" "options": "Opties"