Skip to content

Commit

Permalink
update/add hook for selected feature (#288)
Browse files Browse the repository at this point in the history
* Refactor selecting feature to be a hook

* Rename set selected feature method

* Add current features hook and extend selected feature hook

* Fix so that we find first query with data

This would handle when we have queries that are disabled.

* Add simple tests for useFeatures and useCurrentFeatures

* Use static string for query key

* Fix linting
  • Loading branch information
nathanfranklin authored Dec 6, 2024
1 parent cccce3e commit 3fb9d6e
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 19 deletions.
20 changes: 5 additions & 15 deletions react/src/components/FeatureFileTree/FeatureFileTree.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Tree } from 'antd';
import type { DataNode } from 'antd/es/tree';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
Expand All @@ -11,7 +10,7 @@ import { useResizeDetector } from 'react-resize-detector';
import { Button } from '@tacc/core-components';
import { featureCollectionToFileNodeArray } from '@hazmapper/utils/featureTreeUtils';
import { FeatureCollection, FeatureFileNode } from '@hazmapper/types';
import { useDeleteFeature } from '@hazmapper/hooks';
import { useDeleteFeature, useFeatureSelection } from '@hazmapper/hooks';
import { FeatureIcon } from '@hazmapper/components/FeatureIcon';
import styles from './FeatureFileTree.module.css';

Expand Down Expand Up @@ -48,17 +47,13 @@ const FeatureFileTree: React.FC<FeatureFileTreeProps> = ({
projectId,
}) => {
const { mutate: deleteFeature, isLoading } = useDeleteFeature();
const location = useLocation();
const navigate = useNavigate();
const { selectedFeatureId, setSelectedFeatureId } = useFeatureSelection();

const { height, ref } = useResizeDetector();

const [expanded, setExpanded] = useState<string[]>([]);
const [treeData, setTreeData] = useState<TreeDataNode[]>([]);

const searchParams = new URLSearchParams(location.search);
const selectedFeature = searchParams.get('selectedFeature');

useEffect(() => {
const fileNodeArray = featureCollectionToFileNodeArray(featureCollection);

Expand Down Expand Up @@ -120,7 +115,8 @@ const FeatureFileTree: React.FC<FeatureFileTreeProps> = ({

const titleRender = (node: TreeDataNode) => {
const featureNode = node.featureNode as FeatureFileNode;
const isSelected = selectedFeature === node.key && !featureNode.isDirectory;
const isSelected =
!featureNode.isDirectory && selectedFeatureId === Number(node.key);
const isExpanded = expanded.includes(node.key);

const toggleNode = (e: React.MouseEvent | React.KeyboardEvent) => {
Expand All @@ -135,13 +131,7 @@ const FeatureFileTree: React.FC<FeatureFileTreeProps> = ({
} else {
e.stopPropagation();

const newSearchParams = new URLSearchParams(searchParams);
if (selectedFeature === node.key) {
newSearchParams.delete('selectedFeature');
} else {
newSearchParams.set('selectedFeature', node.key);
}
navigate({ search: newSearchParams.toString() }, { replace: true });
setSelectedFeatureId(Number(node.key));
}
};

Expand Down
3 changes: 2 additions & 1 deletion react/src/hooks/features/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { useDeleteFeature } from './useDeleteFeature';
export { useFeatures } from './useFeatures';
export { useFeatures, useCurrentFeatures } from './useFeatures';
export { useFeatureSelection } from './useFeatureSelection';
3 changes: 2 additions & 1 deletion react/src/hooks/features/useDeleteFeature.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQueryClient } from 'react-query';
import { useDelete } from '@hazmapper/requests';
import { KEY_USE_FEATURES } from './useFeatures';

type DeleteFeatureParams = {
projectId: number;
Expand All @@ -15,7 +16,7 @@ export function useDeleteFeature() {
options: {
onSuccess: () => {
// invalidate *any* feature listing query
queryClient.invalidateQueries(['activeProjectFeatures']);
queryClient.invalidateQueries([KEY_USE_FEATURES]);
},
},
});
Expand Down
76 changes: 76 additions & 0 deletions react/src/hooks/features/useFeatureSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useCallback } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useCurrentFeatures } from '.';
import { Feature, FeatureCollection } from '@hazmapper/types';

const SELECTED_FEATURE_PARAM = 'selectedFeature';

interface UseFeatureSelectionReturn {
selectedFeatureId: number | null;
selectedFeature: Feature | null;
setSelectedFeatureId: (featureId: number) => void;
}

const findFeatureById = (
featureCollection: FeatureCollection,
selectedFeatureId: number | null
): Feature | null => {
if (selectedFeatureId === null) return null;

return (
featureCollection.features.find(
(feature) => feature.id === selectedFeatureId
) || null
);
};

/**
* A custom hook that manages feature selection state through URL search parameters.
*
* This hook provides functionality to:
* - Get the currently selected feature ID from URL parameters
* - Get the corresponding Feature object for the selected ID ( *if* it is in the current filtered feature response )
* - Set/unset the selected feature ID in the URL
*
* */
export function useFeatureSelection(): UseFeatureSelectionReturn {
const currentFeatures = useCurrentFeatures();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();

const selectedFeatureId = searchParams.get(SELECTED_FEATURE_PARAM)
? Number(searchParams.get(SELECTED_FEATURE_PARAM))
: null;

const selectedFeature = currentFeatures.data
? findFeatureById(currentFeatures.data, selectedFeatureId)
: null;

const setSelectedFeatureId = useCallback(
(featureId: number) => {
const newSearchParams = new URLSearchParams(searchParams);

if (selectedFeatureId === Number(featureId)) {
newSearchParams.delete(SELECTED_FEATURE_PARAM);
} else {
newSearchParams.set(SELECTED_FEATURE_PARAM, String(featureId));
}

navigate(
{
pathname: location.pathname,
search: newSearchParams.toString(),
},
{ replace: true }
);
},
[navigate, location.pathname, searchParams, selectedFeatureId]
);

return {
selectedFeatureId,
selectedFeature,
setSelectedFeatureId,
};
}
55 changes: 55 additions & 0 deletions react/src/hooks/features/useFeatures.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useFeatures, useCurrentFeatures } from './useFeatures';
import { featureCollection } from '@hazmapper/__fixtures__/featuresFixture';
import { TestWrapper, testQueryClient } from '@hazmapper/test/testUtil';

describe('Feature Hooks', () => {
afterEach(() => {
testQueryClient.clear();
});

it('useFeatures should fetch features', async () => {
const { result } = renderHook(
() =>
useFeatures({
projectId: 80,
isPublicView: false,
assetTypes: ['image'],
}),
{ wrapper: TestWrapper }
);

expect(result.current.isLoading).toBe(true);

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(result.current.data).toEqual(featureCollection);
});

it('useCurrentFeatures should return cached features', async () => {
// First, populate the cache with a features query
const { result: featuresResult } = renderHook(
() =>
useFeatures({
projectId: 80,
isPublicView: false,
assetTypes: ['image'],
}),
{ wrapper: TestWrapper }
);

await waitFor(() => {
expect(featuresResult.current.isSuccess).toBe(true);
});

// Now test useCurrentFeatures
const { result: currentResult } = renderHook(() => useCurrentFeatures(), {
wrapper: TestWrapper,
});

expect(currentResult.current.isLoading).toBe(false);
expect(currentResult.current.data).toEqual(featureCollection);
});
});
27 changes: 25 additions & 2 deletions react/src/hooks/features/useFeatures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UseQueryResult } from 'react-query';
import { useQueryClient, UseQueryResult } from 'react-query';
import { FeatureCollection } from '@hazmapper/types';
import { useGet } from '@hazmapper/requests';

Expand All @@ -9,6 +9,8 @@ interface UseFeaturesParams {
options?: object;
}

export const KEY_USE_FEATURES = 'activeProjectFeatures';

export const useFeatures = ({
projectId,
isPublicView,
Expand All @@ -33,8 +35,29 @@ export const useFeatures = ({

const query = useGet<FeatureCollection>({
endpoint,
key: ['activeProjectFeatures', { projectId, isPublicView, assetTypes }],
key: [KEY_USE_FEATURES, { projectId, isPublicView, assetTypes }],
options: { ...defaultQueryOptions, ...options },
});
return query;
};

export const useCurrentFeatures = (): UseQueryResult<FeatureCollection> => {
const queryClient = useQueryClient();

// Get all existing queries that match the KEY_USE_FEATURES prefix
const queries = queryClient.getQueriesData<FeatureCollection>([
KEY_USE_FEATURES,
]);

// Find first query with data - getQueriesData returns [queryKey, data] tuples
const activeQuery = queries.find(([, queryData]) => !!queryData);
const currentData = activeQuery ? activeQuery[1] : undefined;

return {
data: currentData,
isSuccess: !!currentData,
isLoading: false,
isError: false,
error: null,
} as UseQueryResult<FeatureCollection>;
};
8 changes: 8 additions & 0 deletions react/src/test/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { http, HttpResponse } from 'msw';
import { testDevConfiguration } from '@hazmapper/__fixtures__/appConfigurationFixture';
import { systems } from '@hazmapper/__fixtures__/systemsFixture';
import { featureCollection } from '@hazmapper/__fixtures__/featuresFixture';

// ArcGIS tiles GET
export const arcgis_tiles = http.get('https://tiles.arcgis.com/*', () => {
Expand All @@ -19,6 +20,12 @@ export const geoapi_projects_list = http.get(
() => HttpResponse.json({}, { status: 200 })
);

// GeoAPI Project Features GET
export const geoapi_project_features = http.get(
`${testDevConfiguration.geoapiUrl}/projects/:projectId/features/`,
() => HttpResponse.json(featureCollection, { status: 200 })
);

// Tapis Systems GET
export const tapis_systems = http.get(
`${testDevConfiguration.tapisUrl}/v3/systems/`,
Expand All @@ -43,6 +50,7 @@ export const defaultHandlers = [
arcgis_tiles,
designsafe_projects,
geoapi_projects_list,
geoapi_project_features,
tapis_files_listing,
tapis_systems,
];
10 changes: 10 additions & 0 deletions react/src/test/testUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ export function renderInTest(children: ReactElement, path = '/') {
</Provider>
);
}

export const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>
<MemoryRouter>
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
</MemoryRouter>
</Provider>
);

0 comments on commit 3fb9d6e

Please sign in to comment.