diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index e4496feab8..97e9ecc94f 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -47,6 +47,20 @@ def resolve_mentors_from_logins(logins: list[str]) -> set[Mentor]: return mentors +def _is_mentor_of_module(user, module) -> bool: + """Check if the given user is a mentor for the module. + + Runs a fallback check against github_user if the mentor hasn't linked + their nest_user profile yet. + """ + if Mentor.objects.filter(nest_user=user, modules=module).exists(): + return True + return ( + hasattr(user, "github_user") + and Mentor.objects.filter(github_user=user.github_user, modules=module).exists() + ) + + def _validate_module_dates(started_at, ended_at, program_started_at, program_ended_at) -> tuple: """Validate and normalize module start/end dates against program constraints.""" if started_at is None or ended_at is None: @@ -144,7 +158,7 @@ def assign_issue_to_user( if module is None: raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) - if not Mentor.objects.filter(nest_user=user, modules=module).exists(): + if not _is_mentor_of_module(user, module): raise PermissionDenied(NOT_MENTOR_ASSIGN_MSG) gh_user = GithubUser.objects.filter(login=user_login).first() @@ -183,7 +197,7 @@ def unassign_issue_from_user( if module is None: raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) - if not Mentor.objects.filter(nest_user=user, modules=module).exists(): + if not _is_mentor_of_module(user, module): raise PermissionDenied(NOT_MENTOR_UNASSIGN_MSG) gh_user = GithubUser.objects.filter(login=user_login).first() @@ -224,7 +238,7 @@ def set_task_deadline( if module is None: raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) - if not Mentor.objects.filter(nest_user=user, modules=module).exists(): + if not _is_mentor_of_module(user, module): raise PermissionDenied(NOT_MENTOR_SET_DEADLINE_MSG) issue = ( @@ -286,7 +300,7 @@ def clear_task_deadline( if module is None: raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) - if not Mentor.objects.filter(nest_user=user, modules=module).exists(): + if not _is_mentor_of_module(user, module): raise PermissionDenied(NOT_MENTOR_CLEAR_DEADLINE_MSG) issue = ( @@ -319,7 +333,14 @@ def clear_task_deadline( @strawberry.mutation(permission_classes=[IsAuthenticated]) @transaction.atomic def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode: - """Update an existing mentorship module.""" + """Update an existing mentorship module. + + User must either be: + - An admin of the program, or + - A mentor explicitly assigned to this module + + Admins and module mentors can edit any field and manage mentor assignments. + """ user = info.context.request.user try: @@ -332,7 +353,7 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e is_admin = module.program.admins.filter(nest_user=user).exists() - is_mentor = Mentor.objects.filter(nest_user=user, modules=module).exists() + is_mentor = _is_mentor_of_module(user, module) if not (is_admin or is_mentor): msg = "Only admins of the program or mentors of this module can edit modules." raise PermissionDenied(msg) @@ -391,3 +412,44 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> module.program.save(update_fields=["experience_levels"]) return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def delete_module( + self, + info: strawberry.Info, + program_key: str, + module_key: str, + ) -> str: + """Delete a mentorship module. User must be an admin of the program.""" + user = info.context.request.user + + try: + module = Module.objects.select_related("program").get( + key=module_key, program__key=program_key + ) + except Module.DoesNotExist as e: + raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e + + if not module.program.admins.filter(nest_user=user).exists(): + msg = "Only program admins can delete modules." + raise PermissionDenied(msg) + + program = module.program + module_name = module.name + + experience_level_to_remove = module.experience_level + if ( + experience_level_to_remove in program.experience_levels + and not Module.objects.filter( + program=program, experience_level=experience_level_to_remove + ) + .exclude(id=module.id) + .exists() + ): + program.experience_levels.remove(experience_level_to_remove) + program.save(update_fields=["experience_levels"]) + + module.delete() + + return f"Module '{module_name}' has been deleted successfully." diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index 1293412174..a8d4d59734 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -445,6 +445,7 @@ jest.mock('components/EntityActions', () => ({ moduleKey, status: _status, setStatus: _setStatus, + isAdmin, ...props }: { type: string @@ -452,9 +453,10 @@ jest.mock('components/EntityActions', () => ({ moduleKey?: string status?: string setStatus?: (status: string) => void + isAdmin?: boolean [key: string]: unknown }) => ( -
+
EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey}
), diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx index b06e30f85f..8cb46bf144 100644 --- a/frontend/__tests__/unit/components/EntityActions.test.tsx +++ b/frontend/__tests__/unit/components/EntityActions.test.tsx @@ -1,3 +1,5 @@ +import { useMutation } from '@apollo/client/react' +import { addToast } from '@heroui/toast' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { useRouter } from 'next/navigation' import { ProgramStatusEnum } from 'types/__generated__/graphql' @@ -9,12 +11,21 @@ jest.mock('next/navigation', () => ({ useRouter: jest.fn(), })) +jest.mock('@apollo/client/react', () => ({ + useMutation: jest.fn(), +})) + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + describe('EntityActions', () => { beforeEach(() => { jest.clearAllMocks() ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, }) + ;(useMutation as unknown as jest.Mock).mockReturnValue([jest.fn()]) }) describe('Program Actions - Create Module', () => { @@ -82,7 +93,14 @@ describe('EntityActions', () => { describe('Module Actions - View Issues', () => { it('navigates to view issues page when View Issues is clicked with moduleKey', () => { - render() + render( + + ) const button = screen.getByRole('button', { name: /Module actions menu/ }) fireEvent.click(button) @@ -95,7 +113,7 @@ describe('EntityActions', () => { }) it('does not navigate when moduleKey is missing for view issues action', () => { - render() + render() const button = screen.getByRole('button', { name: /Module actions menu/ }) fireEvent.click(button) @@ -106,7 +124,14 @@ describe('EntityActions', () => { }) it('closes dropdown after clicking View Issues', () => { - render() + render( + + ) const button = screen.getByRole('button', { name: /Module actions menu/ }) fireEvent.click(button) expect(button).toHaveAttribute('aria-expanded', 'true') @@ -738,4 +763,266 @@ describe('EntityActions', () => { expect(button).toHaveAttribute('aria-expanded', 'false') }) }) + + describe('Module Actions - Delete Module', () => { + let mockDeleteMutation: jest.Mock + + beforeEach(() => { + mockDeleteMutation = jest.fn() + ;(useMutation as unknown as jest.Mock).mockReturnValue([mockDeleteMutation]) + }) + + it('opens delete modal when Delete is clicked', () => { + render( + + ) + const button = screen.getByRole('button', { name: /Module actions menu/ }) + fireEvent.click(button) + + const deleteButton = screen.getByText('Delete') + fireEvent.click(deleteButton) + + expect(screen.getByText('Delete Module')).toBeInTheDocument() + expect(screen.getByText(/Are you sure you want to delete this module/)).toBeInTheDocument() + }) + + it('closes delete modal when Cancel is clicked', async () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + expect(screen.getByText('Delete Module')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) + + await waitFor(() => { + expect(screen.queryByText('Delete Module')).not.toBeInTheDocument() + }) + }) + + it('closes delete modal when Modal close button is clicked (onClose)', async () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + expect(screen.getByText('Delete Module')).toBeInTheDocument() + + const closeButton = screen.getByRole('button', { name: /Close/i }) + fireEvent.click(closeButton) + + await waitFor(() => { + expect(screen.queryByText('Delete Module')).not.toBeInTheDocument() + }) + }) + + it('does not do anything if moduleKey is missing on submit', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + const confirmButton = screen.getByRole('button', { name: 'Delete' }) + fireEvent.click(confirmButton) + + expect(mockDeleteMutation).not.toHaveBeenCalled() + }) + + it('handles successful module deletion', async () => { + mockDeleteMutation.mockResolvedValueOnce({ data: { deleteModule: true } }) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + const confirmButton = screen.getByRole('button', { name: 'Delete' }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockDeleteMutation).toHaveBeenCalledWith({ + variables: { programKey: 'test-program', moduleKey: 'test-module' }, + update: expect.any(Function), + }) + expect(addToast).toHaveBeenCalledWith({ + title: 'Success', + description: 'Module has been deleted successfully.', + color: 'success', + }) + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program') + }) + }) + + it('calls update cache function when delete is successful', async () => { + const mockCache = { + readQuery: jest.fn().mockReturnValue({ + getProgramModules: [{ key: 'test-module' }, { key: 'other-module' }], + }), + writeQuery: jest.fn(), + } + + mockDeleteMutation.mockImplementationOnce(({ update }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (update) update(mockCache as any) + return Promise.resolve({ data: { deleteModule: true } }) + }) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(mockCache.readQuery).toHaveBeenCalled() + expect(mockCache.writeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + getProgramModules: [{ key: 'other-module' }], + }, + }) + ) + }) + }) + + it('handles mutation structural failure', async () => { + mockDeleteMutation.mockResolvedValueOnce({ data: { deleteModule: false } }) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + color: 'danger', + description: 'Failed to delete module. Please try again.', + }) + ) + }) + }) + + it('handles Permission Denied error from server', async () => { + mockDeleteMutation.mockRejectedValueOnce( + new Error('Permission denied: You do not have permission') + ) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + color: 'danger', + description: + 'You do not have permission to delete this module. Only program admins can delete modules.', + }) + ) + }) + }) + + it('handles Unauthorized error from server', async () => { + mockDeleteMutation.mockRejectedValueOnce(new Error('Unauthorized request')) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + color: 'danger', + description: 'Unauthorized: You must be a program admin to delete modules.', + }) + ) + }) + }) + + it('handles a generic network error from server', async () => { + mockDeleteMutation.mockRejectedValueOnce(new Error('Network disconnected')) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /Module actions menu/ })) + fireEvent.click(screen.getByText('Delete')) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + color: 'danger', + description: 'Failed to delete module. Please try again.', + }) + ) + }) + }) + }) }) diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx index 405210b68e..e08a8ee16f 100644 --- a/frontend/__tests__/unit/pages/CreateModule.test.tsx +++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx @@ -359,5 +359,5 @@ describe('CreateModulePage', () => { }) ) }) - }) + }, 10000) }) diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx index 0af4eb86d8..adb2893b7c 100644 --- a/frontend/__tests__/unit/pages/EditModule.test.tsx +++ b/frontend/__tests__/unit/pages/EditModule.test.tsx @@ -150,7 +150,7 @@ describe('EditModulePage', () => { await waitFor(() => { expect(addToast).toHaveBeenCalledWith({ title: 'Access Denied', - description: 'Only program admins can edit modules.', + description: 'Only program admins and module mentors can edit this module.', color: 'danger', variant: 'solid', timeout: 4000, diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index cd29a0b6fa..d8b8217118 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -165,4 +165,17 @@ jest.mock('ics', () => { } }) +jest.mock('@apollo/client/react', () => { + const actual = jest.requireActual('@apollo/client/react') + const mockUseMutation = jest.fn(() => [ + jest.fn().mockResolvedValue({ data: {} }), + { data: null, loading: false, error: null, called: false }, + ]) + + return { + ...actual, + useMutation: mockUseMutation, + } +}) + expect.extend(toHaveNoViolations) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx index 8b4543b9f6..4767957ce4 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx @@ -5,7 +5,7 @@ import { addToast } from '@heroui/toast' import { useParams, useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import React, { useEffect, useState } from 'react' -import { ErrorDisplay, handleAppError } from 'app/global-error' +import { ErrorDisplay } from 'app/global-error' import { ExperienceLevelEnum } from 'types/__generated__/graphql' import type { UpdateModuleInput } from 'types/__generated__/graphql' import { UpdateModuleDocument } from 'types/__generated__/moduleMutations.generated' @@ -21,7 +21,10 @@ import ModuleForm from 'components/ModuleForm' const EditModulePage = () => { const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>() const router = useRouter() - const { data: sessionData, status: sessionStatus } = useSession() + const { data: sessionData, status: sessionStatus } = useSession() as { + data: ExtendedSession | null + status: string + } const [formData, setFormData] = useState(null) const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking') @@ -53,18 +56,22 @@ const EditModulePage = () => { return } - const currentUserLogin = (sessionData as ExtendedSession)?.user?.login + const currentUserLogin = sessionData?.user?.login const isAdmin = data.getProgram.admins?.some( (admin: { login: string }) => admin.login === currentUserLogin ) - if (isAdmin) { + const isMentor = data.getModule.mentors?.some( + (mentor: { login: string }) => mentor.login === currentUserLogin + ) + + if (isAdmin || isMentor) { setAccessStatus('allowed') } else { setAccessStatus('denied') addToast({ title: 'Access Denied', - description: 'Only program admins can edit modules.', + description: 'Only program admins and module mentors can edit this module.', color: 'danger', variant: 'solid', timeout: 4000, @@ -97,6 +104,11 @@ const EditModulePage = () => { if (!formData) return try { + const currentUserLogin = sessionData?.user?.login + const isAdmin = data?.getProgram?.admins?.some( + (admin: { login: string }) => admin.login === currentUserLogin + ) + const input: UpdateModuleInput = { description: formData.description, domains: parseCommaSeparated(formData.domains), @@ -104,7 +116,6 @@ const EditModulePage = () => { experienceLevel: formData.experienceLevel as ExperienceLevelEnum, key: moduleKey, labels: parseCommaSeparated(formData.labels), - mentorLogins: parseCommaSeparated(formData.mentorLogins), name: formData.name, programKey: programKey, projectId: formData.projectId, @@ -113,6 +124,10 @@ const EditModulePage = () => { tags: parseCommaSeparated(formData.tags), } + if (isAdmin) { + input.mentorLogins = parseCommaSeparated(formData.mentorLogins) + } + const result = await updateModule({ awaitRefetchQueries: true, refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }], @@ -129,7 +144,22 @@ const EditModulePage = () => { }) router.push(`/my/mentorship/programs/${programKey}/modules/${updatedModuleKey}`) } catch (err) { - handleAppError(err) + let errorMessage = 'Failed to update module. Please try again.' + + if (err instanceof Error) { + if (err.message.includes('Permission') || err.message.includes('not have permission')) { + errorMessage = + 'You do not have permission to edit this module. Only program admins and assigned mentors can edit modules.' + } + } + + addToast({ + title: 'Error', + description: errorMessage, + color: 'danger', + variant: 'solid', + timeout: 4000, + }) } } diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index 4ecd666e49..72e9ae4587 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -153,11 +153,22 @@ const DetailsCard = ({ /> )} {type === 'module' && - accessLevel === 'admin' && - programKey && - admins?.some((admin) => admin.login === session?.user?.login) && ( - - )} + (() => { + if (!programKey || !entityKey) return null + const currentUserLogin = session?.user?.login + const isAdmin = + accessLevel === 'admin' && + admins?.some((admin) => admin.login === currentUserLogin) + const isMentor = mentors?.some((mentor) => mentor.login === currentUserLogin) + return isAdmin || isMentor ? ( + + ) : null + })()} {!isActive && } {isArchived && type === 'repository' && } {IS_PROJECT_HEALTH_ENABLED && diff --git a/frontend/src/components/EntityActions.tsx b/frontend/src/components/EntityActions.tsx index 329b15a378..ea6357a235 100644 --- a/frontend/src/components/EntityActions.tsx +++ b/frontend/src/components/EntityActions.tsx @@ -1,10 +1,26 @@ 'use client' +import { gql } from '@apollo/client' +import { useMutation } from '@apollo/client/react' +import { Button } from '@heroui/button' +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal' +import { addToast } from '@heroui/toast' import { useRouter } from 'next/navigation' import type React from 'react' import { useState, useRef, useEffect } from 'react' import { FaEllipsisV } from 'react-icons/fa' import { ProgramStatusEnum } from 'types/__generated__/graphql' +import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated' + +const DELETE_MODULE_MUTATION = gql` + mutation DeleteModule($programKey: String!, $moduleKey: String!) { + deleteModule(programKey: $programKey, moduleKey: $moduleKey) + } +` + +type DeleteModuleResponse = { + deleteModule: boolean +} interface EntityActionsProps { type: 'program' | 'module' @@ -12,6 +28,7 @@ interface EntityActionsProps { moduleKey?: string status?: string setStatus?: (newStatus: ProgramStatusEnum) => void | Promise + isAdmin?: boolean } const EntityActions: React.FC = ({ @@ -20,14 +37,19 @@ const EntityActions: React.FC = ({ moduleKey, status, setStatus, + isAdmin = false, }) => { const router = useRouter() const [dropdownOpen, setDropdownOpen] = useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const [focusIndex, setFocusIndex] = useState(-1) const dropdownRef = useRef(null) const triggerButtonRef = useRef(null) const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]) + const [deleteModule] = useMutation(DELETE_MODULE_MUTATION) + const handleAction = (actionKey: string) => { switch (actionKey) { case 'edit_program': @@ -46,6 +68,9 @@ const EntityActions: React.FC = ({ router.push(`/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues`) } break + case 'delete_module': + setDeleteModalOpen(true) + break case 'publish': setStatus?.(ProgramStatusEnum.Published) break @@ -59,6 +84,70 @@ const EntityActions: React.FC = ({ setDropdownOpen(false) } + const handleDeleteConfirm = async () => { + if (!moduleKey) return + + setIsDeleting(true) + + try { + const result = await deleteModule({ + variables: { programKey, moduleKey }, + + update(cache) { + const existing = cache.readQuery({ + query: GetProgramAndModulesDocument, + variables: { programKey }, + }) + + if (existing?.getProgramModules) { + cache.writeQuery({ + query: GetProgramAndModulesDocument, + variables: { programKey }, + data: { + ...existing, + getProgramModules: existing.getProgramModules.filter( + (module) => module.key !== moduleKey + ), + }, + }) + } + }, + }) + + if (!result?.data || typeof result.data !== 'object' || !result.data.deleteModule) { + throw new Error('Delete mutation failed on server') + } + + addToast({ + title: 'Success', + description: 'Module has been deleted successfully.', + color: 'success', + }) + + setDeleteModalOpen(false) + router.push(`/my/mentorship/programs/${programKey}`) + } catch (error) { + let description = 'Failed to delete module. Please try again.' + + if (error instanceof Error) { + if (error.message.includes('Permission') || error.message.includes('not have permission')) { + description = + 'You do not have permission to delete this module. Only program admins can delete modules.' + } else if (error.message.includes('Unauthorized')) { + description = 'Unauthorized: You must be a program admin to delete modules.' + } + } + + addToast({ + title: 'Error', + description, + color: 'danger', + }) + } finally { + setIsDeleting(false) + } + } + const options = type === 'program' ? [ @@ -74,7 +163,10 @@ const EntityActions: React.FC = ({ ] : [ { key: 'edit_module', label: 'Edit' }, - { key: 'view_issues', label: 'View Issues' }, + ...(isAdmin ? [{ key: 'view_issues', label: 'View Issues' }] : []), + ...(isAdmin + ? [{ key: 'delete_module', label: 'Delete', className: 'text-red-500' }] + : []), ] useEffect(() => { @@ -138,52 +230,86 @@ const EntityActions: React.FC = ({ } return ( -
- - {dropdownOpen && ( -
+
+ + {dropdownOpen && ( +
+ {options.map((option, index) => { + const handleMenuItemClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + handleAction(option.key) + setFocusIndex(-1) + } + + return ( + + ) + })} +
+ )} +
+ + {type === 'module' && ( + setDeleteModalOpen(false)}> + + Delete Module + +

Are you sure you want to delete this module? This action cannot be undone.

+
+ + + - ) - })} -
+ Delete + + + + )} -
+ ) } diff --git a/frontend/src/components/SingleModuleCard.tsx b/frontend/src/components/SingleModuleCard.tsx index 5c84628384..1b8eadaa9f 100644 --- a/frontend/src/components/SingleModuleCard.tsx +++ b/frontend/src/components/SingleModuleCard.tsx @@ -9,7 +9,7 @@ import { useSession } from 'next-auth/react' import React, { useState } from 'react' import { FaFolderOpen } from 'react-icons/fa' import { HiUserGroup } from 'react-icons/hi' -import { ExtendedSession } from 'types/auth' +import type { ExtendedSession } from 'types/auth' import type { Contributor } from 'types/contributor' import type { Module } from 'types/mentorship' import { formatDate } from 'utils/dateFormatter' @@ -28,14 +28,17 @@ interface SingleModuleCardProps { } const SingleModuleCard: React.FC = ({ module, accessLevel, admins }) => { - const { data } = useSession() + const { data: sessionData } = useSession() as { data: ExtendedSession | null } const pathname = usePathname() const [showAllMentors, setShowAllMentors] = useState(false) const [showAllMentees, setShowAllMentees] = useState(false) + const currentUserLogin = sessionData?.user?.login + const isAdmin = - accessLevel === 'admin' && - admins?.some((admin) => admin.login === (data as ExtendedSession)?.user?.login) + accessLevel === 'admin' && admins?.some((admin) => admin.login === currentUserLogin) + + const isMentor = module.mentors?.some((mentor) => mentor.login === currentUserLogin) // Extract programKey from pathname (e.g., /my/mentorship/programs/[programKey]) const programKey = pathname?.split('/programs/')[1]?.split('/')[0] || '' @@ -120,7 +123,14 @@ const SingleModuleCard: React.FC = ({ module, accessLevel
- {isAdmin && } + {(isAdmin || isMentor) && ( + + )} {/* Description */} diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 9c6f090df2..7f98b11bbd 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -385,6 +385,7 @@ export type Mutation = { createApiKey: CreateApiKeyResult; createModule: ModuleNode; createProgram: ProgramNode; + deleteModule: Scalars['String']['output']; githubAuth: GitHubAuthResult; logoutUser: LogoutResult; revokeApiKey: RevokeApiKeyResult; @@ -427,6 +428,12 @@ export type MutationCreateProgramArgs = { }; +export type MutationDeleteModuleArgs = { + moduleKey: Scalars['String']['input']; + programKey: Scalars['String']['input']; +}; + + export type MutationGithubAuthArgs = { accessToken: Scalars['String']['input']; }; diff --git a/frontend/types/__generated__/EntityActions.generated.ts b/frontend/types/__generated__/EntityActions.generated.ts new file mode 100644 index 0000000000..28f4e997ba --- /dev/null +++ b/frontend/types/__generated__/EntityActions.generated.ts @@ -0,0 +1,13 @@ +import * as Types from '../../src/types/__generated__/graphql'; + +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type DeleteModuleMutationVariables = Types.Exact<{ + programKey: Types.Scalars['String']['input']; + moduleKey: Types.Scalars['String']['input']; +}>; + + +export type DeleteModuleMutation = { deleteModule: string }; + + +export const DeleteModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file