#708 apply strictier linting rules (#717)

* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+69 -72
View File
@@ -1,8 +1,5 @@
import {
exportCalendar,
getSecretLink,
} from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { exportCalendar, getSecretLink } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
Box,
Button,
@@ -10,90 +7,90 @@ import {
IconButton,
InputAdornment,
TextField,
Typography,
} from "@linagora/twake-mui";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights";
Typography
} from '@linagora/twake-mui'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { SnackbarAlert } from '../Loading/SnackBarAlert'
import { CalendarAccessRights, UserWithAccess } from './CalendarAccessRights'
interface AccessTabProps {
calendar: Calendar;
usersWithAccess: UserWithAccess[];
onUsersWithAccessChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
calendar: Calendar
usersWithAccess: UserWithAccess[]
onUsersWithAccessChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void
}
export function AccessTab({
calendar,
usersWithAccess,
onUsersWithAccessChange,
onInvitesLoaded,
onInvitesLoaded
}: AccessTabProps) {
const { t } = useI18n();
const { t } = useI18n()
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace('.json', '')}`
const isResource = useMemo(
() => calendar?.owner?.resource,
[calendar?.owner?.resource]
);
)
const [secretLink, setSecretLink] = useState("");
const [open, setOpen] = useState(false);
const [secretLink, setSecretLink] = useState('')
const [open, setOpen] = useState(false)
useEffect(() => {
async function fetchSecret() {
const existing = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
false
);
setSecretLink(existing.secretLink);
)
setSecretLink(existing.secretLink)
}
fetchSecret();
}, [calendar.link]);
fetchSecret()
}, [calendar.link])
const handleCopy = (content: string) => {
navigator.clipboard.writeText(content);
setOpen(true);
};
navigator.clipboard.writeText(content)
setOpen(true)
}
const handleResetSecretLink = async () => {
const newSecret = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
true
);
setSecretLink(newSecret.secretLink);
};
)
setSecretLink(newSecret.secretLink)
}
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState("");
const [exportLoading, setExportLoading] = useState(false)
const [exportError, setExportError] = useState('')
const handleExport = async () => {
try {
setExportLoading(true);
setExportLoading(true)
const exportedData = await exportCalendar(
calendar.link.replace(".json", "")
);
const blob = new Blob([exportedData], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${calendar.id.split("/")[1]}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
calendar.link.replace('.json', '')
)
const blob = new Blob([exportedData], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${calendar.id.split('/')[1]}.ics`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
setExportError((e as Error).message);
setExportError((e as Error).message)
} finally {
setExportLoading(false);
setExportLoading(false)
}
};
}
return (
<>
@@ -105,13 +102,13 @@ export function AccessTab({
/>
{!!window.DAV_BASE_URL && !isResource && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<FieldWithLabel label={t('calendar.caldav_access')} isExpanded={false}>
<Box mt={2}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.caldav_access") }}
inputProps={{ 'aria-label': t('calendar.caldav_access') }}
value={calDAVLink}
size="small"
InputProps={{
@@ -124,20 +121,20 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
</Box>
</FieldWithLabel>
)}
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
<FieldWithLabel label={t('calendar.secretUrl')} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.secretUrl") }}
inputProps={{ 'aria-label': t('calendar.secretUrl') }}
value={secretLink}
size="small"
InputProps={{
@@ -147,32 +144,32 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleResetSecretLink}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{t("actions.reset")}
{t('actions.reset')}
</Button>
</Box>
<Typography
variant="body2"
sx={{ color: "text.secondary", mt: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', mt: 1, lineHeight: 1.5 }}
>
{t("calendar.secretUrlDesc")}
{t('calendar.secretUrlDesc')}
</Typography>
</FieldWithLabel>
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
<FieldWithLabel label={t('calendar.exportCalendar')} isExpanded={false}>
<Typography
variant="body2"
sx={{ color: "text.secondary", my: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', my: 1, lineHeight: 1.5 }}
>
{t("calendar.exportDesc")}
{t('calendar.exportDesc')}
</Typography>
<Button
variant="contained"
@@ -180,15 +177,15 @@ export function AccessTab({
onClick={handleExport}
startIcon={!exportLoading && <FileDownloadOutlinedIcon />}
disabled={exportLoading}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{exportLoading ? (
<Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={18} />
{t("actions.exporting")}
{t('actions.exporting')}
</Box>
) : (
t("actions.export")
t('actions.export')
)}
</Button>
</FieldWithLabel>
@@ -196,9 +193,9 @@ export function AccessTab({
<SnackbarAlert
setOpen={setOpen}
open={open}
message={t("common.link_copied")}
message={t('common.link_copied')}
/>
<ErrorSnackbar error={exportError} type="calendar" />
</>
);
)
}
+250 -246
View File
@@ -1,195 +1,199 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import EventPopover from "@/features/Events/EventModal";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { setTimeZone } from "@/features/Settings/SettingsSlice";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { browserDefaultTimeZone } from "@/utils/timezone";
import type { EventApi, LocaleInput } from "@fullcalendar/core";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import frLocale from "@fullcalendar/core/locales/fr";
import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import momentTimezonePlugin from "@fullcalendar/moment-timezone";
import FullCalendar from "@fullcalendar/react";
import timeGridPlugin from "@fullcalendar/timegrid";
import { Box, Button, radius } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import moment from "moment-timezone";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
import { User } from "../Attendees/PeopleSearch";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EditModeDialog } from "../Event/EditModeDialog";
import { Menubar, MenubarProps } from "../Menubar/Menubar";
import "./Calendar.styl";
import CalendarSelection from "./CalendarSelection";
import "./CustomCalendar.styl";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import EventPopover from '@/features/Events/EventModal'
import EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import ImportAlert from '@/features/Events/ImportAlert'
import SearchResultsPage from '@/features/Search/SearchResultsPage'
import { setTimeZone } from '@/features/Settings/SettingsSlice'
import { setDisplayedDateAndRange } from '@/utils/CalendarRangeManager'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { setSelectedCalendars as setSelectedCalendarsToStorage } from '@/utils/storage/setSelectedCalendars'
import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars'
import { browserDefaultTimeZone } from '@/utils/timezone'
import type { EventApi, LocaleInput } from '@fullcalendar/core'
import { CalendarApi, DateSelectArg } from '@fullcalendar/core'
import frLocale from '@fullcalendar/core/locales/fr'
import ruLocale from '@fullcalendar/core/locales/ru'
import viLocale from '@fullcalendar/core/locales/vi'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import momentTimezonePlugin from '@fullcalendar/moment-timezone'
import FullCalendar from '@fullcalendar/react'
import timeGridPlugin from '@fullcalendar/timegrid'
import { Box, Button, radius } from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import moment from 'moment-timezone'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { useCalendarDataLoader } from '../../features/Calendars/useCalendarLoader'
import { User } from '../Attendees/PeopleSearch'
import { EventErrorSnackbar } from '../Error/ErrorSnackbar'
import { EventErrorHandler } from '../Error/EventErrorHandler'
import { EditModeDialog } from '../Event/EditModeDialog'
import { Menubar, MenubarProps } from '../Menubar/Menubar'
import './Calendar.styl'
import CalendarSelection from './CalendarSelection'
import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import { MiniCalendar } from './MiniCalendar'
import { TempCalendarsInput } from './TempCalendarsInput'
import { TimezoneSelector } from './TimezoneSelector'
import {
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility,
} from "./utils/calendarUtils";
updateSlotLabelVisibility
} from './utils/calendarUtils'
const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale,
ru: ruLocale,
vi: viLocale,
en: undefined,
};
en: undefined
}
interface CalendarAppProps {
calendarRef: MutableRefObject<CalendarApi | null>;
onDateChange?: (date: Date) => void;
onViewChange?: (view: string) => void;
menubarProps?: MenubarProps;
calendarRef: MutableRefObject<CalendarApi | null>
onDateChange?: (date: Date) => void
onViewChange?: (view: string) => void
menubarProps?: MenubarProps
}
export default function CalendarApp({
calendarRef,
onDateChange,
onViewChange,
menubarProps,
menubarProps
}: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
const [debouncedDate, setDebouncedDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(new Date())
const [debouncedDate, setDebouncedDate] = useState(new Date())
useEffect(() => {
const t = setTimeout(() => setDebouncedDate(selectedDate), 300);
return () => clearTimeout(t);
}, [selectedDate]);
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch();
const view = useAppSelector((state) => state.settings.view);
const userData = useAppSelector((state) => state.user.userData);
const t = setTimeout(() => setDebouncedDate(selectedDate), 300)
return () => clearTimeout(t)
}, [selectedDate])
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date())
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const dispatch = useAppDispatch()
const view = useAppSelector(state => state.settings.view)
const userData = useAppSelector(state => state.user.userData)
const workingDays = useAppSelector(
(state) => state.settings.businessHours?.daysOfWeek
);
const hideWorkingDays = useAppSelector((state) => state.settings.workingDays);
state => state.settings.businessHours?.daysOfWeek
)
const hideWorkingDays = useAppSelector(state => state.settings.workingDays)
const hideDeclinedEvents = useAppSelector(
(state) => state.settings.hideDeclinedEvents
);
state => state.settings.hideDeclinedEvents
)
const hiddenDays = useMemo(() => {
if (!hideWorkingDays || !workingDays || workingDays.length === 0) return [];
const validWorkingDays = workingDays.filter((d) => d >= 0 && d <= 6);
if (validWorkingDays.length === 0) return [];
return [0, 1, 2, 3, 4, 5, 6].filter((d) => !validWorkingDays.includes(d));
}, [hideWorkingDays, workingDays]);
if (!hideWorkingDays || !workingDays || workingDays.length === 0) return []
const validWorkingDays = workingDays.filter(d => d >= 0 && d <= 6)
if (validWorkingDays.length === 0) return []
return [0, 1, 2, 3, 4, 5, 6].filter(d => !validWorkingDays.includes(d))
}, [hideWorkingDays, workingDays])
const calendars = useAppSelector((state) => state.calendars.list);
const isPending = useAppSelector((state) => state.calendars.pending);
const calendars = useAppSelector(state => state.calendars.list)
const isPending = useAppSelector(state => state.calendars.pending)
const displayWeekNumbers = useAppSelector(
(state) => state.settings.displayWeekNumbers
);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const storedCalendars = useSelectedCalendars();
state => state.settings.displayWeekNumbers
)
const tempcalendars = useAppSelector(state => state.calendars.templist)
const storedCalendars = useSelectedCalendars()
const [selectedCalendars, setSelectedCalendars] =
useState<string[]>(storedCalendars);
useState<string[]>(storedCalendars)
const calendarIdsString = useMemo(
() =>
Object.keys(calendars || {})
.sort()
.join(","),
.join(','),
[calendars]
);
)
const calendarIds = useMemo(
() => (calendarIdsString ? calendarIdsString.split(",") : []),
() => (calendarIdsString ? calendarIdsString.split(',') : []),
[calendarIdsString]
);
)
const [currentView, setCurrentView] = useState("timeGridWeek");
const [currentView, setCurrentView] = useState('timeGridWeek')
const timezone =
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
// Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler());
const initialLoadRef = useRef(true)
const [eventErrors, setEventErrors] = useState<string[]>([])
const errorHandler = useRef(new EventErrorHandler())
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
return () => handler.setErrorCallback(() => {});
}, []);
const handler = errorHandler.current
handler.setErrorCallback(setEventErrors)
return () => handler.setErrorCallback(() => {})
}, [])
const handleErrorClose = () => {
setEventErrors([]);
errorHandler.current.clearAll();
};
setEventErrors([])
errorHandler.current.clearAll()
}
useEffect(() => {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[];
const valid = parsed.filter((id) => calendars[id]);
setSelectedCalendars(valid);
} else {
const personalCalendarIds = calendarIds.filter(
(id) => extractEventBaseUuid(id) === userId
);
setSelectedCalendars(personalCalendarIds);
const updateSelectedCalendars = () => {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem('selectedCalendars')
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[]
const valid = parsed.filter(id => calendars[id])
setSelectedCalendars(valid)
} else {
const personalCalendarIds = calendarIds.filter(
id => extractEventBaseUuid(id) === userId
)
setSelectedCalendars(personalCalendarIds)
}
initialLoadRef.current = false
}
initialLoadRef.current = false;
}
}, [calendarIds, calendars, userId]);
updateSelectedCalendars()
}, [calendarIds, calendars, userId])
// Save selected cals to cache
useEffect(() => {
if (calendarIds.length > 0) {
setSelectedCalendarsToStorage(selectedCalendars);
setSelectedCalendarsToStorage(selectedCalendars)
}
}, [selectedCalendars, calendarIds.length]);
}, [selectedCalendars, calendarIds.length])
useEffect(() => {
if (calendarIds.length === 0) return;
const validCalendarIds = new Set(calendarIds);
setSelectedCalendars((prev) => {
const filtered = prev.filter((calId) => validCalendarIds.has(calId));
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index]);
if (unchanged) {
return prev;
const updateSelectedCalendarsOnCalendarChange = () => {
if (calendarIds.length === 0) return
const validCalendarIds = new Set(calendarIds)
setSelectedCalendars(prev => {
const filtered = prev.filter(calId => validCalendarIds.has(calId))
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index])
if (unchanged) {
return prev
}
}
}
return filtered;
});
}, [calendarIds]);
return filtered
})
}
updateSelectedCalendarsOnCalendarChange()
}, [calendarIds])
const sortedSelectedCalendars = useMemo(
() => [...selectedCalendars].sort(),
[selectedCalendars]
);
)
const tempCalendarIdsString = useMemo(
() =>
Object.keys(tempcalendars || {})
.sort()
.join(","),
.join(','),
[tempcalendars]
);
)
const tempCalendarIds = useMemo(
() => (tempCalendarIdsString ? tempCalendarIdsString.split(",") : []),
() => (tempCalendarIdsString ? tempCalendarIdsString.split(',') : []),
[tempCalendarIdsString]
);
)
useCalendarDataLoader({
selectedDate: debouncedDate,
@@ -198,47 +202,47 @@ export default function CalendarApp({
sortedSelectedCalendars,
calendarIds,
calendarIdsString,
tempCalendarIds,
});
tempCalendarIds
})
const filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
calendars || {},
userData?.email,
hideDeclinedEvents
);
)
const filteredTempEvents: CalendarEvent[] = extractEvents(
tempCalendarIds,
tempcalendars || {},
userData?.email,
hideDeclinedEvents
);
)
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [openEventDisplay, setOpenEventDisplay] = useState(false)
const [eventDisplayedId, setEventDisplayedId] = useState('')
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false)
const [eventDisplayedCalId, setEventDisplayedCalId] = useState('')
// Listen for eventModalError event to reopen modal on API failure
useEffect(() => {
const handleEventModalError = (event: CustomEvent) => {
if (event.detail?.type === "create") {
if (event.detail?.type === 'create') {
// Reopen create event modal
setAnchorEl(document.body);
} else if (event.detail?.type === "update") {
setAnchorEl(document.body)
} else if (event.detail?.type === 'update') {
// Store update modal info to sessionStorage for EventDisplayPreview to pick up
try {
sessionStorage.setItem(
"eventUpdateModalReopen",
'eventUpdateModalReopen',
JSON.stringify({
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
timestamp: Date.now(),
timestamp: Date.now()
})
);
)
// Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage
if (
@@ -246,56 +250,52 @@ export default function CalendarApp({
eventDisplayedId !== event.detail.eventId ||
eventDisplayedCalId !== event.detail.calId
) {
setEventDisplayedId(event.detail.eventId);
setEventDisplayedCalId(event.detail.calId);
setEventDisplayedTemp(false);
setOpenEventDisplay(true);
setEventDisplayedId(event.detail.eventId)
setEventDisplayedCalId(event.detail.calId)
setEventDisplayedTemp(false)
setOpenEventDisplay(true)
} else {
// If EventDisplayPreview is already open, trigger reopen by dispatching a custom event
window.dispatchEvent(
new CustomEvent("eventUpdateModalReopen", {
new CustomEvent('eventUpdateModalReopen', {
detail: {
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
},
typeOfAction: event.detail.typeOfAction
}
})
);
)
}
} catch {
// Ignore sessionStorage errors
}
}
};
}
window.addEventListener(
"eventModalError",
'eventModalError',
handleEventModalError as EventListener
);
)
return () => {
window.removeEventListener(
"eventModalError",
'eventModalError',
handleEventModalError as EventListener
);
};
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]);
)
}
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId])
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined);
)
const [, setTypeOfAction] = useState<'solo' | 'all' | undefined>(undefined)
const [afterChoiceFunc, setAfterChoiceFunc] = useState<
((type: "solo" | "all" | undefined) => void) | undefined
>();
const [, setSelectedEvent] = useState<CalendarEvent>({} as CalendarEvent);
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(
null
);
((type: 'solo' | 'all' | undefined) => void) | undefined
>()
const [, setSelectedEvent] = useState<CalendarEvent>({} as CalendarEvent)
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(null)
const [tempUsers, setTempUsers] = useState<User[]>([]);
const [tempEvent, setTempEvent] = useState<CalendarEvent>(
{} as CalendarEvent
);
const [tempUsers, setTempUsers] = useState<User[]>([])
const [tempEvent, setTempEvent] = useState<CalendarEvent>({} as CalendarEvent)
// Event handlers
const eventHandlers = useCalendarEventHandlers({
@@ -313,8 +313,8 @@ export default function CalendarApp({
setOpenEditModePopup,
tempUsers,
setTempEvent,
timezone,
});
timezone
})
// View handlers
const viewHandlers = useCalendarViewHandlers({
@@ -324,18 +324,22 @@ export default function CalendarApp({
onViewChange,
calendars,
tempcalendars,
errorHandler: errorHandler.current,
});
// Note: To preserve current logic, this will temporarily disable eslint for react-hooks/refs
// eslint-disable-next-line react-hooks/refs
errorHandler: errorHandler.current
})
if (process.env.NODE_ENV === "test") {
window.__calendarRef = calendarRef;
}
useEffect(() => {
if (process.env.NODE_ENV === 'test') {
window.__calendarRef = calendarRef
}
}, [calendarRef])
const { t, lang } = useI18n();
const { t, lang } = useI18n()
return (
<main
className={`main-layout calendar-layout ${menubarProps?.isIframe ? " isInIframe" : ""}`}
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
>
<Box
className="sidebar"
@@ -344,16 +348,16 @@ export default function CalendarApp({
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: "270px",
width: '270px'
}}
>
<Box
sx={{
position: "sticky",
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: "#fff",
paddingTop: menubarProps?.isIframe ? "10px" : 3,
backgroundColor: '#fff',
paddingTop: menubarProps?.isIframe ? '10px' : 3
}}
>
<Button
@@ -365,13 +369,13 @@ export default function CalendarApp({
}
sx={{
borderRadius: radius.lg,
fontSize: "16px",
fontSize: '16px',
fontWeight: 500,
lineHeight: "normal",
lineHeight: 'normal'
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: "20px" }} />{" "}
{t("event.createEvent")}
<AddIcon sx={{ marginRight: 0.5, fontSize: '20px' }} />{' '}
{t('event.createEvent')}
</Button>
</Box>
@@ -385,7 +389,7 @@ export default function CalendarApp({
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={() => {
eventHandlers.handleDateSelect(null as unknown as DateSelectArg);
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}}
/>
</Box>
@@ -399,19 +403,19 @@ export default function CalendarApp({
<div className="calendar">
<ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
{view === "calendar" && (
{view === 'calendar' && (
<FullCalendar
key={hiddenDays.join(",")}
ref={(ref) => {
key={hiddenDays.join(',')}
ref={ref => {
if (ref) {
calendarRef.current = ref.getApi();
calendarRef.current = ref.getApi()
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
momentTimezonePlugin
]}
initialView="timeGridWeek"
firstDay={1}
@@ -420,16 +424,16 @@ export default function CalendarApp({
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height={"100%"}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={(arg) => [
updateSlotLabelVisibility(new Date(), arg, timezone),
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } }
}}
dayMaxEvents={true}
events={eventToFullCalendarFormat(
@@ -444,15 +448,15 @@ export default function CalendarApp({
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === "timeGridWeek" || currentView === "timeGridDay"
currentView === 'timeGridWeek' || currentView === 'timeGridDay'
}
weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<div>
{t("menubar.views.week")} {arg.num}
{t('menubar.views.week')} {arg.num}
</div>
)}
<TimezoneSelector
@@ -463,91 +467,91 @@ export default function CalendarApp({
}
/>
</div>
);
)
}}
dayCellContent={(arg) => {
const month = arg.date.toLocaleDateString(t("locale"), {
month: "short",
timeZone: timezone,
});
if (arg.view.type === "dayGridMonth") {
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === 'dayGridMonth') {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
arg.isToday ? 'current-date' : ''
}`}
>
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
{arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText}
</span>
);
)
}
}}
slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: false,
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={(arg) => {
setCurrentView(arg.view.type);
datesSet={arg => {
setCurrentView(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
setDisplayedDateAndRange(calendarCurrentDate);
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === "dayGridMonth") {
const start = new Date(arg.start).getTime();
const end = new Date(arg.end).getTime();
const middle = start + (end - start) / 2;
setSelectedDate(new Date(middle));
setSelectedMiniDate(calendarCurrentDate);
if (arg.view.type === 'dayGridMonth') {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate);
setSelectedMiniDate(calendarCurrentDate);
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate);
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
if (onViewChange) {
onViewChange(arg.view.type);
onViewChange(arg.view.type)
}
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date());
}, 100);
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={(arg) => {
const m = moment.tz(arg.date, timezone);
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date();
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t("locale"), {
weekday: "short",
timeZone: timezone,
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase();
.toUpperCase()
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== "dayGridMonth" && (
{arg.view.type !== 'dayGridMonth' && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? "current-date" : ""}`}
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
{date}
</span>
)}
</div>
);
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
@@ -561,7 +565,7 @@ export default function CalendarApp({
eventDidMount={viewHandlers.handleEventDidMount}
/>
)}
{view === "search" && <SearchResultsPage />}
{view === 'search' && <SearchResultsPage />}
<EventPopover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
@@ -574,10 +578,10 @@ export default function CalendarApp({
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
eventAction={(type: 'solo' | 'all' | undefined) => {
setTypeOfAction(type)
if (afterChoiceFunc) {
afterChoiceFunc(type);
afterChoiceFunc(type)
}
}}
/>
@@ -593,5 +597,5 @@ export default function CalendarApp({
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div>
</main>
);
)
}
+178 -178
View File
@@ -1,8 +1,8 @@
import { useAppSelector } from "@/app/hooks";
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import { getUserDetails } from "@/features/User/userAPI";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { normalizeEmail } from "@/utils/normalizeEmail";
import { useAppSelector } from '@/app/hooks'
import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes'
import { getUserDetails } from '@/features/User/userAPI'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { normalizeEmail } from '@/utils/normalizeEmail'
import {
AutocompleteRenderInputParams,
Avatar,
@@ -13,236 +13,236 @@ import {
MenuItem,
Select,
TextField,
Typography,
} from "@linagora/twake-mui";
import HighlightOffIcon from "@mui/icons-material/HighlightOff";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { stringAvatar } from "../Event/utils/eventUtils";
import { ResourceAdmin } from "./ResourceAdmins";
Typography
} from '@linagora/twake-mui'
import HighlightOffIcon from '@mui/icons-material/HighlightOff'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ResourceAdmin } from './ResourceAdmins'
export interface UserWithAccess extends User {
accessRight: AccessRight;
accessRight: AccessRight
}
interface CalendarAccessRightsProps {
calendar: Calendar;
value: UserWithAccess[];
onChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
calendar: Calendar
value: UserWithAccess[]
onChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void
}
interface UserInCalendar {
id: string;
access: AccessRight;
id: string
access: AccessRight
}
export function CalendarAccessRights({
calendar,
value: usersWithAccess,
onChange,
onInvitesLoaded,
onInvitesLoaded
}: CalendarAccessRightsProps) {
const { t } = useI18n();
const userData = useAppSelector((state) => state.user.userData);
const isPersonalCalendar = userData?.openpaasId === calendar.id.split("/")[0];
const currentUserEmail = normalizeEmail(userData?.email);
const isDelegatedWithAdministration = !!calendar.invite?.some((invite) => {
const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, ""));
return invitedEmail === currentUserEmail && invite.access === 5;
});
const { t } = useI18n()
const userData = useAppSelector(state => state.user.userData)
const isPersonalCalendar = userData?.openpaasId === calendar.id.split('/')[0]
const currentUserEmail = normalizeEmail(userData?.email)
const isDelegatedWithAdministration = !!calendar.invite?.some(invite => {
const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, ''))
return invitedEmail === currentUserEmail && invite.access === 5
})
const ownerEmail =
calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? "";
const ownerName = makeDisplayName(calendar) ?? ownerEmail;
calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? ''
const ownerName = makeDisplayName(calendar) ?? ownerEmail
const containerRef = useRef<HTMLDivElement>(null);
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined);
const [accessRight, setAccessRight] = useState<AccessRight>(2);
const [inviteLoading, setInvitesLoading] = useState(false);
const [resourceAdmins, setResourceAdmins] = useState<UserWithAccess[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null)
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined)
const [accessRight, setAccessRight] = useState<AccessRight>(2)
const [inviteLoading, setInvitesLoading] = useState(false)
const [resourceAdmins, setResourceAdmins] = useState<UserWithAccess[]>([])
const [adminLoading, setAdminLoading] = useState(false)
const currentUsersRef = useRef<UserWithAccess[]>(usersWithAccess);
const currentUsersRef = useRef<UserWithAccess[]>(usersWithAccess)
useEffect(() => {
currentUsersRef.current = usersWithAccess;
}, [usersWithAccess]);
currentUsersRef.current = usersWithAccess
}, [usersWithAccess])
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
if (!containerRef.current) return
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
setSearchWidth(entry.contentRect.width);
setSearchWidth(entry.contentRect.width)
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
const handleLoadUsers = useCallback(
async (usersInCal: UserInCalendar[], cancelled: boolean) => {
const results = await Promise.allSettled(
usersInCal.map(async (user) => {
const details = await getUserDetails(user.id);
const email = details?.preferredEmail ?? details?.emails?.[0] ?? "";
usersInCal.map(async user => {
const details = await getUserDetails(user.id)
const email = details?.preferredEmail ?? details?.emails?.[0] ?? ''
return {
openpaasId: user.id,
displayName:
[details?.firstname, details?.lastname]
.filter(Boolean)
.join(" ")
.join(' ')
.trim() || email,
email,
accessRight: user.access as AccessRight,
} satisfies UserWithAccess;
accessRight: user.access
} satisfies UserWithAccess
})
);
)
if (cancelled) {
return [] as UserWithAccess[];
return [] as UserWithAccess[]
}
const loaded: UserWithAccess[] = results
.filter((r) => r.status === "fulfilled")
.map((r) => (r as PromiseFulfilledResult<UserWithAccess>).value)
.filter(Boolean) as UserWithAccess[];
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<UserWithAccess>).value)
.filter(Boolean)
return loaded;
return loaded
},
[]
);
)
useEffect(() => {
if (!calendar.invite?.length) return;
if (!calendar.invite?.length) return
let cancelled = false;
let cancelled = false
async function loadInvitedUsers() {
setInvitesLoading(true);
setInvitesLoading(true)
try {
const usersInCal = (calendar.invite
?.map((invite) => {
const principalId = invite.principal.split("/").pop();
if (!principalId) return null;
?.map(invite => {
const principalId = invite.principal.split('/').pop()
if (!principalId) return null
return {
id: principalId,
access: invite.access,
};
access: invite.access
}
})
?.filter(
(invite) =>
invite =>
!!invite &&
!calendar.owner.administrators?.some(
(admin) => admin.id === invite.id
admin => admin.id === invite.id
)
) || []) as UserInCalendar[];
) || []) as UserInCalendar[]
const loaded = await handleLoadUsers(usersInCal, cancelled);
const loaded = await handleLoadUsers(usersInCal, cancelled)
const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email)));
const loadedIds = new Set(loaded.map(u => normalizeEmail(u.email)))
const manuallyAdded = currentUsersRef.current.filter(
(u) => !loadedIds.has(normalizeEmail(u.email))
);
const merged = [...loaded, ...manuallyAdded];
u => !loadedIds.has(normalizeEmail(u.email))
)
const merged = [...loaded, ...manuallyAdded]
onInvitesLoaded(loaded);
onChange(merged);
onInvitesLoaded(loaded)
onChange(merged)
} finally {
if (!cancelled) setInvitesLoading(false);
if (!cancelled) setInvitesLoading(false)
}
}
loadInvitedUsers();
loadInvitedUsers()
return () => {
cancelled = true;
};
cancelled = true
}
}, [
calendar.invite,
calendar.owner.administrators,
handleLoadUsers,
onChange,
onInvitesLoaded,
]);
onInvitesLoaded
])
useEffect(() => {
const isResource = calendar.owner.resource;
const resourceAdmins = calendar.owner.administrators || [];
if (!isResource || !resourceAdmins?.length) return;
const isResource = calendar.owner.resource
const resourceAdmins = calendar.owner.administrators || []
if (!isResource || !resourceAdmins?.length) return
let cancelled = false;
let cancelled = false
async function loadAdmins() {
try {
setAdminLoading(true);
setAdminLoading(true)
const resourceAdminsWithoutOwner = resourceAdmins
.filter((admin) => admin.id !== calendar.owner._id)
.map((admin) => ({
.filter(admin => admin.id !== calendar.owner._id)
.map(admin => ({
id: admin.id,
access: 5, // ADMIN
})) satisfies UserInCalendar[];
access: 5 // ADMIN
})) satisfies UserInCalendar[]
const admins = await handleLoadUsers(
resourceAdminsWithoutOwner,
cancelled
);
)
setResourceAdmins(admins);
setResourceAdmins(admins)
} catch (error) {
console.error(error);
console.error(error)
} finally {
if (!cancelled) setAdminLoading(false);
if (!cancelled) setAdminLoading(false)
}
}
loadAdmins();
loadAdmins()
return () => {
cancelled = true;
};
}, [calendar.owner, handleLoadUsers, setResourceAdmins]);
cancelled = true
}
}, [calendar.owner, handleLoadUsers, setResourceAdmins])
const handleUserSelect = (_event: unknown, users: User[]) => {
const updated: UserWithAccess[] = users.map((user) => {
const updated: UserWithAccess[] = users.map(user => {
const existing = usersWithAccess.find(
(u) => normalizeEmail(u.email) === normalizeEmail(user.email)
);
return existing ?? { ...user, accessRight };
});
onChange(updated);
};
u => normalizeEmail(u.email) === normalizeEmail(user.email)
)
return existing ?? { ...user, accessRight }
})
onChange(updated)
}
const handleRemoveUser = (email: string) => {
onChange(
usersWithAccess.filter(
(u) => normalizeEmail(u.email) !== normalizeEmail(email)
u => normalizeEmail(u.email) !== normalizeEmail(email)
)
);
};
)
}
const handleChangeUserRight = (email: string, right: AccessRight) => {
onChange(
usersWithAccess.map((u) =>
usersWithAccess.map(u =>
normalizeEmail(u.email) === normalizeEmail(email)
? { ...u, accessRight: right }
: u
)
);
};
)
}
const accessRightOptions: { value: AccessRight; label: string }[] = [
{ value: 2, label: t("calendarPopover.access.viewAllEvents") },
{ value: 3, label: t("calendarPopover.access.editor") },
{ value: 5, label: t("calendarPopover.access.administrator") },
];
{ value: 2, label: t('calendarPopover.access.viewAllEvents') },
{ value: 3, label: t('calendarPopover.access.editor') },
{ value: 5, label: t('calendarPopover.access.administrator') }
]
return (
<FieldWithLabel
label={
isPersonalCalendar || isDelegatedWithAdministration
? t("calendarPopover.access.grantAccessRights")
: t("calendarPopover.access.accessRights")
? t('calendarPopover.access.grantAccessRights')
: t('calendarPopover.access.accessRights')
}
isExpanded={false}
>
@@ -251,27 +251,27 @@ export function CalendarAccessRights({
<PeopleSearch
selectedUsers={usersWithAccess}
onChange={handleUserSelect}
objectTypes={["user"]}
objectTypes={['user']}
onToggleEventPreview={() => {}}
customSlotProps={{
popper: {
anchorEl: containerRef.current,
placement: "bottom-start",
placement: 'bottom-start',
sx: {
minWidth: searchWidth,
"& .MuiPaper-root": {
width: "100%",
},
'& .MuiPaper-root': {
width: '100%'
}
},
modifiers: [
{
name: "offset",
name: 'offset',
options: {
offset: [0, 8],
},
},
],
},
offset: [0, 8]
}
}
]
}
}}
customRenderInput={(
params: AutocompleteRenderInputParams,
@@ -282,33 +282,33 @@ export function CalendarAccessRights({
{...params}
fullWidth
autoFocus
placeholder={t("peopleSearch.label")}
placeholder={t('peopleSearch.label')}
value={query}
inputRef={(el) => {
const ref = params.InputProps.ref;
if (typeof ref === "function") {
ref(el);
} else if (ref && "current" in ref) {
(
inputRef={el => {
const ref = params.InputProps.ref
if (typeof ref === 'function') {
ref(el)
} else if (ref && 'current' in ref) {
;(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = el;
).current = el
}
}}
onChange={(e) => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)}
variant="outlined"
inputProps={{
...params.inputProps,
sx: {
fontSize: "14px",
"&::placeholder": { fontSize: "14px" },
},
fontSize: '14px',
'&::placeholder': { fontSize: '14px' }
}
}}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<PeopleOutlineOutlinedIcon
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
/>
</InputAdornment>
),
@@ -316,34 +316,34 @@ export function CalendarAccessRights({
<InputAdornment position="end">
<Select
value={accessRight}
onChange={(e) =>
onChange={e =>
setAccessRight(e.target.value as AccessRight)
}
variant="standard"
disableUnderline
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
fontSize: '0.875rem',
color: 'text.secondary',
'& .MuiSelect-select': {
paddingRight: '24px !important',
paddingY: 0
},
"& .MuiSelect-icon": { fontSize: "1rem" },
"&:before, &:after": { display: "none" },
'& .MuiSelect-icon': { fontSize: '1rem' },
'&:before, &:after': { display: 'none' }
}}
>
{accessRightOptions.map((opt) => (
{accessRightOptions.map(opt => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
{opt.label}
</MenuItem>
))}
</Select>
</InputAdornment>
),
)
}}
/>
)}
@@ -360,14 +360,14 @@ export function CalendarAccessRights({
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(ownerName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{ownerName}</Typography>
@@ -379,7 +379,7 @@ export function CalendarAccessRights({
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.owner")}
{t('calendarPopover.access.owner')}
</Typography>
</Box>
</Box>
@@ -388,7 +388,7 @@ export function CalendarAccessRights({
<CircularProgress size={24} />
</Box>
) : (
resourceAdmins.map((admin) => (
resourceAdmins.map(admin => (
<ResourceAdmin key={admin.email} admin={admin} />
))
)}
@@ -398,7 +398,7 @@ export function CalendarAccessRights({
</Box>
) : (
usersWithAccess.length > 0 &&
usersWithAccess.map((user) => (
usersWithAccess.map(user => (
<Box
key={user.email}
display="flex"
@@ -407,14 +407,14 @@ export function CalendarAccessRights({
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(user.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{user.displayName}</Typography>
@@ -427,7 +427,7 @@ export function CalendarAccessRights({
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Select
value={user.accessRight}
onChange={(e) =>
onChange={e =>
handleChangeUserRight(
user.email,
e.target.value as AccessRight
@@ -439,20 +439,20 @@ export function CalendarAccessRights({
!(isPersonalCalendar || isDelegatedWithAdministration)
}
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
fontSize: '0.875rem',
color: 'text.secondary',
'& .MuiSelect-select': {
paddingRight: '24px !important',
paddingY: 0
},
"& .MuiSelect-icon": { fontSize: "1rem" },
'& .MuiSelect-icon': { fontSize: '1rem' }
}}
>
{accessRightOptions.map((opt) => (
{accessRightOptions.map(opt => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
{opt.label}
</MenuItem>
@@ -461,9 +461,9 @@ export function CalendarAccessRights({
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<IconButton
size="small"
aria-label={t("actions.remove")}
aria-label={t('actions.remove')}
onClick={() => handleRemoveUser(user.email)}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
<HighlightOffIcon fontSize="small" />
</IconButton>
@@ -474,5 +474,5 @@ export function CalendarAccessRights({
)}
</Box>
</FieldWithLabel>
);
)
}
+102 -113
View File
@@ -1,45 +1,35 @@
import AddIcon from "@mui/icons-material/Add";
import CheckIcon from "@mui/icons-material/Check";
import AddIcon from '@mui/icons-material/Add'
import CheckIcon from '@mui/icons-material/Check'
import {
Box,
Button,
Popover,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import { useState, useEffect } from "react";
import { HexColorPicker } from "react-colorful";
import { useI18n } from "twake-i18n";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
useTheme
} from '@linagora/twake-mui'
import { useState } from 'react'
import { HexColorPicker } from 'react-colorful'
import { useI18n } from 'twake-i18n'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
export function ColorPicker({
selectedColor,
colors = defaultColors.slice(0, 4),
onChange,
onChange
}: {
selectedColor: Record<string, string>;
colors?: Record<string, string>[];
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>
colors?: Record<string, string>[]
onChange: (color: Record<string, string>) => void
}) {
const [customColor, setCustomColor] = useState(
!colors.find((c) => c.light === selectedColor?.light)
? selectedColor
: undefined
);
useEffect(() => {
if (!colors.find((c) => c.light === selectedColor?.light)) {
setCustomColor(selectedColor);
} else {
setCustomColor(undefined);
}
}, [selectedColor, colors]);
const customColor = !colors.find(c => c.light === selectedColor?.light)
? selectedColor
: undefined
return (
<Box display="flex" alignItems="center" gap={1}>
{colors.map((c) => (
{colors.map(c => (
<ColorBox
key={c.light}
color={c}
@@ -56,24 +46,23 @@ export function ColorPicker({
)}
<ColorPickerBox
onChange={(c) => {
onChange(c);
setCustomColor(c);
onChange={c => {
onChange(c)
}}
selectedColor={selectedColor}
/>
</Box>
);
)
}
function ColorBox({
color,
onChange,
selectedColor,
selectedColor
}: {
color: Record<string, string>;
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
color: Record<string, string>
onChange: (color: Record<string, string>) => void
selectedColor: Record<string, string>
}) {
return (
<Box
@@ -81,109 +70,109 @@ function ColorBox({
aria-label={`select color ${color.light}`}
onClick={() => onChange(color)}
style={{
width: "46px",
height: "32px",
width: '46px',
height: '32px',
padding: 0,
borderRadius: "4px",
borderRadius: '4px',
backgroundColor: color.light,
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: color.dark,
height: '7px',
width: '100%',
borderRadius: '4px 4px 0px 0px',
backgroundColor: color.dark
}}
></Box>
<CheckIcon
style={{
visibility:
selectedColor?.light === color.light ? "visible" : "hidden",
color: color.dark,
selectedColor?.light === color.light ? 'visible' : 'hidden',
color: color.dark
}}
/>
</Box>
);
)
}
function ColorPickerBox({
onChange,
selectedColor,
selectedColor
}: {
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
onChange: (color: Record<string, string>) => void
selectedColor: Record<string, string>
}) {
const { t } = useI18n();
const { t } = useI18n()
const [oldColor] = useState(
selectedColor ?? { light: "#ffffff", dark: "#808080" }
);
const [color, setColor] = useState(oldColor);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const open = Boolean(anchorEl);
const theme = useTheme();
selectedColor ?? { light: '#ffffff', dark: '#808080' }
)
const [color, setColor] = useState(oldColor)
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const open = Boolean(anchorEl)
const theme = useTheme()
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
onChange(oldColor);
setAnchorEl(null);
};
onChange(oldColor)
setAnchorEl(null)
}
const handleSave = () => {
onChange(color);
setAnchorEl(null);
};
onChange(color)
setAnchorEl(null)
}
const handleColorChange = (c: string) => {
const newLight = c;
const newLight = c
const newColor = {
light: newLight,
dark: getAccessiblePair(newLight, theme),
};
setColor(newColor);
onChange(newColor);
};
dark: getAccessiblePair(newLight, theme)
}
setColor(newColor)
onChange(newColor)
}
return (
<>
<Box
key={"colorPicker"}
key="colorPicker"
role="button"
aria-label={t("colorPicker.selectCustom")}
aria-label={t('colorPicker.selectCustom')}
onClick={handleClick}
style={{
width: "46px",
height: "32px",
width: '46px',
height: '32px',
padding: 0,
borderRadius: "4px",
border: "1px solid #CBD2E0",
backgroundColor: "#FFF",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
borderRadius: '4px',
border: '1px solid #CBD2E0',
backgroundColor: '#FFF',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: "#CBD2E0",
height: '7px',
width: '100%',
borderRadius: '4px 4px 0px 0px',
backgroundColor: '#CBD2E0'
}}
></Box>
<AddIcon
style={{
color: "#CBD2E0",
color: '#CBD2E0'
}}
/>
</Box>
@@ -192,63 +181,63 @@ function ColorPickerBox({
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "center",
horizontal: "center",
vertical: 'center',
horizontal: 'center'
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
style: {
padding: "24px",
width: "294px",
borderRadius: "8px",
boxShadow: "0px 1px 3px #3C404326",
},
},
padding: '24px',
width: '294px',
borderRadius: '8px',
boxShadow: '0px 1px 3px #3C404326'
}
}
}}
>
<Typography variant="subtitle1" fontWeight="600">
{t("colorPicker.title")}
{t('colorPicker.title')}
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: "text.secondary" }}>
{t("colorPicker.subtitle")}
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
{t('colorPicker.subtitle')}
</Typography>
<Box sx={{ mb: 2 }}>
<HexColorPicker
color={color.light}
onChange={handleColorChange}
style={{ width: "100%" }}
style={{ width: '100%' }}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="body2" sx={{ mr: 1 }}>
{t("colorPicker.hex")}
{t('colorPicker.hex')}
</Typography>
<TextField
value={color.light?.toUpperCase()}
onChange={(e) => handleColorChange(e.target.value)}
onChange={e => handleColorChange(e.target.value)}
variant="standard"
size="small"
slotProps={{ inputLabel: { shrink: true } }}
/>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={handleClose}>{t('common.cancel')}</Button>
<Button
variant="contained"
onClick={handleSave}
sx={{ textTransform: "none" }}
sx={{ textTransform: 'none' }}
>
{t("actions.save")}
{t('actions.save')}
</Button>
</Box>
</Popover>
</>
);
)
}
+6 -6
View File
@@ -1,14 +1,14 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { MenuItem } from "@linagora/twake-mui";
import React from "react";
import { CalendarName } from "./CalendarName";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { MenuItem } from '@linagora/twake-mui'
import React from 'react'
import { CalendarName } from './CalendarName'
export function CalendarItemList(
userPersonalCalendars: Calendar[]
): React.ReactNode {
return Object.values(userPersonalCalendars).map((calendar) => (
return Object.values(userPersonalCalendars).map(calendar => (
<MenuItem key={calendar.id} value={calendar.id}>
<CalendarName calendar={calendar} />
</MenuItem>
));
))
}
+40 -40
View File
@@ -1,69 +1,69 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import SettingsPage from "@/features/Settings/SettingsPage";
import { getViewRange } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import CozyBridge from "cozy-external-bridge";
import { useEffect, useMemo, useRef, useState } from "react";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { refreshCalendars } from "../Event/utils/eventUtils";
import { Menubar, MenubarProps } from "../Menubar/Menubar";
import CalendarApp from "./Calendar";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import SettingsPage from '@/features/Settings/SettingsPage'
import { getViewRange } from '@/utils/dateUtils'
import type { CalendarApi } from '@fullcalendar/core'
import CozyBridge from 'cozy-external-bridge'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { refreshCalendars } from '../Event/utils/eventUtils'
import { Menubar, MenubarProps } from '../Menubar/Menubar'
import CalendarApp from './Calendar'
export default function CalendarLayout() {
const calendarRef = useRef<CalendarApi | null>(null);
const dispatch = useAppDispatch();
const error = useAppSelector((state) => state.calendars.error);
const selectedCalendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const view = useAppSelector((state) => state.settings.view);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [currentView, setCurrentView] = useState<string>("timeGridWeek");
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []);
const calendarRef = useRef<CalendarApi | null>(null)
const dispatch = useAppDispatch()
const error = useAppSelector(state => state.calendars.error)
const selectedCalendars = useAppSelector(state => state.calendars.list)
const tempcalendars = useAppSelector(state => state.calendars.templist)
const view = useAppSelector(state => state.settings.view)
const [currentDate, setCurrentDate] = useState<Date>(new Date())
const [currentView, setCurrentView] = useState<string>('timeGridWeek')
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), [])
const handleRefresh = async () => {
// Get current calendar range
if (calendarRef.current) {
const view = calendarRef.current.view;
const calendarRange = getViewRange(view.activeStart, view.type);
const view = calendarRef.current.view
const calendarRange = getViewRange(view.activeStart, view.type)
// Refresh events for selected calendars
await refreshCalendars(
dispatch,
Object.values(selectedCalendars),
calendarRange
);
)
if (tempcalendars) {
await refreshCalendars(
dispatch,
Object.values(tempcalendars),
calendarRange,
"temp"
);
'temp'
)
}
}
};
}
const handleDateChange = (date: Date) => {
setCurrentDate(date);
};
setCurrentDate(date)
}
const handleViewChange = (view: string) => {
setCurrentView(view);
};
setCurrentView(view)
}
// Hide topbar navigation elements when in settings view (same as fullscreen dialog mode)
useEffect(() => {
if (view === "settings") {
document.body.classList.add("fullscreen-view");
if (view === 'settings') {
document.body.classList.add('fullscreen-view')
} else {
document.body.classList.remove("fullscreen-view");
document.body.classList.remove('fullscreen-view')
}
// Cleanup on unmount
return () => {
document.body.classList.remove("fullscreen-view");
};
}, [view]);
document.body.classList.remove('fullscreen-view')
}
}, [view])
const menubarProps: MenubarProps = {
calendarRef,
@@ -72,13 +72,13 @@ export default function CalendarLayout() {
onDateChange: handleDateChange,
currentView,
onViewChange: handleViewChange,
isIframe: isInIframe,
};
isIframe: isInIframe
}
return (
<div className="App">
{!isInIframe && <Menubar {...menubarProps} />}
{(view === "calendar" || view === "search") && (
{(view === 'calendar' || view === 'search') && (
<CalendarApp
calendarRef={calendarRef}
onDateChange={handleDateChange}
@@ -86,8 +86,8 @@ export default function CalendarLayout() {
menubarProps={menubarProps}
/>
)}
{view === "settings" && <SettingsPage isInIframe={isInIframe} />}
{view === 'settings' && <SettingsPage isInIframe={isInIframe} />}
<ErrorSnackbar error={error} type="calendar" />
</div>
);
)
}
+167 -164
View File
@@ -1,190 +1,193 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
createCalendarAsync,
importEventFromFileAsync,
patchACLCalendarAsync,
patchCalendarAsync,
} from "@/features/Calendars/services";
import { updateDelegationCalendarAsync } from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
import { defaultColors } from "@/utils/defaultColors";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { Button, Tab, Tabs } from "@linagora/twake-mui";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { ResponsiveDialog } from "../Dialog";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { AccessTab } from "./AccessTab";
import { UserWithAccess } from "./CalendarAccessRights";
import { ImportTab } from "./ImportTab";
import { SettingsTab } from "./SettingsTab";
patchCalendarAsync
} from '@/features/Calendars/services'
import { updateDelegationCalendarAsync } from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { accessRightToDavProp } from '@/utils/accessRightToDavProp'
import { defaultColors } from '@/utils/defaultColors'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { Button, Tab, Tabs } from '@linagora/twake-mui'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ResponsiveDialog } from '../Dialog'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { AccessTab } from './AccessTab'
import { UserWithAccess } from './CalendarAccessRights'
import { ImportTab } from './ImportTab'
import { SettingsTab } from './SettingsTab'
function CalendarPopover({
open,
onClose,
calendar,
calendar
}: {
open: boolean;
open: boolean
onClose: (
event: object | null,
reason: "backdropClick" | "escapeKeyDown"
) => void;
calendar?: Calendar;
reason: 'backdropClick' | 'escapeKeyDown'
) => void
calendar?: Calendar
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const userData = useAppSelector((state) => state.user.userData) ?? {};
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const dispatch = useAppDispatch()
const userData = useAppSelector(state => state.user.userData) ?? {}
const calendars = useAppSelector(state => state.calendars.list)
const isOwn = calendar?.id
? extractEventBaseUuid(calendar.id) === userData.openpaasId
: true;
: true
const canManageInvites =
isOwn ||
!!calendar?.invite?.some((invite) => {
!!calendar?.invite?.some(invite => {
const inviteEmail = invite.href
.replace(/^mailto:/i, "")
.replace(/^mailto:/i, '')
.trim()
.toLowerCase();
const currentEmail = userData.email?.trim().toLowerCase();
return inviteEmail === currentEmail && invite.access === 5;
});
.toLowerCase()
const currentEmail = userData.email?.trim().toLowerCase()
return inviteEmail === currentEmail && invite.access === 5
})
// existing calendar params
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
const [visibility, setVisibility] = useState<"private" | "public">("public");
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState<Record<string, string>>(defaultColors[0])
const [visibility, setVisibility] = useState<'private' | 'public'>('public')
// access tab state
const [usersWithAccess, setUsersWithAccess] = useState<UserWithAccess[]>([]);
const [usersWithAccess, setUsersWithAccess] = useState<UserWithAccess[]>([])
// Snapshot of the invitee list as loaded from calendar.invite on open.
// Used to diff on save: what changed vs what was removed.
const initialUsersRef = useRef<UserWithAccess[]>([]);
const initialUsersRef = useRef<UserWithAccess[]>([])
// import tab state
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
const [importedContent, setImportedContent] = useState<File | null>(null);
const [importTarget, setImportTarget] = useState("new");
const [tab, setTab] = useState<'settings' | 'access' | 'import'>('settings')
const [importedContent, setImportedContent] = useState<File | null>(null)
const [importTarget, setImportTarget] = useState('new')
// new calendar params (for import new)
const [newCalName, setNewCalName] = useState("");
const [newCalDescription, setNewCalDescription] = useState("");
const [newCalColor, setNewCalColor] = useState(defaultColors[0]);
const [newCalName, setNewCalName] = useState('')
const [newCalDescription, setNewCalDescription] = useState('')
const [newCalColor, setNewCalColor] = useState(defaultColors[0])
const [newCalVisibility, setNewCalVisibility] = useState<
"public" | "private"
>("public");
'public' | 'private'
>('public')
useEffect(() => {
if (!open) return;
if (calendar) {
setName(calendar.name);
setDescription(calendar.description ?? "");
setColor(calendar.color ?? defaultColors[0]);
setVisibility(calendar.visibility ?? "public");
setImportTarget(calendar.id ?? "new");
} else {
setName("");
setDescription("");
setColor(defaultColors[0]);
setVisibility("public");
setImportTarget("new");
if (!open) return
const fillCalendarData = () => {
if (calendar) {
setName(calendar.name)
setDescription(calendar.description ?? '')
setColor(calendar.color ?? defaultColors[0])
setVisibility(calendar.visibility ?? 'public')
setImportTarget(calendar.id ?? 'new')
} else {
setName('')
setDescription('')
setColor(defaultColors[0])
setVisibility('public')
setImportTarget('new')
}
setUsersWithAccess([])
initialUsersRef.current = []
}
setUsersWithAccess([]);
initialUsersRef.current = [];
}, [calendar, open]);
fillCalendarData()
}, [calendar, open])
const handleUsersWithAccessChange = useCallback((users: UserWithAccess[]) => {
setUsersWithAccess(users);
}, []);
setUsersWithAccess(users)
}, [])
const handleInvitesLoaded = useCallback((users: UserWithAccess[]) => {
if (initialUsersRef.current.length === 0) {
initialUsersRef.current = users;
initialUsersRef.current = users
}
}, []);
}, [])
const [saveError, setSaveError] = useState("");
const [saveError, setSaveError] = useState('')
const updateCalendar = async (calId: string, calLink: string) => {
const nameChanged = name.trim() !== calendar?.name;
const descChanged = description.trim() !== (calendar?.description ?? "");
const nameChanged = name.trim() !== calendar?.name
const descChanged = description.trim() !== (calendar?.description ?? '')
const colorChanged =
JSON.stringify(color) !==
JSON.stringify(calendar?.color ?? defaultColors[0]);
JSON.stringify(calendar?.color ?? defaultColors[0])
if (nameChanged || descChanged || colorChanged) {
await dispatch(
patchCalendarAsync({
calId,
calLink,
patch: { name: name.trim(), desc: description.trim(), color },
patch: { name: name.trim(), desc: description.trim(), color }
})
).unwrap();
).unwrap()
}
if (canManageInvites && visibility !== calendar?.visibility) {
await dispatch(
patchACLCalendarAsync({
calId,
calLink,
request: visibility === "public" ? "{DAV:}read" : "",
request: visibility === 'public' ? '{DAV:}read' : ''
})
).unwrap();
).unwrap()
}
};
}
async function updateCalendarInvites(
calLink: string,
initialUsers: UserWithAccess[]
) {
const normaliseEmail = (u: UserWithAccess) =>
u.email?.trim().toLowerCase() ?? "";
u.email?.trim().toLowerCase() ?? ''
const initialMap = new Map(
initialUsers
.filter((u) => !!normaliseEmail(u))
.map((u) => [normaliseEmail(u), u])
);
.filter(u => !!normaliseEmail(u))
.map(u => [normaliseEmail(u), u])
)
const currentMap = new Map(
usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => [normaliseEmail(u), u])
);
.filter(u => !!normaliseEmail(u))
.map(u => [normaliseEmail(u), u])
)
const hasChanges =
usersWithAccess.some((u) => {
const email = normaliseEmail(u);
if (!email) return false;
const initial = initialMap.get(email);
return !initial || initial.accessRight !== u.accessRight;
usersWithAccess.some(u => {
const email = normaliseEmail(u)
if (!email) return false
const initial = initialMap.get(email)
return !initial || initial.accessRight !== u.accessRight
}) ||
initialUsers.some(
(u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u))
);
u => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u))
)
if (!hasChanges || !canManageInvites) return;
if (!hasChanges || !canManageInvites) return
// Send all remaining users in `set`: the server treats it as the full list
const set = usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => ({
"dav:href": `mailto:${normaliseEmail(u)}`,
[accessRightToDavProp(u.accessRight)]: true,
}));
.filter(u => !!normaliseEmail(u))
.map(u => ({
'dav:href': `mailto:${normaliseEmail(u)}`,
[accessRightToDavProp(u.accessRight)]: true
}))
const remove = initialUsers
.filter((u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map((u) => ({ "dav:href": `mailto:${normaliseEmail(u)}` }));
.filter(u => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map(u => ({ 'dav:href': `mailto:${normaliseEmail(u)}` }))
await dispatch(
updateDelegationCalendarAsync({
calId: calendar?.id,
calId: calendar?.id ?? '',
calLink,
share: { set, remove },
share: { set, remove }
})
).unwrap();
).unwrap()
}
const createCalendar = async (
@@ -200,39 +203,39 @@ function CalendarPopover({
desc: desc.trim(),
color: color,
userData,
calId,
calId
})
);
)
dispatch(
patchACLCalendarAsync({
calId: `${userData.openpaasId}/${calId}`,
calLink: `/calendars/${userData.openpaasId}/${calId}.json`,
request: visibility === "public" ? "{DAV:}read" : "",
request: visibility === 'public' ? '{DAV:}read' : ''
})
);
};
)
}
const handleSave = async () => {
if (!name.trim()) return;
if (!name.trim()) return
if (calendar) {
// Snapshot before any await: handleClose resets the ref immediately after
// calling handleSave(), so we must read it synchronously here.
const initialUsersSnapshot = [...initialUsersRef.current];
const initialUsersSnapshot = [...initialUsersRef.current]
try {
await updateCalendar(calendar.id, calendar.link);
await updateCalendarInvites(calendar.link, initialUsersSnapshot);
await updateCalendar(calendar.id, calendar.link)
await updateCalendarInvites(calendar.link, initialUsersSnapshot)
} catch {
setSaveError(t("error.title"));
return;
setSaveError(t('error.title'))
return
}
} else {
createCalendar(crypto.randomUUID(), name, description, color, visibility);
createCalendar(crypto.randomUUID(), name, description, color, visibility)
}
};
}
const handleImport = async () => {
if (importTarget === "new") {
const calId = crypto.randomUUID();
if (importTarget === 'new') {
const calId = crypto.randomUUID()
if (newCalName.trim()) {
await createCalendar(
calId,
@@ -240,14 +243,14 @@ function CalendarPopover({
newCalDescription,
newCalColor,
newCalVisibility
);
)
if (importedContent) {
dispatch(
importEventFromFileAsync({
calLink: `/calendars/${userData.openpaasId}/${calId}.json`,
file: importedContent,
file: importedContent
})
);
)
}
}
} else {
@@ -255,91 +258,91 @@ function CalendarPopover({
dispatch(
importEventFromFileAsync({
calLink: calendars[importTarget].link,
file: importedContent,
file: importedContent
})
);
)
}
}
handleClose({}, "backdropClick");
};
handleClose({}, 'backdropClick')
}
const handleClose = (
e: object | null,
reason: "backdropClick" | "escapeKeyDown" | "cancel"
reason: 'backdropClick' | 'escapeKeyDown' | 'cancel'
): void => {
if (reason !== "cancel") {
handleSave();
onClose(e, reason);
if (reason !== 'cancel') {
handleSave()
onClose(e, reason)
} else {
onClose(e, "backdropClick");
onClose(e, 'backdropClick')
}
setName("");
setDescription("");
setColor(defaultColors[0]);
setTab("settings");
setVisibility("public");
setImportTarget("new");
setImportedContent(null);
setUsersWithAccess([]);
initialUsersRef.current = [];
setSaveError("");
setNewCalName("");
setNewCalDescription("");
setNewCalColor(defaultColors[0]);
setNewCalVisibility("public");
};
setName('')
setDescription('')
setColor(defaultColors[0])
setTab('settings')
setVisibility('public')
setImportTarget('new')
setImportedContent(null)
setUsersWithAccess([])
initialUsersRef.current = []
setSaveError('')
setNewCalName('')
setNewCalDescription('')
setNewCalColor(defaultColors[0])
setNewCalVisibility('public')
}
return (
<ResponsiveDialog
open={open}
onClose={() => handleClose({}, "backdropClick")}
onClose={() => handleClose({}, 'backdropClick')}
title={
<Tabs value={tab} onChange={(_e, v) => setTab(v)}>
<Tab
value="settings"
label={
calendar
? t("calendarPopover.tabs.settings")
: t("calendarPopover.tabs.addNew")
? t('calendarPopover.tabs.settings')
: t('calendarPopover.tabs.addNew')
}
/>
{calendar && (
<Tab value="access" label={t("calendarPopover.tabs.access")} />
<Tab value="access" label={t('calendarPopover.tabs.access')} />
)}
{isOwn && (
<Tab value="import" label={t("calendarPopover.tabs.import")} />
<Tab value="import" label={t('calendarPopover.tabs.import')} />
)}
</Tabs>
}
actions={
<>
<Button variant="outlined" onClick={() => handleClose({}, "cancel")}>
{t("common.cancel")}
<Button variant="outlined" onClick={() => handleClose({}, 'cancel')}>
{t('common.cancel')}
</Button>
<Button
disabled={tab === "import" ? !importedContent : !name.trim()}
disabled={tab === 'import' ? !importedContent : !name.trim()}
variant="contained"
onClick={
tab === "import"
tab === 'import'
? handleImport
: () => handleClose({}, "backdropClick")
: () => handleClose({}, 'backdropClick')
}
>
{tab === "import"
? t("actions.import")
{tab === 'import'
? t('actions.import')
: calendar
? t("actions.save")
: t("actions.create")}
? t('actions.save')
: t('actions.create')}
</Button>
</>
}
>
{tab === "import" && (
{tab === 'import' && (
<ImportTab
importTarget={importTarget}
setImportTarget={setImportTarget}
setImportedContent={setImportedContent}
userId={userData.openpaasId ?? ""}
userId={userData.openpaasId ?? ''}
newCalParams={{
name: newCalName,
setName: setNewCalName,
@@ -348,11 +351,11 @@ function CalendarPopover({
color: newCalColor,
setColor: setNewCalColor,
visibility: newCalVisibility,
setVisibility: setNewCalVisibility,
setVisibility: setNewCalVisibility
}}
/>
)}
{tab === "settings" && (
{tab === 'settings' && (
<SettingsTab
name={name}
setName={setName}
@@ -365,7 +368,7 @@ function CalendarPopover({
calendar={calendar}
/>
)}
{tab === "access" && calendar && (
{tab === 'access' && calendar && (
<AccessTab
calendar={calendar}
usersWithAccess={usersWithAccess}
@@ -375,7 +378,7 @@ function CalendarPopover({
)}
<ErrorSnackbar error={saveError} type="calendar" />
</ResponsiveDialog>
);
)
}
export default CalendarPopover;
export default CalendarPopover
+25 -25
View File
@@ -1,32 +1,32 @@
import { useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { defaultColors } from "@/utils/defaultColors";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { renameDefault } from "@/utils/renameDefault";
import { Box, Typography } from "@linagora/twake-mui";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import { useI18n } from "twake-i18n";
import { OwnerCaption } from "./OwnerCaption";
import { ResourceIcon } from "../Attendees/ResourceIcon";
import { useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { defaultColors } from '@/utils/defaultColors'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { renameDefault } from '@/utils/renameDefault'
import { Box, Typography } from '@linagora/twake-mui'
import SquareRoundedIcon from '@mui/icons-material/SquareRounded'
import { useI18n } from 'twake-i18n'
import { OwnerCaption } from './OwnerCaption'
import { ResourceIcon } from '../Attendees/ResourceIcon'
export function CalendarName({ calendar }: { calendar: Calendar }) {
const userData = useAppSelector((state) => state.user.userData);
const { t } = useI18n();
const userData = useAppSelector(state => state.user.userData)
const { t } = useI18n()
const ownerId = calendar.id.split("/")[0];
const ownerDisplayName = makeDisplayName(calendar) ?? "";
const isOwnCalendar = userData.openpaasId === ownerId;
const isResource = calendar.owner?.resource;
const ownerId = calendar.id.split('/')[0]
const ownerDisplayName = makeDisplayName(calendar) ?? ''
const isOwnCalendar = userData.openpaasId === ownerId
const isResource = calendar.owner?.resource
const showCaption =
calendar.name !== "#default" && !isOwnCalendar && !isResource;
calendar.name !== '#default' && !isOwnCalendar && !isResource
return (
<Box
style={{
display: "flex",
flexDirection: "row",
gap: "16px",
alignItems: "center",
display: 'flex',
flexDirection: 'row',
gap: '16px',
alignItems: 'center'
}}
>
{isResource ? (
@@ -40,12 +40,12 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
style={{
color: calendar.color?.light ?? defaultColors[0].light,
width: 24,
height: 24,
height: 24
}}
/>
)}
<Box style={{ display: "flex", flexDirection: "column" }}>
<Typography variant="body2" sx={{ wordBreak: "break-word" }}>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
{renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)}
</Typography>
<OwnerCaption
@@ -54,5 +54,5 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
/>
</Box>
</Box>
);
)
}
+137 -140
View File
@@ -1,46 +1,46 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { CalendarData } from "@/features/Calendars/types/CalendarData";
import { renameDefault } from "@/utils/renameDefault";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { CalendarData } from '@/features/Calendars/types/CalendarData'
import { renameDefault } from '@/utils/renameDefault'
import {
Box,
Button,
IconButton,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import CloseIcon from "@mui/icons-material/Close";
import { useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { ResponsiveDialog } from "../Dialog";
import { ColorPicker } from "./CalendarColorPicker";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { Resource, ResourceSearch } from "../Attendees/ResourceSearch";
import { ResourceIcon } from "../Attendees/ResourceIcon";
useTheme
} from '@linagora/twake-mui'
import CloseIcon from '@mui/icons-material/Close'
import { useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ResponsiveDialog } from '../Dialog'
import { ColorPicker } from './CalendarColorPicker'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { Resource, ResourceSearch } from '../Attendees/ResourceSearch'
import { ResourceIcon } from '../Attendees/ResourceIcon'
interface CalendarWithOwner {
cal: CalendarData;
owner: Resource;
cal: CalendarData
owner: Resource
}
function CalendarItem({
cal,
onRemove,
onColorChange,
onColorChange
}: {
cal: CalendarWithOwner;
onRemove: () => void;
onColorChange: (color: Record<string, string>) => void;
cal: CalendarWithOwner
onRemove: () => void
onColorChange: (color: Record<string, string>) => void
}) {
const theme = useTheme();
const { t } = useI18n();
const theme = useTheme()
const { t } = useI18n()
return (
<Box
key={cal.cal["dav:name"]}
key={cal.cal['dav:name']}
display="flex"
justifyContent="space-between"
gap={2}
@@ -48,17 +48,17 @@ function CalendarItem({
<Box display="flex" alignItems="center" gap={1}>
<ResourceIcon avatarUrl={cal.owner.avatarUrl} />
<Typography variant="body1">
{renameDefault(cal.cal["dav:name"], cal.owner.displayName, t, false)}
{renameDefault(cal.cal['dav:name'], cal.owner.displayName, t, false)}
</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<ColorPicker
selectedColor={{
light: cal.cal["apple:color"] ?? defaultColors[0].light,
dark: cal.cal["apple:color"]
? getAccessiblePair(cal.cal["apple:color"], theme)
: defaultColors[0].dark,
light: cal.cal['apple:color'] ?? defaultColors[0].light,
dark: cal.cal['apple:color']
? getAccessiblePair(cal.cal['apple:color'], theme)
: defaultColors[0].dark
}}
onChange={onColorChange}
/>
@@ -67,33 +67,30 @@ function CalendarItem({
</IconButton>
</Box>
</Box>
);
)
}
function SelectedCalendarsList({
calendars,
selectedCal,
onRemove,
onColorChange,
onColorChange
}: {
calendars: Record<string, Calendar>;
selectedCal: CalendarWithOwner[];
onRemove: (cal: CalendarWithOwner) => void;
onColorChange: (
cal: CalendarWithOwner,
color: Record<string, string>
) => void;
calendars: Record<string, Calendar>
selectedCal: CalendarWithOwner[]
onRemove: (cal: CalendarWithOwner) => void
onColorChange: (cal: CalendarWithOwner, color: Record<string, string>) => void
}) {
const { t } = useI18n();
if (selectedCal.length === 0) return null;
const { t } = useI18n()
if (selectedCal.length === 0) return null
const groupedByOwner = selectedCal.reduce<
Record<
string,
{
owner: Resource;
visibleCals: CalendarWithOwner[];
alreadyExisting: boolean;
owner: Resource
visibleCals: CalendarWithOwner[]
alreadyExisting: boolean
}
>
>((acc, cal) => {
@@ -101,106 +98,106 @@ function SelectedCalendarsList({
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!acc[cal.owner.displayName]) {
acc[cal.owner.displayName] = {
owner: cal.owner,
visibleCals: [],
alreadyExisting: false,
};
alreadyExisting: false
}
}
if (exists) {
acc[cal.owner.displayName].alreadyExisting = true;
acc[cal.owner.displayName].alreadyExisting = true
} else {
acc[cal.owner.displayName].visibleCals.push(cal);
acc[cal.owner.displayName].visibleCals.push(cal)
}
return acc;
}, {});
return acc
}, {})
return (
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("common.resource")}
{t('common.resource')}
</Typography>
{Object.values(groupedByOwner).map(
({ owner, visibleCals, alreadyExisting }) => (
<Box key={owner.displayName} mb={2}>
{visibleCals.length > 0 ? (
visibleCals.map((cal) =>
visibleCals.map(cal =>
cal.cal ? (
<CalendarItem
key={cal.owner.displayName + cal.cal["dav:name"]}
key={cal.owner.displayName + cal.cal['dav:name']}
cal={cal}
onRemove={() => onRemove(cal)}
onColorChange={(color) => onColorChange(cal, color)}
onColorChange={color => onColorChange(cal, color)}
/>
) : (
<Typography
key={t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
key={t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
{t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
</Typography>
)
)
) : alreadyExisting ? (
<Typography
key={t("calendar.noMoreCalendarsFor", {
name: owner.displayName,
key={t('calendar.noMoreCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noMoreCalendarsFor", { name: owner.displayName })}
{t('calendar.noMoreCalendarsFor', { name: owner.displayName })}
</Typography>
) : null}
</Box>
)
)}
</Box>
);
)
}
export default function CalendarResources({
open,
onClose,
onClose
}: {
open: boolean;
onClose: (ids?: string[]) => void;
open: boolean
onClose: (ids?: string[]) => void
}) {
const dispatch = useAppDispatch();
const theme = useTheme();
const dispatch = useAppDispatch()
const theme = useTheme()
const openpaasId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([]);
const [selectedResources, setSelectedResources] = useState<Resource[]>([]);
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([])
const [selectedResources, setSelectedResources] = useState<Resource[]>([])
const fetchSeqRef = useRef(0);
const fetchSeqRef = useRef(0)
const handleSave = async () => {
if (selectedCal.length > 0) {
const results = await Promise.allSettled(
selectedCal.map(async (cal) => {
const calId = crypto.randomUUID();
selectedCal.map(async cal => {
const calId = crypto.randomUUID()
const exists = Object.values(calendars).some(
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!exists && cal.cal) {
await dispatch(
addCalendarResourceAsync({
@@ -208,134 +205,134 @@ export default function CalendarResources({
calId,
cal: {
...cal,
color: cal.cal["apple:color"]
color: cal.cal['apple:color']
? {
light: cal.cal["apple:color"],
dark: getAccessiblePair(cal.cal["apple:color"], theme),
light: cal.cal['apple:color'],
dark: getAccessiblePair(cal.cal['apple:color'], theme)
}
: defaultColors[0],
},
: defaultColors[0]
}
})
).unwrap();
).unwrap()
return cal.cal._links.self?.href
?.replace("/calendars/", "")
.replace(".json", "");
?.replace('/calendars/', '')
.replace('.json', '')
}
return null;
return null
})
);
)
const idList = results
.filter((r) => r.status === "fulfilled")
.map((r) => (r as PromiseFulfilledResult<string | null>).value)
.filter(Boolean) as string[];
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<string | null>).value)
.filter(Boolean) as string[]
onClose(idList);
onClose(idList)
} else {
onClose();
onClose()
}
setSelectedCalendars([]);
setSelectedResources([]);
};
const { t } = useI18n();
setSelectedCalendars([])
setSelectedResources([])
}
const { t } = useI18n()
const handleClose = () => {
fetchSeqRef.current += 1; // invalidate in-flight fetch results
onClose();
setSelectedCalendars([]);
setSelectedResources([]);
};
fetchSeqRef.current += 1 // invalidate in-flight fetch results
onClose()
setSelectedCalendars([])
setSelectedResources([])
}
return (
<ResponsiveDialog
open={open}
contentSx={{ paddingTop: "8px !important" }}
contentSx={{ paddingTop: '8px !important' }}
onClose={handleClose}
title={t("calendar.browseResources")}
title={t('calendar.browseResources')}
actions={
<>
<Button variant="outlined" onClick={handleClose}>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("actions.add")}
{t('actions.add')}
</Button>
</>
}
>
<ResourceSearch
objectTypes={["resource"]}
objectTypes={['resource']}
selectedResources={selectedResources}
inputSlot={(params) => <TextField {...params} size="small" />}
inputSlot={params => <TextField {...params} size="small" />}
onChange={async (_event: React.SyntheticEvent, value: Resource[]) => {
const requestSeq = ++fetchSeqRef.current;
setSelectedResources(value);
const requestSeq = ++fetchSeqRef.current
setSelectedResources(value)
const results = await Promise.allSettled(
value.map(async (user: Resource) => {
if (user?.openpaasId) {
const cals = await getCalendars(
user.openpaasId,
"sharedPublic=true&"
);
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map((cal) => ({
'sharedPublic=true&'
)
return cals._embedded?.['dav:calendar']
? cals._embedded['dav:calendar'].map(cal => ({
cal,
owner: user,
owner: user
}))
: [{ cal: undefined, owner: user }];
: [{ cal: undefined, owner: user }]
}
return null;
return null
})
);
)
const successfulCals = results
.filter((result) => result.status === "fulfilled")
.filter(result => result.status === 'fulfilled')
.map(
(result) =>
result =>
(result as PromiseFulfilledResult<CalendarWithOwner[]>).value
)
.flat()
.filter(Boolean);
.filter(Boolean)
if (requestSeq !== fetchSeqRef.current) return;
setSelectedCalendars(successfulCals as CalendarWithOwner[]);
if (requestSeq !== fetchSeqRef.current) return
setSelectedCalendars(successfulCals)
}}
/>
<SelectedCalendarsList
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
if (!cal.cal?._links?.self?.href) return;
setSelectedCalendars((prev) =>
onRemove={cal => {
if (!cal.cal?._links?.self?.href) return
setSelectedCalendars(prev =>
prev.filter(
(c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href
c => c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
);
)
if (
!selectedCal.find(
(c) =>
c =>
cal.owner.displayName === c.owner.displayName &&
c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
) {
setSelectedResources((prev) =>
prev.filter((u) => u.displayName !== cal.owner.displayName)
);
setSelectedResources(prev =>
prev.filter(u => u.displayName !== cal.owner.displayName)
)
}
}}
onColorChange={(cal, color) =>
setSelectedCalendars((prev) =>
prev.map((prevcal) =>
setSelectedCalendars(prev =>
prev.map(prevcal =>
prevcal.owner.displayName === cal.owner.displayName &&
prevcal.cal._links.self?.href === cal.cal._links.self?.href
? {
...prevcal,
cal: {
...prevcal.cal,
"apple:color": color.light,
},
'apple:color': color.light
}
}
: prevcal
)
@@ -343,5 +340,5 @@ export default function CalendarResources({
}
/>
</ResponsiveDialog>
);
)
}
+133 -136
View File
@@ -1,9 +1,9 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { addSharedCalendarAsync } from "@/features/Calendars/services";
import { CalendarData } from "@/features/Calendars/types/CalendarData";
import { renameDefault } from "@/utils/renameDefault";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { addSharedCalendarAsync } from '@/features/Calendars/services'
import { CalendarData } from '@/features/Calendars/types/CalendarData'
import { renameDefault } from '@/utils/renameDefault'
import {
Avatar,
Box,
@@ -11,61 +11,61 @@ import {
IconButton,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import CloseIcon from "@mui/icons-material/Close";
import { useState } from "react";
import { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { ResponsiveDialog } from "../Dialog";
import { stringAvatar } from "../Event/utils/eventUtils";
import { ColorPicker } from "./CalendarColorPicker";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
useTheme
} from '@linagora/twake-mui'
import CloseIcon from '@mui/icons-material/Close'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { ResponsiveDialog } from '../Dialog'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ColorPicker } from './CalendarColorPicker'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
interface CalendarWithOwner {
cal: CalendarData;
owner: User;
cal: CalendarData
owner: User
}
function CalendarItem({
cal,
onRemove,
onColorChange,
onColorChange
}: {
cal: CalendarWithOwner;
onRemove: () => void;
onColorChange: (color: Record<string, string>) => void;
cal: CalendarWithOwner
onRemove: () => void
onColorChange: (color: Record<string, string>) => void
}) {
const theme = useTheme();
const { t } = useI18n();
const theme = useTheme()
const { t } = useI18n()
return (
<Box
key={cal.owner.email + cal.cal["dav:name"]}
key={cal.owner.email + cal.cal['dav:name']}
display="flex"
flexDirection="column"
alignItems="flex-start"
style={{
borderRadius: 8,
border: "1px solid #e5e7eb",
border: '1px solid #e5e7eb',
padding: 8,
marginBottom: 8,
marginBottom: 8
}}
>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Avatar
{...stringAvatar(cal.owner.displayName || cal.owner.email)}
style={{
border: `2px solid ${cal.cal["apple:color"] || defaultColors[0].light}`,
boxShadow: cal.cal["apple:color"]
? `0 0 0 2px ${cal.cal["apple:color"]}`
: `0 0 0 2px ${defaultColors[0].light}`,
border: `2px solid ${cal.cal['apple:color'] || defaultColors[0].light}`,
boxShadow: cal.cal['apple:color']
? `0 0 0 2px ${cal.cal['apple:color']}`
: `0 0 0 2px ${defaultColors[0].light}`
}}
/>
<Box>
<Typography variant="body1">
{renameDefault(
cal.cal["dav:name"],
cal.cal['dav:name'],
cal.owner.displayName,
t,
false
@@ -80,10 +80,10 @@ function CalendarItem({
<Box display="flex" alignItems="center" gap={1}>
<ColorPicker
selectedColor={{
light: cal.cal["apple:color"] ?? defaultColors[0].light,
dark: cal.cal["apple:color"]
? getAccessiblePair(cal.cal["apple:color"], theme)
: defaultColors[0].dark,
light: cal.cal['apple:color'] ?? defaultColors[0].light,
dark: cal.cal['apple:color']
? getAccessiblePair(cal.cal['apple:color'], theme)
: defaultColors[0].dark
}}
onChange={onColorChange}
/>
@@ -92,33 +92,30 @@ function CalendarItem({
</IconButton>
</Box>
</Box>
);
)
}
function SelectedCalendarsList({
calendars,
selectedCal,
onRemove,
onColorChange,
onColorChange
}: {
calendars: Record<string, Calendar>;
selectedCal: CalendarWithOwner[];
onRemove: (cal: CalendarWithOwner) => void;
onColorChange: (
cal: CalendarWithOwner,
color: Record<string, string>
) => void;
calendars: Record<string, Calendar>
selectedCal: CalendarWithOwner[]
onRemove: (cal: CalendarWithOwner) => void
onColorChange: (cal: CalendarWithOwner, color: Record<string, string>) => void
}) {
const { t } = useI18n();
if (selectedCal.length === 0) return null;
const { t } = useI18n()
if (selectedCal.length === 0) return null
const groupedByOwner = selectedCal.reduce<
Record<
string,
{
owner: User;
visibleCals: CalendarWithOwner[];
alreadyExisting: boolean;
owner: User
visibleCals: CalendarWithOwner[]
alreadyExisting: boolean
}
>
>((acc, cal) => {
@@ -126,107 +123,107 @@ function SelectedCalendarsList({
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!acc[cal.owner.email]) {
acc[cal.owner.email] = {
owner: cal.owner,
visibleCals: [],
alreadyExisting: false,
};
alreadyExisting: false
}
}
if (exists) {
acc[cal.owner.email].alreadyExisting = true;
acc[cal.owner.email].alreadyExisting = true
} else {
acc[cal.owner.email].visibleCals.push(cal);
acc[cal.owner.email].visibleCals.push(cal)
}
return acc;
}, {});
return acc
}, {})
return (
<Box mt={2}>
<Typography variant="subtitle1" gutterBottom>
{t("common.name")}
{t('common.name')}
</Typography>
{Object.values(groupedByOwner).map(
({ owner, visibleCals, alreadyExisting }) => (
<Box key={owner.email} mb={2}>
{visibleCals.length > 0 ? (
visibleCals.map((cal) =>
visibleCals.map(cal =>
cal.cal ? (
<CalendarItem
key={cal.owner.email + cal.cal["dav:name"]}
key={cal.owner.email + cal.cal['dav:name']}
cal={cal}
onRemove={() => onRemove(cal)}
onColorChange={(color) => onColorChange(cal, color)}
onColorChange={color => onColorChange(cal, color)}
/>
) : (
<Typography
key={t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
key={t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
{t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
</Typography>
)
)
) : alreadyExisting ? (
<Typography
key={t("calendar.noMoreCalendarsFor", {
name: owner.displayName,
key={t('calendar.noMoreCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noMoreCalendarsFor", { name: owner.displayName })}
{t('calendar.noMoreCalendarsFor', { name: owner.displayName })}
</Typography>
) : null}
</Box>
)
)}
</Box>
);
)
}
export default function CalendarSearch({
open,
onClose,
onClose
}: {
open: boolean;
open: boolean
onClose: (
result?: string[] | Record<string, never>,
reason?: "backdropClick" | "escapeKeyDown"
) => void;
reason?: 'backdropClick' | 'escapeKeyDown'
) => void
}) {
const dispatch = useAppDispatch();
const theme = useTheme();
const dispatch = useAppDispatch()
const theme = useTheme()
const openpaasId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([]);
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([])
const [selectedUsers, setSelectedUsers] = useState<User[]>([])
const handleSave = async () => {
if (selectedCal.length > 0) {
const idList = await Promise.all(
selectedCal.map(async (cal) => {
const calId = crypto.randomUUID();
selectedCal.map(async cal => {
const calId = crypto.randomUUID()
const exists = Object.values(calendars).some(
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!exists && cal.cal) {
await dispatch(
addSharedCalendarAsync({
@@ -234,117 +231,117 @@ export default function CalendarSearch({
calId,
cal: {
...cal,
color: cal.cal["apple:color"]
color: cal.cal['apple:color']
? {
light: cal.cal["apple:color"],
dark: getAccessiblePair(cal.cal["apple:color"], theme),
light: cal.cal['apple:color'],
dark: getAccessiblePair(cal.cal['apple:color'], theme)
}
: defaultColors[0],
},
: defaultColors[0]
}
})
);
)
return cal.cal._links.self?.href
?.replace("/calendars/", "")
.replace(".json", "");
?.replace('/calendars/', '')
.replace('.json', '')
}
return null;
return null
})
);
)
onClose(idList.filter(Boolean) as string[]);
setSelectedCalendars([]);
setSelectedUsers([]);
onClose(idList.filter(Boolean) as string[])
setSelectedCalendars([])
setSelectedUsers([])
}
};
const { t } = useI18n();
}
const { t } = useI18n()
return (
<ResponsiveDialog
open={open}
contentSx={{ paddingTop: "8px !important" }}
contentSx={{ paddingTop: '8px !important' }}
onClose={() => {
onClose({}, "backdropClick");
setSelectedCalendars([]);
setSelectedUsers([]);
onClose({}, 'backdropClick')
setSelectedCalendars([])
setSelectedUsers([])
}}
title={t("calendar.browseOtherCalendars")}
title={t('calendar.browseOtherCalendars')}
actions={
<>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
onClick={() => onClose({}, 'backdropClick')}
>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("actions.add")}
{t('actions.add')}
</Button>
</>
}
>
<PeopleSearch
objectTypes={["user"]}
objectTypes={['user']}
selectedUsers={selectedUsers}
inputSlot={(params) => <TextField {...params} size="small" />}
inputSlot={params => <TextField {...params} size="small" />}
onChange={async (_event: React.SyntheticEvent, value: User[]) => {
setSelectedUsers(value);
setSelectedUsers(value)
const cals = await Promise.all(
value.map(async (user: User) => {
if (user?.openpaasId) {
const cals = await getCalendars(
user.openpaasId,
"sharedPublic=true&"
);
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map((cal) => ({
'sharedPublic=true&'
)
return cals._embedded?.['dav:calendar']
? cals._embedded['dav:calendar'].map(cal => ({
cal,
owner: user,
owner: user
}))
: { cal: undefined, owner: user };
: { cal: undefined, owner: user }
}
return null;
return null
})
);
)
setSelectedCalendars(
cals.flat().filter(Boolean) as CalendarWithOwner[]
);
)
}}
/>
<SelectedCalendarsList
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
if (!cal.cal?._links?.self?.href) return;
setSelectedCalendars((prev) =>
onRemove={cal => {
if (!cal.cal?._links?.self?.href) return
setSelectedCalendars(prev =>
prev.filter(
(c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href
c => c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
);
)
if (
!selectedCal.find(
(c) =>
c =>
cal.owner.email === c.owner.email &&
c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
) {
setSelectedUsers((prev) =>
prev.filter((u) => u.email !== cal.owner.email)
);
setSelectedUsers(prev =>
prev.filter(u => u.email !== cal.owner.email)
)
}
}}
onColorChange={(cal, color) =>
setSelectedCalendars((prev) =>
prev.map((prevcal) =>
setSelectedCalendars(prev =>
prev.map(prevcal =>
prevcal.owner.email === cal.owner.email &&
prevcal.cal._links.self?.href === cal.cal._links.self?.href
? {
...prevcal,
cal: {
...prevcal.cal,
"apple:color": color.light,
},
'apple:color': color.light
}
}
: prevcal
)
@@ -352,5 +349,5 @@ export default function CalendarSearch({
}
/>
</ResponsiveDialog>
);
)
}
+160 -156
View File
@@ -1,10 +1,10 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { removeCalendarAsync } from "@/features/Calendars/services";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { renameDefault } from "@/utils/renameDefault";
import { trimLongTextWithoutSpace } from "@/utils/textUtils";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { removeCalendarAsync } from '@/features/Calendars/services'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { renameDefault } from '@/utils/renameDefault'
import { trimLongTextWithoutSpace } from '@/utils/textUtils'
import {
Accordion,
AccordionDetails,
@@ -14,18 +14,18 @@ import {
IconButton,
ListItem,
Menu,
MenuItem,
} from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { SetStateAction, useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import CalendarPopover from "./CalendarModal";
import CalendarSearch from "./CalendarSearch";
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
import { OwnerCaption } from "./OwnerCaption";
import CalendarResources from "./CalendarResources";
MenuItem
} from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import MoreHorizIcon from '@mui/icons-material/MoreHoriz'
import { SetStateAction, useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import CalendarPopover from './CalendarModal'
import CalendarSearch from './CalendarSearch'
import { DeleteCalendarDialog } from './DeleteCalendarDialog'
import { OwnerCaption } from './OwnerCaption'
import CalendarResources from './CalendarResources'
function CalendarAccordion({
title,
@@ -36,40 +36,46 @@ function CalendarAccordion({
onAddClick,
defaultExpanded = false,
setOpen,
hideOwner,
hideOwner
}: {
title: string;
calendars: string[];
selectedCalendars: string[];
handleToggle: (id: string) => void;
showAddButton?: boolean;
onAddClick?: () => void;
defaultExpanded?: boolean;
setOpen: (id: string) => void;
hideOwner?: boolean;
title: string
calendars: string[]
selectedCalendars: string[]
handleToggle: (id: string) => void
showAddButton?: boolean
onAddClick?: () => void
defaultExpanded?: boolean
setOpen: (id: string) => void
hideOwner?: boolean
}) {
const allCalendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n();
const allCalendars = useAppSelector(state => state.calendars.list)
const { t } = useI18n()
const [expended, setExpended] = useState(defaultExpanded);
useEffect(() => setExpended(defaultExpanded), [defaultExpanded]);
const [expended, setExpended] = useState(defaultExpanded)
if (calendars.length === 0 && !showAddButton) return null;
useEffect(() => {
const handleExpendedChange = () => {
setExpended(defaultExpanded)
}
handleExpendedChange()
}, [defaultExpanded])
if (calendars.length === 0 && !showAddButton) return null
return (
<Accordion
defaultExpanded={defaultExpanded}
expanded={expended}
style={{
width: "100%",
width: '100%',
padding: 0,
margin: 0,
marginBottom: "12px",
boxShadow: "none",
marginBottom: '12px',
boxShadow: 'none'
}}
sx={{
"&::before": {
display: "none",
},
'&::before': {
display: 'none'
}
}}
>
<AccordionSummary
@@ -79,23 +85,23 @@ function CalendarAccordion({
className="calendarListHeader"
onClick={() => setExpended(!expended)}
sx={{
"& .MuiAccordionSummary-content": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
'& .MuiAccordionSummary-content': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}
}}
>
<span>{title}</span>
{showAddButton && (
<IconButton
component="span"
onClick={(e) => {
onClick={e => {
if (expended) {
e.stopPropagation();
e.stopPropagation()
}
if (onAddClick) {
onAddClick();
onAddClick()
}
}}
>
@@ -103,13 +109,13 @@ function CalendarAccordion({
</IconButton>
)}
</AccordionSummary>
<AccordionDetails style={{ textAlign: "left", padding: 0 }}>
{calendars.map((id) => (
<AccordionDetails style={{ textAlign: 'left', padding: 0 }}>
{calendars.map(id => (
<CalendarSelector
key={id}
calendars={allCalendars}
id={id}
isPersonal={title === t("calendar.personal")}
isPersonal={title === t('calendar.personal')}
selectedCalendars={selectedCalendars}
handleCalendarToggle={handleToggle}
setOpen={() => setOpen(id)}
@@ -118,111 +124,110 @@ function CalendarAccordion({
))}
</AccordionDetails>
</Accordion>
);
)
}
export default function CalendarSelection({
selectedCalendars,
setSelectedCalendars,
setSelectedCalendars
}: {
selectedCalendars: string[];
setSelectedCalendars: (value: SetStateAction<string[]>) => void;
selectedCalendars: string[]
setSelectedCalendars: (value: SetStateAction<string[]>) => void
}) {
const { t } = useI18n();
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const personalCalendars = Object.keys(calendars || {}).filter(
(id) => extractEventBaseUuid(id) === userId
);
id => extractEventBaseUuid(id) === userId
)
const delegatedCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId &&
calendars[id]?.delegated &&
!calendars?.[id]?.owner?.resource
);
)
const sharedCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId &&
!calendars?.[id]?.delegated &&
!calendars?.[id]?.owner?.resource
);
)
const resourceCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId && calendars?.[id]?.owner?.resource
);
)
const handleCalendarToggle = (name: string) => {
setSelectedCalendars((prev: string[]) =>
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]
);
};
const [selectedCalId, setSelectedCalId] = useState("");
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
)
}
const [selectedCalId, setSelectedCalId] = useState('')
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null)
const [anchorElCalOthers, setAnchorElCalOthers] =
useState<HTMLElement | null>(null);
useState<HTMLElement | null>(null)
const [anchorElCalResources, setAnchorElCalResources] =
useState<HTMLElement | null>(null);
useState<HTMLElement | null>(null)
return (
<>
<div>
<CalendarAccordion
title={t("calendar.personal")}
title={t('calendar.personal')}
calendars={personalCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
showAddButton
onAddClick={() => setAnchorElCal(document.body)}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.delegated")}
title={t('calendar.delegated')}
calendars={delegatedCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.other")}
title={t('calendar.other')}
calendars={sharedCalendars}
selectedCalendars={selectedCalendars}
showAddButton
onAddClick={() => {
setAnchorElCalOthers(document.body);
setAnchorElCalOthers(document.body)
}}
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.resources")}
title={t('calendar.resources')}
calendars={resourceCalendars}
selectedCalendars={selectedCalendars}
onAddClick={() => {
setAnchorElCalResources(document.body);
setAnchorElCalResources(document.body)
}}
showAddButton
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
hideOwner={true}
@@ -232,32 +237,32 @@ export default function CalendarSelection({
open={Boolean(anchorElCal)}
calendar={calendars?.[selectedCalId] ?? undefined}
onClose={() => {
setSelectedCalId("");
setAnchorElCal(null);
setSelectedCalId('')
setAnchorElCal(null)
}}
/>
<CalendarSearch
open={Boolean(anchorElCalOthers)}
onClose={(newCalIds?: string[]) => {
setAnchorElCalOthers(null);
setAnchorElCalOthers(null)
if (newCalIds?.length) {
newCalIds.forEach((id) => handleCalendarToggle(id));
newCalIds.forEach(id => handleCalendarToggle(id))
}
}}
/>
<CalendarResources
open={Boolean(anchorElCalResources)}
onClose={(newResourceIds?: string[]) => {
setAnchorElCalResources(null);
setAnchorElCalResources(null)
if (newResourceIds?.length) {
newResourceIds.forEach((id) => {
handleCalendarToggle(id);
});
newResourceIds.forEach(id => {
handleCalendarToggle(id)
})
}
}}
/>
</>
);
)
}
function CalendarSelector({
@@ -267,113 +272,112 @@ function CalendarSelector({
selectedCalendars,
handleCalendarToggle,
setOpen,
hideOwner,
hideOwner
}: {
calendars: Record<string, Calendar>;
id: string;
isPersonal: boolean;
selectedCalendars: string[];
handleCalendarToggle: (name: string) => void;
setOpen: () => void;
hideOwner?: boolean;
calendars: Record<string, Calendar>
id: string
isPersonal: boolean
selectedCalendars: string[]
handleCalendarToggle: (name: string) => void
setOpen: () => void
hideOwner?: boolean
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calLink =
useAppSelector((state) => state.calendars.list[id].link) ?? "";
const { t } = useI18n()
const dispatch = useAppDispatch()
const calLink = useAppSelector(state => state.calendars.list[id].link) ?? ''
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null);
};
const [userId, calId] = id.split("/");
const isDefault = isPersonal && userId === calId;
setAnchorEl(null)
}
const [userId, calId] = id.split('/')
const isDefault = isPersonal && userId === calId
const [deletePopupOpen, setDeletePopupOpen] = useState(false);
const [deletePopupOpen, setDeletePopupOpen] = useState(false)
const handleDeleteConfirm = () => {
dispatch(removeCalendarAsync({ calId: id, calLink }));
setDeletePopupOpen(false);
handleClose();
};
dispatch(removeCalendarAsync({ calId: id, calLink }))
setDeletePopupOpen(false)
handleClose()
}
const trimmedName = useMemo(
() => trimLongTextWithoutSpace(calendars[id].name),
[calendars, id]
);
)
const ownerDisplayName = useMemo(
() => makeDisplayName(calendars[id]),
[calendars, id]
);
)
const displayName = useMemo(
() => renameDefault(trimmedName, ownerDisplayName ?? "", t, isPersonal),
() => renameDefault(trimmedName, ownerDisplayName ?? '', t, isPersonal),
[trimmedName, ownerDisplayName, t, isPersonal]
);
)
const showCaption =
!isPersonal &&
trimmedName !== "#default" &&
trimmedName !== '#default' &&
ownerDisplayName != null &&
!hideOwner;
!hideOwner
return (
<>
<ListItem
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "background-color 0.2s ease",
"& .MoreBtn": { opacity: 0 },
"&:hover": {
backgroundColor: "#F3F3F6",
"& .MoreBtn": { opacity: 1 },
},
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background-color 0.2s ease',
'& .MoreBtn': { opacity: 0 },
'&:hover': {
backgroundColor: '#F3F3F6',
'& .MoreBtn': { opacity: 1 }
}
}}
>
<label
style={{
display: "flex",
alignItems: "center",
maxWidth: "calc(100% - 40px)",
overflow: "hidden",
display: 'flex',
alignItems: 'center',
maxWidth: 'calc(100% - 40px)',
overflow: 'hidden'
}}
>
<Checkbox
sx={{
color: calendars[id].color?.light,
"&.Mui-checked": { color: calendars[id].color?.light },
'&.Mui-checked': { color: calendars[id].color?.light }
}}
size="small"
checked={selectedCalendars.includes(id)}
onChange={() => handleCalendarToggle(id)}
inputProps={{ "aria-label": displayName }}
inputProps={{ 'aria-label': displayName }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: showCaption ? "6px" : undefined,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: showCaption ? '6px' : undefined
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word'
}}
>
{displayName}
</span>
<OwnerCaption
showCaption={showCaption}
ownerDisplayName={ownerDisplayName ?? ""}
ownerDisplayName={ownerDisplayName ?? ''}
/>
</div>
</label>
@@ -384,16 +388,16 @@ function CalendarSelector({
<Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem
onClick={() => {
setOpen();
handleClose();
setOpen()
handleClose()
}}
>
{t("actions.modify")}
{t('actions.modify')}
</MenuItem>
{!isDefault && <Divider />}
{!isDefault && (
<MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}>
{isPersonal ? t("actions.delete") : t("actions.remove")}
{isPersonal ? t('actions.delete') : t('actions.remove')}
</MenuItem>
)}
</Menu>
@@ -407,5 +411,5 @@ function CalendarSelector({
handleDeleteConfirm={handleDeleteConfirm}
/>
</>
);
)
}
@@ -1,13 +1,13 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
DialogTitle
} from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export function DeleteCalendarDialog({
deletePopupOpen,
@@ -15,39 +15,39 @@ export function DeleteCalendarDialog({
calendars,
id,
isPersonal,
handleDeleteConfirm,
handleDeleteConfirm
}: {
deletePopupOpen: boolean;
setDeletePopupOpen: (e: boolean) => void;
calendars: Record<string, Calendar>;
id: string;
isPersonal: boolean;
handleDeleteConfirm: () => void;
deletePopupOpen: boolean
setDeletePopupOpen: (e: boolean) => void
calendars: Record<string, Calendar>
id: string
isPersonal: boolean
handleDeleteConfirm: () => void
}) {
const { t } = useI18n();
const { t } = useI18n()
return (
<Dialog open={deletePopupOpen} onClose={() => setDeletePopupOpen(false)}>
<DialogTitle>
{t("calendar.delete.title", { name: calendars[id].name })}
{t('calendar.delete.title', { name: calendars[id].name })}
</DialogTitle>
<DialogContent>
<DialogContentText>
{isPersonal
? t("calendar.delete.personalWarning")
: t("calendar.delete.sharedWarning")}
? t('calendar.delete.personalWarning')
: t('calendar.delete.sharedWarning')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeletePopupOpen(false)}>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button onClick={handleDeleteConfirm} variant="contained">
{isPersonal ? t("actions.delete") : t("actions.remove")}
{isPersonal ? t('actions.delete') : t('actions.remove')}
</Button>
</DialogActions>
</Dialog>
);
)
}
+55 -55
View File
@@ -1,5 +1,5 @@
import { useAppSelector } from "@/app/hooks";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { useAppSelector } from '@/app/hooks'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import {
Box,
Button,
@@ -8,72 +8,72 @@ import {
MenuItem,
Select,
TextField,
Typography,
} from "@linagora/twake-mui";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { CalendarItemList } from "./CalendarItemList";
import { SettingsTab } from "./SettingsTab";
Typography
} from '@linagora/twake-mui'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { CalendarItemList } from './CalendarItemList'
import { SettingsTab } from './SettingsTab'
export function ImportTab({
userId,
importTarget,
setImportTarget,
setImportedContent,
newCalParams,
newCalParams
}: {
userId: string;
importTarget: string;
setImportTarget: (target: string) => void;
setImportedContent: (content: File | null) => void;
userId: string
importTarget: string
setImportTarget: (target: string) => void
setImportedContent: (content: File | null) => void
newCalParams: {
name: string;
setName: (name: string) => void;
description: string;
setDescription: (d: string) => void;
color: Record<string, string>;
setColor: (color: Record<string, string>) => void;
visibility: "public" | "private";
setVisibility: (visibility: "public" | "private") => void;
};
name: string
setName: (name: string) => void
description: string
setDescription: (d: string) => void
color: Record<string, string>
setColor: (color: Record<string, string>) => void
visibility: 'public' | 'private'
setVisibility: (visibility: 'public' | 'private') => void
}
}) {
const { t } = useI18n();
const [importMode] = useState<"file" | "url">("file");
const [importFile, setImportFile] = useState<File | null>(null);
const [importUrl, setImportUrl] = useState("");
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const [importMode] = useState<'file' | 'url'>('file')
const [importFile, setImportFile] = useState<File | null>(null)
const [importUrl, setImportUrl] = useState('')
const calendars = useAppSelector(state => state.calendars.list)
const personalCalendars = Object.values(calendars).filter(
(cal) => extractEventBaseUuid(cal.id) === userId
);
cal => extractEventBaseUuid(cal.id) === userId
)
useEffect(() => {
setImportedContent(importMode === "file" ? importFile : null);
}, [importFile, importUrl, importMode, setImportedContent]);
setImportedContent(importMode === 'file' ? importFile : null)
}, [importFile, importUrl, importMode, setImportedContent])
return (
<>
{/* Form group 1: Select file button - first group, margin top 0 */}
{importMode === "file" && (
{importMode === 'file' && (
<Box mt={0}>
<Button
variant="outlined"
component="label"
size="medium"
sx={{ borderRadius: "12px" }}
sx={{ borderRadius: '12px' }}
>
{t("common.select_file")}
{t('common.select_file')}
<input
type="file"
hidden
accept=".ics"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
onChange={e => setImportFile(e.target.files?.[0] ?? null)}
/>
</Button>
{importFile && (
<Typography
variant="body2"
color="text.secondary"
sx={{ marginTop: "6px" }}
sx={{ marginTop: '6px' }}
>
{importFile.name}
</Typography>
@@ -82,28 +82,28 @@ export function ImportTab({
variant="caption"
color="text.secondary"
display="block"
sx={{ marginTop: "6px" }}
sx={{ marginTop: '6px' }}
>
{t("calendar.import_file_description")}
{t('calendar.import_file_description')}
</Typography>
</Box>
)}
{/* Form group 2: URL field */}
{importMode === "url" && (
{importMode === 'url' && (
<Box mt={0}>
<TextField
fullWidth
label={t("calendar.ics_feed_url")}
label={t('calendar.ics_feed_url')}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
onChange={e => setImportUrl(e.target.value)}
size="small"
margin="dense"
sx={{
"&.MuiFormControl-root.MuiFormControl-marginDense": {
marginTop: "6px",
marginBottom: 0,
},
'&.MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: '6px',
marginBottom: 0
}
}}
/>
</Box>
@@ -116,33 +116,33 @@ export function ImportTab({
size="small"
margin="dense"
sx={{
"&.MuiFormControl-root.MuiFormControl-marginDense": {
marginTop: "6px",
marginBottom: 0,
},
'&.MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: '6px',
marginBottom: 0
}
}}
>
<InputLabel id="import-to-label">
{t("calendar.import_to")}
{t('calendar.import_to')}
</InputLabel>
<Select
labelId="import-to-label"
label={t("calendar.import_to")}
label={t('calendar.import_to')}
value={importTarget}
onChange={(e) => setImportTarget(e.target.value)}
onChange={e => setImportTarget(e.target.value)}
>
<MenuItem value="new">{t("calendar.new_calendar")}</MenuItem>
<MenuItem value="new">{t('calendar.new_calendar')}</MenuItem>
{CalendarItemList(personalCalendars)}
</Select>
</FormControl>
</Box>
{/* Form group 4: SettingsTab (when importing to new calendar) */}
{importTarget === "new" && (
{importTarget === 'new' && (
<Box mt={2}>
<SettingsTab {...newCalParams} />
</Box>
)}
</>
);
)
}
+79 -74
View File
@@ -1,121 +1,126 @@
import { useAppDispatch } from "@/app/hooks";
import { setView } from "@/features/Settings/SettingsSlice";
import { computeStartOfTheWeek } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DateCalendar } from "@mui/x-date-pickers";
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import moment from "moment";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { useAppDispatch } from '@/app/hooks'
import { setView } from '@/features/Settings/SettingsSlice'
import { computeStartOfTheWeek } from '@/utils/dateUtils'
import type { CalendarApi } from '@fullcalendar/core'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { DateCalendar } from '@mui/x-date-pickers'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
export function MiniCalendar({
calendarRef,
selectedDate,
setSelectedMiniDate,
setSelectedMiniDate
}: {
calendarRef: React.MutableRefObject<CalendarApi | null>;
selectedDate: Date;
setSelectedMiniDate: (d: Date) => void;
calendarRef: React.MutableRefObject<CalendarApi | null>
selectedDate: Date
setSelectedMiniDate: (d: Date) => void
}) {
const dispatch = useAppDispatch();
const [visibleDate, setVisibleDate] = useState(selectedDate);
const { t } = useI18n();
const dispatch = useAppDispatch()
const [visibleDate, setVisibleDate] = useState(selectedDate)
const { t } = useI18n()
useEffect(() => setVisibleDate(selectedDate), [selectedDate]);
useEffect(() => {
const handleVisibleDateChange = () => {
setVisibleDate(selectedDate)
}
handleVisibleDateChange()
}, [selectedDate])
return (
<LocalizationProvider
dateAdapter={AdapterMoment}
adapterLocale={t("locale") ?? "en-gb"}
adapterLocale={t('locale') ?? 'en-gb'}
>
<DateCalendar
value={moment(visibleDate)}
onChange={async (dateMoment, selectionState) => {
if (!dateMoment) return;
const date = dateMoment.toDate();
if (selectionState === "finish") {
await dispatch(setView("calendar"));
setSelectedMiniDate(date);
calendarRef.current?.gotoDate(date);
if (!dateMoment) return
const date = dateMoment.toDate()
if (selectionState === 'finish') {
await dispatch(setView('calendar'))
setSelectedMiniDate(date)
calendarRef.current?.gotoDate(date)
}
}}
showDaysOutsideCurrentMonth
onMonthChange={(month) => {
setVisibleDate(month.toDate());
onMonthChange={month => {
setVisibleDate(month.toDate())
}}
views={["month", "day"]}
views={['month', 'day']}
slots={{
switchViewIcon: KeyboardArrowDownIcon,
switchViewIcon: KeyboardArrowDownIcon
}}
sx={{
width: "100%",
height: "300px",
"& .MuiPickersCalendarHeader-root": {
marginTop: 3,
},
width: '100%',
height: '300px',
'& .MuiPickersCalendarHeader-root': {
marginTop: 3
}
}}
slotProps={{
day: (ownerState) => {
const date = ownerState.day.toDate();
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(selectedDate);
selected.setHours(0, 0, 0, 0);
day: ownerState => {
const date = ownerState.day.toDate()
const today = new Date()
today.setHours(0, 0, 0, 0)
const selected = new Date(selectedDate)
selected.setHours(0, 0, 0, 0)
const isToday = date.getTime() === today.getTime();
const isToday = date.getTime() === today.getTime()
const isSelectedDay =
calendarRef.current?.view.type === "timeGridDay" &&
date.getTime() === selected.getTime();
calendarRef.current?.view.type === 'timeGridDay' &&
date.getTime() === selected.getTime()
const isInSelectedWeek =
calendarRef.current?.view.type === "timeGridWeek" ||
calendarRef.current?.view.type === 'timeGridWeek' ||
calendarRef.current?.view.type === undefined
? (() => {
const startOfWeek = computeStartOfTheWeek(selected);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
const startOfWeek = computeStartOfTheWeek(selected)
const endOfWeek = new Date(startOfWeek)
endOfWeek.setDate(startOfWeek.getDate() + 6)
endOfWeek.setHours(23, 59, 59, 999)
return date >= startOfWeek && date <= endOfWeek
})()
: false;
: false
const classNames = [
isToday ? "today" : "",
isSelectedDay ? "selectedDay" : "",
isInSelectedWeek ? "selectedWeek" : "",
].join(" ");
isToday ? 'today' : '',
isSelectedDay ? 'selectedDay' : '',
isInSelectedWeek ? 'selectedWeek' : ''
].join(' ')
return {
className: classNames,
selected: classNames.includes("selectedWeek"),
selected: classNames.includes('selectedWeek'),
outsideCurrentMonth: ownerState.isDayOutsideMonth,
disableMargin: false,
style: {
backgroundColor: "transparent",
position: "relative",
flexDirection: "column",
border: 0,
backgroundColor: 'transparent',
position: 'relative',
flexDirection: 'column',
border: 0
},
sx: {
"&.Mui-selected": {
color: "inherit !important",
fontWeight: "inherit !important",
'&.Mui-selected': {
color: 'inherit !important',
fontWeight: 'inherit !important'
},
"&.selectedDay": {
backgroundColor: "lightgray !important",
},
"&.today": {
background: "orange !important",
color: "white !important",
'&.selectedDay': {
backgroundColor: 'lightgray !important'
},
'&.today': {
background: 'orange !important',
color: 'white !important'
}
},
"data-testid": `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
children: <>{ownerState.day.date()}</>,
};
},
'data-testid': `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
children: <>{ownerState.day.date()}</>
}
}
}}
/>
</LocalizationProvider>
);
)
}
+8 -8
View File
@@ -1,23 +1,23 @@
import { Typography } from "@linagora/twake-mui";
import { Typography } from '@linagora/twake-mui'
export function OwnerCaption({
showCaption,
ownerDisplayName,
ownerDisplayName
}: {
showCaption: boolean;
ownerDisplayName: string;
showCaption: boolean
ownerDisplayName: string
}) {
return (
<Typography
variant="caption"
color="text.secondary"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word'
}}
>
{showCaption && ownerDisplayName}
</Typography>
);
)
}
+11 -11
View File
@@ -1,14 +1,14 @@
import { Avatar, Box, Typography } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { stringAvatar } from "../Event/utils/eventUtils";
import { UserWithAccess } from "./CalendarAccessRights";
import { Avatar, Box, Typography } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
import { stringAvatar } from '../Event/utils/eventUtils'
import { UserWithAccess } from './CalendarAccessRights'
interface ResourceAdminProps {
admin: UserWithAccess;
admin: UserWithAccess
}
export function ResourceAdmin({ admin }: ResourceAdminProps) {
const { t } = useI18n();
const { t } = useI18n()
return (
<Box
@@ -18,14 +18,14 @@ export function ResourceAdmin({ admin }: ResourceAdminProps) {
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(admin.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{admin.displayName}</Typography>
@@ -37,9 +37,9 @@ export function ResourceAdmin({ admin }: ResourceAdminProps) {
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.administrator")}
{t('calendarPopover.access.administrator')}
</Typography>
</Box>
</Box>
);
)
}
+60 -58
View File
@@ -1,23 +1,23 @@
import { useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import {
Box,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
useTheme,
} from "@linagora/twake-mui";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import { alpha } from "@mui/material/styles";
import PublicIcon from "@mui/icons-material/Public";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import { AddDescButton } from "../Event/AddDescButton";
import { ColorPicker } from "./CalendarColorPicker";
import { InfoRow } from "../Event/InfoRow";
useTheme
} from '@linagora/twake-mui'
import LockOutlineIcon from '@mui/icons-material/LockOutline'
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
import { alpha } from '@mui/material/styles'
import PublicIcon from '@mui/icons-material/Public'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { AddDescButton } from '../Event/AddDescButton'
import { ColorPicker } from './CalendarColorPicker'
import { InfoRow } from '../Event/InfoRow'
export function SettingsTab({
name,
@@ -28,35 +28,37 @@ export function SettingsTab({
setColor,
visibility,
setVisibility,
calendar,
calendar
}: {
name: string;
setName: (name: string) => void;
description: string;
setDescription: (d: string) => void;
color: Record<string, string>;
setColor: (color: Record<string, string>) => void;
visibility: "public" | "private";
setVisibility: (visibility: "public" | "private") => void;
calendar?: Calendar;
name: string
setName: (name: string) => void
description: string
setDescription: (d: string) => void
color: Record<string, string>
setColor: (color: Record<string, string>) => void
visibility: 'public' | 'private'
setVisibility: (visibility: 'public' | 'private') => void
calendar?: Calendar
}) {
const { t } = useI18n();
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true;
const theme = useTheme();
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const { t } = useI18n()
const [toggleDesc, setToggleDesc] = useState(Boolean(description))
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true
const theme = useTheme()
const infoIconColor = alpha(theme.palette.grey[900], 0.9)
const infoIconSx = { minWidth: '25px', marginRight: 2, color: infoIconColor }
const isResource = useMemo(
() => calendar?.owner?.resource,
[calendar?.owner?.resource]
);
)
useEffect(() => {
if (description) setToggleDesc(true);
}, [description]);
const handleToggleDesc = () => {
if (description) setToggleDesc(true)
}
handleToggleDesc()
}, [description])
return (
<>
@@ -64,15 +66,15 @@ export function SettingsTab({
<Box mt={0}>
<Typography
variant="h6"
sx={{ margin: 0, marginBottom: isResource ? "16px" : 0 }}
sx={{ margin: 0, marginBottom: isResource ? '16px' : 0 }}
>
{t(
isResource
? "calendarPopover.settings.resourceName"
: "calendarPopover.settings.calendarName"
? 'calendarPopover.settings.resourceName'
: 'calendarPopover.settings.calendarName'
)}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
{isResource ? (
<InfoRow
alignItems="flex-start"
@@ -83,24 +85,24 @@ export function SettingsTab({
}
text={name}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
fontSize: '16px',
fontFamily: "'Inter', sans-serif"
}}
/>
) : (
<TextField
fullWidth
label=""
inputProps={{ "aria-label": t("common.name") }}
placeholder={t("common.name")}
inputProps={{ 'aria-label': t('common.name') }}
placeholder={t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onChange={e => setName(e.target.value)}
size="small"
sx={{
"&.MuiFormControl-root": {
'&.MuiFormControl-root': {
marginTop: 0,
marginBottom: 0,
},
marginBottom: 0
}
}}
/>
)}
@@ -123,11 +125,11 @@ export function SettingsTab({
{/* Form group 3: Color */}
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("calendar.color")}
{t('calendar.color')}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
<ColorPicker
onChange={(color) => setColor(color)}
onChange={color => setColor(color)}
selectedColor={color}
/>
</Box>
@@ -137,29 +139,29 @@ export function SettingsTab({
{isOwn && (
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("calendar.newEventsVisibility")}
{t('calendar.newEventsVisibility')}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
<ToggleButtonGroup
value={visibility}
exclusive
onChange={(e, val) => val && setVisibility(val)}
size="medium"
sx={{ borderRadius: "12px" }}
sx={{ borderRadius: '12px' }}
>
<ToggleButton value="public" sx={{ width: "140px" }}>
<ToggleButton value="public" sx={{ width: '140px' }}>
<PublicIcon fontSize="small" sx={{ mr: 1 }} />
{t("common.all")}
{t('common.all')}
</ToggleButton>
<ToggleButton value="private" sx={{ width: "140px" }}>
<ToggleButton value="private" sx={{ width: '140px' }}>
<LockOutlineIcon fontSize="small" sx={{ mr: 1 }} />
{t("common.you")}
{t('common.you')}
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
)}
</>
);
)
}
+76 -79
View File
@@ -1,156 +1,153 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { removeTempCal } from "@/features/Calendars/CalendarSlice";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { getTempCalendarsListAsync } from "@/features/Calendars/services";
import { setView } from "@/features/Settings/SettingsSlice";
import { defaultColors } from "@/utils/defaultColors";
import { TextField } from "@linagora/twake-mui";
import { useRef } from "react";
import { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { removeTempCal } from '@/features/Calendars/CalendarSlice'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { getTempCalendarsListAsync } from '@/features/Calendars/services'
import { setView } from '@/features/Settings/SettingsSlice'
import { defaultColors } from '@/utils/defaultColors'
import { TextField } from '@linagora/twake-mui'
import { useRef } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
const requestControllers = new Map<string, AbortController>();
const requestControllers = new Map<string, AbortController>()
export function TempCalendarsInput({
tempUsers,
setTempUsers,
handleToggleEventPreview,
handleToggleEventPreview
}: {
tempUsers: User[];
setTempUsers: (users: User[]) => void;
handleToggleEventPreview: () => void;
tempUsers: User[]
setTempUsers: (users: User[]) => void
handleToggleEventPreview: () => void
}) {
const dispatch = useAppDispatch();
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const { t } = useI18n();
const dispatch = useAppDispatch()
const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {}
const { t } = useI18n()
const prevUsersRef = useRef<User[]>([]);
const prevUsersRef = useRef<User[]>([])
const userColorsRef = useRef(
new Map<string, { light: string; dark: string }>()
);
)
const handleUserChange = async (_: React.SyntheticEvent, users: User[]) => {
setTempUsers(users);
const handleUserChange = (_: React.SyntheticEvent, users: User[]) => {
setTempUsers(users)
const prevUsers = prevUsersRef.current;
const prevUsers = prevUsersRef.current
const addedUsers = users.filter(
(u) => !prevUsers.some((p) => p.email === u.email)
);
u => !prevUsers.some(p => p.email === u.email)
)
const removedUsers = prevUsers.filter(
(p) => !users.some((u) => u.email === p.email)
);
p => !users.some(u => u.email === p.email)
)
prevUsersRef.current = users;
prevUsersRef.current = users
if (addedUsers.length > 0) {
dispatch(setView("calendar"));
dispatch(setView('calendar'))
for (const user of addedUsers) {
const controller = new AbortController();
requestControllers.set(user.email, controller);
const controller = new AbortController()
requestControllers.set(user.email, controller)
if (!userColorsRef.current.has(user.email)) {
const usedLights = Array.from(userColorsRef.current.values()).map(
(c) => c.light
);
const colorPair = generateDistinctColor(usedLights);
userColorsRef.current.set(user.email, colorPair);
c => c.light
)
const colorPair = generateDistinctColor(usedLights)
userColorsRef.current.set(user.email, colorPair)
}
user.color = userColorsRef.current.get(user.email)!;
dispatch(
getTempCalendarsListAsync(user, { signal: controller.signal })
);
user.color = userColorsRef.current.get(user.email) ?? defaultColors[0]
dispatch(getTempCalendarsListAsync(user, { signal: controller.signal }))
}
}
for (const user of removedUsers) {
const controller = requestControllers.get(user.email);
const controller = requestControllers.get(user.email)
if (controller) {
controller.abort();
requestControllers.delete(user.email);
controller.abort()
requestControllers.delete(user.email)
}
const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email);
calIds?.forEach((id) => dispatch(removeTempCal(id)));
userColorsRef.current.delete(user.email);
const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email)
calIds?.forEach(id => dispatch(removeTempCal(id)))
userColorsRef.current.delete(user.email)
}
};
}
return (
<PeopleSearch
objectTypes={["user", "resource"]}
objectTypes={['user', 'resource']}
selectedUsers={tempUsers}
onChange={handleUserChange}
onToggleEventPreview={handleToggleEventPreview}
placeholder={t("peopleSearch.availabilityPlaceholder")}
inputSlot={(params) => (
placeholder={t('peopleSearch.availabilityPlaceholder')}
inputSlot={params => (
<TextField
{...params}
size="small"
sx={
tempUsers.length > 0
? {
"& .MuiOutlinedInput-root": {
flexDirection: "column",
alignItems: "start",
"& .MuiInputBase-input": {
width: "100%",
},
},
'& .MuiOutlinedInput-root': {
flexDirection: 'column',
alignItems: 'start',
'& .MuiInputBase-input': {
width: '100%'
}
}
}
: undefined
}
/>
)}
/>
);
)
}
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
const map = new Map<string, string[]>();
const map = new Map<string, string[]>()
for (const [id, cal] of Object.entries(calRecord)) {
cal.owner?.emails?.forEach((email) => {
const existing = map.get(email);
cal.owner?.emails?.forEach(email => {
const existing = map.get(email)
if (existing) {
existing.push(id);
existing.push(id)
} else {
map.set(email, [id]);
map.set(email, [id])
}
});
})
}
return map;
return map
}
function shiftLightness(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const clamp = (v: number) => Math.max(0, Math.min(255, v));
const clamp = (v: number) => Math.max(0, Math.min(255, v))
const toHex = (v: number) =>
clamp(v + amount)
.toString(16)
.padStart(2, "0");
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function generateDistinctColor(usedLights: string[]): {
light: string;
dark: string;
light: string
dark: string
} {
for (const color of defaultColors) {
if (!usedLights.includes(color.light)) return color;
if (!usedLights.includes(color.light)) return color
}
const cycle = usedLights.length % defaultColors.length;
const round = Math.floor(usedLights.length / defaultColors.length);
const base = defaultColors[cycle];
const cycle = usedLights.length % defaultColors.length
const round = Math.floor(usedLights.length / defaultColors.length)
const base = defaultColors[cycle]
return {
light: shiftLightness(base.light, round * 12),
dark: shiftLightness(base.dark, -(round * 10)),
};
dark: shiftLightness(base.dark, -(round * 10))
}
}
+40 -40
View File
@@ -1,45 +1,45 @@
import {
browserDefaultTimeZone,
getTimezoneOffset,
resolveTimezone,
} from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import { Button, Popover } from "@linagora/twake-mui";
import { MouseEvent, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
resolveTimezone
} from '@/utils/timezone'
import { TIMEZONES } from '@/utils/timezone-data'
import { Button, Popover } from '@linagora/twake-mui'
import { MouseEvent, useMemo, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
interface TimezoneSelectProps {
value: string;
onChange: (value: string) => void;
referenceDate: Date;
value: string
onChange: (value: string) => void
referenceDate: Date
}
export function TimezoneSelector({
value,
onChange,
referenceDate,
referenceDate
}: TimezoneSelectProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timezoneList = useTimeZoneList();
const timezoneList = useTimeZoneList()
const effectiveTimezone = value
? resolveTimezone(value)
: timezoneList.browserTz;
const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate);
: timezoneList.browserTz
const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate)
const handleOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null);
};
setAnchorEl(null)
}
const open = Boolean(anchorEl);
const { t } = useI18n();
const open = Boolean(anchorEl)
const { t } = useI18n()
return (
<>
<Button
@@ -47,14 +47,14 @@ export function TimezoneSelector({
size="small"
onClick={handleOpen}
sx={{
textTransform: "none",
minWidth: "auto",
padding: "2px 4px",
textTransform: 'none',
minWidth: 'auto',
padding: '2px 4px',
margin: 0,
lineHeight: 1.2,
lineHeight: 1.2
}}
>
{selectedOffset || t("common.select_timezone")}
{selectedOffset || t('common.select_timezone')}
</Button>
<Popover
@@ -62,22 +62,22 @@ export function TimezoneSelector({
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: "hidden", p: 0 },
sx: { width: 280, maxHeight: 400, overflow: 'hidden', p: 0 }
},
transition: {
onEntered: () => {
inputRef.current?.focus();
},
},
inputRef.current?.focus()
}
}
}}
>
<TimezoneAutocomplete
@@ -98,14 +98,14 @@ export function TimezoneSelector({
/>
</Popover>
</>
);
)
}
export function useTimeZoneList() {
return useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(browserDefaultTimeZone);
const zones = Object.keys(TIMEZONES.zones).sort()
const browserTz = resolveTimezone(browserDefaultTimeZone)
return { zones, browserTz, getTimezoneOffset };
}, []);
return { zones, browserTz, getTimezoneOffset }
}, [])
}
+114 -114
View File
@@ -1,47 +1,47 @@
import { AppDispatch } from "@/app/store";
import { User } from "@/components/Attendees/PeopleSearch";
import { formatLocalDateTime } from "@/components/Event/utils/dateTimeFormatters";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { User } from '@/components/Attendees/PeopleSearch'
import { formatLocalDateTime } from '@/components/Event/utils/dateTimeFormatters'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
getEventAsync,
putEventAsync,
updateEventInstanceAsync,
updateSeriesAsync,
} from "@/features/Calendars/services";
import { getEvent } from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange";
updateSeriesAsync
} from '@/features/Calendars/services'
import { getEvent } from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange'
import {
AttendeeOptions,
createAttendee,
} from "@/features/User/models/attendee.mapper";
import { getDeltaInMilliseconds } from "@/utils/dateUtils";
createAttendee
} from '@/features/User/models/attendee.mapper'
import { getDeltaInMilliseconds } from '@/utils/dateUtils'
import {
CalendarApi,
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import { EventResizeDoneArg } from "@fullcalendar/interaction";
EventDropArg
} from '@fullcalendar/core'
import { EventResizeDoneArg } from '@fullcalendar/interaction'
export interface EventHandlersProps {
setSelectedRange: (range: DateSelectArg | null) => void;
setAnchorEl: (el: HTMLElement | null) => void;
calendarRef: React.RefObject<CalendarApi | null>;
dispatch: AppDispatch;
setOpenEventDisplay: (open: boolean) => void;
setEventDisplayedId: (id: string) => void;
setEventDisplayedCalId: (id: string) => void;
setEventDisplayedTemp: (temp: boolean) => void;
calendars: Record<string, Calendar>;
setSelectedEvent: (event: CalendarEvent) => void;
setSelectedRange: (range: DateSelectArg | null) => void
setAnchorEl: (el: HTMLElement | null) => void
calendarRef: React.RefObject<CalendarApi | null>
dispatch: AppDispatch
setOpenEventDisplay: (open: boolean) => void
setEventDisplayedId: (id: string) => void
setEventDisplayedCalId: (id: string) => void
setEventDisplayedTemp: (temp: boolean) => void
calendars: Record<string, Calendar>
setSelectedEvent: (event: CalendarEvent) => void
setAfterChoiceFunc: (
func: ((type: "solo" | "all" | undefined) => void) | undefined
) => void;
setOpenEditModePopup: (open: string) => void;
tempUsers: User[];
setTempEvent: (event: CalendarEvent) => void;
timezone: string;
func: ((type: 'solo' | 'all' | undefined) => void) | undefined
) => void
setOpenEditModePopup: (open: string) => void
tempUsers: User[]
setTempEvent: (event: CalendarEvent) => void
timezone: string
}
export const createEventHandlers = (props: EventHandlersProps) => {
@@ -60,55 +60,55 @@ export const createEventHandlers = (props: EventHandlersProps) => {
setOpenEditModePopup,
tempUsers,
setTempEvent,
timezone,
} = props;
timezone
} = props
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo);
setSelectedRange(selectInfo)
if (tempUsers) {
const newEvent: CalendarEvent = {
start: selectInfo?.start
? formatLocalDateTime(selectInfo?.start, timezone)
: "",
: '',
end: selectInfo?.end
? formatLocalDateTime(selectInfo?.end, timezone)
: "",
attendee: tempUsers.map((user) => {
: '',
attendee: tempUsers.map(user => {
const attendeeOption: AttendeeOptions = {
cal_address: user.email,
cn: user.displayName,
rsvp: "TRUE",
};
if (user.objectType === "resource") {
attendeeOption.cutype = "RESOURCE";
rsvp: 'TRUE'
}
return createAttendee(attendeeOption);
}),
} as CalendarEvent;
setTempEvent(newEvent);
if (user.objectType === 'resource') {
attendeeOption.cutype = 'RESOURCE'
}
return createAttendee(attendeeOption)
})
} as CalendarEvent
setTempEvent(newEvent)
}
setAnchorEl(document.body);
};
setAnchorEl(document.body)
}
const handleClosePopover = () => {
calendarRef.current?.unselect();
setAnchorEl(null);
setSelectedRange(null);
};
calendarRef.current?.unselect()
setAnchorEl(null)
setSelectedRange(null)
}
const handleCloseEventDisplay = () => {
setOpenEventDisplay(false);
};
setOpenEventDisplay(false)
}
const handleEventClick = (info: EventClickArg) => {
info.jsEvent.preventDefault();
info.jsEvent.preventDefault()
if (info.event.url) {
window.open(info.event.url);
window.open(info.event.url)
} else {
setOpenEventDisplay(true);
setOpenEventDisplay(true)
if (
calendars[info.event.extendedProps.calId] &&
calendars[info.event.extendedProps.calId].events[
@@ -121,57 +121,57 @@ export const createEventHandlers = (props: EventHandlersProps) => {
info.event.extendedProps.uid
]
)
);
)
}
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
setEventDisplayedTemp(info.event._def.extendedProps.temp);
setEventDisplayedId(info.event.extendedProps.uid)
setEventDisplayedCalId(info.event.extendedProps.calId)
setEventDisplayedTemp(info.event._def.extendedProps.temp)
}
};
}
const handleEventAllow = () => {
return true;
};
return true
}
const handleEventDrop = async (arg: EventDropArg) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
return
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const calendar = calendars[arg.event._def.extendedProps.calId];
]
const calendar = calendars[arg.event._def.extendedProps.calId]
const isRecurring = event.uid.includes("/");
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const isRecurring = event.uid.includes('/')
const totalDeltaMs = getDeltaInMilliseconds(arg.delta)
const originalStart = new Date(event.start);
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs);
const originalStart = new Date(event.start)
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs)
const originalEnd = new Date(event.end ?? '')
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs)
const newEvent = updateAttendeesAfterTimeChange(
{
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
sequence: (event.sequence ?? 1) + 1
} as CalendarEvent,
true
);
)
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setSelectedEvent(event)
setOpenEditModePopup('edit')
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
() => async (typeOfAction: 'solo' | 'all' | undefined) => {
if (typeOfAction === 'solo') {
await dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
)
} else if (typeOfAction === 'all') {
const master = await getEvent(newEvent, true)
await dispatch(
updateSeriesAsync({
cal: calendar,
@@ -179,61 +179,61 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
sequence: (master.sequence ?? 1) + 1
}
})
);
)
}
}
);
)
} else {
await dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
)
}
};
}
const handleEventResize = async (arg: EventResizeDoneArg) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
return
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const calendar = calendars[arg.event._def.extendedProps.calId];
]
const calendar = calendars[arg.event._def.extendedProps.calId]
const isRecurring = event.uid.includes("/");
const isRecurring = event.uid.includes('/')
const originalStart = new Date(event.start);
const originalStart = new Date(event.start)
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
);
const originalEnd = new Date(event.end ?? "");
)
const originalEnd = new Date(event.end ?? '')
const computedNewEnd = new Date(
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
);
)
const newEvent = updateAttendeesAfterTimeChange(
{
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
sequence: (event.sequence ?? 1) + 1
} as CalendarEvent,
true
);
)
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setSelectedEvent(event)
setOpenEditModePopup('edit')
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
() => async (typeOfAction: 'solo' | 'all' | undefined) => {
if (typeOfAction === 'solo') {
await dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
)
} else if (typeOfAction === 'all') {
const master = await getEvent(newEvent, true)
await dispatch(
updateSeriesAsync({
@@ -242,19 +242,19 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
sequence: (master.sequence ?? 1) + 1
}
})
);
)
}
}
);
)
} else {
await dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
)
}
};
}
return {
handleDateSelect,
@@ -263,6 +263,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
handleEventClick,
handleEventAllow,
handleEventDrop,
handleEventResize,
};
};
handleEventResize
}
}
@@ -1,109 +1,108 @@
interface CalendarHTMLElement extends HTMLElement {
__calendarMouseMoveHandler?: (e: MouseEvent) => void;
__calendarMouseLeaveHandler?: () => void;
__calendarMouseMoveHandler?: (e: MouseEvent) => void
__calendarMouseLeaveHandler?: () => void
}
export interface MouseHandlersProps {
calendarEl: CalendarHTMLElement;
calendarEl: CalendarHTMLElement
}
export const createMouseHandlers = (props: MouseHandlersProps) => {
const { calendarEl } = props;
const { calendarEl } = props
const handleMouseMove = (e: MouseEvent) => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
if (!timegridEl) return;
const timegridEl = calendarEl.querySelector('.fc-timegrid-body')
if (!timegridEl) return
const allDayTable = calendarEl.querySelector(".fc-scrollgrid-sync-table");
const allDayTable = calendarEl.querySelector('.fc-scrollgrid-sync-table')
if (allDayTable) {
const allDayRect = allDayTable.getBoundingClientRect();
const allDayRect = allDayTable.getBoundingClientRect()
if (e.clientY >= allDayRect.top && e.clientY <= allDayRect.bottom) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
return
}
}
const target = e.target as HTMLElement;
if (target && target.closest(".fc-timegrid-slot-label")) {
const target = e.target as HTMLElement
if (target && target.closest('.fc-timegrid-slot-label')) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
return
}
const dayColumns = timegridEl.querySelectorAll(".fc-timegrid-col");
if (dayColumns.length === 0) return;
const dayColumns = timegridEl.querySelectorAll('.fc-timegrid-col')
if (dayColumns.length === 0) return
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
let targetColumn: Element | null = null;
let targetColumn: Element | null = null
for (const column of dayColumns) {
const rect = column.getBoundingClientRect();
const rect = column.getBoundingClientRect()
if (e.clientX >= rect.left && e.clientX <= rect.right) {
targetColumn = column;
break;
targetColumn = column
break
}
}
if (targetColumn) {
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const slotHeight = rect.height / 48;
const slotIndex = Math.floor(relativeY / slotHeight);
const rect = targetColumn.getBoundingClientRect()
const relativeY = e.clientY - rect.top
const slotHeight = rect.height / 48
const slotIndex = Math.floor(relativeY / slotHeight)
if (relativeY >= 0 && relativeY <= rect.height) {
const highlight = document.createElement("div");
highlight.className = "hour-highlight";
highlight.style.top = `${slotIndex * slotHeight}px`;
highlight.style.height = `${slotHeight}px`;
(targetColumn as HTMLElement).style.position = "relative";
targetColumn.appendChild(highlight);
const highlight = document.createElement('div')
highlight.className = 'hour-highlight'
highlight.style.top = `${slotIndex * slotHeight}px`
highlight.style.height = `${slotHeight}px`
;(targetColumn as HTMLElement).style.position = 'relative'
targetColumn.appendChild(highlight)
}
}
};
}
const handleMouseLeave = () => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
const timegridEl = calendarEl.querySelector('.fc-timegrid-body')
if (timegridEl) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
}
};
}
const addMouseEventListeners = () => {
calendarEl.addEventListener("mousemove", handleMouseMove);
calendarEl.addEventListener("mouseleave", handleMouseLeave);
calendarEl.addEventListener('mousemove', handleMouseMove)
calendarEl.addEventListener('mouseleave', handleMouseLeave)
calendarEl.__calendarMouseMoveHandler = handleMouseMove;
calendarEl.__calendarMouseLeaveHandler = handleMouseLeave;
};
calendarEl.__calendarMouseMoveHandler = handleMouseMove
calendarEl.__calendarMouseLeaveHandler = handleMouseLeave
}
const removeMouseEventListeners = () => {
if (calendarEl.__calendarMouseMoveHandler) {
calendarEl.removeEventListener(
"mousemove",
'mousemove',
calendarEl.__calendarMouseMoveHandler
);
delete calendarEl.__calendarMouseMoveHandler;
)
delete calendarEl.__calendarMouseMoveHandler
}
if (calendarEl.__calendarMouseLeaveHandler) {
calendarEl.removeEventListener(
"mouseleave",
'mouseleave',
calendarEl.__calendarMouseLeaveHandler
);
delete calendarEl.__calendarMouseLeaveHandler;
)
delete calendarEl.__calendarMouseLeaveHandler
}
};
}
return {
handleMouseMove,
handleMouseLeave,
addMouseEventListeners,
removeMouseEventListeners,
};
};
removeMouseEventListeners
}
}
@@ -1,26 +1,26 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { EventChip } from "@/components/Event/EventChip/EventChip";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { EventChip } from '@/components/Event/EventChip/EventChip'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { userAttendee } from '@/features/User/models/attendee'
import {
CalendarApi,
DayHeaderMountArg,
EventContentArg,
EventMountArg,
NowIndicatorContentArg,
ViewMountArg,
} from "@fullcalendar/core";
import React from "react";
import { createMouseHandlers } from "./mouseHandlers";
ViewMountArg
} from '@fullcalendar/core'
import React from 'react'
import { createMouseHandlers } from './mouseHandlers'
export interface ViewHandlersProps {
calendarRef: React.RefObject<CalendarApi | null>;
setSelectedDate: (date: Date) => void;
setSelectedMiniDate: (date: Date) => void;
onViewChange?: (view: string) => void;
calendars: Record<string, Calendar>;
tempcalendars: Record<string, Calendar>;
errorHandler: EventErrorHandler;
calendarRef: React.RefObject<CalendarApi | null>
setSelectedDate: (date: Date) => void
setSelectedMiniDate: (date: Date) => void
onViewChange?: (view: string) => void
calendars: Record<string, Calendar>
tempcalendars: Record<string, Calendar>
errorHandler: EventErrorHandler
}
export const createViewHandlers = (props: ViewHandlersProps) => {
@@ -31,126 +31,126 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
onViewChange,
calendars,
tempcalendars,
errorHandler,
} = props;
errorHandler
} = props
const handleNowIndicatorContent = (arg: NowIndicatorContentArg) => {
if (arg.isAxis) {
return React.createElement(
"div",
{ style: { display: "flex", alignItems: "center" } },
'div',
{ style: { display: 'flex', alignItems: 'center' } },
React.createElement(
"div",
{ className: "now-time-label" },
'div',
{ className: 'now-time-label' },
new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: arg.view.dateEnv.timeZone,
timeZone: arg.view.dateEnv.timeZone
})
)
);
)
}
};
}
const handleDayHeaderDidMount = (arg: DayHeaderMountArg) => {
if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el;
if (arg.view.type === 'timeGridWeek') {
const headerEl = arg.el
const handleDayHeaderClick = () => {
calendarRef.current?.changeView("timeGridDay", arg.date);
setSelectedDate(new Date(arg.date));
setSelectedMiniDate(new Date(arg.date));
calendarRef.current?.changeView('timeGridDay', arg.date)
setSelectedDate(new Date(arg.date))
setSelectedMiniDate(new Date(arg.date))
if (onViewChange) {
onViewChange("timeGridDay");
onViewChange('timeGridDay')
}
};
}
headerEl.addEventListener("click", handleDayHeaderClick);
headerEl.__dayHeaderClickHandler = handleDayHeaderClick;
headerEl.addEventListener('click', handleDayHeaderClick)
headerEl.__dayHeaderClickHandler = handleDayHeaderClick
}
};
}
const handleDayHeaderWillUnmount = (arg: DayHeaderMountArg) => {
const headerEl = arg.el;
const headerEl = arg.el
if (headerEl.__dayHeaderClickHandler) {
headerEl.removeEventListener("click", headerEl.__dayHeaderClickHandler);
delete headerEl.__dayHeaderClickHandler;
headerEl.removeEventListener('click', headerEl.__dayHeaderClickHandler)
delete headerEl.__dayHeaderClickHandler
}
};
}
const handleViewDidMount = (arg: ViewMountArg) => {
if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") {
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (arg.view.type === 'timeGridWeek' || arg.view.type === 'timeGridDay') {
const calendarEl = document.querySelector('.fc') as HTMLElement
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.addMouseEventListeners();
const mouseHandlers = createMouseHandlers({ calendarEl })
mouseHandlers.addMouseEventListeners()
}
}
};
}
const handleViewWillUnmount = (arg: ViewMountArg) => {
if (arg.el.__timeInterval) {
clearInterval(arg.el.__timeInterval);
delete arg.el.__timeInterval;
clearInterval(arg.el.__timeInterval)
delete arg.el.__timeInterval
}
if (arg.el.__timeObserver) {
arg.el.__timeObserver.disconnect();
delete arg.el.__timeObserver;
arg.el.__timeObserver.disconnect()
delete arg.el.__timeObserver
}
const calendarEl = document.querySelector(".fc") as HTMLElement;
const calendarEl = document.querySelector('.fc') as HTMLElement
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.removeMouseEventListeners();
const mouseHandlers = createMouseHandlers({ calendarEl })
mouseHandlers.removeMouseEventListeners()
}
};
}
const handleEventContent = (arg: EventContentArg) => {
return React.createElement(EventChip, {
arg,
calendars,
tempcalendars,
errorHandler,
});
};
errorHandler
})
}
const handleEventDidMount = (arg: EventMountArg) => {
const attendees = arg.event._def.extendedProps.attendee || [];
if (!calendars[arg.event._def.extendedProps.calId]) return;
const attendees = arg.event._def.extendedProps.attendee || []
if (!calendars[arg.event._def.extendedProps.calId]) return
const ownerEmails = new Set(
calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(
(email) => email.toLowerCase()
calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(email =>
email.toLowerCase()
)
);
)
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
)
if (!showSpecialDisplay[0]) return;
if (!showSpecialDisplay[0]) return
arg.el.classList.remove(
"declined-event",
"tentative-event",
"needs-action-event"
);
'declined-event',
'tentative-event',
'needs-action-event'
)
switch (showSpecialDisplay[0].partstat) {
case "DECLINED":
arg.el.classList.add("declined-event");
break;
case "TENTATIVE":
arg.el.classList.add("tentative-event");
break;
case "NEEDS-ACTION":
arg.el.classList.add("needs-action-event");
break;
case 'DECLINED':
arg.el.classList.add('declined-event')
break
case 'TENTATIVE':
arg.el.classList.add('tentative-event')
break
case 'NEEDS-ACTION':
arg.el.classList.add('needs-action-event')
break
default:
break;
break
}
};
}
return {
handleNowIndicatorContent,
@@ -159,6 +159,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
handleViewDidMount,
handleViewWillUnmount,
handleEventContent,
handleEventDidMount,
};
};
handleEventDidMount
}
}
@@ -1,12 +1,12 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback } from "react";
import { useCallback } from 'react'
import {
createEventHandlers,
EventHandlersProps,
} from "../handlers/eventHandlers";
EventHandlersProps
} from '../handlers/eventHandlers'
export const useCalendarEventHandlers = (props: EventHandlersProps) => {
const eventHandlers = createEventHandlers(props);
const eventHandlers = createEventHandlers(props)
return {
handleDateSelect: useCallback(eventHandlers.handleDateSelect, [
@@ -14,13 +14,13 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setAnchorEl,
props.setTempEvent,
props.tempUsers,
props.timezone,
props.timezone
]),
handleClosePopover: useCallback(eventHandlers.handleClosePopover, [
props.calendarRef,
props.setAnchorEl,
props.setSelectedRange,
props.dispatch,
props.dispatch
]),
handleCloseEventDisplay: useCallback(
eventHandlers.handleCloseEventDisplay,
@@ -32,7 +32,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setEventDisplayedCalId,
props.setEventDisplayedTemp,
props.calendars,
props.dispatch,
props.dispatch
]),
handleEventAllow: useCallback(eventHandlers.handleEventAllow, []),
handleEventDrop: useCallback(eventHandlers.handleEventDrop, [
@@ -40,11 +40,11 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.dispatch,
props.setSelectedEvent,
props.setOpenEditModePopup,
props.setAfterChoiceFunc,
props.setAfterChoiceFunc
]),
handleEventResize: useCallback(eventHandlers.handleEventResize, [
props.calendars,
props.dispatch,
]),
};
};
props.dispatch
])
}
}
@@ -1,19 +1,16 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback } from "react";
import {
createViewHandlers,
ViewHandlersProps,
} from "../handlers/viewHandlers";
import { useCallback } from 'react'
import { createViewHandlers, ViewHandlersProps } from '../handlers/viewHandlers'
export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
const viewHandlers = createViewHandlers(props);
const viewHandlers = createViewHandlers(props)
return {
handleDayHeaderDidMount: useCallback(viewHandlers.handleDayHeaderDidMount, [
props.calendarRef,
props.setSelectedDate,
props.setSelectedMiniDate,
props.onViewChange,
props.onViewChange
]),
handleDayHeaderWillUnmount: useCallback(
viewHandlers.handleDayHeaderWillUnmount,
@@ -23,14 +20,14 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
handleViewWillUnmount: useCallback(viewHandlers.handleViewWillUnmount, []),
handleEventContent: useCallback(viewHandlers.handleEventContent, [
props.calendars,
props.tempcalendars,
props.tempcalendars
]),
handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [
props.calendars,
props.calendars
]),
handleNowIndicatorContent: useCallback(
viewHandlers.handleNowIndicatorContent,
[]
),
};
};
)
}
}
+136 -136
View File
@@ -1,100 +1,100 @@
import { AppDispatch } from "@/app/store";
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
import { getCalendarDetailAsync } from "@/features/Calendars/services";
import { AclEntry } from "@/features/Calendars/types/CalendarData";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
import { convertEventDateTimeToISO } from "@/utils/timezone";
import { EventInput, SlotLabelContentArg } from "@fullcalendar/core";
import moment from "moment-timezone";
import { useI18n } from "twake-i18n";
import { AppDispatch } from '@/app/store'
import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes'
import { getCalendarDetailAsync } from '@/features/Calendars/services'
import { AclEntry } from '@/features/Calendars/types/CalendarData'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { getEffectiveEmail } from '@/utils/getEffectiveEmail'
import { isEventOrganiser } from '@/utils/isEventOrganiser'
import { convertEventDateTimeToISO } from '@/utils/timezone'
import { EventInput, SlotLabelContentArg } from '@fullcalendar/core'
import moment from 'moment-timezone'
import { useI18n } from 'twake-i18n'
export const updateSlotLabelVisibility = (
currentTime: Date,
slotLabel: SlotLabelContentArg,
timezone: string
) => {
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay()
if (!isCurrentWeekOrDay) {
return "fc-timegrid-slot-label";
return 'fc-timegrid-slot-label'
}
const current = moment.tz(currentTime, timezone);
const currentMinutes = current.hours() * 60 + current.minutes();
const timeText = slotLabel?.text?.trim();
const current = moment.tz(currentTime, timezone)
const currentMinutes = current.hours() * 60 + current.minutes()
const timeText = slotLabel?.text?.trim()
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
const [hours, minutes] = timeText.split(":").map(Number);
const labelMinutes = hours * 60 + minutes;
const [hours, minutes] = timeText.split(':').map(Number)
const labelMinutes = hours * 60 + minutes
let timeDiff = Math.abs(currentMinutes - labelMinutes);
let timeDiff = Math.abs(currentMinutes - labelMinutes)
if (timeDiff > 12 * 60) {
timeDiff = 24 * 60 - timeDiff;
timeDiff = 24 * 60 - timeDiff
}
if (timeDiff <= 15) {
return "timegrid-slot-label-hidden";
return 'timegrid-slot-label-hidden'
}
}
return "fc-timegrid-slot-label";
};
return 'fc-timegrid-slot-label'
}
export const checkIfCurrentWeekOrDay = (): boolean => {
const todayColumn = document.querySelector(".fc-day-today");
const todayColumn = document.querySelector('.fc-day-today')
if (!todayColumn) {
return false;
return false
}
const nowIndicator = document.querySelector(
".fc-timegrid-now-indicator-arrow"
);
return !!nowIndicator;
};
'.fc-timegrid-now-indicator-arrow'
)
return !!nowIndicator
}
export function formatEventChipTitle(
e: CalendarEvent,
t: (key: string) => string
) {
if (!e.title) {
return t("event.untitled");
return t('event.untitled')
}
return e.title === "Busy" && e.class === "PRIVATE"
? t("event.form.busy")
: e.title;
return e.title === 'Busy' && e.class === 'PRIVATE'
? t('event.form.busy')
: e.title
}
type ConvertedEvent = CalendarEvent & {
colors: Record<string, string> | undefined;
editable: boolean;
priority: number;
};
colors: Record<string, string> | undefined
editable: boolean
priority: number
}
function applyTimezoneToEvent(
event: CalendarEvent,
convertedEvent: ConvertedEvent
): void {
const eventTimezone = event.timezone || "Etc/UTC";
const isAllDay = event.allday ?? false;
const eventTimezone = event.timezone || 'Etc/UTC'
const isAllDay = event.allday ?? false
if (!isAllDay && event.start) {
const startISO = convertEventDateTimeToISO(event.start, eventTimezone, {
isAllDay,
});
if (startISO) convertedEvent.start = startISO;
isAllDay
})
if (startISO) convertedEvent.start = startISO
}
if (!isAllDay && event.end && eventTimezone) {
const endISO = convertEventDateTimeToISO(event.end, eventTimezone, {
isAllDay,
});
if (endISO) convertedEvent.end = endISO;
isAllDay
})
if (endISO) convertedEvent.end = endISO
}
}
@@ -109,28 +109,28 @@ function buildConvertedEvent(
const isWriteDelegated =
(calendar?.delegated &&
calendar.access?.write &&
(!event.class || event.class === "PUBLIC")) ??
false;
(!event.class || event.class === 'PUBLIC')) ??
false
const effectiveEmail = getEffectiveEmail(
calendar,
isWriteDelegated,
userAddress
);
const isOrganiser = isEventOrganiser(event, effectiveEmail);
const isPersonalEvent = extractEventBaseUuid(event.calId) === userId;
)
const isOrganiser = isEventOrganiser(event, effectiveEmail)
const isPersonalEvent = extractEventBaseUuid(event.calId) === userId
const convertedEvent: ConvertedEvent = {
...event,
title: formatEventChipTitle(event, t),
colors: event.color,
editable: (isPersonalEvent || isWriteDelegated) && isOrganiser && !pending,
priority: isPersonalEvent ? 1 : 0,
};
priority: isPersonalEvent ? 1 : 0
}
applyTimezoneToEvent(event, convertedEvent);
applyTimezoneToEvent(event, convertedEvent)
return convertedEvent;
return convertedEvent
}
export const eventToFullCalendarFormat = (
@@ -142,10 +142,10 @@ export const eventToFullCalendarFormat = (
calendars: Record<string, Calendar>
): EventInput[] => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useI18n();
const { t } = useI18n()
return filteredEvents
.concat(filteredTempEvents.map((event) => ({ ...event, temp: true })))
.map((event) =>
.concat(filteredTempEvents.map(event => ({ ...event, temp: true })))
.map(event =>
buildConvertedEvent(
event,
calendars[event.calId],
@@ -154,8 +154,8 @@ export const eventToFullCalendarFormat = (
pending,
t
)
) as EventInput[];
};
) as EventInput[]
}
export const extractEvents = (
selectedCalendars: string[],
@@ -163,29 +163,29 @@ export const extractEvents = (
userAddress?: string,
hideDeclinedEvents?: boolean | null
) => {
const allEvents: CalendarEvent[] = [];
const allEvents: CalendarEvent[] = []
selectedCalendars.forEach((id) => {
const calendar = calendars[id];
selectedCalendars.forEach(id => {
const calendar = calendars[id]
if (calendar?.events) {
allEvents.push(...Object.values(calendar.events));
allEvents.push(...Object.values(calendar.events))
}
});
})
return allEvents
.filter((event) => event.status !== "CANCELLED")
.filter(event => event.status !== 'CANCELLED')
.filter(
(event) =>
event =>
!(
hideDeclinedEvents &&
event.attendee?.some(
(a) =>
a =>
calendars[event.calId].owner.emails.includes(a.cal_address) &&
a.partstat === "DECLINED"
a.partstat === 'DECLINED'
)
)
);
};
)
}
export const updateCalsDetails = (
selectedCalendars: string[],
@@ -195,141 +195,141 @@ export const updateCalsDetails = (
previousRangeKey: string,
dispatch: AppDispatch,
calendarRange: { start: Date; end: Date },
calType?: "temp",
calType?: 'temp',
controllers?: Map<string, AbortController>
) => {
if (pending || !rangeKey) return;
if (pending || !rangeKey) return
const newCalendars = selectedCalendars.filter(
(id) => !previousSelectedCalendars.includes(id)
);
id => !previousSelectedCalendars.includes(id)
)
newCalendars.forEach((id) => {
newCalendars.forEach(id => {
if (controllers) {
const controller = new AbortController();
controllers.set(id, controller);
const controller = new AbortController()
controllers.set(id, controller)
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
signal: controller.signal,
signal: controller.signal
})
);
)
} else {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
});
})
if (rangeKey !== previousRangeKey) {
selectedCalendars?.forEach((id) => {
selectedCalendars?.forEach(id => {
if (id) {
if (controllers) {
const controller = new AbortController();
controllers.set(id, controller);
const controller = new AbortController()
controllers.set(id, controller)
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
signal: controller.signal,
signal: controller.signal
})
);
)
} else {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
}
});
})
}
};
}
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
let hasRead = false;
export function getCalendarVisibility(acl: AclEntry[]): 'private' | 'public' {
let hasRead = false
if (acl) {
for (const entry of acl) {
if (entry.principal !== "{DAV:}authenticated") continue;
if (entry.principal !== '{DAV:}authenticated') continue
if (entry.privilege === "{DAV:}read") {
hasRead = true;
break; // highest visibility, can stop
if (entry.privilege === '{DAV:}read') {
hasRead = true
break // highest visibility, can stop
}
}
}
if (hasRead) return "public";
return "private";
if (hasRead) return 'public'
return 'private'
}
export function getCalendarDelegationAccess(
acl: AclEntry[],
userId: string
): DelegationAccess {
const userPrincipal = `principals/users/${userId}`;
const userPrincipal = `principals/users/${userId}`
const access: DelegationAccess = {
freebusy: false,
read: false,
write: false,
"write-properties": false,
all: false,
};
for (const entry of acl ?? []) {
if (entry.principal !== userPrincipal) continue;
privilegeToAccess(entry.privilege, access);
'write-properties': false,
all: false
}
return access;
for (const entry of acl ?? []) {
if (entry.principal !== userPrincipal) continue
privilegeToAccess(entry.privilege, access)
}
return access
}
function privilegeToAccess(privilege: string, currentAccess: DelegationAccess) {
switch (privilege) {
case "{urn:ietf:params:xml:ns:caldav}read-free-busy":
currentAccess["freebusy"] = true;
break;
case "{DAV:}read":
currentAccess["read"] = true;
currentAccess["freebusy"] = true; // read implies read-free-busy
break;
case "{DAV:}write-properties":
currentAccess["write-properties"] = true;
break;
case "{DAV:}write":
currentAccess["write-properties"] = true; // write implies write-properties
currentAccess["write"] = true;
break;
case "{DAV:}all":
currentAccess["freebusy"] = true;
currentAccess["read"] = true;
currentAccess["write-properties"] = true;
currentAccess["write"] = true;
currentAccess["all"] = true;
break;
case '{urn:ietf:params:xml:ns:caldav}read-free-busy':
currentAccess['freebusy'] = true
break
case '{DAV:}read':
currentAccess['read'] = true
currentAccess['freebusy'] = true // read implies read-free-busy
break
case '{DAV:}write-properties':
currentAccess['write-properties'] = true
break
case '{DAV:}write':
currentAccess['write-properties'] = true // write implies write-properties
currentAccess['write'] = true
break
case '{DAV:}all':
currentAccess['freebusy'] = true
currentAccess['read'] = true
currentAccess['write-properties'] = true
currentAccess['write'] = true
currentAccess['all'] = true
break
default:
break;
break
}
}