frontend/app/DeletedItems/DeletedItems.tsx (214 lines of code) (raw):
import React, {useState, useEffect, useRef} from "react";
import {RouteComponentProps} from "react-router";
import {Helmet} from "react-helmet";
import AdminContainer from "../admin/AdminContainer";
import {makeStyles, Snackbar} from "@material-ui/core";
import BoxSizing from "../common/BoxSizing";
import NewTreeView from "../browse/NewTreeView";
import axios from "axios";
import {AdvancedSearchDoc, ArchiveEntry, BulkDeleteConfirmationResponse, CollectionNamesResponse} from "../types";
import {formatError} from "../common/ErrorViewComponent";
import MuiAlert from "@material-ui/lab/Alert";
import BrowsePathSummary from "../browse/BrowsePathSummary";
import DeletedItemSummary from "./DeletedItemSummary";
import DeletedItemsTable from "./DeletedItemsTable";
import {loadDeletedItemStream} from "./DeletedItemsStreamConsumer";
const useStyles = makeStyles((theme)=>({
browserWindow: {
display: "grid",
gridTemplateColumns: "repeat(20, 5%)",
gridTemplateRows: "[top] 150px [info-area] auto [bottom]",
height: "95vh"
},
pathSelector: {
gridColumnStart: 1,
gridColumnEnd: 4,
gridRowStart: "top",
gridRowEnd: "bottom",
borderRight: "1px solid white",
padding: "1em",
overflowX: "hidden",
overflowY: "auto",
},
summaryInfoArea: {
gridColumnStart:6,
gridColumnEnd: -4,
gridRowStart: "top",
gridRowEnd: "info-area",
padding: "1em",
overflow: "hidden"
},
dataView: {
gridRowStart: "info-area",
gridRowEnd: "bottom",
padding: "1em",
overflowX: "hidden",
overflowY: "auto",
height: "95%"
}
}));
const DeletedItemsComponent:React.FC<RouteComponentProps> = (props) => {
const [currentCollection, setCurrentCollection] = useState("");
const [collectionNames, setCollectionNames] = useState<string[]>([]);
const [currentPath, setCurrentPath] = useState<string|undefined>(undefined);
const [reloadCounter, setReloadCounter] = useState(0);
const [entries, setEntries] = useState<ArchiveEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchDoc, setSearchDoc] = useState<AdvancedSearchDoc>({collection:""});
const [lastError, setLastError] = useState<string|undefined>(undefined);
const [successMessage, setSuccessMessage] = useState<string|undefined>(undefined);
const [showingAlert, setShowingAlert] = useState(false);
const [leftDividerPos, setLeftDividerPos] = useState(4);
const [awaitingStopLoading, setAwaitingStopLoading] = useState(false);
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const awaitingStopLoadingRef = useRef<boolean>();
awaitingStopLoadingRef.current = awaitingStopLoading; //in order to access the up-to-date state var in a callback we must use a mutable ref. See https://stackoverflow.com/questions/57847594/react-hooks-accessing-up-to-date-state-from-within-a-callback
const classes = useStyles();
useEffect(()=>{
refreshCollectionNames();
}, [])
useEffect(()=>{
setSearchDoc(Object.assign({}, searchDoc, {collection: currentCollection}))
}, [currentCollection]);
const loadFromFresh = ()=>{
console.log("DeletedItemsTable loading in data");
setEntries([]);
setLoading(true);
return loadDeletedItemStream(currentCollection, currentPath, searchDoc, receivedNewData, 50);
}
useEffect(()=>{
console.log("searchDoc or currentPath changed, loading is ", loading, " awaitingStopLoading is ", awaitingStopLoading);
if(loading) {
setAwaitingStopLoading(true);
setAwaitingRefresh(true);
} else {
loadFromFresh();
}
}, [searchDoc, currentPath]);
/**
* if awaitingStopLoading goes to false, then we should trigger a reload
*/
useEffect(()=>{
if(!awaitingStopLoading && awaitingRefresh) {
setAwaitingRefresh(false);
window.setTimeout(()=>{
loadFromFresh();
}, 1000); //schedule a reload once we have terminated the current operation
}
}, [awaitingStopLoading]);
const receivedNewData = (entry:ArchiveEntry|undefined, isDone:boolean) => {
if(entry) {
setEntries((prev)=>prev.concat(entry));
}
if(isDone) {
setAwaitingStopLoading(false);
setLoading(false);
return true;
}
if(awaitingStopLoadingRef.current) {
console.log("Stopping due to reload...");
setAwaitingStopLoading(false);
setLoading(false);
return false;
} else {
return true;
}
}
const refreshCollectionNames = async () => {
try {
const result = await axios.get<CollectionNamesResponse>("/api/browse/collections");
setCollectionNames(result.data.entries);
setLoading(false); //this must happen BEFORE we change the current collection, otherwise the data table won't update
if(currentCollection=="" && result.data.entries.length>0) setCurrentCollection(result.data.entries[0]);
} catch (err) {
console.error("Could not refresh collection names: ", err);
setLastError(formatError(err, false));
setShowingAlert(true);
}
}
const showComponentError = (errString:string)=>{
setLastError(errString);
setShowingAlert(true);
}
const closeAlert = () => {
setSuccessMessage(undefined);
setLastError(undefined);
setShowingAlert(false);
}
const removalRequested = async (itemId:string)=> {
try {
await axios.delete(`/api/deleted/${encodeURIComponent(currentCollection)}/${encodeURIComponent(itemId)}`)
setEntries((prev)=>prev.filter(entry=>entry.id!==itemId));
setSuccessMessage("Removed tombstone");
setShowingAlert(true);
} catch(err) {
console.error("Could not perform item deletion: ", err);
setLastError(formatError(err, false));
setShowingAlert(true);
}
}
const removeAllRequested = async ()=> {
let args = "";
if(currentPath) {
args = `?prefix=${encodeURIComponent(currentPath)}`;
}
try {
const response = await axios.delete<BulkDeleteConfirmationResponse>(`/api/deleted/${encodeURIComponent(currentCollection)}${args}`, {
data: searchDoc,
headers: {
"Content-Type": "application/json"
}
});
setSuccessMessage(`Removed ${response.data.deletedCount} tombstones in ${response.data.timeTaken} ms`);
setShowingAlert(true);
loadFromFresh();
} catch(err) {
console.error("Bulk removal failed: ", err);
setLastError(formatError(err, false));
setShowingAlert(true);
}
}
return <>
<Helmet>
<title>Deleted items {currentCollection ? `in ${currentCollection}` : ""} - ArchiveHunter</title>
</Helmet>
<Snackbar open={showingAlert && (lastError!=undefined|| successMessage!=undefined)} onClose={closeAlert} autoHideDuration={8000}>
<>
<MuiAlert severity="error" onClose={closeAlert} style={{display: lastError ? "inherit" : "none"}}>{lastError}</MuiAlert>
<MuiAlert severity="info" onClose={closeAlert} style={{display: successMessage ? "inherit" : "none"}}>{successMessage}</MuiAlert>
</>
</Snackbar>
<AdminContainer {...props}>
<div className={classes.browserWindow}>
<div className={classes.summaryInfoArea} style={{gridColumnStart: leftDividerPos+2, gridColumnEnd: -1}}>
<DeletedItemSummary collectionName={currentCollection}
searchDoc={searchDoc}
path={currentPath}
parentIsLoading={loading}
refreshCb={()=>setReloadCounter(prevState => prevState+1)}
goToRootCb={()=>setCurrentPath("")}
requestRemoveAll={removeAllRequested}
loadingNotifiction={`Loaded ${entries.length} items`}
onError={showComponentError}
/>
</div>
<div className={classes.pathSelector} style={{gridColumnEnd: leftDividerPos}}>
<BoxSizing justify="right"
onRightClicked={()=>setLeftDividerPos((prev)=>prev+1)}
onLeftClicked={()=>setLeftDividerPos((prev)=>prev-1)}
/>
<NewTreeView currentCollection={currentCollection}
collectionList={collectionNames}
collectionDidChange={(newCollection)=>setCurrentCollection(newCollection)}
pathSelectionChanged={(newpath)=>setCurrentPath(newpath)}
onError={showComponentError}/>
</div>
<div className={classes.dataView} style={{gridColumnStart: leftDividerPos, gridColumnEnd: -1}}>
<DeletedItemsTable entries={entries}
requestDelete={removalRequested}
currentlyLoading={loading}
requestOpen={(itemId)=>props.history.push(`/item/${encodeURIComponent(itemId)}`)}
/>
</div>
</div>
</AdminContainer>
</>
}
export default DeletedItemsComponent;