14990: added 404 page with wildcard route
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 24 Jun 2020 20:36:52 +0000 (22:36 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Mon, 13 Jul 2020 18:58:39 +0000 (20:58 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

38 files changed:
cypress/integration/page-not-found.spec.js [new file with mode: 0644]
docker/Dockerfile
package.json
src/common/config.ts
src/common/custom-theme.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/file-tree/file-tree.tsx
src/components/tree/virtual-tree.tsx [new file with mode: 0644]
src/index.tsx
src/models/collection-file.ts
src/models/resource.ts
src/models/tree.ts
src/routes/routes.ts
src/services/collection-files-service/collection-files-service.ts [deleted file]
src/services/collection-files-service/collection-manifest-mapper.test.ts [deleted file]
src/services/collection-files-service/collection-manifest-mapper.ts [deleted file]
src/services/collection-files-service/collection-manifest-parser.test.ts [deleted file]
src/services/collection-files-service/collection-manifest-parser.ts [deleted file]
src/services/collection-service/collection-service-files-response.ts
src/services/collection-service/collection-service.ts
src/services/services.ts
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
src/store/collection-panel/collection-panel-reducer.ts
src/store/navigation/navigation-action.ts
src/store/not-found-panel/not-found-panel-action.tsx [new file with mode: 0644]
src/store/workbench/workbench-actions.ts
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/not-found-dialog/not-found-dialog.tsx [new file with mode: 0644]
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views/collection-panel/collection-panel.tsx
src/views/not-found-panel/not-found-panel-root.test.tsx [new file with mode: 0644]
src/views/not-found-panel/not-found-panel-root.tsx [new file with mode: 0644]
src/views/not-found-panel/not-found-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx
yarn.lock

diff --git a/cypress/integration/page-not-found.spec.js b/cypress/integration/page-not-found.spec.js
new file mode 100644 (file)
index 0000000..3dd15a6
--- /dev/null
@@ -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
index a6abbbd39c1634486ce325d5b92129c941d8a712..e134dca09cd8227a7099463a4473582341cbcf7c 100644 (file)
@@ -3,11 +3,15 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 FROM node:8-buster
-MAINTAINER Ward Vandewege <ward@curoverse.com>
+MAINTAINER MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
+
+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 && \
index 0efdbd7d30f479d0278c6fb3aabc1e4a78539488..57c6e311a212eeec8566b4fb7bafd91f2d79468d 100644 (file)
@@ -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",
index 39f9fbd13d37e0e59554008bfa2549e3149a3e31..cf539f3dd51856783b56dd6a90192c1dd69fe319 100644 (file)
@@ -23,6 +23,9 @@ export interface ClusterConfigJSON {
             Scheme: string
         }
     };
+    Mail?: {
+        SupportEmailAddress: string;
+    };
     Services: {
         Controller: {
             ExternalURL: string
index 169358dc06e992b3a8b5023e41a6b846e02b8c80..d93b37b79b9c2de27e92b2a565012711a85de68e 100644 (file)
@@ -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'
index 48b36be16ada976777bad3b161bd803ba57d87f0..c7db48c4b8b5bc63952491a19076cb27bacece49 100644 (file)
@@ -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<TreeItem<FileTreeData>>;
     isWritable: boolean;
+    isLoading: boolean;
+    tooManyFiles: boolean;
     onUploadDataClick: () => void;
     onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
     onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
     onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => 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<CssRules> = theme => ({
     root: {
-        paddingBottom: theme.spacing.unit
+        paddingBottom: theme.spacing.unit,
+        height: '100%'
     },
     cardSubheader: {
         paddingTop: 0,
@@ -44,18 +48,24 @@ const styles: StyleRulesCallback<CssRules> = 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<CssRules>) =>
+        ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes,
+            isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
             <Card data-cy='collection-files-panel' className={classes.root}>
                 <CardHeader
                     title="Files"
+                    className={classes.cardSubheader}
                     classes={{ action: classes.button }}
-                    action={
-                        isWritable &&
+                    action={<>
+                        {isWritable &&
                         <Button
                             data-cy='upload-button'
                             onClick={onUploadDataClick}
@@ -64,26 +74,33 @@ export const CollectionPanelFiles =
                             size='small'>
                             <DownloadIcon className={classes.uploadIcon} />
                             Upload data
-                        </Button>
-                    } />
-                <CardHeader
-                    className={classes.cardSubheader}
-                    action={
+                        </Button>}
+                        {!tooManyFiles &&
                         <Tooltip title="More options" disableFocusListener>
                             <IconButton
                                 data-cy='collection-files-panel-options-btn'
                                 onClick={(ev) => onOptionsMenuOpen(ev, isWritable)}>
                                 <CustomizeTableIcon />
                             </IconButton>
-                        </Tooltip>
-                    } />
-                <Grid container justify="space-between">
-                    <Typography variant="caption" className={classes.nameHeader}>
-                        Name
-                    </Typography>
-                    <Typography variant="caption" className={classes.fileSizeHeader}>
-                        File size
-                    </Typography>
-                </Grid>
-                <FileTree onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)} {...treeProps} />
+                        </Tooltip>}
+                    </>
+                } />
+                { tooManyFiles
+                ? <div className={classes.centeredLabel}>
+                        File listing may take some time, please click to browse: <Button onClick={loadFilesFunc}><DownloadIcon/>Show files</Button>
+                </div>
+                : <>
+                    <Grid container justify="space-between">
+                        <Typography variant="caption" className={classes.nameHeader}>
+                            Name
+                        </Typography>
+                        <Typography variant="caption" className={classes.fileSizeHeader}>
+                            File size
+                        </Typography>
+                    </Grid>
+                    { isLoading
+                    ? <div className={classes.centeredLabel}><CircularProgress /></div>
+                    : <div style={{height: 'calc(100% - 60px)'}}><FileTree onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)} {...treeProps} /></div> }
+                </>
+                }
             </Card>);
index 34a11cd60c78529b472bfe1d269edfa6a117ce19..b5d98c0833b35c9addc9c6f031183e956a2280c4 100644 (file)
@@ -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 (file)
index 0000000..59fe34b
--- /dev/null
@@ -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<CssRules> = (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<T> extends TreeItem<T> {
+    itemCount?: number;
+    level?: number;
+}
+
+// For some reason, on TSX files it isn't accepted just one generic param, so
+// I'm using <T, _> as a workaround.
+export const Row =  <T, _>(itemList: VirtualTreeItem<T>[], render: any, treeProps: TreeProps<T>) => withStyles(styles)(
+    (props: React.PropsWithChildren<ListChildComponentProps> & WithStyles<CssRules>) => {
+        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<T>) =>
+            (event: React.MouseEvent<HTMLElement>) => {
+                treeProps.onContextMenu(event, item);
+            };
+
+        const handleToggleItemOpen = (item: VirtualTreeItem<T>) =>
+            (event: React.MouseEvent<HTMLElement>) => {
+                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) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
+        };
+
+        const handleCheckboxChange = (item: VirtualTreeItem<T>) => {
+            const { toggleItemSelection } = treeProps;
+            return toggleItemSelection
+                ? (event: React.MouseEvent<HTMLElement>) => {
+                    event.stopPropagation();
+                    toggleItemSelection(event, item);
+                }
+                : undefined;
+        };
+
+        return <div style={style}>
+            <ListItem button className={listItem}
+                style={{
+                    paddingLeft: (level + 1) * levelIndentation,
+                    paddingRight: itemRightPadding,
+                }}
+                disableRipple={disableRipple}
+                onClick={event => toggleItemActive(event, it)}
+                selected={showSelection(it) && it.id === currentItemUuid}
+                onContextMenu={handleRowContextMenu(it)}>
+                {it.status === TreeItemStatus.PENDING ?
+                    <CircularProgress size={10} className={loader} /> : null}
+                <i onClick={handleToggleItemOpen(it)}
+                    className={toggableIconContainer}>
+                    <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}>
+                        {getProperArrowAnimation(it.status, it.itemCount!)}
+                    </ListItemIcon>
+                </i>
+                {showSelection(it) && !useRadioButtons &&
+                    <Checkbox
+                        checked={it.selected}
+                        className={classes.checkbox}
+                        color="primary"
+                        onClick={handleCheckboxChange(it)} />}
+                {showSelection(it) && useRadioButtons &&
+                    <Radio
+                        checked={it.selected}
+                        className={classes.checkbox}
+                        color="primary" />}
+                <div className={renderContainer}>
+                    {render(it, level)}
+                </div>
+            </ListItem>
+        </div>;
+    });
+
+const itemSize = 30;
+
+export const VirtualList = <T, _>(height: number, width: number, items: VirtualTreeItem<T>[], render: any, treeProps: TreeProps<T>) =>
+    <FixedSizeList
+        height={height}
+        itemCount={items.length}
+        itemSize={itemSize}
+        width={width}
+    >
+        {Row(items, render, treeProps)}
+    </FixedSizeList>;
+
+export const VirtualTree = withStyles(styles)(
+    class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
+        render(): ReactElement<any> {
+            const { items, render } = this.props;
+
+            return <AutoSizer>
+                {({ height, width }) => {
+                    return VirtualList(height, width, items || [], render, this.props);
+                }}
+            </AutoSizer>;
+        }
+    }
+);
index 1a58dad16e7540ddf47f5b497281e847c3bc801b..d07d3c9e025d870077445af3e79a0078c589a763 100644 (file)
@@ -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})
+                        );
+                    }
                 }
             }
         });
index 97afcac6ca70d4f5d7fc0f8f005e980b3597d7eb..3951d272ee8f3d046c96036be440fef8187f9e91 100644 (file)
@@ -66,7 +66,6 @@ export const createCollectionFilesTree = (data: Array<CollectionDirectory | Coll
             selected: false,
             expanded: false,
             status: TreeNodeStatus.INITIAL
-
         })(tree), createTree<CollectionDirectory | CollectionFile>());
 };
 
index d8cdd4a00503ef2e86b9bd846193cd8750f525b3..371278e52333bbfff0a8e81bc817c23645d5153b 100644 (file)
@@ -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);
index de2f7b71a1b6861ccd6af42e262f652d8e7d67b0..c7713cbcf08fc996429ff0905e601f14fd6a4ec8 100644 (file)
@@ -43,12 +43,13 @@ export const appendSubtree = <T>(id: string, subtree: Tree<T>) => (tree: Tree<T>
     )(subtree) as Tree<T>;
 
 export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
-    return pipe(
-        (tree: Tree<T>) => 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) => <T>(tree: Tree<T>) => {
@@ -156,7 +157,6 @@ export const toggleNodeSelection = (id: string) => <T>(tree: Tree<T>) => {
             toggleAncestorsSelection(id),
             toggleDescendantsSelection(id))(tree)
         : tree;
-
 };
 
 export const selectNode = (id: string) => <T>(tree: Tree<T>) => {
@@ -235,23 +235,3 @@ const getRootNodeChildrenIds = <T>(tree: Tree<T>) =>
     Object
         .keys(tree)
         .filter(id => getNode(id)(tree)!.parent === TREE_ROOT_ID);
-
-
-const addChild = (parentId: string, childId: string) => <T>(tree: Tree<T>): Tree<T> => {
-    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;
-};
index 191fe11b0c05ad58fc3a3dfb2c0265b0e4372a20..452589f6fcb6429a07f173189e555b4222f51cff 100644 (file)
@@ -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 (file)
index f8e7de9..0000000
+++ /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 (file)
index 698a6bb..0000000
+++ /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 (file)
index 6e64f83..0000000
+++ /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<CollectionFile>());
-
-
-export const mapCollectionFileToTreeNode = (file: CollectionFile): TreeNode<CollectionFile> => ({
-    children: [],
-    id: file.id,
-    parent: file.path,
-    value: file,
-    active: false,
-    selected: false,
-    expanded: false,
-    status: TreeNodeStatus.INITIAL,
-});
-
-export const manifestToCollectionFiles = (manifest: KeepManifest): Array<CollectionDirectory | CollectionFile> => ([
-    ...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 (file)
index 09525d8..0000000
+++ /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 (file)
index d564f33..0000000
+++ /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}`;
index 2e726d0bc8e0798fbae17df24f412f920d54906a..5e6f7b83f0ecf7628546ce5101dd75c7e52f6629 100644 (file)
@@ -50,7 +50,6 @@ export const extractFilesData = (document: Document) => {
             return getTagValue(element, 'D:resourcetype', '')
                 ? createCollectionDirectory(data)
                 : createCollectionFile({ ...data, size });
-
         });
 };
 
index 77f5bf3bc605b76a6a37053e9ff1b540925319a4..90441a645f49d92d622fb0dd689fdaee9604bc51 100644 (file)
@@ -68,7 +68,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         const splittedApiToken = apiToken ? apiToken.split('/') : [];
         const userApiToken = `/t=${splittedApiToken[2]}/`;
         const splittedPrevFileUrl = file.url.split('/');
-        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl[2]}`;
+        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
         return {
             ...file,
             url
index af547deccfd81c8f8a00af0a3d2a35cbd0c81b82..41dc831e8cad2b9ce3a30e253ceab2079015718f 100644 (file)
@@ -12,7 +12,6 @@ import { LinkService } from "./link-service/link-service";
 import { FavoriteService } from "./favorite-service/favorite-service";
 import { CollectionService } from "./collection-service/collection-service";
 import { TagService } from "./tag-service/tag-service";
-import { CollectionFilesService } from "./collection-files-service/collection-files-service";
 import { KeepService } from "./keep-service/keep-service";
 import { WebDAV } from "~/common/webdav";
 import { Config } from "~/common/config";
@@ -81,7 +80,6 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
     const ancestorsService = new AncestorService(groupsService, userService);
     const authService = new AuthService(apiClient, config.rootUrl, actions);
     const collectionService = new CollectionService(apiClient, webdavClient, authService, actions);
-    const collectionFilesService = new CollectionFilesService(collectionService);
     const favoriteService = new FavoriteService(linkService, groupsService);
     const tagService = new TagService(linkService);
     const searchService = new SearchService();
@@ -94,7 +92,6 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
         apiClientAuthorizationService,
         authService,
         authorizedKeysService,
-        collectionFilesService,
         collectionService,
         containerRequestService,
         containerService,
index 9922d8b58ab9768b925aca4f6e17e19f7474244b..13943665dfe54457168a67b72ed8b9a77acc6efb 100644 (file)
@@ -3,10 +3,8 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { loadCollectionFiles } from "./collection-panel-files/collection-panel-files-actions";
+import { loadCollectionFiles, COLLECTION_PANEL_LOAD_FILES_THRESHOLD } from "./collection-panel-files/collection-panel-files-actions";
 import { CollectionResource } from '~/models/collection';
-import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions";
-import { createTree } from "~/models/tree";
 import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { TagProperty } from "~/models/tag";
@@ -21,7 +19,8 @@ import { addProperty, deleteProperty } from "~/lib/resource-properties";
 export const collectionPanelActions = unionize({
     SET_COLLECTION: ofType<CollectionResource>(),
     LOAD_COLLECTION: ofType<{ uuid: string }>(),
-    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>()
+    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>(),
+    LOAD_BIG_COLLECTIONS: ofType<boolean>(),
 });
 
 export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
@@ -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<any>(loadCollectionFiles(collection.uuid));
+        if (collection.fileCount <= COLLECTION_PANEL_LOAD_FILES_THRESHOLD &&
+            !getState().collectionPanel.loadBigCollections) {
+            dispatch<any>(loadCollectionFiles(collection.uuid));
+        }
         return collection;
     };
 
index 9d3ae86165ed5d786b00a7e12aae3319d9f4d288..204d4c0e1dbe8f4da27ba74313f560a9a33909fb 100644 (file)
@@ -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<CollectionFilesTree>(),
@@ -25,8 +26,12 @@ export const collectionPanelFilesAction = unionize({
 
 export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
 
+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[]) =>
index 57961538708c900b56631e47e33639a90d66a559..08a717596f62df17779a590ebe4c7bb56d75327d 100644 (file)
@@ -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;
index f09b019873e98e09b082a638a3f12a5b0eea93b2..18590181fb0e79fe150226a66baa9ee51a1f9e64 100644 (file)
@@ -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}),
     });
index fda6ec71183b8f2b8970269b0fa59f88ad96a030..d663ae37167142a2a075e2244412d9e8fd66a396 100644 (file)
@@ -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 (file)
index 0000000..2cb397c
--- /dev/null
@@ -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
index e2ff01f7afa71c6df8e6434ce03d08a830f5c0fb..944c48cf8cc6006ee412182c952f765f05e1004d 100644 (file)
@@ -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<any>, 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 => {
index eb16eb6c406d316e5dc419d02efa4e58233d96d1..7997000350c2639330c91eb82dcda78eda2831e3 100644 (file)
@@ -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<CollectionPanelFilesProps, "items" | "currentItemUuid"> => {
         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<CollectionPanelFilesProps,
     },
 });
 
-
 export const CollectionPanelFiles = connect(memoizedMapStateToProps(), mapDispatchToProps)(Component);
 
-const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | CollectionPanelFile>) =>
-    (id: string): TreeItem<FileTreeData> => {
+const collectionItemToList = (level: number) => (tree: Tree<CollectionPanelDirectory | CollectionPanelFile>) =>
+    (id: string): TreeItem<FileTreeData>[] => {
         const node: TreeNode<CollectionPanelDirectory | CollectionPanelFile> = getNode(id)(tree) || initTreeNode({
             id: '',
             parent: '',
@@ -88,7 +89,8 @@ const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | Collecti
                 collapsed: true
             }
         });
-        return {
+
+        const treeItem = {
             active: false,
             data: {
                 name: node.value.name,
@@ -97,10 +99,20 @@ const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | Collecti
                 url: node.value.url,
             },
             id: node.id,
-            items: getNodeChildrenIds(node.id)(tree)
-                .map(collectionItemToTreeItem(tree)),
+            items: [], // Not used in this case as we're converting a tree to a list.
+            itemCount: node.children.length,
             open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
             selected: node.value.selected,
-            status: TreeItemStatus.LOADED
+            status: TreeItemStatus.LOADED,
+            level,
         };
+
+        const treeItemChilds = treeItem.open
+            ? [].concat.apply([], node.children.map(collectionItemToList(level+1)(tree)))
+            : [];
+
+        return [
+            treeItem,
+            ...treeItemChilds,
+        ];
     };
index 4c6874c6ae296411710a9c864459e369773ec968..2ded3736b2130b2709667018ddb51d17ff2c7d75 100644 (file)
@@ -3,9 +3,9 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { RenameIcon, RemoveIcon } from "~/components/icon/icon";
+import { RemoveIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
+import { openFileRemoveDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 import { CollectionFileViewerAction } from '~/views-components/context-menu/actions/collection-file-viewer-action';
 
 
@@ -21,13 +21,14 @@ export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
 ]];
 
 export const collectionFilesItemActionSet: ContextMenuActionSet = readOnlyCollectionFilesItemActionSet.concat([[
-    {
-        name: "Rename",
-        icon: RenameIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(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<any>(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 (file)
index 0000000..2410ddc
--- /dev/null
@@ -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<CssRules> = (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<CssRules>;
+
+export const NotFoundDialog = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+    withDialog(NOT_FOUND_DIALOG_NAME)(
+        ({ open, closeDialog }: NotFoundDialogProps) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='md'
+                disableBackdropClick
+                disableEscapeKeyDown>
+                <DialogContent>
+                    <NotFoundPanel notWrapped />
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='text'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    )
+));
\ No newline at end of file
index 8e27d445d1a70faa090900975ea8926659dceaea..07b1ad816a6d0475469e5438e08b7351b777282f 100644 (file)
@@ -33,7 +33,7 @@ export interface ProjectsTreePickerDataProps {
     showSelection?: boolean;
     relatedTreePickers?: string[];
     disableActivation?: string[];
-    loadRootItem: (item: TreeItem<ProjectsTreePickerRootItem>, pickerId: string, includeCollections?: boolean, inlcudeFiles?: boolean) => void;
+    loadRootItem: (item: TreeItem<ProjectsTreePickerRootItem>, pickerId: string, includeCollections?: boolean, includeFiles?: boolean) => void;
 }
 
 export type ProjectsTreePickerProps = ProjectsTreePickerDataProps & Partial<PickedTreePickerProps>;
index 3662538774ea9d3116c42bcd878f8483d41b57f1..953e5b4c7d281ea6f817fba5b7d98ae545150592 100644 (file)
@@ -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<CssRules> = (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<CssRules> = (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<CollectionPanelProps> {
             render() {
-                const { classes, item, dispatch, isWritable } = this.props;
+                const { classes, item, dispatch, isWritable, isLoadingFiles, tooManyFiles } = this.props;
                 return item
-                    ? <>
-                        <Card data-cy='collection-info-panel' className={classes.card}>
-                            <CardHeader
-                                avatar={
+                    ? <div className={classes.root}>
+                        <ExpansionPanel data-cy='collection-info-panel' defaultExpanded>
+                            <ExpansionPanelSummary expandIcon={<ExpandIcon />}>
+                                <span>
                                     <IconButton onClick={this.openCollectionDetails}>
                                         <CollectionIcon className={classes.iconHeader} />
                                     </IconButton>
-                                }
-                                action={
-                                    <Tooltip title="More options" disableFocusListener>
-                                        <IconButton
-                                            data-cy='collection-panel-options-btn'
-                                            aria-label="More options"
-                                            onClick={this.handleContextMenu}>
-                                            <MoreOptionsIcon />
-                                        </IconButton>
+                                    <IllegalNamingWarning name={item.name}/>
+                                    {item.name}
+                                    {isWritable ||
+                                    <Tooltip title="Read-only">
+                                        <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
                                     </Tooltip>
-                                }
-                                title={
-                                    <span>
-                                        <IllegalNamingWarning name={item.name}/>
-                                        {item.name}
-                                        {isWritable ||
-                                        <Tooltip title="Read-only">
-                                            <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
-                                        </Tooltip>
-                                        }
-                                    </span>
-                                }
-                                titleTypographyProps={this.titleProps}
-                                subheader={item.description}
-                                subheaderTypographyProps={this.titleProps} />
-                            <CardContent>
-                                <Grid container direction="column">
-                                    <Grid item xs={10}>
+                                    }
+                                </span>
+                            </ExpansionPanelSummary>
+                            <ExpansionPanelDetails>
+                                <Grid container justify="space-between">
+                                    <Grid item xs={11}>
+                                        <Typography variant="caption">
+                                            {item.description}
+                                        </Typography>
                                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
                                             label='Collection UUID'
                                             linkToUuid={item.uuid} />
@@ -147,14 +156,26 @@ export const CollectionPanel = withStyles(styles)(
                                             </span>
                                         }
                                     </Grid>
+                                    <Grid item xs={1} style={{textAlign: "right"}}>
+                                        <Tooltip title="More options" disableFocusListener>
+                                            <IconButton
+                                                data-cy='collection-panel-options-btn'
+                                                aria-label="More options"
+                                                onClick={this.handleContextMenu}>
+                                                <MoreOptionsIcon />
+                                            </IconButton>
+                                        </Tooltip>
+                                    </Grid>
                                 </Grid>
-                            </CardContent>
-                        </Card>
+                            </ExpansionPanelDetails>
+                        </ExpansionPanel>
 
-                        <Card data-cy='collection-properties-panel' className={classes.card}>
-                            <CardHeader title="Properties" />
-                            <CardContent>
-                                <Grid container direction="column">
+                        <ExpansionPanel data-cy='collection-properties-panel' defaultExpanded>
+                            <ExpansionPanelSummary expandIcon={<ExpandIcon />}>
+                                {"Properties"}
+                            </ExpansionPanelSummary>
+                            <ExpansionPanelDetails>
+                                <Grid container>
                                     {isWritable && <Grid item xs={12}>
                                         <CollectionTagForm />
                                     </Grid>}
@@ -180,12 +201,20 @@ export const CollectionPanel = withStyles(styles)(
                                     }
                                     </Grid>
                                 </Grid>
-                            </CardContent>
-                        </Card>
-                        <div className={classes.card}>
-                            <CollectionPanelFiles isWritable={isWritable} />
+                            </ExpansionPanelDetails>
+                        </ExpansionPanel>
+                        <div className={classes.filesCard}>
+                            <CollectionPanelFiles
+                                isWritable={isWritable}
+                                isLoading={isLoadingFiles}
+                                tooManyFiles={tooManyFiles}
+                                loadFilesFunc={() => {
+                                    dispatch(collectionPanelActions.LOAD_BIG_COLLECTIONS(true));
+                                    dispatch<any>(loadCollectionFiles(this.props.item.uuid));
+                                }
+                            } />
                         </div>
-                    </>
+                    </div>
                     : 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 (file)
index 0000000..315b4b8
--- /dev/null
@@ -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<CssRules>;
+
+    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(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // 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(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // 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(
+            <MuiThemeProvider theme={CustomTheme}>
+                <NotFoundPanelRoot {...props} />
+            </MuiThemeProvider>
+            );
+
+        // 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 (file)
index 0000000..6780b84
--- /dev/null
@@ -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<CssRules> = (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<any> | null;
+    clusterConfig: ClusterConfigJSON;
+}
+
+type NotFoundPanelRootProps = NotFoundPanelRootDataProps & NotFoundPanelOwnProps & WithStyles<CssRules>;
+
+const getAdditionalMessage = (location: Location | null) => {
+    if (!location) {
+        return null;
+    }
+
+    const { pathname } = location;
+
+    if (pathname.indexOf('collections') > -1) {
+        const uuidHash = pathname.replace('/collections/', '');
+
+        return (
+            <p>
+                Please make sure that provided UUID/ObjectHash '{uuidHash}' is valid.
+            </p>
+        );
+    }
+
+    return null;
+};
+
+const getEmailLink = (email: string, classes: Record<CssRules, string>) => {
+    const { location: { href: windowHref } } = window;
+    const href = `mailto:${email}?body=${encodeURIComponent('Problem while viewing page ')}${encodeURIComponent(windowHref)}&subject=${encodeURIComponent('Workbench problem report')}`;
+
+    return (<a
+        className={classes.active}
+        href={href}>
+        email us
+    </a>);
+};
+
+
+export const NotFoundPanelRoot = withStyles(styles)(
+    ({ classes, clusterConfig, location, notWrapped }: NotFoundPanelRootProps) => {
+
+        const content = <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+            <div data-cy="not-found-content" className={classes.title}>
+                <h2>Not Found</h2>
+                {getAdditionalMessage(location)}
+                <p>
+                    The page you requested was not found,&nbsp;
+                    {
+                        !!clusterConfig.Mail && clusterConfig.Mail.SupportEmailAddress ?
+                            getEmailLink(clusterConfig.Mail.SupportEmailAddress, classes) :
+                            'email us'
+                    }
+                    &nbsp;if you suspect this is a bug.
+                </p>
+            </div>
+        </Grid>;
+
+        return !notWrapped ? <Paper data-cy="not-found-page"> {content}</Paper> : 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 (file)
index 0000000..0f9f13b
--- /dev/null
@@ -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<NotFoundPanelRootDataProps, null, NotFoundPanelOwnProps>(mapStateToProps, mapDispatchToProps)
+    (NotFoundPanelRoot);
index 31c2a026356d060316b6b02f9e09313e9ec853bd..906c649c293404371fb68fc9994f454bd488c31e 100644 (file)
@@ -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 =
                                 <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
                                 <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
                                 <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
+                                <Route path={Routes.NO_MATCH} component={NotFoundPanel} />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -245,6 +248,7 @@ export const WorkbenchPanel =
             <RichTextEditorDialog />
             <SetupShellAccountDialog />
             <SharingDialog />
+            <NotFoundDialog />
             <Snackbar />
             <UpdateCollectionDialog />
             <UpdateProcessDialog />
index d6677a747c1de4572da8fd6aadf585a79bf9c365..742db46569dd5fa57f5db3662f79b8d6d08df7b1 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   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"