[ARVADOS-WORKBENCH2] updated: 1.4.1-388-g011705a4
Git user
git at public.arvados.org
Mon Jul 13 19:09:15 UTC 2020
Summary of changes:
cypress/integration/page-not-found.spec.js | 47 +++++++++++
src/common/config.ts | 3 +
src/index.tsx | 20 +++--
src/models/resource.ts | 3 +-
src/routes/routes.ts | 5 +-
src/store/navigation/navigation-action.ts | 3 +-
.../not-found-panel/not-found-panel-action.tsx | 16 ++++
.../not-found-dialog/not-found-dialog.tsx | 65 +++++++++++++++
.../not-found-panel/not-found-panel-root.test.tsx | 87 ++++++++++++++++++++
src/views/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 +
12 files changed, 356 insertions(+), 11 deletions(-)
create mode 100644 cypress/integration/page-not-found.spec.js
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
via 011705a4f293a0f5737c021b1ad2c5c87cd0cb03 (commit)
via cf83b358f087a87b5ff095d3ed7a8c6920c60ffe (commit)
from f8fb6725beec5b2bf0eec7f375d49f9189ff1bc3 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
commit 011705a4f293a0f5737c021b1ad2c5c87cd0cb03
Merge: f8fb6725 cf83b358
Author: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
Date: Mon Jul 13 21:08:00 2020 +0200
Merge branch '14990-attempting-to-navigate-to-non-existent-path-not-handled'
Closes #14990
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
commit cf83b358f087a87b5ff095d3ed7a8c6920c60ffe
Author: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
Date: Wed Jun 24 22:36:52 2020 +0200
14990: added 404 page with wildcard route
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
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 <ward at curoverse.com>
+MAINTAINER MAINTAINER Arvados Package Maintainers <packaging at 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 && \
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<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>);
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<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>;
+ }
+ }
+);
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<CollectionDirectory | Coll
selected: false,
expanded: false,
status: TreeNodeStatus.INITIAL
-
})(tree), createTree<CollectionDirectory | CollectionFile>());
};
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 = <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;
-};
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<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
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 at _]*)*$/;
-
-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<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
diff --git a/src/services/services.ts b/src/services/services.ts
index af547dec..41dc831e 100644
--- a/src/services/services.ts
+++ b/src/services/services.ts
@@ -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,
diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts
index 9922d8b5..13943665 100644
--- a/src/store/collection-panel/collection-panel-action.ts
+++ b/src/store/collection-panel/collection-panel-action.ts
@@ -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;
};
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<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[]) =>
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<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 => {
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<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,
+ ];
};
diff --git a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
index 4c6874c6..2ded3736 100644
--- a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
+++ b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
@@ -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
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<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
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<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>;
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<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
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<CssRules>;
+
+ beforeEach(() => {
+ props = {
+ classes: {
+ root: 'root',
+ title: 'title',
+ active: 'active',
+ },
+ clusterConfig: {
+ Mail: {
+ SupportEmailAddress: 'support at 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
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<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,
+ {
+ !!clusterConfig.Mail && clusterConfig.Mail.SupportEmailAddress ?
+ getEmailLink(clusterConfig.Mail.SupportEmailAddress, classes) :
+ 'email us'
+ }
+ 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
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<NotFoundPanelRootDataProps, null, NotFoundPanelOwnProps>(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 =
<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 />
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 at 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 at 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 at 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 at 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 at 16.8.6:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list