X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/4b956bd2e3bcccdeb808df7391d135659cf85b94..2a7fd99c212c33a1ec9911f8529fa5afc59a7bb2:/src/components/collection-panel-files/collection-panel-files.tsx diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index a6bce0f23e..fb36ebce54 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -5,53 +5,138 @@ import React from 'react'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import { CustomizeTableIcon } from 'components/icon/icon'; -import { ListItemIcon, StyleRulesCallback, Theme, WithStyles, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core'; +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 } from 'components/icon/icon'; +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 { - items: any; isWritable: boolean; - isLoading: boolean; - tooManyFiles: boolean; - onUploadDataClick: () => void; + 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; - loadFilesFunc: () => void; currentItemUuid: any; dispatch: Function; collectionPanelFiles: any; collectionPanel: any; } -type CssRules = "wrapper" | "row" | "leftPanel" | "rightPanel" | "pathPanel" | "pathPanelItem" | "rowName" | "listItemIcon" | "rowActive" | "pathPanelMenu" | "rowSelection"; +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', - margin: '0.5rem', + 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: { - paddingTop: '6px', - paddingBottom: '6px', + display: 'inline-flex', + flexDirection: 'column', + justifyContent: 'center' + }, + searchWrapper: { + display: 'inline-block', + marginBottom: '1rem', + marginLeft: '1rem', + }, + searchWrapperHidden: { + width: '0px' }, rowSelection: { padding: '0px', @@ -60,47 +145,100 @@ const styles: StyleRulesCallback = (theme: Theme) => ({ color: `${theme.palette.primary.main} !important`, }, listItemIcon: { - marginTop: '2px', + display: 'inline-flex', + flexDirection: 'column', + justifyContent: 'center' }, pathPanelMenu: { float: 'right', marginTop: '-15px', }, pathPanel: { - padding: '1rem', - marginBottom: '1rem', + 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: '30%', - padding: '1rem', + flex: 0, + padding: '0 1rem 1rem', marginRight: '1rem', - 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%)', + 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: '70%', + flex: '50%', padding: '1rem', - 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%)', + 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' + }, }); -export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({ +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, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props; +}))((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 webdavClient = new WebDAV({ + baseURL: config.keepWebServiceUrl, + headers: { + Authorization: `Bearer ${apiToken}` + }, + }); const webDAVRequestConfig: WebDAVRequestConfig = { headers: { @@ -109,13 +247,18 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState }; const parentRef = React.useRef(null); - const [path, setPath]: any = React.useState([]); - const [pathData, setPathData]: any = React.useState({}); + 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({}); @@ -123,73 +266,109 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState } }, [props.currentItemUuid]); - React.useEffect(() => { - if (rightKey && !pathData[rightKey] && !isLoading) { - webdavClient.propfind(`c=${rightKey}`, webDAVRequestConfig) - .then((request) => { - if (request.responseXML != null) { - const result: any = extractFilesData(request.responseXML); - const sortedResult = result.sort((n1: any, n2: any) => n1.name > n2.name ? 1 : -1); - const newPathData = { ...pathData, [rightKey]: sortedResult }; - setPathData(newPathData); - setIsLoading(false); + 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); } - }); - } else { - setTimeout(() => setIsLoading(false), 100); - } - }, [path, pathData, webdavClient, webDAVRequestConfig, rightKey, isLoading, collectionPanelFiles]); - const leftData = pathData[leftKey]; - const rightData = pathData[rightKey]; + pathPromise[key] = true; - React.useEffect(() => { - webdavClient.propfind(`c=${rightKey}`, webDAVRequestConfig) - .then((request) => { - if (request.responseXML != null) { + 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 = result.sort((n1: any, n2: any) => n1.name > n2.name ? 1 : -1); - const newPathData = { ...pathData, [rightKey]: sortedResult }; - setPathData(newPathData); - setIsLoading(false); + 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 }; } - }); - }, [collectionPanel.item]); + return {}; + }).reduce((prev, next) => { + return { ...next, ...prev }; + }, {}); + setPathData((state) => ({ ...state, ...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) { - setCollectionFiles(rightData, false)(dispatch); + const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); + setCollectionFiles(filtered, false)(dispatch); } - }, [rightData, 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) { + if (!elem || !elem.dataset) { return; } const { id } = elem.dataset; - const item: any = { id, data: rightData.find((elem) => elem.id === id) }; + + const item: any = { + id, + data: rightData.find((elem) => elem.id === id), + }; if (id) { onItemMenuOpen(event, item, isWritable); } }, - [onItemMenuOpen, isWritable, rightData] - ); + [onItemMenuOpen, isWritable, rightData]); React.useEffect(() => { let node = null; - if (parentRef && parentRef.current) { + if (parentRef?.current) { node = parentRef.current; (node as any).addEventListener('contextmenu', handleRightClick); } @@ -204,36 +383,47 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState 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) { + if (elem && elem.dataset && !isCheckbox && !isMoreButton) { const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset; - setIsLoading(true); - if (breadcrumbPath) { const index = path.indexOf(breadcrumbPath); - setPath([...path.slice(0, index + 1)]); + setPath((state) => ([...state.slice(0, index + 1)])); } - if (parentPath) { + if (parentPath && type === 'directory') { if (path.length > 1) { path.pop() } - setPath([...path, parentPath]); + setPath((state) => ([...state, parentPath])); } if (subfolderPath && type === 'directory') { - setPath([...path, subfolderPath]); + 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'); } } @@ -242,8 +432,16 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState 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] + [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps ); const getItemIcon = React.useCallback( @@ -270,9 +468,7 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState const getActiveClass = React.useCallback( (name) => { - const index = path.indexOf(name); - - return index === (path.length - 1) ? classes.rowActive : null + return path[path.length - 1] === name ? classes.rowActive : null; }, [path, classes] ); @@ -281,63 +477,119 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState (ev, isWritable) => { props.onOptionsMenuOpen(ev, isWritable); }, - [props.onOptionsMenuOpen] + [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps ); - return ( -
-
- { - path.map((p: string, index: number) => - {index === 0 ? 'Home' : p} /  - ) - } - - onOptionsMenuOpen(ev, isWritable)}> - + 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
+ }} +
+ :
} +
-
-
- { - leftData && !!leftData.length ? - leftData.filter(({ type }) => type === 'directory').map(({ name, id, type }: any) =>
{getItemIcon(type, getActiveClass(name))}
{name}
-
) :
Loading...
- } +
+
+
-
- { - rightData && !isLoading ? - rightData.map(({ name, id, type }: any) =>
- { + onUploadDataClick(rightKey === leftKey ? undefined : rightKey); + }} + variant='contained' color='primary' size='small'> + + Upload data + } +
{ 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} + {getItemIcon(type, null)} +
+ {name} +
+ + { formatFileSize(size) } + + + + + +
-
) :
Loading...
- } + } }
+ :
This collection is empty
+ }}
+ :
+ +
}
- ); -})); +
}));