(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,
onEdit,
onDelete,
onShare,
onImport,
onSubscription,
}: 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) {
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 (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>

View File

@@ -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;
}
// ============================================================================

View File

@@ -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}
/>
<CalendarShareModal
isOpen={shareModalState.isOpen}
calendar={shareModalState.calendar}
onClose={handleCloseShareModal}
/>
<DeleteConfirmModal

View File

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

View File

@@ -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<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
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 && (
<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>
</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,
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<CalendarModalState>({
@@ -46,6 +41,11 @@ export const useCalendarListState = ({
isLoading: false,
});
const [shareModalState, setShareModalState] = useState<ShareModalState>({
isOpen: false,
calendar: null,
});
const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true);
const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(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,

View File

@@ -13,7 +13,6 @@ export interface CalendarModalProps {
calendar?: CalDavCalendar | null;
onClose: () => 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;
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.
*/

View File

@@ -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"