frontend/app/common/NewSearchComponent.tsx (194 lines of code) (raw):
import React, {useState, useEffect} from "react";
import {AdvancedSearchDoc, ArchiveEntry, ArchiveEntryResponse, ObjectGetResponse, SearchResponse} from "../types";
import axios, {AxiosResponse, CancelToken} from "axios";
import {formatError} from "./ErrorViewComponent";
import {Grid, makeStyles, Typography} from "@material-ui/core";
import EntryView from "../search/EntryView";
interface NewSearchComponentProps {
advancedSearch?: AdvancedSearchDoc; //if advancedSearch is set, then it is preferred over basicQuery
basicQuery?: string; //query string to use if advancedSearch is not set
basicQueryUrl?: string; //supply a full URL for the query if advancedSearch is not set. This overrides basicQuery
pageSize: number;
itemLimit: number;
selectedEntry?: ArchiveEntry;
newlyLightboxed: string[]; //array of item IDs that have been added to lightbox
onEntryClicked: (entry:ArchiveEntry)=>void; //called when an entry is clicked
onErrorOccurred: (errorDescription:string)=>void; //called when a load error occurs, parent should display the error
onLoadingStarted?: ()=>void;
onLoadingFinished?: (loadedData:ArchiveEntry[])=>void;
extraRequiredItemId?: string; //if the container wants to be sure certain items are loaded, given that they exist, put the id here
filterString?: string;
typeString?: string;
typeQuery?: string;
}
const useStyles = makeStyles({
searchResultsContainer: {
overflowY: "auto",
overflowX: "hidden",
height: "100%",
marginLeft: "1.8em",
marginRight: "1.8em"
},
centeredText: {
marginLeft: "auto",
marginRight: "auto"
}
});
/**
* Replacement for SearchComponent and SearchManager. Encapsulates frontend search request and display logic.
* @param props
* @constructor
*/
const NewSearchComponent:React.FC<NewSearchComponentProps> = (props) => {
const [entries, setEntries] = useState<ArchiveEntry[]>([]);
const [loadingInProgress, setLoadingInProgress] = useState(false);
const [cancelToken, setCancelToken] = useState<CancelToken|undefined>(undefined);
const classes = useStyles();
const [localFilter, setLocalFilter] = useState<string>("");
const [localTypeMajor, setLocalTypeMajor] = useState<string>("");
const [localTypeMinor, setLocalTypeMinor] = useState<string>("");
useEffect(()=>{
if((props.filterString) || (props.filterString == "")) {
setLocalFilter(props.filterString);
}
}, [props.filterString]);
useEffect(()=>{
if((props.typeString) && (props.typeString != "Any")) {
setLocalTypeMajor(props.typeString.split('/')[0]);
setLocalTypeMinor(props.typeString.split('/')[1]);
} else {
setLocalTypeMajor("");
setLocalTypeMinor("");
}
}, [props.typeString]);
const makeSearchRequest = (token:CancelToken, startAt: number):Promise<AxiosResponse<SearchResponse>> => {
if(props.advancedSearch) {
return axios.post<SearchResponse>(`/api/search/browser?start=${startAt}&size=${props.pageSize}`, props.advancedSearch, {
cancelToken: token
})
} else if(props.basicQueryUrl) {
//if we already have a query string then append, if we do not then start one
const separator = props.basicQueryUrl.includes("?") ? "&" : "?";
return axios.get<SearchResponse>(props.basicQueryUrl + `${separator}start=${startAt}&length=${props.pageSize}`, {
cancelToken: token
})
} else if(props.basicQuery && props.typeQuery) {
return axios.get<SearchResponse>("/api/search/basic?q=" + encodeURIComponent(props.basicQuery as string) + "&start=" + startAt + "&length=" + props.pageSize + "&mimeMajor=" + encodeURIComponent(props.typeQuery.split('/')[0] as string) + "&mimeMinor=" + encodeURIComponent(props.typeQuery.split('/')[1] as string),
{
cancelToken: token
});
} else {
return new Promise((resolve, reject)=>reject("No query to make"));
}
}
/**
* recursively load in pages from the server. This will recurse until either all results are loaded or
* props.itemLimit is hit.
* @param token cancelToken that will stop the operation. If the cancelToken is activated, then the `entries` state
* var will be blanked.
* @param startAt item number to start at on the server, normally 0.
*/
const loadNextPage:(token:CancelToken, startAt:number)=>Promise<boolean> = async (token:CancelToken, startAt: number)=>{
try {
if(startAt+props.pageSize>props.itemLimit) {
console.log(`Hit the item limit of ${props.itemLimit}, stopping`);
setLoadingInProgress(false);
return true;
}
const results = await makeSearchRequest(token, startAt);
if(results.data.entries.length>0) {
console.log(`Received ${results.data.entries.length} results, loading next page...`)
setEntries((prev)=>prev.concat(results.data.entries));
return loadNextPage(token, startAt+results.data.entries.length);
} else {
console.log("Received 0 results, assuming got to the end of iteration");
setLoadingInProgress(false);
return true;
}
} catch(err) {
if(axios.isCancel(err)) {
console.log("search was cancelled, clearing results");
setEntries([]);
} else if(err=="No query to make") {
} else {
props.onErrorOccurred(formatError(err, false));
}
setLoadingInProgress(false);
return false;
}
}
useEffect(()=>{
if(!loadingInProgress && props.onLoadingFinished) {
props.onLoadingFinished(entries);
}
}, [loadingInProgress, entries]);
/**
* loads in a single extra item by ID and prepends it to the content list.
* invokes the onErrorOccurred callback if it can't load the item for some reason
* @param itemId item ID to load
*/
const loadExtraItem = async (itemId:string) => {
try {
const response = await axios.get<ObjectGetResponse<ArchiveEntry>>(`/api/entry/${itemId}`);
setEntries((prev)=>{
const newEntries = Object.assign([], response.data.entry, prev);
if(props.onLoadingFinished) props.onLoadingFinished(entries);
return newEntries;
});
} catch(err) {
props.onErrorOccurred(formatError(err, false));
}
}
useEffect(()=>{
if(props.extraRequiredItemId && !loadingInProgress) { //only load in when everything else is done
const matchingEntries = entries.filter(entry=>entry.id===props.extraRequiredItemId).length;
if(matchingEntries==0) {
console.log("loading in extra item from ", props.extraRequiredItemId);
loadExtraItem(props.extraRequiredItemId);
} else {
console.log("extra item ", props.extraRequiredItemId, " already loaded in")
}
}
}, [props.extraRequiredItemId, loadingInProgress]);
const indexForFileid = (entryId:string)=>{
for(let i=0;i<entries.length;++i){
console.debug("checking "+entries[i].id+ "against" + entryId);
if(entries[i].id===entryId) return i;
}
console.error("Could not find existing entry for id " + entryId);
return -1;
}
/**
* used to update a specific entry in the list from new data
* @param newEntry the new data to replace
* @param atIndex index to replace it at
* @param entryId the id of the new entry
*/
const updateSearchResults = (newEntry:ArchiveEntry, atIndex: number) => {
setEntries((prevState)=>prevState.slice(0, atIndex).concat([newEntry].concat(prevState.slice(atIndex + 1))));
}
/**
* updates the search view data for a specific item once it has been added to the lightbox.
* Returns a Promise that resolves once the operation is fully completed
* @param entryId entry to update
* @returns {Promise} Promise that resolves once all updates have been done
*/
useEffect(()=>{
const updateNewlyLightboxed = async ()=> {
const promises = props.newlyLightboxed.map((entryId,idx)=>
axios.get<ArchiveEntryResponse>(`/api/entry/${entryId}`)
);
try {
const results = await axios.all(promises);
results.forEach((result) => {
const entryIndex = indexForFileid(result.data.entry.id);
updateSearchResults(result.data.entry, entryIndex);
if(result.data.entry.id===props.selectedEntry?.id) {
props.onEntryClicked(result.data.entry)
}
})
} catch(err) {
console.error("could not update lightboxed entries: ", err);
props.onErrorOccurred(formatError(err, false));
}
}
const timerId = window.setTimeout(()=>updateNewlyLightboxed(), 1000);
return ()=> {
//if we update again before the timeout is completed, then cancel the un-necessary old one and re-wait
window.clearTimeout(timerId);
}
}, [props.newlyLightboxed]);
/**
* when the search parameters change, then load in new data
*/
useEffect(()=>{
const cancelTokenFactory = axios.CancelToken.source();
setCancelToken(cancelTokenFactory.token);
if(props.onLoadingStarted) props.onLoadingStarted();
setLoadingInProgress(true);
setEntries([]);
loadNextPage(cancelTokenFactory.token, 0);
return ()=>{
cancelTokenFactory.cancel("search interrupted");
}
}, [props.advancedSearch, props.basicQuery, props.basicQueryUrl]);
return <Grid container className={classes.searchResultsContainer}>
{
entries.filter(entry => entry.path.toLowerCase().includes(localFilter.toLowerCase())).filter(entry => entry.mimeType.major.includes(localTypeMajor)).filter(entry => entry.mimeType.minor.includes(localTypeMinor)).map((entry,idx)=><EntryView
key={idx}
isSelected={ props.selectedEntry ? props.selectedEntry.id===entry.id : false}
entry={entry}
cancelToken={cancelToken}
itemOpenRequest={props.onEntryClicked}
/>)
}
{
entries.length==0 ? <Typography variant="h3" className={classes.centeredText}>No results</Typography> : null
}
</Grid>
}
export default NewSearchComponent;