Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduction of the Search Feature in the Frontend #161

Merged
merged 8 commits into from
Dec 20, 2023
26 changes: 26 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ npm run lint:fix
│ │ ├── MainDisplay.jsx
│ │ ├── NavigationPane.jsx
│ │ ├── RegionMap.jsx
│ │ ├── Search.jsx
│ │ └── NavigationContext.jsx
│ ├── App.jsx # Main application component
│ └── index.js # Application entry point
Expand Down Expand Up @@ -89,3 +90,28 @@ npm start
- `npm test`: Run tests (currently not specified).
- `npm eject`: Ejects the setup (Note: this is a one-way operation).
- `npm run lint`: Lints the codebase.

## Features

### Search

The application includes a search feature that allows users to quickly find
regions by name. This feature is implemented in the frontend and interacts
with the backend to fetch search results.

#### UI/UX Considerations:

- **Search Bar Placement**: Integrated at the top of the navigation pane,
ensuring high visibility and ease of access for users.
- **Responsive Autocomplete**: Implements a responsive autocomplete mechanism.
As users type in the search bar, suggestions are dynamically generated based
on the input, providing a smooth and interactive user experience.
- **Search Efficiency & Debouncing**: Optimized for performance, the search
feature includes debouncing to limit the number of backend calls, enhancing
efficiency, especially when dealing with large datasets.
- **Search Result Formatting**: Search results display the region name, along
with a unique path or identifier when similar names exist. This helps users
distinguish between regions with identical names.
- **Keyboard Navigation Support**: Users can navigate through search results
using keyboard arrows, enhancing accessibility and ease of use.

13 changes: 13 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,16 @@ export const fetchHierarchies = async () => {
return [];
}
};

export const fetchSearchResults = async (query, hierarchyId) => {
try {
const response = await api.get('/api/regions/search', { params: { query, hierarchyId } });
if (response.status === 204) {
return [];
}
return response.data;
} catch (error) {
console.error('Error fetching search results:', error);
return [];
OhmSpectator marked this conversation as resolved.
Show resolved Hide resolved
}
};
19 changes: 17 additions & 2 deletions frontend/src/components/ListOfRegions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import { Box } from '@mui/material';
import { fetchRootRegions, fetchSubregions } from '../api';
import { fetchAncestors, fetchRootRegions, fetchSubregions } from '../api';
import { useNavigation } from './NavigationContext';

/**
Expand All @@ -20,11 +20,26 @@ function ListOfRegions() {
const [error, setError] = useState(null);

const fetchRegions = async (regionId, hasSubregions) => {
let newRegions = [];
try {
let newRegions = [];
if (regionId) {
if (hasSubregions) {
newRegions = await fetchSubregions(regionId, selectedHierarchy.hierarchyId);
} else {
// Fecth the siblings of the selected region
// TODO: do not fetch the siblings if they are already fetched
// First - fetch the parent of the selected region
// TODO: add a dedicated API endpoint for fetching siblings
const ancestors = await fetchAncestors(regionId, selectedHierarchy.hierarchyId);
// The parent is the second item in the ancestors array as the
// first item is the region itself.
if (!ancestors || ancestors.length < 2) {
setError('Unable to find the parent region, and hence the siblings.');
return;
}
const parent = ancestors[1];
// Then fetch the subregions of the parent
newRegions = await fetchSubregions(parent.id, selectedHierarchy.hierarchyId);
}
} else {
newRegions = await fetchRootRegions(selectedHierarchy.hierarchyId);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/NavigationPane.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box } from '@mui/material';
import BreadcrumbNavigation from './BreadcrumbNavigation';
import ListOfRegions from './ListOfRegions';
import HierarchySwitcher from './HierarchySwitcher';
import Search from './Search';

/**
* NavigationPane is a layout component that renders the navigation side panel,
Expand All @@ -12,6 +13,7 @@ function NavigationPane() {
return (
<Box>
<HierarchySwitcher />
<Search />
<BreadcrumbNavigation />
<ListOfRegions />
</Box>
Expand Down
190 changes: 190 additions & 0 deletions frontend/src/components/Search.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React, { useState, useEffect, useRef } from 'react';
import { Autocomplete, TextField } from '@mui/material';
import { fetchSearchResults, fetchRegion } from '../api';
import { useNavigation } from './NavigationContext';

function Search() {
const [searchTerm, setSearchTerm] = useState({ name: '', force: false });
const [searchResults, setSearchResults] = useState([]);
const [inputValue, setInputValue] = useState({});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { selectedRegion, setSelectedRegion, selectedHierarchyId } = useNavigation();
const prevSelectedRegion = useRef();

function formatNames(foundResults) {
const nameCount = new Map();
foundResults.forEach((item) => {
nameCount.set(item.name, (nameCount.get(item.name) || 0) + 1);
});

return foundResults.map((item) => {
if (nameCount.get(item.name) === 1) {
return ({
name: item.name,
segment: null,
id: item.id,
}); // Unique name, return as is
}
// Find the smallest unique path segment
const pathSegments = item.path.split(' > ');
let uniqueSegment = pathSegments[pathSegments.length - 1];

for (let i = pathSegments.length - 2; i >= 0; i -= 1) {
const testPath = pathSegments.slice(i).join(' > ');
const isUnique = foundResults.filter((r) => r.path.includes(testPath)).length === 1;
if (isUnique) {
uniqueSegment = pathSegments.slice(i).join(' > ');
break;
}
}

return ({
name: item.name,
segment: uniqueSegment,
id: item.id,
});
});
}

useEffect(() => {
let active = true;

const fetchResults = async () => {
if (searchTerm.name.length > 3 || searchTerm.force) {
try {
const results = await fetchSearchResults(searchTerm.name);
if (active) {
setSearchResults(results);
if (results.length > 0) {
setIsDropdownOpen(true);
}
}
} catch (error) {
console.error('Error fetching search results:', error);
}
} else {
setSearchResults([]);
setIsDropdownOpen(false);
}
};

if (prevSelectedRegion.current !== selectedRegion) {
prevSelectedRegion.current = selectedRegion;
return () => {
active = false;
};
}

if (selectedRegion && selectedRegion.name === searchTerm.name) {
return () => {
active = false;
};
}
if (selectedRegion) {
if (!searchTerm.name || (searchTerm.name.length < 3 && !searchTerm.force)) {
setSearchResults([]);
setIsDropdownOpen(false);
return () => {
active = false;
};
}
}

if (searchTerm.force) {
fetchResults();
return () => {
active = false;
};
}

const timerId = setTimeout(fetchResults, 500);

return () => {
active = false;
clearTimeout(timerId);
};
}, [searchTerm, selectedRegion]);

// Handle Enter key press
const handleKeyPress = async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
setSearchTerm({ name: searchTerm.name, force: true });
}
};

return (
<Autocomplete
id="search-autocomplete"
options={searchResults.length > 0 ? formatNames(searchResults) : [inputValue]}
getOptionLabel={(option) => {
if (option && typeof option === 'object' && option.name) {
return option.segment ? `${option.name} (${option.segment})` : option.name;
}
if (option && typeof option === 'string') {
return option;
}
return '';
}}
freeSolo
open={isDropdownOpen}
onOpen={() => {
if (searchResults.length > 0) {
setIsDropdownOpen(true);
}
}}
onClose={() => {
setIsDropdownOpen(false);
}}
value={searchTerm.name}
onChange={async (event, newValue) => {
if (!newValue) {
setSearchTerm({ name: '', force: false });
return;
}
const selectedItem = searchResults.find((region) => region.id === newValue.id);
const region = await fetchRegion(selectedItem.id, selectedHierarchyId);
const newRegion = {
id: region.id,
name: region.name,
info: region.info,
hasSubregions: region.hasSubregions,
};
setSelectedRegion(newRegion);
setIsDropdownOpen(false);
}}
inputValue={searchTerm.name}
onInputChange={(event, newInputValue) => {
if (newInputValue.length === 0) {
setIsDropdownOpen(false);
return;
}
// find the region with the matching name
const matchingRegion = searchResults.find((region) => region.name === newInputValue);
setInputValue(matchingRegion);
setSearchTerm({
name: matchingRegion ? matchingRegion.name : newInputValue,
force: false,
});
}}
onKeyPress={handleKeyPress}
renderInput={(params) => (
<TextField
label="Search Regions"
variant="outlined"
InputProps={{
inputProps: {
...params.inputProps,
},
...params.InputProps,
}}
ref={params.InputProps.ref}
inputRef={params.inputRef}
fullWidth
/>
)}
/>
);
}

export default Search;
Loading