diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index d75cad9b40..ad500663fd 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -624,12 +624,13 @@ export default class CalendarController { static async getIcsFeed(req: Request, res: Response, next: NextFunction) { try { const { token } = req.params as Record; - const { org, calendars } = req.query as Record; + const { org, calendars, events } = req.query as Record; const organizationId = org ?? ''; const calendarIds = calendars ? calendars.split(',').filter(Boolean) : []; + const eventIds = events ? events.split(',').filter(Boolean) : []; - const events = await CalendarService.getIcsFeedEvents(token, organizationId, calendarIds); - const icsContent = generateIcsFeed(events); + const event = await CalendarService.getIcsFeedEvents(token, organizationId, calendarIds, eventIds); + const icsContent = generateIcsFeed(event); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename="finishline.ics"'); diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index d625ca051a..8e07d2f609 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -2824,7 +2824,7 @@ export default class CalendarService { return token; } - static async getIcsFeedEvents(icsToken: string, organizationId: string, calendarIds: string[]) { + static async getIcsFeedEvents(icsToken: string, organizationId: string, calendarIds: string[], eventIds: string[] = []) { const user = await prisma.user.findUnique({ where: { icsToken }, include: { @@ -2836,6 +2836,19 @@ export default class CalendarService { if (!user) throw new NotFoundException('User', 'icsToken'); + // specific events case + if (eventIds.length > 0) { + const events = await prisma.event.findMany({ + where: { + dateDeleted: null, + eventId: { in: eventIds }, + eventType: { calendars: { some: { organizationId } } } + }, + ...getEventQueryArgs(organizationId) + }); + return events.map(eventTransformer); + } + const userTeamIds = [ ...user.teamsAsMember.map((t) => t.teamId), ...user.teamsAsLead.map((t) => t.teamId), @@ -2845,7 +2858,7 @@ export default class CalendarService { const calendarFilter = calendarIds.length > 0 ? [{ eventType: { calendars: { some: { calendarId: { in: calendarIds }, organizationId } } } }] - : []; + : [{ eventType: { calendars: { some: { organizationId } } } }]; const events = await prisma.event.findMany({ where: { @@ -2856,7 +2869,8 @@ export default class CalendarService { { requiredMembers: { some: { userId: user.userId } } }, { optionalMembers: { some: { userId: user.userId } } }, ...(userTeamIds.length > 0 ? [{ teams: { some: { teamId: { in: userTeamIds } } } }] : []), - ...calendarFilter + ...calendarFilter, + ...[] ] }, ...getEventQueryArgs(organizationId) diff --git a/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx index 9d2a12ee5c..cb5b7236e0 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx @@ -181,7 +181,6 @@ const CalendarDayCard: React.FC = ({ marginRight={0.5} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => { - setTooltipHovered(false); setTimeout(() => { if (!isLockedRef.current && !tooltipHoveredRef.current) { setIsHovered(false); @@ -489,7 +488,6 @@ const CalendarDayCard: React.FC = ({ setIsHovered(true)} onMouseLeave={() => { - setTooltipHovered(false); setTimeout(() => { if (!isLockedRef.current && !tooltipHoveredRef.current) { setIsHovered(false); diff --git a/src/frontend/src/pages/CalendarPage/CalendarWeekView.tsx b/src/frontend/src/pages/CalendarPage/CalendarWeekView.tsx index b0289549b7..0ac4628318 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarWeekView.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarWeekView.tsx @@ -500,11 +500,11 @@ const CalendarWeekView: React.FC = ({ const AllDayEventBlock = ({ event }: { event: EventInstance }) => { const [blockHovered, setBlockHovered] = useState(false); const [tooltipHovered, setTooltipHovered] = useState(false); - const tooltipHoveredRef = useRef(false); - tooltipHoveredRef.current = tooltipHovered; const isLocked = lockedTooltipEventId === event.eventId + event.scheduleSlotId; const isLockedRef = useRef(false); isLockedRef.current = isLocked; + const tooltipHoveredRef = useRef(false); + tooltipHoveredRef.current = tooltipHovered; const isOpen = isLocked || blockHovered || tooltipHovered; const baseColor = getEventColor(event); diff --git a/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx index 2694f2fc13..643bd3bbd2 100644 --- a/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx @@ -11,6 +11,7 @@ import { isHead, wbsPipe } from 'shared'; +import { apiUrls } from '../../utils/urls'; import { useCurrentUser } from '../../hooks/users.hooks'; import { Link as RouterLink } from 'react-router-dom'; import { routes } from '../../utils/routes'; @@ -24,6 +25,7 @@ import DoNotDisturbIcon from '@mui/icons-material/DoNotDisturb'; import ConstructionIcon from '@mui/icons-material/Construction'; import StorefrontIcon from '@mui/icons-material/Storefront'; import BusinessCenterIcon from '@mui/icons-material/BusinessCenter'; +import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import LinkIcon from '@mui/icons-material/Link'; import ArticleIcon from '@mui/icons-material/Article'; import DescriptionIcon from '@mui/icons-material/Description'; @@ -34,7 +36,13 @@ import DeleteIcon from '@mui/icons-material/Delete'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import NERSuccessButton from '../../components/NERSuccessButton'; import NERFailButton from '../../components/NERFailButton'; -import { useApproveEvent, useDeleteEvent, useDeleteScheduleSlot, useDenyEvent } from '../../hooks/calendar.hooks'; +import { + useApproveEvent, + useDeleteEvent, + useDeleteScheduleSlot, + useDenyEvent, + useGetIcsToken +} from '../../hooks/calendar.hooks'; import EditEventModal from './Components/EditEventModal'; import DeleteSeriesConfirmationModal from './Components/DeleteSeriesConfirmationModal'; import { useToast } from '../../hooks/toasts.hooks'; @@ -137,6 +145,15 @@ export const EventClickContent: React.FC = ({ const pendingReason = getPendingReason(event); + const { data: tokenData } = useGetIcsToken(); + + const handleExport = (event: EventInstance) => { + if (!tokenData) return; + const feedUrl = apiUrls.icsFeed(tokenData.icsToken, tokenData.organizationId, [], [event.eventId]); + navigator.clipboard.writeText(feedUrl); + toast.success('Copied calendar with event to clipboard!'); + }; + return ( = ({ - {!disable && canEditOrDelete && ( - + + { + stopClick(e); + handleExport(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { color: theme.palette.common.white, bgcolor: 'transparent' } + }} + > + + + + {!disable && canEditOrDelete && ( { @@ -203,7 +234,9 @@ export const EventClickContent: React.FC = ({ > + )} + {!disable && canEditOrDelete && ( { @@ -217,8 +250,8 @@ export const EventClickContent: React.FC = ({ > - - )} + )} + @@ -508,7 +541,6 @@ export const EventClickPopup: React.FC = ({ const [showEditModal, setShowEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showSeriesDeleteModal, setShowSeriesDeleteModal] = useState(false); - const { mutateAsync: deleteEvent } = useDeleteEvent(clickedEvent?.eventId ?? ''); const { mutateAsync: deleteScheduleSlot } = useDeleteScheduleSlot( clickedEvent?.eventId ?? '', diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index f8804b746a..ee39409179 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -491,9 +491,13 @@ const calendarScheduleEvent = (eventId: string) => `${calendar()}/event/${eventI const calendarIcsToken = () => `${calendar()}/ics/token`; // Generates ICS URL to be given to calendars for integration, not directly hit by FL frontend -const icsFeed = (token: string, organizationId: string, calendarIds: string[]) => { +const icsFeed = (token: string, organizationId: string, calendarIds: string[], eventIds: string[] = []) => { const base = `${API_URL}/ics/${token}?org=${organizationId}`; - return calendarIds.length > 0 ? `${base}&calendars=${calendarIds.join(',')}` : base; + let icsUrl = calendarIds.length > 0 ? `${base}&calendars=${calendarIds.join(',')}` : base; + if (eventIds.length > 0) { + icsUrl += `&events=${eventIds.join(',')}`; + } + return icsUrl; }; /**************** Attendance Endpoints ****************/