From 4b956bd2e3bcccdeb808df7391d135659cf85b94 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Daniel=20Kuty=C5=82a?= Date: Fri, 20 Aug 2021 21:59:00 +0200 Subject: [PATCH] 17585: First initial impl MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła --- .gitignore | 1 + src/common/service-provider.ts | 35 ++ .../collection-panel-files.tsx | 409 +++++++++++++----- .../collection-panel-files2.tsx | 138 ++++++ src/index.tsx | 5 + src/models/collection-file.ts | 4 +- .../collection-panel-action.ts | 2 +- .../collection-panel-files-actions.ts | 8 + 8 files changed, 497 insertions(+), 105 deletions(-) create mode 100644 src/common/service-provider.ts create mode 100644 src/components/collection-panel-files/collection-panel-files2.tsx diff --git a/.gitignore b/.gitignore index 8273cc9f..8ce5c380 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /coverage /cypress/videos /cypress/screenshots +/cypress/downloads # production /build diff --git a/src/common/service-provider.ts b/src/common/service-provider.ts new file mode 100644 index 00000000..1362de9a --- /dev/null +++ b/src/common/service-provider.ts @@ -0,0 +1,35 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +class ServicesProvider { + + private static instance: ServicesProvider; + + private services; + + private constructor() {} + + public static getInstance(): ServicesProvider { + if (!ServicesProvider.instance) { + ServicesProvider.instance = new ServicesProvider(); + } + + return ServicesProvider.instance; + } + + public setServices(newServices): void { + if (!this.services) { + this.services = newServices; + } + } + + public getServices() { + if (!this.services) { + throw "Please check if services have been set in the index.ts before the app is initiated"; + } + return this.services; + } +} + +export default ServicesProvider.getInstance(); diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index 41182482..a6bce0f2 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -3,16 +3,21 @@ // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; -import { TreeItem, TreeItemStatus } from 'components/tree/tree'; -import { FileTreeData } from 'components/file-tree/file-tree-data'; -import { FileTree } from 'components/file-tree/file-tree'; -import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button, Tooltip, CircularProgress } from '@material-ui/core'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; import { CustomizeTableIcon } from 'components/icon/icon'; -import { DownloadIcon } from 'components/icon/icon'; -import { SearchInput } from '../search-input/search-input'; +import { ListItemIcon, StyleRulesCallback, Theme, WithStyles, withStyles, Tooltip, IconButton, Checkbox } 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 { setCollectionFiles } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions'; export interface CollectionPanelFilesProps { - items: Array>; + items: any; isWritable: boolean; isLoading: boolean; tooManyFiles: boolean; @@ -24,115 +29,315 @@ export interface CollectionPanelFilesProps { onCollapseToggle: (id: string, status: TreeItemStatus) => void; onFileClick: (id: string) => void; loadFilesFunc: () => void; - currentItemUuid?: string; + currentItemUuid: any; + dispatch: Function; + collectionPanelFiles: any; + collectionPanel: any; } -export type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel' | 'cardHeaderContent' | 'cardHeaderContentTitle'; +type CssRules = "wrapper" | "row" | "leftPanel" | "rightPanel" | "pathPanel" | "pathPanelItem" | "rowName" | "listItemIcon" | "rowActive" | "pathPanelMenu" | "rowSelection"; -const styles: StyleRulesCallback = theme => ({ - root: { - paddingBottom: theme.spacing.unit, - height: '100%' - }, - cardSubheader: { - paddingTop: 0, - paddingBottom: 0, - minHeight: 8 * theme.spacing.unit, +const styles: StyleRulesCallback = (theme: Theme) => ({ + wrapper: { + display: 'flex', }, - cardHeaderContent: { + row: { display: 'flex', - paddingRight: 2 * theme.spacing.unit, - justifyContent: 'space-between', + margin: '0.5rem', + cursor: 'pointer', + "&:hover": { + backgroundColor: 'rgba(0, 0, 0, 0.08)', + } + }, + rowName: { + paddingTop: '6px', + paddingBottom: '6px', }, - cardHeaderContentTitle: { - paddingLeft: theme.spacing.unit, - paddingTop: 2 * theme.spacing.unit, - paddingRight: 2 * theme.spacing.unit, + rowSelection: { + padding: '0px', }, - nameHeader: { - marginLeft: '75px' + rowActive: { + color: `${theme.palette.primary.main} !important`, }, - fileSizeHeader: { - marginRight: '65px' + listItemIcon: { + marginTop: '2px', }, - uploadIcon: { - transform: 'rotate(180deg)' + pathPanelMenu: { + float: 'right', + marginTop: '-15px', }, - button: { - marginRight: -theme.spacing.unit, - marginTop: '8px' + pathPanel: { + padding: '1rem', + marginBottom: '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%)', }, - centeredLabel: { - fontSize: '0.875rem', - textAlign: 'center' + leftPanel: { + flex: '30%', + padding: '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%)', }, + rightPanel: { + flex: '70%', + 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%)', + }, + pathPanelItem: { + cursor: 'pointer', + } + }); -export const CollectionPanelFilesComponent = ({ onItemMenuOpen, onSearchChange, onOptionsMenuOpen, onUploadDataClick, classes, - isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles) => { - const { useState, useEffect } = React; - const [searchValue, setSearchValue] = useState(''); - - useEffect(() => { - onSearchChange(searchValue); - }, [onSearchChange, searchValue]); - - return ( - - Files - - +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; + 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]: any = React.useState([]); + const [pathData, setPathData]: any = React.useState({}); + const [isLoading, setIsLoading] = React.useState(false); + + const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join('/'); + const rightKey = path.join('/'); + + React.useEffect(() => { + if (props.currentItemUuid) { + setPathData({}); + setPath([props.currentItemUuid]); + } + }, [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); + } + }); + } else { + setTimeout(() => setIsLoading(false), 100); + } + }, [path, pathData, webdavClient, webDAVRequestConfig, rightKey, isLoading, collectionPanelFiles]); + + const leftData = pathData[leftKey]; + const rightData = pathData[rightKey]; + + React.useEffect(() => { + 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); + } + }); + }, [collectionPanel.item]); + + React.useEffect(() => { + if (rightData) { + setCollectionFiles(rightData, false)(dispatch); + } + }, [rightData, dispatch]); + + const handleRightClick = React.useCallback( + (event) => { + event.preventDefault(); + + let elem = event.target; + + while (elem && elem.dataset && !elem.dataset.item) { + elem = elem.parentNode; } - className={classes.cardSubheader} - classes={{ action: classes.button }} - action={<> - {isWritable && - } - {!tooManyFiles && - - onOptionsMenuOpen(ev, isWritable)}> - - - } - - } /> - {tooManyFiles - ?
- File listing may take some time, please click to browse: -
- : <> - - - Name - - - File size - - - {isLoading - ?
- :
- onItemMenuOpen(ev, item, isWritable)} - {...treeProps} />
} - + + if (!elem) { + 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 && parentRef.current) { + node = parentRef.current; + (node as any).addEventListener('contextmenu', handleRightClick); } -
); -}; -export const CollectionPanelFiles = withStyles(styles)(CollectionPanelFilesComponent); + return () => { + if (node) { + (node as any).removeEventListener('contextmenu', handleRightClick); + } + }; + }, [parentRef, handleRightClick]); + + const handleClick = React.useCallback( + (event: any) => { + let isCheckbox = false; + let elem = event.target; + + if (elem.type === 'checkbox') { + isCheckbox = true; + } + + while (elem && elem.dataset && !elem.dataset.item) { + elem = elem.parentNode; + } + + if (elem && elem.dataset && !isCheckbox) { + const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset; + + setIsLoading(true); + + if (breadcrumbPath) { + const index = path.indexOf(breadcrumbPath); + setPath([...path.slice(0, index + 1)]); + } + + if (parentPath) { + if (path.length > 1) { + path.pop() + } + + setPath([...path, parentPath]); + } + + if (subfolderPath && type === 'directory') { + setPath([...path, subfolderPath]); + } + } + + if (isCheckbox) { + const { id } = elem.dataset; + const item = collectionPanelFiles[id]; + props.onSelectionToggle(event, item); + } + }, + [path, setPath, collectionPanelFiles] + ); + + 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) => { + const index = path.indexOf(name); + + return index === (path.length - 1) ? classes.rowActive : null + }, + [path, classes] + ); + + const onOptionsMenuOpen = React.useCallback( + (ev, isWritable) => { + props.onOptionsMenuOpen(ev, isWritable); + }, + [props.onOptionsMenuOpen] + ); + + return ( +
+
+ { + path.map((p: string, index: number) => + {index === 0 ? 'Home' : p} /  + ) + } + + onOptionsMenuOpen(ev, isWritable)}> + + + +
+
+
+ { + 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) =>
+   + {getItemIcon(type, null)}
+ {name} +
+
) :
Loading...
+ } +
+
+
+ ); +})); diff --git a/src/components/collection-panel-files/collection-panel-files2.tsx b/src/components/collection-panel-files/collection-panel-files2.tsx new file mode 100644 index 00000000..41182482 --- /dev/null +++ b/src/components/collection-panel-files/collection-panel-files2.tsx @@ -0,0 +1,138 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { TreeItem, TreeItemStatus } from 'components/tree/tree'; +import { FileTreeData } from 'components/file-tree/file-tree-data'; +import { FileTree } from 'components/file-tree/file-tree'; +import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button, Tooltip, CircularProgress } from '@material-ui/core'; +import { CustomizeTableIcon } from 'components/icon/icon'; +import { DownloadIcon } from 'components/icon/icon'; +import { SearchInput } from '../search-input/search-input'; + +export interface CollectionPanelFilesProps { + items: Array>; + isWritable: boolean; + isLoading: boolean; + tooManyFiles: boolean; + onUploadDataClick: () => 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?: string; +} + +export type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel' | 'cardHeaderContent' | 'cardHeaderContentTitle'; + +const styles: StyleRulesCallback = theme => ({ + root: { + paddingBottom: theme.spacing.unit, + height: '100%' + }, + cardSubheader: { + paddingTop: 0, + paddingBottom: 0, + minHeight: 8 * theme.spacing.unit, + }, + cardHeaderContent: { + display: 'flex', + paddingRight: 2 * theme.spacing.unit, + justifyContent: 'space-between', + }, + cardHeaderContentTitle: { + paddingLeft: theme.spacing.unit, + paddingTop: 2 * theme.spacing.unit, + paddingRight: 2 * theme.spacing.unit, + }, + nameHeader: { + marginLeft: '75px' + }, + fileSizeHeader: { + marginRight: '65px' + }, + uploadIcon: { + transform: 'rotate(180deg)' + }, + button: { + marginRight: -theme.spacing.unit, + marginTop: '8px' + }, + centeredLabel: { + fontSize: '0.875rem', + textAlign: 'center' + }, +}); + +export const CollectionPanelFilesComponent = ({ onItemMenuOpen, onSearchChange, onOptionsMenuOpen, onUploadDataClick, classes, + isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles) => { + const { useState, useEffect } = React; + const [searchValue, setSearchValue] = useState(''); + + useEffect(() => { + onSearchChange(searchValue); + }, [onSearchChange, searchValue]); + + return ( + + Files + + + } + className={classes.cardSubheader} + classes={{ action: classes.button }} + action={<> + {isWritable && + } + {!tooManyFiles && + + onOptionsMenuOpen(ev, isWritable)}> + + + } + + } /> + {tooManyFiles + ?
+ File listing may take some time, please click to browse: +
+ : <> + + + Name + + + File size + + + {isLoading + ?
+ :
+ onItemMenuOpen(ev, item, isWritable)} + {...treeProps} />
} + + } +
); +}; + +export const CollectionPanelFiles = withStyles(styles)(CollectionPanelFilesComponent); diff --git a/src/index.tsx b/src/index.tsx index 2d62194b..6ad22a55 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ import { createServices } from "services/services"; import { MuiThemeProvider } from '@material-ui/core/styles'; import { CustomTheme } from 'common/custom-theme'; import { fetchConfig } from 'common/config'; +import servicesProvider from 'common/service-provider'; import { addMenuActionSet, ContextMenuKind } from 'views-components/context-menu/context-menu'; import { rootProjectActionSet } from "views-components/context-menu/action-sets/root-project-action-set"; import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set"; @@ -136,6 +137,10 @@ fetchConfig() } } }); + + // be sure this is initiated before the app starts + servicesProvider.setServices(services); + const store = configureStore(history, services, config); store.subscribe(initListener(history, store, services, config)); diff --git a/src/models/collection-file.ts b/src/models/collection-file.ts index 3951d272..91008d1f 100644 --- a/src/models/collection-file.ts +++ b/src/models/collection-file.ts @@ -52,7 +52,7 @@ export const createCollectionFile = (data: Partial): CollectionF ...data }); -export const createCollectionFilesTree = (data: Array) => { +export const createCollectionFilesTree = (data: Array, joinParents: Boolean = true) => { const directories = data.filter(item => item.type === CollectionFileType.DIRECTORY); directories.sort((a, b) => a.path.localeCompare(b.path)); const files = data.filter(item => item.type === CollectionFileType.FILE); @@ -60,7 +60,7 @@ export const createCollectionFilesTree = (data: Array setNode({ children: [], id: item.id, - parent: getParentId(item), + parent: joinParents ? getParentId(item) : '', value: item, active: false, selected: false, diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts index 813fe446..7401c64a 100644 --- a/src/store/collection-panel/collection-panel-action.ts +++ b/src/store/collection-panel/collection-panel-action.ts @@ -40,7 +40,7 @@ export const loadCollectionPanel = (uuid: string, forceReload = false) => dispatch(resourcesActions.SET_RESOURCES([collection])); if (collection.fileCount <= COLLECTION_PANEL_LOAD_FILES_THRESHOLD && !getState().collectionPanel.loadBigCollections) { - dispatch(loadCollectionFiles(collection.uuid)); + // dispatch(loadCollectionFiles(collection.uuid)); } return collection; }; diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts index 3217d014..71e1f6e8 100644 --- a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts +++ b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts @@ -4,6 +4,7 @@ import { unionize, ofType, UnionOf } from "common/unionize"; import { Dispatch } from "redux"; +import servicesProvider from 'common/service-provider'; import { CollectionFilesTree, CollectionFileType, createCollectionFilesTree } from "models/collection-file"; import { ServiceRepository } from "services/services"; import { RootState } from "../../store"; @@ -31,6 +32,13 @@ export type CollectionPanelFilesAction = UnionOf (dispatch: any) => { + const tree = createCollectionFilesTree(files, joinParents); + const sorted = sortFilesTree(tree); + const mapped = mapTreeValues(servicesProvider.getServices().collectionService.extendFileURL)(sorted); + dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(mapped)); +}; + export const loadCollectionFiles = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PANEL_LOAD_FILES)); -- 2.30.2