Skip to content

Commit

Permalink
Enhanced search: Display which entry points matches the query. (#261)
Browse files Browse the repository at this point in the history
Displayed which section matches the search query, and added a short sentence where the query matches.
Added a message pointing to the Discourse group when no search results were found.
Fixed the bug where the sort order returns to alphabetical when the search query got deleted.
  • Loading branch information
AhmedBasem20 authored Aug 31, 2023
1 parent b9d1075 commit 321f2e8
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 30 deletions.
32 changes: 29 additions & 3 deletions aiida-registry-app/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,6 @@ td {

.submenu-entry {
width:90%;
min-height:140px;
padding:10px;
margin: 20px;
padding-left: 40px;
Expand Down Expand Up @@ -440,12 +439,29 @@ padding-right: 5px;
min-height: 56px;
}

input[type='text'] {
.input-container {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: large;
flex: 1;
display: flex;
}

.input-container input[type="text"] {
flex: 1;
border: none;
outline: none;
font-size: large;
}

.enter-symbol {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 20px;
color: lightgray;
opacity: 0;
}

/* Styles for the suggestions list */
Expand All @@ -462,6 +478,16 @@ padding-right: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 99;
}
.dropdown-search {
display: none;
font-size: 20px;
margin: auto;
width: inherit;
background-color: white;
border: 1px solid #ccc;
cursor: pointer;
min-height: 50px;
}

.suggestion-item {
padding: 8px;
Expand Down
24 changes: 21 additions & 3 deletions aiida-registry-app/src/Components/Details.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import jsonData from '../plugins_metadata.json'
import base64Icon from '../base64Icon';

import { useEffect } from 'react';
import Markdown from 'markdown-to-jsx';

const entrypointtypes = jsonData["entrypointtypes"]
Expand All @@ -12,8 +12,26 @@ const currentPath = import.meta.env.VITE_PR_PREVIEW_PATH || "/aiida-registry/";

function Details({pluginKey}) {
const value = plugins[pluginKey];
window.scrollTo(0, 0);
document.documentElement.style.scrollBehavior = 'smooth';
useEffect(() => {
window.scrollTo(0, 0);
document.documentElement.style.scrollBehavior = 'smooth';
const scrollToAnchor = () => {
const hash = window.location.hash;

if (hash) {
//Add a space to the url and remove it again, this trick allows to scroll to the specified section.
let cur = window.location.href
window.location.href = cur+' ';
window.location.href = cur;
const targetSection = document.getElementById(hash);
if (targetSection) {
targetSection.scrollIntoView();
}
}
};

scrollToAnchor();
}, []);
return (
<>

Expand Down
123 changes: 99 additions & 24 deletions aiida-registry-app/src/Components/MainIndex.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,28 @@ import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SearchIcon from '@mui/icons-material/Search';
import KeyboardReturnIcon from '@mui/icons-material/KeyboardReturn';
import Fuse from 'fuse.js'
import { extractSentenceAroundKeyword } from './utils'

const globalsummary = jsonData["globalsummary"]
const plugins = jsonData["plugins"]
const status_dict = jsonData["status_dict"]
const length = Object.keys(plugins).length;
const currentPath = import.meta.env.VITE_PR_PREVIEW_PATH || "/aiida-registry/";


//The search context enables accessing the search query and the plugins data among different components.
//The search context enables accessing the search query, search status, and search results among different components.
const SearchContext = createContext();

const useSearchContext = () => useContext(SearchContext);

export const SearchContextProvider = ({ children }) => {
const [searchQuery, setSearchQuery] = useState('');
const [sortedData, setSortedData] = useState(plugins);
const [searchResults, setSearchResults] = useState();
const [isSearchSubmitted, setIsSearchSubmitted] = useState(false);
return (
<SearchContext.Provider value={{ searchQuery, setSearchQuery, sortedData, setSortedData}}>
<SearchContext.Provider value={{ searchQuery, setSearchQuery, searchResults, setSearchResults, isSearchSubmitted, setIsSearchSubmitted }}>
{children}
</SearchContext.Provider>
);
Expand Down Expand Up @@ -61,14 +65,22 @@ function preparePluginsForSearch(plugins) {
const pluginsListForSearch = preparePluginsForSearch(plugins);

function Search() {
const { searchQuery, setSearchQuery, sortedData, setSortedData } = useSearchContext();
const { searchQuery, setSearchQuery, setSearchResults, isSearchSubmitted, setIsSearchSubmitted } = useSearchContext();
// Update searchQuery when input changes
const handleSearch = (searchQuery) => {
setSearchQuery(searchQuery);
if (searchQuery == "") {
setSortedData(plugins)
}
document.querySelector(".suggestions-list").style.display = "block";
document.querySelector(".dropdown-search").style.display = "block";
if (searchQuery == "" || isSearchSubmitted == true) {
setIsSearchSubmitted(false);
document.querySelector(".dropdown-search").style.display = "none";
}
// Hide the Enter symbol when the input is empty
const enterSymbol = document.querySelector('.enter-symbol');
if (enterSymbol) {
enterSymbol.style.opacity = searchQuery ? '1' : '0';
}

}
//Create a fuce instance for searching the provided keys.
const fuse = new Fuse(pluginsListForSearch, {
Expand All @@ -78,22 +90,16 @@ function Search() {
threshold: 0.1,
includeMatches: true,
})
let searchRes = fuse.search(searchQuery)
console.log(searchRes)
const suggestions = searchRes.map((item) => item.item.name); //get the list searched plugins
const resultObject = {};

//Convert the search results array to object
searchRes.forEach(item => {
resultObject[item.item.name] = plugins[item.item.name];
});
let searchResults = fuse.search(searchQuery)

//Update the sortedData state with the search results
//Update the searchResults state with the search results
const handleSubmit = (e) => {
e.preventDefault();
if (searchQuery) {
setSortedData(resultObject);
setSearchResults(searchResults);
setIsSearchSubmitted(true);
document.querySelector(".suggestions-list").style.display = "none";
document.querySelector(".dropdown-search").style.display = "none";
}
};

Expand All @@ -103,15 +109,32 @@ function Search() {
<div className="search">
<form className="search-form">
<button style={{fontSize:'20px', minWidth:'90px', backgroundColor:'white', border: '1px solid #ccc', borderRadius: '4px'}} onClick={(e) => {handleSubmit(e);}}><SearchIcon /></button>
<div className='input-container'>
<input type="text" placeholder="Search for plugins" value={searchQuery} label = "search" onChange={(e) => handleSearch(e.target.value)} />
<KeyboardReturnIcon className='enter-symbol' />
</div>
</form>
{/* Display the list of suggestions */}
<ul className="suggestions-list">
{suggestions.map((suggestion) => (
<Link to={`/${suggestion}`}><li key={suggestion} className="suggestion-item">
{suggestion}
</li></Link>
))}
{searchResults.slice(0,3).map((suggestion) => (
<>
<Link to={`/${suggestion.item.name}`}><h3 key={suggestion.item.name} className="suggestion-item">
{suggestion.item.name} </h3></Link>
<ul>
{/* Filter by object means to filter the entry points keys where we need to highlight and redirect.
As entry points keys = ['entry_points', 'ep_group', 'ep_name'] while other keys are strings.
*/}
{suggestion.matches.filter(match => typeof match.key == 'object').slice(0,1).map((match) => (
<>
<Link to={`/${suggestion.item.name}#${match.key[1]}.${match.key[2]}`}><li key={match.key} className="suggestion-item">
{match.key[2]} </li></Link>
<SearchResultSnippet match_value={match.value} />
</>
))}
</ul>
</>
))}
<button className='dropdown-search' onClick={(e) => {handleSubmit(e);}}> Search</button>
</ul>
</div>
</>
Expand All @@ -120,8 +143,9 @@ function Search() {


export function MainIndex() {
const { searchQuery, setSearchQuery, sortedData, setSortedData } = useSearchContext();
const { searchQuery, setSearchQuery, searchResults, isSearchSubmitted, setIsSearchSubmitted } = useSearchContext();
const [sortOption, setSortOption] = useState('commits'); //Available options: 'commits', 'release', and 'alpha'.
const [sortedData, setSortedData] = useState(plugins);

useEffect(() => {
document.documentElement.style.scrollBehavior = 'auto';
Expand Down Expand Up @@ -174,6 +198,8 @@ export function MainIndex() {

const handleSort = (option) => {
setSortOption(option);
setSearchQuery('');
setIsSearchSubmitted(false);


let sortedPlugins = {};
Expand Down Expand Up @@ -235,6 +261,39 @@ export function MainIndex() {
</Box>
</div>

{isSearchSubmitted === true ? (
<>
<h2>Showing {searchResults.length} pages matching the search query.</h2>
{searchResults.length === 0 && (
<div>
<h3 className='submenu-entry' style={{textAlign:'center', color:'black'}}>Can't find what you're looking for?<br/>
Join the AiiDA community on Discourse and request a plugin <a href='https://aiida.discourse.group/new-topic?title=Request%20for%20Plugin...&category=community/plugin-requests' target="_blank">here.</a></h3>
</div>
)}
{searchResults.map((suggestion) => (
<>
<div className='submenu-entry'>
<Link to={`/${suggestion.item.name}`}><h3 key={suggestion.item.name} className="suggestion-item">
{suggestion.item.name}
</h3></Link>
<ul>

{suggestion.matches.filter(match => typeof match.key == 'object').map((match) => (
<>
<Link to={`/${suggestion.item.name}#${match.key[1]}.${match.key[2]}`}><li key={match.key} className="suggestion-item">
{match.key[2]}
</li></Link>
<SearchResultSnippet match_value={match.value} />
</>
))}
</ul>
</div>
</>
))}
</>

):(
<>
{Object.entries(sortedData).map(([key, value]) => (
<div className='submenu-entry' key={key}>
<Link to={`/${key}`}><h2 style={{display:'inline'}}>{key} </h2></Link>
Expand Down Expand Up @@ -303,7 +362,23 @@ export function MainIndex() {

</div>
))}
</>
)}
</div>
</main>
);
}

function SearchResultSnippet({match_value}) {
const {searchQuery} = useSearchContext();
const [before, matchedText, after] = extractSentenceAroundKeyword(match_value, searchQuery);
return (
<>
{before != null && (
<p>{before}
<span style={{backgroundColor:'yellow'}}>{matchedText}</span>
{after}...</p>
)}
</>
)
}
59 changes: 59 additions & 0 deletions aiida-registry-app/src/Components/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Find the sentence where the search query mentioned.
* @param {string} matchedEntryPoint Entry point data dumped as string.
* @param {string} keyword search query.
* @returns {Array} List of three strings, before matched word, matched word, and after matched word.
*/
export function extractSentenceAroundKeyword(matchedEntryPoint, keyword) {
const jsonObject = JSON.parse(matchedEntryPoint);
let matchingSentences = [];

//Recursively loop through the object until finding a string value.
function processKeyValuePairs(key, value) {
if (typeof value === "string" && value.toLowerCase().includes(keyword.toLowerCase())) {
return value.trim();

} else if (typeof value === "object") {
for (const innerKey in value) {
const processedValue = processKeyValuePairs(innerKey, value[innerKey]);
//If the string is part of an array (innerKey is a number),
//get the previous or next line to add more context.
if (innerKey > 0 && typeof processedValue == 'string') {
matchingSentences.push(key + ': ' + value[(innerKey - '1').toString()] + ' ' + processedValue);
} else if (innerKey == 0 && value.length > 1 && typeof processedValue == 'string') {
matchingSentences.push(key + ': ' + processedValue + ' ' + value['1']);
} else if (typeof processedValue == 'string') {
matchingSentences.push(innerKey + ': ' + processedValue);
}
}
}
}

for (const key in jsonObject) {
processKeyValuePairs(key, jsonObject[key]);
}

//The matchingSentences array now contains a list of matching sentences.
//We display only the first matching sentence in the list.
const matchingSentence = matchingSentences[0];
let contextArray = [];
try {
const lowercaseMatchingSentence = matchingSentence.toLowerCase();
const lowercaseKeyword = keyword.toLowerCase();

const indexOfKeyword = lowercaseMatchingSentence.indexOf(lowercaseKeyword);

//This splitting helps in highlighting the keyword.
if (indexOfKeyword !== -1) {
const beforeKeyword = matchingSentence.substring(0, indexOfKeyword);
const keyword = matchingSentence.substring(indexOfKeyword, indexOfKeyword + lowercaseKeyword.length);
const afterKeyword = matchingSentence.substring(indexOfKeyword + lowercaseKeyword.length);

contextArray = [beforeKeyword, keyword, afterKeyword];
}
} catch (error) {
contextArray = [null, null, null];
}

return contextArray
}

0 comments on commit 321f2e8

Please sign in to comment.