// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 import React from "react"; import classNames from "classnames"; import { connect } from "react-redux"; import { FixedSizeList } from "react-window"; import AutoSizer from "react-virtualized-auto-sizer"; import servicesProvider from "common/service-provider"; import { DownloadIcon, MoreHorizontalIcon, MoreVerticalIcon } from "components/icon/icon"; import { SearchInput } from "components/search-input/search-input"; import { ListItemIcon, StyleRulesCallback, Theme, WithStyles, withStyles, Tooltip, IconButton, Checkbox, CircularProgress, Button, } from "@material-ui/core"; import { FileTreeData } from "../file-tree/file-tree-data"; import { TreeItem, TreeItemStatus } from "../tree/tree"; import { RootState } from "store/store"; import { WebDAV, WebDAVRequestConfig } from "common/webdav"; import { AuthState } from "store/auth/auth-reducer"; import { extractFilesData } from "services/collection-service/collection-service-files-response"; import { DefaultIcon, DirectoryIcon, FileIcon, BackIcon, SidePanelRightArrowIcon } from "components/icon/icon"; import { setCollectionFiles } from "store/collection-panel/collection-panel-files/collection-panel-files-actions"; import { sortBy } from "lodash"; import { formatFileSize } from "common/formatters"; import { getInlineFileUrl, sanitizeToken } from "views-components/context-menu/actions/helpers"; import { extractUuidKind, ResourceKind } from "models/resource"; export interface CollectionPanelFilesProps { isWritable: boolean; onUploadDataClick: (targetLocation?: string) => void; onSearchChange: (searchValue: string) => void; onItemMenuOpen: (event: React.MouseEvent, item: TreeItem, isWritable: boolean) => void; onOptionsMenuOpen: (event: React.MouseEvent, isWritable: boolean) => void; onSelectionToggle: (event: React.MouseEvent, item: TreeItem) => void; onCollapseToggle: (id: string, status: TreeItemStatus) => void; onFileClick: (id: string) => void; currentItemUuid: any; dispatch: Function; collectionPanelFiles: any; collectionPanel: any; } type CssRules = | "backButton" | "backButtonHidden" | "pathPanelPathWrapper" | "uploadButton" | "uploadIcon" | "moreOptionsButton" | "moreOptions" | "loader" | "wrapper" | "dataWrapper" | "row" | "rowEmpty" | "leftPanel" | "rightPanel" | "pathPanel" | "pathPanelItem" | "rowName" | "listItemIcon" | "rowActive" | "pathPanelMenu" | "rowSelection" | "leftPanelHidden" | "leftPanelVisible" | "searchWrapper" | "searchWrapperHidden"; const styles: StyleRulesCallback = (theme: Theme) => ({ wrapper: { display: "flex", minHeight: "600px", color: "rgba(0,0,0,0.87)", fontSize: "0.875rem", fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', fontWeight: 400, lineHeight: "1.5", letterSpacing: "0.01071em", }, backButton: { color: "#00bfa5", cursor: "pointer", float: "left", }, backButtonHidden: { display: "none", }, dataWrapper: { minHeight: "500px", }, row: { display: "flex", marginTop: "0.5rem", marginBottom: "0.5rem", cursor: "pointer", "&:hover": { backgroundColor: "rgba(0, 0, 0, 0.08)", }, }, rowEmpty: { top: "40%", width: "100%", textAlign: "center", position: "absolute", }, loader: { top: "50%", left: "50%", marginTop: "-15px", marginLeft: "-15px", position: "absolute", }, rowName: { display: "inline-flex", flexDirection: "column", justifyContent: "center", }, searchWrapper: { display: "inline-block", marginBottom: "1rem", marginLeft: "1rem", }, searchWrapperHidden: { width: "0px", }, rowSelection: { padding: "0px", }, rowActive: { color: `${theme.palette.primary.main} !important`, }, listItemIcon: { display: "inline-flex", flexDirection: "column", justifyContent: "center", }, pathPanelMenu: { float: "right", marginTop: "-15px", }, pathPanel: { padding: "0.5rem", marginBottom: "0.5rem", backgroundColor: "#fff", boxShadow: "0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelPathWrapper: { display: "inline-block", }, leftPanel: { flex: 0, padding: "0 1rem 1rem", marginRight: "1rem", whiteSpace: "nowrap", position: "relative", backgroundColor: "#fff", boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, leftPanelVisible: { opacity: 1, flex: "50%", animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`, }, leftPanelHidden: { opacity: 0, flex: "initial", padding: "0", marginRight: "0", }, "@keyframes animateVisible": { "0%": { opacity: 0, flex: "initial", }, "100%": { opacity: 1, flex: "50%", }, }, rightPanel: { flex: "50%", padding: "1rem", paddingTop: "0.5rem", marginTop: "-0.5rem", position: "relative", backgroundColor: "#fff", boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelItem: { cursor: "pointer", }, uploadIcon: { transform: "rotate(180deg)", }, uploadButton: { float: "right", }, moreOptionsButton: { width: theme.spacing.unit * 3, height: theme.spacing.unit * 3, marginRight: theme.spacing.unit, marginTop: "auto", marginBottom: "auto", justifyContent: "center", }, moreOptions: { position: "absolute", }, }); const pathPromise = {}; export const CollectionPanelFiles = withStyles(styles)( connect((state: RootState) => ({ auth: state.auth, collectionPanel: state.collectionPanel, collectionPanelFiles: state.collectionPanelFiles, }))((props: CollectionPanelFilesProps & WithStyles & { auth: AuthState }) => { const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props; const { apiToken, config } = props.auth; const webdavClient = new WebDAV({ baseURL: config.keepWebServiceUrl, headers: { Authorization: `Bearer ${apiToken}`, }, }); const webDAVRequestConfig: WebDAVRequestConfig = { headers: { Depth: "1", }, }; const parentRef = React.useRef(null); const [path, setPath] = React.useState([]); const [pathData, setPathData] = React.useState({}); const [isLoading, setIsLoading] = React.useState(false); const [leftSearch, setLeftSearch] = React.useState(""); const [rightSearch, setRightSearch] = React.useState(""); const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join("/"); const rightKey = path.join("/"); const leftData = pathData[leftKey] || []; const rightData = pathData[rightKey]; React.useEffect(() => { if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) { setPathData({}); setPath([props.currentItemUuid]); } }, [props.currentItemUuid]); const fetchData = (keys, ignoreCache = false) => { const keyArray = Array.isArray(keys) ? keys : [keys]; Promise.all( keyArray .filter(key => !!key) .map(key => { const dataExists = !!pathData[key]; const runningRequest = pathPromise[key]; if (ignoreCache || (!dataExists && !runningRequest)) { if (!isLoading) { setIsLoading(true); } pathPromise[key] = true; return webdavClient.propfind(`c=${key}`, webDAVRequestConfig); } return Promise.resolve(null); }) .filter(promise => !!promise) ) .then(requests => { const newState = requests .map((request, index) => { if (request && request.responseXML != null) { const key = keyArray[index]; const result: any = extractFilesData(request.responseXML); const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => { if (n1.type === "directory" && n2.type !== "directory") { return -1; } if (n1.type !== "directory" && n2.type === "directory") { return 1; } return 0; }); return { [key]: sortedResult }; } return {}; }) .reduce((prev, next) => { return { ...next, ...prev }; }, {}); setPathData(state => ({ ...state, ...newState })); }, () => { // Nothing to do }) .finally(() => { setIsLoading(false); keyArray.forEach(key => delete pathPromise[key]); }); }; React.useEffect(() => { if (rightKey) { fetchData(rightKey); setLeftSearch(""); setRightSearch(""); } }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps const currentPDH = (collectionPanel.item || {}).portableDataHash; React.useEffect(() => { if (currentPDH) { fetchData([leftKey, rightKey], true); } }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps React.useEffect(() => { if (rightData) { const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); setCollectionFiles(filtered, false)(dispatch); } }, [rightData, dispatch, rightSearch]); const handleRightClick = React.useCallback( event => { event.preventDefault(); let elem = event.target; while (elem && elem.dataset && !elem.dataset.item) { elem = elem.parentNode; } if (!elem || !elem.dataset) { return; } const { id } = elem.dataset; const item: any = { id, data: rightData.find(elem => elem.id === id), }; if (id) { onItemMenuOpen(event, item, isWritable); } }, [onItemMenuOpen, isWritable, rightData] ); React.useEffect(() => { let node = null; if (parentRef?.current) { node = parentRef.current; (node as any).addEventListener("contextmenu", handleRightClick); } return () => { if (node) { (node as any).removeEventListener("contextmenu", handleRightClick); } }; }, [parentRef, handleRightClick]); const handleClick = React.useCallback( (event: any) => { let isCheckbox = false; let isMoreButton = false; let elem = event.target; if (elem.type === "checkbox") { isCheckbox = true; } // The "More options" button click event could be triggered on its // internal graphic element. else if ( (elem.dataset && elem.dataset.id === "moreOptions") || (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === "moreOptions") ) { isMoreButton = true; } while (elem && elem.dataset && !elem.dataset.item) { elem = elem.parentNode; } if (elem && elem.dataset && !isCheckbox && !isMoreButton) { const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset; if (breadcrumbPath) { const index = path.indexOf(breadcrumbPath); setPath(state => [...state.slice(0, index + 1)]); } if (parentPath && type === "directory") { if (path.length > 1) { path.pop(); } setPath(state => [...state, parentPath]); } if (subfolderPath && type === "directory") { setPath(state => [...state, subfolderPath]); } if (elem.dataset.id && type === "file") { const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id); const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item); const fileUrl = sanitizeToken( getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl), true ); window.open(fileUrl, "_blank"); } } if (isCheckbox) { const { id } = elem.dataset; const item = collectionPanelFiles[id]; props.onSelectionToggle(event, item); } if (isMoreButton) { const { id } = elem.dataset; const item: any = { id, data: rightData.find(elem => elem.id === id), }; onItemMenuOpen(event, item, isWritable); } }, [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps ); const getItemIcon = React.useCallback( (type: string, activeClass: string | null) => { let Icon = DefaultIcon; switch (type) { case "directory": Icon = DirectoryIcon; break; case "file": Icon = FileIcon; break; } return ( ); }, [classes] ); const getActiveClass = React.useCallback( name => { return path[path.length - 1] === name ? classes.rowActive : null; }, [path, classes] ); const onOptionsMenuOpen = React.useCallback( (ev, isWritable) => { props.onOptionsMenuOpen(ev, isWritable); }, [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps ); return (
{path.map((p: string, index: number) => ( {index === 0 ? "Home" : p} /  ))}
{ onOptionsMenuOpen(ev, isWritable); }} >
1 ? classes.leftPanelVisible : classes.leftPanelHidden)} data-cy="collection-files-left-panel" > 1 ? classes.backButton : classes.backButtonHidden} > setPath(state => [...state.slice(0, state.length - 1)])}>
1 ? classes.searchWrapper : classes.searchWrapperHidden}>
{leftData ? ( {({ height, width }) => { const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1); return !!filtered.length ? ( {({ index, style }) => { const { id, type, name } = filtered[index]; return (
{getItemIcon(type, getActiveClass(name))}
{name}
{getActiveClass(name) ? ( ) : null}
); }}
) : (
No directories available
); }}
) : (
)}
{isWritable && ( )}
{rightData && !isLoading ? ( {({ height, width }) => { const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); return !!filtered.length ? ( {({ index, style }) => { const { id, type, name, size } = filtered[index]; return (
  {getItemIcon(type, null)}
{name}
{formatFileSize(size)}
); }}
) : (
This collection is empty
); }}
) : (
)}
); }) );