// 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 { CustomizeTableIcon, DownloadIcon, MoreOptionsIcon } 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'; 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(); webdavClient.defaults.baseURL = config.keepWebServiceUrl; webdavClient.defaults.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) { 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({ ...pathData, ...newState }); }) .finally(() => { setIsLoading(false); keyArray.forEach(key => delete pathPromise[key]); }); }; React.useEffect(() => { if (rightKey) { fetchData(rightKey); setLeftSearch(''); setRightSearch(''); } }, [rightKey]); // 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([...path.slice(0, index + 1)]); } if (parentPath && type === 'directory') { if (path.length > 1) { path.pop() } setPath([...path, parentPath]); } if (subfolderPath && type === 'directory') { setPath([...path, 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([...path.slice(0, path.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
}}
:
}
}));