diff --git a/react/src/__fixtures__/projectFixtures.ts b/react/src/__fixtures__/projectFixtures.ts new file mode 100644 index 00000000..4715b087 --- /dev/null +++ b/react/src/__fixtures__/projectFixtures.ts @@ -0,0 +1,99 @@ +import { + Project, + DesignSafeProject, + DesignSafeProjectCollection, + ProjectRequest, +} from '../types'; + +export const projectMock: Project = { + id: 1, + uuid: 'abc123', + name: 'Sample Project', + description: 'A sample project for testing purposes.', + public: true, + system_file: 'sample-file', + system_id: 'sample-id', + system_path: '/path/to/sample', + deletable: true, + streetview_instances: null, + ds_project: { + uuid: 'proj-uuid', + projectId: 'proj-id', + title: 'Sample DesignSafe Project', + value: { + dois: [], + coPis: [], + title: 'Hazmapper V3 PROD Map Test 2024.08.07', + users: [ + { + inst: 'University of Texas at Austin (utexas.edu)', + role: 'pi', + email: 'test1@test.com', + fname: 'Fixture First Name', + lname: 'Fixture Last Name', + username: 'fixture1Username', + }, + { + inst: 'University of Texas at Austin (utexas.edu)', + role: 'co_pi', + email: 'test2@test.com', + fname: 'Tester', + lname: 'Test', + username: 'fixture2Username', + }, + ], + authors: [], + frTypes: [], + nhEvent: '', + nhTypes: [], + fileObjs: [], + fileTags: [], + keywords: [], + nhEvents: [], + dataTypes: [], + projectId: 'PRJ-5566', + tombstone: false, + facilities: [], + nhLatitude: '', + nhLocation: '', + description: 'Map Test description required.', + nhLongitude: '', + projectType: 'None', + teamMembers: [], + awardNumbers: [], + guestMembers: [], + hazmapperMaps: [ + { + name: 'Hazmapper_TestProject', + path: '/', + uuid: '620aeaf4-f813-4b90-ba52-bc87cfa7b07b', + deployment: 'production', + }, + ], + referencedData: [], + associatedProjects: [], + }, + }, +}; + +export const designSafeProjectMock: DesignSafeProject = { + uuid: 'proj-uuid', + projectId: 'proj-id', + title: 'Sample DesignSafe Project', + value: {}, +}; + +export const designSafeProjectCollectionMock: DesignSafeProjectCollection = { + result: [designSafeProjectMock], +}; + +export const projectRequestMock: ProjectRequest = { + name: 'New Project Request', + description: 'A description for the new project request.', + public: true, + system_file: 'new-project-file', + system_id: 'new-system-id', + system_path: '/path/to/new-project', + watch_content: true, + watch_users: false, +}; diff --git a/react/src/components/DeleteMapModal/DeleteMapModal.test.tsx b/react/src/components/DeleteMapModal/DeleteMapModal.test.tsx new file mode 100644 index 00000000..7e68d5e9 --- /dev/null +++ b/react/src/components/DeleteMapModal/DeleteMapModal.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import DeleteMapModal from './DeleteMapModal'; +import { Provider } from 'react-redux'; +import store from '../../redux/store'; +import { projectMock } from '../../__fixtures__/projectFixtures'; +import { Project } from '../../types'; +import { useDeleteProject } from '../../hooks/projects'; + +jest.mock('../../hooks/projects', () => ({ + useDeleteProject: jest.fn(), +})); + +const mockHookReturn = { + mutate: jest.fn(), + isLoading: false, + isError: false, + isSuccess: false, +}; + +const nonDeletableProjectMock: Project = { + ...projectMock, + deletable: false, + name: 'Non-Deletable Project', +}; + +const publicProjectMock: Project = { + ...projectMock, + public: true, + name: 'Public Project', +}; + +const toggleMock = jest.fn(); +const queryClient = new QueryClient(); + +const renderComponent = async (projectData: Project = projectMock) => { + await act(async () => { + render( + + + + + + + + ); + }); +}; + +describe('DeleteMapModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useDeleteProject as jest.Mock).mockReturnValue(mockHookReturn); + }); + + describe('DeleteMapModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useDeleteProject as jest.Mock).mockReturnValue(mockHookReturn); + }); + + it('should display the modal with correct project name', async () => { + await renderComponent(); + const titleElement = screen.getByText(`Delete Map: ${projectMock.name}`); + expect(titleElement).toBeDefined(); + }); + + it('should show delete confirmation message for deletable projects', async () => { + await renderComponent(); + const confirmMessage = screen.getByText( + /Are you sure you want to delete this map?/ + ); + const warningMessage = screen.getByText(/This cannot be undone./); + expect(confirmMessage).toBeDefined(); + expect(warningMessage).toBeDefined(); + }); + + it('should show permission denied message for non-deletable projects', async () => { + await renderComponent(nonDeletableProjectMock); + const deniedMessage = screen.getByText('permission to delete this map', { + exact: false, + }); + expect(deniedMessage).toBeDefined(); + }); + + it('should disable delete button for non-deletable projects', async () => { + await renderComponent(nonDeletableProjectMock); + const deleteButton = screen.getByText('Delete') as HTMLButtonElement; + expect(deleteButton.disabled).toBe(true); + }); + + it('should enable delete button for deletable projects', async () => { + await renderComponent(); + const deleteButton = screen.getByText('Delete') as HTMLButtonElement; + expect(deleteButton.disabled).toBe(false); + }); + + it('should show additional warning for public projects', async () => { + await renderComponent(publicProjectMock); + const publicWarning = screen.getByText(/Note that this is a public map./); + expect(publicWarning).toBeDefined(); + }); + + it('should show error message when isError is true', async () => { + (useDeleteProject as jest.Mock).mockReturnValue({ + ...mockHookReturn, + isError: true, + }); + await renderComponent(); + const errorMessage = screen.getByText( + 'There was an error deleting your map.' + ); + expect(errorMessage).toBeDefined(); + }); + + it('should show success message when isSuccess is true', async () => { + (useDeleteProject as jest.Mock).mockReturnValue({ + ...mockHookReturn, + isSuccess: true, + }); + await renderComponent(); + const successMessage = screen.getByText('Succesfully deleted the map.'); + expect(successMessage).toBeDefined(); + }); + }); +}); diff --git a/react/src/components/DeleteMapModal/DeleteMapModal.tsx b/react/src/components/DeleteMapModal/DeleteMapModal.tsx new file mode 100644 index 00000000..ee814844 --- /dev/null +++ b/react/src/components/DeleteMapModal/DeleteMapModal.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { Button, SectionMessage } from '@tacc/core-components'; +import { Project } from '../../types'; +import { useDeleteProject } from '../../hooks/projects/'; + +type DeleteMapModalProps = { + isOpen: boolean; + close: () => void; + project: Project; +}; + +const DeleteMapModal = ({ + isOpen, + close: parentToggle, + project, +}: DeleteMapModalProps) => { + const { + mutate: deleteProject, + isLoading: isDeletingProject, + isError, + isSuccess, + } = useDeleteProject(project.id); + const handleClose = () => { + parentToggle(); + }; + + const handleDeleteProject = () => { + deleteProject(undefined, {}); + }; + + return ( + + + Delete Map: {project?.name}{' '} + + + {project?.deletable ? ( + <> + Are you sure you want to delete this map? All associated features, + metadata, and saved files will be deleted. + {project?.public && Note that this is a public map. } + + + This cannot be undone. + + > + ) : ( + 'You don’t have permission to delete this map.' + )} + + + + {isSuccess ? 'Close' : 'Cancel'} + + + Delete + + {isSuccess && ( + + {'Succesfully deleted the map.'} + + )} + {isError && ( + + {'There was an error deleting your map.'} + + )} + + + ); +}; + +export default DeleteMapModal; diff --git a/react/src/components/Projects/ProjectListing.tsx b/react/src/components/Projects/ProjectListing.tsx index 07a503f5..c8edc0ec 100644 --- a/react/src/components/Projects/ProjectListing.tsx +++ b/react/src/components/Projects/ProjectListing.tsx @@ -1,11 +1,15 @@ import React, { useState } from 'react'; import { useProjectsWithDesignSafeInformation } from '@hazmapper/hooks'; -import { Button, LoadingSpinner, Icon } from '@tacc/core-components'; +import { Button, LoadingSpinner } from '@tacc/core-components'; import CreateMapModal from '../CreateMapModal/CreateMapModal'; +import DeleteMapModal from '../DeleteMapModal/DeleteMapModal'; +import { Project } from '../../types'; import { useNavigate } from 'react-router-dom'; export const ProjectListing: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedProjectForDeletion, setSelectedProjectForDeletion] = + useState(null); const navigate = useNavigate(); const navigateToProject = (projectId) => { @@ -43,24 +47,47 @@ export const ProjectListing: React.FC = () => { {data?.map((proj) => ( - navigateToProject(proj.uuid)}> - {proj.name} + - {proj.ds_project?.value.projectId}{' '} - {proj.ds_project?.value.title} + {' '} + navigateToProject(proj.uuid)} + > + {proj.name} + - - - - - + {' '} + navigateToProject(proj.uuid)} + > + {proj.ds_project + ? `${proj.ds_project?.value.projectId} | + ${proj.ds_project?.value.title}` + : '---------'} + + + setSelectedProjectForDeletion(proj)} + > + ))} + + {selectedProjectForDeletion && ( + setSelectedProjectForDeletion(null)} + project={selectedProjectForDeletion} + /> + )} > ); }; diff --git a/react/src/hooks/projects/index.ts b/react/src/hooks/projects/index.ts index 8c421c54..5b11a39b 100644 --- a/react/src/hooks/projects/index.ts +++ b/react/src/hooks/projects/index.ts @@ -1,4 +1,5 @@ export { + useDeleteProject, useProjectsWithDesignSafeInformation, useProjects, useDsProjects, diff --git a/react/src/hooks/projects/useProjects.ts b/react/src/hooks/projects/useProjects.ts index 1378d4b6..d0b8ffd3 100644 --- a/react/src/hooks/projects/useProjects.ts +++ b/react/src/hooks/projects/useProjects.ts @@ -1,11 +1,7 @@ -import { UseQueryResult } from 'react-query'; +import { UseQueryResult, useQueryClient } from 'react-query'; import { useMemo } from 'react'; -import { - Project, - DesignSafeProjectCollection, - ApiService, -} from '@hazmapper/types'; -import { useGet } from '../../requests'; +import { Project, DesignSafeProjectCollection, ApiService } from '../../types'; +import { useGet, useDelete } from '../../requests'; export const useProjects = (): UseQueryResult => { const query = useGet({ @@ -79,3 +75,17 @@ export function useProjectsWithDesignSafeInformation(): UseQueryResult< error: dsProjectQuery.error || projectQuery.error, } as UseQueryResult; } + +export const useDeleteProject = (projectId: number) => { + const queryClient = useQueryClient(); + const endpoint = `/projects/${projectId}/`; + return useDelete({ + endpoint, + apiService: ApiService.Geoapi, + options: { + onSuccess: () => { + queryClient.invalidateQueries('projects'); + }, + }, + }); +}; diff --git a/react/src/requests.ts b/react/src/requests.ts index 7e1b9be7..a48c7ccd 100644 --- a/react/src/requests.ts +++ b/react/src/requests.ts @@ -157,3 +157,36 @@ export function usePost({ return useMutation(postUtil, options); } + +type UseDeleteParams = { + endpoint: string; + options?: UseMutationOptions; + apiService?: ApiService; +}; + +export function useDelete({ + endpoint, + options = {}, + apiService = ApiService.Geoapi, +}: UseDeleteParams) { + const client = axios; + const state = store.getState(); + const configuration = useAppConfiguration(); + + useEnsureAuthenticatedUserHasValidTapisToken(); + + const baseUrl = getBaseApiUrl(apiService, configuration); + const headers = getHeaders(apiService, state.auth); + + const deleteUtil = async () => { + const response = await client.delete( + `${baseUrl}${endpoint}`, + { + headers: headers, + } + ); + return response.data; + }; + + return useMutation(deleteUtil, options); +} diff --git a/react/src/types/projects.ts b/react/src/types/projects.ts index 284846e9..2c681d2f 100644 --- a/react/src/types/projects.ts +++ b/react/src/types/projects.ts @@ -1,17 +1,15 @@ export interface Project { - id?: number; + id: number; uuid?: string; name: string; description: string; - public?: boolean; - system_file?: string; - system_id?: string; - system_path?: string; - deletable?: boolean; + public: boolean; + system_file: string; + system_id: string; + system_path: string; + deletable: boolean; streetview_instances?: any; ds_project?: DesignSafeProject; - ds_project_id?: any; - ds_project_title?: any; } export interface DesignSafeProject { uuid: string;