From cf83b358f087a87b5ff095d3ed7a8c6920c60ffe Mon Sep 17 00:00:00 2001 From: =?utf8?q?Daniel=20Kuty=C5=82a?= Date: Wed, 24 Jun 2020 22:36:52 +0200 Subject: [PATCH] 14990: added 404 page with wildcard route MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła --- cypress/integration/page-not-found.spec.js | 47 +++++ docker/Dockerfile | 8 +- package.json | 4 + src/common/config.ts | 3 + src/common/custom-theme.ts | 22 ++ .../collection-panel-files.tsx | 63 +++--- src/components/file-tree/file-tree.tsx | 3 +- src/components/tree/virtual-tree.tsx | 193 ++++++++++++++++++ src/index.tsx | 20 +- src/models/collection-file.ts | 1 - src/models/resource.ts | 3 +- src/models/tree.ts | 34 +-- src/routes/routes.ts | 5 +- .../collection-files-service.ts | 58 ------ .../collection-manifest-mapper.test.ts | 98 --------- .../collection-manifest-mapper.ts | 96 --------- .../collection-manifest-parser.test.ts | 40 ---- .../collection-manifest-parser.ts | 55 ----- .../collection-service-files-response.ts | 1 - .../collection-service/collection-service.ts | 2 +- src/services/services.ts | 3 - .../collection-panel-action.ts | 13 +- .../collection-panel-files-actions.ts | 6 + .../collection-panel-files-reducer.ts | 12 +- .../collection-panel-reducer.ts | 13 +- src/store/navigation/navigation-action.ts | 3 +- .../not-found-panel-action.tsx | 16 ++ src/store/workbench/workbench-actions.ts | 4 + .../collection-panel-files.ts | 32 ++- .../collection-files-item-action-set.ts | 19 +- .../not-found-dialog/not-found-dialog.tsx | 65 ++++++ .../generic-projects-tree-picker.tsx | 2 +- .../collection-panel/collection-panel.tsx | 131 +++++++----- .../not-found-panel-root.test.tsx | 87 ++++++++ .../not-found-panel/not-found-panel-root.tsx | 95 +++++++++ src/views/not-found-panel/not-found-panel.tsx | 19 ++ src/views/workbench/workbench.tsx | 4 + yarn.lock | 32 +++ 38 files changed, 810 insertions(+), 502 deletions(-) create mode 100644 cypress/integration/page-not-found.spec.js create mode 100644 src/components/tree/virtual-tree.tsx delete mode 100644 src/services/collection-files-service/collection-files-service.ts delete mode 100644 src/services/collection-files-service/collection-manifest-mapper.test.ts delete mode 100644 src/services/collection-files-service/collection-manifest-mapper.ts delete mode 100644 src/services/collection-files-service/collection-manifest-parser.test.ts delete mode 100644 src/services/collection-files-service/collection-manifest-parser.ts create mode 100644 src/store/not-found-panel/not-found-panel-action.tsx create mode 100644 src/views-components/not-found-dialog/not-found-dialog.tsx create mode 100644 src/views/not-found-panel/not-found-panel-root.test.tsx create mode 100644 src/views/not-found-panel/not-found-panel-root.tsx create mode 100644 src/views/not-found-panel/not-found-panel.tsx diff --git a/cypress/integration/page-not-found.spec.js b/cypress/integration/page-not-found.spec.js new file mode 100644 index 00000000..3dd15a67 --- /dev/null +++ b/cypress/integration/page-not-found.spec.js @@ -0,0 +1,47 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +describe('Page not found tests', function() { + let adminUser; + + before(function() { + cy.getUser('admin', 'Admin', 'User', true, true) + .as('adminUser').then(function() { + adminUser = this.adminUser; + } + ); + }); + + beforeEach(function() { + cy.clearCookies() + cy.clearLocalStorage() + }); + + it('shows not found page', function() { + // given + const invalidUUID = '1212r12r12r12r12r12r21r' + + // when + cy.loginAs(adminUser); + cy.visit(`/collections/${invalidUUID}`); + + // then + cy.get('[data-cy=not-found-page]').should('exist'); + cy.get('[data-cy=not-found-content]').should('exist'); + }); + + + it('shows not found popup', function() { + // given + const notExistingUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx'; + + // when + cy.loginAs(adminUser); + cy.visit(`/projects/${notExistingUUID}`); + + // then + cy.get('[data-cy=not-found-page]').should('not.exist'); + cy.get('[data-cy=not-found-content]').should('exist'); + }); +}) \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index a6abbbd3..e134dca0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,11 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0 FROM node:8-buster -MAINTAINER Ward Vandewege +MAINTAINER MAINTAINER Arvados Package Maintainers + +RUN echo deb http://deb.debian.org/debian buster-backports main >> /etc/apt/sources.list.d/backports.list RUN apt-get update && \ apt-get -yq --no-install-recommends -o Acquire::Retries=6 install \ libsecret-1-0 libsecret-1-dev rpm ruby ruby-dev rubygems build-essential \ - golang libpam0g-dev && \ + libpam0g-dev && \ + apt-get clean +RUN apt-get -yq --no-install-recommends -t buster-backports install golang-go && \ apt-get clean RUN gem install --no-ri --no-rdoc fpm RUN git clone https://git.arvados.org/arvados.git && cd arvados && \ diff --git a/package.json b/package.json index 0efdbd7d..57c6e311 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@types/react-copy-to-clipboard": "4.2.6", "@types/react-dropzone": "4.2.2", "@types/react-highlight-words": "0.12.0", + "@types/react-virtualized-auto-sizer": "1.0.0", + "@types/react-window": "1.8.2", "@types/redux-form": "7.4.12", "@types/shell-quote": "1.6.0", "axios": "0.18.1", @@ -53,6 +55,8 @@ "react-scripts-ts": "3.1.0", "react-splitter-layout": "3.0.1", "react-transition-group": "2.5.0", + "react-virtualized-auto-sizer": "1.0.2", + "react-window": "1.8.5", "redux": "4.0.3", "redux-form": "7.4.2", "redux-thunk": "2.3.0", diff --git a/src/common/config.ts b/src/common/config.ts index 39f9fbd1..cf539f3d 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -23,6 +23,9 @@ export interface ClusterConfigJSON { Scheme: string } }; + Mail?: { + SupportEmailAddress: string; + }; Services: { Controller: { ExternalURL: string diff --git a/src/common/custom-theme.ts b/src/common/custom-theme.ts index 169358dc..d93b37b7 100644 --- a/src/common/custom-theme.ts +++ b/src/common/custom-theme.ts @@ -103,6 +103,28 @@ export const themeOptions: ArvadosThemeOptions = { fontSize: '1.25rem' } }, + MuiExpansionPanel: { + expanded: { + marginTop: '8px', + } + }, + MuiExpansionPanelDetails: { + root: { + marginBottom: 0, + paddingBottom: '4px', + } + }, + MuiExpansionPanelSummary: { + content: { + '&$expanded': { + margin: 0, + }, + color: grey700, + fontSize: '1.25rem', + margin: 0, + }, + expanded: {}, + }, MuiMenuItem: { root: { padding: '8px 16px' diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index 48b36be1..c7db48c4 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -6,27 +6,31 @@ import * as 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 } from '@material-ui/core'; +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'; export interface CollectionPanelFilesProps { items: Array>; isWritable: boolean; + isLoading: boolean; + tooManyFiles: boolean; onUploadDataClick: () => 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; } -type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button'; +type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel'; const styles: StyleRulesCallback = theme => ({ root: { - paddingBottom: theme.spacing.unit + paddingBottom: theme.spacing.unit, + height: '100%' }, cardSubheader: { paddingTop: 0, @@ -44,18 +48,24 @@ const styles: StyleRulesCallback = theme => ({ button: { marginRight: -theme.spacing.unit, marginTop: '0px' - } + }, + centeredLabel: { + fontSize: '0.875rem', + textAlign: 'center' + }, }); export const CollectionPanelFiles = withStyles(styles)( - ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, isWritable, ...treeProps }: CollectionPanelFilesProps & WithStyles) => + ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, + isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles) => + {isWritable && - } /> - } + {!tooManyFiles && onOptionsMenuOpen(ev, isWritable)}> - - } /> - - - Name - - - File size - - - onItemMenuOpen(ev, item, isWritable)} {...treeProps} /> + } + + } /> + { tooManyFiles + ?
+ File listing may take some time, please click to browse: +
+ : <> + + + Name + + + File size + + + { isLoading + ?
+ :
onItemMenuOpen(ev, item, isWritable)} {...treeProps} />
} + + }
); diff --git a/src/components/file-tree/file-tree.tsx b/src/components/file-tree/file-tree.tsx index 34a11cd6..b5d98c08 100644 --- a/src/components/file-tree/file-tree.tsx +++ b/src/components/file-tree/file-tree.tsx @@ -3,7 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from "react"; -import { Tree, TreeItem, TreeItemStatus } from "../tree/tree"; +import { TreeItem, TreeItemStatus } from "../tree/tree"; +import { VirtualTree as Tree } from "../tree/virtual-tree"; import { FileTreeData } from "./file-tree-data"; import { FileTreeItem } from "./file-tree-item"; diff --git a/src/components/tree/virtual-tree.tsx b/src/components/tree/virtual-tree.tsx new file mode 100644 index 00000000..59fe34b1 --- /dev/null +++ b/src/components/tree/virtual-tree.tsx @@ -0,0 +1,193 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import * as classnames from "classnames"; +import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles'; +import { ReactElement } from "react"; +import { FixedSizeList, ListChildComponentProps } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; + +import { ArvadosTheme } from '~/common/custom-theme'; +import { TreeItem, TreeProps, TreeItemStatus } from './tree'; +import { ListItem, Radio, Checkbox, CircularProgress, ListItemIcon } from '@material-ui/core'; +import { SidePanelRightArrowIcon } from '../icon/icon'; + +type CssRules = 'list' + | 'listItem' + | 'active' + | 'loader' + | 'toggableIconContainer' + | 'iconClose' + | 'renderContainer' + | 'iconOpen' + | 'toggableIcon' + | 'checkbox' + | 'virtualizedList'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + list: { + padding: '3px 0px', + }, + virtualizedList: { + height: '200px', + }, + listItem: { + padding: '3px 0px', + }, + loader: { + position: 'absolute', + transform: 'translate(0px)', + top: '3px' + }, + toggableIconContainer: { + color: theme.palette.grey["700"], + height: '14px', + width: '14px', + }, + toggableIcon: { + fontSize: '14px' + }, + renderContainer: { + flex: 1 + }, + active: { + color: theme.palette.primary.main, + }, + iconClose: { + transition: 'all 0.1s ease', + }, + iconOpen: { + transition: 'all 0.1s ease', + transform: 'rotate(90deg)', + }, + checkbox: { + width: theme.spacing.unit * 3, + height: theme.spacing.unit * 3, + margin: `0 ${theme.spacing.unit}px`, + padding: 0, + color: theme.palette.grey["500"], + } +}); + +export interface VirtualTreeItem extends TreeItem { + itemCount?: number; + level?: number; +} + +// For some reason, on TSX files it isn't accepted just one generic param, so +// I'm using as a workaround. +export const Row = (itemList: VirtualTreeItem[], render: any, treeProps: TreeProps) => withStyles(styles)( + (props: React.PropsWithChildren & WithStyles) => { + const { index, style, classes } = props; + const it = itemList[index]; + const level = it.level || 0; + const { toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = treeProps; + const { listItem, loader, toggableIconContainer, renderContainer } = classes; + const { levelIndentation = 20, itemRightPadding = 20 } = treeProps; + + const showSelection = typeof treeProps.showSelection === 'function' + ? treeProps.showSelection + : () => treeProps.showSelection ? true : false; + + const handleRowContextMenu = (item: VirtualTreeItem) => + (event: React.MouseEvent) => { + treeProps.onContextMenu(event, item); + }; + + const handleToggleItemOpen = (item: VirtualTreeItem) => + (event: React.MouseEvent) => { + event.stopPropagation(); + treeProps.toggleItemOpen(event, item); + }; + + const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => { + const { iconOpen, iconClose, active, toggableIcon } = props.classes; + return classnames(toggableIcon, { + [iconOpen]: isOpen, + [iconClose]: !isOpen, + [active]: isActive + }); + }; + + const isSidePanelIconNotNeeded = (status: string, itemCount: number) => { + return status === TreeItemStatus.PENDING || + (status === TreeItemStatus.LOADED && itemCount === 0); + }; + + const getProperArrowAnimation = (status: string, itemCount: number) => { + return isSidePanelIconNotNeeded(status, itemCount) ? : ; + }; + + const handleCheckboxChange = (item: VirtualTreeItem) => { + const { toggleItemSelection } = treeProps; + return toggleItemSelection + ? (event: React.MouseEvent) => { + event.stopPropagation(); + toggleItemSelection(event, item); + } + : undefined; + }; + + return
+ toggleItemActive(event, it)} + selected={showSelection(it) && it.id === currentItemUuid} + onContextMenu={handleRowContextMenu(it)}> + {it.status === TreeItemStatus.PENDING ? + : null} + + + {getProperArrowAnimation(it.status, it.itemCount!)} + + + {showSelection(it) && !useRadioButtons && + } + {showSelection(it) && useRadioButtons && + } +
+ {render(it, level)} +
+
+
; + }); + +const itemSize = 30; + +export const VirtualList = (height: number, width: number, items: VirtualTreeItem[], render: any, treeProps: TreeProps) => + + {Row(items, render, treeProps)} + ; + +export const VirtualTree = withStyles(styles)( + class Component extends React.Component & WithStyles, {}> { + render(): ReactElement { + const { items, render } = this.props; + + return + {({ height, width }) => { + return VirtualList(height, width, items || [], render, this.props); + }} + ; + } + } +); diff --git a/src/index.tsx b/src/index.tsx index 1a58dad1..d07d3c9e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -62,6 +62,7 @@ import { collectionAdminActionSet } from '~/views-components/context-menu/action import { processResourceAdminActionSet } from '~/views-components/context-menu/action-sets/process-resource-admin-action-set'; import { projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set'; import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions"; +import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action'; console.log(`Starting arvados [${getBuildInfo()}]`); @@ -106,13 +107,18 @@ fetchConfig() errorFn: (id, error, showSnackBar) => { if (showSnackBar) { console.error("Backend error:", error); - store.dispatch(snackbarActions.OPEN_SNACKBAR({ - message: `${error.errors - ? error.errors[0] - : error.message}`, - kind: SnackbarKind.ERROR, - hideDuration: 8000}) - ); + + if (error.errors[0].indexOf("not found") > -1) { + store.dispatch(openNotFoundDialog()); + } else { + store.dispatch(snackbarActions.OPEN_SNACKBAR({ + message: `${error.errors + ? error.errors[0] + : error.message}`, + kind: SnackbarKind.ERROR, + hideDuration: 8000}) + ); + } } } }); diff --git a/src/models/collection-file.ts b/src/models/collection-file.ts index 97afcac6..3951d272 100644 --- a/src/models/collection-file.ts +++ b/src/models/collection-file.ts @@ -66,7 +66,6 @@ export const createCollectionFilesTree = (data: Array()); }; diff --git a/src/models/resource.ts b/src/models/resource.ts index d8cdd4a0..371278e5 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -62,8 +62,9 @@ export enum ResourceObjectType { } export const RESOURCE_UUID_PATTERN = '[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}'; +export const PORTABLE_DATA_HASH_PATTERN = '[a-f0-9]{32}\\+\\d+'; export const RESOURCE_UUID_REGEX = new RegExp("^" + RESOURCE_UUID_PATTERN + "$"); -export const COLLECTION_PDH_REGEX = /^[a-f0-9]{32}\+\d+$/; +export const COLLECTION_PDH_REGEX = new RegExp("^" + PORTABLE_DATA_HASH_PATTERN + "$"); export const isResourceUuid = (uuid: string) => RESOURCE_UUID_REGEX.test(uuid); diff --git a/src/models/tree.ts b/src/models/tree.ts index de2f7b71..c7713cbc 100644 --- a/src/models/tree.ts +++ b/src/models/tree.ts @@ -43,12 +43,13 @@ export const appendSubtree = (id: string, subtree: Tree) => (tree: Tree )(subtree) as Tree; export const setNode = (node: TreeNode) => (tree: Tree): Tree => { - return pipe( - (tree: Tree) => getNode(node.id)(tree) === node - ? tree - : { ...tree, [node.id]: node }, - addChild(node.parent, node.id) - )(tree); + if (tree[node.id] && tree[node.id] === node) { return tree; } + + tree[node.id] = node; + if (tree[node.parent]) { + tree[node.parent].children = Array.from(new Set([...tree[node.parent].children, node.id])); + } + return tree; }; export const getNodeValue = (id: string) => (tree: Tree) => { @@ -156,7 +157,6 @@ export const toggleNodeSelection = (id: string) => (tree: Tree) => { toggleAncestorsSelection(id), toggleDescendantsSelection(id))(tree) : tree; - }; export const selectNode = (id: string) => (tree: Tree) => { @@ -235,23 +235,3 @@ const getRootNodeChildrenIds = (tree: Tree) => Object .keys(tree) .filter(id => getNode(id)(tree)!.parent === TREE_ROOT_ID); - - -const addChild = (parentId: string, childId: string) => (tree: Tree): Tree => { - if (childId === "") { - return tree; - } - const node = getNode(parentId)(tree); - if (node) { - const children = node.children.some(id => id === childId) - ? node.children - : [...node.children, childId]; - - const newNode = children === node.children - ? node - : { ...node, children }; - - return setNode(newNode)(tree); - } - return tree; -}; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 191fe11b..452589f6 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import { matchPath } from 'react-router'; -import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind, COLLECTION_PDH_REGEX } from '~/models/resource'; +import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind, COLLECTION_PDH_REGEX, PORTABLE_DATA_HASH_PATTERN } from '~/models/resource'; import { getProjectUrl } from '~/models/project'; import { getCollectionUrl } from '~/models/collection'; import { Config } from '~/common/config'; @@ -46,8 +46,9 @@ export const Routes = { GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`, LINKS: '/links', PUBLIC_FAVORITES: '/public-favorites', - COLLECTIONS_CONTENT_ADDRESS: '/collections/:id', + COLLECTIONS_CONTENT_ADDRESS: `/collections/:id(${PORTABLE_DATA_HASH_PATTERN})`, ALL_PROCESSES: '/all_processes', + NO_MATCH: '*', }; export const getResourceUrl = (uuid: string) => { diff --git a/src/services/collection-files-service/collection-files-service.ts b/src/services/collection-files-service/collection-files-service.ts deleted file mode 100644 index f8e7de98..00000000 --- a/src/services/collection-files-service/collection-files-service.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { CollectionService } from "../collection-service/collection-service"; -import { parseKeepManifestText, stringifyKeepManifest } from "./collection-manifest-parser"; -import { mapManifestToCollectionFilesTree } from "./collection-manifest-mapper"; - -export class CollectionFilesService { - - constructor(private collectionService: CollectionService) { } - - getFiles(collectionUuid: string) { - return this.collectionService - .get(collectionUuid) - .then(collection => - mapManifestToCollectionFilesTree( - parseKeepManifestText( - collection.manifestText - ) - ) - ); - } - - async renameFile(collectionUuid: string, file: { name: string, path: string }, newName: string) { - const collection = await this.collectionService.get(collectionUuid); - const manifest = parseKeepManifestText(collection.manifestText); - const updatedManifest = manifest.map( - stream => stream.name === file.path - ? { - ...stream, - files: stream.files.map( - f => f.name === file.name - ? { ...f, name: newName } - : f - ) - } - : stream - ); - const manifestText = stringifyKeepManifest(updatedManifest); - return this.collectionService.update(collectionUuid, { manifestText }); - } - - async deleteFile(collectionUuid: string, file: { name: string, path: string }) { - const collection = await this.collectionService.get(collectionUuid); - const manifest = parseKeepManifestText(collection.manifestText); - const updatedManifest = manifest.map(stream => - stream.name === file.path - ? { - ...stream, - files: stream.files.filter(f => f.name !== file.name) - } - : stream - ); - const manifestText = stringifyKeepManifest(updatedManifest); - return this.collectionService.update(collectionUuid, { manifestText }); - } -} diff --git a/src/services/collection-files-service/collection-manifest-mapper.test.ts b/src/services/collection-files-service/collection-manifest-mapper.test.ts deleted file mode 100644 index 698a6bb7..00000000 --- a/src/services/collection-files-service/collection-manifest-mapper.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { parseKeepManifestText } from "./collection-manifest-parser"; -import { mapManifestToFiles, mapManifestToDirectories, mapManifestToCollectionFilesTree, mapCollectionFilesTreeToManifest } from "./collection-manifest-mapper"; - -test('mapManifestToFiles', () => { - const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n./c d41d8cd98f00b204e9800998ecf8427e+0 0:0:d`; - const manifest = parseKeepManifestText(manifestText); - const files = mapManifestToFiles(manifest); - expect(files).toEqual([{ - path: '', - id: '/a', - name: 'a', - size: 0, - type: 'file', - url: '' - }, { - path: '', - id: '/b', - name: 'b', - size: 0, - type: 'file', - url: '' - }, { - path: '', - id: '/output.txt', - name: 'output.txt', - size: 33, - type: 'file', - url: '' - }, { - path: '/c', - id: '/c/d', - name: 'd', - size: 0, - type: 'file', - url: '' - },]); -}); - -test('mapManifestToDirectories', () => { - const manifestText = `./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`; - const manifest = parseKeepManifestText(manifestText); - const directories = mapManifestToDirectories(manifest); - expect(directories).toEqual([{ - path: "", - id: '/c', - name: 'c', - type: 'directory', - url: '' - }, { - path: '/c', - id: '/c/user', - name: 'user', - type: 'directory', - url: '' - }, { - path: '/c/user', - id: '/c/user/results', - name: 'results', - type: 'directory', - url: '' - },]); -}); - -test('mapCollectionFilesTreeToManifest', () => { - const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`; - const tree = mapManifestToCollectionFilesTree(parseKeepManifestText(manifestText)); - const manifest = mapCollectionFilesTreeToManifest(tree); - expect(manifest).toEqual([{ - name: '', - locators: [], - files: [{ - name: 'test.txt', - position: '', - size: 22 - },], - }, { - name: '/c/user/results', - locators: [], - files: [{ - name: 'a', - position: '', - size: 0 - }, { - name: 'b', - position: '', - size: 0 - }, { - name: 'output.txt', - position: '', - size: 33 - },], - },]); - -}); \ No newline at end of file diff --git a/src/services/collection-files-service/collection-manifest-mapper.ts b/src/services/collection-files-service/collection-manifest-mapper.ts deleted file mode 100644 index 6e64f833..00000000 --- a/src/services/collection-files-service/collection-manifest-mapper.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { uniqBy, groupBy } from 'lodash'; -import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest"; -import { TreeNode, setNode, createTree, getNodeDescendantsIds, getNodeValue, TreeNodeStatus } from '~/models/tree'; -import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile, CollectionFileType } from '../../models/collection-file'; - -export const mapCollectionFilesTreeToManifest = (tree: CollectionFilesTree): KeepManifest => { - const values = getNodeDescendantsIds('')(tree).map(id => getNodeValue(id)(tree)); - const files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[]; - const fileGroups = groupBy(files, file => file.path); - return Object - .keys(fileGroups) - .map(dirName => ({ - name: dirName, - locators: [], - files: fileGroups[dirName].map(mapCollectionFile) - })); -}; - -export const mapManifestToCollectionFilesTree = (manifest: KeepManifest): CollectionFilesTree => - manifestToCollectionFiles(manifest) - .map(mapCollectionFileToTreeNode) - .reduce((tree, node) => setNode(node)(tree), createTree()); - - -export const mapCollectionFileToTreeNode = (file: CollectionFile): TreeNode => ({ - children: [], - id: file.id, - parent: file.path, - value: file, - active: false, - selected: false, - expanded: false, - status: TreeNodeStatus.INITIAL, -}); - -export const manifestToCollectionFiles = (manifest: KeepManifest): Array => ([ - ...mapManifestToDirectories(manifest), - ...mapManifestToFiles(manifest) -]); - -export const mapManifestToDirectories = (manifest: KeepManifest): CollectionDirectory[] => - uniqBy( - manifest - .map(mapStreamDirectory) - .map(splitDirectory) - .reduce((all, splitted) => ([...all, ...splitted]), []), - directory => directory.id); - -export const mapManifestToFiles = (manifest: KeepManifest): CollectionFile[] => - manifest - .map(stream => stream.files.map(mapStreamFile(stream))) - .reduce((all, current) => ([...all, ...current]), []); - -const splitDirectory = (directory: CollectionDirectory): CollectionDirectory[] => { - return directory.name - .split('/') - .slice(1) - .map(mapPathComponentToDirectory); -}; - -const mapPathComponentToDirectory = (component: string, index: number, components: string[]): CollectionDirectory => - createCollectionDirectory({ - path: index === 0 ? '' : joinPathComponents(components, index), - id: joinPathComponents(components, index + 1), - name: component, - }); - -const joinPathComponents = (components: string[], index: number) => - `/${components.slice(0, index).join('/')}`; - -const mapCollectionFile = (file: CollectionFile): KeepManifestStreamFile => ({ - name: file.name, - position: '', - size: file.size -}); - -const mapStreamDirectory = (stream: KeepManifestStream): CollectionDirectory => - createCollectionDirectory({ - path: '', - id: stream.name, - name: stream.name, - }); - -const mapStreamFile = (stream: KeepManifestStream) => - (file: KeepManifestStreamFile): CollectionFile => - createCollectionFile({ - path: stream.name, - id: `${stream.name}/${file.name}`, - name: file.name, - size: file.size, - }); - diff --git a/src/services/collection-files-service/collection-manifest-parser.test.ts b/src/services/collection-files-service/collection-manifest-parser.test.ts deleted file mode 100644 index 09525d86..00000000 --- a/src/services/collection-files-service/collection-manifest-parser.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { parseKeepManifestText, parseKeepManifestStream, stringifyKeepManifest } from "./collection-manifest-parser"; - -describe('parseKeepManifestText', () => { - it('should parse text into streams', () => { - const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n./c d41d8cd98f00b204e9800998ecf8427e+0 0:0:d\n`; - const manifest = parseKeepManifestText(manifestText); - expect(manifest[0].name).toBe(''); - expect(manifest[1].name).toBe('/c'); - expect(manifest.length).toBe(2); - }); -}); - -describe('parseKeepManifestStream', () => { - const streamText = './c 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt'; - const stream = parseKeepManifestStream(streamText); - - it('should parse stream name', () => { - expect(stream.name).toBe('/c'); - }); - it('should parse stream locators', () => { - expect(stream.locators).toEqual(['930625b054ce894ac40596c3f5a0d947+33']); - }); - it('should parse stream files', () => { - expect(stream.files).toEqual([ - { name: 'a', position: '0', size: 0 }, - { name: 'b', position: '0', size: 0 }, - { name: 'output.txt', position: '0', size: 33 }, - ]); - }); -}); - -test('stringifyKeepManifest', () => { - const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`; - const manifest = parseKeepManifestText(manifestText); - expect(stringifyKeepManifest(manifest)).toEqual(`. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`); -}); \ No newline at end of file diff --git a/src/services/collection-files-service/collection-manifest-parser.ts b/src/services/collection-files-service/collection-manifest-parser.ts deleted file mode 100644 index d564f33e..00000000 --- a/src/services/collection-files-service/collection-manifest-parser.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest"; - -/** - * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html) - */ -export const parseKeepManifestText: (text: string) => KeepManifestStream[] = (text: string) => - text - .split(/\n/) - .filter(streamText => streamText.length > 0) - .map(parseKeepManifestStream); - -/** - * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html) - */ -export const parseKeepManifestStream = (stream: string): KeepManifestStream => { - const tokens = stream.split(' '); - return { - name: streamName(tokens), - locators: locators(tokens), - files: files(tokens) - }; -}; - -export const stringifyKeepManifest = (manifest: KeepManifest) => - manifest.map(stringifyKeepManifestStream).join(''); - -export const stringifyKeepManifestStream = (stream: KeepManifestStream) => - `.${stream.name} ${stream.locators.join(' ')} ${stream.files.map(stringifyFile).join(' ')}\n`; - -const FILE_LOCATOR_REGEXP = /^([0-9a-f]{32})\+([0-9]+)(\+[A-Z][-A-Za-z0-9@_]*)*$/; - -const FILE_REGEXP = /([0-9]+):([0-9]+):(.*)/; - -const streamName = (tokens: string[]) => tokens[0].slice(1); - -const locators = (tokens: string[]) => tokens.filter(isFileLocator); - -const files = (tokens: string[]) => tokens.filter(isFile).map(parseFile); - -const isFileLocator = (token: string) => FILE_LOCATOR_REGEXP.test(token); - -const isFile = (token: string) => FILE_REGEXP.test(token); - -const parseFile = (token: string): KeepManifestStreamFile => { - const match = FILE_REGEXP.exec(token); - const [position, size, name] = match!.slice(1); - return { name, position, size: parseInt(size, 10) }; -}; - -const stringifyFile = (file: KeepManifestStreamFile) => - `${file.position}:${file.size}:${file.name}`; diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts index 2e726d0b..5e6f7b83 100644 --- a/src/services/collection-service/collection-service-files-response.ts +++ b/src/services/collection-service/collection-service-files-response.ts @@ -50,7 +50,6 @@ export const extractFilesData = (document: Document) => { return getTagValue(element, 'D:resourcetype', '') ? createCollectionDirectory(data) : createCollectionFile({ ...data, size }); - }); }; diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index 77f5bf3b..90441a64 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -68,7 +68,7 @@ export class CollectionService extends TrashableResourceService(), LOAD_COLLECTION: ofType<{ uuid: string }>(), - LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>() + LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>(), + LOAD_BIG_COLLECTIONS: ofType(), }); export type CollectionPanelAction = UnionOf; @@ -31,12 +30,14 @@ export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm'; export const loadCollectionPanel = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid })); - dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() })); const collection = await services.collectionService.get(uuid); dispatch(loadDetailsPanel(collection.uuid)); dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: collection })); dispatch(resourcesActions.SET_RESOURCES([collection])); - dispatch(loadCollectionFiles(collection.uuid)); + if (collection.fileCount <= COLLECTION_PANEL_LOAD_FILES_THRESHOLD && + !getState().collectionPanel.loadBigCollections) { + 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 9d3ae861..204d4c0e 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 @@ -14,6 +14,7 @@ import { filterCollectionFilesBySelection } from './collection-panel-files-state import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form'; import { getDialog } from "~/store/dialog/dialog-reducer"; import { getFileFullPath, sortFilesTree } from "~/services/collection-service/collection-service-files-response"; +import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions"; export const collectionPanelFilesAction = unionize({ SET_COLLECTION_FILES: ofType(), @@ -25,8 +26,12 @@ export const collectionPanelFilesAction = unionize({ export type CollectionPanelFilesAction = UnionOf; +export const COLLECTION_PANEL_LOAD_FILES = 'collectionPanelLoadFiles'; +export const COLLECTION_PANEL_LOAD_FILES_THRESHOLD = 40000; + export const loadCollectionFiles = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PANEL_LOAD_FILES)); const files = await services.collectionService.files(uuid); // Given the array of directories and files, create the appropriate tree nodes, @@ -35,6 +40,7 @@ export const loadCollectionFiles = (uuid: string) => const sorted = sortFilesTree(tree); const mapped = mapTreeValues(services.collectionService.extendFileURL)(sorted); dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(mapped)); + dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PANEL_LOAD_FILES)); }; export const removeCollectionFiles = (filePaths: string[]) => diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts index 57961538..08a71759 100644 --- a/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts +++ b/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts @@ -8,23 +8,25 @@ import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNo import { CollectionFileType } from "~/models/collection-file"; export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => { + // Low-level tree handling setNode() func does in-place data modifications + // for performance reasons, so we pass a copy of 'state' to avoid side effects. return collectionPanelFilesAction.match(action, { SET_COLLECTION_FILES: files => - mergeCollectionPanelFilesStates(state, mapTree(mapCollectionFileToCollectionPanelFile)(files)), + mergeCollectionPanelFilesStates({...state}, mapTree(mapCollectionFileToCollectionPanelFile)(files)), TOGGLE_COLLECTION_FILE_COLLAPSE: data => - toggleCollapse(data.id)(state), + toggleCollapse(data.id)({...state}), - TOGGLE_COLLECTION_FILE_SELECTION: data => [state] + TOGGLE_COLLECTION_FILE_SELECTION: data => [{...state}] .map(toggleSelected(data.id)) .map(toggleAncestors(data.id)) .map(toggleDescendants(data.id))[0], SELECT_ALL_COLLECTION_FILES: () => - mapTreeValues(v => ({ ...v, selected: true }))(state), + mapTreeValues(v => ({ ...v, selected: true }))({...state}), UNSELECT_ALL_COLLECTION_FILES: () => - mapTreeValues(v => ({ ...v, selected: false }))(state), + mapTreeValues(v => ({ ...v, selected: false }))({...state}), default: () => state }) as CollectionPanelFilesState; diff --git a/src/store/collection-panel/collection-panel-reducer.ts b/src/store/collection-panel/collection-panel-reducer.ts index f09b0198..18590181 100644 --- a/src/store/collection-panel/collection-panel-reducer.ts +++ b/src/store/collection-panel/collection-panel-reducer.ts @@ -7,15 +7,22 @@ import { CollectionResource } from "~/models/collection"; export interface CollectionPanelState { item: CollectionResource | null; + loadBigCollections: boolean; } const initialState = { - item: null + item: null, + loadBigCollections: false, }; export const collectionPanelReducer = (state: CollectionPanelState = initialState, action: CollectionPanelAction) => collectionPanelActions.match(action, { default: () => state, - SET_COLLECTION: (item) => ({ ...state, item }), - LOAD_COLLECTION_SUCCESS: ({ item }) => ({ ...state, item }) + SET_COLLECTION: (item) => ({ + ...state, + item, + loadBigCollections: false, + }), + LOAD_COLLECTION_SUCCESS: ({ item }) => ({ ...state, item }), + LOAD_BIG_COLLECTIONS: (loadBigCollections) => ({ ...state, loadBigCollections}), }); diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index fda6ec71..d663ae37 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -14,7 +14,6 @@ import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions'; export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => { const kind = extractUuidKind(uuid); - switch (kind) { case ResourceKind.PROJECT: case ResourceKind.USER: @@ -52,6 +51,8 @@ export const navigateTo = (uuid: string) => } }; +export const navigateToNotFound = push(Routes.NO_MATCH); + export const navigateToRoot = push(Routes.ROOT); export const navigateToFavorites = push(Routes.FAVORITES); diff --git a/src/store/not-found-panel/not-found-panel-action.tsx b/src/store/not-found-panel/not-found-panel-action.tsx new file mode 100644 index 00000000..2cb397c0 --- /dev/null +++ b/src/store/not-found-panel/not-found-panel-action.tsx @@ -0,0 +1,16 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { dialogActions } from '~/store/dialog/dialog-actions'; + +export const NOT_FOUND_DIALOG_NAME = 'notFoundDialog'; + +export const openNotFoundDialog = () => + (dispatch: Dispatch) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: NOT_FOUND_DIALOG_NAME, + data: {}, + })); + }; \ No newline at end of file diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index e2ff01f7..944c48cf 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -101,6 +101,8 @@ import { subprocessPanelActions } from '~/store/subprocess-panel/subprocess-pane import { subprocessPanelColumns } from '~/views/subprocess-panel/subprocess-panel-root'; import { loadAllProcessesPanel, allProcessesPanelActions } from '../all-processes-panel/all-processes-panel-action'; import { allProcessesPanelColumns } from '~/views/all-processes-panel/all-processes-panel'; +import { collectionPanelFilesAction } from '../collection-panel/collection-panel-files/collection-panel-files-actions'; +import { createTree } from '~/models/tree'; export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; @@ -278,6 +280,8 @@ export const loadCollection = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const userUuid = getUserUuid(getState()); if (userUuid) { + // Clear collection files panel + dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() })); const match = await loadGroupContentsResource({ uuid, userUuid, services }); match({ OWNED: async collection => { diff --git a/src/views-components/collection-panel-files/collection-panel-files.ts b/src/views-components/collection-panel-files/collection-panel-files.ts index eb16eb6c..79970003 100644 --- a/src/views-components/collection-panel-files/collection-panel-files.ts +++ b/src/views-components/collection-panel-files/collection-panel-files.ts @@ -8,7 +8,8 @@ import { CollectionPanelFilesProps } from "~/components/collection-panel-files/collection-panel-files"; import { RootState } from "~/store/store"; -import { TreeItem, TreeItemStatus } from "~/components/tree/tree"; +import { TreeItemStatus } from "~/components/tree/tree"; +import { VirtualTreeItem as TreeItem } from "~/components/tree/virtual-tree"; import { CollectionPanelDirectory, CollectionPanelFile, @@ -32,8 +33,9 @@ const memoizedMapStateToProps = () => { return (state: RootState): Pick => { if (prevState !== state.collectionPanelFiles) { prevState = state.collectionPanelFiles; - prevTree = getNodeChildrenIds('')(state.collectionPanelFiles) - .map(collectionItemToTreeItem(state.collectionPanelFiles)); + prevTree = [].concat.apply( + [], getNodeChildrenIds('')(state.collectionPanelFiles) + .map(collectionItemToList(0)(state.collectionPanelFiles))); } return { items: prevTree, @@ -74,11 +76,10 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick) => - (id: string): TreeItem => { +const collectionItemToList = (level: number) => (tree: Tree) => + (id: string): TreeItem[] => { const node: TreeNode = getNode(id)(tree) || initTreeNode({ id: '', parent: '', @@ -88,7 +89,8 @@ const collectionItemToTreeItem = (tree: Tree { - dispatch(openRenameFileDialog({ name: resource.name, id: resource.uuid })); - } - }, + // FIXME: This isn't working. Maybe something related to WebDAV? + // { + // name: "Rename", + // icon: RenameIcon, + // execute: (dispatch, resource) => { + // dispatch(openRenameFileDialog({ name: resource.name, id: resource.uuid })); + // } + // }, { name: "Remove", icon: RemoveIcon, diff --git a/src/views-components/not-found-dialog/not-found-dialog.tsx b/src/views-components/not-found-dialog/not-found-dialog.tsx new file mode 100644 index 00000000..2410ddcb --- /dev/null +++ b/src/views-components/not-found-dialog/not-found-dialog.tsx @@ -0,0 +1,65 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { Dispatch } from "redux"; +import { connect } from "react-redux"; +import { RootState } from '~/store/store'; +import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog"; +import { NOT_FOUND_DIALOG_NAME } from '~/store/not-found-panel/not-found-panel-action'; +import { Dialog, DialogContent, DialogActions, Button, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { NotFoundPanel } from "~/views/not-found-panel/not-found-panel"; + +type CssRules = 'tag'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + tag: { + marginRight: theme.spacing.unit, + marginBottom: theme.spacing.unit + } +}); + +interface NotFoundDialogDataProps { + +} + +interface NotFoundDialogActionProps { + +} + +const mapStateToProps = (state: RootState): NotFoundDialogDataProps => ({ + +}); + +const mapDispatchToProps = (dispatch: Dispatch): NotFoundDialogActionProps => ({ + +}); + +type NotFoundDialogProps = NotFoundDialogDataProps & NotFoundDialogActionProps & WithDialogProps<{}> & WithStyles; + +export const NotFoundDialog = connect(mapStateToProps, mapDispatchToProps)( + withStyles(styles)( + withDialog(NOT_FOUND_DIALOG_NAME)( + ({ open, closeDialog }: NotFoundDialogProps) => + + + + + + + + + ) +)); \ No newline at end of file diff --git a/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx b/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx index 8e27d445..07b1ad81 100644 --- a/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx @@ -33,7 +33,7 @@ export interface ProjectsTreePickerDataProps { showSelection?: boolean; relatedTreePickers?: string[]; disableActivation?: string[]; - loadRootItem: (item: TreeItem, pickerId: string, includeCollections?: boolean, inlcudeFiles?: boolean) => void; + loadRootItem: (item: TreeItem, pickerId: string, includeCollections?: boolean, includeFiles?: boolean) => void; } export type ProjectsTreePickerProps = ProjectsTreePickerDataProps & Partial; diff --git a/src/views/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx index 36625387..953e5b4c 100644 --- a/src/views/collection-panel/collection-panel.tsx +++ b/src/views/collection-panel/collection-panel.tsx @@ -4,19 +4,20 @@ import * as React from 'react'; import { - StyleRulesCallback, WithStyles, withStyles, Card, - CardHeader, IconButton, CardContent, Grid, Tooltip + StyleRulesCallback, WithStyles, withStyles, + IconButton, Grid, Tooltip, Typography, ExpansionPanel, + ExpansionPanelSummary, ExpansionPanelDetails } from '@material-ui/core'; import { connect, DispatchProp } from "react-redux"; import { RouteComponentProps } from 'react-router'; import { ArvadosTheme } from '~/common/custom-theme'; import { RootState } from '~/store/store'; -import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon } from '~/components/icon/icon'; +import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon, ExpandIcon } from '~/components/icon/icon'; import { DetailsAttribute } from '~/components/details-attribute/details-attribute'; import { CollectionResource } from '~/models/collection'; import { CollectionPanelFiles } from '~/views-components/collection-panel-files/collection-panel-files'; import { CollectionTagForm } from './collection-tag-form'; -import { deleteCollectionTag, navigateToProcess } from '~/store/collection-panel/collection-panel-action'; +import { deleteCollectionTag, navigateToProcess, collectionPanelActions } from '~/store/collection-panel/collection-panel-action'; import { getResource } from '~/store/resources/resources'; import { openContextMenu } from '~/store/context-menu/context-menu-actions'; import { ContextMenuKind } from '~/views-components/context-menu/context-menu'; @@ -28,12 +29,28 @@ import { IllegalNamingWarning } from '~/components/warning/warning'; import { GroupResource } from '~/models/group'; import { UserResource } from '~/models/user'; import { getUserUuid } from '~/common/getuser'; +import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer'; +import { COLLECTION_PANEL_LOAD_FILES, loadCollectionFiles, COLLECTION_PANEL_LOAD_FILES_THRESHOLD } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions'; -type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link' | 'centeredLabel' | 'readOnlyIcon'; +type CssRules = 'root' + | 'filesCard' + | 'iconHeader' + | 'tag' + | 'label' + | 'value' + | 'link' + | 'centeredLabel' + | 'readOnlyIcon'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ - card: { - marginBottom: theme.spacing.unit * 2 + root: { + display: 'flex', + flexFlow: 'column', + height: 'calc(100vh - 130px)', // (100% viewport height) - (top bar + breadcrumbs) + }, + filesCard: { + marginBottom: theme.spacing.unit * 2, + flex: 1, }, iconHeader: { fontSize: '1.875rem', @@ -70,6 +87,8 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ interface CollectionPanelDataProps { item: CollectionResource; isWritable: boolean; + isLoadingFiles: boolean; + tooManyFiles: boolean; } type CollectionPanelProps = CollectionPanelDataProps & DispatchProp @@ -88,47 +107,37 @@ export const CollectionPanel = withStyles(styles)( isWritable = itemOwner.writableBy.indexOf(currentUserUUID || '') >= 0; } } - return { item, isWritable }; + const loadingFilesIndicator = getProgressIndicator(COLLECTION_PANEL_LOAD_FILES)(state.progressIndicator); + const isLoadingFiles = loadingFilesIndicator && loadingFilesIndicator!.working || false; + const tooManyFiles = !state.collectionPanel.loadBigCollections && item && item.fileCount > COLLECTION_PANEL_LOAD_FILES_THRESHOLD || false; + return { item, isWritable, isLoadingFiles, tooManyFiles }; })( class extends React.Component { render() { - const { classes, item, dispatch, isWritable } = this.props; + const { classes, item, dispatch, isWritable, isLoadingFiles, tooManyFiles } = this.props; return item - ? <> - - + + }> + - } - action={ - - - - + + {item.name} + {isWritable || + + - } - title={ - - - {item.name} - {isWritable || - - - - } - - } - titleTypographyProps={this.titleProps} - subheader={item.description} - subheaderTypographyProps={this.titleProps} /> - - - + } + + + + + + + {item.description} + @@ -147,14 +156,26 @@ export const CollectionPanel = withStyles(styles)(
} + + + + + + + - - + + - - - - + + }> + {"Properties"} + + + {isWritable && } @@ -180,12 +201,20 @@ export const CollectionPanel = withStyles(styles)( } - - -
- + + +
+ { + dispatch(collectionPanelActions.LOAD_BIG_COLLECTIONS(true)); + dispatch(loadCollectionFiles(this.props.item.uuid)); + } + } />
- +
: null; } diff --git a/src/views/not-found-panel/not-found-panel-root.test.tsx b/src/views/not-found-panel/not-found-panel-root.test.tsx new file mode 100644 index 00000000..315b4b8c --- /dev/null +++ b/src/views/not-found-panel/not-found-panel-root.test.tsx @@ -0,0 +1,87 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { mount, configure } from 'enzyme'; +import * as Adapter from "enzyme-adapter-react-16"; +import { StyledComponentProps, MuiThemeProvider } from '@material-ui/core'; +import { ClusterConfigJSON } from '~/common/config'; +import { CustomTheme } from '~/common/custom-theme'; +import { NotFoundPanelRoot, NotFoundPanelRootDataProps, CssRules } from './not-found-panel-root'; + +configure({ adapter: new Adapter() }); + +describe('NotFoundPanelRoot', () => { + let props: NotFoundPanelRootDataProps & StyledComponentProps; + + beforeEach(() => { + props = { + classes: { + root: 'root', + title: 'title', + active: 'active', + }, + clusterConfig: { + Mail: { + SupportEmailAddress: 'support@example.com' + } + } as ClusterConfigJSON, + location: null, + }; + }); + + it('should render component', () => { + // given + const expectedMessage = "The page you requested was not found"; + + // when + const wrapper = mount( + + + + ); + + // then + expect(wrapper.find('p').text()).toContain(expectedMessage); + }); + + it('should render component without email url when no email', () => { + // setup + props.clusterConfig.Mail.SupportEmailAddress = ''; + + // when + const wrapper = mount( + + + + ); + + // then + expect(wrapper.find('a').length).toBe(0); + }); + + it('should render component with additional message and email url', () => { + // given + const hash = '123hash123'; + const pathname = `/collections/${hash}`; + + // setup + props.location = { + pathname, + } as any; + + // when + const wrapper = mount( + + + + ); + + // then + expect(wrapper.find('p').first().text()).toContain(hash); + + // and + expect(wrapper.find('a').length).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/views/not-found-panel/not-found-panel-root.tsx b/src/views/not-found-panel/not-found-panel-root.tsx new file mode 100644 index 00000000..6780b842 --- /dev/null +++ b/src/views/not-found-panel/not-found-panel-root.tsx @@ -0,0 +1,95 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { Location } from 'history'; +import { StyleRulesCallback, WithStyles, withStyles, Paper, Grid } from '@material-ui/core'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { ClusterConfigJSON } from '~/common/config'; + +export type CssRules = 'root' | 'title' | 'active'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + overflow: 'hidden', + width: '100vw', + height: '100vh' + }, + title: { + paddingLeft: theme.spacing.unit * 3, + paddingTop: theme.spacing.unit * 3, + paddingBottom: theme.spacing.unit * 3, + fontSize: '18px' + }, + active: { + color: theme.customs.colors.green700, + textDecoration: 'none', + } +}); + +export interface NotFoundPanelOwnProps { + notWrapped?: boolean; +} + +export interface NotFoundPanelRootDataProps { + location: Location | null; + clusterConfig: ClusterConfigJSON; +} + +type NotFoundPanelRootProps = NotFoundPanelRootDataProps & NotFoundPanelOwnProps & WithStyles; + +const getAdditionalMessage = (location: Location | null) => { + if (!location) { + return null; + } + + const { pathname } = location; + + if (pathname.indexOf('collections') > -1) { + const uuidHash = pathname.replace('/collections/', ''); + + return ( +

+ Please make sure that provided UUID/ObjectHash '{uuidHash}' is valid. +

+ ); + } + + return null; +}; + +const getEmailLink = (email: string, classes: Record) => { + const { location: { href: windowHref } } = window; + const href = `mailto:${email}?body=${encodeURIComponent('Problem while viewing page ')}${encodeURIComponent(windowHref)}&subject=${encodeURIComponent('Workbench problem report')}`; + + return ( + email us + ); +}; + + +export const NotFoundPanelRoot = withStyles(styles)( + ({ classes, clusterConfig, location, notWrapped }: NotFoundPanelRootProps) => { + + const content = +
+

Not Found

+ {getAdditionalMessage(location)} +

+ The page you requested was not found,  + { + !!clusterConfig.Mail && clusterConfig.Mail.SupportEmailAddress ? + getEmailLink(clusterConfig.Mail.SupportEmailAddress, classes) : + 'email us' + } +  if you suspect this is a bug. +

+
+
; + + return !notWrapped ? {content} : content; + } +); diff --git a/src/views/not-found-panel/not-found-panel.tsx b/src/views/not-found-panel/not-found-panel.tsx new file mode 100644 index 00000000..0f9f13b5 --- /dev/null +++ b/src/views/not-found-panel/not-found-panel.tsx @@ -0,0 +1,19 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { RootState } from '~/store/store'; +import { connect } from 'react-redux'; +import { NotFoundPanelRoot, NotFoundPanelRootDataProps, NotFoundPanelOwnProps } from '~/views/not-found-panel/not-found-panel-root'; + +const mapStateToProps = (state: RootState): NotFoundPanelRootDataProps => { + return { + location: state.router.location, + clusterConfig: state.auth.config.clusterConfig, + }; +}; + +const mapDispatchToProps = null; + +export const NotFoundPanel = connect(mapStateToProps, mapDispatchToProps) + (NotFoundPanelRoot); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 31c2a026..906c649c 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -48,6 +48,7 @@ import { SshKeyPanel } from '~/views/ssh-key-panel/ssh-key-panel'; import { SiteManagerPanel } from "~/views/site-manager-panel/site-manager-panel"; import { MyAccountPanel } from '~/views/my-account-panel/my-account-panel'; import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog'; +import { NotFoundDialog } from '~/views-components/not-found-dialog/not-found-dialog'; import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog'; import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog'; import { VirtualMachineUserPanel } from '~/views/virtual-machine-panel/virtual-machine-user-panel'; @@ -96,6 +97,7 @@ import { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel' import { FedLogin } from './fed-login'; import { CollectionsContentAddressPanel } from '~/views/collection-content-address-panel/collection-content-address-panel'; import { AllProcessesPanel } from '../all-processes-panel/all-processes-panel'; +import { NotFoundPanel } from '../not-found-panel/not-found-panel'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -188,6 +190,7 @@ export const WorkbenchPanel = + @@ -245,6 +248,7 @@ export const WorkbenchPanel = + diff --git a/yarn.lock b/yarn.lock index d6677a74..742db465 100644 --- a/yarn.lock +++ b/yarn.lock @@ -427,6 +427,20 @@ dependencies: "@types/react" "*" +"@types/react-virtualized-auto-sizer@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272" + integrity sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg== + dependencies: + "@types/react" "*" + +"@types/react-window@1.8.2": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" + integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "16.9.11" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.11.tgz#70e0b7ad79058a7842f25ccf2999807076ada120" @@ -7063,6 +7077,11 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" +"memoize-one@>=3.1.1 <6": + version "5.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" + integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== + memoize-one@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.1.0.tgz#a2387c58c03fff27ca390c31b764a79addf3f906" @@ -9081,6 +9100,19 @@ react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-virtualized-auto-sizer@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" + integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg== + +react-window@1.8.5: + version "1.8.5" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1" + integrity sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@16.8.6: version "16.8.6" resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" -- 2.30.2