frontend/app/ProjectDeliverablesComponent.tsx (588 lines of code) (raw):

import React, { useCallback, useEffect, useState, useMemo } from "react"; import axios from "axios"; import Cookies from "js-cookie"; import LocationLink from "./LocationLink"; import { Helmet } from "react-helmet"; import { Breadcrumb } from "@guardian/pluto-headers"; import { Button, IconButton, makeStyles, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TableSortLabel, Typography, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Input, TextField, Collapse, Tooltip, Link, } from "@material-ui/core"; import DeleteIcon from "@material-ui/icons/Delete"; import EditIcon from "@material-ui/icons/Edit"; import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp"; import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown"; import KeyboardArrowRightIcon from "@material-ui/icons/KeyboardArrowRight"; import { RouteComponentProps, useHistory, useLocation, useParams, } from "react-router-dom"; import DeliverableTypeSelector from "./DeliverableTypeSelector"; import { getProjectDeliverables, deleteProjectDeliverable, } from "./api-service"; import MasterList from "./MasterList/MasterList"; import DeliverableRow from "./ProjectDeliverables/DeliverableRow"; import BeforeUnloadComponent from "react-beforeunload-component"; import { Check, CloudUpload } from "@material-ui/icons"; import UploaderMain from "./DeliverableUploader/UploaderMain"; import MuiDialogTitle from "@material-ui/core/DialogTitle"; import CloseIcon from "@material-ui/icons/Close"; import { createStyles, Theme, withStyles, WithStyles, } from "@material-ui/core/styles"; import CreateBundleDialogContent from "./CreateBundle/CreateBundleDialogContent"; import CustomDialogTitle from "./CustomDialogTitle"; interface HeaderTitles { label: string; key?: keyof Deliverable; } declare var deploymentRootPath: string; declare var vidispineBaseUri: string; const tableHeaderTitles: HeaderTitles[] = [ { label: "Selector" }, { label: "Filename", key: "filename" }, { label: "Version" }, { label: "Size", key: "size" }, { label: "Duration" }, { label: "Date ingested", key: "ingest_complete_dt" }, { label: "Type", key: "type" }, { label: "Last modified", key: "modified_dt" }, { label: "Import progress" }, { label: "Action/status", key: "status" }, ]; const useStyles = makeStyles({ table: { maxWidth: "100%", }, buttonContainer: { display: "grid", gridTemplateColumns: "repeat(10,10%)", }, buttons: { marginRight: "0.4rem", marginBottom: "1.2rem", marginTop: "0.625rem", }, adoptAssetInput: { gridColumnStart: -3, gridColumnEnd: -1, marginBottom: "1em", marginLeft: "0.2em", }, addAssetButton: { gridColumnStart: -4, gridColumnEnd: -3, marginRight: "0.4rem", marginBottom: "1.2rem", marginTop: "0.625rem", }, centralMessage: { gridColumnStart: 3, gridColumnEnd: 8, margin: "auto", }, sectionHeader: { display: "inline", marginRight: "1em", }, collapsableTableRow: { "& td": { paddingBottom: 0, paddingTop: 0, }, "& .expandable-cell": { width: "100%", }, }, root: { "& > *": { borderBottom: "unset", }, }, }); const pageSizeOptions = [10, 50, 100, 250]; type SortDirection = "asc" | "desc"; const ProjectDeliverablesComponent: React.FC<RouteComponentProps> = () => { // React Router const history = useHistory(); // @ts-ignore const { search } = useLocation(); // @ts-ignore const { projectid } = useParams(); // React state const [deliverables, setDeliverables] = useState<Deliverable[]>([]); const [loading, setLoading] = useState<boolean>(false); const [selectedIDs, setSelectedIDs] = useState<bigint[]>([]); const [typeOptions, setTypeOptions] = useState<DeliverableTypes>({}); const [openDialog, setOpenDialog] = useState<boolean>(false); const [parentBundleInfo, setParentBundleInfo] = useState<Project | undefined>( undefined ); const [assetToAdd, setAssetToAdd] = useState<string>(""); const [adoptInProgress, setAdoptInProgress] = useState<boolean>(false); const [centralMessage, setCentralMessage] = useState<string>(""); const [blockRoute, setBlockRoute] = useState(false); const [showingUploader, setShowingUploader] = useState(false); const [haveExistingBundle, setHaveExistingBundle] = useState(true); const [order, setOrder] = useState<SortDirection>("asc"); const [orderBy, setOrderBy] = useState<keyof Deliverable>("filename"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState<number>(pageSizeOptions[2]); // Material-UI const classes = useStyles(); const doRefresh = async () => { try { const rescanResult = await axios({ method: "POST", url: `/api/bundle/scan?project_id=${projectid}`, headers: { "X-CSRFToken": Cookies.get("csrftoken"), }, }); const projectDeliverables = await getProjectDeliverables( projectid, orderBy, order ); setDeliverables(projectDeliverables); await loadStartedStatus(); } catch (err) { if (err.response) { //server returned a bad status code if (err.response.data.detail) return setCentralMessage(err.response.data.detail); else return setCentralMessage(`Error code ${err.response.status}`); } else if (err.request) { setCentralMessage(`Could not contact server: ${err.message}`); } else { setCentralMessage(err.message); } } }; const handleChangePage = ( _event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, newPage: number ) => { setPage(newPage); }; const handleChangeRowsPerPage = ( event: React.ChangeEvent<HTMLInputElement> ) => { const rows: number = +event.target.value; setRowsPerPage(rows); setPage(0); }; // Avoid a layout jump when reaching the last page with empty rows. const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - deliverables.length) : 0; const loadRecord = useCallback(async () => { setLoading(true); try { const projectDeliverables = await getProjectDeliverables( projectid, orderBy, order ); setDeliverables(projectDeliverables); await loadStartedStatus(); } catch (err) { if (err.response) { //server returned a bad status code if (err.response.data.detail) return setCentralMessage(err.response.data.detail); else return setCentralMessage(`Error code ${err.response.status}`); } else if (err.request) { setCentralMessage(`Could not contact server: ${err.message}`); } else { setCentralMessage(err.message); } } }, [order, orderBy]); const loadDelTypes = async () => { try { const response = await axios.get("/api/typeslist"); return setTypeOptions(response.data); } catch (err) { console.error("Could not load in deliverable types: ", err); setCentralMessage("Could not load in deliverable types"); } }; const loadParentBundle = async () => { try { if (projectid == "-1") { return setParentBundleInfo({ pk: -1, commission_id: -1, created: "2020-11-01T00:00:00Z", local_open_uri: "", local_path: "", name: "Invalid deliverables", pluto_core_project_id: -1, project_id: "-1", }); } else { const response = await axios.get(`/api/bundle/byproject/${projectid}`); return setParentBundleInfo(response.data); } } catch (err) { if (err.response.status == 404) { console.log("bundle does not exist for project ", projectid); setHaveExistingBundle(false); } else { console.error("Could not load in parent bundle data: ", err); setCentralMessage("Could not load in parent bundle data"); } } }; const deleteSelectedDeliverables = async () => { try { await deleteProjectDeliverable(projectid, selectedIDs); setDeliverables( deliverables.filter( (deliverable) => !selectedIDs.includes(deliverable.id) ) ); setSelectedIDs([]); } catch (error) { console.error(`failed to delete deliverable`, error); setCentralMessage("Could not delete deliverable"); } }; const closeDialog = () => { setOpenDialog(false); }; const doAdoptItem = async () => { setAdoptInProgress(true); setCentralMessage(""); try { const result = await axios.post( `/api/bundle/adopt?project_id=${projectid}&vs_id=${assetToAdd}`, {}, { headers: { "X-CSRFToken": Cookies.get("csrftoken"), }, } ); setCentralMessage(`Attached ${assetToAdd} succeessfully`); setAssetToAdd(""); setAdoptInProgress(false); return loadRecord(); } catch (error) { //TODO: improve error handling. the endpoint returns 409=>item already exists, 404=?item not found, 400=>invalid argument, 500=>server error. console.error("failed to perform adoption: ", error); setCentralMessage( `Could not attach ${assetToAdd}, please contact MultimediaTech` ); } }; const getSelectedDeliverables = (): Deliverable[] => deliverables.filter((deliverable) => selectedIDs.includes(deliverable.id)); console.log("getSelectedDeliverables"); useEffect(() => { loadDelTypes(); }, []); useEffect(() => { const performLoad = async () => { await Promise.all([loadRecord(), loadParentBundle()]); }; if (haveExistingBundle) { performLoad().catch((err) => { console.error("Could not load in bundle data: ", err); }); } }, [haveExistingBundle]); const loadStartedStatus = async () => { try { const response = await axios.get( `/api/bundle/started?project_id=${projectid}` ); if (response.data.ingests_started == true) { setBlockRoute(false); } else { setBlockRoute(true); } } catch (err) { console.error("Could not load if bundle has started ingesting: ", err); } }; const handleClose = () => { setShowingUploader(false); }; const newBundleCreated = async () => { setHaveExistingBundle(true); setCentralMessage(""); doRefresh(); }; const sortByColumn = (property: keyof Deliverable) => ( _event: React.MouseEvent<unknown> ) => { const isAsc = orderBy === property && order === "asc"; setOrder(isAsc ? "desc" : "asc"); setOrderBy(property); }; useEffect(() => { loadRecord(); }, [order, orderBy]); const loadRecordCallback = useCallback(loadRecord, []); return ( <> {parentBundleInfo?.name ? ( <Helmet> <title>[{parentBundleInfo.name}] – Deliverables</title> </Helmet> ) : null} <BeforeUnloadComponent blockRoute={blockRoute} ignoreChildrenLinks={true} alertMessage="One or more items are not ingesting. Are you sure you want to leave?" > <div> {parentBundleInfo && projectid != -1 ? ( <> <Breadcrumb projectId={projectid} /> <LocationLink bundleInfo={parentBundleInfo} networkUploadSelected={() => setShowingUploader(true)} /> </> ) : ( <p style={{ fontWeight: "bold", fontFamily: "gnmFontEgypBold", fontSize: "1.8rem", color: "#3f51b5", }} > Unlinked Deliverables </p> )} </div> <Link style={{ cursor: "pointer" }} onClick={() => window.open( `https://sites.google.com/guardian.co.uk/multimedia/final-project-delivery-checklist` ) } > Deliverable Files Checklist </Link> <span className={classes.buttonContainer}> <Button className={classes.buttons} variant="outlined" disabled={projectid === "-1"} onClick={() => doRefresh()} > Refresh </Button> <Button className={classes.buttons} variant="outlined" disabled={selectedIDs.length === 0 || projectid === -1} onClick={() => setOpenDialog(true)} > Delete </Button> <Typography className={classes.centralMessage}> {centralMessage} </Typography> <Button className={classes.addAssetButton} style={{ display: assetToAdd == "" ? "none" : "inherit" }} variant="outlined" disabled={assetToAdd == "" || adoptInProgress} onClick={doAdoptItem} > Add Item </Button> <TextField className={classes.adoptAssetInput} onChange={(evt) => setAssetToAdd(evt.target.value)} value={assetToAdd} label="paste Pluto master or asset ID" InputProps={{ readOnly: adoptInProgress, }} /> </span> <Paper elevation={3}> <TableContainer> <Table className={classes.table}> <TableHead> <TableRow> {tableHeaderTitles.map((entry, idx) => ( <TableCell key={entry.label ? entry.label : idx} sortDirection={orderBy === entry.key ? order : false} > {entry.key ? ( <TableSortLabel active={orderBy === entry.key} direction={order} onClick={sortByColumn(entry.key)} > {entry.label} </TableSortLabel> ) : ( entry.label )} </TableCell> ))} <TableCell /> </TableRow> </TableHead> <TableBody> {deliverables .slice(page * rowsPerPage, (page + 1) * rowsPerPage) .map((del, idx) => ( <DeliverableRow key={del.id.toString()} deliverable={del} classes={classes} typeOptions={typeOptions} setCentralMessage={setCentralMessage} onCheckedUpdated={(isChecked) => isChecked ? setSelectedIDs((prevContent) => prevContent.concat(del.id) ) : setSelectedIDs((prevContent) => prevContent.filter((value) => value !== del.id) ) } parentBundleInfo={parentBundleInfo} onNeedsUpdate={loadRecordCallback} vidispineBaseUri={vidispineBaseUri} openJob={(jobId: string) => { const w = window.open( `/vidispine-jobs/job/${jobId}`, "_blank" ); if (w) w.focus(); }} project_id={projectid} onSyndicationStarted={() => {}} /> ))} {emptyRows > 0 && ( <TableRow style={{ height: 90 * emptyRows }}> <TableCell colSpan={6} /> </TableRow> )} </TableBody> </Table> </TableContainer> <TablePagination rowsPerPageOptions={pageSizeOptions} component="div" count={deliverables.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} /> </Paper> <hr /> <Dialog open={openDialog} onClose={closeDialog} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" > <DialogTitle id="alert-dialog-title"> Delete Deliverable{selectedIDs.length > 1 ? "s" : ""} </DialogTitle> <DialogContent> <DialogContentText id="alert-dialog-description"> Are you sure you want to delete the deliverable {selectedIDs.length > 1 ? "s" : ""}{" "} {getSelectedDeliverables() .map( (selectedDeliverable) => `"${selectedDeliverable.filename}"` ) .join(", ")} ? </DialogContentText> </DialogContent> <DialogActions> <Button variant="contained" onClick={closeDialog}> Cancel </Button> <Button variant="contained" color="secondary" startIcon={<DeleteIcon />} onClick={() => { setOpenDialog(false); deleteSelectedDeliverables(); }} > Delete </Button> </DialogActions> </Dialog> <Dialog open={showingUploader} onClose={() => { doRefresh().catch((err) => { console.error("Could not refresh: ", err); setCentralMessage( "There was an error, please click the Refresh button" ); }); setShowingUploader(false); }} aria-labelled-by="uploader-title" aria-describedby="uploader-desc" > <CustomDialogTitle id="customized-dialog-title" onClose={handleClose}> Upload deliverables to project bundle </CustomDialogTitle> <DialogContent> <UploaderMain projectId={projectid} dropFolder={parentBundleInfo ? parentBundleInfo.local_path : ""} /> </DialogContent> </Dialog> { //if we have no existing bundle, then display a modal dialog prompting the user to create one haveExistingBundle ? undefined : ( <Dialog open={!haveExistingBundle} onClose={() => history.goBack()} aria-labelled-by="create-bundle-title" > <CreateBundleDialogContent projectid={projectid} didComplete={newBundleCreated} /> </Dialog> ) } </BeforeUnloadComponent> </> ); }; export default ProjectDeliverablesComponent;