app/Nearline.tsx (280 lines of code) (raw):

import React, { useContext, useEffect, useState } from "react"; import { Redirect, RouteComponentProps } from "react-router"; import VidispineSearchDoc, { SearchOrderValue, } from "./vidispine/search/VidispineSearch"; import { VidispineItem } from "./vidispine/item/VidispineItem"; import axios from "axios"; import { VError } from "ts-interface-checker"; import SearchResultsPane from "./Frontpage/SearchResultsPane"; import VidispineSearchForm from "./Nearline/VidispineSearchForm"; import { Button, Grid, makeStyles, Typography } from "@material-ui/core"; import { FacetCountResponse, validateFacetResponse, } from "./vidispine/search/FacetResponse"; import FacetDisplays from "./Frontpage/FacetDisplays"; import VidispineContext from "./Context/VidispineContext"; import { SystemNotifcationKind, SystemNotification, } from "@guardian/pluto-headers"; require("./dark.css"); require("./FrontPageLayout.css"); interface FrontpageComponentProps extends RouteComponentProps { itemLimit?: number; projectIdToLoad?: number; } const useStyles = makeStyles({ statusArea: { margin: "12px", }, }); const NearlineComponent: React.FC<FrontpageComponentProps> = (props) => { const [currentSearch, setCurrentSearch] = useState< VidispineSearchDoc | undefined >( new VidispineSearchDoc( undefined, new Map([["gnm_nearline_id", [""]]]), new Map([["Asset", new Map()]]) ) ); const [hideSearchBox, setHideSearchBox] = useState<boolean>( !props.location.pathname.startsWith("/search") ); const [hideFacets, setHideFacets] = useState<boolean>(true); const [searching, setSearching] = useState<boolean>(false); const [lastError, setLastError] = useState<string | undefined>(undefined); const [pageSize, setPageSize] = useState<number>(20); const [itemLimit, setItemLimit] = useState<number>(props.itemLimit ?? 500); const [moreItemsAvailable, setMoreItemsAvailable] = useState(false); const [loadFrom, setLoadFrom] = useState<number>(0); const [itemList, setItemList] = useState<VidispineItem[]>([]); const [totalItems, setTotalItems] = useState<number>(0); const [facetList, setFacetList] = useState<FacetCountResponse[]>([]); const [previousItemsAvailable, setPreviousItemsAvailable] = useState(false); const [redirectToItem, setRedirectToItem] = useState<string | undefined>( undefined ); const [projectTitle, setProjectTitle] = useState<string | undefined>( undefined ); const classes = useStyles(); const vidispineContext = useContext(VidispineContext); /** * validates a given vidispine item, returning either a VidispineItem or undefined if it fails to validate. * error message is output to console if it fails. * @param content object to verify */ const validateVSItem = (content: any) => { try { return new VidispineItem(content); } catch (err) { if (err instanceof VError) { const vErr = err as VError; const itemId = content.id ?? "(no id given)"; console.error( `Item ${itemId} failed metadata validation at ${vErr.path}: ${vErr.message}` ); SystemNotification.open( SystemNotifcationKind.Error, "This item contains invalid data and can't be displayed" ); } else { console.error("Unexpected error: ", err); } return undefined; } }; /** * puts terms to request the default graph set onto the provided SearchDoc * @param toSearch VidispineSearchDoc to add them to */ const addDefaultFacets = (toSearch: VidispineSearchDoc) => { //FIXME: should load these in from config or from some kind of user profile! return toSearch .addFacet("mediaType", true) .addFacet("gnm_category", true) .addFacet("gnm_newswire_provider", true); }; /** * Puts the project id to load onto the provided SearchDoc * @param toSearch VidispineSearchDoc to add them to */ const addProject = (toSearch: VidispineSearchDoc) => { return toSearch.withSearchTerm("gnm_containing_projects", [ String(props.projectIdToLoad), ]); }; /** * load the next page of results as per the currently set search. * this "recurses" to pull in subsequent pages, after a short delay * to allow the ui to update */ const loadNextPage = async ( startAt?: number, previousItemList?: VidispineItem[] ) => { setSearching(true); const fromParam = startAt ?? loadFrom + itemList.length; const shouldCount: boolean = fromParam == 0; const searchUrl = `${ vidispineContext?.baseUrl }/API/item?content=metadata&first=${ fromParam + 1 }&number=${pageSize}&count=${shouldCount}`; try { let initialSearch = currentSearch ?? new VidispineSearchDoc(); if (props.projectIdToLoad != 0) { initialSearch = addProject(initialSearch); } const payload = fromParam == 0 ? addDefaultFacets(initialSearch) : initialSearch; const serverContent = await axios.put( searchUrl, payload.setOrdering("created", SearchOrderValue.descending), { headers: { "Content-Type": "application/json", Accept: "application/json", }, } ); if (serverContent.data.hits) { //we only take the "hits" field on the first page setTotalItems(serverContent.data.hits); } if (serverContent.data.facet && serverContent.data.facet.length > 0) { setFacetList( serverContent.data.facet .map(validateFacetResponse) .filter( (maybeFacet: FacetCountResponse | undefined) => maybeFacet !== undefined ) ); setHideFacets(false); } if (serverContent.data.item) { if (serverContent.data.item.length < pageSize) { setMoreItemsAvailable(false); } else { setMoreItemsAvailable(true); } if (loadFrom == 0) { setPreviousItemsAvailable(false); } else { setPreviousItemsAvailable(true); } //only add in items that validate as VidispineItem. Items that don't are logged to console. const existingList = previousItemList ?? itemList; const updatedItemList = existingList.concat( serverContent.data.item .map(validateVSItem) .filter((item: VidispineItem | undefined) => item !== undefined) ); setItemList(updatedItemList); if ( updatedItemList.length < itemLimit && serverContent.data.item.length ) { //allow the javascript engine to process state updates above before recursing on to next page. window.setTimeout( () => loadNextPage(updatedItemList.length + loadFrom, updatedItemList), 200 ); } else { setSearching(false); return; //no more to do } } } catch (err) { console.error("Could not load content from server: ", err); setLastError("Please see console for error details"); } }; const getProjectTitle = async (projectId: number | undefined) => { try { const project = await axios.get( `../pluto-core/api/project/` + projectId, { headers: { Authorization: `Bearer ${localStorage.getItem( "pluto:access-token" )}`, }, } ); setProjectTitle(project.data.result.title); } catch (error) { console.error("Unable to fetch project title: ", error); setProjectTitle("Could not load project title"); } }; /** * re-run the search when the searchdoc changes * */ useEffect(() => { console.log("Search updated, reloading..."); setLastError(undefined); //give the above a chance to execute before we kick off the download window.setTimeout(() => loadNextPage(loadFrom, []), 100); }, [currentSearch, loadFrom]); if (redirectToItem) return <Redirect to={`/item/${redirectToItem}`} />; //FIXME: there must be a better way of doing dynamic grid resizing than this! const makeClassName = () => { let className = ["front-page-container"]; if (hideSearchBox && hideFacets) { className = className.concat("hide-both"); } else if (hideSearchBox) { className = className.concat("hide-left"); } else if (hideFacets) { className = className.concat("hide-right"); } return className.join(" "); }; const resultsContainerRef = React.createRef<HTMLDivElement>(); return ( <div className={makeClassName()}> <div className="status-container"> <Grid container className={classes.statusArea}> {searching ? ( <Grid item> <Typography>Loading...</Typography> </Grid> ) : ( <Grid item> {props.projectIdToLoad != 0 ? ( <Typography>Items from project: {projectTitle}</Typography> ) : null} </Grid> )} </Grid> </div> <div className="form-container"> <VidispineSearchForm currentSearch={currentSearch} onUpdated={(newSearch) => { console.log("Got new search doc: ", newSearch); setCurrentSearch(newSearch); setLoadFrom(0); }} onHideToggled={(newValue) => setHideSearchBox(newValue)} isHidden={hideSearchBox} projectIdToLoad={props.projectIdToLoad} moreItemsAvailable={moreItemsAvailable} onLoadMoreClicked={() => { setLoadFrom((currentValue) => currentValue + 500); }} previousItemsAvailable={previousItemsAvailable} onLoadPreviousClicked={() => { setLoadFrom((currentValue) => currentValue - 500); }} searching={searching} /> </div> <div className="results-container" ref={resultsContainerRef}> <SearchResultsPane results={itemList} parentRef={resultsContainerRef} onItemClicked={(itemId) => { console.log("You clicked ", itemId); props.history.push(`/item/${itemId}`); }} /> </div> <div className="facets-container"> <FacetDisplays isHidden={hideFacets} onHideToggled={(newValue) => setHideFacets(newValue)} facets={facetList} > <Typography style={{ marginLeft: "auto", marginRight: "0.6em", textAlign: "right", }} > Showing item {itemList.length > 0 ? loadFrom + 1 : 0} -{" "} {itemList.length + loadFrom} of {totalItems} </Typography> </FacetDisplays> </div> </div> ); }; export type { FrontpageComponentProps }; export default NearlineComponent;