diff --git a/react/jest.setup.ts b/react/jest.setup.ts index da882238..fb4c2a98 100644 --- a/react/jest.setup.ts +++ b/react/jest.setup.ts @@ -53,8 +53,9 @@ export function shouldIgnoreError(args: any[]): boolean { beforeAll(() => { // Establish mocking of APIs before all tests - server.listen({ onUnhandledRequest: 'error' }); - + server.listen({ + onUnhandledRequest: 'error', + }); const originalError = console.error; jest.spyOn(console, 'error').mockImplementation((...args: any[]) => { diff --git a/react/src/AppRouter.tsx b/react/src/AppRouter.tsx index dab56191..5a16e3c1 100644 --- a/react/src/AppRouter.tsx +++ b/react/src/AppRouter.tsx @@ -65,7 +65,10 @@ function AppRouter() { } /> - } /> + } + /> } /> ({ @@ -18,8 +19,8 @@ jest.mock('@hazmapper/components/FeatureFileTree', () => { describe('AssetsPanel', () => { const defaultProps = { featureCollection, - projectId: 1, - isPublic: false, + project: projectMock, + isPublicView: false, }; beforeEach(() => { diff --git a/react/src/components/AssetsPanel/AssetsPanel.tsx b/react/src/components/AssetsPanel/AssetsPanel.tsx index 64989237..84745e97 100644 --- a/react/src/components/AssetsPanel/AssetsPanel.tsx +++ b/react/src/components/AssetsPanel/AssetsPanel.tsx @@ -1,22 +1,26 @@ import React from 'react'; import styles from './AssetsPanel.module.css'; import FeatureFileTree from '@hazmapper/components/FeatureFileTree'; -import { FeatureCollection } from '@hazmapper/types'; +import { FeatureCollection, Project } from '@hazmapper/types'; import { Button } from '@tacc/core-components'; import { useFeatures } from '@hazmapper/hooks'; +const getFilename = (projectName: string) => { + // Convert to lowercase filename based on projectName + const sanitizedString = projectName.toLowerCase().replace(/[^a-z0-9]/g, '_'); + return `${sanitizedString}.json`; +}; + interface DownloadFeaturesButtonProps { - projectId: number; - isPublic: boolean; + project: Project; } const DownloadFeaturesButton: React.FC = ({ - projectId, - isPublic, + project, }) => { const { isLoading: isDownloading, refetch: triggerDownload } = useFeatures({ - projectId, - isPublic, + projectId: project.id, + isPublicView: project.public, assetTypes: [], // Empty array to get all features options: { enabled: false, // Only fetch when triggered by user clicking button @@ -31,7 +35,7 @@ const DownloadFeaturesButton: React.FC = ({ const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `hazmapper.json`; + link.download = getFilename(project.name); document.body.appendChild(link); link.click(); @@ -56,23 +60,23 @@ interface Props { featureCollection: FeatureCollection; /** - * Whether or not the map project is public. + * Whether or not the map project is a public view. */ - isPublic: boolean; + isPublicView: boolean; /** - * active project id + * active project */ - projectId: number; + project: Project; } /** * A panel component that displays info on feature assets */ const AssetsPanel: React.FC = ({ - isPublic, + isPublicView, featureCollection, - projectId, + project, }) => { return ( @@ -81,13 +85,13 @@ const AssetsPanel: React.FC = ({ - + ); diff --git a/react/src/components/DeleteMapModal/DeleteMapModal.tsx b/react/src/components/DeleteMapModal/DeleteMapModal.tsx index ee814844..4fb2db1e 100644 --- a/react/src/components/DeleteMapModal/DeleteMapModal.tsx +++ b/react/src/components/DeleteMapModal/DeleteMapModal.tsx @@ -20,13 +20,13 @@ const DeleteMapModal = ({ isLoading: isDeletingProject, isError, isSuccess, - } = useDeleteProject(project.id); + } = useDeleteProject(); const handleClose = () => { parentToggle(); }; const handleDeleteProject = () => { - deleteProject(undefined, {}); + deleteProject({ projectId: project.id }); }; return ( diff --git a/react/src/components/FeatureFileTree/FeatureFileTree.test.tsx b/react/src/components/FeatureFileTree/FeatureFileTree.test.tsx index acf52002..d1640b57 100644 --- a/react/src/components/FeatureFileTree/FeatureFileTree.test.tsx +++ b/react/src/components/FeatureFileTree/FeatureFileTree.test.tsx @@ -1,14 +1,10 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; import FeatureFileTree from './FeatureFileTree'; +import { server, renderInTest } from '@hazmapper/test/testUtil'; import { featureCollection } from '@hazmapper/__fixtures__/featuresFixture'; -import { useDeleteFeature } from '@hazmapper/hooks'; - -// Mock the hooks -jest.mock('@hazmapper/hooks', () => ({ - useDeleteFeature: jest.fn(), -})); +import { testDevConfiguration } from '@hazmapper/__fixtures__/appConfigurationFixture'; jest.mock('react-resize-detector', () => ({ useResizeDetector: () => ({ @@ -17,31 +13,20 @@ jest.mock('react-resize-detector', () => ({ }), })); -const renderWithRouter = (ui: React.ReactElement, { route = '/' } = {}) => { - return { - ...render({ui}), - }; -}; - describe('FeatureFileTree', () => { - const defaultProps = { + const defaultTreeProps = { featureCollection: featureCollection, - isPublic: false, + isPublicView: false, projectId: 1, }; beforeEach(() => { jest.clearAllMocks(); - - (useDeleteFeature as jest.Mock).mockImplementation(() => ({ - mutate: jest.fn(), - isLoading: false, - })); }); it('renders feature list correctly', () => { - const { getByText } = renderWithRouter( - + const { getByText } = renderInTest( + ); expect(getByText('foo')).toBeDefined(); @@ -49,32 +34,38 @@ describe('FeatureFileTree', () => { expect(getByText('image2.JPG')).toBeDefined(); }); - it('handles feature deletion for non-public projects', () => { - const deleteFeatureMock = jest.fn(); - (useDeleteFeature as jest.Mock).mockImplementation(() => ({ - mutate: deleteFeatureMock, - isLoading: false, - })); + it('handles feature deletion for non-public projects', async () => { + const featureId = 1; + let wasDeleted = false; + + server.use( + http.delete( + `${testDevConfiguration.geoapiUrl}/projects/${defaultTreeProps.projectId}/features/${featureId}/`, + () => { + wasDeleted = true; + return HttpResponse.json({}, { status: 200 }); + } + ) + ); - const { getByTestId } = renderWithRouter( - , - { route: '/?selectedFeature=1' } + const { getByTestId } = renderInTest( + , + `/?selectedFeature=${featureId}` ); // Find and click delete button (as featured is selected) const deleteButton = getByTestId('delete-feature-button'); fireEvent.click(deleteButton); - expect(deleteFeatureMock).toHaveBeenCalledWith({ - projectId: 1, - featureId: 1, + await waitFor(() => { + expect(wasDeleted).toBeTruthy(); }); }); it('does not show delete button for public projects', () => { - const { queryByTestId } = renderWithRouter( - , - { route: '/?selectedFeature=1' } + const { queryByTestId } = renderInTest( + , + '/?selectedFeature=1' ); // Verify delete button is not present @@ -83,8 +74,8 @@ describe('FeatureFileTree', () => { }); it('does not show delete button when no feature is selected', () => { - const { queryByTestId } = renderWithRouter( - + const { queryByTestId } = renderInTest( + ); // Verify delete button is not present diff --git a/react/src/components/FeatureFileTree/FeatureFileTree.tsx b/react/src/components/FeatureFileTree/FeatureFileTree.tsx index 1652597f..c41d17a0 100644 --- a/react/src/components/FeatureFileTree/FeatureFileTree.tsx +++ b/react/src/components/FeatureFileTree/FeatureFileTree.tsx @@ -31,7 +31,7 @@ interface FeatureFileTreeProps { /** * Whether or not the map project is public. */ - isPublic: boolean; + isPublicView: boolean; /** * active project id @@ -44,7 +44,7 @@ interface FeatureFileTreeProps { */ const FeatureFileTree: React.FC = ({ featureCollection, - isPublic, + isPublicView, projectId, }) => { const { mutate: deleteFeature, isLoading } = useDeleteFeature(); @@ -169,7 +169,7 @@ const FeatureFileTree: React.FC = ({ )} {featureNode.name} - {!isPublic && isSelected && ( + {!isPublicView && isSelected && ( = ({ - isPublic, + isPublicView, }) => { const navigate = useNavigate(); const location = useLocation(); @@ -24,7 +24,7 @@ const ManageMapProjectModal: React.FC = ({ TODO - Manage Map Project TODO, isPublic: {isPublic} + Manage Map Project TODO, isPublicView: {isPublicView} diff --git a/react/src/components/MapProjectNavBar/MapProjectNavBar.test.tsx b/react/src/components/MapProjectNavBar/MapProjectNavBar.test.tsx index 633879b7..98d4a4b5 100644 --- a/react/src/components/MapProjectNavBar/MapProjectNavBar.test.tsx +++ b/react/src/components/MapProjectNavBar/MapProjectNavBar.test.tsx @@ -7,7 +7,7 @@ describe('MapProjectNavBar', () => { it('renders the public nav items for public maps', () => { const { getByText, queryByText } = render( - + ); expect(getByText('Assets')).toBeDefined(); @@ -22,7 +22,7 @@ describe('MapProjectNavBar', () => { it('renders all nav items for non-public maps', () => { const { getByText } = render( - + ); diff --git a/react/src/components/MapProjectNavBar/MapProjectNavBar.tsx b/react/src/components/MapProjectNavBar/MapProjectNavBar.tsx index 9898e54a..9f78fc65 100644 --- a/react/src/components/MapProjectNavBar/MapProjectNavBar.tsx +++ b/react/src/components/MapProjectNavBar/MapProjectNavBar.tsx @@ -59,10 +59,12 @@ const navItems: NavItem[] = [ ]; interface NavBarPanelProps { - isPublic?: boolean; + isPublicView?: boolean; } -const MapProjectNavBar: React.FC = ({ isPublic = false }) => { +const MapProjectNavBar: React.FC = ({ + isPublicView = false, +}) => { const location = useLocation(); const queryParams = new URLSearchParams(location.search); const activePanel = queryParams.get(queryPanelKey); @@ -70,7 +72,7 @@ const MapProjectNavBar: React.FC = ({ isPublic = false }) => { return ( {navItems - .filter((item) => (isPublic ? item.showWhenPublic : true)) + .filter((item) => (isPublicView ? item.showWhenPublic : true)) .map((item) => { const updatedQueryParams = new URLSearchParams(location.search); diff --git a/react/src/hooks/features/useDeleteFeature.ts b/react/src/hooks/features/useDeleteFeature.ts index 990a6cc1..63252e86 100644 --- a/react/src/hooks/features/useDeleteFeature.ts +++ b/react/src/hooks/features/useDeleteFeature.ts @@ -1,5 +1,5 @@ import { useQueryClient } from 'react-query'; -import { useDeleteWithParams } from '@hazmapper/requests'; +import { useDelete } from '@hazmapper/requests'; type DeleteFeatureParams = { projectId: number; @@ -9,7 +9,7 @@ type DeleteFeatureParams = { export function useDeleteFeature() { const queryClient = useQueryClient(); - return useDeleteWithParams({ + return useDelete({ endpoint: ({ projectId, featureId }) => `/projects/${projectId}/features/${featureId}/`, options: { diff --git a/react/src/hooks/features/useFeatures.ts b/react/src/hooks/features/useFeatures.ts index 2d69fc78..7bb0f5a7 100644 --- a/react/src/hooks/features/useFeatures.ts +++ b/react/src/hooks/features/useFeatures.ts @@ -4,18 +4,19 @@ import { useGet } from '@hazmapper/requests'; interface UseFeaturesParams { projectId: number; - isPublic: boolean; + isPublicView: boolean; assetTypes: string[]; options?: object; } export const useFeatures = ({ projectId, - isPublic, + isPublicView, assetTypes, options = {}, }: UseFeaturesParams): UseQueryResult => { - const featuresRoute = isPublic ? 'public-projects' : 'projects'; + // TODO can be reworked as /projects can be used and /public-projects can be removed since we are no longer a WSO2 API + const featuresRoute = isPublicView ? 'public-projects' : 'projects'; let endpoint = `/${featuresRoute}/${projectId}/features/`; if (assetTypes?.length) { endpoint += `?assetType=${assetTypes.join(',')}`; @@ -32,7 +33,7 @@ export const useFeatures = ({ const query = useGet({ endpoint, - key: ['activeProjectFeatures', { projectId, isPublic, assetTypes }], + key: ['activeProjectFeatures', { projectId, isPublicView, assetTypes }], options: { ...defaultQueryOptions, ...options }, }); return query; diff --git a/react/src/hooks/projects/useProjects.ts b/react/src/hooks/projects/useProjects.ts index 224f57c1..62458d1e 100644 --- a/react/src/hooks/projects/useProjects.ts +++ b/react/src/hooks/projects/useProjects.ts @@ -21,16 +21,16 @@ export const useProjects = (): UseQueryResult => { interface UseProjectParams { projectUUID?: string; - isPublic: boolean; + isPublicView: boolean; options: object; } export const useProject = ({ projectUUID, - isPublic, + isPublicView, options, }: UseProjectParams): UseQueryResult => { - const projectRoute = isPublic ? 'public-projects' : 'projects'; + const projectRoute = isPublicView ? 'public-projects' : 'projects'; const endpoint = `/${projectRoute}/?uuid=${projectUUID}`; const query = useGet({ endpoint, @@ -86,11 +86,15 @@ export function useProjectsWithDesignSafeInformation(): UseQueryResult< } as UseQueryResult; } -export const useDeleteProject = (projectId: number) => { +type DeleteProjectParams = { + projectId: number; +}; + +export const useDeleteProject = () => { const queryClient = useQueryClient(); - const endpoint = `/projects/${projectId}/`; - return useDelete({ - endpoint, + + return useDelete({ + endpoint: ({ projectId }) => `/projects/${projectId}/`, apiService: ApiService.Geoapi, options: { onSuccess: () => { diff --git a/react/src/hooks/tileServers/useTileServers.ts b/react/src/hooks/tileServers/useTileServers.ts index a5378ef9..7eaccf60 100644 --- a/react/src/hooks/tileServers/useTileServers.ts +++ b/react/src/hooks/tileServers/useTileServers.ts @@ -4,21 +4,21 @@ import { TileServerLayer } from '@hazmapper/types'; interface UseTileServerParams { projectId?: number; - isPublic: boolean; + isPublicView: boolean; options?: object; } export const useTileServers = ({ projectId, - isPublic, + isPublicView, options = {}, }: UseTileServerParams): UseQueryResult => { - const tileServersRoute = isPublic ? 'public-projects' : 'projects'; + const tileServersRoute = isPublicView ? 'public-projects' : 'projects'; const endpoint = `/${tileServersRoute}/${projectId}/tile-servers/`; const query = useGet({ endpoint, - key: ['tile-servers', { projectId, isPublic }], + key: ['tile-servers', { projectId, isPublicView }], options, }); diff --git a/react/src/pages/MapProject/MapProject.tsx b/react/src/pages/MapProject/MapProject.tsx index 8029ba16..5220149c 100644 --- a/react/src/pages/MapProject/MapProject.tsx +++ b/react/src/pages/MapProject/MapProject.tsx @@ -19,13 +19,13 @@ interface MapProjectProps { * Whether or not the map project is public. * @default false */ - isPublic?: boolean; + isPublicView?: boolean; } /** * A component that displays a map project including initial loading/error components */ -const MapProject: React.FC = ({ isPublic = false }) => { +const MapProject: React.FC = ({ isPublicView = false }) => { const { projectUUID } = useParams(); const { @@ -34,7 +34,7 @@ const MapProject: React.FC = ({ isPublic = false }) => { error, } = useProject({ projectUUID, - isPublic, + isPublicView, options: { enabled: !!projectUUID }, }); @@ -61,7 +61,12 @@ const MapProject: React.FC = ({ isPublic = false }) => { ); } - return ; + return ( + + ); }; interface LoadedMapProject { @@ -73,7 +78,7 @@ interface LoadedMapProject { /** * Whether or not the map project is public. */ - isPublic; + isPublicView; } /** @@ -81,7 +86,7 @@ interface LoadedMapProject { */ const LoadedMapProject: React.FC = ({ activeProject, - isPublic, + isPublicView, }) => { const [selectedAssetTypes, setSelectedAssetTypes] = useState( Object.keys(assetTypeOptions) @@ -112,7 +117,7 @@ const LoadedMapProject: React.FC = ({ error: featuresError, } = useFeatures({ projectId: activeProject.id, - isPublic, + isPublicView, assetTypes: formattedAssetTypes, }); @@ -122,7 +127,7 @@ const LoadedMapProject: React.FC = ({ error: tileServerLayersError, } = useTileServers({ projectId: activeProject.id, - isPublic, + isPublicView, }); const location = useLocation(); @@ -157,8 +162,8 @@ const LoadedMapProject: React.FC = ({ {activePanel === Panel.Assets && ( )} @@ -175,7 +180,7 @@ const LoadedMapProject: React.FC = ({ )} {activePanel === Panel.Manage && ( - + )} ({ 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); -} - -type UseDeleteWithParams = { +type UseDeleteParams = { endpoint: string | ((variables: Variables) => string); options?: Omit< UseMutationOptions, @@ -199,11 +166,11 @@ type UseDeleteWithParams = { apiService?: ApiService; }; -export function useDeleteWithParams({ +export function useDelete({ endpoint, options = {}, apiService = ApiService.Geoapi, -}: UseDeleteWithParams) { +}: UseDeleteParams) { const client = axios; const state = store.getState(); const configuration = useAppConfiguration(); diff --git a/react/src/test/testUtil.tsx b/react/src/test/testUtil.tsx index c03a4985..db33b37a 100644 --- a/react/src/test/testUtil.tsx +++ b/react/src/test/testUtil.tsx @@ -17,6 +17,9 @@ export const testQueryClient = new QueryClient({ staleTime: 0, useErrorBoundary: true, }, + mutations: { + retry: false, + }, }, });